N+1 문제는 데이터베이스에 데이터를 조회할 때 발생하는 성능 저하 문제 중 하나로, 특히 ORM을 사용할 때 자주 나타납니다. 이 문제는 기본 쿼리 1개와 추가 쿼리 N개로 구성된 일련의 쿼리 실행 때문에 발생합니다. 결과적으로, 요청한 데이터의 양이 많아질수록 데이터베이스에 보내는 쿼리 수도 선형적으로 증가하여 성능이 크게 저하됩니다.
ORM은 객체 지향 프로그래밍과 관계형 데이터베이스 간의 불일치를 해결하기 위해 설계되었습니다. 하지만, 객체 간의 관계를 데이터베이스의 조인 관계로 매핑할 때, ORM이 자동으로 모든 관련 데이터를 로드하지 않고 필요할 때마다 추가 쿼리를 실행하는 경우가 있습니다. 이로 인해 기본 쿼리 외에 추가로 N개의 쿼리가 실행되어 N+1 문제가 발생합니다.
예를 들어, 회원과 게시글 간의 관계에서 하나의 회원은 여러 게시글을 작성할 수 있습니다. 이를 JPA 엔티티로 정의하면 다음과 같습니다.
// Member.java
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 게시글과의 일대다 관계
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
// getters and setters
}
// Post.java
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// 회원과의 다대일 관계
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// getters and setters
}
여기서 MemberRepository는 기본 JpaRepository를 상속
// MemberService.java
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
@Transactional(readOnly = true)
public void printMembersAndPosts() {
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (Post post : member.getPosts()) {
System.out.println(" Post: " + post.getTitle());
}
}
}
}
SELECT * FROM member;
SELECT * FROM post WHERE member_id = 1;
SELECT * FROM post WHERE member_id = 2;
...
SELECT * FROM post WHERE member_id = N;
총 N+1개의 쿼리가 실행되어 회원 수(N)가 많아질수록 데이터베이스에 보내는 쿼리 수가 급격히 증가하여 성능 저하가 발생합니다.
Member 엔티티의 posts 필드는 LAZY 로딩으로 설정되어 있습니다. 따라서 member.getPosts()를 호출할 때마다 별도의 쿼리가 실행됩니다. 회원이 100명이라면 기본 쿼리 1개와 추가 쿼리 100개가 실행되는 셈입니다.
N+1 문제를 해결하기 위해서는 가능한 한 최소한의 쿼리로 필요한 모든 데이터를 가져오는 것이 중요합니다. 이를 위해 다양한 전략과 JPA 기능을 활용할 수 있습니다.
Lazy Loading은 연관된 엔티티를 실제로 필요할 때 조회하여 초기 쿼리는 빠르지만 연관된 데이터가 많아질수록 추가 쿼리가 많이 발생할 수 있습니다. 하지만 Eager Loading은 연관된 엔티티를 처음부터 함께 조회하여 초기 쿼리가 조금 복잡해지지만 추가 쿼리를 줄여 전체 성능을 향상시킬 수 있습니다.
// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m JOIN FETCH m.posts")
List<Member> findAllWithPosts();
}
// MemberService.java
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
@Transactional(readOnly = true)
public void printMembersAndPosts() {
List<Member> members = memberRepository.findAllWithPosts();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (Post post : member.getPosts()) {
System.out.println(" Post: " + post.getTitle());
}
}
}
}
이렇게 되면 다음과 같은 쿼리만 발생합니다.
SELECT m.*, p.*
FROM member m
JOIN post p ON m.id = p.member_id;
1개의 쿼리로 모든 회원과 그들의 게시글을 한 번에 조회할 수 있습니다.
// Member.java
@Entity
@NamedEntityGraph(
name = "Member.posts",
attributeNodes = @NamedAttributeNode("posts")
)
public class Member {
// 기존 코드
}
// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(value = "Member.posts", type = EntityGraph.EntityGraphType.LOAD)
List<Member> findAll();
}
이와 같이 작성하면 1번의 방법과 유사하게 1개의 쿼리로 모든 데이터를 조회합니다.
Batch Fetching은 연관된 엔티티들을 지정된 배치 크기 단위로 한번에 조호히합니다. 예를 들어, 배치 크기를 100으로 설정하면, 연관된 엔티티들을 최대 100개 씩 묶어서 조회합니다.
즉
1. 기본 쿼리(1개) : 모든 회원 조회
2. 배치 쿼리(N/Batch_Size 개): 회원들의 게시글을 배치 단위로 조회합니다.
ex) 총 100명의 회원이 있고 배치 크기가 20이라면 기본 쿼리 1개와 추가 쿼리 5개가 실행됩니다.
Hibernate 설정을 위해 application.yml에 다음 설정을 추가합니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
// Member.java
@Entity
public class Member {
// 기존 코드
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Post> posts = new ArrayList<>();
// getters and setters
}
Hibernate는 한 번에 지정된 배치 크기만큼의 엔티티를 로드합니다. 예를 들어 100명의 회원이 있을 경우, 1개의 기본 쿼리와 추가적으로 1개의 쿼리로 모든 게시글을 로드할 수 있습니다.
직접 필요한 데이터만을 조회하여 성능을 최적화할 수 있습니다.
DTO를 사용하여 필요한 필드만을 선택적으로 조회합니다.
// MemberPostDTO.java
public class MemberPostDTO {
private String memberName;
private String postTitle;
public MemberPostDTO(String memberName, String postTitle) {
this.memberName = memberName;
this.postTitle = postTitle;
}
// getters and setters
}
// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT new com.example.dto.MemberPostDTO(m.name, p.title) FROM Member m JOIN m.posts p")
List<MemberPostDTO> findAllMemberPostDTOs();
}
// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT new com.example.dto.MemberPostDTO(m.name, p.title) FROM Member m JOIN m.posts p")
List<MemberPostDTO> findAllMemberPostDTOs();
}
이러면 다음과 같은 쿼리만을 호출하므로 필요한 데이터만을 조회하여 불필요한 데이터를 로드하지 않아 성능을 최적화할 수 있습니다
SELECT m.name, p.title
FROM member m
JOIN post p ON m.id = p.member_id;
ORM(Object-Relational Mapping)을 사용할 때 발생하는 N+1 문제는 백엔드 개발자가 자주 직면하게 되는 성능 관련 이슈 중 하나입니다. 신입 개발자나 취업 준비 중인 Java, Spring 백엔드 개발자라면 이 문제를 이해하고 해결하는 능력이 중요합니다. 이를 위해 실습할 만한 프로젝트와 단계별 가이드를 제공하겠습니다.
N+1 문제는 데이터베이스에서 데이터를 조회할 때 발생하는 성능 저하 문제입니다. 주로 Lazy Loading 설정에서 발생하며, 기본 쿼리 1개와 추가 쿼리 N개가 실행되어 총 N+1개의 쿼리가 발생하는 상황을 말합니다. 예를 들어, 100명의 회원을 조회할 때 각 회원의 게시글을 조회하기 위해 추가로 100개의 쿼리가 실행되는 것입니다. 이는 데이터베이스와의 왕복 횟수가 증가하여 애플리케이션의 응답 시간이 길어지고, 서버 자원이 낭비되는 결과를 초래합니다.
신입 및 취업 준비 중인 개발자를 위해, 다음과 같은 목표를 설정합니다:
Spring Boot를 사용하여 간단한 프로젝트를 생성합니다. Spring Initializr를 이용하면 쉽게 설정할 수 있습니다.
application.yml 설정# src/main/resources/application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
show-sql: true
format_sql: true
default_batch_fetch_size: 100
properties:
hibernate:
batch_fetch_size: 100
h2:
console:
enabled: true
설명:
ddl-auto: create: 애플리케이션 시작 시 스키마 자동 생성.show-sql: true: 실행되는 SQL 쿼리 로그 출력.format_sql: true: SQL 로그 포맷팅.default_batch_fetch_size: 기본 배치 크기 설정.Member와 Post 간의 일대다 관계를 설정합니다.
// src/main/java/com/example/demo/entity/Member.java
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.BatchSize;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 일대다 관계, LAZY 로딩
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@BatchSize(size = 100) // 배치 크기 설정
private List<Post> posts = new ArrayList<>();
}
// src/main/java/com/example/demo/entity/Post.java
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// 다대일 관계, LAZY 로딩
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
}
설명:
fetch = FetchType.LAZY).// src/main/java/com/example/demo/repository/MemberRepository.java
package com.example.demo.repository;
import com.example.demo.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
// src/main/java/com/example/demo/repository/PostRepository.java
package com.example.demo.repository;
import com.example.demo.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
}
테스트를 위해 초기 데이터를 삽입합니다.
// src/main/java/com/example/demo/DataInitializer.java
package com.example.demo;
import com.example.demo.entity.Member;
import com.example.demo.entity.Post;
import com.example.demo.repository.MemberRepository;
import com.example.demo.repository.PostRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class DataInitializer {
@Autowired
private MemberRepository memberRepository;
@Autowired
private PostRepository postRepository;
@PostConstruct
public void init() {
for (int i = 1; i <= 1000; i++) { // 1000명의 회원 생성
Member member = new Member();
member.setName("Member " + i);
memberRepository.save(member);
// 각 회원마다 10개의 게시글 생성
for (int j = 1; j <= 10; j++) {
Post post = new Post();
post.setTitle("Post " + j + " of " + member.getName());
post.setMember(member);
postRepository.save(post);
}
}
}
}
N+1 문제를 재현하고 해결하는 다양한 방법을 실습할 수 있는 서비스를 작성합니다.
// src/main/java/com/example/demo/service/MemberService.java
package com.example.demo.service;
import com.example.demo.entity.Member;
import com.example.demo.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
// N+1 문제 발생 메서드
@Transactional(readOnly = true)
public void printMembersAndPostsWithNPlusOne() {
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (var post : member.getPosts()) { // Lazy Loading으로 추가 쿼리 발생
System.out.println(" Post: " + post.getTitle());
}
}
}
// Fetch Join을 사용한 메서드
@Transactional(readOnly = true)
public void printMembersAndPostsWithFetchJoin() {
List<Member> members = memberRepository.findAllWithPosts();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (var post : member.getPosts()) {
System.out.println(" Post: " + post.getTitle());
}
}
}
// Entity Graph을 사용한 메서드
@Transactional(readOnly = true)
public void printMembersAndPostsWithEntityGraph() {
List<Member> members = memberRepository.findAllWithEntityGraph();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (var post : member.getPosts()) {
System.out.println(" Post: " + post.getTitle());
}
}
}
// Batch Fetching을 사용한 메서드
@Transactional(readOnly = true)
public void printMembersAndPostsWithBatchFetching() {
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println("Member: " + member.getName());
for (var post : member.getPosts()) { // Batch Fetching으로 배치 단위로 쿼리 발생
System.out.println(" Post: " + post.getTitle());
}
}
}
}
설명:
findAll()을 사용하여 N+1 문제가 발생하는 예시.각 해결 방법에 맞는 레포지토리 메서드를 추가합니다.
// src/main/java/com/example/demo/repository/MemberRepository.java
package com.example.demo.repository;
import com.example.demo.entity.Member;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
// Fetch Join을 사용한 커스텀 쿼리
@Query("SELECT m FROM Member m JOIN FETCH m.posts")
List<Member> findAllWithPosts();
// Entity Graph을 사용한 메서드
@EntityGraph(attributePaths = "posts")
List<Member> findAllWithEntityGraph();
}
설명:
JOIN FETCH를 사용하여 Fetch Join 구현.@EntityGraph 어노테이션을 사용하여 Entity Graph 구현.서비스 메서드를 호출할 수 있는 REST 컨트롤러를 작성합니다.
// src/main/java/com/example/demo/controller/MemberController.java
package com.example.demo.controller;
import com.example.demo.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MemberController {
@Autowired
private MemberService memberService;
// N+1 문제 발생
@GetMapping("/nplusone")
public String nPlusOne() {
memberService.printMembersAndPostsWithNPlusOne();
return "N+1 Problem Executed";
}
// Fetch Join 사용
@GetMapping("/fetchjoin")
public String fetchJoin() {
memberService.printMembersAndPostsWithFetchJoin();
return "Fetch Join Executed";
}
// Entity Graph 사용
@GetMapping("/entitygraph")
public String entityGraph() {
memberService.printMembersAndPostsWithEntityGraph();
return "Entity Graph Executed";
}
// Batch Fetching 사용
@GetMapping("/batchfetching")
public String batchFetching() {
memberService.printMembersAndPostsWithBatchFetching();
return "Batch Fetching Executed";
}
}
이제 실제로 실습을 진행하면서 N+1 문제를 이해하고 해결해보겠습니다.
/nplusone 엔드포인트에 GET 요청을 보냅니다.SELECT * FROM member)SELECT * FROM post WHERE member_id = ?)콘솔 로그 예시:
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_ from member member0_
Hibernate: select posts0_.member_id as member_i3_0_0_, posts0_.id as id1_1_0_, posts0_.id as id1_1_1_, posts0_.title as title2_1_1_, posts0_.member_id as member_i3_1_1_ from post posts0_ where posts0_.member_id=?
...
(총 1001개의 쿼리)
/fetchjoin 엔드포인트에 GET 요청을 보냅니다.콘솔 로그 예시:
Hibernate: select distinct member0_.id as id1_0_0_, member0_.name as name2_0_0_, posts1_.id as id1_1_1_, posts1_.title as title2_1_1_, posts1_.member_id as member_i3_1_1_ from member member0_ inner join post posts1_ on member0_.id=posts1_.member_id
/entitygraph 엔드포인트에 GET 요청을 보냅니다.콘솔 로그 예시:
Hibernate: select distinct member0_.id as id1_0_0_, member0_.name as name2_0_0_, posts1_.id as id1_1_1_, posts1_.title as title2_1_1_, posts1_.member_id as member_i3_1_1_ from member member0_ left outer join post posts1_ on member0_.id=posts1_.member_id
/batchfetching 엔드포인트에 GET 요청을 보냅니다.콘솔 로그 예시:
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_ from member member0_
Hibernate: select posts0_.member_id as member_i3_0_, posts0_.id as id1_1_0_, posts0_.title as title2_1_0_, posts0_.member_id as member_i3_1_0_ from post posts0_ where posts0_.member_id in (1,2,3,...,100)
...
(총 11개의 쿼리)
Fetch Join을 사용하여 페이징을 적용하면 어떻게 동작하는지 확인해보세요. 페이징과 Fetch Join을 함께 사용할 때 발생할 수 있는 문제(데이터 중복 등)를 경험하고, Entity Graph나 Batch Fetching을 사용하여 해결해보세요.
Hibernate의 통계 기능을 활성화하여 쿼리 실행 횟수를 모니터링해보세요.
// src/main/java/com/example/demo/service/StatisticsService.java
package com.example.demo.service;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.persistence.EntityManager;
@Service
public class StatisticsService {
@Autowired
private EntityManager entityManager;
public void printStatistics() {
Session session = entityManager.unwrap(Session.class);
SessionFactory sessionFactory = session.getSessionFactory();
Statistics stats = sessionFactory.getStatistics();
stats.setStatisticsEnabled(true);
System.out.println("Query Count: " + stats.getQueryExecutionCount());
System.out.println("Second Level Cache Hit Count: " + stats.getSecondLevelCacheHitCount());
System.out.println("Second Level Cache Miss Count: " + stats.getSecondLevelCacheMissCount());
System.out.println("Second Level Cache Put Count: " + stats.getSecondLevelCachePutCount());
}
}
설명:
일대일, 다대다 관계에서도 N+1 문제가 발생할 수 있습니다. 다양한 관계 설정에서 N+1 문제를 재현하고 해결해보세요.
N+1 문제는 ORM을 사용할 때 발생할 수 있는 흔한 성능 이슈입니다. 신입 개발자나 취업 준비 중인 개발자는 이를 이해하고 다양한 해결 방법을 실습을 통해 익히는 것이 중요합니다. 위의 실습을 통해 N+1 문제를 경험하고, Fetch Join, Entity Graph, Batch Fetching을 효과적으로 활용하여 성능을 최적화하는 방법을 습득하시기 바랍니다.
추가로, 실습을 진행하면서 다음을 유의하세요:
Happy Coding! 🚀