N+1 문제의 발생
N+1 문제는 "연관관계가 설정된 엔티티를 조회할 때, 1번의 쿼리로 데이터를 가져온 후, 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 상황"을 말한다.
예를 들어 User와 Profile이 1:1 관계(FetchType.LAZY 지연 로딩)로 매핑되어 있다고 가정해보자.
// Entity 예시
@Entity
public class User {
// ...
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private Profile profile;
}
문제가 되는 서비스 코드 예시(Bad Way)
@Transactional(readOnly = true)
public List<UserDto> getUsers() {
// 1. (쿼리 1번) User 10명을 조회합니다.
List<User> users = userRepository.findAll(PageRequest.of(0, 10));
// 2. (쿼리 N번) DTO로 변환하는 과정에서 N+1 발생
return users.stream()
.map(user -> {
// [N+1 발생 지점]
// .getName()으로 실제 데이터에 접근하는 순간,
// 지연 로딩된 프록시가 실제 DB 쿼리를 날립니다.
String profileName = user.getProfile().getName(); // 👈 쿼리 발생
return new UserDto(user.getId(), profileName);
})
.toList();
}
User 10명을 조회하기 위해 총 1 + 10 = 11번의 쿼리가 나간다.
해결 방법
1. FETCH JOIN
JOIN FETCH는 JPA(JPQL)에게 "처음 User를 조회할 때, 연관된 Profile 데이터도 미리 다 가져와서 채워 넣어줘"라고 명시적으로 지시하는 것이다.
//Repository (JPQL)
public interface UserRepository extends JpaRepository<User, UUID> {
// N:1 또는 1:1 관계에서는 JOIN FETCH를 페이징과 함께 써도 문제없습니다.
@Query("SELECT u FROM User u JOIN FETCH u.profile")
List<User> findAllWithProfile(Pageable pageable);
}
//실행되는 SQL 로그:
SELECT u.*, p.* -- User와 Profile의 모든 필드를 한 번에
FROM user u
INNER JOIN profile p ON u.id = p.user_id
LIMIT 10;
단 1번의 쿼리로 N+1 문제가 해결된다.
단, 주의할점은
1:N(일대다) 관계 + 페이징 함정 JOIN FETCH는 1:1, N:1 관계에서는 완벽하지만, 1:N 관계(예: User -> List<Post>)와 페이징(Pageable)을 함께 쓰면 안된다.
1:N 조인 시 DB Row가 뻥튀기되어 페이징(LIMIT)이 깨지고, 최악의 경우 하이버네이트의 메모리 페이징 경고(HHH000104)와 함께 OOM(Out of Memory)으로 서버가 다운될 수 있다.
2. @BatchSize
BatchSize는 LAZY 로딩을 유지하되, 프록시 객체에 접근할 때 N번의 쿼리를 날리는 대신, ID를 모아서 IN 절 쿼리 1번으로 한방에 가져오게 하는 '똑똑한 지연 로딩' 기술이다.
//application.yml 설정
spring:
jpa:
properties:
hibernate:
# N:1, 1:1, 1:N 컬렉션 모두 이 설정으로 묶음 조회가 가능합니다.
default_batch_fetch_size: 100
//서비스 코드 (JOIN FETCH 없음):
@Transactional(readOnly = true)
public List<UserDto> getUsers() {
// 1. (쿼리 1번) JOIN FETCH 없이 User 10명을 조회합니다.
List<User> users = userRepository.findAll(PageRequest.of(0, 10));
// 2. DTO 변환
return users.stream()
.map(user -> {
// [N+1 해결 지점]
// user[0].getProfile() 최초 접근 시,
// BatchSize 설정이 user[0]~user[9]의 ID를 모아
// IN 절 쿼리를 1번 날립니다.
String profileName = user.getProfile().getName();
return new UserDto(user.getId(), profileName);
})
.toList();
}
//실행되는 SQL 로그:
# 1. (쿼리 1번) User 10명을 조회
SELECT * FROM user LIMIT 10;
# 2. (쿼리 2번) user.getProfile() 최초 접근 시
# user_id 10개를 묶어서 IN 절 쿼리 1번 실행
SELECT * FROM profile WHERE user_id IN (1, 2, 3, ..., 10);
총 2번의 쿼리로, 페이징도 지키고 N+1 문제도 해결했다.
3. QueryDSL DTO 프로젝션 (Read vs. Write)
지금까지의 방법(JOIN FETCH, BatchSize)은 모두 엔티티(Entity)를 반환받았습니다.
여기서 가장 중요한 질문이 나옵니다. "조회한 엔티티를... 수정(Write)할지, 아니면 그냥 조회(Read)만 할지"
이 질문에 따라 우리가 선택할 기술이 완전히 달라진다. 이것이 CQS (Command Query Separation, 명령과 조회 분리) 원칙의 핵심이다.
3.1 Command (수정, Write)이 목적인 경우
엔티티를 조회한 뒤, 그 객체의 상태를 변경(user.changeName("새이름"))하고 싶다면, JPA의 '변경 감지(Dirty Checking)' 기능을 활용해야 한다.
- 이때는 FETCH JOIN 또는 @BatchSize를 사용해야 한다.
- 이 방법들은 JPA가 관리하는 영속화된 엔티티를 반환한다.
- 따라서 서비스 로직에서 엔티티의 값을 변경하면, @Transactional이 끝날 때 JPA가 자동으로 UPDATE 쿼리를 실행해 준다.
// 예: 수정(Write) 로직
@Transactional
public void updateAllUsers() {
// 1. '엔티티'를 조회 (JOIN FETCH 또는 BatchSize 활용)
List<User> users = userRepository.findAllWithProfile(PageRequest.of(0, 10));
// 2. 엔티티 상태 변경
users.forEach(user -> user.changeStatus("UPDATED"));
// 3. 메서드 종료 시 '변경 감지'로 UPDATE 쿼리 자동 실행
}
3.2 Query (조회, Read)가 목적인 경우
하지만 대부분의 API는 단순히 데이터를 조회(Read)해서 DTO로 변환한 뒤 JSON으로 응답하는 것이 목적이다.
- 이때는 엔티티를 조회하는 것 자체가 낭비이다.
- 이때는 QueryDSL DTO 프로젝션이 권장된다.
- 처음부터 DB에서 DTO에 필요한 필드만 골라서 가져온다.
//Repository (QueryDSL):
// UserRepositoryCustomImpl.java (QueryDSL 구현체)
public List<UserDto> findUsersAsDto(Pageable pageable) {
QUser user = QUser.user;
QProfile profile = QProfile.profile;
return queryFactory
// 1. 엔티티가 아닌 DTO로 바로 조회
.select(Projections.constructor(UserDto.class,
user.id,
profile.name // 👈 필요한 필드만 정확히 지정
))
.from(user)
.join(user.profile, profile) // 1:1이므로 JOIN
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
//실행되는 SQL 로그:
SELECT
u.id, p.name -- 👈 DTO 생성에 필요한 단 2개의 컬럼만 SELECT
FROM
user u
INNER JOIN
profile p ON u.id = p.user_id
LIMIT 10;
DTO 프로젝션의 3가지 압도적인 이점 (vs 엔티티 조회):
- N+1 해결: 쿼리가 1번만 실행된다.
- SELECT 절 최적화: SELECT *가 아닌 필요한 컬럼만 가져오므로 네트워크 I/O가 극적으로 감소한다.
- 영속성 컨텍스트 부하 없음: DTO는 엔티티가 아니므로, JPA가 1차 캐시에 등록하고 관리(스냅샷 저장 등)하는 CPU/메모리 오버헤드가 전혀 없다.
실제 프로젝트에서 N+1 문제가 발생한 상황
N+1 문제 발생하는 코드
// N+1 문제 발생하는 코드
@Transactional(readOnly = true)
public UserDtoCursorResponse getUsersBadWay(UserDtoCursorRequest request) {
// 1. 요청 정보를 기반으로 Pageable 객체 생성 (커서 대신 0번 페이지 조회로 단순화)
int limit = request.limit() > 0 ? request.limit() : 20;
// SortBy enum(CREATED_AT, EMAIL)을 User 엔티티의 필드명(createdAt, email)으로 변환
String sortProperty = request.sortBy() == SortBy.CREATED_AT ? "createdAt" : "email";
Sort.Direction direction = request.sortDirection() == SortDirection.ASCENDING ?
Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortProperty);
// N+1 데모를 위해 0번 페이지를 고정으로 조회
Pageable pageable = PageRequest.of(0, limit, sort);
// 2. (쿼리 1번) User 목록을 조회합니다.
Page<User> userPage = userRepository.findAll(pageable);
// 3. (쿼리 N번) 조회된 User 목록을 순회하며 DTO로 변환.
List<UserDto> dtos = userPage.getContent().stream()
.map(user -> {
// ** [N+1 쿼리 발생 지점] **
// 루프를 돌면서 각 User의 Profile 정보를 조회하기 위해
// profileRepository.findByUserId()를 N번 호출.
String profileName = profileRepository.findByUserId(user.getId())
.map(Profile::getName)
.orElse(null);
// User 엔티티와 조회한 name을 UserDto로 매핑
// (이전 createUser에서 사용한 userMapper.toUserDto(User, String) 메서드 활용)
return userMapper.toUserDto(user, profileName);
})
.toList();
// 4. Page 객체 정보를 바탕으로 CursorResponse와 유사하게 응답을 조립.
return new UserDtoCursorResponse(
dtos,
null, // 커서 로직이 아니므로 nextCursor는 null
null, // nextIdAfter는 null
userPage.hasNext(),
userPage.getTotalElements(),
request.sortBy(),
request.sortDirection()
);
}
postman으로 확인한 응답시간

