실제 프로그램은 값 하나가 아니라 값의 묶음을 다룬다. 사용자 목록, 주문 내역, 단어 빈도처럼 데이터는 묶음으로 온다. 값을 묶는 가장 기본은 배열이고, 그 한계를 넘어서는 도구가 컬렉션이다. 이번 편은 고정 크기 배열에서 시작해 크기가 자유로운 List, 중복을 걸러 주는 Set, 키로 값을 찾는 Map까지 훑는다.
배열은 같은 타입의 값을 정해진 개수만큼 담는다. 한 번 정한 크기는 바꿀 수 없다. int[] scores = new int[5];는 정수 다섯 칸을 만들고 각 칸은 기본값 0이다. 값을 알면 {90, 80, 70}처럼 바로 채운다. 각 칸은 0번부터 세는 인덱스로 접근해, 다섯 칸이면 마지막이 scores[4]다. 이 0부터 세는 규칙을 초보자가 자주 헷갈린다.
int[] scores = {90, 80, 70};
System.out.println(scores[0]); // 90
System.out.println(scores.length); // 3 (괄호 없는 필드)
for (int s : scores) System.out.println(s);
범위를 벗어난 인덱스는 예외로 막힌다. 크기 3 배열에서 scores[3]에 접근하면 ArrayIndexOutOfBoundsException(배열 범위 초과 예외)이 터진다. C처럼 엉뚱한 메모리를 조용히 읽는 게 아니라 즉시 알려 주니 안전하다. 순회 표준형은 for (int i = 0; i < arr.length; i++)로, 조건이 <=가 아니라 <다. 참고로 배열 크기는 괄호 없는 length 필드지만, 문자열은 length(), 컬렉션은 size() 메서드라 셋이 달라 자주 틀린다.
배열 작업은 Arrays 유틸리티가 돕는다. 배열 자체는 기능이 빈약해 java.util.Arrays가 보충한다. Arrays.sort는 정렬, Arrays.copyOf는 복사다. 특히 배열을 그냥 출력하면 내용이 아니라 주소 문자열이 나오니, 내용을 보려면 Arrays.toString(a)을 거쳐야 한다.
배열의 진짜 한계는 크기 고정이다. 원소가 몇 개 올지 미리 모르는 상황이 훨씬 많다. 배열은 꽉 차면 더 큰 배열로 옮겨 담아야 하는데, 이를 대신하며 크기가 자유롭게 늘고 줄고 추가, 삭제, 검색이 풍부한 게 컬렉션이다. 실무의 데이터 묶음은 거의 컬렉션으로 다룬다.
![[Java 04] 배열과 컬렉션 기초](https://img.thenullpage.com/posts/6448/6448_1_38a8db.webp)
List는 순서가 있는 목록이다. 가장 많이 쓰는 컬렉션으로, 들어온 순서를 유지하고 중복을 허용하며 인덱스로 접근한다. List<String>의 꺾쇠 안은 담을 타입을 못 박는 제네릭이라, 엉뚱한 타입을 넣는 실수가 컴파일에서 막힌다. 구현체는 보통 ArrayList다.
List<String> names = new ArrayList<>();
names.add("kim");
names.add("kim"); // 중복 허용
System.out.println(names.get(0)); // kim
System.out.println(names.size()); // 2
ArrayList와 LinkedList는 성능 성격이 다르다. ArrayList는 속이 배열이라 인덱스 접근은 즉시지만 중간 삽입, 삭제는 뒤 원소를 밀어야 해 느리다. LinkedList는 사슬 구조라 그 반대다. 현실에선 인덱스 접근이 훨씬 잦고 메모리도 알뜰해, 특별한 이유가 없으면 거의 ArrayList를 쓴다.
Set은 중복을 허용하지 않는다. 같은 값을 두 번 넣어도 하나만 남아, 중복 제거나 이미 봤는지 판별에 쓴다. HashSet은 순서 보장 없이 가장 빠르고, LinkedHashSet은 넣은 순서를, TreeSet은 정렬 상태를 유지한다. 보통 HashSet이 기본이다.
Set<String> tags = new HashSet<>();
tags.add("java");
tags.add("java"); // 무시됨
System.out.println(tags.size()); // 1
Map은 키로 값을 찾는 사전이다. 키와 값을 짝지어 저장하고 키로 값을 즉시 찾는다. 단어별 빈도, 아이디별 사용자처럼 대응 관계를 다루는 거의 모든 곳에 쓴다. put으로 넣고 get으로 꺼내며, 없는 키를 get하면 null이 나오니 기본값이 필요하면 getOrDefault가 편하다.
Map<String, Integer> ages = new HashMap<>();
ages.put("kim", 30);
System.out.println(ages.get("kim")); // 30
System.out.println(ages.getOrDefault("park", 0)); // 0
Map으로 빈도를 세는 관용구는 요긴하다. 각 항목이 몇 번 나왔나 세는 코드는 자주 나온다. merge는 없으면 초기값, 있으면 더하기를 한 줄에 처리한다. computeIfAbsent는 키가 없을 때만 초기값을 만들어 넣어, 값이 리스트인 Map에 특히 편하다.
Map<String, Integer> count = new HashMap<>();
for (String w : words)
count.merge(w, 1, Integer::sum); // 없으면 1, 있으면 +1
Map 순회는 entrySet이 표준이다. 키만 필요하면 keySet, 값만 필요하면 values, 둘 다면 entrySet을 돈다. 키로 다시 get하는 것보다 짝을 한 번에 꺼내는 게 효율적이다. HashMap은 순회 순서가 들쭉날쭉하니, 넣은 순서대로면 LinkedHashMap, 키 정렬 순이면 TreeMap을 쓴다.
for (Map.Entry<String, Integer> e : ages.entrySet())
System.out.println(e.getKey() + " = " + e.getValue());
순회 중에 원소를 지우면 예외가 난다. 향상된 for로 돌면서 그 안에서 remove를 부르면 ConcurrentModificationException(순회 중 변경 예외)이 터진다. 조건에 맞는 원소를 지우려면 removeIf를 쓰거나 반복자의 Iterator.remove로 지운다. 워낙 흔한 함정이라 능숙한 사람도 가끔 밟는다.
list.removeIf(s -> s.isEmpty());
불변 컬렉션은 List.of로 만든다. JDK 9부터 List.of, Set.of, Map.of로 고정된 컬렉션을 한 줄에 만든다. 다만 여기에 add나 put을 하면 읽기 전용이라 예외가 나므로, 수정이 필요하면 new ArrayList<>(List.of(...))로 가변 복사본을 만든다.
직접 만든 클래스를 키로 쓰려면 준비가 필요하다. HashMap과 HashSet은 해시로 원소를 흩어 저장해 빠르게 찾는다. 그래서 우리 클래스를 Set이나 Map 키로 쓰려면 그 클래스가 equals와 hashCode를 갖춰야 하고, 빠뜨리면 넣었는데도 contains가 못 찾는다. String, Integer는 이미 구현돼 있어 그냥 쓴다.
어떤 그릇을 고를지는 하려는 일이 정한다. 순서가 있고 중복을 허용하면 List, 중복을 걸러야 하면 Set, 키로 값을 찾으면 Map이다. 그 안에서 순서가 상관없으면 해시 계열, 넣은 순서를 지키면 Linked 계열, 정렬이 필요하면 Tree 계열이다. 대부분 ArrayList, HashSet, HashMap이 무난한 기본값이고, 특별한 요구가 있을 때만 다른 걸 꺼낸다.
다음 편에서는 클래스와 객체를 다룬다. 지금까지 자바가 만들어 준 그릇을 썼다면, 이제 우리만의 타입을 직접 만들 차례다. 필드와 생성자로 데이터를 묶고, this로 자기 자신을 가리키며, 접근제어자로 안팎을 나누는 캡슐화까지 세운다. 이번 편의 List와 Map에 우리가 만든 객체를 담게 되며 자연스럽게 이어진다.