본문으로 바로가기

프로젝트의 전체 소스 코드는 이곳에서 확인하실 수 있습니다.


Shoe-auction 프로젝트의 비즈니스 로직 구현이 7~80프로 정도 완료된 것 같다. 새로운 feature를 구현하기 전에 성능적인 부분을 최적화하는 작업을 진행하기로 했다.

이번 포스팅에서는 JPA를 사용하면서 발생할 수 있는 불필요한 쿼리를 줄이며 성능을 최적하는 과정을 일부분 소개하려고 한다.

1. Fetch 전략을 EAGER로 설정했을 때 발생하는 문제점 해결하기

 

첫 번째로 해결할 문제는 User와 @OneToOne 관계를 맺고 있는 AddressBook(주소록)의 Fetch전략이 EAGER로 설정되어 있어서 발생하는 문제다.

참고로 XToOne 처럼 One으로 끝나는 연관관계는 모두 Fetch전략의 default 타입이 EAGER라는 점을 알아두자.

✏️ EAGER로 설정했을 때 문제점은 ?

아래 코드는 해당 사용자(USER)의 계좌 정보(ACCOUNT)를 수정하는 코드이다.

해당 코드를 실행하면 email로 User를 조회하는 Select 쿼리 , Account 정보를 수정하는 update 쿼리가 발생하면서 총 2개의 쿼리가 실행될 것으로 예상된다. 아래 실행 결과를 보자.

 ----------------------USER 조회 select 쿼리 ---------------------------
 select
        user0_.user_id as user_id2_9_,
        user0_1_.created_date as created_3_9_,
        user0_1_.modified_date as modified4_9_,
        user0_1_.email as email5_9_,
        user0_1_.password as password6_9_,
        user0_1_.user_level as user_lev7_9_,
        user0_.account_number as account_1_8_,
        user0_.bank_name as bank_nam2_8_,
        user0_.depositor as deposito3_8_,
        user0_.addressbook_id as addressb9_8_,
        user0_.cart_id as cart_id10_8_,
        user0_.nickname as nickname4_8_,
        user0_.nickname_modified_date as nickname5_8_,
        user0_.phone as phone6_8_,
        user0_.user_status as user_sta7_8_ 
    from
        user user0_ 
    inner join
        user_base user0_1_ 
            on user0_.user_id=user0_1_.user_id 
    where
        user0_1_.email=?
---------------------AddressBook 조회 select 쿼리----------------------
 select
        addressboo0_.id as id1_1_0_ 
    from
        address_book addressboo0_ 
    where
        addressboo0_.id=?
---------------------Account 수정 update 쿼리---------------------------
 update
        user 
    set
        account_number=?,
        bank_name=?,
        depositor=?,
        addressbook_id=?,
        cart_id=?,
        nickname=?,
        nickname_modified_date=?,
        phone=?,
        user_status=? 
    where
        user_id=?

예상과 다르게 계좌 정보(Account) 업데이트와는 전혀 관련 없는 AddressBook을 조회하는 쿼리가 실행되었다.

위 예제 코드처럼 Fetch 전략을 EAGER로 설정하게 되면 USER를 조회할 때 연관관계를 맺고 있는 AddressBook까지 함께 조회하게 된다. 우리는 단순히 USER 정보만 필요한데, AddressBook까지 함께 조회하는 쿼리를 실행하는 것은 분명한 낭비이다.

🎁아래와 같이 개선해보자

fetch 전략을 LAZY로 수정하면 User가 조회되는 시점에 AdressBook을 프록시 객체로 가져와서 실제로 AddressBook이 사용되는 시점에 실행된다.

추가적으로 orphanRemoval = true 옵션은 고아 객체를 삭제해 주는 옵션인데, 해당 USER가 회원 탈퇴를 했을 경우 AddressBook도 함께 삭제되도록 설정해 주자.

이제 다시 Account 수정 메서드를 실행하면 정상적으로 email로 User를 조회하는 Select쿼리 , Account 정보를 수정하는 update 쿼리만 발생할 것이다.

2. N+1 문제 해결하기

N+1이란 ?

@Entity
public class User {
    @Id
    @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY, orphanRemoval = true)
    @JoinColumn(name = "ADDRESSBOOK_ID")
    private AddressBook addressBook;

}

N+1 문제란 findAll() 메서드를 통해 USER 전체를 조회하게 되면 USER 전체를 조회하는 쿼리가 한번 나가고, USER의 수만큼 USER와 연관관계를 맺고있는 AddressBook을 조회하는 쿼리가 추가로 실행되는 것이다.

 

예를들어 USER가 10명이 존재한다고 가정했을 때 findAll()메서드를 한번 실행하게 되면 전체 USER를 조회하는 쿼리 1번, AddressBook을 조회하는 쿼리 10번이 실행되는 문제를 의미한다.

 
✏️ N+1 문제 해결하기

위에서 Fetch 전략을 Lazy로 변경한 후 현재 Shoe-auction 프로젝트의 USER 테이블은 다음과 같다.

