본문으로 건너뛰기
Previous
Next
Java 컬렉션 | ArrayList, HashMap, Set

Java 컬렉션 | ArrayList, HashMap, Set

Java 컬렉션 | ArrayList, HashMap, Set

이 글의 핵심

Java 컬렉션: ArrayList, HashMap, Set. List 인터페이스·Set 인터페이스.

들어가며

List·Set·Map은 그냥 “자료구조”가 아니라, 팀이 어떤 가정을 코딩해버리는지 보여주는 구간이야. 나는 ArrayList만 쓰다가 ConcurrentHashMap이 필요한 줄 모르는 코드, HashSet에 DTO를 넣어놓고 equals는 안 쓰는 코드, 이런 거 보면 아직도 열받음. (과장 아님, 레거시에 진짜 있었음)


엔터프라이스 한 판: “주문 번호 캐시” 전쟁

예전 팀에선 주문 API가 있었는데, 옛날에는 주문 ID → DTOHashMap에 때려 넣어 두고 재사용하는 패턴이 퍼져 있었어. 배치 잡이 돌면서 캐시를 갱신하는데, 멀티스레드 환경에서 HashMap을 그대로 둔 건… 솔직히 말해 자살이야. 다행히 우리 쪽은 ConcurrentHashMap으로 갈아탔고, “그냥 Map이면 다 되지 않나?”라던 분은 장애 티켓 한 번 보고 말이 짧아졌지.

또 팀 회의에선 “리스트는 무조건 LinkedList가 빠르다” 류의 미신이 가끔 떴어. 대부분의 엔드포인트는 순회 + 랜덤 접근인데, ArrayList가 기본이고 LinkedList는 큐/덱 쓰는 자료구조 쪽이나 쓰라고 못 박는 편이야. (입문 강의에서 LinkedList를 과대평가하는 건, 나 개인적으론 이해는 가는데 실무 체감이랑은 거리가 있음)

정리하면, 컬렉션 선택은 “빅오 표”가 아니라 런타임 가정(동시성, 순서, 중복 키, equals/hashCode 계약)부터 고르는 게 맞다고 본다. 이 글에선 그 가정이 코드로 어떻게 드러나는지만 짧게 잡을게.


List: ArrayList가 디폴트인 이유

ArrayList는 내부가 배열이라 인덱스로 찍는 건 보통 O(1)에 가깝고, 끝에 붙이는 것도 상각하면 괜찮아. 가운데 끼워 넣거나 지우면 뒤를 밀어야 해서 O(n) — 이건 면접질문으로만 외우지 말고, “우리가 리스트에 자주 add(0, x)를 하냐?”를 한번 봐. 안 하면 ArrayList 써.

get은 빠른데, add(index) / remove요소를 옮기는 비용이 붙는다. 표로 뿌릴 수도 있겠지만, 머릿속에 “끝이 편하다, 중간은 아프다” 정도만 있으면 80%는 커버야.

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 설계로 큐/스택이 필요할 때만 꺼낸다. “삽입이 빠르다”만 보고 쓰면, 캐시 친화성 떨어지고 순회 비용이 생각보다 아프다 — 상황 따라 다르긴 하지만, 웹 백엔드 일상 코드에선 기본이 ArrayList다.


Set: HashSetequals/hashCode가 진짜 주인

HashSet의 중복 제거는 해시로 버킷을 찍고, 충돌 나면 equals로 맞는지 본다. 엔터프라이스에서 제일 지저분한 버그 중 하나가 “DTO에 hashCode/equals를 안 썼는데 Set에 넣었다”야. 런은 되는데, “같은 주문”이 두 개 들어갈 수 있고, 그게 나중에 정산/재고 쪽에서 터짐.

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());
}

운영 쪽에선 null 키/값 허용 여부(버전/구현), 동시성 (HashMap vs ConcurrentHashMap)만큼이나 중요해. “싱글 스레드 배치”와 “HTTP 요청마다 공유 맵”은 완전히 다른 이야기야. 나는 공유면 락 설계부터 잡는 편 — 아니 Concurrent 계열 쓰고, “그냥 synchronized 붙이면 됨”은 최후의 수단.

TreeMap은 키 순회가 정렬된 순서로 나가야 할 때. 하지만 “그냥 가끔 정렬”이면 HashMap 쓰고 정렬해도 된다. 과한 자료구조가 팀에 남는 건, 나중에 온보딩 비용으로 돌아온다.


Collections 유틸은 편한데, 과하면 냄새

Collections.sort, shuffle, frequency — 코테·스크립트·일회성 변환엔 좋다. 다만 도메인 로직Collections만 잔뜩 먹는 파일은, 스트림이나 작은value object로 쪼개는 게 읽기 좋다고 본다. (이건 취향 70% + 유지보수 30%)


토이 예제: 성적 — 현실론

아래 류는 학습용으론 OK인데, 실서비스면 ID 타입 String 말고 value object, 동시성, Null 안전까지 엮어야 해서 코드가 3배는 길어진다. 그래서 “이거 그대로 프로덕션”은 금지 — 구조만 보고 가.

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는 구조적 이유가 있을 때.
  • SethashCode/equals 계약이 없으면 망한다 — 특히 DTO/엔티티 섞는 레거시에서.
  • Map은 공유/동시성을 먼저 정하고 HashMap vs Concurrent…를 고른다.
  • 다음: Stream에서 이 컬렉션들이 어떻게 파이프라인으로 갈리는지 보는 걸 추천 — 나는 컬렉션 이해 없이 스트림만 쓰는 코드를 별로 안 좋아함.

관련 글