이 글은 Velog에서 이전한 글입니다. Velog 원문 보기
JPA를 사용하여 연관된 엔티티들을 한 번에 조회(Fetch Join)할 때, 의도치 않게 데이터가 중복되어 반환되는 현상을 분석하고 해결한 기록입니다.
1. 문제 상황 (Symptoms)
모임(Meeting) 정보를 조회할 때, 연관된 날짜(dates), 참여자(participants), 투표 날짜(voteDates)를 한 번에 가져오기 위해 JOIN FETCH를 사용했습니다.
하지만 응답 결과(JSON)를 확인해 보니, 동일한 참여자(participants) 정보가 중복되어 리스트에 담기는 현상이 발생했습니다.
오류 로그 (Response Body)
participants배열에 동일한id: 193객체가 8번이나 반복되고 있습니다.
{
"responseBody": {
"id": "tgqQEwZyd52B",
"title": "행복한 하루",
"dates": ["2026-01-24", ... ],
"participants": [
{"id":193, "name":"ㄹㄹㄹ", "hasVoted":false},
{"id":193, "name":"ㄹㄹㄹ", "hasVoted":false},
{"id":193, "name":"ㄹㄹㄹ", "hasVoted":false},
... (총 8개 중복)
],
"hostName": "ㄹㄹㄹ"
}
}
1-1. 문제 코드
Repository
@Query("""
SELECT DISTINCT m FROM MeetingJpaEntity m
LEFT JOIN FETCH m.dates
LEFT JOIN FETCH m.participants p
LEFT JOIN FETCH p.voteDates
WHERE m.meetId = :meetId
""")
fun findByMeetIdWithParticipants(@Param("meetId") meetId: String): MeetingJpaEntity?
Entity
// 컬렉션 타입이 MutableList(List)로 선언됨
@OneToMany(mappedBy = "meeting", cascade = [CascadeType.ALL], orphanRemoval = true)
var participants: MutableList<ParticipantJpaEntity> = mutableListOf()
2. 원인 분석 (Root Cause)
이 문제의 핵심 원인은 다중 컬렉션 Fetch Join과 List 타입 사용의 조합으로 인한 카르테시안 곱(Cartesian Product) 발생입니다.
2-1. 데이터 뻥튀기 (Cartesian Product)
RDB 관점에서 1:N 관계를 조인하면 데이터 Row 수가 늘어납니다. 여기에 또 다른 1:M 관계를 조인하면 Row 수는 기하급수적으로 증가합니다.

예를 들어 데이터가 다음과 같다면:
dates: 3개participants: 2명voteDates: 각 2개
SQL 실행 결과 Row 수는 3(dates) × 2(participants) × 2(voteDates) = 12개가 됩니다.
2-2. List의 중복 허용
JPA 엔티티 조회 시 SELECT DISTINCT를 사용했으나, 이는 SQL 레벨에서의 중복 제거를 의미합니다.
하지만 조인된 결과 Row 자체가 다르기 때문에(다른 컬럼 값의 조합) SQL단에서는 중복이 제거되지 않은 채로 모든 Row를 가져옵니다.
JPA(Hibernate)는 이 결과셋을 객체로 매핑할 때, participants가 List 타입이라면 중복된 엔티티 식별자를 걸러내지 않고 조회된 Row 수만큼 리스트에 그대로 추가합니다. (만약 Set이었다면 자료구조 특성상 중복이 제거됩니다.)
3. 해결 방법 (Solutions)
✅ 적용한 해결책: 컬렉션을 Set으로 변경
중복 데이터가 담기는 것을 방지하기 위해 엔티티의 컬렉션 타입을 List에서 Set으로 변경했습니다. Set은 자료구조 특성상 중복을 허용하지 않으므로, 다중 조인으로 인해 발생한 중복 엔티티가 자동으로 제거됩니다.

// MutableList -> MutableSet 변경
@OneToMany(mappedBy = "meeting", cascade = [CascadeType.ALL], orphanRemoval = true)
var participants: MutableSet<ParticipantJpaEntity> = mutableSetOf()
- 결과: 중복이 제거되어 정상적인 개수의
participants만 반환됨. - 참고: 순서가 중요하다면
LinkedHashSet을 사용하거나, 조회 후 애플리케이션(Service) 레벨에서 정렬해야 합니다.
밑에 나오지만 더 좋은 대안이 존재합니다.
저는 현재 구조를 유지하며 최대한 빠르게 이슈를 해결해야 하는 사항이었기에 Set으로 처리를 했지만, 추후 Fetch Join 분리 & BatchSize 활용하는 방식으로 리팩토링 할 예정입니다.
💡 그 외 대안 (Best Practices)
상황에 따라 더 나은 성능이나 구조를 위해 고려할 수 있는 방법들입니다.

- Fetch Join 분리 & BatchSize 활용 (권장)
- To-One 관계만 Fetch Join으로 가져오고, 컬렉션(To-Many)은 지연 로딩(Lazy Loading)으로 둡니다.
@BatchSize설정(또는default_batch_fetch_size)을 통해IN쿼리로 한 번에 조회하여 N+1 문제를 해결합니다.
- 쿼리 분리
- 부모 엔티티 조회 쿼리와 자식 컬렉션 조회 쿼리를 명시적으로 나누어 실행합니다.
- DTO 직접 조회
- 엔티티를 거치지 않고 필요한 데이터 구조에 맞춘 DTO로 바로 조회하여 불필요한 데이터 로딩을 막습니다.
4. 마무리 및 요약
다중 컬렉션 Fetch Join은 얼핏 보면 쿼리 한 번으로 데이터를 다 가져오는 효율적인 방법 같지만, 실제로는 데이터 폭발(Cartesian Product)과 메모리 이슈를 일으키는 주범이 될 수 있습니다.
- 핵심: 2개 이상의 컬렉션에 대해 동시에
Fetch Join을 사용하는 것은 피하자. - 주의: 부득이하게 사용해야 한다면 컬렉션 타입을
Set으로 하여 중복을 방지하자. - 추천: 가장 안전하고 성능 예측이 쉬운 방법은 Fetch Join 최소화 + 지연 로딩 + BatchSize 최적화 조합이다.
'Project > YAPP 27기' 카테고리의 다른 글
| ECS(EC2) 환경에서 Grafana Cloud로 모니터링 구축하기 (Prometheus + Loki) (0) | 2026.08.04 |
|---|---|
| Terraform 상태 관리를 로컬에서 S3 + S3 Native State Locking으로 이전한 이유와 대안 비교 (0) | 2026.08.03 |
| Terraform으로 구축한 초기 서비스 인프라: 비용 최적화를 위한 단일 AZ 아키텍처 설계 과정 (0) | 2026.08.03 |