Spring Boot로 REST API를 개발하다 보면 같은 고민이 반복된다.

“컨트롤러에서 들어오는 데이터를 어떻게 처리할 것인가?”

흔한 실수는 이렇다.

  • DTO에서 검증하지 않고 Service에서 if (data == null)로 처리한다
  • 권한 검증을 Service가 맡는다 → Service에 보안 로직이 섞인다
  • 컨트롤러가 모든 검증을 한다 → 어느 계층(Controller · Service · Repository처럼 역할별로 나눈 층)이 무엇을 책임지는지 흐려진다

이 글은 내가 실무에서 정한 “계층별 책임 분리” 기반의 API 데이터 수신 전략을 적는다. 안전하면서도 효율적인 구조를 만드는 방법이다.


검증 코드가 Controller와 Service에 흩어지면 같은 검사를 두 번 쓰게 된다

일반적인 개발 흐름의 문제점

많은 개발자가 API 데이터를 처리할 때 다음 같은 구조를 쓴다.

@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@RequestBody UserRequest request) {
    // 컨트롤러: 모든 검증을 여기서?
    if (request.getName() == null) {
        return ResponseEntity.badRequest().body(...);
    }
    if (request.getEmail() == null) {
        return ResponseEntity.badRequest().body(...);
    }
    
    // 또는 Service에서:
    userService.createUser(request);
    // (설명에 필요 없는 return 문은 생략했다)
}

// Service
public void createUser(UserRequest request) {
    // Service에서도 검증?
    if (request.getName() == null) {
        throw new IllegalArgumentException("이름은 필수입니다");
    }
    // 실제 비즈니스 로직
}

한 줄씩 따라가기. @PostMapping("/users")는 POST 방식(HTTP 메서드)으로 /users에 온 요청만 이 메서드로 보내라는 표시다. 메서드가 돌려주는 ResponseEntity<UserResponse>는 응답 본문뿐 아니라 HTTP 상태 번호까지 함께 담는 상자다. 매개변수 앞의 @RequestBody는 요청 본문(JSON)을 UserRequest 객체로 바꿔서 받으라는 표시다. request.getName() == null이 참이면 ResponseEntity.badRequest().body(...)를 실행해 HTTP 상태 코드 400(Bad Request)과 함께 바로 응답을 끝낸다 — 그 아래 userService.createUser(request) 줄까지 가지 않는다. 아래 Service의 if는 같은 이름 검사를 한 번 더 한다. 주석의 “실제 비즈니스 로직”은 비즈니스 로직, 즉 업무 규칙을 코드로 옮긴 부분을 말한다.

문제점:

  • 검증 로직이 여러 곳에 흩어진다
  • Service에 데이터 검증이 섞인다
  • 각 계층의 책임이 흐려진다
  • 같은 검증을 반복해서 쓴다

더 심각한 문제: 보안 관련

@PutMapping("/users/{userId}/password")
public ResponseEntity<Void> changePassword(
    @PathVariable Long userId,
    @RequestBody PasswordChangeRequest request) {
    
    // 누가 권한을 확인하나?
    userService.changePassword(userId, request);
    // (return 문은 생략했다)
}

반환 타입의 Void(대문자)는 “응답 본문에 담아 돌려줄 것이 없다”는 뜻으로 쓰는 타입이다. @PutMapping은 PUT 방식(이미 있는 것을 바꾸는 요청)을 받는다. 주소 안의 {userId}는 그 자리에 오는 값이 매번 달라지는 칸이다 — /users/7/password로 오면 7이 들어간다. @PathVariable Long userId가 그 칸의 값을 꺼내 userId에 담는다.

이 API는 누구나 호출할 수 있다. 로그인한 사람이 주소에 다른 사람의 userId를 넣으면?

  • Service에서 권한을 검증한다 → Service 메서드마다 앞머리에 권한 검사가 붙는다. 업무 규칙을 읽으려면 권한 코드를 먼저 헤치고 지나가야 하고, 권한 방식을 바꾸면 Service 메서드를 전부 고쳐야 한다
  • Filter에서 검증한다 → 필터는 요청이 Controller에 닿기 전에 먼저 거치는 곳이라, 여기서 막으면 Controller와 Service는 권한을 몰라도 된다. 자리는 맞다. 남는 질문은 어떻게 구현하느냐다

해결책은 각 계층이 자기 책임만 지게 나누는 것이다

이 문제들의 해결책은 각 계층이 자신의 책임만 충실히 하도록 분리하는 것이다.

전체 Flow 구조

아래 그림은 전체 흐름을 한 번에 보여준다. 403 Forbidden, 400 Bad Request, 409 Conflict 같은 상태 코드와 MethodArgumentNotValidException, @ControllerAdvice 같은 이름이 먼저 나오는데, 전부 바로 아래 「각 계층의 역할 정의」와 「계층별 상세 구현」에서 하나씩 설명한다. 지금은 그림의 전체 모양만 눈에 담아 둔다. 그림의 Interceptor는 필터와 같은 일을 할 수 있는 한 단계 안쪽 부품이다. 이 글의 예시는 Filter로 쓴다.

flowchart TD Start([API 요청]) --> Filter Filter["1. Filter/Interceptor ━━━━━━━━━━━━━━━━ 권한/소유권 검증 (보안 계층)"] Filter -->|PASS| DTO Filter -->|FAIL| Forbidden["403 Forbidden 권한 없음"] DTO["2. DTO + @Valid ━━━━━━━━━━━━━━━━ 기술적 검증 (null, 길이, 포맷)"] DTO -->|PASS| Controller DTO -->|FAIL| BadRequest["400 Bad Request MethodArgumentNotValidException @ControllerAdvice 처리"] Controller["3. Controller ━━━━━━━━━━━━━━━━ 검증하지 않는다 (Service 에 위임)"] Controller --> Service Service["4. Service ━━━━━━━━━━━━━━━━ 비즈니스 규칙 검증 + 비즈니스 로직"] Service -->|SUCCESS| Success["성공 응답"] Service -->|EXCEPTION| BusinessError["409 Conflict 비즈니스 오류 (중복, 재고 부족 등)"] style Start fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:#000 style Filter fill:#e1f5ff,stroke:#0277bd,stroke-width:3px,color:#000 style DTO fill:#fff4e1,stroke:#f57f17,stroke-width:3px,color:#000 style Controller fill:#ffe1f5,stroke:#c2185b,stroke-width:3px,color:#000 style Service fill:#e1ffe1,stroke:#388e3c,stroke-width:3px,color:#000 style Success fill:#ccffcc,stroke:#2e7d32,stroke-width:2px,color:#000 style Forbidden fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000 style BadRequest fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000 style BusinessError fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000

