[문화발자국 개발기] 페이징과 join fetch에 대한 공부

오젼·2025년 4월 21일
0

https://github.com/MoonBaar/moon-baar-backend/pull/43

1. N+1 문제 발견과 원인 분석

발자국 지도 API를 구현하며 좋아요 목록 조회 API에서도 N+1 문제가 발생하고 있음을 확인했습니다.

Hibernate:        -- 1번의 쿼리
    select
        u1_0.id,
        ...
    from
        users u1_0 
    where
        u1_0.id=?
Hibernate: 
    select
        le1_0.id,
        ...
    from
        liked_events le1_0 
    where
        le1_0.user_id=? 
    order by
        le1_0.created_at desc 
    limit
        ?
Hibernate:        -- N번 반복되는 쿼리
    select
        ce1_0.id,
        ...
    from
        cultural_events ce1_0 
    where
        ce1_0.id=?
Hibernate: 
    select
        ce1_0.id,
        ...
    from
        cultural_events ce1_0 
    where
        ce1_0.id=?
...

이는 LikedEvent 엔티티를 조회할 때 LAZY로 페치 타입이 설정된 CulturalEvent를 프록시 객체로 가지고 있다가, 실제로 이벤트 정보가 필요한 시점에 추가적인 쿼리가 발생하기 때문이었습니다. 20개의 좋아요 항목이 있다면 총 21번의 쿼리가 실행되는 상황이었습니다.

2. 일반적인 해결책과 고려사항

보통 N+1 문제를 해결할 때 join fetch를 사용합니다. 그런데 정보를 찾아보던 중 JPA에서 페이징과 fetch join을 함께 사용할 수 없다는 블로그 글을 발견하고 @BatchSize를 적용하려 했습니다.

  1. Join Fetch 사용: 연관 엔티티를 한 번의 쿼리로 함께 조회
  2. EntityGraph 사용: 연관 관계를 명시적으로 정의하여 함께 로딩
  3. @BatchSize 적용: IN 절을 사용해 여러 엔티티를 배치로 조회

이렇게 차례대로 해결법을 적용하고 결과를 비교하려 했으나, 예상과 달리 join fetch를 적용해보니 바로 정상적으로 작동했습니다:

@Query("SELECT l FROM LikedEvent l JOIN FETCH l.event WHERE l.user = :user")
Page<LikedEvent> findByUserWithEventFetchJoin(@Param("user") User user, Pageable pageable);
select        -- 단 1번의 조회
    le1_0.id,
    le1_0.created_at,
    le1_0.event_id,
    e1_0.id,
    /* 다른 필드들 생략 */
from
    liked_events le1_0 
join
    cultural_events e1_0 
        on e1_0.id=le1_0.event_id 
where
    le1_0.user_id=? 
order by
    le1_0.created_at desc 
limit        -- 올바른 limit 절
    ?

3. 연관관계에 따라 JPA의 페이징과 Join Fetch의 가능 여부가 달라진다

이유는 LikedEvent와 CulturalEvent 사이의 관계가 N:1이기 때문이었습니다.

1:N 관계(OneToMany)에서의 페이징과 Fetch Join

  • Post와 Comment 같은 1:N 관계에서 JOIN을 하면 결과 row 수가 N의 개수만큼 증가합니다.
  • 예: Post 10개가 각각 평균 5개의 Comment를 가지면 JOIN 결과는 약 50개 row가 됩니다.
  • 이는 페이징을 적용할 기준이 되는 엔티티를 왜곡시킵니다.
  • 여러 row가 동일한 Post를 반복해서 나타내게 되면, JPA는 "Post 10개를 가져온다"는 기준을 제대로 적용할 수 없게 됩니다.
  • 그래서 Hibernate는 이 경우 DB 레벨에서 페이징을 하지 못하고, 모든 결과를 메모리에 올린 후 애플리케이션에서 페이징을 강제로 수행합니다.
  • 이때 다음과 같은 경고 로그가 출력됩니다:
    HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
  • 결과적으로 페이징 성능 저하와 예상치 못한 결과가 발생할 수 있습니다.
    image

N:1 관계(ManyToOne)에서의 페이징과 Fetch Join

  • 반대로 LikedEvent와 CulturalEvent처럼 다수의 엔티티가 하나의 엔티티를 참조하는 N:1 관계에서는 상황이 다릅니다.
  • LikedEvent는 여러 개가 있을 수 있지만, 각각의 CulturalEvent는 하나만 참조하므로, JOIN FETCH를 해도 결과 row 수는 LikedEvent의 개수와 동일하게 유지됩니다.
  • 즉, 하나의 LikedEvent는 하나의 CulturalEvent를 참조하므로 중복 row가 발생하지 않습니다.
  • 이처럼 페이징의 기준이 되는 엔티티(LikedEvent)의 row 수가 변하지 않기 때문에, 정확한 DB 수준의 페이징(LIMIT/OFFSET) 이 가능하고 성능 문제도 발생하지 않습니다.
    image

좋아요 목록 조회의 경우 N:1 관계이기 때문에 간단하게 join fetch를 사용해서 N+1 문제를 해결할 수 있었습니다.

4. join fetch vs left join fetch에 대한 고민

join fetch는 기본적으로 INNER JOIN이 적용됩니다.
그런데 1:N 관계에서 페이징이 제대로 적용되지 않았던 이유가 row 수가 많아지는 것이었다면, 반대로 INNER JOIN 중 조인 대상인 자식 테이블의 데이터가 삭제되어 일부 row가 빠지는 경우에도 페이징 결과가 왜곡되지 않을까 하는 의문이 생겼습니다.

