Spring MVC Controller에서 객체 생성을 지양해야 하는 이유
– 에스크로 프로젝트를 진행하며 얻은 실전 교훈
에스크로 트랜잭션 상태 변경 기능을 만들면서 겪은 고민
Spring Boot 기반으로 에스크로 서비스를 직접 구현하면서, 거래 상태를 변경하는 기능을 만들게 되었다.
처음에는 빠르게 동작하는 코드를 만드는 데 집중했고, 자연스럽게 Controller 내부에서 필요한 객체들을 직접 생성하는 방식으로 구현했다.
아래는 처음 작성한 코드이다..
@PostMapping("/{id}/status")
public ResponseEntity<Void> updateStatus(
@PathVariable Long id,
@RequestBody @Valid TransactionStatusUpdateRequest request
) {
UpdateTransactionStatusCommand command = new UpdateTransactionStatusCommand(
id,
request.status(),
request.changedBy(),
request.memo()
);
updateTransactionStatusUsecase.execute(command);
return ResponseEntity.ok().build();
}
처음 봤을 때는 이 코드가 전혀 문제 없어 보였다.
하지만 점점 기능이 늘어나고 도메인 복잡도가 올라가면서, Controller의 코드가 점점 길어지고 복잡해지는 문제가 발생했다.
Controller의 책임은 정말 어디까지여야 할까?
Controller는 MVC 구조에서 요청을 받고 전달만 하는 아주 얇은 계층이다.
그런데 위와 같은 방식은 Controller가 도메인 객체의 생성 책임까지 떠안고 있는 셈이다.
이를 코드 책임 관점에서 보면 다음과 같은 문제점이 드러난다.
- Controller에서 비즈니스 로직과 밀접한 객체를 new로 직접 생성
- 파라미터가 많아질수록 코드 가독성 저하
- 단위 테스트 시 Controller 테스트가 어려워짐
- 도메인 구조가 바뀔 때 Controller까지 수정 범위가 확장됨
해결 방향: DTO 내부에서 Command 객체로 변환하도록 위임
이 문제를 해결하기 위해, 객체 생성 책임을 Controller에서 DTO로 옮기기로 결정했다.
즉, DTO 내부에 toCommand()라는 변환 메서드를 정의했다.
public record TransactionStatusUpdateRequest(
String status,
String changedBy,
String memo
) {
public UpdateTransactionStatusCommand toCommand(Long transactionId) {
return new UpdateTransactionStatusCommand(transactionId, status, changedBy, memo);
}
}
이제 Controller는 아래처럼 훨씬 간결하고 명확하게 변했다.
@PostMapping("/{id}/status")
public ResponseEntity<Void> updateStatus(
@PathVariable Long id,
@RequestBody @Valid TransactionStatusUpdateRequest request
) {
updateTransactionStatusUsecase.execute(request.toCommand(id));
return ResponseEntity.ok().build();
}
이 방식의 실질적인 장점
항목 | 개선 전 | 개선 후 |
Controller 책임 | Command 직접 생성 | 요청 위임만 수행 |
가독성 | 파라미터 나열 | 의미 있는 추상화 (toCommand) |
확장성 | 파라미터 변경 시 Controller까지 영향 | DTO 내부 캡슐화로 유지보수 용이 |
테스트 | Command 생성 로직 테스트 어려움 | DTO 단위 테스트 가능 |
이처럼 작은 변경이지만 코드의 책임 분리, 테스트 용이성, 유지보수성 측면에서 큰 차이를 만들어냈다.
내가 직접 겪은 실전 상황과 변화
에스크로 프로젝트에서는 거래 상태가 변경될 때, 누가 상태를 바꿨는지, 어떤 사유로 바뀌었는지 등의 정보가 함께 전달되어야 했다.
초기에는 Controller에서 그 모든 데이터를 조합해 Command 객체로 만들어 Usecase에 넘겼다.
그런데 기능이 점점 늘어나면서, 다른 API에서도 비슷한 구조의 Command 객체를 만들어야 할 상황이 생겼고,
Controller마다 거의 동일한 new 생성 코드가 중복되기 시작했다.
이때 DTO.toCommand() 방식을 도입한 뒤, 모든 Controller 코드가 일관되고 깔끔해졌고,
각 Usecase에서도 더 일관된 Command 구조를 받을 수 있게 되었다.
회고: Controller는 얇고 단순하게, 책임은 각자의 계층으로
이번 경험을 통해 다음과 같은 교훈을 얻었다.
- Controller는 정말 단순하게 유지해야 한다.
- 객체 생성 책임은 비즈니스와 가까운 계층(DTO 또는 Factory)로 위임하는 것이 유지보수에 좋다.
- 중복되는 생성 로직은 toCommand() 등 명확한 이름을 가진 변환 메서드로 캡슐화하자.
정리 요약
- Controller에서 new Command(...) 직접 생성 → 책임 과도
- DTO에 toCommand() 메서드를 두어 객체 생성 책임 분리 → 가독성/테스트성 향상
- 실무 코드에서 Controller의 역할을 명확하게 유지하는 것은 작은 습관이지만 프로젝트 전반의 품질을 결정짓는 요소다.
이 경험은 단순한 코드 스타일 변경이 아닌, 도메인 책임 분리의 시작점이었다고 생각한다.
앞으로도 "Controller는 요청을 받아 전달만 한다"는 원칙을 프로젝트 전반에 적용하려고 한다.
특히 복잡한 트랜잭션 흐름이 있는 서비스일수록 이런 구조적 분리가 더 큰 힘을 발휘한다.
'개발 기록 > 회고' 카테고리의 다른 글
왜 Repository를 직접 쓰지 않고 인터페이스를 쓰는가 (2) | 2025.08.02 |
---|---|
Spring Service에서 객체 생성 책임을 분리해야 하는 이유 – 실무에서 Factory 패턴을 도입한 회고 (1) | 2025.05.29 |
Spring Boot 입문기 – @Getter, @Setter, 그리고 Controller 어노테이션을 처음 마주했을 때 (2) | 2025.05.26 |
TokenService는 왜 static이어야 했을까? 객체지향을 넘나드는 JS 설계 회고 (0) | 2025.05.12 |
Node.js 인증 서비스의 구조적 문제와 개선을 위한 리팩토링 여정 (0) | 2025.05.08 |