Spring Boot로 REST API를 개발하다 보면 반복되는 고민이 생깁니다.
“컨트롤러에서 들어오는 데이터를 어떻게 처리할 것인가?”
흔한 실수들을 보면:
- DTO에서 검증하지 않고 Service에서
if (data == null)처리 - 권한 검증을 Service에서 담당 → Service가 보안 로직으로 오염
- 컨트롤러에서 모든 검증을 함 → 계층 책임이 불명확함
이 글은 실무에서 정립한 “계층별 책임 분리” 기반의 API 데이터 수신 전략을 공유합니다. 안전하면서도 효율적인 구조를 만드는 방법입니다.
문제 상황: 왜 이게 필요한가?
일반적인 개발 흐름의 문제점
많은 개발자들이 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);
}
// Service
public void createUser(UserRequest request) {
// Service에서도 검증?
if (request.getName() == null) {
throw new IllegalArgumentException("이름은 필수입니다");
}
// 실제 비즈니스 로직
}
문제점:
- 검증 로직이 여러 곳에 산재됨
- Service가 데이터 검증으로 오염됨
- 각 계층의 책임이 불명확함
- 같은 검증을 반복 작성
더 심각한 문제: 보안 관련
@PutMapping("/users/{userId}/password")
public ResponseEntity<Void> changePassword(
@PathVariable Long userId,
@RequestBody PasswordChangeRequest request) {
// 누가 권한을 확인하나?
userService.changePassword(userId, request);
}
이 API는 누구나 호출할 수 있습니다. 다른 사람의 userId를 대입하면?
- Service에서 권한 검증? → Service가 보안 로직으로 오염
- Filter에서 검증? → 구조는 맞지만 어떻게 구현할 것인가?
해결책: 계층별 책임 분리 전략
이 문제들의 해결책은 각 계층이 자신의 책임만 충실히 하도록 분리하는 것입니다.
전체 Flow 구조
각 계층의 역할 정의
| 계층 | 책임 | 실패 시 응답 |
|---|---|---|
| Filter | 권한/소유권 검증 | 403 Forbidden |
| DTO @Valid | 기술적 검증 (null, 타입, 포맷) | 400 Bad Request |
| Controller | 데이터 상태 판별 | 400/422 Unprocessable Entity |
| Service | 순수 비즈니스 로직 | 비즈니스 예외 |
계층별 상세 구현
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;
}
}
특징:
- ✅ 컨트롤러 진입 전에 차단
- ✅ Service는 권한 검증 없이 순수 로직만
- ✅ 401/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
}
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;
}
특징:
- ✅ 기술적 검증만 담당 (null, 타입, 포맷)
- ✅ 명확한 오류 메시지
- ✅ 재사용 가능
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;
}
응답 예시:
{
"status": 400,
"message": "요청 데이터 검증 실패",
"errors": {
"email": "유효한 이메일 형식이 아닙니다",
"age": "18세 이상만 가입 가능합니다"
},
"timestamp": "2026-01-04T10:30:00"
}
4단계: Controller에서 여러 필드 간 관계 검증
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserCreateRequest request) {
// 이미 DTO 검증을 통과한 데이터
// 여기서는 비즈니스 포맷만 검증
UserResponse response = userService.createUser(request);
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);
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);
}
}
// 비즈니스 검증 예외
public class InvalidPasswordConfirmException extends RuntimeException {
public InvalidPasswordConfirmException(String message) {
super(message);
}
}
5단계: Service는 순수 비즈니스 로직만
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserResponse createUser(UserCreateRequest request) {
// 컨트롤러에서 이미 검증됨:
// - name, email, age가 null이 아님 (DTO)
// - age >= 18 (DTO)
// - 권한이 있음 (Filter)
// 순수 비즈니스 로직만 구현
// 중복 이메일 체크는 비즈니스 규칙
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("이미 가입된 이메일입니다");
}
// Factory Method를 사용한 엔티티 생성
User user = User.of(request, passwordEncoder);
User savedUser = userRepository.save(user);
return UserResponse.from(savedUser);
}
}
// User 엔티티에 추가
public class User {
// ... 기타 필드 및 메서드
// Factory Method: DTO에서 엔티티로 변환
public static User of(UserCreateRequest request, PasswordEncoder passwordEncoder) {
return User.builder()
.name(request.getName())
.email(request.getEmail())
.age(request.getAge())
.password(passwordEncoder.encode(request.getPassword()))
.build();
}
public void changePassword(Long userId, PasswordChangeRequest request) {
// 컨트롤러에서 이미 검증됨:
// - 비밀번호와 확인이 모두 필수입니다 (DTO @NotBlank)
// - 길이 범위가 맞습니다 (DTO @Length)
// - 권한이 있습니다 (Filter)
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
// 비즈니스 검증 1: 현재 비밀번호 확인
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new InvalidCurrentPasswordException("현재 비밀번호가 일치하지 않습니다");
}
// 비즈니스 검증 2: 새 비밀번호와 확인 비밀번호 일치
if (!request.getNewPassword().equals(request.getNewPasswordConfirm())) {
throw new InvalidPasswordConfirmException("새 비밀번호가 일치하지 않습니다");
}
// 비즈니스 검증 3: 기존 비밀번호와 새 비밀번호가 다른지 확인
if (passwordEncoder.matches(request.getNewPassword(), user.getPassword())) {
throw new SamePasswordException("새 비밀번호는 기존 비밀번호와 달라야 합니다");
}
// 비밀번호 변경
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
}
}
특징:
- ✅ 방어 코드 없음 (null 체크 불필요)
- ✅ 순수 비즈니스 로직만 구현
- ✅ 코드가 깔끔하고 읽기 쉬움
실무 시나리오별 적용 예제
시나리오 1: 주문 생성 (중첩 객체 + 여러 필드 관계)
// DTO 정의
public class CreateOrderRequest {
@NotNull(message = "주문일자는 필수입니다")
@PastOrPresent(message = "미래 날짜는 허용되지 않습니다")
private LocalDate orderDate;
@NotNull(message = "배송 시작일은 필수입니다")
private LocalDate shippingStartDate;
@NotNull(message = "배송 예정일은 필수입니다")
private LocalDate shippingEndDate;
@NotEmpty(message = "상품 목록은 필수입니다")
private List<@Valid OrderItemRequest> items;
// Getter, Setter
}
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) {
// 비즈니스 포맷 검증
if (request.getShippingStartDate().isAfter(request.getShippingEndDate())) {
throw new InvalidDateRangeException("배송 시작일이 배송 예정일보다 뒤에 있을 수 없습니다");
}
if (request.getShippingStartDate().isBefore(request.getOrderDate())) {
throw new InvalidDateException("배송 시작일이 주문일보다 빠를 수 없습니다");
}
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// Service: 순수 비즈니스 로직
public OrderResponse createOrder(CreateOrderRequest request) {
// 모든 검증이 완료된 상태
// 재고 확인 (비즈니스 규칙)
for (OrderItemRequest item : request.getItems()) {
if (!productService.hasStock(item.getProductId(), item.getQuantity())) {
throw new InsufficientStockException(
"상품 ID " + item.getProductId() + "의 재고가 부족합니다"
);
}
}
// Factory Method를 사용한 엔티티 생성
Order order = Order.of(request);
Order savedOrder = orderRepository.save(order);
return OrderResponse.from(savedOrder);
}
// Order 엔티티에 추가
public class Order {
// ... 기타 필드 및 메서드
// Factory Method: DTO에서 엔티티로 변환
public static Order of(CreateOrderRequest request) {
return Order.builder()
.orderDate(request.getOrderDate())
.shippingStartDate(request.getShippingStartDate())
.shippingEndDate(request.getShippingEndDate())
.items(request.getItems().stream()
.map(OrderItem::of)
.collect(Collectors.toList()))
.build();
}
}
// OrderItem 엔티티에 추가
public class OrderItem {
// ... 기타 필드 및 메서드
// Factory Method: DTO에서 엔티티로 변환
public static OrderItem of(OrderItemRequest request) {
return OrderItem.builder()
.productId(request.getProductId())
.quantity(request.getQuantity())
.build();
}
}
시나리오 2: 권한 검증이 필요한 리소스 수정
// Filter: 권한 검증
@Component
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+)");
@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)) {
if (isUserResource(uri)) {
validateUserOwnership(request, response, uri);
} else if (isOrderResource(uri)) {
validateOrderOwnership(request, response, uri);
}
}
filterChain.doFilter(request, response);
}
private void validateUserOwnership(HttpServletRequest request,
HttpServletResponse response,
String uri) throws IOException {
Long resourceUserId = extractId(uri, UPDATE_USER_PATTERN);
Long currentUserId = getCurrentUserId();
if (!isAuthorized(currentUserId, resourceUserId)) {
sendForbiddenResponse(response);
return;
}
}
private void validateOrderOwnership(HttpServletRequest request,
HttpServletResponse response,
String uri) throws IOException {
Long orderId = extractId(uri, UPDATE_ORDER_PATTERN);
Long currentUserId = getCurrentUserId();
// 주문의 소유자 확인 (DB 조회)
if (!orderService.isOrderOwner(orderId, currentUserId)) {
sendForbiddenResponse(response);
return;
}
}
private void sendForbiddenResponse(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("""
{
"status": 403,
"message": "이 리소스에 대한 권한이 없습니다"
}
""");
}
}
// Controller: 권한은 이미 확인됨
@PutMapping("/orders/{orderId}")
public ResponseEntity<OrderResponse> updateOrder(
@PathVariable Long orderId,
@Valid @RequestBody UpdateOrderRequest request) {
// Filter에서 권한 확인 완료
// DTO에서 기술적 검증 완료
// 여기서는 데이터 상태만 검증
if (request.getShippingDate().isBefore(request.getOrderDate())) {
throw new InvalidDateException("배송일은 주문일 이후여야 합니다");
}
OrderResponse response = orderService.updateOrder(orderId, request);
return ResponseEntity.ok(response);
}
체크리스트: 올바른 계층 분리
API 개발 시 다음을 확인하세요:
Filter/Interceptor 체크리스트
- ✅ 권한/소유권 검증을 수행하는가?
- ✅ 검증 실패 시 403 Forbidden을 반환하는가?
- ✅ 비즈니스 로직은 포함되지 않았는가?
- ✅ 필요한 경우만 DB 조회를 수행하는가?
DTO 체크리스트
- ✅ @NotNull, @NotBlank, @Email 등의 기술적 검증만 포함되어 있는가?
- ✅ 명확한 오류 메시지를 제공하는가?
- ✅ 다른 필드와의 관계 검증은 포함하지 않았는가? (예: start < end)
- ✅ 비즈니스 규칙은 포함하지 않았는가? (예: 중복 확인)
Controller 체크리스트
- ✅ 여러 필드 간 관계 검증을 수행하는가? (데이터 상태 판별)
- ✅ 명확한 오류 메시지와 함께 예외를 발생시키는가?
- ✅ Service 호출 전에 모든 기술적 검증이 완료되었는가?
- ✅ 비즈니스 로직은 Service에 위임했는가?
Service 체크리스트
- ✅ 들어오는 데이터가 유효하다고 가정하는가?
- ✅ 방어 코드(null 체크 등)를 최소화했는가?
- ✅ 순수 비즈니스 로직만 구현했는가?
- ✅ DB 조회가 필요한 검증을 수행하는가? (예: 중복 이메일)
실무 팁
1. GET 요청은 다르게 처리
// 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의 관계 검증
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new InvalidDateRangeException("시작일이 종료일보다 뒤에 있을 수 없습니다");
}
return ResponseEntity.ok(orderService.searchOrders(status, startDate, endDate, page, size));
}
2. 복잡한 권한 검증은 Annotation으로
// 커스텀 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) {
// 소유권 검증 로직
}
}
// 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));
}
3. 검증 순서 최적화
// 성능 최적화: 빠른 검증부터
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserCreateRequest request) {
// 1. 빠른 검증 (메모리 기반)
if (request.getName().length() > 100) {
throw new ValidationException("이름이 너무 깁니다");
}
// 2. DB 조회가 필요한 검증 (느린 검증)
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("이미 가입된 이메일입니다");
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.createUser(request));
}
결론
Spring Boot에서 API 데이터를 안전하고 효율적으로 받는 방법:
이 구조의 장점:
✅ 명확성: 각 계층의 책임이 분명함 ✅ 재사용성: DTO를 다양한 곳에서 재사용 가능 ✅ 테스트 용이: 각 계층을 독립적으로 테스트 가능 ✅ 유지보수: 한 곳의 변경이 전체에 영향 최소화 ✅ 보안: 권한 검증이 일관성 있게 적용됨 ✅ 코드 품질: Service가 순수 로직으로 깔끔함
이 구조를 적용하면 복잡한 API도 체계적으로 관리할 수 있으며, 팀원들과도 명확하게 의사소통할 수 있습니다.
댓글로 의견을 나눠주세요:
- 현재 프로젝트에서 사용 중인 데이터 검증 구조는?
- 권한 검증을 어느 계층에서 처리하고 계신가요?
- 이 구조를 적용하면서 겪은 어려움이나 개선 사항이 있나요?
다른 개발자들의 경험이 모여 더 좋은 실무 가이드라인을 만들 수 있습니다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기