kee-log

Spring MVC Service와 CQRS + Mediator, 무엇이 다른가

Spring MVC Service와 CQRS + Mediator, 무엇이 다른가

Spring 애플리케이션의 구조를 공부하다 보면 흔히 두 가지 형태를 마주친다.

Controller → Service → Repository
Controller → Mediator → Command/Query Handler → Repository

겉으로 보면 첫 번째는 전통적인 MVC, 두 번째는 CQRS를 적용한 새로운 구조처럼 보인다. 그래서 “작은 프로젝트는 MVC, 큰 프로젝트는 CQRS”라고 정리하기 쉽다.

하지만 이 비교에는 중요한 함정이 있다. Spring MVC, Service Layer, CQRS, Mediator는 서로 같은 층에 있는 대안이 아니다. 하나를 선택하면 나머지를 버려야 하는 관계도 아니다.

이 글에서는 먼저 네 개념의 위치를 분리한 뒤, Spring 애플리케이션에서 실제로 어떤 기준으로 선택하면 되는지 살펴본다.

먼저 바로잡아야 할 비교

Spring MVC는 HTTP 요청을 Controller에 연결하고 응답을 만드는 웹 프레젠테이션 패턴이다. Spring 공식 문서도 @Controller, @RestController를 요청 매핑과 입력, 예외 처리 등을 담당하는 웹 컴포넌트로 설명한다.

Service Layer는 웹과 도메인 또는 데이터 접근 코드 사이에서 애플리케이션의 유스케이스와 트랜잭션을 조정하는 방식이다.

반면 CQRS(Command Query Responsibility Segregation)는 상태를 변경하는 모델과 데이터를 조회하는 모델의 책임을 분리하는 패턴이다. Mediator는 요청을 구체적인 처리자에게 전달해 호출자와 처리자의 직접 의존을 줄이는 별개의 패턴이다.

따라서 더 정확한 그림은 다음과 같다.

웹 계층          Spring MVC Controller

애플리케이션 계층   Service 또는 Command/Query Handler

도메인·데이터 계층  Domain Model / Repository

CQRS를 적용해도 앞단에 Spring MVC를 그대로 사용할 수 있고, Command Handler가 내부 Application Service를 호출할 수도 있다.

전통적인 Service 중심 구조

Service 중심 구조에서는 Controller가 HTTP에 관한 일만 처리하고, 하나의 Service가 유스케이스를 수행한다.

@RestController
@RequestMapping("/users")
public class UserController {
 
    private final UserService userService;
 
    public UserController(UserService userService) {
        this.userService = userService;
    }
 
