코드 한 줄의 기록

Java 컬렉션 프레임워크 이해하기: List/Set/Map 완벽 가이드 본문

JAVA

Java 컬렉션 프레임워크 이해하기: List/Set/Map 완벽 가이드

CodeByJin 2025. 10. 24. 21:25
반응형

배열(Array)을 처음 배웠을 때 생각했던 게 있어요. 크기가 미리 정해지니까 데이터를 추가하거나 삭제할 때마다 새로운 배열을 만들어야 한다는 점이 정말 불편했습니다. 게다가 데이터가 중간에 삭제되면 뒤에 있는 모든 요소를 앞으로 한 칸씩 이동시켜야 하고요. 이런 문제들을 해결하기 위해 Java가 제공하는 것이 바로 컬렉션 프레임워크(Collection Framework)입니다.

 

요즘 Java 개발을 하면서 컬렉션 프레임워크를 모르고는 단 하루도 버틸 수 없을 정도로 중요하더군요. 특히 List, Set, Map 이 세 가지는 정말 자주 마주치게 됩니다. 이 글에서는 제가 공부하면서 배운 내용을 여러분과 함께 정리해보겠습니다.

컬렉션 프레임워크란 무엇인가?

먼저 컬렉션 프레임워크가 정확히 뭔지 알아봅시다. 컬렉션 프레임워크는 다수의 데이터를 쉽고 효과적으로 처리할 수 있는 표준화된 방법을 제공하는 클래스의 집합입니다. 쉽게 말해서, Java가 미리 만들어놓은 유용한 데이터 구조들이라고 보시면 됩니다.

 

컬렉션 프레임워크를 사용하면 여러분은 복잡한 데이터 구조를 처음부터 구현할 필요 없이, 제공되는 인터페이스와 클래스를 사용하기만 하면 됩니다. 게다가 각 자료구조의 특성에 맞게 최적화되어 있어서, 상황에 따라 가장 적합한 것을 선택해서 사용할 수 있다는 장점도 있어요.

세 가지 주요 인터페이스: List, Set, Map

컬렉션 프레임워크는 여러 인터페이스들로 구성되어 있는데, 그 중에서도 가장 많이 사용되는 것이 List, Set, Map 세 가지입니다. 각각의 특징을 간단하게 비교해보면 다음과 같습니다.

 

List순서가 있으며, 중복을 허용하는 자료구조입니다. 마치 일렬로 줄을 선 사람들처럼, 각 데이터가 정해진 위치(인덱스)를 가지고 있습니다.

 

Set순서가 없으며, 중복을 허용하지 않습니다. 수학에서의 집합 개념과 비슷하다고 보시면 됩니다. 마치 가방에 물건을 막 집어넣듯이, 어떤 순서로 넣든 결과는 똑같아요.

 

MapKey와 Value의 쌍으로 데이터를 저장합니다. Key는 고유해야 하고 중복을 허용하지 않지만, Value는 중복되어도 괜찮습니다. 전화번호부처럼 이름(Key)과 전화번호(Value)를 연결하는 구조라고 생각하면 이해하기 쉬워요.

List 인터페이스 깊이 있게 살펴보기

List는 정말 자주 사용되는 인터페이스예요. ArrayList와 LinkedList가 두 가지 주요 구현체인데, 이 둘은 내부 구조가 완전히 다릅니다.

ArrayList: 배열처럼 빠른 접근

ArrayList는 내부적으로 배열을 사용합니다. 따라서 인덱스를 통해 빠르게 특정 요소에 접근할 수 있습니다. 배열 기반이기 때문에 get() 메서드로 요소를 조회할 때 정말 빠르죠. O(1)의 시간복잡도를 가집니다.

 

하지만 단점이 있어요. 데이터를 중간에 삽입하거나 삭제할 때는 뒤에 있는 모든 요소를 한 칸씩 이동시켜야 합니다. 마치 사람들이 일렬로 서 있을 때 중간에 누군가 끼어들면, 그 뒤에 있는 모든 사람이 한 칸씩 뒤로 물러나야 하는 것처럼요. 크기가 부족하면 더 큰 배열을 만들어서 모든 요소를 복사하는 리사이징도 발생합니다.

 

