5.08 예외 처리와 유효성 검증
사용자 도메인에서 예외 처리와 유효성 검증은 시스템의 안정성과 데이터 무결성을 보장하는 핵심 요소입니다. 도메인 모델의 불변식을 보호하고 사용자에게 명확한 피드백을 제공하기 위해 체계적인 접근이 필요합니다.
도메인 예외 계층 구조
도메인 예외는 명확한 계층 구조를 통해 예외의 성격과 처리 방법을 구분합니다.
package net.badnom.library.shared.core.error.exception;
public abstract class DomainException extends RuntimeException {
private final ErrorCode errorCode;
private final Object[] args;
protected DomainException(ErrorCode errorCode, Object... args) {
super(formatMessage(errorCode, args));
this.errorCode = errorCode;
this.args = args.clone();
}
public ErrorCode getErrorCode() {
return errorCode;
}
public Object[] getArgs() {
return args.clone();
}
private static String formatMessage(ErrorCode code, Object[] args) {
return String.format("[%s] %s", code,
code.formatMessage(args));
}
}
// 유효성 검증 실패 예외
public class ValidationException extends DomainException {
public ValidationException(ErrorCode errorCode, Object... args) {
super(errorCode, args);
}
}
// 비즈니스 규칙 위반 예외
public class BusinessRuleViolationException extends DomainException {
public BusinessRuleViolationException(ErrorCode errorCode, Object... args) {
super(errorCode, args);
}
}
// Result 변환 예외
public class ResultException extends DomainException {
public ResultException(ErrorCode errorCode, Object... args) {
super(errorCode, args);
}
}오류 코드 체계
오류 코드는 발생 가능한 모든 예외 상황을 명확하게 식별하고 적절한 메시지를 제공합니다.
package net.badnom.library.user.domain;
public enum ErrorCodes {
// 사용자 식별자 관련 오류
USER_ID_EMPTY("사용자 ID는 필수입니다"),
USER_ID_LENGTH_INVALID("사용자 ID는 %d-%d자 사이여야 합니다"),
// 사용자 이름 관련 오류
USERNAME_EMPTY("사용자명은 필수입니다"),
USERNAME_LENGTH_INVALID("사용자명은 %d-%d자 사이여야 합니다"),
USERNAME_INVALID_PATTERN("사용자명은 영문, 숫자, 특수문자만 사용할 수 있습니다"),
USERNAME_ALREADY_EXISTS("이미 사용 중인 사용자명입니다: %s"),
// 이메일 관련 오류
EMAIL_BLANK("이메일 주소는 필수입니다"),
EMAIL_INVALID_PATTERN("잘못된 이메일 형식입니다: %s"),
EMAIL_ALREADY_EXISTS("이미 사용 중인 이메일입니다: %s"),
// 비밀번호 관련 오류
USER_PASSWORD_BLANK("비밀번호는 필수입니다"),
USER_PASSWORD_LENGTH_INVALID("비밀번호는 %d-%d자 사이여야 합니다"),
USER_PASSWORD_INVALID_STRENGTH("비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다"),
// 회원 등급 관련 오류
ERROR_NULL_NEW_TIER("새로운 회원 등급이 지정되지 않았습니다"),
SAME_TIER("동일한 등급으로는 변경할 수 없습니다"),
INVALID_TIER_CHANGE("잘못된 등급 변경입니다: %s에서 %s로 변경할 수 없습니다");
private final String messageTemplate;
ErrorCodes(String messageTemplate) {
this.messageTemplate = messageTemplate;
}
public String formatMessage(Object... args) {
return String.format(messageTemplate, args);
}
}계약 기반 유효성 검증
ContractValidator를 사용하여 체계적이고 조합 가능한 유효성 검증을 구현합니다.
package net.badnom.library.shared.core.validation;
public class ContractValidator {
private final List<Result<Void>> validations = new ArrayList<>();
public static ContractValidator start() {
return new ContractValidator();
}
public ContractValidator require(boolean condition,
ErrorCode errorCode,
Object... args) {
validations.add(condition ?
Result.success(null) :
Result.failure(errorCode, args));
return this;
}
public ContractValidator requireNotNull(Object value,
ErrorCode errorCode,
Object... args) {
return require(value != null, errorCode, args);
}
public ContractValidator requireNotBlank(String value,
ErrorCode errorCode,
Object... args) {
return require(value != null && !value.isBlank(), errorCode, args);
}
public ContractValidator requirePattern(String value,
Pattern pattern,
ErrorCode errorCode,
Object... args) {
return require(pattern.matcher(value).matches(), errorCode, args);
}
public Result<Void> validate() {
return validations.stream()
.reduce((acc, next) ->
acc.flatMap(__ -> next))
.orElse(Result.success(null));
}
}값 객체의 유효성 검증
값 객체는 생성 시점에 모든 유효성 검증을 수행하여 불변식을 보장합니다.
public final class Email extends AbstractValueObject {
private static final String EMAIL_REGEX =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
private final String address;
private Email(String address) {
this.address = address;
}
public static Result<Email> of(String address) {
return validate(address)
.map(Email::normalize)
.map(Email::new);
}
private static Result<String> validate(String value) {
return ContractValidator.start()
.requireNotBlank(value, ErrorCodes.EMAIL_BLANK)
.requirePattern(value, EMAIL_PATTERN,
ErrorCodes.EMAIL_INVALID_PATTERN, value)
.validate()
.map(__ -> value);
}
}도메인 예외 처리
애플리케이션 계층에서는 도메인 예외를 적절한 응답으로 변환합니다.
package net.badnom.library.user.presentation;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomainException(
DomainException ex) {
// 도메인 예외의 로깅
log.error("Domain exception occurred", ex);
ErrorResponse response = new ErrorResponse(
ex.getErrorCode(),
ex.getErrorCode().formatMessage(ex.getArgs())
);
return switch (ex) {
case ValidationException ve ->
ResponseEntity.badRequest().body(response);
case BusinessRuleViolationException be ->
ResponseEntity.unprocessableEntity().body(response);
default ->
ResponseEntity.internalServerError().body(response);
};
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
// 예상치 못한 예외의 로깅
log.error("Unexpected error occurred", ex);
ErrorResponse response = new ErrorResponse(
ErrorCodes.INTERNAL_SERVER_ERROR,
"An unexpected error occurred"
);
return ResponseEntity.internalServerError().body(response);
}
}이러한 체계적인 예외 처리와 유효성 검증을 통해 다음과 같은 이점을 얻을 수 있습니다:
- 도메인 모델의 일관성과 무결성 보장
- 명확하고 의미 있는 오류 메시지 제공
- 예외 상황의 효과적인 추적과 디버깅
- 클라이언트에게 적절한 피드백 제공
- 시스템의 안정성과 신뢰성 향상
또한, Result 타입과 ContractValidator를 활용한 접근 방식은 예외적인 상황을 일반적인 프로그램 흐름의 일부로 다룰 수 있게 해주며, 이는 더 견고하고 유지보수하기 쉬운 코드로 이어집니다.