들어가며
최근 Spring Boot 프로젝트에서 N+1 문제를 해결하는 과정에서, 단순한 쿼리 최적화를 넘어 아키텍처 차원의 해결책까지 고민해보게 되었습니다. 이 글에서는 N+1 문제의 본질부터 CQRS 패턴까지, 성능 최적화를 위한 다양한 접근법을 정리해보겠습니다.
N+1 문제란 무엇인가?
N+1 문제는 1번의 메인 쿼리와 N번의 추가 쿼리가 실행되어 총 N+1번의 쿼리가 발생하는 성능 문제입니다. 특히 JPA에서 지연 로딩(Lazy Loading)을 사용할 때 자주 발생합니다.
문제 상황
다음과 같은 Comment 엔티티가 있다고 가정해보겠습니다:
@Entity
public class Comment {
@Id
private Long id;
private String contents;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
}
일반적인 조회 코드에서 N+1 문제가 발생합니다:
// Repository
@Query("SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);
// Service
public List<CommentResponse> getComments(long todoId) {
List<Comment> comments = commentRepository.findByTodoIdWithUser(todoId);
return comments.stream()
.map(comment -> new CommentResponse(
comment.getId(),
comment.getContents(),
comment.getUser().getEmail() // 여기서 N번의 추가 쿼리 발생!
))
.toList();
}
JOIN vs FETCH JOIN의 차이점
N+1 문제를 이해하려면 먼저 일반 JOIN과 FETCH JOIN의 차이를 알아야 합니다.
일반 JOIN
- SQL 레벨에서는 테이블을 조인하지만, JPA는 메인 엔티티만 생성
- 연관 엔티티는 프록시 상태로 남아있음
- 연관 엔티티 접근 시 추가 쿼리 실행 (N+1 문제 발생)
FETCH JOIN
- SQL 레벨에서 테이블을 조인하고, JPA가 연관 엔티티까지 모두 생성
- 연관 엔티티가 완전히 로딩된 상태
- 연관 엔티티 접근 시 추가 쿼리 없음
N+1 문제 해결법
해결법 1: FETCH JOIN 사용
@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId")
List<Comment> findByTodoIdWithUser(@Param("todoId") Long todoId);
이렇게 하면 Comment와 User 정보를 한 번의 쿼리로 모두 가져올 수 있습니다.
해결법 2: Stream API로 서비스 코드 개선
기존의 for문 대신 Stream API를 사용하여 코드를 더 간결하게 만들 수 있습니다:
public List<CommentResponse> getComments(long todoId) {
List<Comment> comments = commentRepository.findByTodoIdWithUser(todoId);
return comments.stream()
.map(comment -> new CommentResponse(
comment.getId(),
comment.getContents(),
new UserResponse(comment.getUser().getId(), comment.getUser().getEmail())
))
.toList();
}
Repository에서 DTO 직접 반환 vs Entity 변환 방식
Repository에서 DTO 직접 반환하는 방법
Projection을 사용하면 Repository에서 바로 DTO를 반환할 수 있습니다:
@Query("SELECT new CommentResponse(c.id, c.contents, u.email) " +
"FROM Comment c JOIN c.user u WHERE c.todo.id = :todoId")
List<CommentResponse> findCommentResponsesByTodoId(@Param("todoId") Long todoId);
현재 방식(Entity → DTO 변환)을 사용하는 이유
- 책임 분리: Repository는 데이터 조회, Service는 비즈니스 로직과 변환
- 유연성: 같은 Entity를 여러 DTO로 변환 가능
- 재사용성: 하나의 Repository 메서드를 여러 Service에서 활용
- 비즈니스 로직 적용: Entity의 메서드를 활용한 복잡한 로직 처리 가능
Projection 패턴의 활용
Projection의 장점
- 성능 최적화: 필요한 필드만 조회
- 보안성: 민감한 필드 자동 제외
- 네트워크 효율성: 불필요한 데이터 전송 방지
- API 명세 명확성: 정확한 응답 구조 정의
Projection의 단점
- 코드 중복: 여러 인터페이스에서 같은 필드 반복 정의
- 비즈니스 로직 처리 어려움: Entity 메서드 사용 불가
- 연관관계 탐색 불가: 복잡한 도메인 로직 적용 제한
- 테스트 복잡성: 인터페이스 모킹의 어려움
연관관계와 CQRS 패턴 고려
연관관계 제거의 장단점
연관관계를 제거하고 FK만 사용하는 방식도 고려할 수 있습니다:
장점:
- N+1 문제 원천 차단
- 단순한 Repository 메서드
- 성능 예측 가능
단점:
- Service 계층의 복잡성 증가
- 여러 번의 Repository 호출 필요
- 비즈니스 로직 구현의 어려움
CQRS 패턴의 도입
Command Query Responsibility Segregation(CQRS) 패턴을 적용하면 읽기와 쓰기를 완전히 분리할 수 있습니다.
동기 CQRS의 문제점
동기적으로 읽기 모델을 업데이트하는 방식은 도메인 간 결합도를 높입니다:
@Service
public class CommentService {
private final UserRepository userRepo; // 다른 도메인 의존
private final TodoRepository todoRepo; // 다른 도메인 의존
// 결합도가 높아지는 문제
}
비동기 CQRS의 해결책
이벤트 기반의 비동기 CQRS로 도메인을 완전히 분리할 수 있습니다:
- Command Side: 각 도메인이 독립적으로 이벤트 발행
- 별도의 읽기 모델 도메인: 자체 Repository와 Entity로 읽기 모델 관리
- 이벤트 핸들러: 각 도메인의 이벤트를 받아서 읽기 모델 업데이트
이렇게 하면 User나 Todo Repository를 직접 호출하지 않고, 각 도메인에서 발행하는 이벤트를 통해 필요한 정보를 받아 자체 데이터베이스에 저장할 수 있습니다.
CQRS의 실제 단점
기술적 관점에서 CQRS의 주요 단점은 다음과 같습니다:
- 저장공간 증가: 데이터 중복 저장으로 약 1.5-2배의 공간 필요
- 관리 복잡성: 스키마 변경 시 여러 곳 수정 필요
- 학습 비용: 이벤트 기반 아키텍처와 최종 일관성 개념 이해 필요
- 운영 복잡성: 이벤트 큐 모니터링과 데이터 동기화 도구 필요
결론 및 선택 기준
현재 프로젝트에 적합한 선택
- 일반적인 CRUD 애플리케이션: Entity + FETCH JOIN 방식
- 검증된 방법으로 안정적
- 학습 비용이 낮음
- 대부분의 성능 문제 해결 가능
- 성능이 중요한 대용량 서비스: Projection 추가 활용
- 목록 조회나 통계 API에서 선택적 사용
- Entity 방식과 혼합하여 사용
- 복잡한 도메인과 높은 확장성이 필요한 경우: CQRS 패턴 고려
- 읽기와 쓰기 요구사항이 크게 다른 경우
- 도메인 간 완전한 분리가 필요한 경우
점진적 개선 전략
- 1단계: FETCH JOIN으로 N+1 문제 해결
- 2단계: 성능 이슈가 있는 부분에 Projection 도입
- 3단계: 캐시 레이어 추가
- 4단계: 대규모 서비스로 성장 시 CQRS 고려
결국 기술 선택은 현재 요구사항과 팀의 역량, 그리고 미래의 확장성을 종합적으로 고려하여 결정해야합니다. 무엇보다 과도한 엔지니어링보다는 현재 문제를 효과적으로 해결하는 것이 중요합니다. 그래서 실용성 vs 확장성 둘 사이에서 적절한 기준을 잡아야합니다.
'Spring 7기 프로젝트 > 코드 개선 + 대용량 트래픽 문제' 카테고리의 다른 글
| [트러블슈팅] 외래키 자동 인덱스와 성능 최적화 분석 (0) | 2025.07.01 |
|---|---|
| 대규모 트래픽 환경에서의 데이터베이스 설계 전략: CQRS와 레플리카의 역할 분담 (0) | 2025.06.23 |