본문 바로가기
나의 공부

JPA의 N+1 문제와 해결

by 이숴 2023. 4. 12.
반응형

서론

JPA를 사용하며 SQL를 관리하던 한 프로젝트에서 어느날, 조회시에 수많은 쿼리가 생성되는 문제를 이 게시글을 들어온 여러분은 겪은적이 많을 거에요. 저또한!

이번에는 N+1 문제가 무엇인지, 해결하기위해서는 어떻게 쿼리를 작성해야하는지에 대해 알아보도록 합시다.

 

JPA N+1문제란?

연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터의 개수만큼 연관관계의 조회 쿼리가 발생하여 데이터를 읽어오게 되는 문제입니다.

 

그렇다면 어떠한 경우에 이 N+1 문제가 발생할까요? 실제 코드를 보면서 같이 확인해보겠습니다.

 

예시

다음과 같은 상황의 User 클래스와 Team 클래스가 있다고 해봅시다.

User 클래스

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id @GeneratedValue
    Long id;

    String username;
    Long password;

    @ManyToOne(fetch = FetchType.EAGER)		// 즉시 로딩
    @JoinColumn(name = "team_id", nullable = false)
    private Team team;

    public User(String username, Long password) {
        this.username = username;
        this.password = password;
    }
}

 

Team 클래스

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {

    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(fetch = FetchType.EAGER)
    private List<User> users = new ArrayList<>();

}

 

팀당 여러명의 인원을 저장할 수 있는 구조라고 볼 수 있을 것같은데요.

테스트를 한번 해보겠습니다.

@Test
	public void test() throws Exception {
		Set<User> users = new LinkedHashSet<>();
		List<Team> teams = new ArrayList<>();

		for (int i = 0; i < 10; i++) {
			users.add(new User("user" + i+1, 1234L));
		}
		userRepository.saveAll(users);

		for(int i = 0; i < 2; i++){
			Team owner = new Team("owner" + i+1);
			owner.setUsers(users);
			teams.add(owner);
		}
		teamRepository.saveAll(teams);

		//when
		System.out.println("============== N+1 시점 확인용 ===================");
		teamRepository.findAll();



	    //then

	}

위 구조로 테스트를 진행했을 때, 조회시에는 몇번의 쿼리가 생성될까요?

============== N+1 시점 확인용 ===================
2023-04-12 21:39:21.756 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.758  INFO 3740 --- [           main] p6spy                                    : #1681303161758 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user01', 1);
2023-04-12 21:39:21.759 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.760  INFO 3740 --- [           main] p6spy                                    : #1681303161759 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user11', 2);
2023-04-12 21:39:21.760 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.760  INFO 3740 --- [           main] p6spy                                    : #1681303161760 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user21', 3);
2023-04-12 21:39:21.760 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.760  INFO 3740 --- [           main] p6spy                                    : #1681303161760 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user31', 4);
2023-04-12 21:39:21.760 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.760  INFO 3740 --- [           main] p6spy                                    : #1681303161760 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user41', 5);
2023-04-12 21:39:21.761 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.761  INFO 3740 --- [           main] p6spy                                    : #1681303161761 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user51', 6);
2023-04-12 21:39:21.761 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.761  INFO 3740 --- [           main] p6spy                                    : #1681303161761 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user61', 7);
2023-04-12 21:39:21.761 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.762  INFO 3740 --- [           main] p6spy                                    : #1681303161762 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user71', 8);
2023-04-12 21:39:21.762 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.762  INFO 3740 --- [           main] p6spy                                    : #1681303161762 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user81', 9);
2023-04-12 21:39:21.762 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.User
        */ insert 
        into
            user
            (password, team_id, username, id) 
        values
            (?, ?, ?, ?)
2023-04-12 21:39:21.762  INFO 3740 --- [           main] p6spy                                    : #1681303161762 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (?, ?, ?, ?)
/* insert com.ll.basic1.model.User */ insert into user (password, team_id, username, id) values (1234, NULL, 'user91', 10);
2023-04-12 21:39:21.762 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.Team
        */ insert 
        into
            team
            (name, id) 
        values
            (?, ?)
2023-04-12 21:39:21.763  INFO 3740 --- [           main] p6spy                                    : #1681303161763 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.Team */ insert into team (name, id) values (?, ?)
/* insert com.ll.basic1.model.Team */ insert into team (name, id) values ('owner01', 11);
2023-04-12 21:39:21.763 DEBUG 3740 --- [           main] org.hibernate.SQL                        : 
    /* insert com.ll.basic1.model.Team
        */ insert 
        into
            team
            (name, id) 
        values
            (?, ?)

팀만 출력했을 뿐인데, 팀에 포함되어있는 user들을 전부 쿼리로 생성해서 가져옵니다.

 

이렇게 진행되는 이유로는 유저들이 저장되어있는 팀배열이 FetchType.EAGER로 지정되어있기 때문입니다.

FetchType.EAGER은 연관된 엔티티들을 필요하지 않아도 모두 즉시 조회하기 때문인데요.

현재는 필요하지않은 유저 쿼리를 조회하게 되면 불필요한 메모리가 소요되고, 성능저하가 발생하게 됩니다.

 

따라서 지연로딩(LAZY)이라는 것으로 fetch모드를 지정해야하는데요. 지연로딩은 즉시로딩과는 다르게, 실제 사용되지 않으면 같이 조회되지 않고, 조회시에 프록시를 실제 사용할 때 조회되도록 하는 옵션입니다. 예시로 든 엔티티에서는 user를 접근하지 않으면 팀쿼리만 생성한다는 의미입니다.

 