각 계층의 역할 정의

계층 책임 실패 시 응답
Filter 권한/소유권 검증 403 Forbidden
DTO @Valid 기술적 검증 (null, 길이, 포맷) 400 Bad Request
Controller 검증하지 않는다. Service에 위임만 한다 —
Service 비즈니스 규칙 검증 + 순수 비즈니스 로직 409 Conflict 등

「기술적 검증」과 「비즈니스 규칙」을 가르는 기준은 그 시스템이 다루는 업무를 알아야 판단할 수 있는가다. “이메일 칸이 비었다”는 이 프로그램이 쇼핑몰인지 게시판인지 몰라도 판단할 수 있다 — 기술적 검증이다. “이미 가입된 이메일이다”는 회원 가입이라는 업무 규칙을 알아야 하고 DB까지 봐야 한다 — 비즈니스 규칙이다. 이 글에서 “도메인 지식이 필요한 규칙”이라고 하면 뒤쪽을 말한다. 그리고 “순수 비즈니스 로직”은 권한 검사와 형식 검사가 빠지고 이런 업무 규칙만 남은 코드를 말한다.

Controller 본문에는 검증을 두지 않는다. @Valid는 컨트롤러 메서드가 호출되기 전에 실행되므로, 메서드 안에 들어왔다는 것은 이미 기술적 검증이 끝났다는 뜻이다. 여기서 무언가를 더 검사한다면 그건 도메인 지식이 필요한 규칙이고, 그러면 비즈니스 로직이 HTTP 요청을 받는 계층(Controller)으로 새어나온다. 자세한 기준은 계층별 검증 표준에 있다.


계층마다 검증을 이렇게 구현한다

1단계: Filter에서 권한 검증

@Component
public class AuthorizationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response,
                                   FilterChain filterChain) 
            throws ServletException, IOException {
        
        String requestURI = request.getRequestURI();
        String method = request.getMethod();
        
        // PUT /users/{userId}/password 같은 요청 처리
        if (isProtectedResource(requestURI, method)) {
            Long requestedUserId = extractUserIdFromPath(requestURI);
            
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            Long currentUserId = getCurrentUserId(auth);
            boolean isAdmin = hasAdminRole(auth);
            
            // 권한 검증: 본인이거나 관리자만 허용
            if (!isAdmin && !requestedUserId.equals(currentUserId)) {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.setContentType("application/json");
                response.getWriter().write("""
                    {
                        "status": 403,
                        "message": "권한이 없습니다",
                        "timestamp": "%s"
                    }
                    """.formatted(LocalDateTime.now()));
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }
    
    private boolean isProtectedResource(String uri, String method) {
        // /users/{userId}/password PUT 요청 보호
        return uri.matches("/users/\\d+/password") && "PUT".equals(method);
    }
    
    private Long extractUserIdFromPath(String uri) {
        Pattern pattern = Pattern.compile("/users/(\\d+)");
        Matcher matcher = pattern.matcher(uri);
        if (matcher.find()) {
            return Long.parseLong(matcher.group(1));
        }
        return null;
    }

    // getCurrentUserId(auth), hasAdminRole(auth) 는 인증 정보에서
    // 사용자 ID와 관리자 여부를 꺼내는 도우미 메서드다. 이 글에서는 생략한다.
}

한 줄씩 따라가기. 클래스 위의 @Component는 스프링이 이 클래스로 객체를 만들어 직접 관리하라는 표시다 — 그래야 스프링이 이 필터를 찾아 요청마다 끼워 넣는다. extends OncePerRequestFilter는 이 클래스가 필터라는 뜻이다 — 스프링이 제공하는 기반 클래스를 상속받으면 요청 하나당 딱 한 번만 거치는 필터가 된다(서블릿 컨테이너는 한 요청을 내부에서 여러 번 넘기는 경우가 있는데, 그때도 한 번만 돌게 해 준다). doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)의 세 매개변수는 각각 들어온 요청, 돌려줄 응답, 그리고 “다음 순서로 넘기는 통로”다. 이름에 붙은 Servlet은 서블릿에서 온 말이다. 메서드 옆의 throws ServletException, IOException은 “이 메서드 안에서 이 두 예외가 날 수 있으니 부르는 쪽이 처리하라”는 선언이다 — try/catch로 잡지 않고 바깥으로 넘긴다는 뜻이다(Checked 예외). 맨 아래 filterChain.doFilter(request, response)를 호출해야 요청이 다음 필터나 Controller로 이어지고, 호출하지 않으면 거기서 요청이 멈춘다.

request.getRequestURI()는 요청 주소에서 경로 부분(URI, 예: /users/7/password)을 돌려준다. request.getMethod()는 자바 메서드가 아니라 HTTP 메서드 이름("GET", "PUT" 같은 글자)을 돌려준다 — 이름이 같아서 헷갈리기 쉽다. SecurityContextHolder.getContext().getAuthentication()은 지금 로그인한 사람의 인증 정보를 꺼내는 코드다.

권한이 없으면 응답을 직접 만든다. HttpServletResponse.SC_FORBIDDEN은 숫자 403을 담은 상수다. setContentType("application/json")은 응답 본문이 JSON이라고 알리는 Content-Type을 정한다. response.getWriter().write(...)는 응답 본문에 글자를 써 넣는다. """..."""는 자바의 텍스트 블록 문법으로 여러 줄 문자열을 줄바꿈 그대로 적을 수 있게 해 주고, .formatted(...)는 그 안의 %s 자리에 뒤에 적은 값(LocalDateTime.now(), 지금 날짜와 시각)을 채워 넣는다. 그다음 return;으로 메서드를 끝내므로 filterChain.doFilter까지 가지 않는다 — 요청이 Controller에 닿지 않는다.

