본문 바로가기

Dev Log

Java 컬렉션 프레임워크 정리: List, Set, Map, HashMap

728x90

Java에서 말하는 컬렉션 프레임워크란, 여러 개의 데이터를 효율적으로 저장하고, 관리하고, 탐색하기 위한 자료구조 + 이를 다루는 공통 인터페이스의 집합이다. 쉽게 말해 데이터 묶음을 다루기 위한 도구 모음과 같은 것이다.

 

컬렉션 프레임워크를 사용하면 검색, 정렬, 중복 처리 등을 훨씬 편하게 할 수 있다. 실무에서 사용되는 패턴 위주로 정리해봤다.

자바 컬렉션은 크게 List, Set, Map 으로 나눠지고 대표 구현채로는 ArrayList, LinkedList, HashSet, HashMap, TreeMap 등이 있다.

  1. List - 순서가 있는 데이터 모음
  2. Set - 중복 없는 데이터 모음
  3. Map - 키-값 쌍 데이터 구조
  4. HashMap - 가장 많이 쓰이는 Map 구현체

1. List

개념 및 구조

List는 순서가 있는 데이터의 모음이다. 배열과 유사하지만 크기가 동적으로 변할 수 있다.

[요소1] → [요소2] → [요소3] → [요소4]
  ↑                        ↑
인덱스 0                인덱스 3

주요 특징

  • 순서 보장: 삽입한 순서대로 저장된다
  • 중복 허용: 같은 값을 여러 번 저장할 수 있다
  • 인덱스 접근: get(index)로 특정 위치의 요소에 접근할 수 있다
  • 동적 크기: 요소 추가/삭제 시 자동으로 크기가 조정된다

구현체 종류

  • ArrayList: 배열 기반, 빠른 조회, 느린 삽입/삭제
  • LinkedList: 노드 기반, 빠른 삽입/삭제, 느린 조회

장점

✅ 인덱스로 빠른 접근 (ArrayList 기준 O(1))

✅ 순서가 중요한 데이터 처리에 적합

✅ 중복 데이터 저장이 가능하다

✅ Stream API와 잘 어울린다

단점

❌ 중간 삽입/삭제 시 성능 저하 (ArrayList)

❌ 중복 체크가 필요할 때 별도 로직이 필요하다 ❌ 특정 값 검색 시 순차 탐색이 필요하다 (O(n))

기본 사용법

// 생성
List<String> names = new ArrayList<>();
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// 추가
names.add("김철수");
names.add("이영희");
names.add("박민수");

// 조회
String first = names.get(0);  // "김철수"
int size = names.size();      // 3

// 순회
for (String name : names) {
    System.out.println(name);
}

// Stream API 활용
List<String> filtered = names.stream()
    .filter(name -> name.startsWith("김"))
    .toList();

2. Set

개념 및 구조

Set은 중복을 허용하지 않는 데이터의 모음이다. 수학의 집합(Set) 개념과 동일하다.

{요소1, 요소2, 요소3}
  ↑      ↑      ↑
중복 없음, 순서 보장 안됨 (HashSet 기준)

주요 특징

  • 중복 불가: 같은 값을 두 번 저장할 수 없다
  • 순서 보장 안됨: HashSet은 순서를 보장하지 않으며, LinkedHashSet은 삽입 순서를 보장한다
  • 빠른 검색: contains() 메서드가 매우 빠르다 (O(1))

구현체 종류

  • HashSet: 해시 테이블 기반, 가장 빠르다, 순서가 없다
  • LinkedHashSet: HashSet + 삽입 순서 보장
  • TreeSet: 정렬된 순서로 저장된다, O(log n) 성능이다

장점

✅ 중복 자동 제거

✅ 빠른 존재 여부 확인 (contains)

✅ 집합 연산 (합집합, 교집합 등) 지원

✅ 메모리 효율적 (중복 데이터 제거)

단점

❌ 순서를 보장하지 않는다 (HashSet)

