1. 상황
가치택시 프로젝트 중 나의 친구 목록을 반환하는 API 구현중이었다.
초기에는 기본 FetchJoin을 JPQL에 때려서 List<Friends> 를 가져온 후 map으로 FriendsResponseDto로 변환했다.
초창기 코드에 따른 코드 리뷰 내용!
2. 코드 리뷰 내용
1. stream().map() 으로 많은 데이터를 처리할 경우 많은 메모리 사용량!
2. @BatchSize 사용을 해보는 게 어떨 지!
3. Repository 조회 시점에 바로 Dto에 매핑시켜서 가져오는 방법은 어떨 지!
3. 나의 생각
1. stream().map의 경우 데이터가 너무 많으면 메모리 사용량이 많아 성능에 좋지 않다. 하지만 친구 목록이 100명이 넘지 않는 이상 메모리 사용량에 따른 성능 이슈가 있을까?? 그래도 고려해보면 좋을 거 같다!
2. @BatchSize
잘 몰랐던 어노테이션이다. 지연로딩으로 데이터를 조회해올 때, 발생하는 N+1 추가 쿼리 문제를 하나의 추가 쿼리로 줄여주는 어노테이션이다.
application.yml에서 전역적으로 배치 사이즈를 조절할 수 있고 또는 필요한 곳에만 @BatchSize를 적용할 수 있다.
많은 양의 데이터가 될거라 예상되지 않는데(작은 규모의 서비스 앱에서 친구 목록이 100명이 넘어가는 일이 극히 적기 때문), 굳이 BatchSize 까지..? 라는 생각을 했었다!
3. Repository 조회 시점에 바로 Dto에 매핑 시켜서 가져오기
한번도 해보지 않았지만, 아무래도 데이터베이스 단에서 조회를 끝내는 것이 가장 성능 상 좋지 않을까? 라는 생각을 가졌다. (Dto로 바로 매핑 시 stream().map()을 사용하지 않아도 되니까!)
4. 중간 점검 (코드 반영 사항)
나는 Repository단에서 Dto로 바로 반영하도록 코드를 수정해봤다!
코드는 다음과 같고 FriendsResponseDto에 생성자를 만들어둬야한다!
@Query("SELECT new 패키지 주소.FriendsResponseDto( " +
"CASE WHEN f.sender.id = :memberId THEN f.receiver.id ELSE f.sender.id END, " +
"CASE WHEN f.sender.id = :memberId THEN f.receiver.nickname ELSE f.sender.nickname END, " +
"CASE WHEN f.sender.id = :memberId THEN f.receiver.profilePicture ELSE f.sender.profilePicture END, " +
"CASE WHEN f.sender.id = :memberId THEN f.receiver.gender ELSE f.sender.gender END " +
") FROM Friends f " +
"WHERE f.status = 'ACCEPTED' " +
"AND (f.sender.id = :memberId OR f.receiver.id = :memberId)")
List<FriendsResponseDto> findAcceptedFriendsByMemberId(@Param("memberId") Long memberId);
확실히 DB에서 바로 Dto로 조회해오니까 비지니스 로직도 깔끔해졌다.
그러나 JPQL로 만들어 낸 조회 쿼리이다 보니 유지보수가 매우 힘들어질 거 같다는 생각이 들었다...
Case When 떡칠 + 문자 형태이다 보니 오류 가능성 높음 + 컴파일 시점에 쿼리 문법 오류가 잡히지 않음.
5. 추가 코드 리뷰
우선 위 코드를 반영해두고 음 어떤 식으로 구현하는 게 좋을 지 생각했다.
마침 수정 코드를 보시고 달아주신 다른 분의 생각!
성능상의 이점은 있지만 관리하기 힘들다! 그렇다 나의 생각과 일치한다!
그런데 리뷰에서 Slice나 페이지네이션 구현을 고려해보는 게 어떨 지 라는 내용이 있다!
앗차차... 친구 목록에 무한 스크롤이나 페이지네이션을 구현 해야할 것을 까먹고 있었다
그런데 FetchJoin과 Slice, 페이지네이션은 같이 사용할 수 없는 걸로 알고 있었는데... N+1 문제 해결 하면서 페이지네이션이 가능한가??
6. 오개념 컽!
FetchJoin과 Slice, 페이지네이션은 같이 사용할 수 없다는 오개념을 가지고 있었다. 결론만 따지면 반은 맞고 반은 틀린 개념이다.
정확히는 해당 엔티티에 @XXXToOne 으로 걸려 있는 필드는 FetchJoin과 Slice, 페이지네이션을 같이 사용할 수 있다!
FetchJoin은 Friends엔티티를 가져올 때 이와 연관된 sender와 recevier를 한 번의 쿼리로 몽땅 조회해온다. -> ㄱㅊ
만약 @OneToMany 라면? List<Members>, List<Friends> 와 같은 형태로 되어있다면, 심지어 @OneToMany를 사용하는 필드가 여러개라면? 이를 가져올 때 카타시안 곱으로 값을 읽어오기 때문에 MultipleBagFetchExcetion이 발생하게 된다!
나는 현재 Member <-> Friends 의 관계를 설정할 때 Friends 쪽에만 다음과 같이 @ManyToOne을 사용했다.
public class Friends extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
private Members sender;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id")
private Members receiver;
@Builder.Default
@Enumerated(EnumType.STRING)
private FriendStatus status = PENDING;
}
따라서 Slice를 사용해도 상관 없는 것!
7. Slice로 구현
가치택시는 아무래도 최종 어플로 마이그레이션 할 예정이므로 1, 2, 3 페이지를 넘기기보다는 어플에 적합한 무한 스크롤로 구현하는 게 더 좋다고 생각했다! 그래서 Slice를 반환할 것이다!
다음과 같이 FetchJoin으로 한번에 가져오고 Slice를 적용!
@Query("SELECT f FROM Friends f " +
"JOIN FETCH f.sender s " +
"JOIN FETCH f.receiver r " +
"WHERE (s.id = :memberId OR r.id = :memberId) " +
"AND f.status = 'ACCEPTED'")
Slice<Friends> findFriendsListByMemberId(@Param("memberId") Long memberId, Pageable pageable);
반환 결과는 다음과 같다! (page=0, size=5)
{
"code": 200,
"message": "친구 목록을 조회합니다",
"data": {
"content": [
{
"friendsId": 4,
"friendsNickName": "테스트멤버2",
"friendsProfileUrl": null,
"gender": "MALE"
},
{
"friendsId": 7,
"friendsNickName": "테스트멤버4",
"friendsProfileUrl": null,
"gender": "MALE"
},
{
"friendsId": 3,
"friendsNickName": "테스트멤버1",
"friendsProfileUrl": null,
"gender": "MALE"
},
{
"friendsId": 5,
"friendsNickName": "테스트멤버3",
"friendsProfileUrl": null,
"gender": "MALE"
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 5,
"sort": {
"empty": true,
"sorted": false,
"unsorted": true
},
"offset": 0,
"paged": true,
"unpaged": false
},
"size": 5,
"number": 0,
"sort": {
"empty": true,
"sorted": false,
"unsorted": true
},
"first": true,
"last": true,
"numberOfElements": 4,
"empty": false
}
}
참고 블로그
[이슈] JPA N+1 문제 해결 및 성능 비교 (feat. Batch Size)
0. 들어가기 전현재 진행중인 프로젝트에서 쿼리를 카운트해본 결과, JPA의 N+1 문제가 발생하고 있었습니다. N+1 문제에 대한 소개, 고찰은 이전에 포스팅했었기 때문에 링크만 남기고 넘어가도
ksh-coding.tistory.com
'프로젝트에서 일어난 일' 카테고리의 다른 글
졸프: 라즈베리파이와 MSA를 곁들인 (0) | 2025.04.10 |
---|---|
Jwt토큰 인가 검증에서 일어난 간단한 사건! (0) | 2025.02.04 |
카카오, 구글 통합 로그인 中 (의사소통의 중요성) (0) | 2025.01.16 |
허용한 경로까지 막아버리는 불효자 코드 물효자로 만들기 (0) | 2025.01.14 |
AccessToken을 상쾌하게 만드는 RefreshToken (0) | 2025.01.14 |