Pattern.compile(...)과 Matcher는 정규식으로 문자열(여기서는 URL) 안에서 원하는 부분(숫자)을 뽑아내는 코드다. 정규식에서 \d+는 “숫자 한 개 이상”이고, 자바 문자열 안에서 역슬래시 하나를 쓰려면 \\처럼 두 번 적어야 해서 코드에는 \\d+로 보인다. uri.matches("/users/\\d+/password")는 주소 문자열 전체가 이 모양과 맞는지 true/false로 알려 준다. matcher.find()는 문자열 안에서 모양에 맞는 부분을 찾아보고 찾았으면 true를 돌려준다 — 찾은 다음에야 무엇을 찾았는지 꺼낼 수 있어서 group(1)보다 먼저 부른다. 정규식 /users/(\\d+)에서 괄호로 묶은 (\\d+)가 1번 그룹이고, matcher.group(1)은 그 괄호에 걸린 글자("7")를 돌려준다.

특징:

  • 컨트롤러 진입 전에 차단한다
  • Service는 권한 검증 없이 순수 로직만 가진다
  • 권한이 없으면 403 상태 코드로 명확하게 응답한다

2단계: DTO에서 기술적 검증

public class PasswordChangeRequest {
    
    @NotBlank(message = "현재 비밀번호는 필수입니다")
    private String currentPassword;
    
    @NotBlank(message = "새 비밀번호는 필수입니다")
    @Length(min = 8, max = 50, message = "비밀번호는 8자 이상 50자 이하여야 합니다")
    private String newPassword;
    
    @NotBlank(message = "비밀번호 확인은 필수입니다")
    private String newPasswordConfirm;
    
    // Getter, Setter, toCommand() 생략
}

public class UserCreateRequest {
    
    @NotNull(message = "이름은 필수입니다")
    @NotBlank(message = "이름은 공백일 수 없습니다")
    private String name;
    
    @NotNull(message = "이메일은 필수입니다")
    @Email(message = "유효한 이메일 형식이 아닙니다")
    private String email;
    
    @NotNull(message = "나이는 필수입니다")
    @Min(value = 18, message = "18세 이상만 가입 가능합니다")
    @Max(value = 120, message = "유효한 나이가 아닙니다")
    private Integer age;

    @NotBlank(message = "비밀번호는 필수입니다")
    private String password;

    // Getter 생략

    // Service 가 받는 UserCreateCommand 로 바꿔 준다 (4단계에서 쓴다)
    public UserCreateCommand toCommand() {
        return UserCreateCommand.of(name, email, age, password);
    }
}

@NotBlank, @NotNull, @Email, @Min/@Max는 모두 Bean Validation 애너테이션이다. @Length(min = 8, max = 50, ...)는 Bean Validation 표준이 아니라 Hibernate Validator 라이브러리(org.hibernate.validator.constraints 패키지)에 있는 애너테이션이다. Hibernate Validator는 Bean Validation 표준을 실제 코드로 구현한 기준 라이브러리라, 표준 애너테이션을 검사하는 바로 그 검사기가 이것도 함께 검사한다. 그래서 쓰는 법은 같다 — 글자 수가 그 범위를 벗어나면, Controller 매개변수에 붙인 @Valid(4단계 코드에 나온다)가 이 객체를 검사할 때 같이 걸러낸다.

@NotBlank는 null도 막고 공백만 있는 글자도 막는다. 그래서 name에 붙은 @NotNull은 null 검사를 한 번 더 할 뿐이고, 빼도 null은 @NotBlank가 막는다. age를 int가 아니라 Integer로 둔 것은, int는 null이 될 수 없어서 값이 빠져도 0이 들어간 채 @NotNull을 통과하기 때문이다(계층별 검증 표준에 이 함정이 자세히 있다).

특징:

  • 기술적 검증만 맡는다 (null, 길이, 포맷)
  • 오류 메시지가 명확하다
  • 같은 DTO를 쓰는 곳마다 다시 쓸 수 있다

3단계: @ControllerAdvice로 검증 실패 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {
        
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse errorResponse = ErrorResponse.builder()
            .status(HttpStatus.BAD_REQUEST.value())
            .message("요청 데이터 검증 실패")
            .errors(errors)
            .timestamp(LocalDateTime.now())
            .build();
        
        return ResponseEntity.badRequest().body(errorResponse);
    }
}

@Getter
@Builder
public class ErrorResponse {
    private int status;
    private String message;
    private Map<String, String> errors;
    private LocalDateTime timestamp;
}

한 줄씩 따라가기. 클래스 위의 @RestControllerAdvice는 이 클래스가 모든 Controller에서 발생한 예외를 한곳에서 받는다는 표시다. 제목과 그림의 @ControllerAdvice에 “돌려준 값을 응답 본문(JSON)으로 내보낸다”(@ResponseBody)를 합친 것이 @RestControllerAdvice다. @ExceptionHandler(MethodArgumentNotValidException.class)는(클래스 이름 뒤의 .class는 그 클래스 자체를 값으로 넘기는 자바 문법이다) @Valid 검증이 실패했을 때 스프링이 던지는 바로 그 예외를 여기서 잡으라는 뜻이다. ex.getBindingResult().getFieldErrors().forEach(error -> ...)는 실패한 필드들을 하나씩 돌면서 errors 맵에 “필드 이름 → 에러 메시지”를 채워 넣는 코드다. error -> ... 모양은 람다로, “필드 오류 하나(error)를 받아 화살표 오른쪽을 실행하라”는 뜻이다. HttpStatus.BAD_REQUEST.value()는 숫자 400을 꺼낸다.

아래 ErrorResponse 클래스에는 메서드가 하나도 없는데 .builder()...build()가 되는 이유는, 클래스 위의 @Getter, @Builder가 Lombok이 컴파일할 때 그 메서드들을 대신 만들어 주기 때문이다. 이 ErrorResponse 객체를 아래 JSON 글자로 바꾸는 일은 스프링에 들어 있는 Jackson이 대신 해 준다.

응답 예시:

{
    "status": 400,
    "message": "요청 데이터 검증 실패",
    "errors": {
        "email": "유효한 이메일 형식이 아닙니다",
        "age": "18세 이상만 가입 가능합니다"
    },
    "timestamp": "2026-01-04T10:30:00"
}

위 응답은 JSON 형식이다 — 중괄호와 큰따옴표로 값을 적어 프로그램끼리 데이터를 주고받을 때 쓰는 표현 방식이다.

