본문 바로가기
삽질 기록

[JPA] 같은 엔티티를 조회함에도 불구하고 쿼리가 여러번 날아가는 이유

by 딱구킴 2023. 10. 7.

최근 사내 프로젝트 코드에서 단일 책임 원칙을 지키지 않는 메서드가 발견되어 이를 분리한 경험이 있는데요.

 

분리를 하게 되면서, 미처 신경쓰지 못한 부분 때문에 같은 엔티티를 조회함에도 불구하고, 조회 쿼리가 2번 날아가게된 삽질 경험이 있어 기록하고자 합니다.

 

해당 메서드는 금액을 변경하는 로직과 변경 후 금액을 조회하는 로직이 함께 섞여있었는데요. 이는 단일 책임 원칙을 지키지 않는 코드라고 판단되어, 변경 로직과 조회 로직을 아래와 같이 분리를 하였습니다. (로직은 매우 간소화 하였으니 참고 부탁드립니다.)

 

public class MoneyService {
    private final UserMoneyRepository userMoneyRepository;

    public MoneyService(UserMoneyRepository userMoneyRepository) {
        this.userMoneyRepository = userMoneyRepository;
    }

    // As-is
    public Integer getTotalAmount(String userId, Integer amount) {
        UserMoney userMoney = userMoneyRepository.findByUserId(userId);
        userMoney.increaseAmount(amount);
        return userMoney.getTotalAmount();
    }

    // To-be
    public void increaseAmount(String userId, Integer amount) {
        UserMoney userMoney = userMoneyRepository.findByUserId(userId);
        userMoney.increaseAmount(amount);
    }

    public Integer getTotalAmount(String userId) {
        UserMoney userMoney = userMoneyRepository.findByUserId(userId);
        return userMoney.getTotalAmount();
    }
}

 

 

그리고, 해당 메서드를 호출하여 사용하는 쪽에도 아래와 같이 변경되었습니다. 

public class UserService {
    private final MoneyService moneyService;

    public UserService(MoneyService moneyService) {
        this.moneyService = moneyService;
    }

    // As-is
    @Transactional
    public void 아주_복잡한_비즈니스_로직(String userId, Integer amount) {
        Integer totalAmount = moneyService.getTotalAmount(userId, amount);

        // 복잡한 로직들 수행
    }

    // To-be
    @Transactional
    public void 변경후_아주_복잡한_비즈니스_로직(String userId, Integer amount) {
        moneyService.increaseAmount(userId, amount);
        Integer totalAmount = moneyService.getTotalAmount(userId);

        // 복잡한 로직들 수행
    }
}

 

 

결과적으로, 메서드를 분리하게 되면서 단일 책임 원칙을 지키게 되었지만, 사용하는 측면에서는 아래의 메서드를 2회나 호출하게 되었습니다.

userMoneyRepository.findByUserId(userId)

 

레포지토리에 조회 쿼리를 여러번 날리게 되는 것은 그만큼 I/O가 발생하기 때문에 비용이 커지는 작업인데요. 그럼에도 불구하고 위와 같이 분리를 선택한 이유는 다음과 같습니다. 

  • 메서드를 분리함으로써 단일 책임 원칙을 지키게 됨 금액 증액 관련 비즈니스에 변경 사항이 생기더라도 조회 로직에는 영향을 미치지 않게 됨. -> 유지보수성 증가 
  • JPA를 사용하므로, 동일 트랜잭션 내에서(동일 영속성 컨텍스트 내에서) 같은 엔티티를 2회 조회할 경우 영속성 컨텍스트의 1차 캐시를 이용하여 결과적으로 1회의 조회 쿼리만 날아갈 것

 

결과적으로, 해당 리팩토링건은 좋은 방향으로 수행된 것이라고 생각하고, 뿌듯해 하며 자신있게 MR을 날렸습니다. 

 

그런데, 예상 밖의 리뷰를 받았습니다. (자신감이 있었는데요.. 없어졌습니다..)

 

JPA의 1차 캐시를 활용하지 못하는 코드를 짜버린 것이었습니다. 이유가 무엇이었을까요?

결론부터 말씀드리자면, JPQL은 DB를 먼저 조회하기 때문입니다.

 

제가 작성한 코드를 다시 볼까요? 

