312 lines
8.2 KiB
Markdown
312 lines
8.2 KiB
Markdown
# Validation Location 예시
|
|
|
|
## 좋은 예시
|
|
|
|
### 예시 1. request DTO 구조 검증은 presentation에서 처리한다
|
|
|
|
```java
|
|
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 제약은 메서드 파라미터에 직접 둔다
|
|
|
|
```java
|
|
@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은 조회가 필요한 정책 검증을 담당한다
|
|
|
|
```java
|
|
@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은 자기 불변식을 스스로 보장한다
|
|
|
|
```java
|
|
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로 제한적으로 둔다
|
|
|
|
```java
|
|
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 대상으로 직접 노출한다
|
|
|
|
```java
|
|
@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에서 아무 것도 보장하지 않는다
|
|
|
|
```java
|
|
public final class Email {
|
|
|
|
private final String value;
|
|
|
|
public Email(String value) {
|
|
this.value = value;
|
|
}
|
|
}
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- 다른 진입 경로에서 잘못된 값이 들어오면 막지 못한다
|
|
- domain이 자기 불변식을 보장하지 못한다
|
|
|
|
### 예시 3. controller 클래스에 @Validated를 붙여 구식 proxy 방식에 기대한다
|
|
|
|
```java
|
|
@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을 수행한다
|
|
|
|
```java
|
|
@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만 믿고 복잡한 정책을 숨긴다
|
|
|
|
```java
|
|
@Service
|
|
@Validated
|
|
public class BadCreateSessionService {
|
|
|
|
public void create(
|
|
@NotBlank String email,
|
|
@NotBlank String password,
|
|
@NotNull LoginType loginType
|
|
) {
|
|
// 복잡한 도메인 정책을 전부 메서드 시그니처 제약에 기대함
|
|
}
|
|
}
|
|
```
|
|
|
|
**나쁜 이유:**
|
|
|
|
- method validation은 보조 수단이지 핵심 정책 엔진이 아니다
|
|
- proxy 기반 동작 특성 때문에 경계가 흐려질 수 있다
|
|
- business rule이 시그니처 제약 뒤에 숨어 버린다
|