Files
project-auth-server/docs/examples/spring/validation-location.md
T

8.2 KiB

Validation Location 예시

좋은 예시

예시 1. request DTO 구조 검증은 presentation에서 처리한다

public record CreateSessionRequest(
        @NotBlank String email,
        @NotBlank String password,
        @NotNull LoginType loginType
) {
}

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/sessions")
public class SessionCommandController {

    private final CreateSessionUseCase createSessionUseCase;

    @PostMapping
    public ApiResult<CreateSessionResponse> create(@Valid @RequestBody CreateSessionRequest request) {
        CreateSessionResponse response = createSessionUseCase.create(
                request.email(),
                request.password(),
                request.loginType()
        );
        return ApiResult.success(response);
    }
}

좋은 이유:

  • request shape 검증이 web boundary에 있다
  • controller는 transport DTO를 domain object와 분리한다
  • business rule 판단은 use case로 넘긴다

예시 2. path variable / request param 제약은 메서드 파라미터에 직접 둔다

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

    private final UserQueryUseCase userQueryUseCase;

    @GetMapping("/{userId}")
    public ApiResult<UserResponse> getUser(
            @PathVariable @NotBlank String userId,
            @RequestParam(defaultValue = "1") @Min(1) int page
    ) {
        UserResponse response = userQueryUseCase.getUser(userId, page);
        return ApiResult.success(response);
    }
}

좋은 이유:

  • scalar input 제약이 controller boundary에 명확히 드러난다
  • request DTO가 필요 없는 단순 입력을 과하게 감싸지 않는다

예시 3. application은 조회가 필요한 정책 검증을 담당한다

@Service
@RequiredArgsConstructor
public class CreateSessionUseCase {

    private final UserRepository userRepository;

    public CreateSessionResponse create(CreateSessionCommand command) {
        if (!userRepository.existsActiveUserByEmail(command.email())) {
            throw new UserNotFoundException(command.email());
        }

        if (command.loginType() == LoginType.PASSWORDLESS
                && command.credential() instanceof PasswordCredential) {
            throw new InvalidLoginRequestException();
        }

        // 실제 세션 생성
        return new CreateSessionResponse(...);
    }
}

public record CreateSessionCommand(
        String email,
        LoginCredential credential,
        LoginType loginType
) {
    public CreateSessionCommand {
        Objects.requireNonNull(email, "email must not be null");
        Objects.requireNonNull(credential, "credential must not be null");
        Objects.requireNonNull(loginType, "loginType must not be null");
    }
}

public sealed interface LoginCredential permits PasswordCredential, PasswordlessCredential {
}

public record PasswordCredential(String value) implements LoginCredential {
    public PasswordCredential {
        Objects.requireNonNull(value, "value must not be null");
        if (value.isBlank()) {
            throw new InvalidLoginRequestException();
        }
    }
}

public record PasswordlessCredential() implements LoginCredential {
}

좋은 이유:

  • DB 조회가 필요한 규칙을 controller validation에 두지 않았다
  • use case 전제조건 검증이 application에 있다
  • nullable password를 application 내부로 전파하지 않고 명시적 credential 타입으로 표현한다

예시 4. domain은 자기 불변식을 스스로 보장한다

public final class Email {

    private final String value;

    private Email(String value) {
        if (value == null || value.isBlank()) {
            throw new DomainRuleViolationException("Email must not be blank");
        }
        if (!value.contains("@")) {
            throw new DomainRuleViolationException("Email format is invalid");
        }
        this.value = value;
    }

    public static Email of(String value) {
        return new Email(value);
    }

    public String value() {
        return value;
    }
}

좋은 이유:

  • domain invariant를 controller에 의존하지 않는다
  • 어디서 생성되더라도 유효한 상태만 허용한다

예시 5. web 전용 복잡한 입력 검증은 @InitBinder + custom Validator로 제한적으로 둔다

public class ChangePasswordRequestValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return ChangePasswordRequest.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ChangePasswordRequest request = (ChangePasswordRequest) target;

        if (request.newPassword() != null
                && request.newPasswordConfirm() != null
                && !request.newPassword().equals(request.newPasswordConfirm())) {
            errors.rejectValue("newPasswordConfirm", "password.confirm.mismatch");
        }
    }
}

@RestController
@RequestMapping("/api/password")
public class PasswordController {

    @InitBinder("changePasswordRequest")
    void initBinder(WebDataBinder binder) {
        binder.addValidators(new ChangePasswordRequestValidator());
    }

    @PostMapping("/change")
    public ApiResult<Void> changePassword(
            @Valid @RequestBody ChangePasswordRequest changePasswordRequest
    ) {
        return ApiResult.success(null);
    }
}

좋은 이유:

  • request-object 내부의 web 입력 규칙만 binder validator에 둔다
  • business rule 전체를 validator에 몰아넣지 않는다

나쁜 예시

예시 1. entity를 request binding 대상으로 직접 노출한다

@Entity
public class User {
    @Id
    private Long id;
    private String email;
    private String role;
}

@PostMapping("/users")
public ApiResult<Void> create(@Valid @RequestBody User user) {
    ...
}

나쁜 이유:

  • web input model과 domain/persistence model이 섞였다
  • 바인딩 범위가 불필요하게 넓다
  • request schema 변경이 domain/persistence 모델에 직접 번진다

예시 2. controller validation만 믿고 domain에서 아무 것도 보장하지 않는다

public final class Email {

    private final String value;

    public Email(String value) {
        this.value = value;
    }
}

나쁜 이유:

  • 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
  • domain이 자기 불변식을 보장하지 못한다

예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다

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

    @GetMapping("/{userId}")
    public ApiResult<UserResponse> get(@PathVariable @NotBlank String userId) {
        ...
    }
}

나쁜 이유:

  • Spring MVC 6.1+ built-in method validation 대신 class-level AOP proxy 경로로 흐를 수 있다
  • 이 프로젝트의 controller 규칙과 맞지 않는다

예시 4. filter / interceptor에서 business validation을 수행한다

@Component
@RequiredArgsConstructor
public class BadLoginValidationFilter extends OncePerRequestFilter {

    private final LoginPolicyService loginPolicyService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        loginPolicyService.validateLoginWindow();
        filterChain.doFilter(request, response);
    }
}

나쁜 이유:

  • business validation이 web infrastructure 훅으로 새어 나갔다
  • 요청 바운더리 검증과 use case 규칙이 섞였다

예시 5. service method validation만 믿고 복잡한 정책을 숨긴다

@Service
@Validated
public class BadCreateSessionService {

    public void create(
            @NotBlank String email,
            @NotBlank String password,
            @NotNull LoginType loginType
    ) {
        // 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
    }
}

나쁜 이유:

  • method validation은 보조 수단이지 핵심 정책 엔진이 아니다
  • proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
  • business rule이 시그니처 제약 뒤에 숨어 버린다