N+1 문제란?

어떠한 값을 얻기 위해 JPA를 이용하여 1개의 쿼리를 사용하는 것을 의도했지만, 개발자의 의도와는 다르게 N개의 쿼리가 더발생하는 문제입니다. 

 

JPA를 공부한 적이 있는 사람이라면, @xxxToOne(fetch = FetchType.EAGER)로 설정돼 있는 것들을 @xxxToOne(fetch = FetchType.LAZY)로 설정하면 된다는 것을 들어보셨을 수도 있습니다. 저 역시도 이렇게 알고 있었지만, 프로젝트를 진행하며 겪은 N+1 문제를 살펴보겠습니다.

 

테이블

Parent와 Child가 1:1 관계를 가졌을 때 문제가 발생하므로 해당 케이스만 살펴보겠습니다. 여기서 연관관계의 주인은 Child로 설정하겠습니다. 

 

Parent

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "parent_id")
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "age", nullable = false)
    private int age;

    @OneToOne(mappedBy = "parent", fetch = FetchType.LAZY)
    private Child child;

    public static Parent createParent(String name, int age) {
        Parent parent = new Parent();
        parent.name = name;
        parent.age = age;
        return parent;
    }

    public void setChild(Child child){
        this.child=child;
    }
}

 

Child

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

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "child_id")
    private Long id;

    @Column(name = "child_name")
    private String name;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Parent parent;

    public static Child createChild(String name, Parent parent){
        Child child = new Child();
        child.name=name;
        child.parent=parent;
        parent.setChild(child);
        return child;
    }
    
}

 

미리 데이터를 넣어놨습니다. 한 개의 데이터씩만 들어있습니다. 

Controller

@RestController
@RequiredArgsConstructor
public class ParentController {

    private final ParentRepository parentRepository;

    private final ChildRepository childRepository;

    @GetMapping("/parent")
    public void getParent(){
        Parent parent = parentRepository.findById(1l).get();
    }

    @GetMapping("/child")
    public void getChild(){
        Child child = childRepository.findById(1L).get();
    }

}

지연 로딩 테스트 진행

1. 연관 관계의 주인(Child)을 조회했을 때

Postman으로 /child를 호출합니다.

지연 로딩을 설정했으므로, parent는 조회되지 않고, child만 조회됐습니다.

 

2. 연관 관계의 주인이 아닌 것(Parent)을 조회했을 때

Postman으로 /parent 를 호출합니다.

지연 로딩 설정으로 N+1 문제를 해결했다고, 생각했지만 예상과 다르게 child를 조회하는 쿼리가 나가  N+1 문제가 발생했습니다. 

 

지연 로딩 설정에도 불구하고 N+1 문제가 발생한 이유

JPA에선 객체 그래프 탐색이라는 개념이 존재합니다. 위에서 그래프 탐색을 보여주진 않았지만, child.getParent()를 실행해주면 child -> parent로 객체 그래프 탐색을 진행할 수 있습니다. 이처럼 연관 관계를 맺은 엔티티들이 그래프 탐색을 진행할 수 있고, 이러한 전략을 정하는 것이 앞서 잠시 봤던, 지연 로딩 전략입니다. 지연 로딩 설정한 특정 엔티티를 조회할 때 실제 조회된 엔티티가 아니라 엔티티를 가져올 수 있도록 설정된 임의 객체가 생성됩니다. 이것을 프록시 객체라고 합니다. 실제로 프록시 객체인지 테스트 코드로 살펴보겠습니다. 

 

@Test
void proxyTest(){
    Parent parent = Parent.createParent("hi", 10);

    parentRepository.save(parent);

    Child child = Child.createChild("bye", parent);

    childRepository.save(child);

    em.flush(); // 1차 캐시를 비워주기 위해서 남아있는 것을 데이터베이스에 반영
    em.clear(); // 1차 캐시 비움

    Child resultChild = childRepository.findById(1L).get();

    Parent resultParent = resultChild.getParent();
    
    log.info("{}",Hibernate.isInitialized(resultParent)); // 결과값 프록시인지 출력
    assertThat(Hibernate.isInitialized(resultParent)).isFalse(); // 실제 객체인지 프록시인지 확인
}

 