userMoneyRepository.findByUserId(userId)

 

UserMoneyRepository는 JpaRepository를 구현한 인터페이스입니다. 영속성 컨텍스트를 먼저 조회하기 위해서는 findById를 사용해야 했는데, 위 로직은 findByUserId라는 JPQL을 호출하는 메서드를 사용하고 있었던 겁니다. 

 

따라서, 해당 메서드들을 전부 findById() 라는 JPA가 기본으로 제공하는 메서드를 사용하도록 변경해 주었습니다.

userMoneyRepository.findById(userId)

위와 같이 변경해 줌으로써, 2회가 날아가던 조회 쿼리는 1회만 날아가도록 수정이 되었고, 결과적으로 JPA의 1차 캐시를 이용할 수 있게 되었습니다. 🥳

 

 

위와 같은 일이 발생하는 이유 

위와 같은 일이 발생한 이유를 상세하게 파헤쳐 봅시다. 

 

처음에는, "userId가 PK가 아니라서 1차 캐시를 이용하지 못해서 2회의 쿼리가 날아간다."라고 의심하였습니다. 하지만, 해당 이유가 아니었습니다. 소스 코드를 한번 살펴볼까요? 

 

제가 사용한 UserMoneyRepository의 소스 코드는 아래와 같이 구성되어 있었습니다.

public interface UserMoneyRepository extends JpaRepository<UserMoney, String> {
    UserMoney findByUserId(String userId);  // userId가 PK임에도 불구하고 필요없는 로직이 구현돼있음
}

 

여기서 제 실수가 드러나는데요. 이전의 코드를 제대로 읽지 않고 그냥 있는 그대로 사용했다는 것과, Entity의 PK가 무엇인지도 파악하지 않았다는 것입니다. 앞으로는 소스 코드를 제대로 읽고 사용해야 한다고 다짐하는 계기가 되었네요. 소스 코드를 꼼꼼히 잘 읽어야 하고, 해당 메서드가 왜 만들어진 것일까 항상 의문을 품으면서 읽읍시다. 🥲

 

그래서, findByUserId나 findById나 둘 모두 같은 userId로 엔티티를 조회 해오는데 왜 쿼리가 날아가는 횟수에 차이가 있을까요?

 

여기서 또 제가 공부를 제대로 안했음이 드러나네요. 하지만 복습을 하고 제것으로 만들 기회라고 생각하면 기분이 그나마 나아집니다.(?)😅

 

두 메서드 간의 차이는 아래와 같습니다.

  • findByUserId는 JPQL을 호출하는 메서드입니다. 
    • JPQL은 DB를 먼저 조회합니다. (쿼리 발생)
    • DB를 조회하여 Entity를 가져온 후, 조회한 값을 영속성 컨텍스트에 저장하려고 시도합니다.
    • 영속성 컨텍스트에 데이터가 있으면 조회 결과를 버리고 영속성 컨텍스트에 존재하는 값을 반환합니다.
  • findById는 JPA가 기본으로 제공하는 메서드입니다.
    • 이는 영속성 컨텍스트를 먼저 조회합니다. (쿼리가 발생하지 않음
    • 영속성 컨텍스트(1차 캐시)에 데이터가 있으면 해당 값을 반환합니다. (쿼리가 발생하지 않음)
      • 영속성 컨텍스트(1차 캐시)에 데이터가 없으면 조회 쿼리를 발생시킵니다. (쿼리 발생)
 

즉, JPQL 메서드를 2회 호출하게 되면 무조건 DB를 우선 조회하므로, 결과적으로 영속성 컨텍스트 내의 엔티티를 반환한다 하더라도 무조건 2회의 조회 쿼리가 발생하기 되는 것이었습니다. 

 

요약 정리 

  • JPQL은 무조건 DB를 먼저 조회하므로 무조건 쿼리가 발생하게 된다 
  • 학습은 꼼꼼히 하자 (저거 JPA 책에 다 있는 내용입니다...)
  • 소스 코드는 꼼꼼히 잘 읽고, 왜 만들어진 것일까를 항상 고민하자 

'삽질 기록' 카테고리의 다른 글

QueryDsl Projection 생성자 관련 삽질 기록  (0) 2022.12.25
상속 관련 QueryDSL 삽질 기록  (0) 2022.12.23

댓글