Spring Boot
강의

#8 - Controller에서 하면 안 되는 것들 — Spring Boot 실무 설계 기준과 코드

중년개발자
중년개발자

@loxo

4일 전

9

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
  • 타입 검증
  • 필수 필드 존재 여부
java
@PostMapping("/orders") public ResponseEntity<Void> create( @Valid @RequestBody CreateOrderRequest request ) { orderService.create(request); return ResponseEntity.ok().build(); }

여기서 중요한 기준은 이것이다.

이 메서드 안에서 if 문이 늘어나기 시작하면 설계가 무너지고 있다.


2️⃣ 요청자의 자격을 확정한다 (Auth Context Fixing)

Controller는 인증을 "처리"하지 않는다.

하지만 다음 사실은 확정해야 한다.

  • 누가 요청했는가
  • 어떤 권한 컨텍스트인가
java
@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 요청을 하나의 유스케이스로 묶는다.

java
@PostMapping("/orders") public ResponseEntity<Void> create(...) { orderService.create(...); }
  • Service는 한 번만 호출한다
  • 여러 규칙을 엮지 않는다

여러 Service 호출이 필요하다면,
그건 Controller의 책임이 아니라 새로운 Service의 책임이다.

즉, Service는 다음 역할을 맡는다.

  • 여러 도메인 규칙을 하나의 유스케이스로 묶는다
  • 필요한 Repository, Domain 객체들을 내부에서 조합한다
  • 성공과 실패를 트랜잭션 단위로 책임진다

Controller는 이를 지휘하지 않고,
"이 유스케이스를 실행하라"고 요청만 한다.


3. Controller가 절대 하지 않는 처리

❌ 비즈니스 판단

java
if (request.quantity() > 100) { throw new IllegalArgumentException(); }

이 판단은 규칙이며,
규칙은 Service 또는 Entity의 소유물이다.


❌ 상태 변경

Controller에서 Entity를 수정하거나,
Service에서 반환된 객체를 가공하면

트랜잭션 경계와 책임 경계가 동시에 붕괴된다.


❌ 흐름 조합

java
userService.check(); orderService.create(); logService.save();

이 구조는
Controller를 오케스트레이터로 만들어 버린다.


4. DTO → Response DTO 변환은 어디까지 허용되는가

이 질문은 매우 중요하다.

결론부터 말하면, "표현 변환"까지만 허용된다.


허용되는 변환

  • Entity → Response DTO
  • Service 결과 → API 응답 형태
java
@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>
  • 이미 결정된 키를 기준으로 ListMap

아래는 허용되는 예시다.

java
// 허용 Map<OrderStatus, List<OrderResponse>> map = orders.stream() .map(OrderResponse::from) .collect(groupingBy(OrderResponse::status));

아래는 허용되지 않는 예시다.

java
// ❌ 가공 Map<String, List<Order>> grouped = orders.stream() .collect(groupingBy(o -> o.isDelayed() ? "DELAYED" : "NORMAL" ));

여기서 중요한 기준은 다음과 같다.

  • 새로운 기준을 만들지 않음
  • 데이터를 “해석”하지 않음
  • 의미를 “묶거나 나누지” 않음

대표적인 허용 사례는 다음과 같다.

  • 날짜/시간 포맷 변환 (도메인 타입 → API 표현)
  • List 구조를 **이미 결정된 기준(상태, 타입, 키 값 등)**에 따라 API 소비에 맞게 Map 형태로 변환 (새로운 조건·판단·의미 해석 없이)
  • enum, 코드 값을 문자열/라벨로 치환

이 변환들은 의미를 바꾸지 않고, 보이는 형태만 바꾸는 작업이다.


허용되지 않는 변환

java
Order order = orderService.get(id); order.changeStatus(...); // ❌ return ResponseEntity.ok(new OrderResponse(...));

Controller가 데이터를 바꾸는 순간,
Entity는 더 이상 스스로를 지키지 못한다.


5. Response DTO 설계 기준

Response DTO는 읽기 전용 모델이다.

  • setter 없음
  • 비즈니스 의미 없음
  • 계산 없음
java
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의 레이어 구조는
설명하지 않아도 읽히는 코드가 된다.

clipboard-1770622856191.png

#Spring Boot#Controller#설계 원칙#비즈니스 로직 분리#계층 분리

댓글 0

Ctrl + Enter를 눌러 등록할 수 있습니다
※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.