직장을 다니면서 기존에 개발했던 프로젝트를 리펙토링 하고 디벨롭하기는 체력도 시간도 되지 않아 입사 초 때는 해야 할 일을 밀어두다가 요즘 다시 예전 기억을 떠올리며, 시간이 될 때 마이브러리 옛날 코드를 보면서 조금씩 천천히 디벨롭해야겠다는 생각이 들었다.
가장 마지막에 개발했던 추천 피드를 개발할 때, @OneToOne에서의 N+1에 대해서 기록하고자 한다.
(현 직장에서는 JPA를 사용하지 않고 Mabatis를 사용하기 때문에 JPA에 대해서 많이 까먹은 부분이 많았다.. )
먼저 @OneToOne 에서의 N+1 가 발생했던 스토리에 대해서 소개하자면..
현재는 마이브러리에서 "추천 피드"를 조회하는 QueryDsl는 아래와 같다. 조금 복잡하고 보완해야 할 점이 많지만, 이 QueryDsl를 작성하기까지 여러 어려움이 있었다.
- 사용하는 칼럼 값만 가져오기, transform & groupBy
- 여러 테이블의 Left Join
- NoOffset 페이징 쿼리
- Left Join과 페이징 쿼리의 데이터 중복 문제 발생
- ...
그래서 여러 방법으로 도전해보고 했지만, 지금까지는 아래 코드가 나에게 최선이었다. 분명 더 좋은 방법이 있을 거지만.. 다음에 해당 쿼리를 완성하기까지의 과정을 포스팅을 하려고 한다.
(혹시라도 피드백이 있다면 언제든지 환영합니다.! 더 나이스한 방법을 알고 싶습니다..)
@Override
public List<RecommendationFeedViewAllModel> getRecommendationFeedViewAll(Long recommendationFeedId, int pageSize, String userId) {
List<RecommendationFeedViewAllModel> models = queryFactory.select(fields(RecommendationFeedViewAllModel.class,
recommendationFeed.id.as("recommendationFeedId"),
recommendationFeed.content.as("content"),
recommendationFeed.userId.as("userId"),
myBook.id.as("myBookId"),
book.id.as("bookId"),
book.title.as("title"),
book.isbn13.as("isbn13"),
book.thumbnailUrl.as("thumbnailUrl"),
book.holderCount.as("holderCount"),
book.interestCount.as("interestCount"),
book.authors.as("bookAuthors")
))
.from(recommendationFeed)
.where(ltRecommendationFeedId(recommendationFeedId))
.orderBy(recommendationFeed.id.desc())
.limit(pageSize)
.join(recommendationFeed.myBook, myBook).on(recommendationFeed.myBook.id.eq(myBook.id))
.join(recommendationFeed.myBook.book, book).on(recommendationFeed.myBook.book.id.eq(book.id))
.fetch();
List<Long> recommendationFeedIds = models.stream()
.map(RecommendationFeedViewAllModel::getRecommendationFeedId)
.toList();
Map<Long, List<RecommendationTargetModel>> recommendationTargetModelMap = queryFactory
.select(
fields(RecommendationTargetModel.class,
recommendationTarget.id.as("targetId"),
recommendationTarget.targetName.as("targetName")
)
).from(recommendationTarget)
.where(recommendationTarget.recommendationFeed.id.in(recommendationFeedIds))
.transform(groupBy(recommendationTarget.recommendationFeed.id)
.as(list(fields(RecommendationTargetModel.class,
recommendationTarget.id.as("targetId"),
recommendationTarget.targetName.as("targetName")
))));
Set<Long> interestedBookIdSet = queryFactory.select(book.id).from(bookInterest)
.where(bookInterest.userId.eq(userId))
.transform(groupBy(book.id).as(set(book.id))).keySet();
models.forEach(model -> {
model.setRecommendationTargets(recommendationTargetModelMap.getOrDefault(model.getRecommendationFeedId(), List.of()));
model.setInterested(interestedBookIdSet.contains(model.getBookId()));
});
return models;
}
암튼, 본론으로 들어가서 전체적인 쿼리를 작성하기 전에 "사용할 테이블 + 페이징 쿼리 + Join"을 조합해서 정말 나이브하게 아래와 같이 QueryDsl을 작성하고 테스트 코드를 작성하여, 반환되는 SQL을 살펴보았다.
@Override
public List<RecommendationFeedViewAllModel> test(Long recommendationFeedId, int pageSize, String userId) {
List<RecommendationFeed> feeds = queryFactory.selectFrom(recommendationFeed)
.join(recommendationFeed.myBook, myBook).fetchJoin()
.join(recommendationFeed.myBook.book, book)
.leftJoin(recommendationTarget).on(recommendationFeed.id.eq(recommendationTarget.recommendationFeed.id))
.leftJoin(bookAuthor).on(bookAuthor.book.id.eq(book.id))
.join(author).on(bookAuthor.author.id.eq(author.id))
.groupBy(recommendationFeed.id)
.where(ltRecommendationFeedId(recommendationFeedId))
.orderBy(recommendationFeed.id.desc())
.limit(pageSize)
.fetch();
return RecommendationFeedViewAllModel.of(feeds);
}
> 사용했던 테이블
각오했지만, 아래와 같이 말도 안 되는 SQL이 발생했다.... 페이징 쿼리가 됐든, Left Join 이 됐든 중요하지 않고, 왜 N+1 문제가 발생했는지 궁금했다..
Hibernate:
select
r1_0.id,
r1_0.content,
r1_0.created_at,
m1_0.id,
m1_0.book_id,
m1_0.created_at,
m1_0.deleted,
m1_0.exchangeable,
m1_0.read_status,
m1_0.shareable,
m1_0.showable,
m1_0.start_date_of_possession,
m1_0.updated_at,
m1_0.user_id,
r1_0.updated_at,
r1_0.user_id
from
recommendation_feed r1_0
join
my_book m1_0
on m1_0.id=r1_0.my_book_id
and (
m1_0.deleted = false
)
join
books b1_0
on b1_0.id=m1_0.book_id
left join
recommendation_target r2_0
on r1_0.id=r2_0.recommendation_feed_id
left join
books_authors b2_0
on b2_0.book_id=m1_0.book_id
join
authors a1_0
on b2_0.author_id=a1_0.id
group by
r1_0.id
order by
r1_0.id desc fetch first ? rows only
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.meaning_tag_id,
m1_0.meaning_tag_color,
m1_0.my_book_id,
m1_0.updated_at
from
my_book_meaning_tag m1_0
where
m1_0.my_book_id=?
Hibernate:
select
m1_0.id,
m1_0.book_id,
m1_0.content,
m1_0.created_at,
m1_0.my_book_id,
m1_0.star_rating,
m1_0.updated_at
from
my_review m1_0
where
m1_0.my_book_id=?
Hibernate:
select
b1_0.id,
b1_0.aladin_review_count,
b1_0.aladin_star_rating,
b1_0.author,
b1_0.book_category_id,
b1_0.created_at,
b1_0.description,
b1_0.holder_count,
b1_0.interest_count,
b1_0.isbn10,
b1_0.isbn13,
b1_0.link,
b1_0.pages,
b1_0.price_sales,
b1_0.price_standard,
b1_0.publication_date,
b1_0.publisher,
b1_0.read_count,
b1_0.recommendation_feed_count,
b1_0.review_count,
b1_0.size_depth,
b1_0.size_height,
b1_0.size_width,
b1_0.star_rating,
b1_0.sub_title,
b1_0.thumbnail_url,
b1_0.title,
b1_0.toc,
b1_0.updated_at,
b1_0.weight
from
books b1_0
where
array_contains(?,b1_0.id)
Hibernate:
select
f1_0.recommendation_feed_id,
f1_0.id,
f1_0.created_at,
f1_0.target_name,
f1_0.updated_at
from
recommendation_target f1_0
where
array_contains(?,f1_0.recommendation_feed_id)
Hibernate:
select
b1_0.book_id,
b1_0.id,
b1_0.author_id,
b1_0.created_at,
b1_0.updated_at
from
books_authors b1_0
where
array_contains(?,b1_0.book_id)
Hibernate:
select
a1_0.id,
a1_0.aid,
a1_0.created_at,
a1_0.name,
a1_0.updated_at
from
authors a1_0
where
array_contains(?,a1_0.id)
내려도 내려도 끝이 없다.. ㅎㅎ (N+1 이 두 번 발생했다..)
N+1 문제가 발생한 엔티티는 MyBookMeaningTag와 MyReview였고, 다음과 같은 생각을 했다.
- @OneToOne의 Default Fetch 전략은 FetchType.EAGER이니 FetchType.LAZY로는 잘 설정했다.
- Fetch Join을 실제 쿼리에서 사용하지 않았으니, 해당 엔티티는 지연 로딩으로 가져오게 되고 사용하는 시점에 추가 쿼리가 발생할 것이라 생각했다.
- 테스트 코드에서도 해당 엔티티를 사용하지 않고 조회만 했는데도, 즉시 로딩이 발생하여 N+1 문제가 발생한다.
MyBook과 MyBookMeaningTag와 MyReview는 일다일 관계이며, MyBookMeaningTag와 MyReview가 연관관계의 주인이다. (MyBook은 MyBookMeaningTag와 MyReview가 있을수도 있고 없을 수도 있기 때문이다.)
public class MyBook extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
@ManyToOne(fetch = FetchType.LAZY)
private Book book;
@Enumerated(EnumType.STRING)
private ReadStatus readStatus;
@OneToOne(mappedBy = "myBook", fetch = FetchType.LAZY)
private MyBookMeaningTag myBookMeaningTag;
@OneToOne(mappedBy = "myBook", fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.REMOVE)
private MyReview myReview;
...
}
public class MyBookMeaningTag extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
private MyBook myBook;
...
}
음.. @OneToOne 의 N+1 문제는 흔한 문제이고, 주의해야 할 점이었다. 구글링만 해도 쉽게 원인을 찾을 수 있었다.
(사실 취준할 때, 글로만 이해하고 알았던 문제였는데 실제로 문제 상황에 부딪히니 생각이 않았던 것 같다..)
막간의 개념 정리
* JPA 프록시 : JPA에서 프록시는 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체. 프록시 객체는 실제 객체에 대한 참조를 가지고 있기에 프록시 객체의 메서드를 호출하면 프록시 객체는 참조를 통해 메서드 호출을 위임하고 실제 객체의 메서드를 호출하게 된다.
* 즉시 로딩 : 엔티티를 조회할 때 연관관계에 있는 엔티티도 함께 조회하는 방법
+ 즉시 로딩 실행 SQL에서 JPA 가 INNER JOIN이 아닌 LEFT OUTHER JOIN을 사용하는 것을 확인할 수 있는데, 이는 NULL 가능성 때문이다. INNER JOIN이 LEFT OUTHER JOIN 보다 성능이 좋기 때문에 외래키에 NOT NULL 제약 조건을 설정하는 것이 좋다. (nullable = false)
* 지연 로딩 : 연관된 엔티티를 실제 사용할 때 조회하는 방법
+ 되도록 지연 로딩을 사용하도록 권장한다.
원인과 해결책
원인은 프록시의 한계 때문이다.
JPA는 객체의 참조가 프록시 기반으로 동작하는데, 연관관계가 있는 객체는 참조할 때 기본적으로 null 아닌 프록시 객체를 반환한다. null이 허용되는 경우, 프록시 형태로 null 객체를 반환할 수 없게 된다.
즉 @OneToOne 양방향 관계에서 연관관계 주인(MyBookMeaningTag) 이 호출할 때는 Lazy가 동작하지만, 주인이 아닌 곳(MyBook)에서 호출할 때에는 다른 쪽의 One의 존재를 알 수 없어 null 일 수 있기에 Lazy Loading이 먹히지 않는다.
연관관계 주인은 외래키를 가지고 있어 연관관계 대상의 값이 null 인지 아닌지를 알 수 있어 프록시로 감싸줄 수 있지만, 반대로 연관관계 주인이 아닌 입장에서는 연관관계 주인에 대한 필드 값을 가지고 있지 않아, 무조건 대상 테이블을 조회해서 null 인지 또는 값이 있는지를 확인해야 하기 때문에 지연로딩의 의미가 없어지고 즉시로딩을 실시하는 것이다.
@OneToOne 관계에서 Lazy Loading의 발동 조건은 아래와 같다.
- 양방향 관계에서 연관관계 주인이 호출할 때
- nullable이 허용되지 않는 1:1 관계. 즉, 참조 객체가 optional = false로 지정할 수 있는 관계
- 양방향이 아닌 단방향 1:1 관계
- @PrimaryKeyJoin은 허용되지 않는다. 부모와 자식 엔티티 간의 조인칼럼이 모두 PK의 경우를 의미한다.
해당 문제를 해결하기 위해서 연관관계를 수정하는 방법이 있다.
- @OneToOne 양방향 관계를 단방향 연관관계로 수정한다.
- @OneToOne 양방향 관계를 @OneToMany 양방향 관계로 수정한다.
하지만, 저는 아직 연관관계를 수정하지 않고, QueryDsl를 사용하여 필요한 칼럼만 조회할 수 있도록 해서 @OneToOne 양방향 관계에서 발생하는 N+1 문제를 해결했다.
(추천피드를 조회할 때 MyBookMeaningTag와 MyReview를 사용하지 않는다.)
+ 근데 @OneToMany에서는 어떻게 괜찮은 걸까?
@OneToOne와 마찬가지로, @OneToMany에서 Many 쪽이 연관관계 주인이기 때문에 One의 존재를 알 수 있어 Lazy Loading이 발생하지만 One 쪽은 Many의 존재를 알 수 없다.
하지만 Many 쪽을 컬렉션으로 관리를 하기 때문에 size을 통해서 null을 표현 가능하다. 프록시 객체를 만들어 놓고 조회 시 빈 컬렉션을 리턴하면 된다.
빈 컬렉션은 비어있는 상태의 List, Set, Map 등과 같은 객체를 의미한다. 따라서 이들은 이미 객체이므로 프록시 객체로 감쌀 수 있다.
프록시 객체의 목적은 원래 객체를 대신하여 지연 로딩을 처리하는 것이며, 그 자체로 객체로써의 기능을 수행한다. 따라서 프록시 객체가 생성되더라도 객체의 기본적인 속성과 메서드를 가지고 있다.
반면에 null은 아무런 값도 가지지 않은 상태를 나타내는데, 이는 객체가 아니며 프록시 객체로 null을 대신할 수는 없습니다.
참고 레퍼런스
'개발' 카테고리의 다른 글
Readable Code: 읽기 좋은 코드를 작성하는 사고법 정리 (1) | 2024.11.27 |
---|---|
[Spring] MDC, 로그 트레이싱하기 (0) | 2024.04.02 |
ERD 설계 이후 개발하면서 반정규화 하기 (0) | 2024.03.17 |
[Test] Test Code에 필요한 Test Fixture 재사용하기 (0) | 2023.07.11 |