https://github.com/MoonBaar/moon-baar-backend/pull/43
발자국 지도 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번의 쿼리가 실행되는 상황이었습니다.
보통 N+1 문제를 해결할 때 join fetch를 사용합니다. 그런데 정보를 찾아보던 중 JPA에서 페이징과 fetch join을 함께 사용할 수 없다는 블로그 글을 발견하고 @BatchSize
를 적용하려 했습니다.
Join Fetch
사용: 연관 엔티티를 한 번의 쿼리로 함께 조회EntityGraph
사용: 연관 관계를 명시적으로 정의하여 함께 로딩@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 절
?
이유는 LikedEvent와 CulturalEvent 사이의 관계가 N:1
이기 때문이었습니다.
기준이 되는 엔티티를 왜곡
시킵니다.HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
좋아요 목록 조회의 경우 N:1
관계이기 때문에 간단하게 join fetch
를 사용해서 N+1 문제를 해결할 수 있었습니다.
join fetch
는 기본적으로 INNER JOIN
이 적용됩니다.
그런데 1:N
관계에서 페이징이 제대로 적용되지 않았던 이유가 row 수가 많아지는 것이었다면, 반대로 INNER JOIN
중 조인 대상인 자식 테이블의 데이터가 삭제되어 일부 row가 빠지는 경우에도 페이징 결과가 왜곡되지 않을까 하는 의문이 생겼습니다.
이런 고민 끝에, 혹시 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"
))
이전 프로젝트에서는 @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"
))
JPA Cascade:
ManyToOne
에서 cascade를 사용하면 N → 1 방향으로 작용 (좋아요 삭제 시 이벤트도 삭제됨 - 의도하지 않은 동작)DB 외래 키 제약조건:
limit ?
와 동적 SQL 파라미터Hibernate 쿼리 로그를 보면 다음과 같은 형태의 SQL이 출력되는 경우가 있습니다:
select ... from liked_event limit ?
처음엔 limit 뒤에 숫자가 바로 보이지 않아, 쿼리가 제대로 생성되지 않은 것이 아닐까? 하는 의문이 들었습니다.
하지만 이는 Hibernate가 Prepared Statement(준비된 문장) 를 사용하기 때문에 나타나는 현상이라는 것을 알게 되었습니다.
하지만 이는 Hibernate가 Prepared Statement(준비된 문장) 를 사용하기 때문에 나타나는 현상이라는 것을 알게 되었습니다.
Prepared Statement는 쿼리문을 미리 컴파일해두고, 실행 시점에 필요한 값(예: 페이징 정보)을 바인딩합니다.
즉, limit ?에서 ?는 실행 시점에 다음과 같이 바뀝니다:
offset = pageNumber * pageSize
limit = pageSize
예를 들어:
LIMIT 0, 20
LIMIT 20, 20
이처럼 Pageable 객체는 내부적으로 offset
과 limit
값을 계산해 동적으로 바인딩하며, 이는 다음과 같은 장점을 가집니다:
SQL 인젝션 방지
: 값이 쿼리문 안에 직접 들어가지 않음
쿼리 계획 캐싱
: 동일한 쿼리 구조는 DB에서 재사용 가능
성능 및 유지보수성 향상
: 파라미터만 바꾸면 쿼리를 반복 재사용 가능
예전에 CS 면접 공부를 하면서 막연하게 이해했던 Prepared Statement
의 개념을 훨씬 더 명확하게 체감할 수 있었습니다.
이번 개선으로 인한 효과는 다음과 같습니다:
쿼리 수 감소:
데이터 일관성 향상: