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, 20LIMIT 20, 20이처럼 Pageable 객체는 내부적으로 offset과 limit 값을 계산해 동적으로 바인딩하며, 이는 다음과 같은 장점을 가집니다:
SQL 인젝션 방지: 값이 쿼리문 안에 직접 들어가지 않음
쿼리 계획 캐싱: 동일한 쿼리 구조는 DB에서 재사용 가능
성능 및 유지보수성 향상: 파라미터만 바꾸면 쿼리를 반복 재사용 가능
예전에 CS 면접 공부를 하면서 막연하게 이해했던 Prepared Statement의 개념을 훨씬 더 명확하게 체감할 수 있었습니다.
이번 개선으로 인한 효과는 다음과 같습니다:
쿼리 수 감소:
데이터 일관성 향상: