Spring/Spring TDD

[Spring TDD] 애플리케이션 계층별 입력값 검증

leejunkim 2025. 8. 18. 10:22

Weeklypaper: 애플리케이션의 각 계층에서 수행되는 입력값 검증의 범위와 책임을 어떻게 나눌 것인지에 대해 설명해주세요. 특히 중복 검증을 피하면서도 안정성을 확보하는 방안과, 이와 관련된 트레이드오프에 대해 설명해주세요.


테스트 계층을 나누는 이유

레이어드 아키텍쳐 (layered architecture) 구저의 각 계층마다 역할을 나누지 않으면 비효율적인 주복 검증이 발생하거나, 반대로 검증이 누락되어 시스템에 오류가 생길 수 있다. 

각 계층의 책임과 검증 범위를 명확하게 하고, 중복을 피하며 안전성을 확보해야 한다.

 

비유를 하자면, 레이어드 아키텍처는 공장의 '분업화된 조립 라인'으로 생각해볼 수도 있다.

  • 1번 라인 (Controller): 부품의 모양이 올바른지, 빠진 부품은 없는지만 확인하고 넘긴다.
  • 2번 라인 (Service): 전달받은 부품들을 조립하며, 서로 맞물리는지, 규칙에 맞게 동작하는지 확인한다.
  • 3번 라인 (Repository): 완성된 제품을 창고에 저장한다.

API (컨트롤러) 계층

가장 중요한 것은 들어온 요청이 유효한지 판단하는 것이다. 컨트롤러 계층은 외부로부터 들어오는 모든 HTTP 요청을 가장 먼저 마주치는 계층이므로, 형식적으로 유효하지 않은 요청은 서비스 계층으로 넘어가기 전에 최대한 빨리 걸러내어 서비스 계층의 부담을 덜어주는 것이 목표다.

 

이때는 Bean Validation을 사용한다.

자주 사용되는 Bean Validation 어노테이션을 정리한 테이블:

분류 어노테이션 설명
문자열 @NotNull, @NotEmpty, @NotBlank null, 빈 문자열(""), 공백 문자열(" ")을 검증
문자열 @Size, @Length 문자열의 길이를 검증
문자열 @Email, @Pattern 이메일 형식이나 정규 표현식(regex) 패턴을 검증
숫자 @Min, @Max, @Range 숫자의 최솟값, 최댓값, 범위를 검증
숫자 @Positive, @Negative 양수 또는 음수 여부를 검증 (@PositiveOrZero 등 포함)
숫자 @Digits, @DecimalMin, @DecimalMax 정수 및 소수 자릿수, 십진수의 최솟/최댓값을 검증
날짜 @Past, @Future 등 과거 날짜인지 미래 날짜인지를 검증 (@PastOrPresent 등 포함)
컬렉션 @Size, @Valid 및 요소 레벨 어노테이션 리스트/맵의 크기를 검증하고, 내부 요소들을 재귀적으로 검증

 

검증 범위:

  • 요청 형식(Format) 및 구문(Syntax) 검증
  • 필수값 존재 여부 검증: null이거나 비어 있으면 안 되는 필드가 제대로 채워져 있는가?
  • 기본적인 값의 제약 조건 검증:
    • 문자열 길이 (@Size, @NotBlank)
    • 숫자의 최솟값/최댓값 (@Min, @Max)
    • 이메일, URL, 주민등록번호 등 정해진 패턴 검증 (@Email, @Pattern)
  • 인증/인가 정보 검증: 요청을 보낸 사용자가 누구이며, 해당 요청을 보낼 권한이 있는가? (e.g., JWT 토큰 검증)

코드 예시:

@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserCreateRequest request) {
    // @Valid에 의해 검증 실패 시, ControllerAdvice가 예외를 처리하여 400 Bad Request 응답
    // 이 메서드에 도달했다면, request 객체는 기본적인 제약 조건을 통과한 상태
    UserResponse response = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
public class UserCreateRequest {
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "유효한 이메일 형식이 아닙니다.")
    private String email;

    @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하이어야 합니다.")
    private String password;
}

