안녕하세요! 오늘은 Java의 Set 컬렉션에 대해 자세히 알아보려고 합니다. 데이터 중복 제거나 고유 값 관리 상황에서 Set은 정말 유용한 도구인데요. 특히 최근 제가 진행한 프로젝트에서 사용자 활동 로그의 중복 제거 작업을 진행하면서 얻은 경험을 바탕으로 각 Set 구현체의 특징과 활용법을 공유하고자 합니다.
1. 문제 상황: 데이터 중복 제거와 순서 관리
실제 프로젝트에서 마주친 다음과 같은 상황을 예시로 들어보겠습니다:
- 사용자 활동 로그에서 중복된 이벤트를 제거해야 하는 상황
- 상품 카테고리를 자동으로 정렬해서 보여줘야 하는 상황
- 장바구니에 담은 상품의 순서를 그대로 유지해야 하는 상황
이러한 요구사항들을 효과적으로 처리하기 위해 각각 다른 Set 구현체를 사용할 수 있습니다.
2. Set 구현체별 특징과 활용
2.1 HashSet: 가장 빠른 중복 제거
@Slf4j
@Service
public class EventLogService {
/**
* 이벤트 로그 목록에서 중복된 이벤트를 제거합니다.
* HashSet을 사용하여 O(n) 시간 복잡도로 중복을 제거합니다.
*
* @param events 원본 이벤트 로그 목록
* @return 중복이 제거된 이벤트 로그 목록
*/
public List<EventLog> deduplicateEvents(List<EventLog> events) {
// HashSet의 생성자를 사용하여 한 번에 중복 제거
Set<EventLog> uniqueEvents = new HashSet<>(events);
// 중복 제거 전후의 크기 차이를 로깅
log.info("Original events size: {}, After deduplication: {}",
events.size(), uniqueEvents.size());
// Set을 다시 List로 변환하여 반환
return new ArrayList<>(uniqueEvents);
}
@Getter
@EqualsAndHashCode // HashSet이 정상 동작하기 위해 반드시 필요!
public static class EventLog {
private final String userId; // 이벤트를 발생시킨 사용자 ID
private final String eventType; // 이벤트 유형 (예: CLICK, VIEW 등)
private final LocalDateTime timestamp; // 이벤트 발생 시간
// 생성자 생략
}
}
2.2 TreeSet: 자동 정렬이 필요할 때
@Service
public class CategoryService {
/**
* 데이터베이스의 모든 카테고리를 조회하여 이름 순으로 정렬된 Set을 반환합니다.
* TreeSet을 사용하여 자동 정렬을 구현합니다.
*
* @return 이름 순으로 정렬된 카테고리 Set
*/
public Set<Category> getSortedCategories() {
// TreeSet 생성 시 정렬 기준을 이름으로 설정
Set<Category> categories = new TreeSet<>(Comparator.comparing(Category::getName));
// 데이터베이스에서 조회한 카테고리를 자동 정렬되는 Set에 추가
categories.addAll(fetchCategoriesFromDatabase());
return categories;
}
@Getter
public static class Category implements Comparable<Category> {
private final String name; // 카테고리 이름
private final int priority; // 카테고리 우선순위
/**
* 카테고리 간 정렬을 위한 비교 메서드
* 이름을 기준으로 오름차순 정렬됩니다.
*/
@Override
public int compareTo(Category other) {
return this.name.compareTo(other.name);
}
// equals, hashCode 구현 생략
}
}
2.3 LinkedHashSet: 입력 순서 유지가 필요할 때
@Service
public class ShoppingCartService {
// 사용자별 장바구니를 관리하는 Map (Thread-safe)
private final Map<String, Set<Product>> userCarts = new ConcurrentHashMap<>();
/**
* 사용자의 장바구니에 상품을 추가합니다.
* LinkedHashSet을 사용하여 상품의 추가 순서를 유지합니다.
*
* @param userId 사용자 ID
* @param product 추가할 상품
*/
public void addToCart(String userId, Product product) {
// 사용자의 장바구니가 없으면 새로 생성하고, 상품 추가
userCarts.computeIfAbsent(userId, k -> new LinkedHashSet<>())
.add(product);
}
/**
* 사용자의 장바구니에 담긴 상품 목록을 조회합니다.
* 상품들은 담은 순서대로 정렬되어 있습니다.
*
* @param userId 사용자 ID
* @return 장바구니에 담긴 상품 목록
*/
public List<Product> getCartItems(String userId) {
return new ArrayList<>(userCarts.getOrDefault(userId, new LinkedHashSet<>()));
}
@Getter
@EqualsAndHashCode // LinkedHashSet에서 중복 체크를 위해 필요
public static class Product {
private final String id; // 상품 고유 ID
private final String name; // 상품명
private final BigDecimal price; // 상품 가격
// 생성자 생략
}
}
3. 성능 비교 및 선택 가이드
각 Set 구현체의 주요 작업별 시간 복잡도:
작업 | HashSet | TreeSet | LinkedHashSet |
---|---|---|---|
추가 | O(1) | O(log n) | O(1) |
검색 | O(1) | O(log n) | O(1) |
삭제 | O(1) | O(log n) | O(1) |
Set 구현체 특징 비교
기능 | HashSet | TreeSet | LinkedHashSet |
---|---|---|---|
저장 순서 유지 | ❌ | ✅ | ✅ |
빠른 검색 | ✅ | ✅ | ✅ |
정렬 | ❌ | ✅ | ❌ |
선택 가이드:
- HashSet 선택 시점:
- 단순히 중복 제거가 목적일 때
- 순서가 중요하지 않을 때
- 최대한의 성능이 필요할 때
- 예: 로그 데이터 중복 제거, 유니크 값 추출
- TreeSet 선택 시점:
- 데이터의 정렬이 필요할 때
- 범위 검색이 필요할 때 (headSet, tailSet 메소드 활용)
- 순서와 정렬이 모두 중요할 때
- 예: 정렬된 카테고리 목록, 점수 순위 관리
- LinkedHashSet 선택 시점:
- 입력 순서 유지가 필요할 때
- HashSet의 성능과 순서 보장이 동시에 필요할 때
- 순서는 중요하지만 정렬은 필요 없을 때
- 예: 장바구니 상품 목록, 최근 조회 이력
💡 선택 팁:
- 특별한 요구사항이 없다면 기본적으로 HashSet 사용
- 정렬이 필요하면 TreeSet
- 입력 순서 유지가 필요하면 LinkedHashSet 선택
4. 실무 적용 시 주의사항
- equals()와 hashCode() 구현
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof EventLog eventLog)) return false; return Objects.equals(userId, eventLog.userId) && Objects.equals(eventType, eventLog.eventType) && Objects.equals(timestamp, eventLog.timestamp); }
@Override
public int hashCode() {
return Objects.hash(userId, eventType, timestamp);
}
2. **Thread Safety 고려**
```java
// Thread-safe한 Set 사용이 필요한 경우
Set<Event> events = Collections.synchronizedSet(new HashSet<>());
// 또는
Set<Event> events = ConcurrentHashMap.newKeySet();
- 메모리 사용량 고려
// 초기 용량 지정으로 리사이징 횟수 줄이기 Set<String> set = new HashSet<>(1000, 0.75f);
5. 테스트 코드 예제
/**
* 각 Set 구현체의 동작 방식을 검증하는 테스트
*/
@Test
void testSetBehaviors() {
// Given: 중복이 포함된 테스트 데이터 준비
List<String> inputs = Arrays.asList("B", "A", "C", "A", "B");
// When: 각각의 Set 구현체에 동일한 데이터 입력
Set<String> hashSet = new HashSet<>(inputs);
Set<String> treeSet = new TreeSet<>(inputs);
Set<String> linkedHashSet = new LinkedHashSet<>(inputs);
// Then: 중복이 제거되어 모든 Set의 크기가 3이 되어야 함
assertEquals(3, hashSet.size()); // HashSet 크기 검증
assertEquals(3, treeSet.size()); // TreeSet 크기 검증
assertEquals(3, linkedHashSet.size()); // LinkedHashSet 크기 검증
// TreeSet 검증: 알파벳 순으로 정렬되어야 함
assertIterableEquals(
Arrays.asList("A", "B", "C"), // 예상 결과: 알파벳 순
new ArrayList<>(treeSet) // 실제 TreeSet 결과
);
// LinkedHashSet 검증: 최초 입력 순서가 유지되어야 함
assertIterableEquals(
Arrays.asList("B", "A", "C"), // 예상 결과: 최초 입력 순서
new ArrayList<>(linkedHashSet) // 실제 LinkedHashSet 결과
);
// HashSet은 순서를 보장하지 않으므로 순서 테스트를 하지 않음
}
결론
Set 구현체들은 각각의 특성을 잘 이해하고 사용하면 정말 강력한 도구가 됩니다. 특히 데이터의 중복 제거나 순서 관리가 빈번하게 필요한데, 이때 요구사항에 맞는 적절한 Set 구현체를 선택하는 것이 중요합니다.
제가 실제 프로젝트에서 경험한 바로는, 대부분의 경우 HashSet으로 충분하지만, 데이터의 순서가 중요한 UI 요소나 사용자 경험과 관련된 부분에서는 LinkedHashSet이 유용했습니다. TreeSet은 특히 카테고리나 태그 같은 데이터를 다룰 때 자동 정렬 기능이 큰 도움이 되었죠. 긴 글 읽어주셔서 감사합니다!
'개발일지' 카테고리의 다른 글
[TIL] 자바스크립트 옵셔널 체이닝 연산자(?.) 완벽 가이드 (0) | 2024.11.28 |
---|---|
Java Map 깊이 이해하기: HashMap vs TreeMap vs ConcurrentHashMap (0) | 2024.11.28 |
[pre-project 문제해결 공유] @RestController 어노테이션과 @Validated 어노테이션 충돌문제 (0) | 2023.08.22 |
[pre-project 회고] 경험치를 올려 레벨업을 했다 feat.코드스테이츠 (0) | 2023.06.30 |
[프리프로젝트] 프론트, 백엔드 서버 배포 관련 조사 (0) | 2023.06.29 |