자바 백엔드 입문 35편. 회사 고유 규칙(한국 전화번호·중복 이메일·비즈니스 룰)을 직접 어노테이션으로 만드는 커스텀 Validator 패턴을 회사 사규집 비유로 풀어쓴 Phase 5 마무리 글.
이 글은 자바 백엔드 입문 시리즈 59편 중 35편이에요. Phase 5 Validation 마지막 글입니다. 34편에서 @NotBlank·@Email 같은 표준 어노테이션 10종을 다뤘다면, 이번 35편은 그것으로 안 되는 회사 고유 규칙을 직접 어노테이션으로 만드는 패턴.
커스텀 Validator가 언제 필요한가
@Email 은 "이메일 형식" 만 검사해요. 다음 같은 룰은 표준 어노테이션으로 안 돼요.
- 한국 전화번호 형식 —
010-1234-5678형태 (@Pattern으로도 가능하지만 한 곳에 정의해 재사용이 더 깔끔) - 사업자 등록번호 — 한국 사업자 번호 체크섬 검증
- 중복 검사 — "이미 가입된 이메일이 아닌가" (DB 조회 필요)
- 비밀번호 일치 — "비밀번호와 비밀번호 확인이 같은가" (필드 두 개 비교)
- 사용자별 검증 — "이 사용자가 이 주문을 만들 수 있는가" (컨텍스트 필요)
이런 룰을 위해 커스텀 Validator 를 만들어요. 두 부품으로 구성 — "어노테이션" + "검증 로직 클래스".
이 글에서는 회사 사규집 비유로 풀어요. 표준 어노테이션 = "노동법 일반 규정", 커스텀 어노테이션 = "회사 내규 추가 규정". 끝까지 따라오시면 자체 어노테이션 만드는 표준 골격이 한 번에 잡혀요.
첫 커스텀 Validator — @PhoneNumber
목표 — 한국 휴대폰 번호 형식(010-1234-5678) 검증 어노테이션. 두 파일을 만들어요.
1. 어노테이션 정의
package com.example.myshop.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class) // ← 검증 로직 클래스 지정
public @interface PhoneNumber {
String message() default "올바른 휴대폰 번호 형식이 아닙니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
핵심 3가지:
- @Constraint(validatedBy = ...) — 실제 검증 로직 클래스 연결
- message() — 검증 실패 시 기본 메시지
- groups(), payload() — Bean Validation 표준 메서드 (groups는 24편에서 본 시나리오 분기)
message·groups·payload 세 메서드는 Bean Validation 표준이라 반드시 정의해야 해요.
2. 검증 로직 클래스
package com.example.myshop.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
private static final Pattern PATTERN = Pattern.compile("^010-\\d{4}-\\d{4}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null은 통과 — @NotNull과 조합해서 쓸 것
return PATTERN.matcher(value).matches();
}
}
ConstraintValidator<어노테이션, 검증 대상 타입> 두 제네릭. 위에서는 <PhoneNumber, String> — "PhoneNumber 어노테이션이 박힌 String 필드를 검증".
isValid() 반환 룰:
- true = 유효
- false = 검증 실패 (예외 발생)
null 처리 패턴 — 보통 null 은 통과시켜요. "필수 여부" 는 @NotNull 이 책임지고, "형식" 만 이 어노테이션이 책임. 분리 원칙.
3. 사용하기
@Getter @Setter @NoArgsConstructor
public class UserSignupRequest {
@NotBlank
private String name;
@NotBlank
@PhoneNumber // ← 커스텀 어노테이션 박기
private String phoneNumber;
}
@PhoneNumber 한 줄만 박으면 — 기존 @NotBlank·@Email 처럼 자동으로 검증되고, 실패 시 MethodArgumentNotValidException 자동 발생. 표준 어노테이션과 동일하게 동작.
두 번째 예 — @Unique (DB 조회 필요)
표준 어노테이션으로 절대 못 하는 패턴 — "DB에서 중복 검사". 다음 같은 시나리오.
@NotBlank
@Email
@Unique(message = "이미 가입된 이메일입니다")
private String email;
이건 Validator 안에서 DB를 조회해야 해요. Spring Bean을 주입받을 수 있어야 합니다.
어노테이션
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "이미 사용 중인 이메일입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
검증 로직 — Spring Bean 주입
@Component // ← Bean으로 등록
@RequiredArgsConstructor
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository; // 의존성 주입
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true;
return !userRepository.existsByEmail(email);
}
}
핵심 — Validator 클래스에 @Component 박아 Spring Bean으로 등록. 그러면 생성자 주입으로 다른 Bean(Repository·Service 등)을 받아 쓸 수 있어요. 평범한 Validator는 Spring이 자동 생성하지만 @Component 박힌 건 Spring 컨테이너 관리.
세 번째 예 — 필드 두 개 비교 (@PasswordMatch)
"비밀번호와 비밀번호 확인이 같은가" — 한 필드가 아니라 두 필드를 동시에 봐야 해요. 클래스 레벨 어노테이션으로 처리.
어노테이션
@Target(ElementType.TYPE) // ← 클래스에 박음
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "비밀번호와 비밀번호 확인이 일치하지 않습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String passwordField();
String confirmField();
}
@interface 안에 메서드를 추가해 "어느 필드를 비교할지" 매개변수로 받아요.
검증 로직
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
private String passwordField;
private String confirmField;
@Override
public void initialize(PasswordMatch annotation) {
this.passwordField = annotation.passwordField();
this.confirmField = annotation.confirmField();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
BeanWrapper wrapper = new BeanWrapperImpl(obj);
Object password = wrapper.getPropertyValue(passwordField);
Object confirm = wrapper.getPropertyValue(confirmField);
return Objects.equals(password, confirm);
}
}
initialize() 에서 어노테이션 매개변수를 받아 보관. isValid() 에서 BeanWrapper로 동적 필드 접근. Spring BeanWrapperImpl 이 "이 객체의 이 이름 필드 값을 줘" 를 처리.
사용
@PasswordMatch(passwordField = "password", confirmField = "passwordConfirm")
@Getter @Setter @NoArgsConstructor
public class UserSignupRequest {
@NotBlank private String password;
@NotBlank private String passwordConfirm;
}
클래스 위에 어노테이션 한 줄 박으면 — DTO 검증 시 자동으로 두 필드 비교. 글로벌 핸들러에서 "PasswordMatch 검증 실패" 가 한 줄 오류로 응답에 들어가요.
커스텀 Validator 패턴 한 그림
지금까지 다룬 세 유형을 정리.
| 패턴 | 특징 | 예 |
|---|---|---|
| 단순 필드 검증 | 형식·범위 등 표준 어노테이션으로 안 되는 룰 | @PhoneNumber·@BizRegNumber·@KoreanName |
| 외부 자원 의존 검증 | Validator에 Bean 주입, DB·외부 API 조회 | @UniqueEmail·@ValidProduct |
| 필드간 검증 | 클래스 레벨 어노테이션, 두 개 이상 필드 비교 | @PasswordMatch·@DateRange |
회사 시스템에는 보통 5~15개의 커스텀 Validator 가 박혀 있어요. 첫 한두 개 박는 게 어렵지, 패턴 잡히면 빠르게 추가 가능.
커스텀 오류 메시지 동적 처리
@interface 의 message() 는 기본 메시지인데, Validator 안에서 동적 메시지 만들 수도 있어요.
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true;
if (userRepository.existsByEmail(email)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"이메일 " + email + " 은 이미 사용 중입니다"
).addConstraintViolation();
return false;
}
return true;
}
기본 메시지를 끄고 동적 메시지를 박는 패턴. "어느 이메일이" 같은 컨텍스트가 포함되어 클라이언트가 더 명확하게 이해.
커스텀 Validator는 "입력 형식·간단한 사실 검사" 에만 써요. "이 주문이 결제 가능한 상태인가", "재고가 충분한가" 같은 복잡한 비즈니스 규칙은 서비스 레이어에서 처리. 검증 어노테이션에 비즈니스 로직 넣으면 디버깅과 테스트가 어려워져요.
한국 회사 백엔드 자주 만드는 커스텀 어노테이션 5종
실무에서 자주 만나는 커스텀 어노테이션 — 시리즈 1 완주 후 회사 들어가면 만나게 될 거예요.
@KoreanPhoneNumber— 한국 휴대폰 번호 형식@KoreanResidentNumber— 주민등록번호 체크섬 검증@BusinessRegistrationNumber— 사업자 등록번호 체크섬@UniqueEmail·@UniqueUsername— DB 중복 검사@PasswordMatch— 비밀번호 확인 일치
이 5개만 박혀 있어도 회사 검증 코드의 80%가 표준화돼요.
한 줄 정리 — 커스텀 Validator = @interface 어노테이션 + ConstraintValidator 구현 클래스 두 파일. DB 조회 필요하면 Validator를 @Component Bean으로. 필드간 비교는 클래스 레벨 어노테이션 + BeanWrapper.
시험 직전 한 번 더 — 커스텀 Validator 입문자가 매번 헷갈리는 것
- 커스텀 Validator =
@interface+ConstraintValidator두 파일 @Constraint(validatedBy = ...)= 어노테이션에 Validator 클래스 연결message()/groups()/payload()3개 메서드 필수ConstraintValidator<어노테이션, 검증 타입>제네릭isValid()반환 =true통과 /false실패- null은 보통 통과 —
@NotNull과 책임 분리 - DB 조회 필요한 Validator =
@Component박아 Bean으로 - 생성자 주입으로 Repository·Service 받아 쓸 수 있음
- 단일 필드 =
@Target(FIELD), 클래스 레벨 =@Target(TYPE) - 클래스 레벨 어노테이션 = 두 개 이상 필드 비교 패턴
initialize()에서 어노테이션 매개변수 보관- BeanWrapper로 객체 안 필드 동적 접근
- 동적 메시지 =
context.buildConstraintViolationWithTemplate("...") context.disableDefaultConstraintViolation()로 기본 메시지 끄기- 커스텀 Validator도 표준
@Valid한 줄로 자동 실행 - 글로벌 핸들러가
MethodArgumentNotValidException으로 잡음 - 검증 vs 비즈니스 로직 분리 — 복잡한 룰은 서비스 레이어
- 회사 시스템에 보통 5~15개 커스텀 Validator
- 자주 만나는 패턴 = 전화번호·주민번호·사업자번호·중복·필드 일치
@Valid와 동일하게 동작 — 사용자가 차이 못 느낌
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 30편 — ArgumentResolver와 @LoginUser
- 31편 — 파일 업로드 @RequestPart MultipartFile
- 32편 — CORS 설정
- 33편 — @ExceptionHandler @ControllerAdvice
- 34편 — Bean Validation @Valid @NotNull
다음 글: