4.7 도메인 오류(Domain Error) 구현 가이드

4.7 도메인 오류(Domain Error) 구현 가이드

도메인 오류(Domain Error) 구현 가이드

개요

도메인 예외는 비즈니스 규칙 위반이나 도메인 로직 실행 중 발생하는 오류를 표현하는 메커니즘입니다. 이 가이드에서는 도메인 예외를 효과적으로 구현하고 처리하는 방법을 살펴보겠습니다.

도메인 예외의 기본 원칙

1. 예외와 Result 타입의 조화

도메인 로직에서는 두 가지 방식의 오류 처리를 적절히 조합해야 합니다:

  1. Result 타입: 예상된 실패를 표현
  2. 예외: 예상치 못한 오류나 시스템 수준의 문제를 표현
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);
        }
    }
}

결론

효과적인 도메인 예외 처리를 위해서는:

  1. Result 타입과 예외를 적절히 조합하여 사용
  2. 명확한 예외 계층 구조 설계
  3. 구체적이고 의미 있는 오류 코드 정의
  4. 각 계층에 맞는 예외 처리 전략 수립
  5. 철저한 테스트와 문서화

이러한 원칙들을 따르면서 도메인 예외를 구현하면, 오류 상황을 명확하게 표현하고 효과적으로 처리할 수 있는 시스템을 만들 수 있습니다.