그럼에도 불구하고 ArrayList는 매우 자주 사용되는데, 그 이유는 대부분의 경우 데이터를 조회하는 작업이 수정하는 작업보다 훨씬 많기 때문입니다. 저도 실무에서 거의 매일 ArrayList를 사용하고 있습니다.

ArrayList<String> list = new ArrayList<>();
list.add("홍길동");
list.add("김유신");
list.add("이순신");

// 빠른 조회
String name = list.get(1); // "김유신"

// 중간 삽입 (뒤의 요소들이 모두 이동)
list.add(1, "세종대왕"); // 인덱스 1에 삽입

LinkedList: 삽입과 삭제에 최적화

LinkedList는 ArrayList와 완전히 다른 구조입니다. 노드(Node)들을 연결하는 방식으로 동작합니다. 각 노드는 데이터와 다음 노드를 가리키는 포인터를 가지고 있죠. Java의 LinkedList는 양방향 포인터를 사용해서 앞뒤로 이동할 수 있습니다.

 

LinkedList의 가장 큰 장점은 삽입과 삭제가 빠르다는 점입니다. 단순히 포인터를 바꿔주기만 하면 되니까요. O(1)의 시간복잡도를 가집니다. 하지만 특정 요소를 찾기 위해서는 처음부터 차근차근 노드를 따라가야 해서, 조회 성능은 ArrayList에 비해 훨씬 떨어집니다. O(n)의 시간복잡도를 가지죠.

 

게다가 각 노드가 포인터를 가져야 하므로, ArrayList보다 메모리를 더 많이 사용합니다. 데이터 외에도 다음 노드를 가리키는 참조 정보를 저장해야 하기 때문입니다.

LinkedList<String> list = new LinkedList<>();
list.add("홍길동");
list.add("김유신");
list.add("이순신");

// 느린 조회
String name = list.get(1); // "김유신" (노드를 따라가야 함)

// 빠른 삽입/삭제
list.add(1, "세종대왕");
list.remove(0);

Vector: 레거시 클래스

Vector는 ArrayList보다 먼저 만들어진 클래스입니다. 동기화(synchronized)가 내장되어 있어서 멀티스레드 환경에서도 안전합니다. 하지만 동기화로 인한 성능 저하가 발생하고, 요즘은 더 나은 방법들이 많으므로 거의 사용하지 않습니다. 레거시 코드와의 호환성을 위해 남아있을 뿐이에요.

Set 인터페이스의 특징과 구현체들

Set은 중복을 허용하지 않고 순서가 없다는 점이 List와의 가장 큰 차이입니다. 하지만 Set의 구현체들은 각각 다른 특징을 가지고 있어서, 상황에 따라 적절한 것을 선택해야 합니다.

HashSet: 가장 빠르고 순서 없음

HashSet은 내부적으로 HashMap을 사용하며, 해시 함수를 이용해서 데이터를 저장합니다. 덕분에 추가, 삭제, 검색이 모두 O(1)의 평균적인 시간복잡도를 가져서 정말 빠릅니다.

 

하지만 순서를 보장하지 않습니다. 데이터를 넣을 때의 순서와 빼낼 때의 순서가 다를 수 있다는 뜻이에요. 또한 null 값을 한 개 저장할 수 있습니다.

HashSet<Integer> set = new HashSet<>();
set.add(3);
set.add(1);
set.add(7);
set.add(3); // 중복은 저장되지 않음

// 순서를 보장하지 않음
for (Integer num : set) {
    System.out.println(num); // 1, 3, 7 순서가 아닐 수 있음
}

TreeSet: 정렬된 상태 유지

TreeSet은 이진 탐색 트리, 특히 레드-블랙 트리를 내부적으로 사용합니다. 레드-블랙 트리는 트리가 한쪽으로 치우치지 않도록 균형을 유지하는 특별한 자료구조예요.

 

