1. 문제 상황
게시글(board)과 게시글통계정보(boardStatistic) 1:1 양방향 연관관계로 설계된 상황이다.
연관관계의 주인은 boardStatistic이며, board는 mappedBy를 통해 참조만 하는 구조이다.
로딩 전략은 모두 LAZY로 설정되어 있다.
@Table(name = "board")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "board", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private BoardStatistic boardStatistic;
// ..
}
@Entity
@Table(name = "board_statistic")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardStatistic {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Board board;
// ..
}
예상했던 동작
Board를 페이징 조회할 때, BoardStatistic은 LAZY로 설정되어 있기에, 실제 board.getBoardStatistic()을 호출하기 전까지 boardStatistic을 조회하는 쿼리가 발생하지 않을 것으로 예상했다.
Page<Board> findAll(Pageable pageable);
실제 동작(N+1 발생)
쿼리 로그를 확인해보니, 의도와 다르게 Board를 한 건마다 BoardStatistic을 조회하는 쿼리가 발생하는 것이었다.
board가 20개라면, boardStatistic 조회하는 쿼리가 20개 발생될 수 있는 상황이다.
-- 1. Board 페이징 조회
select * from board b1_0 order by b1_0.created_at desc limit ?
-- 2. 각 Board마다 BoardStatistic을 개별 조회 (N+1 발생!)
select * from board_statistic bs1_0 where bs1_0.board_id = ?
select * from board_statistic bs1_0 where bs1_0.board_id = ?
select * from board_statistic bs1_0 where bs1_0.board_id = ?
내가 평소 알고 있던, JPA 지식과 실제 동작에서 차이가 나는 지점은 2가지이다.
- Lazy로 설정했음에도, 사용여부와 상관없이 연관 엔티티를 조회하는 것일까?
- default_batch_fetch_size = 100으로 설정했음에도, 왜 IN쿼리가 아닌 단건 조회가 나가는 것일까?
2. 원인 파악
왜 Lazy 설정을 무시하고 즉시 쿼리가 나가는걸까?
이유는 JPA가 사용하는 프록시 객체의 한계 때문이다.
연관관계의 주인인 boardStatstic은 board_id(FK)를 직접 가지기에 Board의 존재 여부를 즉시 알 수 있다.
하지만, board는 boardStatstic이 나를 참조하고 있는지에 대한 정보를 전혀 갖고 있지 않다.
jpa 입장에서는 board를 조회할 때 boardStaistic 필드에 프록시를 넣어야 할지, null을 넣어야 할지 결정해야 하는데, board 테이블만 봐서 알 방법이 없다.
결국 jpa는 존재 여부를 확인하기 위해 boardStatistic을 직접 조회하게 되고, 그로 인해 LAZY 설정이 무시된 것이다.
그럼 왜 IN 쿼리는 안나가고, 단건 조회로 N번만큼 조회하는걸까?
default_batch_fetch_size는 이미 생성된 프록시들을 한꺼번에 초기화할 때 사용되는 설정이다.
현재 상황은 프록시 자체를 생성할 수 있는지 (null 여부) 를 확인하려고 쿼리를 날리는 단계로, 해당 설정과 무관한 것이었다.
3. 해결 방안
내가 생각한 방안은 3가지이다.
1. Fetch Join 또는 @EntityGraph 사용
board를 조회할 때 boardStatistic을 함께 조회하도록 설정하는 방법이다. 1번의 쿼리로 데이터를 모두 조회할 수 있다.
하지만, 구조적인 문제를 고치지 않으면, board를 조회하는 모든 메서드에서 해당 설정을 추가해줘야 하므로 매우 번거롭다.
@EntityGraph(attributePaths = {"boardStatistic"})
Page<Board> findAll(Pageable pageable);
select
b1_0.id,
-- ... Board의 다른 컬럼들,
-- ... BoardStatistic의 컬럼들
from
board b1_0
left join
board_statistic bs1_0
on
b1_0.id = bs1_0.board_id
order by
b1_0.created_at desc
limit ?
2. 연관관계 주인(FK) 위치 수정
비즈니스 로직상 board에서 boardStatistic을 조회하는 빈도가 훨씬 높다면, DB설계를 변경하여 board 테이블이 board_statistic_id(FK)를 갖도록 설계를 변경하는 방법이다.
이렇게 하면, board 테이블에 FK가 존재하기에, JPA는 DB 조회시점에 해당 컬럼이 null인지 아닌지 바로 알 수 있으므로 불필요한 쿼리가 발생하지 않는다.
하지만 게시글통계가 게시글의 종속적인 구조이기에 DB관점에서 boardStatistic이 board_id(FK)를 갖는 것이 더 자연스럽다.
DBA가 별로 좋아하지 않을 것 같다..
3. 양방향 관계 고민
이 방법이 가장 근본적인 해결책인 것 같다.
양방향 매핑을 포기하는 것이다.
양방향 매핑을 사용하면 객체 그래프 탐색이 가능해 로직이 깔끔하고 관계가 명확해보이는 장점은 있지만, 이번 케이스처럼 쿼리 예측이 어렵다는 치명적인 단점이 있다.
단점이 매우 치명적이기에 양방향 매핑은 매우 신중하게 접근해야 한다.
지금 같은 게시글 - 게시글통계 구조에서 굳이 양방향 매핑을 유지해 예상치 못한 쿼리가 발생하여 성능이 저하되거나, 매번 fetch join 등을 고민하기 보다는, 단방향으로 끊어내고 필요한 시점에만 따로 조회하는 것이 훨씬 안전하고 깔끔한 설계라고 생각한다.
@Table(name = "board")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
@Entity
@Table(name = "board_statistic")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardStatistic {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Board board;
// ..
}
'Programming > Spring' 카테고리의 다른 글
| [JPA] JPA Soft Delete 지원하는 여러 방식들에 대한 고민(@SoftDelete, @SqlDelete, @SQLRestriction) (0) | 2026.01.04 |
|---|---|
| [Spring] Filter vs Interceptor 차이 (0) | 2025.10.20 |
| [Spring] Spring MVC에서 Pageable 파라미터가 동작하는 방식 (1) | 2025.09.16 |
| [Spring Security] SecurityFilterChain 동작 분석 - Logout 요청 시 AuthenticationEntryPoint 미호출 문제 (3) | 2025.08.13 |
| [Spring 테스트] Mockito ArguementCaptor란? (1) | 2025.08.10 |