4.7 도메인 오류(Domain Error) 구현 가이드
도메인 오류(Domain Error) 구현 가이드
개요
도메인 예외는 비즈니스 규칙 위반이나 도메인 로직 실행 중 발생하는 오류를 표현하는 메커니즘입니다. 이 가이드에서는 도메인 예외를 효과적으로 구현하고 처리하는 방법을 살펴보겠습니다.
도메인 예외의 기본 원칙
1. 예외와 Result 타입의 조화
도메인 로직에서는 두 가지 방식의 오류 처리를 적절히 조합해야 합니다:
- Result 타입: 예상된 실패를 표현
- 예외: 예상치 못한 오류나 시스템 수준의 문제를 표현
public sealed interface Result<T> {
T getValue();
ErrorCode getErrorCode();
Object[] getErrorArgs();
default T orElseThrow() {
if (this instanceof Success<T> success) {
return success.value();
}
Failure<T> failure = (Failure<T>) this;
throw new ResultException(failure.code(), failure.args());
}
}2. 계층화된 예외 구조
도메인 예외는 명확한 계층 구조를 가져야 합니다:
// 기본 도메인 예외
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,
Arrays.toString(args));
}
}
// Result에서 발생하는 예외
public class ResultException extends DomainException {
public ResultException(ErrorCode errorCode, Object... args) {
super(errorCode, 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);
}
}3. 명확한 오류 코드
도메인 예외는 구체적이고 명확한 오류 코드를 사용해야 합니다:
public enum ErrorCodes {
// 사용자 관련 오류
USERNAME_EMPTY("사용자명은 필수입니다"),
USERNAME_LENGTH_INVALID("사용자명은 %d-%d자 사이여야 합니다"),
USERNAME_INVALID_PATTERN("사용자명은 영문, 숫자, 특수문자만 사용할 수 있습니다"),
DUPLICATE_EMAIL("이미 사용 중인 이메일입니다: %s"),
// 대출 관련 오류
LOAN_LIMIT_EXCEEDED("대출 가능 권수를 초과했습니다"),
BOOK_NOT_AVAILABLE("현재 대출 불가능한 도서입니다"),
OVERDUE_BOOKS_EXIST("연체 도서가 있어 추가 대출이 불가능합니다"),
// 시스템 오류
INTERNAL_ERROR("내부 시스템 오류가 발생했습니다");
private final String messageTemplate;
ErrorCodes(String messageTemplate) {
this.messageTemplate = messageTemplate;
}
public String formatMessage(Object... args) {
return String.format(messageTemplate, args);
}
}예외 처리 패턴
1. Result 타입을 활용한 예상된 실패 처리
비즈니스 로직에서 예상되는 실패는 Result 타입으로 처리합니다:
public class LoanService {
public Result<Loan> createLoan(Member member, Book book) {
// 대출 가능 여부 검사
if (member.hasOverdueBooks()) {
return Result.failure(ErrorCodes.OVERDUE_BOOKS_EXIST);
}
if (member.getCurrentLoans() >= member.getLoanLimit()) {
return Result.failure(ErrorCodes.LOAN_LIMIT_EXCEEDED);
}
if (!book.isAvailable()) {
return Result.failure(ErrorCodes.BOOK_NOT_AVAILABLE);
}
// 대출 처리
try {
Loan loan = new Loan(member, book);
return Result.success(loan);
} catch (Exception e) {
return Result.failure(ErrorCodes.INTERNAL_ERROR);
}
}
}2. 예외를 통한 예상치 못한 오류 처리
시스템 수준의 오류나 예상치 못한 상황은 예외로 처리합니다:
public class UserService {
public User authenticateUser(String username, String password) {
try {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new AuthenticationException(
ErrorCodes.USER_NOT_FOUND, username));
if (!passwordHasher.matches(password, user.getPassword())) {
throw new AuthenticationException(
ErrorCodes.INVALID_PASSWORD);
}
if (!user.isActive()) {
throw new AuthenticationException(
ErrorCodes.USER_ACCOUNT_INACTIVE, username);
}
return user;
} catch (DataAccessException e) {
throw new SystemException(
ErrorCodes.DATABASE_ERROR, e.getMessage());
}
}
}3. 계층 간 예외 변환
각 계층에 맞는 예외로 변환하여 처리합니다:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomainException(
DomainException ex) {
ErrorResponse response = new ErrorResponse(
ex.getErrorCode(),
ex.getErrorCode().formatMessage(ex.getArgs())
);
return switch (ex) {
case ValidationException ve ->
ResponseEntity.badRequest().body(response);
case AuthenticationException ae ->
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(response);
case BusinessRuleViolationException be ->
ResponseEntity.unprocessableEntity().body(response);
default ->
ResponseEntity.internalServerError().body(response);
};
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDataAccessException(
DataAccessException ex) {
ErrorResponse response = new ErrorResponse(
ErrorCodes.DATABASE_ERROR,
"데이터베이스 오류가 발생했습니다."
);
return ResponseEntity.internalServerError().body(response);
}
}테스트 전략
1. 예외 발생 테스트
예외가 적절히 발생하는지 검증:
@Test
@DisplayName("잘못된 이메일 형식으로 생성 시 ValidationException 발생")
void shouldThrowValidationExceptionForInvalidEmail() {
String invalidEmail = "invalid-email";
assertThatThrownBy(() -> Email.from(invalidEmail))
.isInstanceOf(ValidationException.class)
.hasFieldOrPropertyWithValue("errorCode",
ErrorCodes.EMAIL_INVALID_PATTERN);
}2. Result 반환 테스트
Result 타입을 통한 오류 처리 검증:
@Test
@DisplayName("연체 도서가 있는 회원의 대출 시도는 실패해야 함")
void shouldFailToLoanWhenMemberHasOverdueBooks() {
// Given
Member member = TestMemberBuilder.aDefaultMember()
.withOverdueBook()
.build();
Book book = TestBookBuilder.anAvailableBook().build();
// When
Result<Loan> result = loanService.createLoan(member, book);
// Then
assertThat(result.isFailure()).isTrue();
assertThat(result.getErrorCode())
.isEqualTo(ErrorCodes.OVERDUE_BOOKS_EXIST);
}모범 사례
1. 구체적인 예외 타입 사용
예외는 가능한 한 구체적이어야 합니다:
// 좋은 예: 구체적인 예외 타입
public class DuplicateEmailException
extends BusinessRuleViolationException {
public DuplicateEmailException(Email email) {
super(ErrorCodes.DUPLICATE_EMAIL, email);
}
}
// 나쁜 예: 모호한 예외 타입
public class UserException extends DomainException {
public UserException(String message) {
super(ErrorCodes.INTERNAL_ERROR, message);
}
}2. 의미 있는 예외 메시지
예외 메시지는 문제 해결에 도움이 되어야 합니다:
public class LoanLimitExceededException
extends BusinessRuleViolationException {
public LoanLimitExceededException(Member member, int limit) {
super(ErrorCodes.LOAN_LIMIT_EXCEEDED,
String.format(
"회원 %s의 현재 대출 권수: %d, 최대 대출 가능 권수: %d",
member.getId(),
member.getCurrentLoans(),
limit
)
);
}
}3. 예외 문서화
예외 발생 가능성을 문서화합니다:
/**
* 새로운 대출을 생성합니다.
*
* @param member 대출을 신청한 회원
* @param book 대출할 도서
* @return 생성된 대출 정보
* @throws LoanLimitExceededException 회원의 대출 한도 초과 시
* @throws OverdueBooksException 연체 도서가 있는 경우
* @throws BookNotAvailableException 도서가 대출 불가능한 상태인 경우
*/
public Result<Loan> createLoan(Member member, Book book) {
// 구현...
}4. 예외 처리 계층화
예외 처리를 계층별로 구분합니다:
// 도메인 계층: 비즈니스 규칙 검증
public class Member {
public Result<Loan> borrowBook(Book book) {
if (hasOverdueBooks()) {
return Result.failure(ErrorCodes.OVERDUE_BOOKS_EXIST);
}
// ...
}
}
// 응용 계층: 트랜잭션 및 기술적 예외 처리
@Service
public class LoanService {
@Transactional
public Result<Loan> createLoan(UserId memberId, BookId bookId) {
try {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new EntityNotFoundException(
ErrorCodes.MEMBER_NOT_FOUND, memberId));
// ...
} catch (DataAccessException e) {
log.error("대출 생성 중 데이터베이스 오류", e);
return Result.failure(ErrorCodes.DATABASE_ERROR);
}
}
}
// 인프라 계층: 기술적 예외의 변환
@Repository
public class JpaMemberRepository implements MemberRepository {
@Override
public Result<Member> findById(UserId id) {
try {
MemberEntity entity = em.find(MemberEntity.class, id);
return entity != null
? Result.success(mapper.toDomain(entity))
: Result.failure(ErrorCodes.MEMBER_NOT_FOUND, id);
} catch (PersistenceException e) {
log.error("회원 조회 중 오류", e);
return Result.failure(ErrorCodes.DATABASE_ERROR);
}
}
}결론
효과적인 도메인 예외 처리를 위해서는:
- Result 타입과 예외를 적절히 조합하여 사용
- 명확한 예외 계층 구조 설계
- 구체적이고 의미 있는 오류 코드 정의
- 각 계층에 맞는 예외 처리 전략 수립
- 철저한 테스트와 문서화
이러한 원칙들을 따르면서 도메인 예외를 구현하면, 오류 상황을 명확하게 표현하고 효과적으로 처리할 수 있는 시스템을 만들 수 있습니다.