#8 - Controller에서 하면 안 되는 것들 — Spring Boot 실무 설계 기준과 코드
중년개발자
@loxo
4일 전
Controller를 입구로 설계한다는 것
Spring Boot에서 Controller를 가장 실무적으로 사용하는 기준
"의심한다"는 명제를 코드로 구현하는 방법
이 문서를 읽는 기준
1️⃣ “의심한다”는 명제를 추상적으로 말하지 않는다
- 의심을 구체적인 책임 목록으로 풀어낸다
→ 해석 / 검증 / 자격 확인 - 반대로 의심과 무관한 행동을 금지 목록으로 명확히 제시한다
이렇게 하면
“Controller에서 뭘 하면 안 되는지”가 감각이 아니라 기준이 된다.
2️⃣ 가장 많이 헷갈리는 질문을 정면으로 다룬다
현장에서 반복되는 질문은 늘 같다.
- Service를 여러 번 호출해도 되는가?
- Service에서 받은 데이터를 Controller에서 편집해도 되는가?
3️⃣ Spring Boot의 ‘사상’에 맞게 정렬한다
Spring이 기대하는 Controller는 다음이 아니다.
- orchestration 계층 ❌
- 판단 계층 ❌
- 가공 계층 ❌
→ 입구 계층이다.
그래서 이 문서는 마지막에 이 문장으로 수렴한다.
검증한다 / 위임한다 / 전달한다
1. Controller는 무엇의 입구인가
Controller는 기능의 입구가 아니다.
Controller는 시스템 신뢰 경계의 입구다.
HTTP 요청이 이 선을 넘는 순간,
그 요청은 외부 입력에서 시스템 내부 이벤트로 바뀐다.
그래서 Controller의 모든 코드는
다음 질문에 답하고 있어야 한다.
"이 요청을 시스템 안으로 들여도 되는가?"
2. 입구에서 실제로 처리해야 하는 것 (실무 기준)
1️⃣ 요청을 해석한다 (Parsing & Shape Validation)
Controller는 의미를 해석하지 않는다.
- JSON → DTO
- 타입 검증
- 필수 필드 존재 여부
@PostMapping("/orders")
public ResponseEntity<Void> create(
@Valid @RequestBody CreateOrderRequest request
) {
orderService.create(request);
return ResponseEntity.ok().build();
}여기서 중요한 기준은 이것이다.
이 메서드 안에서 if 문이 늘어나기 시작하면 설계가 무너지고 있다.
2️⃣ 요청자의 자격을 확정한다 (Auth Context Fixing)
Controller는 인증을 "처리"하지 않는다.
하지만 다음 사실은 확정해야 한다.
- 누가 요청했는가
- 어떤 권한 컨텍스트인가
@PostMapping("/orders")
public ResponseEntity<Void> create(
@AuthenticationPrincipal UserPrincipal user,
@Valid @RequestBody CreateOrderRequest request
) {
orderService.create(user.id(), request);
return ResponseEntity.ok().build();
}👉 Service는 더 이상 "누가 요청했는가"를 의심하지 않는다.
3️⃣ 요청을 하나의 유스케이스로 고정한다
Controller는 하나의 HTTP 요청을 하나의 유스케이스로 묶는다.
@PostMapping("/orders")
public ResponseEntity<Void> create(...) {
orderService.create(...);
}- Service는 한 번만 호출한다
- 여러 규칙을 엮지 않는다
여러 Service 호출이 필요하다면,
그건 Controller의 책임이 아니라 새로운 Service의 책임이다.
즉, Service는 다음 역할을 맡는다.
- 여러 도메인 규칙을 하나의 유스케이스로 묶는다
- 필요한 Repository, Domain 객체들을 내부에서 조합한다
- 성공과 실패를 트랜잭션 단위로 책임진다
Controller는 이를 지휘하지 않고,
"이 유스케이스를 실행하라"고 요청만 한다.
3. Controller가 절대 하지 않는 처리
❌ 비즈니스 판단
if (request.quantity() > 100) {
throw new IllegalArgumentException();
}이 판단은 규칙이며,
규칙은 Service 또는 Entity의 소유물이다.
❌ 상태 변경
Controller에서 Entity를 수정하거나,
Service에서 반환된 객체를 가공하면
트랜잭션 경계와 책임 경계가 동시에 붕괴된다.
❌ 흐름 조합
userService.check();
orderService.create();
logService.save();이 구조는
Controller를 오케스트레이터로 만들어 버린다.
4. DTO → Response DTO 변환은 어디까지 허용되는가
이 질문은 매우 중요하다.
결론부터 말하면, "표현 변환"까지만 허용된다.
허용되는 변환
- Entity → Response DTO
- Service 결과 → API 응답 형태
@GetMapping("/orders/{id}")
public ResponseEntity<OrderResponse> get(@PathVariable Long id) {
Order order = orderService.get(id);
return ResponseEntity.ok(OrderResponse.from(order));
}이 변환의 성격은:
- 계산 ❌
- 판단 ❌
- 가공 ❌
👉 표현(View)의 문제다.
🔹 View 표현이란 무엇인가
의미는 그대로 두고, 보이는 형태만 바꾸는 것이다.
- 도메인이 이미 결정한 의미를
- API 소비자가 이해하기 쉬운 모양으로만 전달한다
즉,
정보의 해석 주체가 바뀌지 않는다
대표적인 View 표현은 다음과 같다.
LocalDateTime→"2026-02-09 15:30:00"enum OrderStatus.COMPLETED→"COMPLETED"List<Order>→List<OrderResponse>- 이미 결정된 키를 기준으로
List→Map
아래는 허용되는 예시다.
// 허용
Map<OrderStatus, List<OrderResponse>> map =
orders.stream()
.map(OrderResponse::from)
.collect(groupingBy(OrderResponse::status));아래는 허용되지 않는 예시다.
// ❌ 가공
Map<String, List<Order>> grouped =
orders.stream()
.collect(groupingBy(o ->
o.isDelayed() ? "DELAYED" : "NORMAL"
));여기서 중요한 기준은 다음과 같다.
- 새로운 기준을 만들지 않음
- 데이터를 “해석”하지 않음
- 의미를 “묶거나 나누지” 않음
대표적인 허용 사례는 다음과 같다.
- 날짜/시간 포맷 변환 (도메인 타입 → API 표현)
- List 구조를 **이미 결정된 기준(상태, 타입, 키 값 등)**에 따라 API 소비에 맞게 Map 형태로 변환 (새로운 조건·판단·의미 해석 없이)
- enum, 코드 값을 문자열/라벨로 치환
이 변환들은 의미를 바꾸지 않고, 보이는 형태만 바꾸는 작업이다.
허용되지 않는 변환
Order order = orderService.get(id);
order.changeStatus(...); // ❌
return ResponseEntity.ok(new OrderResponse(...));Controller가 데이터를 바꾸는 순간,
Entity는 더 이상 스스로를 지키지 못한다.
5. Response DTO 설계 기준
Response DTO는 읽기 전용 모델이다.
- setter 없음
- 비즈니스 의미 없음
- 계산 없음
public record OrderResponse(
Long id,
int quantity,
String status
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getQuantity(),
order.getStatus().name()
);
}
}6. Controller 설계가 잘 되었는지 확인하는 체크리스트
- Controller 메서드가 10줄 이내인가?
- if / for 문이 거의 없는가?
- Service 호출이 하나인가?
- 트랜잭션 어노테이션이 없는가?
- 테스트가 단순한가?
이 질문에 모두 "예"라면,
Controller는 입구 역할에 충실하다.
7. 한 문장으로 정리
Controller는 로직을 시작하지 않는다.
Controller는 신뢰를 시작한다.
이 관점으로 Controller를 설계하면,
Spring Boot의 레이어 구조는
설명하지 않아도 읽히는 코드가 된다.