이 핸들러는 MethodArgumentNotValidException 하나만 잡는다. 5단계에서 Service가 던지는 비즈니스 예외(중복 이메일 등)를 409 같은 응답으로 바꾸는 핸들러는 이 글에서 생략했다. 그 방식은 예외 처리 표준에 있다.

4단계: Controller는 검증하지 않고 위임만 한다

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody UserCreateRequest request) {
        
        // 이미 DTO 검증을 통과한 데이터다
        // 여기서는 검증하지 않고 Command 로 바꿔 Service 에 넘기기만 한다
        
        UserResponse response = userService.createUser(request.toCommand());
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PutMapping("/{userId}/password")
    public ResponseEntity<Void> changePassword(
            @PathVariable Long userId,
            @Valid @RequestBody PasswordChangeRequest request) {
        
        // 1. Filter에서 권한 검증 완료 (본인 또는 관리자)
        // 2. DTO @Valid로 기술적 검증 완료
        // 3. Service에서 모든 비즈니스 검증 처리
        
        userService.changePassword(userId, request.toCommand());
        
        return ResponseEntity.noContent().build();
    }

    @GetMapping
    public ResponseEntity<Page<UserResponse>> getUsers(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) String email,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        
        // GET은 @RequestParam으로 선택적 조건 처리
        Page<UserResponse> users = userService.searchUsers(name, email, page, size);
        return ResponseEntity.ok(users);
    }
}

한 줄씩 따라가기. 클래스 위의 @RestController는 이 클래스가 Controller이고 메서드가 돌려준 값을 JSON으로 내보낸다는 표시다. 클래스에 붙은 @RequestMapping("/users")는 이 클래스 안 모든 메서드 주소의 앞부분을 /users로 정한다 — 그래서 주소 없이 @PostMapping만 붙은 createUser도 /users로 온 POST 요청을 받고, @PutMapping("/{userId}/password")는 /users/{userId}/password가 된다. private final UserService userService;는 final 필드인데 생성자가 안 보인다. 클래스 위의 @RequiredArgsConstructor가 final 필드를 받는 생성자를 Lombok으로 만들어 주고, 스프링이 그 생성자에 UserService 객체를 넣어 준다(의존성 주입).

매개변수의 @Valid가 2단계 DTO에 붙인 규칙을 검사하게 하는 스위치다. request.toCommand()는 HTTP 요청 모양의 UserCreateRequest(Request)를 Service가 받는 UserCreateCommand(Command)로 바꾸는 메서드다(2단계 코드 맨 아래에 있다) — Request와 Command를 굳이 나누는 이유는 이 아래 5단계에서 설명한다.

응답 번호는 이렇게 고른다. ResponseEntity.status(HttpStatus.CREATED)는 201(새로 만들었다), ResponseEntity.noContent().build()는 204(성공했고 돌려줄 본문은 없다), ResponseEntity.ok(users)는 200(성공)과 함께 users를 본문에 담는다.

@GetMapping은 GET 방식(가져오기) 요청을 받는다. getUsers에 붙은 @RequestParam은 ?name=철수처럼 주소 뒤에 붙는 선택적인 값을 받는 표시다 — required = false면 그 값이 없어도 되고, defaultValue를 주면 없을 때 그 값을 대신 쓴다. 돌려주는 Page<UserResponse>는 전체 결과를 한 번에 다 주지 않고 page번째 묶음을 size개씩 잘라서 담아 돌려주는 Spring Data의 타입이다.

5단계: Service는 순수 비즈니스 로직만

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    // Service 가 받는 것은 Request 가 아니라 Command 다.
    // Controller 가 request.toCommand() 로 바꿔서 넘긴다.
    public UserResponse createUser(UserCreateCommand command) {
        // 컨트롤러에 오기 전에 이미 검증됨:
        // - name, email, age, password 가 비어 있지 않음 (DTO)
        // - age >= 18 (DTO)
        
        // 순수 비즈니스 로직만 구현
        // 중복 이메일 체크는 비즈니스 규칙
        if (userRepository.existsByEmail(command.getEmail())) {
            throw new DuplicateEmailException("이미 가입된 이메일입니다");
        }
        
        User user = User.of(command.getName(), command.getEmail(), command.getAge(),
                            passwordEncoder.encode(command.getPassword()));
        
        User savedUser = userRepository.save(user);
        return UserResponse.from(savedUser);
    }

    // 여기도 Request 가 아니라 Command 를 받는다.
    public void changePassword(Long userId, PasswordChangeCommand command) {
        // 컨트롤러에 오기 전에 이미 검증됨:
        // - 비밀번호와 확인이 모두 필수다 (DTO @NotBlank)
        // - 길이 범위가 맞다 (DTO @Length)
        // - 권한이 있다 (Filter)

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));

        // 비즈니스 검증 1: 현재 비밀번호 확인
        if (!passwordEncoder.matches(command.getCurrentPassword(), user.getPassword())) {
            throw new InvalidCurrentPasswordException("현재 비밀번호가 일치하지 않습니다");
        }

        // 비즈니스 검증 2: 새 비밀번호와 확인 비밀번호 일치
        if (!command.getNewPassword().equals(command.getNewPasswordConfirm())) {
            throw new InvalidPasswordConfirmException("새 비밀번호가 일치하지 않습니다");
        }

        // 비즈니스 검증 3: 기존 비밀번호와 새 비밀번호가 다른지 확인
        if (passwordEncoder.matches(command.getNewPassword(), user.getPassword())) {
            throw new SamePasswordException("새 비밀번호는 기존 비밀번호와 달라야 한다");
        }

        // 비밀번호 변경
        user.setPassword(passwordEncoder.encode(command.getNewPassword()));
        userRepository.save(user);
    }
}

// User 엔티티에 추가
@Builder
public class User {
    // ... 기타 필드 및 메서드
    
    // 엔티티는 값만 받는다. Request 나 Command 타입을 인자로 받지 않는다.
    public static User of(String name, String email, int age, String encodedPassword) {
        return User.builder()
            .name(name)
            .email(email)
            .age(age)
            .password(encodedPassword)
            .build();
    }
}

// 비즈니스 검증 예외 — 위 changePassword 가 던진다
public class InvalidPasswordConfirmException extends RuntimeException {
    public InvalidPasswordConfirmException(String message) {
        super(message);
    }
}