❌ 인덱스 접근이 불가능하다

❌ 정렬이 필요하면 TreeSet을 사용한다 (성능 저하)

기본 사용법

// 생성
Set<String> uniqueNames = new HashSet<>();
Set<Integer> numbers = Set.of(1, 2, 3, 4, 5);  // 불변 Set

// 추가
uniqueNames.add("김철수");
uniqueNames.add("이영희");
uniqueNames.add("김철수");  // 중복, 추가되지 않는다

// 조회
boolean exists = uniqueNames.contains("김철수");  // true
int size = uniqueNames.size();  // 2

// 중복 제거 예시
List<String> namesWithDuplicates = Arrays.asList("김", "이", "김", "박");
Set<String> unique = new HashSet<>(namesWithDuplicates);  // {김, 이, 박}

// 집합 연산
Set<String> set1 = Set.of("A", "B", "C");
Set<String> set2 = Set.of("B", "C", "D");
Set<String> union = new HashSet<>(set1);
union.addAll(set2);  // 합집합: {A, B, C, D}

3. Map

개념 및 구조

Map은 키(Key)와 값(Value)의 쌍으로 데이터를 저장하는 자료구조이다.

키(Key)     →     값(Value)
"name"      →     "김철수"
"age"       →     30
"city"      →     "서울"

주요 특징

  • 키-값 쌍: 각 데이터는 고유한 키와 값으로 구성된다
  • 키는 중복 불가: 같은 키는 하나만 존재한다
  • 값은 중복 가능: 서로 다른 키가 같은 값을 가질 수 있다
  • 빠른 검색: 키로 값을 빠르게 찾을 수 있다 (O(1))

구현체 종류

  • HashMap: 해시 테이블 기반, 가장 빠르다, 순서가 없다
  • LinkedHashMap: HashMap + 삽입 순서 보장
  • TreeMap: 키 기준 정렬, O(log n) 성능이다

장점

✅ 키로 빠른 값 조회 (O(1))

✅ 키-값 관계를 명확하게 표현

✅ 중복 키 자동 처리 (덮어쓰기)

✅ 다양한 데이터 타입 조합이 가능하다

단점

❌ 순서를 보장하지 않는다 (HashMap)

❌ 키가 없으면 값 접근이 불가능하다

❌ 메모리 사용량이 상대적으로 크다

기본 사용법

// 생성
Map<String, String> userInfo = new HashMap<>();
Map<Integer, String> idToName = Map.of(
    1, "김철수",
    2, "이영희",
    3, "박민수"
);

// 추가/수정
userInfo.put("name", "김철수");
userInfo.put("age", "30");
userInfo.put("city", "서울");

// 조회
String name = userInfo.get("name");  // "김철수"
String age = userInfo.getOrDefault("age", "0");  // 키가 없으면 기본값

// 존재 여부 확인
boolean hasName = userInfo.containsKey("name");  // true
boolean hasValue = userInfo.containsValue("김철수");  // true