주석으로 약간의 설명을 써놨습니다. 결론적으론 resultParent가 Proxy인지 아니면 실제 객체인지 확인하는 과정입니다. 결과는 아래와 같습니다. 

 

실제 객체가 아니고, 프록시이므로 테스트가 통과하며 log를 보시면 프록시인 것을 확인시켜주는 false입니다.

 

이제 Parent로 프록시 테스트를 확인해보겠습니다.

 

@Test
void proxyTest(){
    Parent parent = Parent.createParent("hi", 10);

    parentRepository.save(parent);

    Child child = Child.createChild("bye", parent);

    childRepository.save(child);

    em.flush();
    em.clear();

    Parent resultParent = parentRepository.findById(1L).get();

    Child resultChild = resultParent.getChild();

    log.info("{}",Hibernate.isInitialized(resultChild));
    assertThat(Hibernate.isInitialized(resultChild)).isFalse();
}

 

테스트는 실패합니다. 즉 프록시 객체가 아닌 실제 객체가 반환된 것을 알 수 있습니다. 

 

이러한 결과가 나오는 이유는 Proxy를 생성하기 위해선 Non-Null 조건이 있기 때문입니다. 하지만 연관 관계의 주인을 조회할 때는 N+1 문제가 발생하지 않습니다. 그 이유는 데이터베이스 테이블을 확인하면 알 수 있습니다. 

 

 

child 테이블에는 parent_id가 있어서 외래 키 조건을 만족하며, 객체 그래프 탐색에서 Non-Null을 만족해 프록시를 생성할 수 있습니다. 하지만, parent를 조회할 때는 child에 대한 id값을 가지고 있지 않기 때문에 객체 그래프 탐색을 하기 위해선 프록시가 아닌 실제로 조회해야 하는 상황이 발생해 N+1 문제가 발생합니다.

하지만 1:다의 관계에선 프록시가 적용됩니다. 그 이유는 간단합니다. mappedBy 속성을 주게 되면 insertable, updatable의 속성이 false로 설정돼 있기 때문에 아래와 같이 

List <Parent> parents = new ArrayList <>(); 필드에서 바로 초기화를 해주기 때문에 Non-null 조건을 만족해 프록시를 생성하기 때문입니다. 

 

해결방법

2가지가 있습니다. 

 

1. 패치 조인 사용

N+1 가장 큰 문제는 개발자가 알지 못하는 쿼리가 발생하는 것이므로 패치 조인을 통해 개발자는 어떠한 쿼리가 나가는지 알 수 있고, 쿼리가 한 번 나가 대체로 성능 최적화를 진행할 수 있습니다. 

 

아래와 같이 @EntityGraph를 통해서 패치 조인을 진행하고, 쿼리를 확인합니다. (@Query에서 패치 조인을 명시해도 됩니다.)

@EntityGraph(attributePaths = "parent")
Optional<Child> findListById(Long id);
@GetMapping("/parent")
public void getParent(){
    Parent parent = parentRepository.findFetchChildById(1L).get();
}

 

 

 

레프트 조인으로 한 개의 쿼리만 나가는 것을 알 수 있습니다. 

 

2. Querydsl 사용

Querydsl을 사용해서 필요한 값들만 프로젝션 해서 사용한다면, N+1 문제가 발생하지 않습니다. 해당 예시는 따로 기재하지 않겠습니다.

 

결론

지연 로딩 설정이 항상 적용되는 것은 아니고, @OneToOne에서 연관 관계의 주인이 아닌 것을 조회할 때  N+1 문제가 발생한다. 따라서 패치 조인이나 Querydsl을 이용한 프로젝션으로 N+1 쿼리를 해결할 수 있고, 대체로 최적화를 진행할 수 있다.