입력값 검증을 계층별로 분리해야 하는 이유

Pure·2025년 6월 22일

Spring

목록 보기
7/9
post-thumbnail

1. 왜 입력값 검증이 중요한가?

웹 애플리케이션에서 입력값 검증(validation)은 단순히 예외를 막는 수준이 아니라, 시스템 전체의 안정성과 무결성을 지키는 핵심 기초이다.

사용자의 실수, 악의적인 요청, 외부 API의 예기치 못한 반환값까지 고려해, 각 계층은 자신이 감당할 수 있는 수준에서 데이터를 검증해야 한다.

2. Controller: DTO를 통한 유효성 검증

2.1 🎯 책임

  • 클라이언트가 전송한 데이터의 형식, 범위, null 여부 등을 검증

  • @Valid, @RequestBody, @RequestParam 등 사용

  • DTO 단에서 주로 처리

2.2 🔍 예시

public record UserCreateRequest(
    @NotBlank(message = "사용자명은 필수입니다.")
    @Size(min = 2, max = 10, message = "사용자명은 2자 이상 10자 이하여야 합니다.")
    String username,

    @Email(message = "유효한 이메일 형식이어야 합니다.")
    String email
) {}
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody UserCreateRequest request) {
    return ResponseEntity.ok(userService.create(request));
}

2.3 ✅ 장점

  • 빠른 피드백 제공

  • 클라이언트 측 실수 빠르게 차단 가능

  • 메시지를 통해 사용자 친화적 에러 제공

2.4 ❗️ 단점

  • 필드 단위 검증에만 적합

  • 다중 필드 간의 관계 검증, DB 조회 기반 검증, 도메인 상태 기반 검증은 어려움

3 Service 계층: 도메인 로직 기반 검증

3.1 🎯 책임

  • DTO의 단일 필드로 검증할 수 없는 다중 필드 간 관계

  • DB 기반 중복 체크

  • 현재 도메인 상태 기반으로 유효 여부 판단

3.2 🔍 예시

사용자 등록 시 사용자명 중복 확인

public UserDto create(UserCreateRequest request) {
    if (userRepository.existsByUsername(request.username())) {
        throw new UsernameAlreadyExistsException(request.username());
    }

    if (userRepository.existsByEmail(request.email())) {
        throw new EmailAlreadyExistsException(request.email());
    }

    User user = new User(request.username(), request.email());
    return userMapper.toDto(userRepository.save(user));
}

사용자 상태가 "비활성"이면 수정 불가

public void updateUser(UUID userId, UserUpdateRequest request) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new UserNotFoundException(userId));

    if (user.getStatus() == UserStatus.INACTIVE) {
        throw new InvalidUserStateException("비활성화된 사용자는 수정할 수 없습니다.");
    }

    user.update(request);
}

3.3 ✅ 장점

  • 비즈니스 로직과 함께 유기적으로 검증

  • 엔티티 상태, DB 정보 등 복합적인 조건 활용 가능

3.4 ❗️ 단점

  • 로직이 길어지면 가독성 저하

  • 서비스 코드 내에서 유효성 검증 책임이 명확하지 않으면 유지보수가 어려움

⇒ 별도의 Validator 클래스로 위임 가능

3.5 서비스 검증 책임 분리를 위한 팁

비즈니스 검증 로직이 많아지면 다음처럼 Validator 클래스로 추출

@Component
@RequiredArgsConstructor
public class UserValidator {

    private final UserRepository userRepository;

    public void validateDuplicateUsername(String username) {
        if (userRepository.existsByUsername(username)) {
            throw new UsernameAlreadyExistsException(username);
        }
    }

    public void validateUpdatable(User user) {
        if (user.getStatus() == UserStatus.INACTIVE) {
            throw new InvalidUserStateException("수정 불가 상태입니다.");
        }
    }
}

→ 서비스는 검증을 위임받고, 핵심 도메인 로직에 집중할 수 있게 된다.

4. Repository 계층 - DB 제약조건과 트랜잭션 보호

4.1 🎯 책임

  • 기본적으로 검증 책임 없음

  • 단, UNIQUE 제약조건, NOT NULL, FOREIGN KEY 등으로 마지막 방어선 역할

4.2 🔍 예시

