Java 컬렉션 | ArrayList, HashMap, Set
이 글의 핵심
주문 번호 캐시에 넣은 객체가 분명히 있는데 contains가 false를 돌려주는 문제는 equals와 hashCode를 제대로 정의하지 않았을 때 흔히 생깁니다. 그런 실무 사례에서 출발해 List·Set·Map 구현체를 고르는 기준과 Collections 유틸을 과하게 쓸 때의 문제를 짧은 판단 기준으로 정리합니다.
들어가며
List·Set·Map은 단순한 “자료구조”가 아니라, 팀이 어떤 가정을 코드에 박아 넣었는지 보여 주는 부분입니다. 여러 스레드가 공유하는 캐시에 HashMap을 그대로 쓴 코드, HashSet에 DTO를 넣어 놓고 equals는 정의하지 않은 코드는 레거시 프로젝트에서 흔히 만나는 패턴이고, 둘 다 컴파일도 테스트도 통과한 뒤 운영 환경에서야 문제를 드러냅니다.
엔터프라이즈 사례: “주문 번호 캐시” 문제
주문 API에서 주문 ID → DTO를 HashMap에 넣어 두고 재사용하는 캐시 패턴을 생각해 보겠습니다. 요청 스레드들은 캐시를 읽고, 배치 잡은 주기적으로 캐시를 갱신합니다. 이렇게 여러 스레드가 동시에 쓰는 환경에서 HashMap을 그대로 두면 문제가 생깁니다. HashMap은 동기화가 전혀 없어서 두 스레드가 동시에 put하면 한쪽의 값이 사라질 수 있고, 내부 배열을 늘리는 리사이즈 도중에 다른 스레드가 읽으면 방금 넣은 키를 못 찾기도 합니다. Java 7 이전에는 동시 리사이즈 중에 버킷의 연결 리스트가 순환 구조로 꼬여 get이 무한 루프에 빠지고 CPU가 100%로 치솟는 현상이 잘 알려져 있었습니다. Java 8에서 이 무한 루프는 사라졌지만 데이터 유실은 여전히 가능하므로, 공유 캐시라면 ConcurrentHashMap으로 바꾸는 것이 기본입니다. 값 계산까지 원자적으로 하려면 get 후 put 대신 computeIfAbsent를 써야 두 스레드가 같은 키를 동시에 계산하는 경쟁도 막을 수 있습니다.
팀 회의에서는 “리스트는 무조건 LinkedList가 빠르다” 같은 미신도 가끔 등장했습니다. 대부분의 엔드포인트는 순회 + 랜덤 접근이므로 ArrayList를 기본으로 하고, LinkedList는 큐/덱이 필요한 자료구조에서나 쓰라고 정해 두는 편입니다. (입문 강의에서 LinkedList를 과대평가하는 이유는 이해하지만, 실무에서 체감하는 것과는 거리가 있습니다.)
정리하면, 컬렉션 선택은 “빅오 표”가 아니라 런타임 가정(동시성, 순서, 중복 키, equals/hashCode 계약)에서 출발해야 한다고 봅니다. 이 글에서는 그 가정이 코드로 어떻게 드러나는지를 간단히 짚어 보겠습니다.
List: ArrayList가 디폴트인 이유
ArrayList는 내부가 배열이라 인덱스로 접근하는 것은 보통 O(1)이며, 끝에 추가하는 것도 상각하면 효율적입니다. 중간에 끼워 넣거나 지우면 뒤쪽 원소를 밀어야 하므로 O(n)입니다. 이것을 면접 질문으로만 외우지 말고, “우리가 리스트에 add(0, x)를 자주 하는가?”를 한번 확인해 보십시오. 그렇지 않다면 ArrayList를 쓰면 됩니다.
get은 빠르지만, add(index) / remove에는 요소를 옮기는 비용이 붙습니다. 표로 정리할 수도 있지만, “끝은 편하고, 중간은 비싸다” 정도만 기억해도 대부분의 경우를 커버할 수 있습니다.
import java.util.ArrayList;
import java.util.List;
List<String> fruits = new ArrayList<>();
fruits.add("사과");
fruits.add(1, "딸기");
System.out.println(fruits.get(0));
fruits.remove(0);
LinkedList는 API 설계상 큐/스택이 필요할 때만 꺼냅니다. “삽입이 빠르다”는 점만 보고 쓰면 캐시 친화성이 떨어지고 순회 비용이 생각보다 큽니다. 게다가 LinkedList의 중간 삽입이 O(1)인 것은 이미 그 위치의 노드를 들고 있을 때 이야기이고, add(index, x)처럼 인덱스로 위치를 찾으려면 앞에서부터 노드를 따라가야 하므로 결국 O(n)입니다. 큐나 덱이 필요할 때도 요즘은 배열 기반인 ArrayDeque가 대부분의 경우 더 빠르므로, LinkedList를 꺼낼 일은 생각보다 드뭅니다. 상황에 따라 다르긴 하지만, 웹 백엔드의 일상적인 코드에서는 ArrayList가 기본입니다.
List를 다룰 때 자주 만나는 예외도 두 가지 알아 두면 좋습니다. 첫째, for (String f : fruits) 루프 안에서 fruits.remove(f)를 호출하면 다음 반복에서 ConcurrentModificationException이 납니다. 이름과 달리 멀티스레드와 무관하게 단일 스레드에서도 나는 예외로, 순회 중 구조 변경을 감지하는 장치입니다. 조건에 맞는 원소를 지우려면 fruits.removeIf(f -> ...)나 Iterator.remove()를 씁니다. 둘째, List.of(...)와 Collections.unmodifiableList는 불변 리스트라서 add를 호출하면 UnsupportedOperationException이 나고, Arrays.asList(...)는 크기만 고정이라 set은 되지만 add·remove는 같은 예외를 던집니다. 다른 메서드가 돌려준 리스트를 수정하기 전에는 새 ArrayList로 복사하는 습관이 안전합니다.
Set: HashSet의 동작은 equals/hashCode가 좌우합니다
HashSet의 중복 제거는 해시로 버킷을 찾고, 충돌이 나면 equals로 같은지 확인하는 방식입니다. 엔터프라이즈 환경에서 가장 지저분한 버그 중 하나가 “DTO에 hashCode/equals를 정의하지 않은 채 Set에 넣은 경우”입니다. 실행은 되지만 “같은 주문”이 두 개 들어갈 수 있고, 그것이 나중에 정산이나 재고 쪽에서 문제를 일으킵니다. equals를 재정의하지 않으면 Object의 기본 구현인 참조 비교가 쓰이므로, 필드 값이 똑같아도 new로 따로 만든 두 객체는 다른 원소로 취급됩니다. 요약에서 말한 “분명히 넣었는데 contains가 false” 문제도 같은 원인입니다.
// Java 16+: record는 모든 필드 기반의 equals/hashCode를 자동 생성
record OrderKey(String orderId, int version) {}
Set<OrderKey> seen = new HashSet<>();
seen.add(new OrderKey("A-100", 1));
seen.contains(new OrderKey("A-100", 1)); // true
equals만 재정의하고 hashCode를 빠뜨리는 경우가 오히려 더 까다롭습니다. HashSet은 먼저 hashCode로 버킷을 고르므로, 같은 값의 두 객체가 서로 다른 버킷에 들어가 equals가 호출될 기회조차 없습니다. 그래서 “equals는 true인데 Set에는 둘 다 들어간다”는 이상한 결과가 나옵니다. 또 하나의 함정은 Set에 넣은 뒤 hashCode 계산에 쓰이는 필드를 바꾸는 것입니다. 객체는 옛 해시 값의 버킷에 남아 있으므로 contains와 remove가 그 객체를 찾지 못하고, 결과적으로 지울 수 없는 원소가 쌓이는 메모리 누수가 됩니다. 해시 컬렉션의 키나 원소로 쓰는 객체는 record처럼 불변으로 만드는 것이 가장 확실한 예방책입니다. JPA 엔티티처럼 저장 전에는 ID가 없다가 저장 후에 생기는 객체는 ID 기반 hashCode가 저장 시점에 바뀌므로 특히 주의해야 합니다.
TreeSet은 정렬·비교(Comparable / Comparator)가 필요하므로, 키가 정렬되어 있어야 의미가 있는 도메인에만 쓰십시오. 단순히 “정렬이 필요하다”면 리스트 + 정렬이나 스트림이 더 읽기 쉬운 경우도 많습니다. 저는 “정렬된 Set이 정말 도메인 요구사항인가?”를 먼저 따져 보는 편입니다.
Map: HashMap이 기본, 순서나 정렬이 필요하면 다른 구현체
Map<String, Integer> ages = new HashMap<>();
ages.put("홍길동", 25);
ages.putIfAbsent("박민수", 0);
ages.getOrDefault("없는사람", 0);
for (var e : ages.entrySet()) {
System.out.println(e.getKey() + " -> " + e.getValue());
}
예제의 putIfAbsent와 getOrDefault는 Java 8에서 추가된 메서드로, containsKey로 확인한 뒤 get이나 put을 하는 두 단계 코드를 한 번의 호출로 줄여 줍니다. 여기에 computeIfAbsent, merge까지 익혀 두면 “키가 없으면 리스트를 만들고 추가”나 “개수 세기” 같은 흔한 패턴을 한 줄로 쓸 수 있습니다. 예를 들어 단어 빈도는 counts.merge(word, 1, Integer::sum)으로 셉니다.
운영 환경에서는 null 키/값 허용 여부가 동시성(HashMap vs ConcurrentHashMap) 문제만큼이나 중요합니다. HashMap은 null 키 하나와 null 값을 허용하지만, ConcurrentHashMap은 키와 값 모두 null을 넣으면 NullPointerException을 던지고, TreeMap은 기본 정렬에서 null 키를 허용하지 않습니다. HashMap을 ConcurrentHashMap으로 바꾸는 순간 그동안 조용히 들어가던 null 값 때문에 예외가 나는 경우가 있으므로, 교체할 때는 null이 들어올 수 있는 경로를 먼저 확인해야 합니다. “싱글 스레드 배치”와 “HTTP 요청마다 공유되는 맵”은 완전히 다른 이야기입니다. 저는 공유 맵이라면 락 설계부터, 정확히는 Concurrent 계열을 쓰는 것부터 정하며, “그냥 synchronized를 붙이면 된다”는 최후의 수단으로 둡니다.
HashMap의 순회 순서는 명세상 보장되지 않고, 원소 수가 늘어 내부 배열이 커지면 순서가 바뀔 수도 있습니다. 테스트에서 우연히 삽입 순서대로 나오던 결과에 의존했다가 데이터가 많아진 운영 환경에서 순서가 달라지는 경우가 있으므로, 삽입 순서가 필요하면 LinkedHashMap을 씁니다. LinkedHashMap은 생성자의 accessOrder를 true로 주고 removeEldestEntry를 재정의하면 간단한 LRU 캐시로도 쓸 수 있습니다. TreeMap은 키 순회가 정렬된 순서로 나와야 할 때 씁니다. 하지만 “가끔 정렬이 필요한” 정도라면 HashMap을 쓰고 필요할 때 정렬해도 됩니다. 과한 자료구조가 팀 코드에 남으면 나중에 온보딩 비용으로 돌아옵니다.
Collections 유틸은 편하지만, 과하면 좋지 않은 신호입니다
Collections.sort, shuffle, frequency는 코딩 테스트, 스크립트, 일회성 변환에는 좋습니다. 다만 도메인 로직이 Collections 호출로 가득한 파일은 스트림이나 작은 value object로 쪼개는 편이 읽기 좋다고 봅니다. (취향 70%, 유지보수 30%인 의견입니다.)
토이 예제: 성적 관리 (현실적인 관점에서)
아래와 같은 코드는 학습용으로는 괜찮지만, 실서비스라면 ID 타입을 String 대신 value object로, 동시성, Null 안전성까지 고려해야 해서 코드가 세 배는 길어집니다. 따라서 “이대로 프로덕션에 쓰는 것”은 권하지 않습니다. 구조만 참고하십시오.
import java.util.*;
class Student {
String name;
int score;
Student(String name, int score) { this.name = name; this.score = score; }
}
public class GradeManager {
private final Map<String, Student> students = new HashMap<>();
public void add(String id, String name, int score) {
students.put(id, new Student(name, score));
}
public double average() {
if (students.isEmpty()) return 0;
return students.values().stream()
.mapToInt(s -> s.score)
.average()
.orElse(0);
}
}
짧은 정리 (제 기준)
- 리스트는
ArrayList가 기본입니다.LinkedList는 구조적인 이유가 있을 때만 씁니다. - Set은
hashCode/equals계약이 없으면 제대로 동작하지 않습니다. 특히 DTO와 엔티티가 섞인 레거시 코드에서 그렇습니다. - Map은 공유·동시성 여부를 먼저 정한 뒤
HashMap과Concurrent…중에서 고릅니다. - 다음 글인 Stream에서 이 컬렉션들이 어떻게 파이프라인으로 이어지는지 보시기를 추천합니다. 저는 컬렉션을 이해하지 않은 채 스트림만 쓰는 코드를 그다지 좋아하지 않습니다.
같이 보면 좋은 글
- Java Stream API: filter, map, reduce와 collect 활용
- Java 변수와 타입 | 기본 타입, 참조 타입, 형변환
- Kotlin 컬렉션: List, Set, Map과 컬렉션 함수
- Rust 컬렉션 | Vec, HashMap, HashSet