한 줄씩 따라가기. @Service도 @Component처럼 스프링이 이 클래스로 객체를 만들어 관리하라는 표시다(@Component를 품은 좀 더 구체적인 이름이다). userRepository.existsByEmail(...), userRepository.save(...)의 UserRepository는 DB에서 값을 읽고 쓰는 창구 역할을 하는 인터페이스다 — Spring Data가 메서드 이름만 보고 쿼리(DB에 보내는 요청)를 자동으로 만들어 실행해 준다.

PasswordEncoder는 비밀번호를 다루는 도구다. encode(...)는 비밀번호를 되돌릴 수 없는 해시로 바꿔 돌려주고, DB에는 이 해시가 저장된다. 그래서 현재 비밀번호를 확인할 때 equals로 비교하지 못하고 matches(들어온 비밀번호, 저장된 해시)를 쓴다 — 들어온 비밀번호를 같은 방법으로 바꿨을 때 저장된 값과 맞는지 알려 준다.

userRepository.findById(userId)는 그 ID의 사용자가 없을 수도 있어서 Optional이라는 상자에 담아 돌려준다. .orElseThrow(() -> new UserNotFoundException(...))는 상자가 비어 있으면 화살표 오른쪽에서 만든 예외를 던지고, 들어 있으면 그 User를 꺼내 준다.

User.builder().name(...).email(...).build()는 new User(...)로 생성자를 직접 부르는 대신, @Builder가 만들어 준 방식으로 값을 하나씩 이름 붙여 채워 객체를 만드는 코드다 — 어떤 값이 무엇인지 이름으로 바로 알아볼 수 있다. User는 DB 테이블 한 줄을 옮긴 엔티티이고, 클래스 위에 @Builder가 붙어 있어서 User.builder()를 쓸 수 있다. UserResponse.from(savedUser)는 반대 방향으로, 저장된 User를 응답용 DTO로 바꾸는 정적 메서드다.

맨 아래 InvalidPasswordConfirmException은 RuntimeException을 상속한 예외다. RuntimeException 계열은 Unchecked 예외라서, 이 예외를 던지는 Service 메서드나 부르는 Controller에 throws를 적지 않아도 된다 — 던지면 3단계에서 본 @RestControllerAdvice 쪽까지 그대로 올라간다. super(message)는 받은 메시지를 부모 클래스(RuntimeException)의 생성자에 넘겨, 나중에 getMessage()로 꺼낼 수 있게 한다.

특징:

  • 방어 코드가 없다 (null 체크 불필요)
  • 순수 비즈니스 로직만 구현한다
  • 코드가 깔끔하고 읽기 쉽다

Service 가 Request 를 받지 않는 이유는 계층별 DTO 네이밍 표준에 있다. Request 는 HTTP 요청의 모양이라 API 스펙(클라이언트와 약속한 요청 · 응답 모양, 계약)이 바뀌면 같이 바뀐다. Service 가 그걸 그대로 받으면 API 스펙을 고칠 때마다 Service의 메서드 시그니처가 흔들린다. 엔티티가 Request 를 받는 것은 한 걸음 더 나쁘다. Request는 Controller가 있는 api 패키지(바깥에서 요청을 받는 층)에 있는데, 엔티티는 DB 저장을 맡는 영속성 쪽, 더 안쪽에 있다. 안쪽 코드가 바깥쪽 타입을 import 하면 바깥 API를 고칠 때 안쪽 엔티티까지 같이 고쳐야 하고, 이것을 의존 방향이 뒤집힌다고 한다. 그래서 위 User.of(...) 는 값만 받는다.


실무 시나리오에서도 Controller는 검증하지 않는다

시나리오 1: 주문 생성 (중첩 객체 + 여러 필드 관계)

필드 하나하나의 형식은 DTO가 보지만, “배송 시작일이 배송 예정일보다 뒤면 안 된다”처럼 필드 둘을 비교해야 하는 검사는 DTO에도 Controller에도 두지 않고 Service에 둔다(아래 체크리스트와 같은 기준이다).

날짜 비교는 받은 값만 보면 되니 기술적 검증처럼 보인다. 그런데 “배송은 주문한 날 이후에 시작한다”, “배송 시작일은 예정일보다 앞선다”는 주문 · 배송이라는 업무를 알아야 나오는 규칙이다. 쇼핑몰이 아니라 다른 시스템이면 시작일과 종료일의 관계가 달라질 수 있다. 앞에서 정한 기준(업무를 알아야 판단할 수 있는가)으로 보면 이쪽은 비즈니스 규칙이라 Service 몫이다.

// DTO 정의
public class CreateOrderRequest {
    
    @NotNull(message = "주문일자는 필수입니다")
    @PastOrPresent(message = "미래 날짜는 허용되지 않습니다")
    private LocalDate orderDate;
    
    @NotNull(message = "배송 시작일은 필수입니다")
    private LocalDate shippingStartDate;
    
    @NotNull(message = "배송 예정일은 필수입니다")
    private LocalDate shippingEndDate;
    
    @Valid
    @NotEmpty(message = "상품 목록은 필수입니다")
    private List<OrderItemRequest> items;
    
    // Getter, Setter, toCommand() 생략
}

public class OrderItemRequest {
    
    @NotNull(message = "상품 ID는 필수입니다")
    @Positive(message = "상품 ID는 양수여야 합니다")
    private Long productId;
    
    @NotNull(message = "수량은 필수입니다")
    @Positive(message = "수량은 1개 이상이어야 합니다")
    private Integer quantity;
}