TreeSet의 가장 큰 특징은 데이터를 오름차순으로 자동 정렬한다는 점입니다. 숫자면 작은 것부터, 문자면 알파벳 순서대로 저장되죠. 삽입, 삭제, 검색이 모두 O(log n)의 시간복잡도를 가집니다. 정렬 기능이 없는 HashSet보다는 느리지만, 정렬된 데이터가 필요하다면 정말 유용합니다.

TreeSet<Integer> set = new TreeSet<>();
set.add(3);
set.add(1);
set.add(7);
set.add(5);

// 자동으로 정렬됨
for (Integer num : set) {
    System.out.println(num); // 1, 3, 5, 7
}

 

사용자 정의 객체를 TreeSet에 저장하려면, Comparable 인터페이스를 구현해서 정렬 기준을 명시해야 합니다. 또는 Comparator를 생성자에 전달할 수도 있습니다.

LinkedHashSet: 입력 순서 보존

LinkedHashSet은 HashSet과 TreeSet의 중간쯤이라고 볼 수 있습니다. 내부적으로 LinkedHashMap을 사용하며, 입력한 순서를 유지합니다.

 

성능은 HashSet과 거의 비슷하지만, 순서 정보를 추가로 관리해야 하므로 약간의 오버헤드가 있습니다. 데이터를 입력한 순서대로 처리해야 할 때 유용합니다.

LinkedHashSet<Integer> set = new LinkedHashSet<>();
set.add(3);
set.add(1);
set.add(7);
set.add(5);

// 입력 순서 보존
for (Integer num : set) {
    System.out.println(num); // 3, 1, 7, 5
}

Map 인터페이스: 키-값 쌍으로 데이터 관리

Map은 List나 Set과 조금 다릅니다. 키(Key)와 값(Value)의 쌍으로 데이터를 저장하기 때문입니다. 값은 중복되어도 괜찮지만, 키는 반드시 고유해야 합니다.

HashMap: 빠른 검색, 순서 없음

HashMap은 해시 테이블 기반으로 동작합니다. 키에 대한 해시 값을 계산한 후, 그 값을 버킷의 인덱스로 사용해서 데이터를 저장하는 방식이죠.

HashMap<String, Integer> map = new HashMap<>();
map.put("홍길동", 25);
map.put("김유신", 28);
map.put("이순신", 30);

// 빠른 검색
Integer age = map.get("홍길동"); // 25

 

HashMap의 평균적인 성능은 정말 뛰어나서, 삽입, 삭제, 검색이 모두 O(1)의 시간복잡도를 가집니다. 다만 순서를 보장하지 않으며, null 키와 null 값을 허용합니다. 그리고 멀티스레드 환경에서 안전하지 않습니다. 여러 스레드가 동시에 접근하면 데이터 무결성이 손상될 수 있다는 뜻이에요.

 

내부적으로 해시 충돌이 발생할 수 있습니다. 서로 다른 키가 같은 해시 값을 가질 수 있기 때문이죠. Java 8 이후로는 해시 충돌이 많이 발생하면 연결 리스트 대신 레드-블랙 트리를 사용해서 성능을 유지합니다.

LinkedHashMap: 입력 순서 보존

LinkedHashMap은 HashMap처럼 동작하지만, 입력한 순서를 기억합니다. 내부적으로 이중 연결 리스트를 유지하기 때문입니다.

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("홍길동", 25);
map.put("김유신", 28);
map.put("이순신", 30);

// 입력 순서 보존
for (String key : map.keySet()) {
    System.out.println(key); // 홍길동, 김유신, 이순신
}

 

순서 정보를 관리해야 하므로 HashMap보다 약간의 오버헤드가 있지만, 데이터를 입력한 순서대로 처리해야 할 때는 정말 편합니다.

TreeMap: 키를 기준으로 정렬

TreeMap은 레드-블랙 트리를 기반으로 동작하며, 키를 오름차순으로 자동 정렬합니다.

TreeMap<String, Integer> map = new TreeMap<>();
map.put("홍길동", 25);
map.put("김유신", 28);
map.put("이순신", 30);

// 키를 기준으로 정렬됨
for (String key : map.keySet()) {
    System.out.println(key); // 김유신, 이순신, 홍길동 (가나다순)
}

 

