본문 바로가기

Logging/Spring

[JPA] OSIV 만 믿었다가 Lazy 로딩에 발등찍힌 썰

728x90

 

작업을 하다가 처음 보는 문제점을 확인해서 로깅 기록을 남겨보게 되었다. 요즘 글 많이 쓰기 힘들어서 문제 설명만 하고 빨리빨리 진행하겠다. 이 로직만 정말 하루 종일 작업했다. 

 

 

1. 문제 상황

 

 

Group 과 GroupCategory, Category 가 있다. 카테고리에는 categoryValue 가 있고, Group 과 Category 는 N:N 관계이며 GroupCategory 테이블이 둘을 연결해준다. 

 

 

Group 을 검색하는 로직을 만들고 있을 때, JPQL, 1:N 의 fetchJoin 에서의 Paging 처리 문제 등등으로 인해서, GroupCategory 와 Category 를 fetch Join 하지 못한채 원하는 Group List 를 가져온 상황이다. (이럴 수밖에 없었음.. 해쉬태그 구현이랑 유사했었다)

 

 

OSIV 는 켜져 있다. Controller 영역에서 영속성 컨텍스트 안을 계속 사용하기 위해서이다. 또한 Group 에는 다음과 같이 @OneToMany 로 양방향 매핑이 되어 있다. 

 

 

@Entity
public class Group {
    ...
    @OneToMany(fetch = fetchType.LAZY, mappedBy="group")
    private List<GroupCategory> groupCategories = new ArrayList<>();
    
    ...
}

 

 

문제가 되는 Service 영역은 다음과 같이 진행되고 있다

 

 

...

    List<Group> pagedGroups = repository.findByDynamicSearchCondition(...);
    
    return pagedGroups;

}

 

 

하지만 나는 이 요청의 응답에 Category 정보를 실어서 보내줘야 했다. 따라서 GroupCategory 를 기준으로 Category 를 fetchJoin 하는 쿼리를 하나 더 발생시켜서 영속성 컨텍스트에 보관해줄 생각을 했다. (다시 한번 말하지만 저 SearchCondition 쿼리에서 fetch Join 을 할 수가 없는 상황이다) 

 

 

차피 OSIV 도 ON 이니까, Category 가 fetch join 된 GroupCategory 들만 따로 영속성 컨텍스트에 로딩해 놓고, Controller 에서 Group 의 GroupCategory 들을 사용할 때 알아서 영속성 컨텍스트에서 불러와서 사용하게 하자!

 

 

내 생각은 위와 같았다. 따라서 pagedGroup 의 Id 들만 가져와서 GroupCategory 를 가져오는 in 절 쿼리를 발생시켰다. 

 

 

    ...
    List<Group> pagedGroups = repository.~~;
    List<Long> moimIds = pagedGroups.stream().map(Group::getId).collect(Collectors.toList());
    
    // GroupCategory 와 연관된 Category 들을 모두 영속성 컨텍스트에 로딩한다
    List<GroupCategory> groupCategoires = categoryRepo.findWithCategoryByGroupIds(groupIds);

    return pagedGroups;
}
// Transactional 종료

 

 

하지만 Controller 영역에서 Group.getGroupCategories() 를 하는 시점에 다음과 같이 쿼리가 발생하는 모습들을 확인할 수 있었다. 심지어 Group 의 갯수만큼 쿼리가 발생해서, N+1 문제를 확인할 수 있었다. 

 

 

헬로월드는 나의 로깅 흔적.. 발생 시점을 찾는 것도 한참 걸렸다 (moim 이 group 이다)

 

 

Lazy 로딩에서 N+1 문제라니.. 이 문제일거라고 생각을 못해서 찾는 것도 오래걸렸다. 여기저기 찾아봤을 때 정확하게 사유를 찾은 것은 아니지만, 내가 내린 결론은 다음과 같다. 

 

 

  • Group 을 조회할 때 GroupCategory 를 @OneToMany 관계를 이용해서 Fetch Join 을 한 경우가 아니라면, Group.getGroupCategory 를 통해서 사용을 하려 한다면 쿼리가 발생한다

  • 어찌보면 당연한게, 영속성 컨텍스트를 생각하지 않고 Java 입장에서 봤을 때, 저 위에서 조회한 List<GroupCategory> 안에 있는 객체들과 Group.getGroupCategory 객체들은 다르다. fetch join 을 하지 않았기 때문에 Group 객체 안에 있는 GroupCategory List 들은 모두 프록시로 감싸져있는 가객체이다

  • 따라서 Group 입장에서 .getGroupCategory 가 쓰이면, 영속성 컨텍스트는 신경쓰지 않고 "내가 가지고 있는건 프록시 객체니까 조회해와야 겠다!" 하고 쿼리를 발생시키는 것이다. 

 

 

2. 해결 및 고찰

 

 

나의 해결방안은 비교적 간단하게 해결했는데, 따로 매핑을 해주는 것이였다. 차피 List<CategoryGroup> 을 모두 들고 있으니, 이를 Controller 영역에 같이 전달해서 Response Dto 를 매핑해줄 때 Group Id 를 통해서 매핑을 해서 넘겨주는 방법을 사용하였다. 이렇게 했더니 더 이상 쿼리는 발생하지 않았다. 

 

 

하지만 아직 의문인 점이 있다. 내 앱에서 이미 적용되어 있던 것처럼,  default_batch_size=100 으로 했으면 OneToMany 관계에 있는 List 를 사용하려고 했을 때 in 절을 사용해서 한번에 들고와야 하는 것이 아닌가? 그렇지 않고 하나씩 가져오는 이유를 모르겠다. 

 

 

어쨌든 이런 JPA 에 순수하게 의존하는 것은 정말 한계가 많다고 느끼고, JPA 를 100% 마스터 했다! 하기전까지 이해안되는 문제들이 너무 많은 것 같다. 따라서 가끔은 이번 방법처럼 NAIVE 하게 해결해보는 것도 좋은 것 같다. (비슷한 느낌으로 점점 QueryDSL 도 안쓰고 그냥 JPQL 이 편한 것 같다. String 오류 나봤자 해결하면 되고.. 동적 쿼리 작성하는데 String 으로 붙였다 떼는게 훨씬 편한 것 같다...)

 

 

오랜만에 밟아본 N+1 문제였다. OSIV랑 JPA 만 너무 믿지 말고 항상 콘솔에 쿼리 찍히는걸 보면서 개발하는게 너무 중요하다.

 

 

N+1 은 성능에 치명적이다

 

 

728x90