// Controller: 검증 없이 넘기기만 한다
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
        @Valid @RequestBody CreateOrderRequest request) {
    
    OrderResponse response = orderService.createOrder(request.toCommand());
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

// Service: 필드 관계 검증 + 비즈니스 로직
public OrderResponse createOrder(CreateOrderCommand command) {
    // 형식 검증은 DTO 에서 끝난 상태
    
    // 필드끼리의 관계 검증 (받은 값만 보면 된다)
    if (command.getShippingStartDate().isAfter(command.getShippingEndDate())) {
        throw new InvalidDateRangeException("배송 시작일이 배송 예정일보다 뒤에 있을 수 없습니다");
    }
    if (command.getShippingStartDate().isBefore(command.getOrderDate())) {
        throw new InvalidDateException("배송 시작일이 주문일보다 빠를 수 없습니다");
    }
    
    // 재고 확인 (DB 를 봐야 하는 비즈니스 규칙)
    for (OrderItemCommand item : command.getItems()) {
        if (!productService.hasStock(item.getProductId(), item.getQuantity())) {
            throw new InsufficientStockException(
                "상품 ID " + item.getProductId() + "의 재고가 부족합니다"
            );
        }
    }
    
    // 엔티티에는 값만 넘긴다
    List<OrderItem> items = command.getItems().stream()
        .map(item -> OrderItem.of(item.getProductId(), item.getQuantity()))
        .collect(Collectors.toList());
    Order order = Order.of(command.getOrderDate(), command.getShippingStartDate(),
                           command.getShippingEndDate(), items);
    
    Order savedOrder = orderRepository.save(order);
    return OrderResponse.from(savedOrder);
}

// Order 엔티티에 추가
@Builder
public class Order {
    // ... 기타 필드 및 메서드
    
    // Factory Method: 값만 받아 엔티티를 만든다
    public static Order of(LocalDate orderDate, LocalDate shippingStartDate,
                           LocalDate shippingEndDate, List<OrderItem> items) {
        return Order.builder()
            .orderDate(orderDate)
            .shippingStartDate(shippingStartDate)
            .shippingEndDate(shippingEndDate)
            .items(items)
            .build();
    }
}

// OrderItem 엔티티에 추가
@Builder
public class OrderItem {
    // ... 기타 필드 및 메서드
    
    // Factory Method: 값만 받아 엔티티를 만든다
    public static OrderItem of(Long productId, int quantity) {
        return OrderItem.builder()
            .productId(productId)
            .quantity(quantity)
            .build();
    }
}

한 줄씩 따라가기. LocalDate는 시각 없이 날짜(연-월-일)만 담는 자바 타입이다 — 앞에 나온 LocalDateTime은 날짜와 시각을 함께 담는다. a.isAfter(b)는 날짜 a가 b보다 뒤면 true, a.isBefore(b)는 앞이면 true를 돌려준다. 새 애너테이션 셋의 뜻은 이렇다. @PastOrPresent는 날짜가 미래면 안 된다, @NotEmpty는 리스트가 null이거나 비어 있으면 안 된다, @Positive는 0보다 큰 수여야 한다(0도 걸린다). items 필드에 붙은 @Valid는 리스트 안의 OrderItemRequest 하나하나에 붙은 규칙까지 내려가 검사하라는 표시다 — 이게 없으면 productId가 비어 있어도 걸러지지 않는다.

Controller는 toCommand()로 바꿔 넘기는 두 줄뿐이다. 날짜 순서 검사는 Service의 첫머리로 옮겼다.

옮긴 대가로 응답 코드를 따로 챙겨야 한다. 처음 이 검사는 Controller에 있었고, 그러면 표의 「Controller는 검증하지 않는다」와 어긋났다. 그래서 Service로 옮겼다(2026-10-20). 그런데 위 그림에서 Service가 던진 예외는 409 Conflict로 간다. 날짜를 거꾸로 넣은 요청은 「이미 있는 것과 부딪혔다」(409)가 아니라 「요청이 잘못됐다」(400)에 가깝다. 그래서 InvalidDateRangeException · InvalidDateException은 400으로 바꾸는 예외 핸들러를 따로 둬야 한다. 이 글에서는 그 핸들러를 생략했다. 다른 길로 DTO에 클래스 수준 검증(@AssertTrue)을 두면 400을 그대로 얻고 Controller도 비울 수 있다. 하지만 이 글이 정한 「업무를 알아야 판단하는 규칙은 Service」 기준과 어긋나서 고르지 않았다.

그 아래 command.getItems().stream().map(item -> OrderItem.of(...)).collect(Collectors.toList())는 스트림으로, “Command의 상품 줄 하나하나를 OrderItem 엔티티로 바꿔서 리스트로 모아라”는 뜻이다. for 문으로 새 리스트에 하나씩 add하는 것과 결과가 같다.

Order.of(...), OrderItem.of(...)처럼 of라는 이름의 정적 팩토리 메서드로 엔티티를 만드는 패턴을 Factory Method라고 부른다. 생성자를 직접 호출하는 대신 이 메서드만 쓰게 하면, 어떤 값으로 엔티티를 만들 수 있는지를 한 곳에서 통제할 수 있다. 5단계의 User.of(...)와 같은 이유로, 이 메서드들도 Request나 Command가 아니라 값만 받는다.

Service가 던진 InvalidDateRangeException 같은 예외를 몇 번 응답으로 바꿀지는 3단계 끝에서 말한 대로 예외 처리 표준이 정한다.

시나리오 2: 권한 검증이 필요한 리소스 수정

// Filter: 권한 검증
@Component
@RequiredArgsConstructor
public class ResourceOwnershipFilter extends OncePerRequestFilter {
    
    private static final Pattern UPDATE_USER_PATTERN = Pattern.compile("/users/(\\d+)");
    private static final Pattern UPDATE_ORDER_PATTERN = Pattern.compile("/orders/(\\d+)");

    private final OrderService orderService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain)
            throws ServletException, IOException {
        
        String method = request.getMethod();
        String uri = request.getRequestURI();
        
        // PUT/DELETE 요청 검증
        if (isModifyRequest(method)) {
            boolean allowed = true;
            if (isUserResource(uri)) {
                allowed = isUserOwner(uri);
            } else if (isOrderResource(uri)) {
                allowed = isOrderOwner(uri);
            }

            if (!allowed) {
                sendForbiddenResponse(response);
                return;   // 여기서 끝내야 요청이 Controller 로 넘어가지 않는다
            }
        }
        
        filterChain.doFilter(request, response);
    }
    
    private boolean isUserOwner(String uri) {
        Long resourceUserId = extractId(uri, UPDATE_USER_PATTERN);
        Long currentUserId = getCurrentUserId();
        return isAuthorized(currentUserId, resourceUserId);
    }
    
    private boolean isOrderOwner(String uri) {
        Long orderId = extractId(uri, UPDATE_ORDER_PATTERN);
        Long currentUserId = getCurrentUserId();
        
        // 주문의 소유자 확인 (DB 조회)
        return orderService.isOrderOwner(orderId, currentUserId);
    }
    
    private void sendForbiddenResponse(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("""
            {
                "status": 403,
                "message": "이 리소스에 대한 권한이 없습니다"
            }
            """);
    }

    // isModifyRequest, isUserResource, isOrderResource, extractId,
    // getCurrentUserId, isAuthorized 는 1단계와 같은 방식의 도우미 메서드다. 생략한다.
}

