티스토리 뷰
문제 상황
동화들 중 즐겨찾기한 동화를 조회했을 때 발생한 문제입니다.
쿼리를 확인해보면
2024-09-06T17:22:14.628+09:00 DEBUG 7032 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
select
pe1_0.id,
pe1_0.birthDate,
pe1_0.imageUrl,
pe1_0.name,
pe1_0.pinNumber,
pe1_0.user_id
from
ProfileEntity pe1_0
where
pe1_0.id=?
2024-09-06T17:22:14.934+09:00 DEBUG 7032 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
/* <criteria> */ select
be1_0.id,
be1_0.coverImage,
be1_0.currentPage,
be1_0.isFavorite,
be1_0.isReading,
be1_0.profile_id,
be1_0.title
from
BookEntity be1_0
where
be1_0.profile_id=?
and be1_0.isFavorite
// 반복
2024-09-06T17:22:14.948+09:00 DEBUG 7032 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
select
se1_0.id,
se1_0.book_id,
se1_0.fontSize,
se1_0.readingSpeed
from
SettingEntity se1_0
where
se1_0.book_id=?
2024-09-06T17:22:14.956+09:00 DEBUG 7032 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
select
se1_0.id,
se1_0.book_id,
se1_0.fontSize,
se1_0.readingSpeed
from
SettingEntity se1_0
where
se1_0.book_id=?
즐겨찾기 목록을 조회하는 로직을 확인해보겠습니다.
// 즐겨찾기 책 필터링 기능 추가
public List<BookListResponseDTO> getFavoriteBooks(Integer profileId) {
ProfileEntity profile = profileRepository.findById(profileId)
.orElseThrow(() -> new ProfileNotFoundException(ErrorCode.PROFILE_NOT_FOUND));
List<BookEntity> books = bookRepository.findByProfileAndIsFavoriteTrue(profile);
return BookMapper.mapToBookListResponseDTOs(books);
}
첫 번째 쿼리는 프로필을 찾아오기 위한 쿼리(findById)
두 번째 쿼리는 즐겨찾기를 한 동화 목록을 조회하는 쿼리(findByProfileAndIsFavoriteTrue) 임을 알 수 있다.
문제는 그 이후에 반복하는 SettingEntity에서 발생하는 SELECT문인데,
왜냐면 단순히 쿼리만 봤을 때는 N + 1문제이구나 생각했지만 BookListResponseDTO를 확인해보면
@Getter
@Builder
public class BookListResponseDTO {
private Integer bookId;
private String title;
private String coverImage;
private Integer currentPage;
private Boolean isReading;
private Boolean isFavorite;
}
위 코드처럼 Setting Entity와는 관련 있는 필드를 불러오고 있지 않다?!
그렇다면 왜 N + 1 문제가 일어났던 것일까?
문제 원인
바로 결론부터 말하자면 양방향 OneToOne 관계에서 지연 로딩으로 설정했지만 적용이 안되고 즉시로딩으로 조회해서 발생하는 N + 1 문제였던거였습니다.
더 정확하게는 주인관계에서 지연 로딩이 되지만 주인이 아닌 Entity에서 조회할 때는 즉시로딩이 발생하는 문제입니다.
자 Setting Entity와 Book Entity를 확인해보겠습니다.
코드를 보면 Setting 이 연관관계의 주인이고 Book 은 연관관계의 주인이 아닙니다.
그렇기 때문에 위에서 bookRepository.findByProfileAndIsFavoriteTrue(profile); 를 통해 동화 목록을 불러오면
관련한 Setting 을 즉시로딩으로 불러와 추가적인 쿼리가 나가는 것이었습니다.
테스트해보자!
왜 이런 현상이 일어나는지 테스트 케이스를 간단하게 만들어서 확인해보겠습니다.
++ 테스트를 위해 @BeforeEach를 통해 데이터들을 생성 후
++ 정확하게 쿼리가 나가는 것을 확인하기 위해 캐싱을 사용하지 않도록 entityManager.clear()를 해주겠습니다.
@Transactional
@SpringBootTest
class BookServiceTest {
@Autowired
EntityManager entityManager;
@Autowired
JPAQueryFactory jpaQueryFactory;
@Autowired
BookRepository bookRepository;
@Autowired
SettingRepository settingRepository;
@BeforeEach
public void create_data() {
// 동화 생성
BookEntity book1 = BookEntity.builder()
.title("title1")
.coverImage("coverImage1")
.currentPage(0)
.isFavorite(false)
.isReading(false)
.build();
BookEntity book2 = BookEntity.builder()
.title("title2")
.coverImage("coverImage2")
.currentPage(0)
.isFavorite(false)
.isReading(false)
.build();
bookRepository.save(book1);
bookRepository.save(book2);
// 설정 생성
SettingEntity setting1 = SettingEntity.builder()
.fontSize(FontSize.LARGE)
.readingSpeed(ReadingSpeed.FAST)
.book(book1)
.build();
SettingEntity setting2 = SettingEntity.builder()
.fontSize(FontSize.SMALL)
.readingSpeed(ReadingSpeed.SLOW)
.book(book2)
.build();
settingRepository.save(setting1);
settingRepository.save(setting2);
entityManager.clear();
}
}
우선 먼저 연관관계의 주인인 Setting의 목록을 불러와보는 테스트입니다.
@Test
public void 양방향_OneToOne_지연로딩_주인() {
System.out.println("===== 연관관계의 주인 지연로딩 시작 ====");
QSettingEntity settingEntity = QSettingEntity.settingEntity;
List<SettingEntity> settingList = jpaQueryFactory.selectFrom(settingEntity).fetch();
settingList.stream().forEach(setting -> {
System.out.println("[설정 폰트 사이즈]: "+ setting.getFontSize());
});
System.out.println("===== 연관관계의 주인 지연로딩 끝 ====");
}
연관관계의 주인이기 때문에 우리가 설정한 지연로딩이 제대로 적용이 된 것을 볼 수 있습니다.
디버깅을 통해 좀 더 자세하게 보겠습니다.
Setting Entity와 관련된 Book Entity는 지연로딩을 위한 proxy가 들어가 있는 것을 확인 할 수 있습니다.
그렇다면 연관관계의 주인이 아닌 BookEntity의 목록을 불러오는 경우는 어떨까요?
@Test
public void 양방향_OneToOne_지연로딩_주인X() {
System.out.println("===== 연관관계의 주인X 지연로딩 시작 ====");
QBookEntity bookEntity = QBookEntity.bookEntity;
List<BookEntity> bookList = jpaQueryFactory.selectFrom(bookEntity).fetch();
bookList.stream().forEach(book -> {
System.out.println("[책 제목] " + book.getTitle());
});
System.out.println("===== 연관관계의 주인X 지연로딩 끝 ====");
}
쿼리를 확인해보면 Book을 한 번 조회하고 Setting을 N만큼 조회하는 쿼리가 발생한 것을 확인 할 수 있습니다.
이것도 상세하게 확인하기 위해 디버깅을 해보겠습니다.
이전과 다르게 proxy가 아닌 실제 Setting이 들어가 있는 것을 확인할 수 있습니다.
왜 이런 현상이 발생했을까요?
연관관계의 주인을 설정하는 이유는 데이터베이스에서 외래 키를 관리하기 위해서입니다.
현재 예시에서는 Setting이 연관관계의 주인으로 설정되어 있으므로, Setting은 데이터베이스에 있는 외래 키인 book_id를 가지고 있습니다.
주인인 Setting의 입장에서는 Setting과 연관된 Book의 외래 키를 가지고 있기 때문에, 이를 통해 연관된 Book을 프록시로 참조할 수 있습니다. 프록시 객체 덕분에 실제 데이터를 조회하지 않고 있다가 필요할 때만 데이터베이스에서 가져올 수 있습니다.
반면, 반대 입장인 Book은 Setting의 외래 키를 가지고 있지 않기 때문에 프록시로 참조할 수 없고,
따라서 Setting 값을 조회할 때 실제 값을 데이터베이스에서 가져와야 합니다.
즉, 지연 로딩으로 설정되어 있어도, 이런 특징 때문에 즉시로딩이 발생하게 됩니다.
이로 인해 위 문제처럼 N + 1 문제가 발생할 수 있습니다.
문제 해결
@OneToOne에서 양방향 매핑 지연 로딩으로 데이터를 가져오기는 간단하지는 않다.
++ Hibernate의 Bytecode Instrumentation 기능을 사용하여 LAZY 로딩 가능한 프록시 객체를 생성하는 방법도 있습니다.
추가적이 설정이 필요하고 복잡합니다. 관련한 링크를 남겨두겠습니다.
그렇기 때문에 이런 설계가 최선인지도 다시 한 번 생각해봐야 합니다.
- 구조 변경 고려
- 양방향 관계의 필요성 검토를 고민해본다. 단방향으로 수정할 수 있는지 고려합니다.
- OneToMany 또는 ManyToOne 관계로 변환할 수 있는지 고려해본다.
- 구조를 유지한채 해결하기
- 페치 조인으로 연관된 엔티티를 한 번에 조회한다.
- batch fetch size를 사용한다.
마무리
이 프로젝트에서는 양방향 연관관계의 필요성을 검토한 결과,
책(BookEntity)과 설정(SettingEntity) 간의 단방향 연관관계가 충분하다고 판단했습니다.
++ 전에는 연관관계의 주인이 설정(SettingEntity) 에 있었지만 외래 키는 책(BookEntity) 이 관리하는 것이 적절하다고 판단
수정 후 쿼리를 확인해보면 불필요한 쿼리는 더 이상 안 나가는 것을 확인할 수 있습니다.
2024-09-08T10:14:23.996+09:00 DEBUG 3292 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
select
pe1_0.id,
pe1_0.birthDate,
pe1_0.imageUrl,
pe1_0.name,
pe1_0.pinNumber,
pe1_0.user_id
from
ProfileEntity pe1_0
where
pe1_0.id=?
2024-09-08T10:14:24.001+09:00 DEBUG 3292 --- [storyteller] [nio-8080-exec-1] org.hibernate.SQL :
/* <criteria> */ select
be1_0.id,
be1_0.coverImage,
be1_0.currentPage,
be1_0.isFavorite,
be1_0.isReading,
be1_0.profile_id,
be1_0.setting_id,
be1_0.title
from
BookEntity be1_0
where
be1_0.profile_id=?
and be1_0.isFavorite
Reference
https://devlog-wjdrbs96.tistory.com/432
https://gmoon92.github.io/spring/jpa/hibernate/n+1/2021/01/12/jpa-n-plus-one.html
'TIL' 카테고리의 다른 글
내가 원하는 컬럼으로만 업데이트 (2) | 2024.08.24 |
---|---|
JPA에서 save할 때 select 쿼리가 나가는 이유? (0) | 2024.08.23 |
Docker란? (0) | 2024.08.19 |
쿠키, 세션, 토큰 어떤 차이일까? (0) | 2024.04.23 |
- N + 1
- @ManyToOne
- @Entity
- @Cacheable
- 인메모리 db
- 연관관계
- 엔티티 매니저
- @OneToMany
- 변경감지
- 영속성 컨텍스트
- @GeneratedValue
- mappedBy
- 최적화
- 비영속
- 준영속
- 비동기
- 단일 테이블 전략
- @TransactionalEventListener
- @MappedSuperclass
- 메일
- 조인 전략
- 즉시 로딩
- onetoone
- @joincolumn
- @Table
- 1차 캐시
- Redis
- JPA
- 스키마 자동 생성
- @Id