crossorigin="anonymous"> $(function(){ $('.article_view').find('table').each(function (idx, el) { $(el).wrap('
') }); $('img[alt="N"]').each(function(){ $(this).replaceWith('

N

') }); });

새소식

고수만/언어

JAVA N+1에 대하여 - 해결 방법 (3) - 어려움

  • -

N+1의 해결 방법

 

 

N+1 문제를 설명하면서 왜 길게 JoinColumn을 설명했냐 하면 이러한 객체 참조를 이용할 때 많이 나타날 수 있기 때문이다. 물론 JPA를 사용하지 않아도 발생할 수 있지만 비슷한 ORM을 사용할 때 많이 발생하고 해결해야 하기에 JoinColumn 먼저 설명하였다.

이론상 Contest에서 participants를 일대다로 JoinColumn 하고 Lazyloading을 썼다면 , Contest만 조회를 하면 participants는 조회를 하지 않아야 한다. 그러나 문제는 Entity를 View로 전환 하는 과정이다. API에서 Entity를 그대로 내보내면 최종 Return 객체 값을 Json 형태로 바꿔준다.

   @GetMapping("/contests")
    public List<ContestEntity> getContests() {
        List<ContestEntity> contestEntities= contestRepository.findAll();
        return contestEntities;
    }

 

Return List<ContestEntity> 이 부분이다.

return contestEntities; 이 부분에 브레이크를 잡아보면

 

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0

 

여기 까지만 호출 된 것을 볼 수 있다.

즉 그 이후 return에서 Json으로 값을 바꾸는 과정에서 참조가 LazyLoading이 일어나 호출을 진행한 것이다.

물론 fetchType을 Eager 로 바꾸어도 결과는 같다. 왜냐하면 최종적으로 엔티티가 Json으로 바뀌는 과정에서

추가 조회가 일어나는 것이기 때문이다.

그럼 이를 JPQL로 left join을 하면 한번에 조회를 해 가져오니 해결되지 않을까? 라는 생각을 했지만

@Repository
public interface ContestRepository extends JpaRepository<ContestEntity, Long> {
    @Query("SELECT c FROM ContestEntity c  left join c.participants p")
    public List<ContestEntity>findAll();
}

 

여기서 LEFT JOIN이 추가되어 조회를 한건 사실이지만 조인만 수행하였을 뿐

연관된 엔티티의 데이터를 함께 조회해 엔티티를 채우는 필요한 추가 조치가 없었다. 그래서

 

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0 
    left join
        participant p1_0 
            on ce1_0.id=p1_0.contest_id
Hibernate: 
    select
        p1_0.contest_id,
        p1_0.id,
        p1_0.name 
    from
        participant p1_0 
    where
        p1_0.contest_id=?


    select
        p1_0.contest_id,
        p1_0.id,
        p1_0.name 
    from
        participant p1_0 
    where
        p1_0.contest_id=?

 

위와 같이 left join을 하고도 participant 을 N번 호출하게 된다.

이를 해결하기 위해선 여러 방법이 존재하는데 대표적인 방법들을 이야기 해보겠다.

방법 1 : fetch join 사용

JPQL에 fetch join을 사용하면 연관된 엔티티를 한번에 조회가 가능하다.

@Repository
public interface ContestRepository extends JpaRepository<ContestEntity, Long> {
    @Query("SELECT c FROM ContestEntity c  left join FETCH c.participants p")
    public List<ContestEntity>findAll();
}

결과 값

Hibernate: 
    select
        ce1_0.id,
        p1_0.contest_id,
        p1_0.id,
        p1_0.name,
        ce1_0.title 
    from
        contest ce1_0 
    left join
        participant p1_0 
            on ce1_0.id=p1_0.contest_id

 

추가적인 participant 조회가 일어나지 않는 것을 볼 수 있다.

방법 2: @EntityGraph

1.명시적 엔티티 그래프

아래는 명시적 엔티티 그래프 사용 코드이다.

엔티티에 명시적으로 NamedEntityGraph를 사용해 관계를 작성해주고

JPA 레포지토리에 @EntityGraph를 사용하여 엔티티그래프를 사용해준다.

 

//ContestEntity

@NamedEntityGraph(name = "ContestEntity.withParticipants",
        attributeNodes = @NamedAttributeNode("participants"))
@Data
@Table(name = "contest")
@Entity
public class ContestEntity  {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String title;

    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "contest_id")
    List<ParticipantEntity> participants;

}

 

//Repository

@Repository
public interface ContestRepository extends JpaRepository<ContestEntity, Long> {
    @EntityGraph(value = "ContestEntity.withParticipants", type = EntityGraph.EntityGraphType.LOAD)
    public List<ContestEntity>findAll();
}

 

결과 값

Hibernate: 
    select
        ce1_0.id,
        p1_0.contest_id,
        p1_0.id,
        p1_0.name,
         ce1_0.title 
    from
        contest ce1_0 
    left join
        participant p1_0 
            on ce1_0.id=p1_0.contest_id

 

 

2.동적 엔티티 그래프

조회의 쿼리가 간단하다면 JPA string data를 사용하여 위와 같이 명시적으로 사용해주거나 FETCH JOIN을 사용하면 되지만 조회 조건이 복잡한 경우 에는 Entity Manager를 사용하여 JPQL로 직접 쿼리문을 만들어서

결과를 가져와야 한다. 그러나 JPQL로 FETCH JOIN을 사용하면 JSON 변환 과정에서 N+1이 일어나는 똑같은 문제가 생긴다.

@Repository
public class CustomRepositoryImpl implements CustomRepositoy {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<ContestEntity> findAll() {
        var jpql = "SELECT c FROM ContestEntity c join fetch EntryConfigEntity ec ON ec.id=c.id WHERE 1=1 ";

        //상황에 따라 조회 조건을 추가 가능함.
        //ex) if(조회 조건) jpql += "c.id = :contestId" 

        var query = entityManager.createQuery(jpql, ContestEntity.class);
        return query.getResultList();
    }
}

 

동적 엔티티 그래프는 런타임에 엔티티를 로딩 전략을 동적으로 변경할 수 있는 방법이다.

EntityManager 를 사용하여 쿼리를 실행할 때 로딩 전략을 지시할 수 있게 해준다.

명시적 엔티티 그래프보다 복잡한 쿼리나 런타임 기반을 한 로딩 전략이 필요할 때 사용이 가능하다.

위 쿼리를 동적 엔티티를 적용시키면 아래와 같다.

    @Override
    public List<ContestEntity> findAll() {
        var jpql = "SELECT c FROM ContestEntity c join fetch EntryConfigEntity ec ON ec.id=c.id WHERE 1=1 ";
        EntityGraph<ContestEntity> graph = entityManager.createEntityGraph(ContestEntity.class);

        graph.addAttributeNodes("entryConfigEntity");

        var result = entityManager.createQuery(jpql, ContestEntity.class).setHint("javax.persistence.loadgraph", graph).getResultList();
        return result;
    }

 

엔티티의 연관 엔티티의 관계를 graph로 만들어 setHint로 넣어주면 쿼리를 만들어 조회할 때 반영을 동적으로 해 결과를 가져와준다.

Hibernate: 
    select
        ce1_0.id,
        ece2_0.id,
        ece2_0.max_entry_count,
        ece2_0.now_entry_count,
        ce1_0.title 
    from
        contest ce1_0 
    join
        entry_config ece1_0 
            on ece1_0.id=ce1_0.id 
    left join
        entry_config ece2_0 
            on ece2_0.id=ce1_0.id 
    where
        1=1

 

아래와 같이 해당 결과 값은 N+1이 해결되어 나오는 걸 볼 수 있다.

 

방법 3: BatchSize 사용

다시 돌아와서 ContestEntity EntryConfigEntity가 1대1로 연관 관계가 맺어진 상황에서

ContestEntity 가 조회 된 결과 값이 2개 일 때 EntryConfigEntity를 2번 조회하는 N+1을 다시

생각해 보자

결과 값은 이러하다.

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0
Hibernate: 
    select
        ece1_0.id,
        ece1_0.max_entry_count,
        ece1_0.now_entry_count 
    from
        entry_config ece1_0 
    where
        ece1_0.id=?
2024-03-19T11:56:44.099+09:00 TRACE 194988 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
Hibernate: 
    select
        ece1_0.id,
        ece1_0.max_entry_count,
        ece1_0.now_entry_count 
    from
        entry_config ece1_0 
    where
        ece1_0.id=?
2024-03-19T11:56:44.103+09:00 TRACE 194988 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [2]

 

contest ce1_0 의 결과 값 만큼 N+1로 entry_config ece1_0 를 조회하는 문제를 볼 수있다.

BatchSize는 이 문제는 독특하게 해결해 준다.

일반 BatchSize 는 연관 된 엔티티를 로드할 떄 지정된 크기의 배치로 여러 엔티티를 한번에 로드하는 방법이다. 여기서 중요한건 ‘지정된 크기’ 도 있지만 ‘한번에 로드’가 중요하다.

아래는 BatchSize 를 사용한 엔티티 코드이다. 참조 된 엔티티 필드와 엔티티 모두 BatchSize 를 넣어준 것을 볼 수 있다.

@Data
@Table(name = "contest")
@Entity
public class ContestEntity  {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String title;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id")
    @BatchSize(size = 10)
    private EntryConfigEntity entryConfigEntity;

}
@Data
@Entity
@Table(name = "entry_config")
@BatchSize(size = 10)
public class EntryConfigEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private int MaxEntryCount;
    private int NowEntryCount;

}

 

위 코드로 조회 하면

아래와 같이 나온다.

contest ce1_0를 한번 조회하고 entry_config ece1_0 를 한번 조회한다. 그런데

ece1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)로 조회 한 것이 보인다.

 

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0
Hibernate: 
    select
        ece1_0.id,
        ece1_0.max_entry_count,
        ece1_0.now_entry_count 
    from
        entry_config ece1_0 
    where
        ece1_0.id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2024-03-19T12:01:21.840+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
2024-03-19T12:01:21.840+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (2:BIGINT) <- [2]
2024-03-19T12:01:21.840+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (3:BIGINT) <- [null]
2024-03-19T12:01:21.840+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (4:BIGINT) <- [null]
2024-03-19T12:01:21.840+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (5:BIGINT) <- [null]
2024-03-19T12:01:21.841+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (6:BIGINT) <- [null]
2024-03-19T12:01:21.841+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (7:BIGINT) <- [null]
2024-03-19T12:01:21.841+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (8:BIGINT) <- [null]
2024-03-19T12:01:21.841+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (9:BIGINT) <- [null]
2024-03-19T12:01:21.841+09:00 TRACE 176852 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (10:BIGINT) <- [null]

 

배치 사이즈만큼 조회 할 목록이 있는지를 IN으로 판단하여 조건으로 넣어 검색하는 것이다.

그래서 사이즈 만큼 조회하면서 한번에 조회가 가능하다!

물론 무조건 참조 엔티티를 한번 조회를 한다는 점과 불필요한 조건들도 검색 대상이 된다는 점은 아쉬운 부분이다.

방법 4: DTO 사용

드디어 DTO를 사용해서 해결하는 방법이다.

이는 Entity가 가진 연관 관계를 끊어주는 방법인데

JPA Spring Data의 결과 값을 DTO의 생성자로 직접 받아서 보여주는 방법이다.

아래는 ContestEntity 를 받을 ContestDTO 이고 그 안에는 엔티티 속성을 받을 생성자이다.

JQPL안에는 new 를 사용해 DTO 객체를 생성함과 동시에 데이터를 매핑해가져온다.

 

@Data
public class ContestDTO {
    private long id;
    private String title;

    public ContestDTO(long id, String title) {
        this.id = id;
        this.title = title;
    }
}
@Repository
public interface ContestRepository extends JpaRepository<ContestEntity, Long> {
    @Query("SELECT new com.example.testproject.persistent.entity.ContestDTO(c.id,c.title) FROM ContestEntity c ")
     List<ContestDTO>findAllByDTO();
}

 

아래는 결과 조회문이다.

당연히 쿼리가 단순하게 조회된다.

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0

 

그러나 DTO의 큰 문제점이 존재한다.

바로 생성자에 엔티티를 바로 넣을 수 없다는 점이다.

ContestDTO 의 생성자의 요청 파라미터를 보면 id와 title이 각각 있다. 요청 파라미터를 ContestEntity로 받아와 하나하나 매핑해주면 좋지 않을까?

그렇게 하는 순간 ContestEntity 자체를 조회하게 되어 N+1의 지옥이 또 다시 시작된다…

필드 값이 많지 않다면 좋은 방안이 될 수 있지만

5개가 넘어가는 순간 생성자와 JPQL에 길게 나열되어 들어가고 가독성을 심하게 떨어트린다.

또한 연관 관계 안에 연관 관계가 있는 경우에는 이 문제가 더 심해진다.


그러면 해결 된 것일까?

위에서 여러 해결 방법이 나오고 각 장단점을 이야기 하였다.

그럼 저 중에 한 가지를 쓰면 해결이 될까? 안타깝게도 또 다른 예외가 존재한다.

Pagination

우리는 조회를 할 때 전체 조회를 피하는 경우가 많다. 이유는 굳이 모두 다 들고와

메모리에 부하를 줄 필요가 없기 때문이다. 이는 DB에도 부하를 줄 수 있으며 서버 인스턴스의

인메모리에도 부하를 줄 수 있다.

그래서 우리는 필요한 페이지만 조회를 하는 페이징을 사용한다.

자바의 Page 인터페이스를 사용하면 Page size와 number를 설정해 해당 페이지만큼만 조회 할 수 있도록

할 수 있다.

아래와 같이 entityManager에 시작 점과 찾는 사이즈를 지정해주면 offset과 limit 설정이 가능하다.

var query = this.entityManager.createQuery(sql, c);
    
    query.setFirstResult(pageable.getPageNumber() * pageable.getPageSize());
    query.setMaxResults(pageable.getPageSize());

근데 뭐가 문제라는 것인가?

아래는 문제가 되는 상황은 OneToMany의 조회일 때이다.

ContestEntity  participants가 OneToMany의 관계로 연관 관계가 있을 때

동적 그래프를 사용하여 N+1를 해결한 예제이다.

다른 점은 Page로 호출해서 결과 값을 Page로 가져온다는 것이다.

(OneToOne에선 문제 없음, OneToOne에선 Limit 설정이 잘 됨)

 

//호출 메서드

    @GetMapping("/contests/custom")
    public Page<ContestEntity> getContestsByCustom(int page, int size) {
        Pageable pageable = Pageable.ofSize(size).withPage(page);
        Page<ContestEntity> contestEntities= customRepositoryImpl.findAll(pageable);
        return contestEntities;
    }

 

//ContestEntity

@Data
@Table(name = "contest")
@Entity
public class ContestEntity  {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String title;

    @OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name = "contestId")
    private List<ParticipantEntity> participants;

}

 

//repository

@Repository
public class CustomRepositoryImpl implements CustomRepositoy {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Page<ContestEntity> findAll(Pageable pageable) {
        var jpql = "SELECT c FROM ContestEntity c ";
        EntityGraph<ContestEntity> graph = entityManager.createEntityGraph(ContestEntity.class);

        graph.addAttributeNodes("participants");

        var query =entityManager.createQuery(jpql, ContestEntity.class);
        query.setFirstResult(pageable.getPageNumber() * pageable.getPageSize());
        query.setMaxResults(pageable.getPageSize());

        var results = query.setHint("javax.persistence.loadgraph", graph).getResultList();

        var totalRows = entityManager.createQuery("SELECT COUNT(c) FROM ContestEntity c", Long.class).getSingleResult();
        return new PageImpl<>(results, pageable, totalRows);
    }
}

 

//결과 값

Hibernate: 
    select
        ce1_0.id,
        p1_0.contest_id,
        p1_0.id,
        p1_0.name,
        ce1_0.title 
    from
        contest ce1_0 
    left join
        participant p1_0 
            on ce1_0.id=p1_0.contest_id
Hibernate: 
    select
        count(ce1_0.id) 
    from
        contest ce1_0

 

결과 값을 보면 전체 조회를 한 join문과 전체 카운트를 위해 조회한 카운트 조회 문 2개가 보인다.

N+1이 잘 해결 된 모습이다. 그러나

N+1을 해결한 코드를 자세히 보면 기존의 linit와 offset이 사라진 것을 볼 수 있다.

 

그 이유는 eager 타입으로 fetch join 하기 때문에 필요한 엔티티를 모두 가져와서

인메모리 상에서 조인한다. pagination의 장점인 limit으로 검색 구역을 좁히고

나온 결과물을 메모리에 올리는 것이 아니라 모두 가져와서 메모리에서 조인하고 엔티티를

필터링하여 page로 나눈다.

 

따라서 oneToMany의 부분이 커질 수록 부하가 커진다.

 

즉 Pagination에선 eager 타입으로 모두 들고와 N+1을 해결하는건 바람직 하지 않다는 것이다.

 

결과적으로 N+1과 Pagination이 혼합 된 문제에선

BatchSize 나 DTO로 풀어야 한다.

 

이 BatchSizse로 문제를 해결하면 아래와 같다

Hibernate: 
    select
        ce1_0.id,
        ce1_0.title 
    from
        contest ce1_0 
    limit
        ?, ?
2024-03-19T15:24:30.852+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:INTEGER) <- [0]
2024-03-19T15:24:30.852+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (2:INTEGER) <- [2]
Hibernate: 
    select
        count(ce1_0.id) 
    from
        contest ce1_0