// Controller: 권한은 이미 확인됨
@PutMapping("/orders/{orderId}")
public ResponseEntity<OrderResponse> updateOrder(
        @PathVariable Long orderId,
        @Valid @RequestBody UpdateOrderRequest request) {
    
    // Filter에서 권한 확인 완료
    // DTO에서 기술적 검증 완료
    // 배송일과 주문일의 순서 같은 관계 검증은 Service 가 한다
    
    OrderResponse response = orderService.updateOrder(orderId, request.toCommand());
    return ResponseEntity.ok(response);
}

한 줄씩 따라가기. 1단계 필터와 모양이 같고, 주소가 둘(/users/숫자, /orders/숫자)로 늘었다. 소유자인지 확인하는 두 메서드(isUserOwner, isOrderOwner)는 true/false만 돌려주고, 403 응답을 쓰고 return으로 끝내는 일은 doFilterInternal 한 곳에서 한다. 도우미 메서드 안에서 403을 쓰고 return하면 그 도우미만 끝나고 doFilterInternal은 계속 진행해 filterChain.doFilter가 불린다 — 403을 써 놓고도 요청이 Controller까지 가 버린다. 그래서 판단(true/false)과 차단(return)을 나눴다.

주문의 주인이 누구인지는 주소만 봐서는 알 수 없어서 DB를 봐야 한다. 그래서 필터가 OrderService를 받아 isOrderOwner를 부른다. @RequiredArgsConstructor가 4단계 Controller와 같은 방식으로 이 final 필드를 채운다. UpdateOrderRequest는 시나리오 1의 CreateOrderRequest처럼 생긴 DTO라 정의를 생략했다.


계층마다 이 체크리스트로 분리가 지켜졌는지 확인한다

API를 만들 때 다음을 확인한다.

Filter/Interceptor 체크리스트

  • 권한/소유권 검증을 수행하는가?
  • 검증 실패 시 403 Forbidden을 반환하는가?
  • 비즈니스 로직은 포함되지 않았는가?
  • 필요한 경우만 DB 조회를 수행하는가? (주문의 주인처럼 주소와 로그인 정보만으로 판단할 수 없을 때만)

DTO 체크리스트

  • @NotNull, @NotBlank, @Email 등의 기술적 검증만 포함되어 있는가?
  • 명확한 오류 메시지를 제공하는가?
  • 다른 필드와의 관계 검증은 포함하지 않았는가? (예: start < end — Service로 간다)
  • 비즈니스 규칙은 포함하지 않았는가? (예: 중복 확인)

Controller 체크리스트

  • 메서드 본문에 검증 코드가 하나도 없는가?
  • @RequestBody에 @Valid를 붙였는가?
  • 받은 것을 Command로 바꿔 Service에 넘기기만 하는가?

Service 체크리스트

  • 값의 형식은 이미 검증되었다고 가정하는가?
  • 방어 코드(null 체크 등)를 최소화했는가?
  • 도메인 지식이 필요한 규칙과 필드끼리의 관계를 여기서 검증하는가? (마감 시각, 상태 전이, 시작일 < 종료일)
  • DB 조회가 필요한 검증을 수행하는가? (예: 중복 이메일)

실무에서 자주 걸리는 세 경우도 같은 기준으로 푼다

1. GET 요청은 조건을 @RequestParam으로 받는다

목록을 거르는 검색 조건은 JSON 본문이 아니라 주소 뒤에 ?status=PAID&page=0처럼 붙여 보낸다. 그래서 @RequestBody가 아니라 @RequestParam으로 하나씩 받는다.

// GET은 필터링 목적이므로 @RequestParam 사용
@GetMapping("/orders")
public ResponseEntity<Page<OrderResponse>> searchOrders(
        @RequestParam(required = false) String status,
        @RequestParam(required = false) LocalDate startDate,
        @RequestParam(required = false) LocalDate endDate,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size) {
    
    // startDate와 endDate의 관계 검증은 Service 의 searchOrders 첫머리에서 한다
    return ResponseEntity.ok(orderService.searchOrders(status, startDate, endDate, page, size));
}

// Service
public Page<OrderResponse> searchOrders(String status, LocalDate startDate, LocalDate endDate,
                                        int page, int size) {
    if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
        throw new InvalidDateRangeException("시작일이 종료일보다 뒤에 있을 수 없습니다");
    }
    // ... 조회
}

한 줄씩 따라가기. 주소에 붙은 글자(2026-01-04 같은)를 LocalDate로 바꾸는 일은 스프링이 맡는다. 어떤 글자 모양을 기본으로 받는지는 이 글에서 확인하지 않았다. 날짜 둘의 순서 검사는 시나리오 1과 같은 이유로 Controller가 아니라 Service에 둔다. 조건이 선택 항목이라 둘 다 들어왔을 때만(!= null) 비교한다.

2. 메서드마다 다른 권한 검증은 Annotation으로 붙인다

1단계 · 시나리오 2의 필터는 주소 글자를 정규식으로 해석해서 보호할 곳을 고른다. 보호할 주소가 늘면 패턴도 늘고, 어떤 Controller 메서드가 보호되는지는 필터를 열어 봐야 안다. 대신 보호할 메서드에 직접 표시를 붙이는 방법이 있다.

// 커스텀 Annotation 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireResourceOwnership {
    String resourceIdParamName();
    String resourceType();
}

// AOP로 처리
@Component
@Aspect
public class ResourceOwnershipAspect {
    
    @Before("@annotation(requireResourceOwnership)")
    public void checkOwnership(JoinPoint joinPoint, 
                             RequireResourceOwnership requireResourceOwnership) {
        // 소유권 검증 로직 (이 글에서는 비워 둔다)
    }
}

