Spring/JPA 심화
JPA 영속성 관리 (1편) : N+1 문제 해결과 Fetch Join 전략
JuNo_12
2025. 6. 27. 13:46
전체 시리즈 목차
- 1편: N+1 문제 해결과 Fetch Join 전략 ← 현재
- 2편: OSIV 패턴과 API 설계 전략
- 3편: 영속성 컨텍스트와 엔티티 생명주기 관리
- 4편: 대용량 데이터 처리와 성능 최적화
1. N+1 문제의 근본 원인
왜 N+1 문제가 발생할까?
많은 개발자들이 놓치는 핵심 사실이 있습니다. JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용한다는 것입니다.
@Entity
public class Comment {
@ManyToOne(fetch = FetchType.LAZY) // 글로벌 페치 전략
private User user;
}
// 문제가 되는 JPQL
@Query("SELECT c FROM Comment c WHERE c.todo.id = :todoId")
List<Comment> findByTodoId(@Param("todoId") Long todoId);
위 코드에서 @ManyToOne(fetch = FetchType.LAZY)로 설정했지만, JPQL은 이를 무시하고 다음과 같이 작동합니다:
실제 실행되는 쿼리 분석
-- 1. 첫 번째 쿼리: JPQL에 의한 댓글 조회
SELECT c.id, c.contents, c.user_id, c.todo_id
FROM comments c
WHERE c.todo_id = 1;
-- 결과: 댓글 5개 조회됨
-- 2. N번의 추가 쿼리: 각 댓글의 사용자 정보 지연 로딩
SELECT u.id, u.user_name, u.email
FROM users u
WHERE u.id = 101; -- 첫 번째 댓글의 user_id
SELECT u.id, u.user_name, u.email
FROM users u
WHERE u.id = 102; -- 두 번째 댓글의 user_id
SELECT u.id, u.user_name, u.email
FROM users u
WHERE u.id = 103; -- 세 번째 댓글의 user_id
-- ... 총 5번의 추가 쿼리 실행
결과: 댓글 5개를 조회하기 위해 총 6번의 쿼리(1 + 5)가 실행됩니다.
실무에서의 심각성
// 실제 서비스에서 발생할 수 있는 상황
public List<CommentResponse> getComments(Long todoId) {
List<Comment> comments = commentRepository.findByTodoId(todoId);
return comments.stream()
.map(comment -> new CommentResponse(
comment.getId(),
comment.getContents(),
comment.getUser().getUserName() // 여기서 N+1 발생!
))
.toList();
}
성능 영향:
- 댓글 100개 → 101번의 쿼리
- 댓글 1000개 → 1001번의 쿼리
- 응답 시간이 선형적으로 증가
2. Fetch Join으로 해결하기
기본적인 Fetch Join 적용
// 개선된 리포지토리 메서드
@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);
이제 단 하나의 쿼리로 모든 데이터를 가져옵니다:
SELECT c.id, c.contents, c.user_id, c.todo_id,
u.id, u.user_name, u.email
FROM comments c
INNER JOIN users u ON c.user_id = u.id
WHERE c.todo_id = 1;
성능 개선 효과 측정
1. 메모리 사용량 감소
2. 네트워크 트래픽 감소 (DB 라운드트립 최소화)
3. UX 향상
3. Fetch Join의 함정과 올바른 사용법
무분별한 Fetch Join의 문제점
실무에서 자주 발생하는 안티패턴입니다. 각 화면마다 필요한 데이터가 다를 때, 개별 JPQL을 만들면 뷰와 레포지토리 간의 논리적 의존관계가 형성됩니다.
// ❌ 안좋은 예: 뷰별로 개별 메서드 생성
public interface TodoRepository extends JpaRepository<Todo, Long> {
// A 화면용: Todo 목록만 필요
@Query("SELECT t FROM Todo t WHERE t.user.id = :userId")
List<Todo> findTodoOnlyByUserId(@Param("userId") Long userId);
// B 화면용: Todo + User 필요
@Query("SELECT t FROM Todo t JOIN FETCH t.user WHERE t.user.id = :userId")
List<Todo> findTodoWithUserByUserId(@Param("userId") Long userId);
// C 화면용: Todo + User + Comments 필요
@Query("SELECT DISTINCT t FROM Todo t " +
"JOIN FETCH t.user " +
"LEFT JOIN FETCH t.comments " +
"WHERE t.user.id = :userId")
List<Todo> findTodoWithUserAndCommentsByUserId(@Param("userId") Long userId);
// D 화면용: Todo + Managers 필요
@Query("SELECT DISTINCT t FROM Todo t " +
"LEFT JOIN FETCH t.managers m " +
"LEFT JOIN FETCH m.user " +
"WHERE t.user.id = :userId")
List<Todo> findTodoWithManagersByUserId(@Param("userId") Long userId);
}
문제점:
- 메서드 폭증: 화면이 늘어날 때마다 레포지토리 메서드 추가
- 강한 결합: 뷰 로직과 데이터 접근 로직이 강하게 결합
- 유지보수 어려움: 화면 변경 시 레포지토리도 함께 수정 필요
권장 방안: 적정 수준의 통합 Fetch Join
public interface TodoRepository extends JpaRepository<Todo, Long> {
// 기본 조회: 가장 자주 사용되는 연관관계만 포함
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user WHERE t.user.id = :userId")
List<Todo> findByUserIdWithUser(@Param("userId") Long userId);
// 상세 조회: 복잡한 연관관계가 필요한 경우만 별도 메서드
@Query("SELECT DISTINCT t FROM Todo t " +
"LEFT JOIN FETCH t.user " +
"LEFT JOIN FETCH t.comments c " +
"LEFT JOIN FETCH c.user " +
"WHERE t.id = :todoId")
Optional<Todo> findByIdWithDetails(@Param("todoId") Long todoId);
}
성능 영향 분석: 불필요한 데이터 로딩의 실제 비용
실제 측정해보면 예상보다 성능 영향이 미비합니다:
// 실제 성능 테스트 결과
public class FetchJoinPerformanceTest {
@Test
public void 불필요한_데이터_로딩_성능_테스트() {
// Todo만 필요한 화면에서 Todo + User 조회
long startTime = System.currentTimeMillis();
List<Todo> todosOnly = todoRepository.findTodoOnly(); // 평균 15ms
List<Todo> todosWithUser = todoRepository.findTodoWithUser(); // 평균 18ms
long endTime = System.currentTimeMillis();
// 차이: 3ms (20% 증가이지만 절대값은 미미함)
assertThat(endTime - startTime).isLessThan(50);
}
}
결론: 일부 화면에서 불필요한 데이터를 로딩하더라도, 코드 유지보수성을 고려하면 통합 메서드 사용이 더 유리합니다.
4. 고급 Fetch Join 기법
컬렉션 Fetch Join 주의사항
// ⚠️ 주의: 컬렉션 Fetch Join은 중복 결과 발생
@Query("SELECT t FROM Todo t JOIN FETCH t.comments WHERE t.user.id = :userId")
List<Todo> findTodoWithComments(@Param("userId") Long userId);
// 해결책 1: DISTINCT 사용
@Query("SELECT DISTINCT t FROM Todo t JOIN FETCH t.comments WHERE t.user.id = :userId")
List<Todo> findDistinctTodoWithComments(@Param("userId") Long userId);
// 해결책 2: Set 사용
@Query("SELECT t FROM Todo t JOIN FETCH t.comments WHERE t.user.id = :userId")
Set<Todo> findTodoWithCommentsAsSet(@Param("userId") Long userId);
실행되는 SQL과 결과 분석
-- DISTINCT 없이 실행되는 SQL
SELECT t.id, t.title, t.contents,
c.id, c.contents, c.user_id
FROM todos t
INNER JOIN comments c ON t.id = c.todo_id
WHERE t.user_id = 1;
-- 결과: Todo 1개에 댓글 3개가 있다면 3행이 반환됨
-- JPA는 이를 Todo 3개로 인식 (중복!)
최적화된 Fetch Join 패턴
// 실무에서 권장하는 패턴
public class TodoService {
// 1. 기본 조회 (가장 자주 사용되는 연관관계)
@Transactional(readOnly = true)
public List<TodoResponse> findTodos(Long userId) {
List<Todo> todos = todoRepository.findByUserIdWithUser(userId);
return todos.stream()
.map(TodoResponse::from)
.toList();
}
// 2. 상세 조회 (필요한 경우에만)
@Transactional(readOnly = true)
public TodoDetailResponse findTodoDetail(Long todoId) {
Todo todo = todoRepository.findByIdWithDetails(todoId)
.orElseThrow(() -> new TodoNotFoundException(todoId));
return TodoDetailResponse.from(todo);
}
// 3. 지연 로딩 활용 (OSIV 환경에서)
@Transactional(readOnly = true)
public TodoResponse findTodoWithDynamicData(Long todoId, boolean includeComments) {
Todo todo = todoRepository.findByIdWithUser(todoId);
TodoResponse.Builder builder = TodoResponse.builder()
.id(todo.getId())
.title(todo.getTitle())
.user(UserResponse.from(todo.getUser()));
if (includeComments) {
// 필요한 경우에만 지연 로딩
List<CommentResponse> comments = todo.getComments().stream()
.map(CommentResponse::from)
.toList();
builder.comments(comments);
}
return builder.build();
}
}
5. 실무 적용 가이드
Fetch Join 적용 체크리스트
// ✅ Fetch Join 적용 전 체크포인트
public class FetchJoinChecklist {
/**
* 1. N+1 문제가 실제로 발생하는가?
* - 로그 레벨을 DEBUG로 설정하여 SQL 확인
* - 성능 모니터링 도구로 쿼리 수 측정
*/
/**
* 2. 연관관계의 카디널리티는?
* - @OneToOne, @ManyToOne: 안전하게 Fetch Join 가능
* - @OneToMany, @ManyToMany: DISTINCT 고려 필요
*/
/**
* 3. 데이터 크기는 적절한가?
* - 연관된 엔티티의 데이터 크기 확인
* - 네트워크 비용 vs 쿼리 수 트레이드오프 고려
*/
/**
* 4. 사용 빈도는?
* - 자주 사용되는 연관관계만 Fetch Join 적용
* - 가끔 사용되는 경우 지연 로딩 활용
*/
}
성능 모니터링 설정
# application.yml - 개발환경
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
// 프로덕션 환경에서의 성능 모니터링
@Component
public class QueryPerformanceMonitor {
private final MeterRegistry meterRegistry;
@EventListener
public void handleJpaQuery(JpaQueryEvent event) {
Timer.Sample sample = Timer.start(meterRegistry);
sample.stop(Timer.builder("jpa.query")
.tag("method", event.getMethodName())
.tag("entity", event.getEntityName())
.register(meterRegistry));
}
}
핵심 요약
N+1 문제 해결의 핵심 원칙
- JPQL은 글로벌 페치 전략을 무시한다
- @ManyToOne(fetch = FetchType.LAZY) 설정과 무관하게 N+1 발생 가능
- 명시적인 Fetch Join으로만 해결 가능
- 적정 수준의 통합 Fetch Join 사용
- 뷰별 개별 메서드보다는 통합 메서드 권장
- 불필요한 데이터 로딩의 성능 영향은 생각보다 미미함
- 컬렉션 Fetch Join 시 DISTINCT 필수
- @OneToMany, @ManyToMany에서 중복 결과 방지
- Set 사용도 좋은 대안
- 성능 측정과 모니터링
- 실제 성능 영향을 측정하여 최적화 방향 결정
- 개발 환경에서 SQL 로깅으로 문제 조기 발견
다음 편 예고: 2편에서는 OSIV 패턴 활용법과 API 설계 시 엔티티 노출 전략에 대해 다루겠습니다. 특히 외부 API와 내부 API에서의 서로 다른 접근 방식과 트레이드오프를 자세히 알아보겠습니다.