    @PostMapping
    public ResponseEntity<Void> create(@RequestBody CreateUserRequest request) {
        userService.create(request.name());
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
@Service
public class UserService {
 
    private final UserRepository userRepository;
 
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    @Transactional
    public void create(String name) {
        User user = User.create(name);
        userRepository.save(user);
    }
}

이 구조의 핵심은 @Service라는 애너테이션이 아니라 Controller에서 유스케이스를 분리했다는 점이다. @Transactional을 Service의 유스케이스 경계에 둘 수 있고, 호출 흐름도 짧아서 따라가기 쉽다.

장점

  • 클래스와 개념의 수가 적어 구조를 익히기 쉽다.
  • Controller → Service라는 호출 흐름이 명확하다.
  • 일반적인 CRUD와 단순한 업무 규칙을 빠르게 구현할 수 있다.
  • 유스케이스 단위의 트랜잭션 경계를 표현하기 쉽다.

주의할 점

Service 방식 자체가 문제는 아니다. 문제는 사용자와 관련된 모든 기능을 하나의 UserService에 계속 추가하는 식으로 기능 경계를 무시할 때 생긴다.

UserService
 ├─ createUser()
 ├─ updateProfile()
 ├─ changePassword()
 ├─ grantRole()
 ├─ suspendUser()
 └─ ...

클래스가 커지고 의존성이 늘면 변경 이유도 많아지고 테스트 준비 비용도 커진다. 그렇다고 CQRS만이 해답인 것은 아니다. CreateUserService, ChangePasswordService처럼 Service를 유스케이스 단위로 나누는 것만으로도 상당 부분 해결할 수 있다.

CQRS는 무엇을 분리하는가

CQRS의 핵심은 Command와 Query의 책임을 구분하는 것이다.

  • Command: 시스템의 상태를 변경하려는 의도를 표현한다.
  • Query: 상태를 변경하지 않고 데이터를 반환한다.
public record CreateUserCommand(String name) {
}
 
public record GetUserQuery(Long userId) {
}
@Component
public class CreateUserHandler {
 
    private final UserRepository userRepository;
 
    public CreateUserHandler(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    @Transactional
    public void handle(CreateUserCommand command) {
        User user = User.create(command.name());
        userRepository.save(user);
    }
}
@Component
public class GetUserHandler {
 
    private final UserQueryRepository userQueryRepository;
 
    public GetUserHandler(UserQueryRepository userQueryRepository) {
        this.userQueryRepository = userQueryRepository;
    }
 
    @Transactional(readOnly = true)
    public UserView handle(GetUserQuery query) {
        return userQueryRepository.findViewById(query.userId())
            .orElseThrow(UserNotFoundException::new);
    }
}

이렇게 나누면 쓰기 모델은 상태 전이와 불변 조건에 집중하고, 조회 모델은 화면에 필요한 데이터 형태와 조회 성능에 집중할 수 있다.

다만 Command와 Query 클래스를 나누었다고 곧바로 완전한 CQRS가 되는 것은 아니다. 같은 데이터 모델과 같은 데이터베이스를 사용하면서 코드의 책임만 나누는 가벼운 형태도 있고, 읽기 전용 모델과 저장소까지 별도로 운영하는 형태도 있다. 두 저장소를 반드시 분리해야 하는 것은 아니다.

Mediator는 CQRS의 필수 구성 요소가 아니다

Mediator를 사용하면 Controller는 구체적인 Handler 대신 하나의 전송 인터페이스만 알게 된다.

@RestController
@RequestMapping("/users")
public class UserController {
 
    private final CommandBus commandBus;
 
    public UserController(CommandBus commandBus) {
        this.commandBus = commandBus;
    }
 
    @PostMapping
    public ResponseEntity<Void> create(@RequestBody CreateUserRequest request) {
        commandBus.send(new CreateUserCommand(request.name()));
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
public interface CommandBus {
    void send(Object command);
}

CommandBus 구현체는 Command 타입에 맞는 Handler를 찾아 호출한다. 이 중간 계층을 이용하면 로깅, 검증, 권한 확인, 트랜잭션 같은 공통 동작을 Handler 호출 전후에 일관되게 적용할 수도 있다.

Controller
   → CommandBus
      → Validation
      → Authorization
      → Logging
      → Handler

하지만 CQRS의 본질은 읽기와 쓰기의 책임 분리이지 Mediator 사용이 아니다. Controller가 CreateUserHandler를 직접 주입받아 호출해도 CQRS를 적용할 수 있다.

또한 Spring의 ApplicationEventPublisher는 하나의 이벤트를 여러 Listener에 알리는 발행-구독 도구다. 하나의 요청을 담당 Handler에 보내고 결과를 받는 Command Bus와 목적이 다르므로, 둘을 같은 것으로 간주하지 않는 편이 안전하다.

무엇이 실제로 좋아지는가

CQRS와 Mediator를 적용한다고 유지보수성과 테스트 가능성이 자동으로 좋아지지는 않는다. 얻는 효과는 다음처럼 조건부다.

관점Service 중심CQRS + HandlerMediator까지 적용
기본 단위Service 메서드Command/Query Handler요청과 Handler 사이의 간접 호출
호출 흐름짧고 직접적유스케이스별로 명확함Pipeline까지 포함해 길어질 수 있음
읽기·쓰기 모델보통 공유필요에 따라 분리CQRS와 동일
공통 관심사AOP, Filter, Interceptor 등각 Handler 또는 별도 구성Pipeline으로 일관되게 적용 가능
파일과 추상화 수적음많아짐가장 많아짐
적합한 상황단순 CRUD, 작은 업무 흐름읽기·쓰기 요구가 뚜렷하게 다름Handler 수가 많고 공통 처리 방식이 필요함

Handler 하나의 의존성이 작아지면 단위 테스트가 간단해질 수 있다. 반대로 Mediator와 Pipeline을 모두 통과해야 실제 동작을 확인할 수 있다면 통합 테스트의 중요성은 더 커진다. 구조가 테스트를 돕는 것이지, 패턴 이름이 테스트 용이성을 보장하는 것은 아니다.

“프로젝트 규모”보다 중요한 도입 신호

“소규모는 MVC, 중대규모는 CQRS”라는 기준은 지나치게 거칠다. 팀과 코드베이스가 커도 단순한 CRUD 시스템이라면 Service 중심 구조가 더 적합할 수 있다. 반대로 작은 시스템이어도 복잡한 상태 전이와 조회 요구가 있다면 일부 영역에 CQRS가 도움이 된다.

CQRS 도입을 검토할 만한 신호는 다음과 같다.

  • 쓰기 모델의 도메인 규칙과 조회 화면의 데이터 형태가 크게 다르다.
  • 읽기와 쓰기의 부하 특성이 달라 서로 다른 최적화가 필요하다.
  • 하나의 범용 Service보다 유스케이스별 변경 이유를 분리할 필요가 있다.
  • 복잡한 도메인의 상태 변경을 명시적인 Command로 표현할 가치가 있다.

반대로 아래 상황에서는 먼저 단순한 Service 구조를 유지하는 편이 낫다.

  • 대부분이 단순 CRUD다.
  • 읽기와 쓰기가 같은 데이터 모델로 충분하다.
  • 별도 Read Model의 동기화 비용을 감수할 이유가 없다.
  • Handler, Bus, Pipeline을 추가해도 해결되는 구체적인 문제가 없다.

CQRS는 전체 시스템에 일괄 적용하기보다 효과가 분명한 Bounded Context나 기능부터 제한적으로 적용할 수 있다.

현실적인 발전 순서

처음부터 모든 요청에 Command, Query, Handler, Validator, Pipeline을 만들 필요는 없다. 다음처럼 실제 문제에 맞춰 한 단계씩 발전시키는 편이 안전하다.

1. Controller를 얇게 유지하고 Service에 유스케이스를 둔다.
2. Service가 커지면 기능 또는 유스케이스 단위로 나눈다.
3. 읽기와 쓰기의 요구가 달라질 때 Command와 Query 모델을 분리한다.
4. Handler 호출에 반복되는 공통 처리가 많아질 때 Mediator를 검토한다.
5. 독립 확장이 필요할 때만 Read DB 분리와 비동기 동기화까지 확장한다.

이 순서에서 중요한 것은 “Service가 커졌으니 CQRS로 갈아탄다”가 아니다. 먼저 무엇이 커졌고 어떤 책임이 충돌하는지를 확인해야 한다. 단순히 클래스 하나가 길어진 문제라면 유스케이스별 Service 분리가 더 작은 해법일 수 있다.

결론

Spring MVC와 CQRS는 양자택일의 관계가 아니다.

Spring MVC  = 웹 요청과 응답을 다루는 프레젠테이션 구조
Service     = 애플리케이션 유스케이스를 조정하는 방식
CQRS        = 상태 변경과 조회의 책임 및 모델을 분리하는 방식
Mediator    = 요청 발신자와 처리자의 결합을 낮추는 전달 방식

단순한 Service 구조도 책임을 잘 나누면 오래 유지할 수 있다. CQRS + Mediator 구조도 해결할 문제가 분명하지 않으면 파일과 간접 호출만 늘어난다.

좋은 기준은 프로젝트의 크기나 유행하는 패턴이 아니라 현재 도메인의 복잡성, 읽기·쓰기 요구의 차이, 그리고 감수할 운영 비용이다.

참고 자료