오랜 외도 (파견 근무) 끝에 본사 복귀 후 내부 서비스 운영 팀장을 맡게 되었는데요.
업무 파악 시작한지 한주도 되기전에 충격에 빠지고 말았습니다.
서비스 중인 화면 이동에 너무 오랜 시간이 소요 되는것을 보고 로그를 찍어 봤는데...
미친듯이 찍혀 나오는 DB 조회 로그들... 심지어 메뉴 이동시 마다 메뉴 목록 + 메뉴별 role 정보 조회를 하고 있는... ㅠㅠ
운영중인 서비스는 숨넘어 가기 직전의 응급 환자 상태 였으며, JPA 를 이용하면서 Entity 구조를 잘못 잡아 N+1 이슈 발생한 케이스 였다는걸 알아 내는데 그리 오랜 시간이 걸리진 않았습니다.
Spring boot 의 JPA 는 무엇이며 N+1 이슈는 어떤건지, 그리고 해결 방법에 대해 간단히 정리 하겠습니다.
JPA 란?
JPA(Java Persistence API)는 자바 애플리케이션과 관계형 데이터베이스를 연결하는 기술입니다. JPA는 ORM(Object-Relational Mapping)을 기반으로 하며, 객체 지향 프로그래밍과 관계형 데이터베이스 간의 매핑을 단순화하고 개발자가 데이터베이스에 대한 저수준의 작업을 추상화합니다.
JPA를 사용하면 객체와 테이블 간의 매핑을 어노테이션 또는 XML 설정으로 정의할 수 있습니다. 이를 통해 개발자는 SQL 쿼리를 직접 작성하지 않고도 객체 지향적인 방식으로 데이터베이스에 접근할 수 있습니다. JPA는 엔티티(Entity), 영속성 컨텍스트(Persistence Context), 엔티티 매니저(Entity Manager) 등 다양한 개념과 기능을 제공합니다.
그러나 JPA를 사용할 때 발생할 수 있는 성능 문제 중 하나가 "N+1 이슈"입니다.
N+1 이슈란?
한 번의 쿼리로 가져온 엔티티들과 관련된 연관 엔티티들을 조회할 때 추가적인 N번의 쿼리가 발생하는 현상을 의미합니다. 예를 들어, 게시물 목록을 조회하는 쿼리에서 각 게시물의 작성자 정보를 함께 가져와야 할 경우, 게시물 개수만큼의 추가 쿼리가 실행되어 성능 저하를 초래합니다.
N+1 이슈는 다음과 같은 상황에서 주로 발생합니다.
일대다(One-to-Many) 또는 다대다(Many-to-Many)와 같은 연관 관계에서 Lazy Loading 설정이 되어 있을 때.
연관 엔티티에 대한 필요한 정보가 사용되기 전까지 초기화되지 않았을 때.
해결 방법으로는 다음과 같은 접근 방식들이 있습니다:
- Fetch Join 사용: Fetch Join은 JPQL(Java Persistence Query Language) 또는 Criteria API를 사용하여 한 번의 쿼리로 모든 필요한 데이터를 한 번에 가져옵니다. 이렇게 하면 N+1 문제가 발생하지 않습니다.
- Batch Size 설정: Hibernate에서 제공하는 @BatchSize 어노테이션을 사용하여 일대다 또는 다대다 관계에서 한 번에 가져올 엔티티의 수를 지정할 수 있습니다.
- Entity Graphs 활용: JPA 2.1부터 도입된 Entity Graphs 기능을 사용하여 필요한 연관 엔티티들도 함께 로딩하도록 명시적으로 지정할 수 있습니다.
- DTO(Data Transfer Object) 활용: 필요한 데이터만 선택하여 DTO 객체로 변환하여 반환함으로써 원하는 정보만 조회하고 N+1 문제를 회피할 수 있습니다.
N+1 이슈는 JPA 애플리케이션 개발 시 주의해야 할 성능 문제 중 하나입니다. 위에서 소개한 해결 방법 중 상황에 맞게 가장 적합한 방법을 선택하여 성능 향상을 추구할 수 있습니다
JPA N+1 이슈에 대한 예제 코드와 해결 방법 예제 (여기서는 Fetch join 만)
예를 들어, 게시물(Post)과 작성자(Author)라는 두 개의 엔티티가 일대다 관계로 연관되어 있다고 가정해보겠습니다. 각 게시물에 대한 작성자 정보를 조회할 때 N+1 이슈가 발생할 수 있습니다.
엔티티 클래스 정의:
@Entity
public class Post {
@Id
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
private Author author;
// Getters and Setters
}
@Entity
public class Author {
@Id
private Long id;
private String name;
// Getters and Setters
}
위의 예제에서는 Post 엔티티가 Author 엔티티와 Many-to-One 관계로 매핑되어 있습니다.
FetchType.LAZY 옵션을 사용하여 게시물을 조회할 때 작성자 정보는 지연 로딩됩니다.
N+1 이슈 발생하는 코드:
List<Post> posts = entityManager.createQuery("SELECT p FROM Post p", Post.class).getResultList();
for (Post post : posts) {
System.out.println("Title: " + post.getTitle());
System.out.println("Author: " + post.getAuthor().getName()); // N+1 이슈 발생!
}
위의 코드에서는 모든 게시물을 조회한 후 각 게시물의 작성자 정보를 출력하고 있습니다.
하지만 post.getAuthor().getName() 호출 시마다 추가적인 쿼리가 실행되므로 N+1 문제가 발생합니다.
N+1 이슈 해결 방법 - Fetch Join 사용:
List<Post> posts = entityManager.createQuery("SELECT p FROM Post p JOIN FETCH p.author", Post.class)
.getResultList();
for (Post post : posts) {
System.out.println("Title: " + post.getTitle());
System.out.println("Author: " + post.getAuthor().getName()); // 추가 쿼리 없이 작동!
}
위의 코드에서는 Fetch Join을 사용하여 한 번의 쿼리로 모든 필요한 데이터를 가져오도록 변경되었습니다.
"SELECT p FROM Post p JOIN FETCH p.author" 쿼리는 게시물과 작성자를 함께 로딩하므로 N+1 문제가 발생하지 않습니다.
이처럼 Fetch Join은 JPQL 또는 Criteria API에서 사용할 수 있는 방법 중 하나입니다.
다른 방법으로도 Batch Size 설정, Entity Graphs 활용 등이 있으며, 상황에 따라 가장 적합한 해결 방법을 선택하여 성능 향상을 할 수 있습니다.
개인적으로 선호하는 mapper 방식을 활용하는 방법에 대해서는 다음에 기회가 되면 추가로 작성 하겠습니다.
실질적으로 저는 queryDsl 방식과 mybatis mapper 방식을 병행하는 것을 선호합니다.
복잡도 높은 쿼리문 작성이나 튜닝이 필요한 경우 mapper 방식이 훨씬 편하다고 느껴집니다.
QueryDsl 의 경우 entity 구조만 명확하게 잘 잡아 둔다면 간단한 쿼리문의 경우 편하게 사용할 수 있으며, DML 을 치는 경우는 N+1 이슈에서 자유롭기 때문에 활용도가 높습니다.
서비스 상에 Excel 파일 다운로드 기능이 있고, 다운 받을때 N+1 이슈가 있는 경우 proxy server error 로 서버가 사망하는 환상적인? 경험을 하실 수 있습니다.