지연로딩을 설정했다면 N+1문제는 발생하지 않는 것처럼 보입니다. 그러나

 

가져온 findAll 객체에서 users에 접근하려 하면! N+1문제가 동일하게 발생하게 되는데요.

 

이는 지연로딩을 적용해서 N+1이 표면적으로는 발생하지 않는 것처럼 보일 수 있지만, 막상 연관관계로 이루어진 객체에 접근을 하려고 하게되면! 연관된 엔티티 객체까지 조회를 하기 때문입니다.

 

지연로딩이지만 N+1문제가 발생하는 경우

  1. findAll 메소드를 실행하는 순간 select t from Team t 이라는 jpql 구문이 생성되고 해당 구문을 분석한 select * from team 이라는 sql이 생성되어 실행된다. (sql로그 중 Hibernate: select team0_.id as id1_0_, team0_.name as name2_0_ from team team0_ 부분)
  2. 코드 중에서 team 의 user 객체를 사용하려고 하는 시점에 영속성 컨텍스트에서 연관된 user가 있는지 확인한다.
  3. 영속성 컨텍스트에 없다면 위에서 만들어진 team 인스턴스들 개수에 맞게 select * from user where team_id = ? 이라는 SQL 구문이 생성된다. (N+1 발생)

그렇다면, 이러할때는 어떻게 해줘야할까요?

 

N+1 문제 해결방안

여러가지 해결방안이 존재하지만 저는 fetch join으로 문제를 해결하는 것을 우선으로 생각합니다. 이는 jpql구문으로 작성될때, db에서 값을 조회할 경우 처음부터 연관된 데이터까지 같이 가져오게 하는 방법입니다. 물론 EntityGraph, Batch Size를 지정하는 등 방법이 있지만,  추천되는 방법은 아니라 알아두시기만 하시는게 좋을 것 같습니다..

 

EAGER와 같은 방식이라고 생각하실 수도 있지만, 연관된 데이터의 경우 사용할 데이터만 콕 찝어서 가져오게 할 수 있는 것이 fetch join입니다. 기본으로는 리포지토리 클래스에서 @Query 어노테이션을 사용해서 "엔티티1~~~`join fetch ~~~연관된 엔티티2" 와 같은 구문으로 작성되는데요.

 

아까의 테스트 환경에서는 findAll시에 개선 방법으로 보여드리자면,

@Query("select t from Team t join fetch t.users")
    List<Team> findAllFetchJoin();

와 같이 작성되고 이것의 반환 쿼리를 보면,

2023-04-12 22:02:17.507  INFO 3508 --- [           main] p6spy                                    : #1681304537507 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* insert com.ll.basic1.model.Team */ insert into team (name, id) values (?, ?)
/* insert com.ll.basic1.model.Team */ insert into team (name, id) values ('owner11', 12);
2023-04-12 22:02:17.510 DEBUG 3508 --- [           main] org.hibernate.SQL                        : 
    /* select
        t 
    from
        Team t 
    join
        fetch t.users */ select
            team0_.id as id1_2_0_,
            users1_.id as id1_3_1_,
            team0_.name as name2_2_0_,
            users1_.password as password2_3_1_,
            users1_.team_id as team_id4_3_1_,
            users1_.username as username3_3_1_,
            users1_.team_id as team_id4_3_0__,
            users1_.id as id1_3_0__ 
        from
            team team0_ 
        inner join
            user users1_ 
                on team0_.id=users1_.team_id
2023-04-12 22:02:17.511  INFO 3508 --- [           main] p6spy                                    : #1681304537511 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/basic1
/* select t from Team t join fetch t.users */ select team0_.id as id1_2_0_, users1_.id as id1_3_1_, team0_.name as name2_2_0_, users1_.password as password2_3_1_, users1_.team_id as team_id4_3_1_, users1_.username as username3_3_1_, users1_.team_id as team_id4_3_0__, users1_.id as id1_3_0__ from team team0_ inner join user users1_ on team0_.id=users1_.team_id
/* select t from Team t join fetch t.users */ select team0_.id as id1_2_0_, users1_.id as id1_3_1_, team0_.name as name2_2_0_, users1_.password as password2_3_1_, users1_.team_id as team_id4_3_1_, users1_.username as username3_3_1_, users1_.team_id as team_id4_3_0__, users1_.id as id1_3_0__ from team team0_ inner join user users1_ on team0_.id=users1_.team_id;
2023-04-12 22:02:17.518  INFO 3508 --- [           main] p6spy                                    : #1681304537518 | took 0ms | rollback | connection 3| url jdbc:h2:tcp://localhost/~/basic1

;
2023-04-12 22:02:17.519  INFO 3508 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@babafc2 testClass = Basic1ApplicationTests, testInstance = com.ll.basic1.Basic1ApplicationTests@67b20b4c, testMethod = test@Basic1ApplicationTests, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@31add175 testClass = Basic1ApplicationTests, locations = '{}', classes = '{class com.ll.basic1.Basic1Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@4cc8eb05, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@79da8dc5, org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@696da30b, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@29e495ff, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@1a4013, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@79be0360], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]

와 같이 하나의 쿼리만을 생성하는 것을 확인하실 수 있으십니다.

 

이렇게 우리는 JPA의 N+1 문제를 해결할 수 있었습니다.!

부록

본문에서는 jpql만으로 작성되는 fetch join을 보여드렸지만, querydsl로 작성하면 jpql로 작성하는 것보다 동적쿼리를 직관적으로 작성하고, 자바코드처럼 빠르게 오류를 찾아낼 수 있는 장점이 있습니다. 공부해보시는게 좋을 것 같아요!

반응형

댓글