사용자가 문법 퀴즈를 풀고 틀린 문제를 저장하는 테이블(UserGrammarStatus)이 있습니다. 이 테이블에서 한 사용자가 틀린 모든 문법 문제를 조회하는 기능에서 N+1이 발생했습니다.
해당 기능은 아래 메서드이며, 사용자 이름으로 SiteUser 테이블에서 id를 찾아, UserGrammarStatus 테이블에서 해당 id의 grammar들을 조회합니다.
@Transactional(readOnly = true)
public WrongGrammarsResponseDto getUserWrongGrammars(String userName) {
Long userId = getUserIdByUserName(userName);
List<UserGrammarStatus> userGrammarStatuses = this.userGrammarStatusRepository.findByUserId(userId);
List<GrammarDto> wrongGrammars = new ArrayList<>();
for (UserGrammarStatus userGrammarStatus : userGrammarStatuses) {
Grammar wrongGrammar = userGrammarStatus.getWrongGrammar();
wrongGrammars.add(GrammarDto.builder()
.id(wrongGrammar.getId())
.sentence(wrongGrammar.getSentence())
.grammarExamples(GrammarExampleToGrammarExampleDtoConverter.convert(wrongGrammar.getExamples()))
.grammarBookName(wrongGrammar.getGrammarBook().getName())
.build());
}
return WrongGrammarsResponseDto.builder()
.wrongGrammars(wrongGrammars)
.lastTryTime(userGrammarStatuses.get(userGrammarStatuses.size()-1).getLastTryTime())
.build();
}
UserGrammarStatus 엔티티는 다음과 같습니다.
@Entity
@Getter
@NoArgsConstructor
public class UserGrammarStatus {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private SiteUser user;
@OneToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "wrong_grammar_id")
private Grammar wrongGrammar;
private Long grammarBookId;
@DateTimeFormat(pattern = "yyyy-MM-ddTHH:mm:ss")
private LocalDateTime lastTryTime;
public void updateLastTryTime(LocalDateTime lastTryTime) {
this.lastTryTime = lastTryTime;
}
@Builder
public UserGrammarStatus(SiteUser user, Grammar wrongGrammar, Long grammarBookId, LocalDateTime lastTryTime) {
this.user = user;
this.wrongGrammar = wrongGrammar;
this.grammarBookId = grammarBookId;
this.lastTryTime = lastTryTime;
}
}
위 메서드 테스트 시 다음의 쿼리가 발생합니다.
Hibernate:
select
su1_0.id,
su1_0.create_user_date,
su1_0.email,
su1_0.password,
su1_0.phone_number,
su1_0.point,
su1_0.role,
su1_0.token,
su1_0.user_name
from
site_user su1_0
where
su1_0.user_name=?
Hibernate:
select
ugs1_0.id,
ugs1_0.grammar_book_id,
ugs1_0.last_try_time,
ugs1_0.user_id,
ugs1_0.wrong_grammar_id
from
user_grammar_status ugs1_0
where
ugs1_0.user_id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
Hibernate:
select
gb1_0.id,
gb1_0.name
from
grammar_book gb1_0
where
gb1_0.id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
Hibernate:
select
gb1_0.id,
gb1_0.name
from
grammar_book gb1_0
where
gb1_0.id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
Hibernate:
select
g1_0.id,
g1_0.grammar_book_id,
g1_0.sentence
from
grammar g1_0
where
g1_0.id=?
Hibernate:
select
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer
from
grammar_example e1_0
where
e1_0.grammar_id=?
총 6개의 Grammar가 지연 로딩되어 N + 1문제가 발생합니다.
서브쿼리를 시도하려 했으나 엔티티를 객체지향적으로 구성되있다면 서브쿼리가 거의 필요 없고, 안티패턴이라는 의견이 있었습니다. 또 성능상으로도 대부분의 경우 서브쿼리가 느리다고 합니다.
아래 참고 글의 글쓴이 분은 서브쿼리 대신
- join으로 해결
- 어플리케이션에서 처리
- 쿼리를 나눠서 실행
등을 고려한다고 합니다.
제가 작성해본 서브쿼리는 from 절에서의 서브쿼리였는데, JPQL이나 QueryDSL-JPA는 from 절에서의 서브쿼리를 지원하지 않는다고 합니다.
그래서 join으로 해결해보기로 했습니다. 추가적으로 UserGrammarStatus 이름을 UserWrongGrammar로 변경했으며, Grammar와 OneToOne 매핑을 제거하고 Grammar의 id를 저장하도록 변경했습니다.
@Entity
@Getter
@NoArgsConstructor
public class UserWrongGrammar {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private SiteUser user;
@Column(nullable = false)
private Long wrongGrammarId;
private Long grammarBookId;
@DateTimeFormat(pattern = "yyyy-MM-ddTHH:mm:ss")
private LocalDateTime lastTryTime;
public void updateLastTryTime(LocalDateTime lastTryTime) {
this.lastTryTime = lastTryTime;
}
@Builder
public UserWrongGrammar(SiteUser user, Long wrongGrammarId, Long grammarBookId, LocalDateTime lastTryTime) {
this.user = user;
this.wrongGrammarId = wrongGrammarId;
this.grammarBookId = grammarBookId;
this.lastTryTime = lastTryTime;
}
}
우선 GrammarRepository에 다음과 같은 쿼리 메서드를 작성했습니다.
조회할 Grammar들의 id가 담긴 List를 받아 @EntityGraph
를 활용해서 이중 join합니다.
@EntityGraph(attributePaths = {"examples", "grammarBook"})
List<Grammar> findGrammarWithGrammarBookAndExampleByIdIn(List<Long> ids);
사용자의 오답 Grammar를 조회하는 기능은 다음과 같이 수정했습니다.
@Transactional(readOnly = true)
public WrongGrammarsResponseDto getUserWrongGrammars(String userName) {
Long userId = getUserIdByUserName(userName);
List<UserWrongGrammar> userWrongGrammars = this.userWrongGrammarRepository.findByUserId(userId);
List<Long> wrongGrammarIds = collectWrongGrammarIds(userWrongGrammars);
List<Grammar> wrongGrammars = this.grammarRepository.findGrammarWithGrammarBookAndExampleByIdIn(wrongGrammarIds);
List<GrammarDto> wrongGrammarDtos = new ArrayList<>();
for (Grammar wrongGrammar : wrongGrammars) {
wrongGrammarDtos.add(GrammarDto.builder()
.id(wrongGrammar.getId())
.sentence(wrongGrammar.getSentence())
.grammarExamples(GrammarExampleToGrammarExampleDtoConverter.convert(wrongGrammar.getExamples()))
.grammarBookName(wrongGrammar.getGrammarBook().getName())
.build());
}
다음은 위 메서드를 테스트시 실행된 쿼리입니다. 보시면 grammar 관련 쿼리의 where절에서 in 연산자로 한 번에 데이터를 조회합니다.
Hibernate:
select
su1_0.id,
su1_0.create_user_date,
su1_0.email,
su1_0.password,
su1_0.phone_number,
su1_0.point,
su1_0.role,
su1_0.token,
su1_0.user_name
from
site_user su1_0
where
su1_0.user_name=?
Hibernate:
select
uwg1_0.id,
uwg1_0.grammar_book_id,
uwg1_0.last_try_time,
uwg1_0.user_id,
uwg1_0.wrong_grammar_id
from
user_wrong_grammar uwg1_0
where
uwg1_0.user_id=?
Hibernate:
select
g1_0.id,
e1_0.grammar_id,
e1_0.id,
e1_0.example,
e1_0.is_answer,
g1_0.grammar_book_id,
gb1_0.id,
gb1_0.name,
g1_0.sentence
from
grammar g1_0
left join
grammar_example e1_0
on g1_0.id=e1_0.grammar_id
join
grammar_book gb1_0
on gb1_0.id=g1_0.grammar_book_id
where
g1_0.id in (?, ?, ?, ?, ?, ?)
이렇게 Grammar 조회시 발생했던 N+1문제를 해결했습니다.
추가로 고민할 문제는 userName으로 SiteUser 테이블에서 id를 찾아 이에 해당하는 UserWrongGrammar를 조회하기 때문에 SiteUser를 반드시 조회해야한다는 점입니다. 현재 UserWrongGrammar와 SiteUser는 ManyToOne 단방향 매핑 관계로, UserWrongGrammar가 연관관계의 주인이며, SiteUser의 id를 FK로 가집니다. SiteUser를 조회하지 않기 위해서는
- UserWrongGrammar가 userName을 FK로 가지거나
- UserWrongGrammar가 userName을 컬럼으로 가지고 연관관계를 제거하는
방법이 있습니다. FK를 userName으로 지정하는 방법은 JPA 연관관계 매핑시 바람직하지 못하다는 글을 본적이 있어 연관관계 제거를 적용해볼 수 있으나, 이로인한 사이드 이펙트를 고려해야하기에 우선 여기서 마무리 짓겠습니다.
'Springboot' 카테고리의 다른 글
Spring Cloud Bus & Spring Cloud Config monitor & kafka로 환경설정 무중단 자동 반영하기 (1) | 2024.11.28 |
---|---|
[트러블 슈팅]N+1 해결하기 (3) (0) | 2024.02.24 |
[트러블 슈팅]N+1 해결하기 (1) (0) | 2024.02.24 |
[트러블 슈팅]LazyInitializationException 간단 해결법 (0) | 2024.02.24 |
[트러블 슈팅]N : 1 양방향 연관 관계에서 주인이 아닌쪽에서 참조 시 null값이 포함되는 문제 (1) | 2024.02.21 |