아래 코드는 회원의 주소록을 조회하는 메서드다.

실행되는 쿼리는 다음과 같다.

-------------------------------USER 조회 쿼리 -------------------------
    select
        user0_.user_id as user_id2_9_,
        user0_1_.created_date as created_3_9_,
        user0_1_.modified_date as modified4_9_,
        user0_1_.email as email5_9_,
        user0_1_.password as password6_9_,
        user0_1_.user_level as user_lev7_9_,
        user0_.account_number as account_1_8_,
        user0_.bank_name as bank_nam2_8_,
        user0_.depositor as deposito3_8_,
        user0_.addressbook_id as addressb9_8_,
        user0_.cart_id as cart_id10_8_,
        user0_.nickname as nickname4_8_,
        user0_.nickname_modified_date as nickname5_8_,
        user0_.phone as phone6_8_,
        user0_.user_status as user_sta7_8_ 
    from
        user user0_ 
    inner join
        user_base user0_1_ 
            on user0_.user_id=user0_1_.user_id 
    where
        user0_1_.email=?
----------------addressBook 조회 쿼리 : N+1 문제 해결을 통해 제거 가능-------------
    select
        addressboo0_.id as id1_1_0_ 
    from
        address_book addressboo0_ 
    where
        addressboo0_.id=?
---------------주소록에 저장되어있는 주소 조회 쿼리----------------------                      
    select
        addresslis0_.addressbook_id as addressb6_0_0_,
        addresslis0_.id as id1_0_0_,
        addresslis0_.id as id1_0_1_,
        addresslis0_.address_name as address_2_0_1_,
        addresslis0_.detailed_address as detailed3_0_1_,
        addresslis0_.postal_code as postal_c4_0_1_,
        addresslis0_.road_name_address as road_nam5_0_1_ 
    from
        address addresslis0_ 
    where
        addresslis0_.addressbook_id=?

N+1 문제는 위에서 설명한 것과 같이 USER의 개수만큼 추가로 실행된다고 했다. 위 예제 코드는 단일 유저에 대한 조회이기 때문에 1개의 추가 쿼리가 발생할 것이다.

 

select 쿼리 하나 더 추가된다고 당장 애플리케이션 성능에 무리를 주는 것은 아니지만, 이런 이슈들이 쌓이고 쌓이면 결국 성능적인 부분에서 문제가 발생하게 될 것이다.

 

해결 방법은 매우 간단하다. USER를 조회할 때 해당 USER와 연관관계를 맺고 있는 AddressBook까지 한 번에 조회해버리면 간단하게 해결할 수 있다.

🎁아래와 같이 개선해보자

Spring Data JPA에서는 @EntityGraph 으로 간단하게 N+1 문제를 해결할 수 있다.

위와 같이 수정하고 주소록을 조회하는 메서드를 실행하면 다음과 같은 쿼리가 실행된다.

-------------------------------USER 조회 쿼리 -------------------------
    select
        user0_.user_id as user_id2_9_,
        user0_1_.created_date as created_3_9_,
        user0_1_.modified_date as modified4_9_,
        user0_1_.email as email5_9_,
        user0_1_.password as password6_9_,
        user0_1_.user_level as user_lev7_9_,
        user0_.account_number as account_1_8_,
        user0_.bank_name as bank_nam2_8_,
        user0_.depositor as deposito3_8_,
        user0_.addressbook_id as addressb9_8_,
        user0_.cart_id as cart_id10_8_,
        user0_.nickname as nickname4_8_,
        user0_.nickname_modified_date as nickname5_8_,
        user0_.phone as phone6_8_,
        user0_.user_status as user_sta7_8_ 
    from
        user user0_ 
    inner join
        user_base user0_1_ 
            on user0_.user_id=user0_1_.user_id 
    where
        user0_1_.email=?

---------------주소록에 저장되어있는 주소 조회 쿼리----------------------                      
    select
        addresslis0_.addressbook_id as addressb6_0_0_,
        addresslis0_.id as id1_0_0_,
        addresslis0_.id as id1_0_1_,
        addresslis0_.address_name as address_2_0_1_,
        addresslis0_.detailed_address as detailed3_0_1_,
        addresslis0_.postal_code as postal_c4_0_1_,
        addresslis0_.road_name_address as road_nam5_0_1_ 
    from
        address addresslis0_ 
    where
        addresslis0_.addressbook_id=?

 

후기

간단하게 Fetch 전략을 Lazy로 수정하고, N+1 문제를 해결해서 JPA 성능 최적화를 해보았다.

처음 USER 클래스를 설계했을 때 USER와 AddressBook은 항상 함께 사용된다고 생각해서 EAGER 타입으로 지정했었다. 하지만 EAGER 타입으로 설정하게 되면 예상하지 못한 쿼리가 실행된다. 따라서 모든 Fetch 전략은 Lazy로 설정하고, N+1 쿼리가 발생하는 경우 @EntityGraph 또는 Fetch Join으로 해결하자.