삽입, 삭제, 검색이 모두 O(log n)의 시간복잡도를 가집니다. HashMap보다는 느리지만, 정렬된 데이터가 필요하거나 범위 검색을 해야 할 때는 TreeMap이 정말 유용합니다.

언제 뭘 사용할까? 선택 기준

지금까지 여러 컬렉션들을 살펴봤는데, 결국 중요한 것은 상황에 맞는 것을 선택하는 것입니다. 저는 다음과 같은 기준으로 선택하곤 해요:

  • ArrayList를 사용할 때
    • 데이터를 주로 조회하는 경우
    • 데이터의 순서가 중요한 경우
    • 끝에 데이터를 추가하는 경우가 많을 때
  • LinkedList를 사용할 때
    • 데이터를 자주 중간에 삽입하거나 삭제해야 할 때
    • 데이터의 순서가 중요한 경우
  • HashSet을 사용할 때
    • 중복을 제거하고 싶을 때
    • 순서가 상관없을 때
    • 빠른 검색이 필요할 때
  • TreeSet을 사용할 때
    • 데이터를 정렬된 상태로 유지하고 싶을 때
    • 범위 검색이 필요할 때
  • HashMap을 사용할 때
    • 키-값 쌍으로 데이터를 관리해야 할 때
    • 빠른 검색이 필요할 때
    • 대부분의 경우 Map이 필요하면 HashMap을 먼저 생각하세요
  • TreeMap을 사용할 때
    • 키를 정렬된 상태로 유지하고 싶을 때
    • 특정 범위의 키를 검색해야 할 때

실전에서의 팁: 내가 배운 것들

실제 프로젝트에서 일하다 보니 알게 된 팁들이 있습니다.

  1. null 처리에 주의하세요
    Set은 null을 제한적으로 허용하거든요. HashSet은 null을 하나 저장할 수 있지만, TreeSet은 null을 저장하면 NullPointerException이 발생합니다. 미리 확인하세요.
  2. 멀티스레드 환경에서는 동기화 처리를 하세요
    HashMap을 여러 스레드에서 동시에 접근하면 위험합니다. 필요하면 Collections.synchronizedMap()을 사용하거나 ConcurrentHashMap을 사용하세요.
  3. 성능이 중요하면 초기 용량을 설정하세요
    ArrayList나 HashMap은 크기가 부족하면 자동으로 확장되는데, 이 과정에서 성능이 떨어집니다. 데이터의 크기를 대략 알고 있다면, 생성할 때 초기 용량을 설정하는 게 좋습니다.
// 좋은 예
ArrayList<String> list = new ArrayList<>(1000); // 초기 용량 1000
HashMap<String, Integer> map = new HashMap<>(1000);

// 나쁜 예
ArrayList<String> list = new ArrayList<>(); // 기본값부터 시작
  1. 불변 컬렉션을 만들 수 있어요
    Collections 유틸리티 클래스를 사용하면 불변 컬렉션을 만들 수 있습니다. 또는 Java 9 이상에서는 List, Set, Map의 of() 메서드를 사용할 수 있어요.
// Java 9 이상
List<String> immutableList = List.of("홍길동", "김유신");
Set<String> immutableSet = Set.of("홍길동", "김유신");
Map<String, Integer> immutableMap = Map.of("홍길동", 25, "김유신", 28);

 

컬렉션 프레임워크는 Java 개발에서 가장 기본이면서도 중요한 개념입니다. List, Set, Map 각각의 특징을 이해하고, 상황에 맞게 선택할 수 있다면 훨씬 더 효율적인 코드를 작성할 수 있어요.

 

처음에는 복잡해 보일 수 있지만, 실제로 프로젝트를 하면서 사용하다 보면 자연스럽게 익혀집니다. 저도 처음엔 ArrayList와 LinkedList의 차이도 헷갈렸지만, 지금은 상황에 따라 자동으로 선택하게 되더군요.

 

앞으로 코드를 작성할 때 "이 경우에는 어떤 컬렉션이 가장 효율적일까?"를 먼저 생각해보세요. 그런 습관이 쌓이면, 더 좋은 성능의 코드를 만들 수 있을 겁니다. 화이팅!

반응형