// 순회
for (Map.Entry<String, String> entry : userInfo.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

// 키만 순회
for (String key : userInfo.keySet()) {
    System.out.println(key);
}

// 값만 순회
for (String value : userInfo.values()) {
    System.out.println(value);
}

4. HashMap

개념 및 구조

HashMap은 Map 인터페이스의 가장 널리 사용되는 구현체이다. 해시 테이블을 기반으로 한다.

해시 버킷 구조:
[0] → [키1:값1] → [키2:값2]
[1] → [키3:값3]
[2] → null
[3] → [키4:값4]

주요 특징

  • 해시 함수: 키의 해시값을 계산하여 버킷에 저장한다
  • 평균 O(1) 성능: 조회, 삽입, 삭제가 평균적으로 매우 빠르다
  • 순서 보장 안 됨: 삽입 순서와 무관하게 저장된다
  • null 허용: 키와 값 모두 null을 허용한다 (단, 키는 하나만)

장점

✅ 매우 빠른 조회/삽입/삭제 (평균 O(1))

✅ 메모리 효율적

✅ 가장 널리 사용되어 검증되었다

✅ 다양한 데이터 타입 지원

단점

❌ 최악의 경우 O(n) 성능 (해시 충돌 시)

❌ 순서를 보장하지 않는다

❌ 동기화를 지원하지 않는다 (멀티스레드 환경에서는 ConcurrentHashMap 사용)

기본 사용법

// 생성
HashMap<String, Integer> scoreMap = new HashMap<>();

// 추가
scoreMap.put("김철수", 95);
scoreMap.put("이영희", 88);
scoreMap.put("박민수", 92);

// 조회
Integer score = scoreMap.get("김철수");  // 95
Integer defaultScore = scoreMap.getOrDefault("홍길동", 0);  // 키가 없으면 0

// 수정
scoreMap.put("김철수", 100);  // 기존 값 덮어쓰기
scoreMap.replace("이영희", 90);  // 키가 존재할 때만 수정

// 삭제
scoreMap.remove("박민수");
scoreMap.remove("김철수", 95);  // 키와 값이 모두 일치할 때만 삭제

// 크기 확인
int size = scoreMap.size();  // 2
boolean isEmpty = scoreMap.isEmpty();  // false

// 모든 키-값 쌍 제거
scoreMap.clear();

5. 실전 사용 예시

예시 1: 사용자 목록 관리 (List)

시나리오: 여러 사용자의 정보를 순서대로 관리하고, 특정 조건에 맞는 사용자만 필터링

public class UserService {

    // 사용자 정보를 순서대로 저장
    public List<UserInfo> getUserList() {
        List<UserInfo> users = new ArrayList<>();

        users.add(new UserInfo("김철수", 30, "서울"));
        users.add(new UserInfo("이영희", 25, "부산"));
        users.add(new UserInfo("박민수", 35, "대구"));

        return users;
    }

    // 특정 지역 사용자만 필터링
    public List<UserInfo> getUsersByCity(List<UserInfo> users, String city) {
        return users.stream()
            .filter(user -> city.equals(user.getCity()))
            .toList();
    }

    // 나이순으로 정렬
    public List<UserInfo> sortByAge(List<UserInfo> users) {
        return users.stream()
            .sorted(Comparator.comparingInt(UserInfo::getAge))
            .toList();
    }
}

class UserInfo {
    private String name;
    private int age;
    private String city;

    // 생성자, getter, setter 생략
}

왜 List를 사용하는가?

  • 사용자 목록은 순서가 중요하다 (최신순, 가입순 등)
  • 같은 사용자가 여러 번 나타날 수 있다
  • 인덱스로 특정 위치의 사용자에 빠르게 접근할 수 있다

예시 2: 중복 제거 및 빠른 검색 (Set)

시나리오: 설문 답변에서 중복된 질문 코드를 확인하고, 유효한 답변인지 검증

public class SurveyService {

    // 중복된 질문 코드 확인
    public boolean hasDuplicateQuestions(List<SurveyAnswer> answers) {
        List<String> questionCodes = answers.stream()
            .map(SurveyAnswer::getQuestionCode)
            .toList();

        // Set을 사용하여 중복 확인
        Set<String> uniqueCodes = new HashSet<>(questionCodes);

        // 크기가 다르면 중복이 있다는 의미이다
        return questionCodes.size() != uniqueCodes.size();
    }

    // 허용된 질문 코드 목록과 비교
    public boolean isValidQuestions(List<SurveyAnswer> answers) {
        Set<String> allowedCodes = Set.of("Q001", "Q002", "Q003", "Q004");
        Set<String> answerCodes = answers.stream()
            .map(SurveyAnswer::getQuestionCode)
            .collect(Collectors.toSet());

        // 모든 답변이 허용된 코드에 포함되는지 확인한다
        return allowedCodes.containsAll(answerCodes);
    }

    // 빠른 존재 여부 확인
    public boolean isQuestionExists(String questionCode) {
        Set<String> validCodes = Set.of("Q001", "Q002", "Q003");
        return validCodes.contains(questionCode);  // O(1) 빠른 검색
    }
}

class SurveyAnswer {
    private String questionCode;
    private String answer;

    // 생성자, getter, setter 생략
}

왜 Set을 사용하는가?

  • 중복 자동 제거로 별도 로직이 불필요하다
  • contains() 메서드가 매우 빠르다 (O(1))
  • 집합 연산 (합집합, 교집합)이 간단하다

예시 3: 사용자별 통계 집계 (HashMap)

시나리오: 여러 사용자의 점수를 관리하고, 특정 사용자의 점수를 빠르게 조회

public class ScoreService {

    // 사용자별 점수 저장 및 조회
    public Map<String, Integer> calculateUserScores(List<ScoreRecord> records) {
        Map<String, Integer> scoreMap = new HashMap<>();

        // 사용자별 점수 합계 계산
        for (ScoreRecord record : records) {
            String userId = record.getUserId();
            int score = record.getScore();

            // 기존 점수가 있으면 더하고, 없으면 새로 추가
            scoreMap.put(userId, scoreMap.getOrDefault(userId, 0) + score);
        }

        return scoreMap;
    }

    // 특정 사용자 점수 조회
    public Integer getUserScore(Map<String, Integer> scoreMap, String userId) {
        return scoreMap.getOrDefault(userId, 0);
    }

    // 상위 N명 사용자 조회
    public List<String> getTopUsers(Map<String, Integer> scoreMap, int topN) {
        return scoreMap.entrySet().stream()
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
            .limit(topN)
            .map(Map.Entry::getKey)
            .toList();
    }
}

class ScoreRecord {
    private String userId;
    private int score;

    // 생성자, getter, setter 생략
}

왜 HashMap을 사용하는가?

  • 사용자 ID(키)로 점수(값)를 빠르게 조회할 수 있다 (O(1))
  • 키-값 관계가 명확하다
  • 동적 추가/수정이 용이하다

예시 4: 복잡한 데이터 구조 (중첩 Map)

시나리오: 제품별 카테고리별 가격 정보를 관리

public class ProductService {

    // 제품명 → (카테고리 → 가격) 구조
    public Map<String, Map<String, Integer>> getProductPriceMap() {
        Map<String, Map<String, Integer>> productMap = new HashMap<>();

        // 제품별 카테고리별 가격 설정
        Map<String, Integer> productAPrices = new HashMap<>();
        productAPrices.put("기본형", 10000);
        productAPrices.put("프리미엄", 20000);
        productMap.put("제품A", productAPrices);

        Map<String, Integer> productBPrices = new HashMap<>();
        productBPrices.put("기본형", 15000);
        productBPrices.put("프리미엄", 25000);
        productMap.put("제품B", productBPrices);

        return productMap;
    }

    // 특정 제품의 특정 카테고리 가격 조회
    public Integer getPrice(
            Map<String, Map<String, Integer>> productMap,
            String productName,
            String category) {

        Map<String, Integer> prices = productMap.get(productName);
        if (prices == null) {
            return null;
        }
        return prices.get(category);
    }

    // 모든 제품의 기본형 가격 조회
    public Map<String, Integer> getAllBasicPrices(
            Map<String, Map<String, Integer>> productMap) {

        Map<String, Integer> result = new HashMap<>();
        for (Map.Entry<String, Map<String, Integer>> entry : productMap.entrySet()) {
            String productName = entry.getKey();
            Map<String, Integer> prices = entry.getValue();
            Integer basicPrice = prices.get("기본형");
            if (basicPrice != null) {
                result.put(productName, basicPrice);
            }
        }
        return result;
    }
}

왜 중첩 Map을 사용하는가?

  • 2차원 데이터 구조를 명확하게 표현한다
  • 제품명과 카테고리로 빠르게 가격을 조회할 수 있다
  • 계층적 데이터 구조 표현에 적합하다

예시 5: 날짜별 데이터 관리 (LinkedHashMap)

시나리오: 날짜 순서대로 데이터를 저장하고 순회해야 하는 경우

public class DailyDataService {

    // 날짜 순서를 보장하면서 데이터 저장
    public Map<LocalDate, Integer> getDailyStatistics(List<DataRecord> records) {
        // LinkedHashMap: 삽입 순서 보장
        Map<LocalDate, Integer> dailyMap = new LinkedHashMap<>();

        for (DataRecord record : records) {
            LocalDate date = record.getDate();
            int value = record.getValue();

            // 같은 날짜의 값들을 합산
            dailyMap.put(date, dailyMap.getOrDefault(date, 0) + value);
        }

        return dailyMap;  // 삽입한 순서대로 반환된다
    }

    // 최근 N일 데이터 조회
    public List<Map.Entry<LocalDate, Integer>> getRecentDays(
            Map<LocalDate, Integer> dailyMap, int days) {

        return dailyMap.entrySet().stream()
            .skip(Math.max(0, dailyMap.size() - days))
            .toList();
    }
}

class DataRecord {
    private LocalDate date;
    private int value;

    // 생성자, getter, setter 생략
}

왜 LinkedHashMap을 사용하는가?

  • 날짜 순서가 중요하다
  • 삽입 순서를 보장하면서 Map의 빠른 조회 성능을 유지한다
  • 최신 데이터부터 순회하기 쉽다

6. 언제 무엇을 사용할까?

선택 가이드

상황 추천 자료구조 이유

순서가 중요하고 중복 허용 List (ArrayList) 인덱스 접근, 순서 보장
중복 제거가 필요 Set (HashSet) 자동 중복 제거
빠른 검색이 필요 Set (HashSet) contains()가 O(1)
키로 값을 찾아야 함 Map (HashMap) 키-값 쌍 구조
삽입 순서 보장 필요 LinkedHashSet / LinkedHashMap 순서 보장 + 빠른 성능
정렬이 필요 TreeSet / TreeMap 자동 정렬
중간 삽입/삭제가 많음 LinkedList 빠른 삽입/삭제
조회가 많음 ArrayList 빠른 인덱스 접근

성능 비교 요약

연산 ArrayList LinkedList HashSet HashMap

인덱스 조회 O(1) O(n) - -
값 검색 O(n) O(n) O(1) O(1)
삽입 (끝) O(1) O(1) O(1) O(1)
삽입 (중간) O(n) O(1) - -
삭제 O(n) O(1) O(1) O(1)

실전 팁

  1. 기본적으로 ArrayList와 HashMap을 사용한다
    • 대부분의 경우에 충분히 빠르고 안정적이다
  2. 중복 체크가 필요하면 Set을 활용한다
    • List.contains()는 O(n), Set.contains()는 O(1)이다
  3. 순서가 중요하면 Linked 계열을 사용한다
    • LinkedHashSet, LinkedHashMap
  4. 멀티스레드 환경에서는 동기화된 컬렉션을 사용한다
    • ConcurrentHashMap, Collections.synchronizedList()
  5. 불변 컬렉션이 필요하면
    • List.of(), Set.of(), Map.of()를 사용한다

마무리

Java 컬렉션 프레임워크는 다양한 상황에 맞는 최적의 자료구조를 제공한다. 각 자료구조의 특징과 성능을 이해하고, 상황에 맞게 선택하는 것이 중요하다.

  • List: 순서가 있고 중복 허용
  • Set: 중복 없음, 빠른 검색
  • Map: 키-값 쌍, 빠른 조회
  • HashMap: 가장 널리 사용되는 Map 구현체

올바른 자료구조 선택은 코드의 가독성과 성능을 크게 향상시킨다!

728x90