문제 상황
현재까지 만들어놓은 모든 API에 대해서 로그 파악을 진행하다가 질문 상세내역으로 들어가면 N+1 문제가 발생하는 걸 확인했다.
해결 과정
아래 로그는 해당 질문 상세내역으로 들어가는 건데 4개의 쿼리가 실행되고 있다.
Hibernate:
select
m1_0.id,
m1_0.banned_date,
m1_0.create_date,
m1_0.email,
m1_0.login_last_date,
mr1_0.member_id,
mr1_0.id,
mr1_0.role,
m1_0.nickname,
m1_0.password,
m1_0.social_type,
m1_0.token,
m1_0.withdrawal_date
from
member m1_0
join
member_role mr1_0
on m1_0.id=mr1_0.member_id
where
m1_0.id=?
Hibernate:
select
i1_0.id,
i1_0.answer_id,
i1_0.content,
i1_0.create_date,
i1_0.email_send_check,
i1_0.inquiry_category,
i1_0.is_answered,
i1_0.member_id,
i1_0.title
from
inquiry i1_0
where
i1_0.id=?
Hibernate:
select
m1_0.id,
m1_0.banned_date,
m1_0.create_date,
m1_0.email,
m1_0.login_last_date,
m1_0.nickname,
m1_0.password,
m1_0.social_type,
m1_0.token,
m1_0.withdrawal_date
from
member m1_0
where
m1_0.id=?
Hibernate:
select
a1_0.id,
a1_0.content,
a1_0.create_date,
a1_0.update_date
from
answer a1_0
where
a1_0.id=?
1. 유저 Entity, 유저 권한 Entity 조회 (권한은 FetchJoin)
2. Inquiry 조회
3. 유저 Entity 조회
4. Answer 조회
일단 1번에서 유저를 조회했기 때문에 3번에서 유저를 다시 조회할 필요가 없다. (1차 캐시 데이터를 가져오면 되기에)
전체 서비스 로직을 확인하면
// 질문 상세내용 가져오기
@Override
@Transactional(readOnly = true)
public InquiryInfoResponseDTO getTargetInquiry(Long inquiryId) {
Member currentMember = getCurrentMemberFetchJoinMemberRoles();
Answer answer = null;
Inquiry inquiry = inquiryRepository.findById(inquiryId)
.orElseThrow(() -> new InquiryException(InquiryExceptionInfo.NOT_FOUND_INQUIRY, inquiryId + "번 문의 내역을 찾을 수 없습니다."));
if (!inquiry.getMember().equals(currentMember) && !isAdmin(currentMember)) {
throw new InquiryException(InquiryExceptionInfo.NOT_MATCH_MEMBER, currentMember.getEmail() + " 유저가 " + inquiryId + "질문에 접근했습니다.(접근 차단)");
}
if (inquiry.isAnswered()) {
answer = inquiry.getAnswer();
}
return InquiryInfoResponseDTO.toDTO(answer, inquiry);
}
currentMember를 통해 1번 쿼리를 실행하게 된다.
이후 inquiry를 가져오면서 2번 쿼리를 실행하는하는데 inquiry.getMember() 로직때문에 유저를 한번 더 찾아오게 된다.
Inquiry inquiry = inquiryRepository.findById(inquiryId)
그래서 findByIdFetchJoinMember 메서드를 하나 만들어서 멤버를 같이 FetchJoin해서 찾아오도록 만들었다.
@Query("select i from Inquiry i join fetch i.member where i.id = :id")
Optional<Inquiry> findByInquiryFetchJoinMember(@Param("id") Long id);
일단 여기까지 하고 다시 쿼리 로그를 살펴보면
Hibernate:
select
m1_0.id,
m1_0.banned_date,
m1_0.create_date,
m1_0.email,
m1_0.login_last_date,
mr1_0.member_id,
mr1_0.id,
mr1_0.role,
m1_0.nickname,
m1_0.password,
m1_0.social_type,
m1_0.token,
m1_0.withdrawal_date
from
member m1_0
join
member_role mr1_0
on m1_0.id=mr1_0.member_id
where
m1_0.id=?
Hibernate:
select
i1_0.id,
i1_0.answer_id,
i1_0.content,
i1_0.create_date,
i1_0.email_send_check,
i1_0.inquiry_category,
i1_0.is_answered,
m1_0.id,
m1_0.banned_date,
m1_0.create_date,
m1_0.email,
m1_0.login_last_date,
m1_0.nickname,
m1_0.password,
m1_0.social_type,
m1_0.token,
m1_0.withdrawal_date,
i1_0.title
from
inquiry i1_0
join
member m1_0
on m1_0.id=i1_0.member_id
where
i1_0.id=?
Hibernate:
select
a1_0.id,
a1_0.content,
a1_0.create_date,
a1_0.update_date
from
answer a1_0
where
a1_0.id=?
하나가 줄은 걸 확인할 수 있다.
1, 3번의 유저를 찾는 쿼리가 중복됐었는데 FetchJoin을 통해 하나를 줄였다.
남은 부분은 Answer의 쿼리를 줄이는 것인데 여기서 고민이 발생했다.
현재 Inquiry와 Answer는 단방향 OneToOne이고 Inquiry에서 Answer를 가지고 있는 상황이다. Answer에서는 Inquiry를 알 필요가 없다고 판단해서 이렇게 설계했다.
그런데 아래의 경우에 따라 문제가 발생한다.
1. 답변이 존재하는 경우 -> Answer를 찾기 위해 추가쿼리를 날린다.
2. 답변이 존재하지 않는 경우 -> 추가 쿼리가 필요없다.
추가 쿼리가 필요없는 이유는 Answer의 유무 판단을 Inquiry에 boolean으로 컬럼을 하나 둬서 판단한다.
근데 나는 추가쿼리를 날리는 것이 싫었어서 FetchJoin을 생각했는데 결국 답변이 없다고 해도 FetchJoin을 하게 돼서 성능에 영향이 끼친다고 생각했다.
그래서 FetchJoin vs 추가쿼리에 대해 고민이 발생했다.
테스트
총 4가지 경우를 산정했다.
모든 테스트는 10개 스레드 기준 100번의 반복 요청을 보냈습니다.
1. 추가 쿼리 사용
- answer가 존재하는 경우
- answer가 존재하지 않는 경우
2. 페치조인 사용
- answer가 존재하는 경우
- answer가 존재하지 않는 경우
1. (추가 쿼리 사용) answer가 존재하는 경우 (쿼리 3번)
2. (추가 쿼리 사용) answer가 존재하지 않는 경우 (쿼리 2번)
3. (페치조인 사용) answer가 존재하는 경우 (쿼리 2번)
4. (페치조인 사용) answer가 존재하지 않는 경우 (쿼리 2번)
사실 시간적으로 보면 큰 차이가 없다.
결국 추가쿼리를 사용하면 db connection이 한번 더 일어날 수 있다는 점.
그러나 fetchJoin을 사용하면 결국 null을 가져오기 때문에 딱히 문제가 되지 않을 것이라 생각했다.
그래서 시간이 비슷하다면 db의 커넥션을 하나 줄이는 방향으로 선택했다.
'프로젝트 > RESTAPI 추천 서비스' 카테고리의 다른 글
공유 게시글의 좋아요를 광클한다면?(Feat, @Table) (0) | 2024.06.04 |
---|---|
프로젝트 코드리뷰 (1) (0) | 2024.06.03 |
기록 요청에 필요한 데이터만 뽑아내자(Feat, QureyDsl) (0) | 2024.05.31 |
이메일 전송을 비동기로 처리하기 (Feat, @Async) (3) | 2024.05.30 |