서버 출력 로그
2025-11-02T19:23:31.730+09:00 INFO 22440 --- [nio-8080-exec-8] c.s.m.o.user.controller.UserController : GET /api/users/bad - 계정 목록 조회 요청
Hibernate:
select
u1_0.id,
u1_0.created_at,
u1_0.email,
u1_0.follower_count,
u1_0.following_count,
u1_0.locked,
u1_0.password,
u1_0.role
from
users u1_0
order by
u1_0.created_at
offset
? rows
fetch
first ? rows only
Hibernate:
select
count(u1_0.id)
from
users u1_0
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.birth,
p1_0.gender,
p1_0.name,
p1_0.temperature_sensitivity,
p1_0.user_id
from
profiles p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
where
u1_0.id=?
2025-11-02T19:23:31.785+09:00 INFO 22440 --- [nio-8080-exec-8] c.s.m.o.user.controller.UserController : GET /api/users - 계정 목록 조회 완료
QueryDsl 사용
// service 계층 코드
@Override
public UserDtoCursorResponse getUsers(UserDtoCursorRequest request) {
UserSlice<UserRow> slice = userRepository.searchUserRowWithCursor(request);
List<UserRow> rows = slice.content();
// Row → API DTO 매핑 (이 단계에서 플레이스홀더 처리 등 가능)
List<UserDto> dtos = rows.stream()
.map(userRow -> userMapper.toUserDtoFromUserRow(
userRow,
List.of()
))
.toList();
String nextCursor = null;
UUID nextIdAfter = null;
if (slice.hasNext()) {
UserRow last = rows.get(rows.size() - 1);
nextIdAfter = last.id();
nextCursor = switch (request.sortBy()) {
case EMAIL -> last.email();
case CREATED_AT-> last.createdAt().toString();
};
}
return new UserDtoCursorResponse(
dtos,
nextCursor,
nextIdAfter,
slice.hasNext(),
slice.totalCount(),
request.sortBy(),
request.sortDirection()
);
}
// repository 계층
// queryDsl 사용 코드
@Slf4j
@Repository
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
private final JPAQueryFactory queryFactory;
public UserRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public UserSlice<UserRow> searchUserRowWithCursor(UserDtoCursorRequest request) {
QUser user = QUser.user;
QProfile profile = QProfile.profile;
QProfileImage profileImage = QProfileImage.profileImage;
BooleanBuilder where = buildConditions(user, request);
OrderSpecifier<?>[] orders = getOrderSpecifiers(user, request);
int limit = request.limit() > 0 ? request.limit() : 20;
// count
long total = queryFactory.select(user.count())
.from(user)
.where(where)
.fetchOne();
// rows (한 방 조회)
List<UserRow> rows = queryFactory
.select(Projections.constructor(UserRow.class,
user.id,
user.createdAt,
user.email,
profile.name,
user.role,
user.locked
))
.from(user)
.leftJoin(profile).on(profile.user.eq(user))
.leftJoin(profileImage).on(profileImage.profile.eq(profile)) // 1:1이므로 row 폭발 없음
.where(where)
.orderBy(orders)
.limit(limit + 1)
.fetch();
boolean hasNext = rows.size() > limit;
if (hasNext) rows = rows.subList(0, limit);
return new UserSlice<>(rows, hasNext, total);
}
```
}
postman으로 확인한 응답시간

서버 출력 로그
2025-11-02T19:22:27.281+09:00 INFO 22440 --- [nio-8080-exec-5] c.s.m.o.user.controller.UserController : GET /api/users - 계정 목록 조회 요청
Hibernate:
select
count(u1_0.id)
from
users u1_0
Hibernate:
select
u1_0.id,
u1_0.created_at,
u1_0.email,
p1_0.name,
u1_0.role,
u1_0.locked
from
users u1_0
left join
profiles p1_0
on p1_0.user_id=u1_0.id
left join
profile_images pi1_0
on pi1_0.profile_id=p1_0.id
order by
u1_0.created_at,
u1_0.id
fetch
first ? rows only
2025-11-02T19:22:27.288+09:00 INFO 22440 --- [nio-8080-exec-5] c.s.m.o.user.controller.UserController : GET /api/users - 계정 목록 조회 완료