CREATE TABLE users (
    id UUID PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL
);

4.3 💡 요약

  • 트랜잭션 충돌 또는 동시성 이슈 대비

  • DB 제약은 Service 검증의 보완 수단이지 대체 수단이 아님

5. 중복 검증

5.1 중복 검증이란?

“중복 검증”이란 같은 검증 로직이 여러 계층, 또는 여러 위치에 반복해서 존재하는 것을 말한다.

예를 들어, 사용자 이메일 중복 여부를

  • Controller에서도,
  • Service에서도,
  • 심지어 Entity나 Validator에서도 동일한 조건으로 중복 검사하고 있다면

→ 이것이 바로 중복 검증이다.

5.2 왜 중복 검증이 문제가 되는가?

1. 유지보수의 어려움

하나의 검증 로직이 여러 군데 흩어져 있으면, 정책이 변경될 때 모두 수정해야 함

2. 불일치 위험

로직이 중복된 만큼 한쪽은 수정됐지만, 다른 쪽은 안 되는 일이 발생할 수 있음

3. 코드랑 증가 및 가독성 저하

같은 검증 코드가 곳곳에 있으면 로직이 중첩돼 읽기 어려움

4. 버그 유발 가능성

서로 다른 계층에서 서로 다르게 판단하여 모순된 행동을 유발 (Controller에선 통과, Service에선 실패 등

5.3 🔍 예시

❌ 잘못된 방식 (중복 검증)

// Controller
if (userRepository.existsByEmail(request.email())) {
    throw new EmailAlreadyExistsException(request.email());
}

// Service
if (userRepository.existsByEmail(request.email())) {
    throw new EmailAlreadyExistsException(request.email());
}

✅ 바람직한 방식 (책임 분리)

// Controller: @Valid로 형식만 검증
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody UserCreateRequest request) {
    userService.create(request);
}

// Service: 비즈니스 로직 기반 중복 여부 확인
public void create(UserCreateRequest request) {
    if (userRepository.existsByEmail(request.email())) {
        throw new EmailAlreadyExistsException(request.email());
    }
}

5.3 요약

🔹 단순 형식 검증: DTO + @Valid (Controller 레이어)

🔹 복잡한 비즈니스 규칙: Service 레이어에서만 처리

🔹 Validator 클래스로 공통화: 중복 방지 + 책임 분리

🔹 DB 제약 조건은 마지막 보루로 사용 (예: UNIQUE)

(추가) Entity 자체에 유효성 검증을 넣는 방식

Entity에도 어노테이션을 붙여 직접 검증할 수 있다.

@Entity
public class User {

    @Id
    private UUID id;

    @NotBlank
    @Size(min = 2, max = 10)
    private String username;

    @Email
    @Column(unique = true)
    private String email;

    // ...
}

✅ 장점

  • 도메인 객체 스스로 유효한 상태만 유지하려는 의도 표현

  • 도메인 주도 설계(DDD)에 가까운 방식

❗️단점

  • Presentation과 Domain의 경계를 흐림

  • 유효성 메시지 관리 불편

  • API 요청이 아닌 내부 로직에서 생성된 객체에도 검증 제약이 걸릴 수 있음

🟡 결론
일반적으로는 DTO에만 검증 어노테이션을 부여하고, Entity는 비즈니스 상태 중심의 불변성만 유지하는 게 명확한 책임 분리에 유리하다.

항목DTO 검증 (@Valid)Entity 검증
주 목적API 요청의 유효성 검증도메인 객체의 무결성 표현
사용 위치Controller, 일부 ServiceEntity 클래스 내부
검증 타이밍요청 수신 시점Entity 생성 및 영속화 시점
장점에러 메시지 명확, 클라이언트 피드백 쉬움도메인 객체의 자기 방어적 설계
단점비즈니스 로직 적용 불가, 상태 판단 불가메시지 관리 불편, 요청 검증 부적합
일반적 권장✅ 요청 DTO에만 어노테이션 사용❌ Entity에는 최소한만 (또는 사용 X)
profile
Clean Code를 위한 한 걸음

1개의 댓글

comment-user-thumbnail
2025년 6월 23일

정리가 잘 되어 있어 잘 읽고 갑니다!👍

답글 달기