JPA JOIN FETCH 와 Optional 조합 시 발생하는 NonUniqueResult 오류 해결

J_log·2025년 7월 5일
0
post-thumbnail

문제 상황

Spring Boot 프로젝트에서 날짜별 콘텐츠를 조회하는 기능을 구현하던 중, 다음과 같은 오류가 발생했다.

Query did not return a unique result: 2 results were returned

처음에는 동일한 날짜에 여러 개의 레코드가 존재하는 것으로 생각했다. 하지만 MySQL에서 직접 쿼리를 실행해보니 예상과 달랐다.

문제가 된 코드

Repository

@Query("SELECT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2")
Optional<EmailContent> findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);

Controller

LocalDate targetDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd"));
LocalDateTime startOfDay = targetDate.atStartOfDay();        // 2025-07-05T00:00:00
LocalDateTime endOfDay = targetDate.atTime(23, 59, 59);     // 2025-07-05T23:59:59

Optional<EmailContent> content = emailContentRepository.findByCreatedDateBetween(startOfDay, endOfDay);

MySQL 직접 확인 결과

-- 날짜별 레코드 수 확인
SELECT DATE(created_at) as date, COUNT(*) as count 
FROM email_content 
GROUP BY DATE(created_at) 
HAVING count > 1;
-- 결과: Empty set

-- 해당 날짜의 데이터 확인
SELECT count(1) 
FROM email_content e 
JOIN content_theme t ON t.id = e.theme_id 
WHERE e.created_at BETWEEN '2025-07-05 00:00:00' AND '2025-07-05 23:59:59';
-- 결과: 1 (정상)

MySQL에서는 1개의 결과만 나오는데, 왜 JPA에서는 2개 결과가 반환된다고 하지?

원인 분석

JPA JOIN FETCH의 특성

문제는 JPA의 JOIN FETCH와 Optional 조합에서 발생했다.

  • JOIN FETCH는 연관된 엔티티를 즉시 로딩하기 위한 JPA 최적화 기법
  • 하지만 Optional과 함께 사용할 때, JPA 구현체 (Hibernate)의 내부 처리 과정에서 중복 결과로 인식하는 경우가 있음
  • 실제 DB에서는 1개 행 이지만, JPA 레벨에서는 2개로 인식하는 상황

Hibernate 로그 분석

Hibernate: 
    select
        ec1_0.id,
        ec1_0.created_at,
        ec1_0.detailed_content,
        ec1_0.html_content,
        ec1_0.sent,
        ec1_0.sent_at,
        t1_0.id,
        t1_0.jlptlevel,
        t1_0.topic,
        t1_0.used,
        t1_0.used_at 
    from
        email_content ec1_0 
    join
        content_theme t1_0 
            on t1_0.id=ec1_0.theme_id 
    where
        ec1_0.created_at between ? and ?

SQL 자체는 정상적이지만, JPA가 결과를 Optional로 변환하는 과정에서 문제가 발생한다.

해결 방법

1. DISTINCT 추가

@Query("SELECT DISTINCT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2")
Optional<EmailContent> findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);

왜 DISTINCT가 해결책?

  • JPA에서 JOIN FETCH를 사용할 때 권장되는 패턴
  • 중복 결과 제거로 Optional 안전하게 동작

2. 반환 타입 변경

@Query("SELECT e FROM EmailContent e JOIN FETCH e.theme WHERE e.createdAt BETWEEN ?1 AND ?2")
List<EmailContent> findByCreatedDateBetween(LocalDateTime startDate, LocalDateTime endDate);

// Controller에서 사용
List<EmailContent> contents = emailContentRepository.findByCreatedDateBetween(startOfDay, endOfDay);
Optional<EmailContent> content = contents.isEmpty() ? Optional.empty() : Optional.of(contents.get(0));

3. JOIN FETCH 제거

Optional<EmailContent> findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);

연관된 엔티티는 지연 로딩으로 처리하고, 필요시 별도 조회


TIL

  1. JPA와 실제 DB 동작의 차이 : MySQL에서 정상 동작하는 쿼리라도 JPA 레벨에서는 다른 결과가 나올 수 있다.
  2. JOIN FETCH + Optional 조합 주의 : 이 조합은 NonUniqueResult 오류를 발생시킬 수 있으므로 DISTINCT를 함께 사용하는 것이 안전하다.
  3. 디버깅 접근법 :
    • 먼저 DB에서 직접 쿼리 실행
    • Hibernate 로그 확인
    • JPA 내부 동작 원리 이해
  4. Best Practice : JPA에서 JOIN FETCH를 사용할 때는 항상 DISTINCT를 고려하자.

단순해 보이는 조회 기능이지만, JPA의 내부 동작을 이해하지 못하면 예상치 못한 오류에 직면할 수 있다.
이번 경험을 통해 ORM의 추상화 뒤에 숨겨진 복잡성을 다시 한번 깨달았다.

0개의 댓글