Hibernate: 
    select
        p1_0.contest_id,
        p1_0.id,
        p1_0.name 
    from
        participant p1_0 
    where
        p1_0.contest_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (2:BIGINT) <- [2]
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (3:BIGINT) <- [null]
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (4:BIGINT) <- [null]
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (5:BIGINT) <- [null]
2024-03-19T15:24:30.902+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (6:BIGINT) <- [null]
2024-03-19T15:24:30.903+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (7:BIGINT) <- [null]
2024-03-19T15:24:30.903+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (8:BIGINT) <- [null]
2024-03-19T15:24:30.903+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (9:BIGINT) <- [null]
2024-03-19T15:24:30.903+09:00 TRACE 90504 --- [TestProject] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (10:BIGINT) <- [null]

 

OneToOne에서 LazyLoading 이슈

 

추가적인 문제 사항으로 OneToOne LazyLoading이 발동을 안하는 이슈 사항이 있을 수 있다.

해당 내용은 아래에 잘 정리가 되있기에 읽어보시길 바란다.

 

 

JPA 도입 — OneToOne 관계에서의 LazyLoading 이슈 #1

JPA를 쓰다 보면, DB설계시 자연스럽게 적용했던 테이블간 1:1 관계로 인한 예상치 못한 어려움과 혼돈을 겪는 경우가 발생한다. 이 글은 1:1 관계로 인해 발생하는 이슈들과 고민, 그에 따른 여러

yongkyu-jang.medium.com

 

결론

ORM을 사용하면서 개발자는 큰 편의성을 얻는다. 그러나 기본 설계가 맞지 않거나 잘못 설계되면

ORM을 위한 개발이 진행되는 상황을 겪게 된다. JoinColumn은 개발에 큰 편의를 제공하지만 예상치 못한

N+1이나 Pagination 문제로 부하를 야기할 수 있다.

 

hibernate에 적합하게 DB설계를 하고 싶다면 차라리 ORM에 맞추어 자동 DB가 업데이트 되도록 하는 것이

차라리 나아 보인다. 자그마한 DB 설계 실수가 무한 오류를 발생 시킬 수 있다.

DB의 조인 관계가 있다고 Entity 관계에 모두 JoinColumn을 쓰는 건 피해야 될 것으로 보이며

필요할 때만 사용하고 나머지는 JPA spring data와 JPQL을 적절히 섞어 주는 것이 좋아 보인다.

 

Contents