공부일기
[Spring Boot + JPA] N+1 문제란 무엇인가 (EntityGraph vs DTO Projection) 본문
[Spring Boot + JPA] N+1 문제란 무엇인가 (EntityGraph vs DTO Projection)
석새우 2026. 3. 9. 15:48MiniSNS 프로젝트를 진행하면서 게시글 목록 조회 API를 구현하다가 예상하지 못한 성능 문제를 발견했다.
바로 JPA에서 매우 유명한 N+1 문제였다.
이번 글에서는 MiniSNS 프로젝트에서 실제로 발생했던 사례를 기반으로 다음 내용을 정리하려 한다.
- 페이지네이션 구현과 PageResponse
- Java 제네릭(Generic)의 기본 개념
- N+1 문제란 무엇인가
- Lazy Loading과 N+1의 관계
- EntityGraph와 DTO Projection
- MiniSNS 프로젝트에서 선택한 해결 전략
1. 게시글 조회 API와 PageResponse
게시글 목록 API는 다음과 같이 구현했다.
@GetMapping("/posts")
public PageResponse<PostResponse> getPosts(Pageable pageable) {
return PageResponse.from(postService.getPosts(pageable));
}
여기서 눈에 띄는 부분이 있다.
PageResponse<PostResponse>
이 구조는 페이지 응답을 일정한 형태로 만들기 위해 만든 DTO이다.
Spring Data JPA의 Page 객체를 그대로 반환하면 JSON 구조가 복잡해지기 때문에
보통 API에서는 커스텀 페이지 DTO를 만들어 반환한다.
MiniSNS 프로젝트에서는 다음과 같은 PageResponse를 만들었다.
2. PageResponse 구현
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isLast()
);
}
}
이 DTO는 페이지 응답을 일정한 구조로 만들어주는 역할을 한다.
예를 들어 API 응답은 다음과 같은 형태가 된다.
{
"content": [
{
"id": 1,
"username": "user1",
"title": "게시글 제목",
"content": "게시글 내용"
}
],
"page": 0,
"size": 10,
"totalElements": 25,
"totalPages": 3,
"last": false
}
이렇게 하면 프론트엔드에서도 페이지 정보를 쉽게 사용할 수 있다고 한다. 음하맣ㅁ... 너무 좋은거 같아!!
3. 여기서 등장한 Java 제네릭(Generic)
PageResponse 코드에서 가장 헷갈렸던 부분은 바로 이것이었다.
<T>
Java 제네릭은 타입을 일반화하는 문법이다.
즉 하나의 클래스나 메서드가 여러 타입을 처리할 수 있도록 만드는 기능이다.
그냥 무적 타입인셈임!!
예를 들어 List를 생각해보자.
List<String> names
List<Integer> numbers
List는 같은 구조지만 데이터 타입만 다르다.
그래서 Java는 다음처럼 제네릭을 사용한다.
List<T>
여기서 T는 타입을 의미하는 변수이다.
4. PageResponse에 제네릭을 사용한 이유
PageResponse는 게시글 페이지, 댓글 페이지 등 다양한 데이터를 처리해야 한다.
예를 들어 다음 두 API를 생각해보자.
게시글 목록
PageResponse<PostResponse>
댓글 목록
PageResponse<CommentResponse>
이 두 응답은 구조는 PageResponse루 동일하지만 데이터 타입이 다름!!
그래서 PageResponse를 이렇게 만들었다.
PageResponse<T>
즉
T = PostResponse
T = CommentResponse
로 다 들어가서 사용할 수 있다.
이렇게 하면 하나의 클래스만으로 다양한 페이지 응답을 처리할 수 있다.
5. 제네릭 메서드 public static <T>
PageResponse에는 다음 메서드가 있다.
public static <T> PageResponse<T> from(Page<T> page)
여기서 <T>는 제네릭 메서드 선언이다.
이 의미는 다음과 같다.
예를 들어
Page<PostResponse>
Page<CommentResponse>
모두 처리할 수 있다.
즉 이 메서드는
Page → PageResponse
변환을 담당한다.
그냥 여느 다른 함수와 같이 반환 타입이 <T> PageResponse<T> 라고 생각하면 됨!! 어떤 타입이든 들어갈 수 있는 페이지 응답구조다~~
6. N + 1 문제를 처음 발견한 순간!!
게시글 목록 조회 API는 다음과 같이 구현되어 있었따.
@GetMapping("/posts")
public PageResponse<PostResponse> getPosts(Pageable pageable) {
return PageResponse.from(postService.getPosts(pageable));
}
서비스에서는 게시글을 조회한 뒤 응답 DTO 타입의 Page로 변환했다.
public Page<PostResponse> getPosts(Pageable pageable) {
return postRepository.findAll(pageable)
.map(PostResponse::toResponse);
}
DTO 변환 코드 (toResponse)에서는 작성자의 이름인 username을 함께 반환하고 있었는데,,,,,
public static PostResponse toResponse(Post post) {
return new PostResponse(
post.getId(),
post.getUser().getId(),
post.getUser().getUsername(),
post.getTitle(),
post.getContent()
);
}
여기서 자ㅉㅁㅁㅆ마산ㄱ아마ㅓ싸머싸싸ㅓㅏ잠싸까까난만!~~~~~
여기서 문제가 발생하는 곳은 바로~
post.getUser().getUsername()
딩동댕 정답~!~!~!~!~~
이 코드 덕문에 세팅해놓은 Lazy Loading 이 발생하게 되었는데~~
7. Lazy Loading이 실제로 발생하는 순간
2026.03.08 - [스프링 부트/개인 프로젝트] - [Spring Boot 입문] JPA와 Entity 관계 매핑 이해하기 (ManyToOne, JoinColumn, 외래키)
Post Entity에는 다음과 같은 연관관계가 있었다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
FetchType.LAZY는 지연 로딩(Lazy Loading)을 의미한다.
Lazy Loading의 동작 방식은 다음과 같다.
Post 조회
→ User는 아직 조회하지 않음
→ post.getUser() 호출 시 조회
즉 연관된 User는 처음부터 조회되는 것이 아니라 필요할 때 조회된다.
8. 실제로 실행된 SQL 로그
게시글 조회 과정은 크게 두 단계로 나뉜다.
1) 게시글 조회
먼저 Repository에서 게시글을 조회한다.
Page<Post> posts = postRepository.findAll(pageable);
이때 실행되는 SQL 로그는!
select
p.id,
p.content,
p.title,
p.user_id
from post p
order by p.id desc
limit 10;
여기서는 User 조회가 발생하지 않아요~
아무 문제 없습니다~~~~ 하 지 만 ~~~!!!
2) DTO 변환 과정
이후 서비스에서 게시글을 DTO로 변환할때 발생이 되는데~
posts.map(PostResponse::toResponse);
toResponse 내부에서는 다음 코드가 실행된다.
post.getUser().getUsername()
왔구나 왔어,,,, 위에서 말한 문제의 그 지점!! 레이지 로딩이 발생하는 그지점!!
User 정보가 필요하구나
→ DB에서 조회
다음 SQL이 실행된다.
select
u.id,
u.username
from users u
즉 만약 게시글이 3개라면 다음과 같이 3번 실행된다!
select * from users where id = 1;
select * from users where id = 2;
select * from users where id = 3;
9. 이것이 바로 N + 1 문제
정리하자면 다음과 같은 문제 발생~!
게시글 조회 1번
+
사용자 조회 N번
즉 총 N + 1 개의 쿼리가 실행되기 때문에 이러한 현상을 N+1 문제라고 부른다.
10. Lazy Loading은 N+1 문제를 해결하지 않는다
많은 사람들이 다음과 같이 생각한다.
Lazy Loading을 사용하면 N+1 문제가 해결될 것이다.
하지만 실제로는 그렇지 않다.
Lazy Loading은 단지
연관 데이터를 언제 조회할지
결정하는 전략일 뿐이다.
즉 Lazy Loading은
조회 시점을 늦출 뿐
N+1 문제 자체를 해결하지는 않는다.
11. N+1 문제의 해결 방법
대표적인 해결 방법은 다음 세가지 입니다.
1️⃣ Fetch Join
2️⃣ EntityGraph
3️⃣ DTO Projection
이번 프로젝트에서는 패치조인은 다루지 않았고 주로 EntityGraph 이후에 DTO Projection을 사용했다.
12. EntityGraph
EntityGraph는 연관 엔티티를 함께 조회하도록 지정하는 기능이다.
Repository에서 다음과 같이 작성할 수 있다.
@EntityGraph(attributePaths = "user")
Page<Post> findAll(Pageable pageable);
이렇게 하면 SQL은 다음처럼 실행된다.
select
p.id,
p.title,
p.content,
u.id,
u.username
from post p
join users u
on p.user_id = u.id
즉
EntityGraph 특징
장점
- 코드가 단순하다
- 엔티티를 그대로 사용할 수 있다
단점
- 엔티티 전체 컬럼을 조회한다
- DTO 변환 과정이 필요하다
그래서 단순한 조회에서만 사용하는 경우가 많다.
13. DTO Projection
이번 프로젝트에서는 DTO Projection 방식을 애용했다. 레포지토리 단에서 바로 응답 DTO를 반환하는 게 그냥 너무 멋있어 보였다.
예를 들어 댓글 조회는 다음과 같이 구현했다.
@Query("""
select new com.example.minisns.dto.CommentResponse(
c.id,
c.post.id,
c.user.id,
c.user.username,
c.content
)
from Comment c
where c.post.id = :postId
""")
Page<CommentResponse> findResponsesByPostId(
@Param("postId") Long postId,
Pageable pageable
);
보아라. DB에서 DTO(CommentResponse)를 바로 생성해서 반환하는 마법을,,,,
14. DTO Projection의 장점
1. DTO 프로젝션은 필요한 컬럼만 조회할 수 있는 장점이 있다.
title
content
createdAt
updatedAt
viewCount
likeCount
...
모든것을 다 가져오기 보다는 내가 필요한 정보만 가져오는 경우가 훨씬 많기 때문에,,,
2. Entity 변환이 필요 없다.
일반적으로는 위에서 계속 했던 것 처럼 Entity 자체를 직접 조회하거나 가져오고 toResponse같은걸 만들어서 DTO로 변환한다.
반면 DTO 프로젝션은 DB에서 DTO를 바로 생성해서 코드가 단순해지고 성능도 좋아짐!
15. MiniSNS 프로젝트에서의 전략
MiniSNS 프로젝트에서는 다음 기준으로 설계했다.
연관관계 설정
조회 API
엔티티 사용
예
게시글 작성
게시글 수정
댓글 작성
즉 다음 구조로 정리할 수 있다.
쓰기 (Command) → Entity 사용
조회 (Query) → DTO Projection
16. 정리
이번 글에서 정리한 핵심은 다음과 같다.
Lazy Loading
연관 데이터를 필요할 때 조회하는 전략
N+1 문제
1번의 메인 쿼리
+
N번의 추가 쿼리
해결 방법
- Fetch Join
- EntityGraph
- DTO Projection
MiniSNS 프로젝트에서는 조회 성능과 API 구조를 고려해 DTO Projection 중심 설계를 선택했다.