이런 고민 끝에, 혹시 LEFT JOIN FETCH를 사용하면 안정적으로 페이징이 가능하지 않을까? 라는 생각으로 다음과 같은 쿼리를 시도했습니다:

  1. LEFT JOIN FETCH 사용하기:
    @Query("SELECT l FROM LikedEvent l LEFT JOIN FETCH l.event WHERE l.user = :user")

이 방식이라면 이벤트가 삭제되어도 좋아요(LikedEvent)는 남아 있으므로, 전체 좋아요 목록을 빠짐없이 가져올 수 있다고 판단했습니다.
하지만 결과적으로 삭제된 CulturalEvent를 참조하는 좋아요도 함께 조회되는 문제가 발생했고, 이는 사용자 입장에서 존재하지 않는 이벤트에 좋아요를 눌렀다는 이상한 상황이 되어버립니다.

처음엔 페이징 때문에 left join을 고려했지만, 이 문제를 보고 나니 핵심은 join 방식이 아니라, 이벤트가 삭제되었을 때 관련 좋아요도 자동으로 삭제되도록 처리하는 게 맞다는 걸 깨달았습니다.

그래서 결국 다음과 같이 DB 수준에서 외래키 제약조건에 ON DELETE CASCADE를 적용하는 방식으로 문제를 해결했습니다:

@JoinColumn(name = "event_id", nullable = false, 
           foreignKey = @ForeignKey(
               name = "fk_liked_event_event_id",
               foreignKeyDefinition = "FOREIGN KEY (event_id) REFERENCES cultural_events(id) ON DELETE CASCADE"
           ))

5. JPA Cascade와 데이터베이스 제약조건에 대한 공부

이전 프로젝트에서는 @OneToMany(cascade=CascadeType.DELETE)와 같이 JPA 수준에서 제약 조건을 설정했었습니다.

그래서 @ManyToOne(cascade=CascadeType.DELETE)로 설정할 수 있지 않을까 생각했는데, 이는 의도와 맞지 않는 방법이었습니다. @ManyToOne(cascade=CascadeType.DELETE)를 설정하면 N에서 1 방향으로 작용하기 때문에, 좋아요가 삭제될 때 이벤트도 함께 삭제되는 제약조건이 적용됩니다.

따라서 LikedEvent에 DB 레벨의 외래키 제약조건을 적용했습니다.

// JPA Cascade 방식 (애플리케이션 레벨)
@ManyToOne(cascade = CascadeType.REMOVE)
private CulturalEvent event;

// 데이터베이스 제약조건 방식 (DB 레벨)
@JoinColumn(foreignKey = @ForeignKey(
    foreignKeyDefinition = "FOREIGN KEY (event_id) REFERENCES cultural_events(id) ON DELETE CASCADE"
))
  1. JPA Cascade:

    • 애플리케이션(Java) 레벨에서 동작
    • JPA/Hibernate를 통한 작업에만 적용됨
    • ManyToOne에서 cascade를 사용하면 N → 1 방향으로 작용 (좋아요 삭제 시 이벤트도 삭제됨 - 의도하지 않은 동작)
  2. DB 외래 키 제약조건:

    • 데이터베이스 레벨에서 동작
    • 모든 데이터 조작에 적용 (JPA 외의 다른 애플리케이션, 직접 SQL 실행 등)
    • 관계의 주인에 상관없이 참조 방향으로 작용 (이벤트 삭제 시 좋아요도 삭제됨 - 의도한 동작)

6. 쿼리 로그의 limit ?와 동적 SQL 파라미터

Hibernate 쿼리 로그를 보면 다음과 같은 형태의 SQL이 출력되는 경우가 있습니다:

select ... from liked_event limit ?

처음엔 limit 뒤에 숫자가 바로 보이지 않아, 쿼리가 제대로 생성되지 않은 것이 아닐까? 하는 의문이 들었습니다.
하지만 이는 Hibernate가 Prepared Statement(준비된 문장) 를 사용하기 때문에 나타나는 현상이라는 것을 알게 되었습니다.

하지만 이는 Hibernate가 Prepared Statement(준비된 문장) 를 사용하기 때문에 나타나는 현상이라는 것을 알게 되었습니다.

Prepared Statement는 쿼리문을 미리 컴파일해두고, 실행 시점에 필요한 값(예: 페이징 정보)을 바인딩합니다.
즉, limit ?에서 ?는 실행 시점에 다음과 같이 바뀝니다:

offset = pageNumber * pageSize
limit = pageSize

예를 들어:

  • 페이지 크기 20, 페이지 번호 0(첫 페이지): LIMIT 0, 20
  • 페이지 크기 20, 페이지 번호 1(두번째 페이지): LIMIT 20, 20

이처럼 Pageable 객체는 내부적으로 offsetlimit 값을 계산해 동적으로 바인딩하며, 이는 다음과 같은 장점을 가집니다:

SQL 인젝션 방지: 값이 쿼리문 안에 직접 들어가지 않음
쿼리 계획 캐싱: 동일한 쿼리 구조는 DB에서 재사용 가능
성능 및 유지보수성 향상: 파라미터만 바꾸면 쿼리를 반복 재사용 가능

예전에 CS 면접 공부를 하면서 막연하게 이해했던 Prepared Statement의 개념을 훨씬 더 명확하게 체감할 수 있었습니다.

영향 및 성능 개선 효과

이번 개선으로 인한 효과는 다음과 같습니다:

  1. 쿼리 수 감소:

    • N개의 좋아요 항목 조회 시: N+1개 쿼리 → 1개 쿼리
  2. 데이터 일관성 향상:

    • 이벤트 삭제 시 관련 좋아요 자동 삭제로 항상 유효한 데이터만 제공
    • "좋아요했지만 이미 삭제된 이벤트" 같은 혼란스러운 상태 방지

0개의 댓글