thenullpage.com
값을 하나만 담는 변수로는 부족한 순간이 금방 온다. 학생 서른 명의 점수, 장바구니 상품 목록, 상품 이름으로 재고를 찾는 표 같은 것들이다. 자바는 여러 값을 한 덩어리로 묶는 수단을 두 갈래로 준비해 뒀다. 칸 수가 처음에 정해지고 바뀌지 않는 배열, 그리고 값을 넣고 빼는 대로 크기가 알아서 늘고 주는 컬렉션이다.
배열은 칸 수를 먼저 정한다. 배열(array)은 같은 타입의 값을 정해진 개수만큼 나란히 담는 그릇이다. new 뒤에 타입과 칸 수를 적어 만든다.
int[] scores = new int[3];
scores[0] = 90;
scores[1] = 85;
System.out.println(scores[2]); // 0
System.out.println(scores.length); // 3
new int[3]은 칸 세 개를 만들면서 정수 배열이므로 각 칸을 0으로 채워 둔다. 아무 값도 넣지 않은 scores[2]가 0으로 찍히는 이유다. String[]처럼 참조 타입 배열이라면 각 칸의 초기값은 null이다. 칸 수를 꺼내는 length에는 괄호가 없다. 문자열 길이를 재는 length()가 메서드라 괄호가 붙는 것과 다르다. 담을 값을 이미 알고 있다면 중괄호로 한 번에 만들 수도 있다.
String[] names = {"김민수", "이서연", "박지훈"};
for (String n : names) {
System.out.println(n); // 세 이름이 한 줄씩 출력
}
인덱스는 0부터 시작한다. 첫 칸의 번호가 1이 아니라 0이다. 칸이 세 개면 번호는 0, 1, 2까지고 3은 없는 칸이다.
int[] nums = {10, 20, 30};
System.out.println(nums[2]); // 30
System.out.println(nums[3]);
// 실행 중 예외: ArrayIndexOutOfBoundsException (Index 3, length 3)
없는 칸을 건드리는 코드는 컴파일은 통과하고 실행 중에 터진다. 반복문 조건을 i < nums.length가 아니라 i <= nums.length로 쓰면 마지막 한 바퀴가 없는 칸을 짚어 그대로 이 예외를 만난다. 배열을 통째로 출력하면 [I@1b6d3586 같은 값이 나오니, 안에 든 값은 Arrays.toString(nums)로 확인한다.
배열의 한계와 컬렉션. 배열은 만들 때 정한 칸 수를 못 바꾼다. 네 번째 값을 추가하려면 더 큰 배열을 새로 만들어 옮겨 담아야 하고, 중간의 한 칸만 빼내는 기능도 없다. 그래서 개수가 실행 중에 변하는 데이터는 컬렉션(collection)을 쓴다. 컬렉션은 java.util 패키지에 있고 쓰임새에 따라 세 갈래다. 순서대로 줄 세우는 List, 중복을 허용하지 않는 Set, 키로 값을 찾는 Map이다.
List는 순서가 있는 목록이다. 가장 많이 쓰는 구현이 ArrayList다.
import java.util.ArrayList;
import java.util.List;
List<String> cart = new ArrayList<>();
cart.add("사과");
cart.add("우유");
cart.add("빵");
System.out.println(cart.size()); // 3
System.out.println(cart.get(1)); // 우유
cart.remove("우유");
System.out.println(cart); // [사과, 빵]
컬렉션 클래스는 파일 맨 위에 import 문이 있어야 한다. 꺾쇠 안의 String은 이 목록이 문자열만 담는다는 표시라, 엉뚱한 타입을 넣는 코드는 컴파일 단계에서 걸린다. 왼쪽 타입을 List로 적는 건 나중에 다른 구현으로 바꿀 때 오른쪽 한 곳만 고치면 되기 때문이다. 크기를 재는 size()는 배열의 length와 달리 괄호가 붙는다.
ArrayList와 LinkedList. 둘 다 List라서 쓰는 코드는 같지만 내부 구조가 다르다. ArrayList는 안쪽에 배열을 두고 값을 채우다가 자리가 모자라면 더 큰 배열로 옮긴다. 번호로 값을 꺼내는 일은 즉시 끝나지만, 중간에 값을 끼워 넣으면 뒤쪽 원소를 전부 한 칸씩 밀어야 한다. LinkedList는 값 하나하나를 마디로 만들고 앞뒤 마디를 연결해 사슬처럼 잇는다. 중간 삽입은 연결만 바꾸면 되지만, 세 번째 값을 꺼내려면 첫 마디부터 따라가야 한다. 선언 한 줄만 new LinkedList<>()로 바꾸면 나머지 코드는 그대로 돈다. 다만 번호로 꺼내 쓰는 일이 훨씬 흔해 기본 선택은 대개 ArrayList다.
Set은 중복을 걸러낸다. 같은 값을 여러 번 넣어도 하나만 남는다.
Set<String> tags = new HashSet<>();
tags.add("java");
tags.add("backend");
tags.add("java");
System.out.println(tags.size()); // 2
System.out.println(tags.contains("java")); // true
세 번 넣었는데 크기가 2다. 중복 검사 코드를 따로 짤 필요가 없다는 뜻이다. 대신 HashSet은 넣은 순서를 지켜 주지 않아 꺼내지는 순서가 다를 수 있다. 순서까지 필요하면 LinkedHashSet, 정렬 상태로 두려면 TreeSet으로 클래스 이름만 바꾸면 된다.
Map은 키로 값을 찾는다. 상품 이름으로 재고 수량을 찾는 것처럼 두 값을 짝지어 저장할 때 쓴다.
Map<String, Integer> stock = new HashMap<>();
stock.put("사과", 5);
stock.put("우유", 2);
System.out.println(stock.get("사과")); // 5
System.out.println(stock.get("포도")); // null
System.out.println(stock.getOrDefault("포도", 0)); // 0
꺾쇠 안이 두 개인 이유는 키 타입과 값 타입을 따로 적기 때문이다. 없는 키를 get으로 찾으면 예외가 아니라 null이 나온다. 이 null을 int 변수에 담으면 NullPointerException이 터지니, 기본값이 있다면 getOrDefault가 안전하다. 같은 키에 put을 다시 하면 기존 값을 조용히 덮어쓴다. 전체를 훑을 때는 entrySet을 쓴다.
for (Map.Entry<String, Integer> e : stock.entrySet()) {
System.out.println(e.getKey() + ": " + e.getValue());
}
// 출력:
// 사과: 5
// 우유: 2
순회하면서 지우면 터진다. 목록을 돌면서 조건에 맞는 값을 빼는 코드는 자연스러워 보이지만 예외가 난다.
List<String> items = new ArrayList<>(List.of("a", "bb", "c"));
for (String s : items) {
if (s.length() == 1) items.remove(s);
}
// 실행 중 예외: ConcurrentModificationException
순회를 맡은 쪽은 도는 도중 목록의 구조가 바뀌면 어디까지 갔는지 보장할 수 없어 예외를 던진다. 값을 걸러 내는 게 목적이라면 removeIf 한 줄로 끝난다.
items.removeIf(s -> s.length() == 1);
System.out.println(items); // [bb]
s -> s.length() == 1은 값 하나를 받아 참인지 거짓인지 돌려주는 짧은 함수이고, removeIf는 참이 나온 값만 안전하게 걸러 낸다. 위에서 List.of를 new ArrayList로 감싼 데도 이유가 있다. List.of가 만든 목록은 읽기 전용이라 add를 부르면 UnsupportedOperationException이 난다.
이번 편에서 손에 쥐어야 할 감각은 세 가지다. 개수가 고정이면 배열이고 실행 중에 늘고 주는 데이터면 컬렉션이라는 것, 줄 세우기는 List, 중복 제거는 Set, 짝지어 찾기는 Map이라는 것, 그리고 목록을 도는 중에 그 목록을 고치면 예외가 난다는 것. 짧은 코드를 직접 쳐서 출력을 확인해 보면 훨씬 빨리 는다.