한 줄씩 따라가기. @Target(ElementType.METHOD)와 @Retention(RetentionPolicy.RUNTIME)가 붙은 @interface RequireResourceOwnership은 내가 직접 만든 애너테이션이다 — @Target은 “메서드에만 붙일 수 있다”, @Retention(RUNTIME)은 “프로그램이 실행되는 동안에도 이 표시가 남아 있어서 코드가 읽을 수 있다”는 뜻이다. 안에 메서드처럼 적은 String resourceIdParamName();은 이 애너테이션을 붙일 때 채워야 하는 칸이다 — 아래 Controller에서 resourceIdParamName = "orderId"처럼 값을 넣는다. resourceType("ORDER")은 무슨 종류의 리소스인지 알려 주는 칸이다. Aspect 본문을 비워 둬서 이 글에서는 쓰는 곳을 보이지 않았다. 채운다면 이 값으로 주문 소유자를 확인할지 회원 본인을 확인할지 고르게 된다.

이렇게 만든 애너테이션은 그 자체로는 아무 일도 하지 않고, 누군가 읽어서 행동으로 옮겨야 한다. @Aspect가 붙은 클래스는 AOP 방식으로 원래 코드를 감싸는 역할을 하고, @Before("@annotation(requireResourceOwnership)")는 “@RequireResourceOwnership이 붙은 메서드가 불리기 전에 이 메서드를 먼저 실행하라”는 뜻이다. 따옴표 안의 소문자 requireResourceOwnership은 아래 매개변수 이름과 같아야 한다 — 스프링이 이름을 맞춰서, 메서드에 붙은 애너테이션 객체(그 안의 "orderId", "ORDER" 값)를 그 매개변수에 넣어 준다. 첫 매개변수 JoinPoint는 지금 불리려는 메서드와 그 인자 값을 담고 있다. 소유권 검증 본문은 비워 뒀다. 채운다면 JoinPoint에서 orderId 인자 값을 꺼내 시나리오 2의 isOrderOwner와 같은 검사를 하게 된다.

// Controller에서 사용
@PutMapping("/orders/{orderId}")
@RequireResourceOwnership(resourceIdParamName = "orderId", resourceType = "ORDER")
public ResponseEntity<OrderResponse> updateOrder(
        @PathVariable Long orderId,
        @Valid @RequestBody UpdateOrderRequest request) {
    // 권한은 Aspect에서 처리됨
    return ResponseEntity.ok(orderService.updateOrder(orderId, request.toCommand()));
}

이제 어떤 메서드가 소유자만 부를 수 있는지가 메서드 위의 표시로 바로 보인다.

3. Service 안에서는 빠른 검증을 먼저 한다

Service가 검증을 여러 개 한다면 순서도 정한다. 받은 값끼리만 비교하는 검사는 이미 메모리에 있는 값을 보는 것이라 바로 끝나고, DB를 봐야 하는 검사는 DB에 쿼리를 보내고 답을 기다려야 한다. 빠른 검사에서 걸릴 요청이라면 DB까지 갈 필요가 없다.

// 성능 최적화: 빠른 검증부터
public OrderResponse createOrder(CreateOrderCommand command) {
    
    // 1. 빠른 검증 (받은 값만 비교한다)
    if (command.getShippingStartDate().isAfter(command.getShippingEndDate())) {
        throw new InvalidDateRangeException("배송 시작일이 배송 예정일보다 뒤에 있을 수 없습니다");
    }
    
    // 2. DB 조회가 필요한 검증 (느린 검증)
    for (OrderItemCommand item : command.getItems()) {
        if (!productService.hasStock(item.getProductId(), item.getQuantity())) {
            throw new InsufficientStockException(
                "상품 ID " + item.getProductId() + "의 재고가 부족합니다"
            );
        }
    }
    
    // ... 주문 생성
}

시나리오 1의 createOrder가 이미 이 순서였다. 날짜 비교(1)가 재고 조회(2)보다 앞에 있어서, 날짜가 틀린 요청은 DB에 한 번도 가지 않고 끝난다.


결론 — 검증은 Filter · DTO · Service 세 곳에서만 한다

Spring Boot에서 API 데이터를 안전하고 효율적으로 받는 방법:

아래 그림에서 Controller에는 검증 단계가 없다. 필드 하나하나는 유효하지만 필드끼리 비교하면 말이 안 되는 경우(배송 시작일이 배송 예정일보다 뒤)도, DB를 봐야 아는 경우(재고 부족, 중복 이메일)도 전부 Service가 걸러낸다.

flowchart TD A[Filter ━━━━━ 권한/소유권 검증 403 Forbidden] B[DTO @Valid ━━━━━ 기술적 검증 400 Bad Request] C[Controller ━━━━━ 검증 없이 Command 로 바꿔 위임] D[Service ━━━━━ 비즈니스 규칙 검증 + 순수 비즈니스 로직] A -->|PASS| B A -->|FAIL| Fail1["차단"] B -->|PASS| C B -->|FAIL| Fail2["차단"] C --> D D -->|SUCCESS| Success["성공 응답"] D -->|EXCEPTION| Fail4["비즈니스 오류 409 Conflict 등"] style A fill:#e1f5ff,stroke:#0277bd,stroke-width:3px,color:#000 style B fill:#fff4e1,stroke:#f57f17,stroke-width:3px,color:#000 style C fill:#ffe1f5,stroke:#c2185b,stroke-width:3px,color:#000 style D fill:#e1ffe1,stroke:#388e3c,stroke-width:3px,color:#000 style Success fill:#ccffcc,stroke:#2e7d32,stroke-width:2px,color:#000 style Fail1 fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000 style Fail2 fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000 style Fail4 fill:#ffcccc,stroke:#d32f2f,stroke-width:2px,color:#000

이 구조의 장점:

  • 명확성: 각 계층의 책임이 분명하다
  • 재사용성: DTO를 여러 곳에서 다시 쓸 수 있다
  • 테스트 용이: 각 계층을 따로 떼어 테스트할 수 있다
  • 유지보수: 한 곳을 바꿔도 다른 계층에 주는 영향이 작다
  • 보안: 권한 검증이 한 곳에서 일관되게 적용된다
  • 코드 품질: Service가 순수 로직만 담아 깔끔하다

이 구조를 적용하면 복잡한 API도 체계적으로 관리할 수 있고, 팀원과도 “이 검사는 어느 계층 몫인가”를 같은 기준으로 이야기할 수 있다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기