본문 바로가기
Spring 7기 프로젝트/코드 개선 + 대용량 트래픽 문제

N+1 문제 해결과 CQRS 패턴: 성능 최적화를 위한 다양한 접근법

by JuNo_12 2025. 6. 24.

들어가며

최근 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 변환)을 사용하는 이유

  1. 책임 분리: Repository는 데이터 조회, Service는 비즈니스 로직과 변환
  2. 유연성: 같은 Entity를 여러 DTO로 변환 가능
  3. 재사용성: 하나의 Repository 메서드를 여러 Service에서 활용
  4. 비즈니스 로직 적용: 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로 도메인을 완전히 분리할 수 있습니다:

  1. Command Side: 각 도메인이 독립적으로 이벤트 발행
  2. 별도의 읽기 모델 도메인: 자체 Repository와 Entity로 읽기 모델 관리
  3. 이벤트 핸들러: 각 도메인의 이벤트를 받아서 읽기 모델 업데이트

이렇게 하면 User나 Todo Repository를 직접 호출하지 않고, 각 도메인에서 발행하는 이벤트를 통해 필요한 정보를 받아 자체 데이터베이스에 저장할 수 있습니다.

 

CQRS의 실제 단점

기술적 관점에서 CQRS의 주요 단점은 다음과 같습니다:

  1. 저장공간 증가: 데이터 중복 저장으로 약 1.5-2배의 공간 필요
  2. 관리 복잡성: 스키마 변경 시 여러 곳 수정 필요
  3. 학습 비용: 이벤트 기반 아키텍처와 최종 일관성 개념 이해 필요
  4. 운영 복잡성: 이벤트 큐 모니터링과 데이터 동기화 도구 필요

결론 및 선택 기준

현재 프로젝트에 적합한 선택

  1. 일반적인 CRUD 애플리케이션: Entity + FETCH JOIN 방식
    • 검증된 방법으로 안정적
    • 학습 비용이 낮음
    • 대부분의 성능 문제 해결 가능
  2. 성능이 중요한 대용량 서비스: Projection 추가 활용
    • 목록 조회나 통계 API에서 선택적 사용
    • Entity 방식과 혼합하여 사용
  3. 복잡한 도메인과 높은 확장성이 필요한 경우: CQRS 패턴 고려
    • 읽기와 쓰기 요구사항이 크게 다른 경우
    • 도메인 간 완전한 분리가 필요한 경우

 

점진적 개선 전략

  1. 1단계: FETCH JOIN으로 N+1 문제 해결
  2. 2단계: 성능 이슈가 있는 부분에 Projection 도입
  3. 3단계: 캐시 레이어 추가
  4. 4단계: 대규모 서비스로 성장 시 CQRS 고려

결국 기술 선택은 현재 요구사항과 팀의 역량, 그리고 미래의 확장성을 종합적으로 고려하여 결정해야합니다. 무엇보다 과도한 엔지니어링보다는 현재 문제를 효과적으로 해결하는 것이 중요합니다. 그래서 실용성 vs 확장성 둘 사이에서 적절한 기준을 잡아야합니다.