N+1 문제란?
처음 조회를 위해 1번의 SQL이 실행되고, 연관된 데이터를 조회하기 위해 N번의 SQL이 추가로 실행되는 현상
- 지연 로딩(Lazy Loading) 된 연관 엔티티를 반복적으로 조회할 때 발생
- 처음에는 부모 엔티티만 조회하고, 연관 엔티티에 접근할 때마다 추가 SQL이 실행되기 때문에 데이터 개수(N)만큼 쿼리가 증가
지연 로딩 (Lazy Loading) 이란?
연관된 엔티티를 처음부터 조회하지 않고, 실제로 사용할 때 조회하는 방식
프로젝트 구조 (ERD)

N+1 과의 1차전 (@EntityGraph 활용)
// 댓글 모두 조회
@Transactional(readOnly = true)
public List<GetCommentResponse> getAll(Long scheduleId) {
List<Comment> commentList = commentRepository.findBySchedule_Id(scheduleId);
List<GetCommentResponse> dtoList = new ArrayList<>();
for (Comment comment : commentList) {
dtoList.add(new GetCommentResponse(
comment.getId(),
comment.getContents(),
comment.getUser().getName(), // User Entity 호출!
comment.getCreatedAt(),
comment.getModifiedAt()));
}
return dtoList;
}
특정 일정에 속한 모든 댓글 데이터를 가져오는 기능이다. 문제는
comment.getUser().getName(),
여기서 발생한다. 지연 로딩(Lazy Loading)에 의해 for문에서 각 comment의 user.getName()을 요청할 때마다 DB에 user에 대해 select sql을 쏘게된다.
즉 N + 1이 발생한다! (N : user 테이블에 대한 select문) (1 : comment list를 호출한 select 문)
이를 해결하기 위해, CommentRepository에 구현된 findBySchedule_Id에 다음과 같은 어노테이션을 추가해줬다.
public interface CommentRepository extends JpaRepository<Comment,Long> {
// N+1 방지
@EntityGraph(attributePaths = "user")
List<Comment> findBySchedule_Id(Long id);
}
엔티티 그래프(@Entity Graph)란?
JPA에서 제공하는 기능으로, Entity를 조회할 때, 매핑된 Entity를 어떤 방식으로 가져올지 정의하는 방법
명시적으로 패치 전략(Fetch Strategy)을 설정하고, 지연 로딩(Lazy Loading)과 즉시 로딩(Eager Loading)을 제어할 수 있다.
* 원하는 연관 관계만을 선택적으로 즉시 로딩할 수 있다! * <- 이게 핵심이다!
@EntityGraph(attributePaths = "user")
즉 위 어노테이션은 매핑된 User에 대한 데이터는 Comment가 호출될 때 즉시 로딩하라는 의미이다.
결과
select
c1_0.id,
c1_0.contents,
c1_0.created_at,
c1_0.modified_at,
c1_0.schedule_id,
c1_0.user_id,
u1_0.id,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.name,
u1_0.pw
from
comments c1_0
join
users u1_0
on u1_0.id=c1_0.user_id
where
c1_0.schedule_id=?
commentRepository.findBySchedule_Id(scheduleId)가 호출되는 타이밍에 DB에 보내는 SQL을 확인해보니 join을 통해 User의 데이터를 즉시 로딩하는 것을 확인 할 수 있었다.
N+1 과의 2차전 (@EntityGraph -> JPQL)
@Transactional(readOnly = true)
public GetScheduleResponse getOne(Long id) {
Schedule schedule = scheduleRepository.findById(id).orElseThrow(
()->new ScheduleNotFoundException("존재하지 않는 일정 입니다.")
);
List<GetCommentResponse> getCommentResponseList = new ArrayList<>();
for (Comment comment : schedule.getComments()) {
getCommentResponseList.add( new GetCommentResponse(
comment.getId(),
comment.getContents(),
comment.getUser().getName(),
comment.getCreatedAt(),
comment.getModifiedAt()
));
}
return new GetScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getUser().getName(),
getCommentResponseList,
schedule.getCreatedAt(),
schedule.getModifiedAt());
}
Schedule과 Comment는 양방향으로 매핑(지연 로딩) 되어 있어서 schedule.getComments()를 호출할 경우,
select
c1_0.schedule_id,
c1_0.id,
c1_0.contents,
c1_0.created_at,
c1_0.modified_at,
c1_0.user_id
from
comments c1_0
where
c1_0.schedule_id=?
위의 SQL이 호출되어 Schedule 내부의 List<Comment>를 채워준다. (+1)
그 뒤, 1차전과 마찬가지로 comment.getUser().getName()에서 N+1 문제가 발생한다.
결국 이 getOne 함수에서는 1번의 SQL로 처리 될 수 있는데, N+1+1번의 SQL이 발생하고 있다.
엔티티 그래프(@Entity Graph)로 해결! 인줄 알았는데 ...
public interface ScheduleRepository extends JpaRepository<Schedule,Long> {
@EntityGraph(attributePaths = {
"user",
"comments",
"comments.user"
})
Optional<Schedule> findById(@NonNull Long id);
}
위처럼 Schedule에 대해서 엔티티 그래프를 이용해 3개의 연관 관계에 대해서 즉시 로딩을 설정해주면,
select
distinct s1_0.id,
s1_0.comment_count,
c1_0.schedule_id,
c1_0.id,
c1_0.contents,
c1_0.created_at,
c1_0.modified_at,
c1_0.user_id,
u2_0.id,
u2_0.created_at,
u2_0.email,
u2_0.modified_at,
u2_0.name,
u2_0.pw,
s1_0.contents,
s1_0.created_at,
s1_0.modified_at,
s1_0.title,
s1_0.user_id,
u1_0.id,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.name,
u1_0.pw
from
schedules s1_0
left join
users u1_0
on u1_0.id=s1_0.user_id
left join
comments c1_0
on s1_0.id=c1_0.schedule_id
left join
users u2_0
on u2_0.id=c1_0.user_id
where
s1_0.id=?
기존 getOne에서 호출하던 모든 데이터를 이 SQL 하나로 로딩할 수 있다. 문제가 해결된 것처럼 보였다.
그런데 다음 함수를 보던 중...
@Transactional
public UpdateScheduleResponse update(Long id, SessionUser sessionUser, UpdateScheduleRequest request) {
Schedule schedule = scheduleRepository.findById(id).orElseThrow(
()->new ScheduleNotFoundException("존재하지 않는 일정 입니다.")
);
if (!schedule.getUser().getId().equals(sessionUser.getId())) {
throw new UnauthorizedException("자신의 일정만 수정할 수 있습니다.");
}
schedule.update(request.getTitle(), request.getContents());
return new UpdateScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getUser().getName(),
schedule.getCreatedAt(),
schedule.getModifiedAt());
}
Schedule을 업데이트 하는 함수에서도 scheduleRepository.findById(id)를 쓰고 있었다.
추가한 엔티티 그래프 어노테이션은 사용되는 모든 scheduleRepository.findById(id) 에 적용되므로, update 함수에서 필요도 없는 Comment의 대한 데이터를 로딩하게 된다.
- 이런 필요 없는 데이터까지 로딩하게 되는 것을 오버 페칭(Over Fetching)이라고 한다.
- user는 N+1 문제 해결을 위해 즉시 로딩해야 한다.
그래서 getOne 함수와 update 함수에 있는 scheduleRepository.findById(id)를 분리하기 위해서 JpaRepository에 JPQL로 새로운 함수를 만들어야 했다.
JPQL란?
JPQL(Java Persistence Query Language) 은 JPA에서 엔티티를 조회하기 위해 사용하는 객체 지향 쿼리 언어다.
JPA는 Repository의 함수 이름으로 쿼리를 만들기 때문에, 함수의 이름을 마음대로 지을 수 없다. 하지만 @Query 어노테이션으로 직접 쿼리를 작성한다면, 함수 이름을 마음대로 지을 수 있게 된다.
* JpaRepository에 원하는 이름의 함수를 만들고 싶다면 JPQL로 직접 쿼리를 넣어야 한다 *
public interface ScheduleRepository extends JpaRepository<Schedule,Long> {
// Entity Graph
@EntityGraph(attributePaths = "user")
Optional<Schedule> findById(@NonNull Long id);
// JPQL
@Query("""
select distinct s
from Schedule s
left join fetch s.user
left join fetch s.comments c
left join fetch c.user
where s.id = :id
""")
Optional<Schedule> findDetailById(@Param("id") Long id);
}
위 두 함수는 id를 통해 Schedule을 찾는 함수지만, 아래와 같은 차이점을 가진다.
- findById()는 user에 대해서만 즉시 로딩을 한다. -> update 함수에 적용
- findDetailById()는 user, comment, comment.user에 대해서 즉시 로딩을 한다. -> getOne 함수에 적용
결과
위 두 함수를 각각 update와 getOne에 적용 시키고 SQL을 확인했다.
getOne의 findDetailById()의 SQL은 위에서 작동한 것과 같았으며, update에서 실행한 findById()는 아래와 같은 SQL을 요청했다.
select
u1_0.id,
u1_0.created_at,
u1_0.email,
u1_0.modified_at,
u1_0.name,
u1_0.pw
from
users u1_0
where
u1_0.id=?
// user만 join으로 즉시 로딩
N+1 과의 3차전
// 전체 조회 (페이지네이션)
@Transactional(readOnly = true)
public List<GetAllScheduleResponse> getAll(int page) {
Pageable pageable = PageRequest.of(page, 5, Sort.by(Sort.Direction.DESC, "modifiedAt"));
Page<Schedule> schedules = scheduleRepository.findAll(pageable);
List<GetAllScheduleResponse> dtos = new ArrayList<>();
for (Schedule schedule : schedules) {
GetAllScheduleResponse dto = new GetAllScheduleResponse(
schedule.getId(),
schedule.getTitle(),
schedule.getContents(),
schedule.getUser().getName(), // N+1 발생
schedule.getComments().size(), // N+1 발생
schedule.getCreatedAt(),
schedule.getModifiedAt()
);
dtos.add(dto);
}
return dtos;
}
schedule.getComments().size()
혹시 size() 정도는 N+1 문제 없이 구할 수 있을까 했지만, 어김없이 N+1 문제가 발생했다.
@Getter
@Entity
@Table(name = "schedules")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Schedule extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private final List<Comment> comments = new ArrayList<>();
private int commentCount;
public Schedule(String title, String contents, User user) {
this.title = title;
this.contents = contents;
this.user = user;
this.commentCount = 0;
}
public void addComment(Comment comment) {
this.comments.add(comment);
commentCount++;
}
}
결국 댓글의 수를 따로 int commentCount라는 변수를 만들어 관리하는 구조로 Entity를 설계했고, 문제 없이 잘 작동하는 것을 확인 했다.
'Spring > 관련 지식' 카테고리의 다른 글
| JWT(JSON Web Token) (0) | 2026.07.22 |
|---|---|
| JPA에서 Enum 활용 방법 (ORDINAL vs STRING 등) (0) | 2026.07.21 |
| IoC/DI, 스프링 컨테이너와 Bean의 개념 (0) | 2026.07.07 |
| 생성자를 구현했는데도 @NoArgsConstructor를 쓰는 이유 (0) | 2026.07.04 |
| Spring에서의 예외 처리 (+ 커스텀 에러 구현) (0) | 2026.07.02 |