서비스 계층

핵심은 이 요청이 현재 데이터 상태와 비즈니스 규칙에 맞는 요청인지 판단하는 것이다.

위의 API 계층에서 기본적인 형식을 통과한 데이터를 받아, 실제 비즈니스 로직에 부합하는지 깊게 검증한다.

 

검증 범위

  • 비즈니스 룰 검증
    • 이미 존재하는 이매일인가? (DB 조회 필요)
    • 주문하려는 상품의 재고가 충분한가?
  • 데이터 일관성/관계 검증
    • 주문 요청에 포함된 사용자 ID와 상품 ID가 실제로 DB에 존재하는 유효한 ID인가?
    • 시작일이 종료일보다 이전인가?
  • 상태 기반 검증
    • '배송중'인 주문을 '취소'할 수 있는가?

예시 코드:

public UserResponse createUser(UserCreateRequest request) {
    // 1. 비즈니스 규칙 검증 (DB 조회 필요)
    if (userRepository.existsByEmail(request.getEmail())) {
        throw new EmailAlreadyExistsException("이미 사용 중인 이메일입니다.");
    }
    // ...추가적인 비즈니스 규칙 검증...

    // 2. 검증 통과 후 실제 로직 수행
    User user = new User(request.getEmail(), passwordEncoder.encode(request.getPassword()));
    User savedUser = userRepository.save(user);

    return UserResponse.from(savedUser);
}

 

비즈니스 규칙에 맞지 않으면 EmailAlreadyExistsException와 같은 명시적인 예외를 발생시킨다. 

  • 이때 GlobalExceptionHandler, ErrorResponse, ErrorCode 등 예외 처리 코드를 미리 짜놓으면 편리하다

리포지토리 계층

여기까지 왔다면, 사실 입력값 검증을 거의 수행하지 않는다. 이 계층의 주된 책임은 데이터를 DB에 저장하거나 조회하는 것이지, 데이터의 유효성을 판단하는 것은 아니다. 여기까지 도달한 데이터는 이미 API + 컨트롤러 계층을 통과한 완전히 신뢰할 수 있는 데이터라고 가정해야 한다.

 

최소한의 검증 범위:

  • NOT NULL, UNIQUE, CHECK 등 DB 스키마에 정의된 제약 조건을 통해 데이터의 최종 무결성을 보장
  • 예를 들어, 서비스 계층에서 중복 이메일 체크를 놓치더라도, DB의 UNIQUE 제약 조건이 마지막으로 중복 저장을 막아준다
  • 이는 애플리케이션 로직의 버그로부터 데이터를 보호하는 최후의 방어선 역할을 한다

기억해야 할것: 리포지토리 계층에서 비즈니스 관련 if 문을 넣어 검증 로직을 추가하는 것은 책임 분리 원칙 (Separation of Concerns)을 위반하는 것이다.

 

트레이드 오프 (Tradeoffs)

  • 성능 vs 안정성
    • 모든 계층에서 꼼꼼하게 검증하면 안정성은 높아지지만, 특히 서비스 계층의 DB 조회 같은 검증 로직은 성능 저하를 유발할 수 있다. 하지만 반대로 검증을 너무 생략하면 예상치 못한 버그나 데이터 오염이 발생할 수 있다.
    • "Fail-fast" 원칙에 따라, 잘못된 요청은 가능한 앞단(컨트롤러)에서 빠르게 실패시키는 것이 전체 시스템의 부하를 줄이는 데 유리하다.
  • 코드 복잡도 vs 책임 분리
    • 검증 로직을 각 계층에 올바르게 분배하면 코드의 복잡도가 다소 증가하고 관리 포인트가 늘어날 수 있다.
    • 하지만 이는 각 계층이 자신의 책임에만 집중하게 하여 장기적으로는 코드의 유지보수성과 테스트 용이성을 크게 향상시킨다 => 특정 비즈니스 규칙이 변경되었을 때 서비스 계층의 코드만 수정하면 되므로 변경의 영향 범위가 명확해진다