입력값 검증 가이드
기본 원칙
- 클라이언트 검증은 UX 보조용이며, 서버사이드 검증이 반드시 병행되어야 한다.
- 모든 외부 입력값은 서버에서 검증한다.
- 검증 실패 시
GlobalExceptionHandler가 400 응답으로 처리한다.
Bean Validation 어노테이션
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class PostCreateReq {
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 200, message = "제목은 200자 이내여야 합니다.")
private String title;
@NotBlank(message = "내용은 필수입니다.")
@Size(max = 50000, message = "내용이 너무 깁니다.")
private String content;
@NotBlank(message = "카테고리는 필수입니다.")
private String category;
@Email(message = "올바른 이메일 형식이 아닙니다.")
@Size(max = 254)
private String email;
@Min(value = 1, message = "최소 1 이상이어야 합니다.")
@Max(value = 100, message = "최대 100 이하여야 합니다.")
private Integer pageSize;
}
주요 검증 어노테이션
| 어노테이션 | 설명 |
|---|---|
@NotNull | null 불허 |
@NotBlank | null, 빈 문자열, 공백만 있는 값 불허 |
@NotEmpty | null, 빈 컬렉션 불허 |
@Size(min, max) | 문자열/컬렉션 크기 제한 |
@Min(value) | 숫자 최솟값 |
@Max(value) | 숫자 최댓값 |
@Email | 이메일 형식 |
@Pattern(regexp) | 정규식 패턴 |
@Positive | 양수 |
@PositiveOrZero | 0 이상 |
컨트롤러에서 @Valid 적용
@PostMapping
public ResponseEntity<ApiResponse<PostDetailRes>> regPost(
@RequestBody @Valid PostCreateReq req) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(postService.regPost(req)));
}
// 경로 변수는 @Validated + 제약조건
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<PostDetailRes>> getPost(
@PathVariable @Positive(message = "ID는 양수여야 합니다.") Long id) {
return ResponseEntity.ok(ApiResponse.ok(postService.getPost(id)));
}
GlobalExceptionHandler 검증 실패 처리
// MethodArgumentNotValidException — @Valid 실패 시 발생
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResponse.fail(message));
}
실패 응답 예시:
{
"success": false,
"data": null,
"error": "title: 제목은 필수입니다., category: 카테고리는 필수입니다."
}
비즈니스 검증 (Service)
Bean Validation 이외의 비즈니스 규칙은 Service에서 검증한다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional
public PostDetailRes regPost(PostCreateReq req) {
// 비즈니스 검증 — 중복 제목 확인
if (postRepository.existsByTitle(req.getTitle())) {
throw new IllegalArgumentException("이미 존재하는 제목입니다: " + req.getTitle());
}
Post post = Post.builder()
.title(req.getTitle())
.content(req.getContent())
.build();
return PostDetailRes.from(postRepository.save(post));
}
}
커스텀 검증 어노테이션
반복되는 검증 패턴은 커스텀 어노테이션으로 추출한다.
// 허용 카테고리 검증 어노테이션
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidCategoryValidator.class)
public @interface ValidCategory {
String message() default "허용되지 않은 카테고리입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 검증 구현체
public class ValidCategoryValidator implements ConstraintValidator<ValidCategory, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null은 @NotBlank로 처리
return CategoryConstant.ALLOWED_CATEGORIES.contains(value);
}
}
검증 체크리스트
- [ ] 요청 VO에 Bean Validation 어노테이션 적용
- [ ] 컨트롤러 메서드 파라미터에
@Valid적용 - [ ] 비즈니스 검증은 Service에서 처리
- [ ] 검증 실패 메시지는 사용자가 이해할 수 있는 한국어로 작성
- [ ]
GlobalExceptionHandler에서 검증 실패 처리 확인 - [ ] 파일 업로드: 확장자 + MIME 타입 이중 검증