3.4 도메인 모델에서의 Result 타입 활용

3.4 도메인 모델에서의 Result 타입 활용

이전 장에서 우리는 Result 타입의 기본 구조와 핵심 연산자들을 살펴보았습니다. 이번 장에서는 이러한 개념들을 실제 도메인 모델에 적용하는 방법을 알아보겠습니다. 특히 도메인 주도 설계(Domain-Driven Design)의 맥락에서 Result 타입이 어떻게 도메인 규칙을 명확하게 표현하고 강제할 수 있는지 도서관 시스템을 예시로 살펴보겠습니다.

3.4.1 도메인 규칙의 명시적 표현

도메인 모델의 핵심은 비즈니스 규칙을 명확하게 표현하는 것입니다. Result 타입을 활용하면 이러한 규칙들을 타입 시스템을 통해 강제할 수 있습니다. 도서관의 도서 대출(Checkout) 시스템을 예로 살펴보겠습니다.

public class BookCopy {
    private final BookCopyId id;
    private final ISBN isbn;
    private BookStatus status;
    private Location location;

    public Result<BookCopy> markAsCheckedOut() {
        if (status != BookStatus.AVAILABLE) {
            return Result.failure(BookErrorCodes.BOOK_NOT_AVAILABLE,
                "Book copy %s is currently %s", id, status)
                .withContext("bookId", id)
                .withContext("currentStatus", status);
        }
        
        status = BookStatus.CHECKED_OUT;
        return Result.success(this);
    }
}

이 구현에서 주목할 점은 다음과 같습니다:

  1. 명시적인 규칙 표현: 도서의 대출 가능 상태가 별도의 검증 단계로 명확하게 표현됩니다.
  2. 풍부한 오류 정보: 대출 불가능한 경우 그 원인과 현재 도서의 상태가 상세하게 전달됩니다.
  3. 타입 안전성: 컴파일러가 오류 처리를 강제합니다.

3.4.2 값 객체의 생성과 검증

값 객체는 도메인 모델의 중요한 구성 요소입니다. Result 타입을 활용하면 값 객체의 생성과 검증을 안전하게 처리할 수 있습니다. 도서관 시스템에서 ISBN을 예로 들어보겠습니다.

public record ISBN(String value) {
    public static Result<ISBN> create(String isbn) {
        if (isbn == null || isbn.isBlank()) {
            return Result.failure(ValidationErrorCodes.REQUIRED_FIELD,
                "ISBN cannot be empty");
        }

        String normalized = isbn.replaceAll("-", "");
        if (!normalized.matches("\\d{13}")) {
            return Result.failure(ValidationErrorCodes.INVALID_FORMAT,
                "ISBN must be 13 digits: %s", isbn);
        }

        // ISBN-13 체크섬 검증
        if (!validateChecksum(normalized)) {
            return Result.failure(ValidationErrorCodes.INVALID_CHECKSUM,
                "Invalid ISBN checksum: %s", isbn);
        }

        return Result.success(new ISBN(normalized));
    }

    private static boolean validateChecksum(String isbn) {
        int sum = 0;
        for (int i = 0; i < 12; i++) {
            int digit = Character.getNumericValue(isbn.charAt(i));
            sum += (i % 2 == 0) ? digit : digit * 3;
        }
        int checksum = (10 - (sum % 10)) % 10;
        return checksum == Character.getNumericValue(isbn.charAt(12));
    }
}

이러한 접근 방식의 장점은 다음과 같습니다:

  1. 불변성 보장: 한번 생성된 ISBN은 항상 유효한 상태를 유지합니다.
  2. 자가 검증: 생성 시점에 모든 비즈니스 규칙이 검증됩니다.
  3. 도메인 용어 사용: 오류 메시지가 도서관 업무 담당자가 이해할 수 있는 언어로 표현됩니다.

3.4.3 집계 루트의 일관성 보장

집계 루트는 관련 객체들의 일관성을 책임집니다. 도서관 시스템에서 Member(회원) 클래스는 대출 관련 규칙을 강제하는 집계 루트의 좋은 예시입니다.

public class Member {
    private final MemberId id;
    private final String name;
    private MemberStatus status;
    private List<Checkout> checkouts;
    
    public Result<Boolean> canCheckout() {
        // 회원 상태 검증
        if (status != MemberStatus.ACTIVE) {
            return Result.failure(MemberErrorCodes.MEMBER_NOT_ACTIVE,
                "Member %s is currently %s", id, status);
        }

        // 연체 도서 확인
        boolean hasOverdue = checkouts.stream()
            .anyMatch(Checkout::isOverdue);
        if (hasOverdue) {
            return Result.failure(MemberErrorCodes.HAS_OVERDUE_BOOKS,
                "Member has overdue books");
        }

        // 대출 한도 확인
        if (checkouts.size() >= 5) {
            return Result.failure(MemberErrorCodes.CHECKOUT_LIMIT_EXCEEDED,
                "Member has reached maximum checkout limit");
        }

        return Result.success(true);
    }
}

이 구현의 핵심 특징은 다음과 같습니다:

  1. 계층적 검증: 회원 상태, 연체 여부, 대출 한도 등 여러 수준의 규칙을 체계적으로 검증합니다.
  2. 문맥 유지: 각 검증 단계에서 실패의 원인을 명확하게 설명합니다.
  3. 불변식 보장: 모든 대출 작업이 비즈니스 규칙을 준수하도록 강제합니다.

3.4.4 도메인 서비스에서의 활용

도메인 서비스는 여러 집계에 걸친 복잡한 비즈니스 로직을 처리합니다. 도서 대출 서비스는 이러한 복잡한 프로세스의 좋은 예시입니다.

public class CheckoutService {
    private final BookCopyRepository bookCopyRepository;
    private final MemberRepository memberRepository;
    private final CheckoutRepository checkoutRepository;

    public Result<Checkout> checkout(BookCopyId bookId, MemberId memberId, LocalDate dueDate) {
        return validateCheckout(bookId, memberId, dueDate)
            .flatMap(valid -> loadEntities(bookId, memberId))
            .flatMap(entities -> processCheckout(entities.bookCopy(), 
                                               entities.member(), 
                                               dueDate))
            .flatTap(this::notifyMember)
            .flatTap(this::updateStatistics);
    }

    private Result<Boolean> validateCheckout(
            BookCopyId bookId, 
            MemberId memberId,
            LocalDate dueDate) {
        
        if (dueDate.isBefore(LocalDate.now())) {
            return Result.failure(CheckoutErrorCodes.INVALID_DUE_DATE,
                "Due date cannot be in the past");
        }

        return Result.success(true);
    }

    private Result<EntityPair> loadEntities(BookCopyId bookId, MemberId memberId) {
        return Result.combine(
            bookCopyRepository.findById(bookId),
            memberRepository.findById(memberId),
            EntityPair::new
        );
    }

    private Result<Checkout> processCheckout(
            BookCopy bookCopy,
            Member member,
            LocalDate dueDate) {
        
        return member.canCheckout()
            .flatMap(canCheckout -> bookCopy.markAsCheckedOut()
                .map(book -> new Checkout(book, member, dueDate)));
    }
}

이 서비스 구현의 주요 특징은 다음과 같습니다:

  1. 단계적 처리: 각 단계가 명확하게 구분되어 있으며, 실패 시 즉시 중단됩니다.
  2. 함수형 접근: flatMap과 flatTap을 통해 여러 연산을 명확하게 조합합니다.
  3. 부수 효과 관리: 알림 발송과 통계 업데이트 같은 부수 효과가 명시적으로 처리됩니다.

3.4.5 트랜잭션과 동시성 처리

도메인 모델에서 Result 타입을 사용할 때는 트랜잭션 관리와 동시성 제어가 특히 중요합니다.

public class CheckoutService {
    @Transactional
    public Result<Checkout> checkout(BookCopyId bookId, MemberId memberId, LocalDate dueDate) {
        try {
            return processCheckout(bookId, memberId, dueDate);
        } catch (OptimisticLockingFailureException e) {
            return Result.failure(CheckoutErrorCodes.CONCURRENT_MODIFICATION,
                "Book was checked out by another user");
        }
    }
}

public class BookCopy {
    @Version
    private Long version;  // 낙관적 잠금을 위한 버전 정보
}

여기서 중요한 점들은 다음과 같습니다:

  1. 트랜잭션 경계: @Transactional 어노테이션으로 명확한 트랜잭션 경계를 설정합니다.
  2. 동시성 제어: @Version을 통한 낙관적 잠금으로 동시 대출 시도를 안전하게 처리합니다.
  3. 명확한 오류 처리: 동시성 문제가 발생했을 때 이해하기 쉬운 오류 메시지를 제공합니다.

3.4.6 비동기 처리와 Result 타입

도서관 시스템에서 일부 작업은 비동기적으로 처리될 수 있습니다. 예를 들어, 대출 처리 중 외부 시스템 검증이나 알림 발송 등이 이에 해당합니다.

public class AsyncCheckoutService {
    public CompletableFuture<Result<Checkout>> checkoutAsync(
            BookCopyId bookId, 
            MemberId memberId,
            LocalDate dueDate) {
        
        return validateCheckoutAsync(bookId, memberId, dueDate)
            .thenCompose(result -> result.match(
                valid -> processValidCheckout(bookId, memberId, dueDate),
                failure -> CompletableFuture.completedFuture(
                    Result.<Checkout>failure(failure))
            ));
    }

    private CompletableFuture<Result<Boolean>> validateCheckoutAsync(
            BookCopyId bookId,
            MemberId memberId,
            LocalDate dueDate) {
        
        return CompletableFuture.supplyAsync(() -> {
            // 비동기 검증 로직 (예: 외부 시스템을 통한 회원 자격 검증)
            return externalSystemCheck(memberId);
        });
    }
}

비동기 처리의 주요 특징은 다음과 같습니다:

  1. 비동기 검증: 외부 시스템과의 통신이 필요한 검증을 비동기적으로 수행합니다.
  2. 결과 조합: CompletableFuture와 Result를 자연스럽게 조합합니다.
  3. 오류 전파: 비동기 체인에서도 오류를 명확하게 전파합니다.

3.4.7 도메인 이벤트와 통합

도서 대출 시스템에서 발생하는 중요한 이벤트들을 도메인 이벤트로 모델링할 수 있습니다.

public class CheckoutCompletedEvent extends DomainEvent {
    private final CheckoutId checkoutId;
    private final BookCopyId bookId;
    private final MemberId memberId;
    private final LocalDateTime checkoutTime;
    private final LocalDate dueDate;

    // 도서 대출 통계, 알림, 기타 시스템 통합에 활용
}

public class CheckoutService {
    private Result<List<DomainEvent>> publishEvents(Checkout checkout) {
        List<DomainEvent> events = List.of(
            new CheckoutCompletedEvent(checkout),
            new StatisticsUpdatedEvent(checkout)
        );

        return Result.traverse(
            events.stream()
                .map(this::publishEvent)
                .collect(Collectors.toList())
        ).map(successes -> events);
    }
}

이벤트 기반 처리의 장점은 다음과 같습니다:

  1. 느슨한 결합: 대출 처리와 부가 기능이 이벤트를 통해 느슨하게 결합됩니다.
  2. 확장성: 새로운 기능을 이벤트 구독자로 쉽게 추가할 수 있습니다.
  3. 감사 추적: 모든 중요 작업이 이벤트로 기록되어 추적이 용이합니다.

3.4.8 테스트와 디버깅

Result 타입을 사용하는 도서관 도메인 모델의 테스트는 더욱 명확하고 예측 가능해집니다.

class CheckoutServiceTest {
    @Test
    void shouldPreventCheckoutForOverdueBooks() {
        // Given
        Member member = createMemberWithOverdueBook();
        BookCopy book = createAvailableBook();

        // When
        Result<Checkout> result = checkoutService.checkout(
            book.getId(), 
            member.getId(), 
            LocalDate.now().plusDays(14)
        );

        // Then
        assertTrue(result instanceof Result.Failure);
        var failure = (Result.Failure<Checkout>) result;
        assertEquals(MemberErrorCodes.HAS_OVERDUE_BOOKS, failure.errorCode());
        
        // 컨텍스트 검증
        var context = failure.context();
        assertEquals(member.getId(), context.get("memberId").orElse(null));
        assertTrue(context.get("overdueCount", Integer.class).isPresent());
    }

    @Test
    void shouldSuccessfullyCheckoutBook() {
        // Given
        Member member = createActiveMember();
        BookCopy book = createAvailableBook();
        LocalDate dueDate = LocalDate.now().plusDays(14);

        // When
        Result<Checkout> result = checkoutService.checkout(
            book.getId(), 
            member.getId(), 
            dueDate
        );

        // Then
        assertTrue(result instanceof Result.Success);
        var checkout = result.getValue();
        assertEquals(BookStatus.CHECKED_OUT, checkout.getBookCopy().getStatus());
        assertEquals(dueDate, checkout.getDueDate());
        
        // 이벤트 검증
        List<DomainEvent> events = eventPublisher.getPublishedEvents();
        assertTrue(events.stream()
            .anyMatch(e -> e instanceof CheckoutCompletedEvent));
    }
}

테스트 작성 시 주목할 점은 다음과 같습니다:

  1. 명확한 시나리오: 각 테스트가 특정 비즈니스 규칙이나 사용 사례를 검증합니다.
  2. 상세한 검증: 성공과 실패 케이스 모두에서 결과의 세부 사항을 검증합니다.
  3. 문맥 검증: 실패 시의 컨텍스트 정보가 올바르게 포함되었는지 확인합니다.
  4. 부수 효과 검증: 이벤트 발행과 같은 부수 효과도 명시적으로 테스트합니다.

3.4.9 실용적인 활용 패턴

실제 도서관 시스템 구현에서 Result 타입을 효과적으로 활용하기 위한 추가적인 패턴들을 살펴보겠습니다.

복구 전략 패턴

네트워크 오류나 일시적인 데이터베이스 문제로 인한 실패를 자동으로 복구하는 패턴입니다:

public class RetryableCheckoutService {
    private final CheckoutService delegate;
    private final int maxAttempts;
    private final Duration delay;

    public Result<Checkout> checkout(BookCopyId bookId, MemberId memberId, LocalDate dueDate) {
        return Result.attempt(
            () -> delegate.checkout(bookId, memberId, dueDate),
            this::shouldRetry,
            maxAttempts,
            delay
        );
    }

    private boolean shouldRetry(Exception e) {
        return e instanceof TransientDataAccessException ||
               e instanceof OptimisticLockingFailureException;
    }
}

감사 추적 패턴

도서 대출과 관련된 모든 작업의 이력을 추적하는 패턴입니다:

public class AuditableCheckout extends Checkout {
    public Result<Checkout> process(Librarian librarian) {
        return super.process()
            .flatTap(checkout -> recordAudit(librarian, "BOOK_CHECKED_OUT"))
            .recover(failure -> {
                recordAudit(librarian, "CHECKOUT_FAILED", failure);
                return Result.failure(failure);
            });
    }

    private Result<AuditLog> recordAudit(Librarian librarian, String action) {
        return Result.attempt(
            () -> auditLogger.log(
                new AuditEntry(librarian, action, LocalDateTime.now())
            ),
            e -> AuditErrorCodes.LOGGING_FAILED
        );
    }
}

권한 검증 패턴

도서 대출 작업에 대한 사서의 권한을 검증하는 패턴입니다:

public class SecureCheckoutService {
    public Result<Checkout> processCheckout(
            BookCopyId bookId, 
            MemberId memberId, 
            Librarian librarian) {
            
        return checkPermission(librarian, Permission.PROCESS_CHECKOUT)
            .flatMap(allowed -> checkoutService.checkout(bookId, memberId));
    }

    private Result<Boolean> checkPermission(Librarian librarian, Permission permission) {
        if (!permissionChecker.hasPermission(librarian, permission)) {
            return Result.failure(SecurityErrorCodes.UNAUTHORIZED,
                "Librarian %s does not have %s permission",
                librarian.getId(), permission)
                .withContext("librarianId", librarian.getId())
                .withContext("permission", permission);
        }
        return Result.success(true);
    }
}

정리

도서관 도메인 모델에서 Result 타입을 활용함으로써 우리는 다음과 같은 이점을 얻을 수 있습니다:

  1. 도메인 규칙의 명확한 표현

    • 대출 가능 여부, 연체 상태, 대출 한도 등의 규칙을 명시적으로 검증
    • 각 규칙 위반 시 상세한 오류 정보 제공
  2. 안전한 상태 관리

    • 도서와 회원의 상태 변경이 항상 일관성 있게 처리됨
    • 동시성 문제를 명시적으로 처리
  3. 풍부한 오류 처리

    • 각 실패 상황에 대한 상세한 컨텍스트 정보 제공
    • 오류 상황을 도메인 전문가가 이해할 수 있는 언어로 표현
  4. 유연한 확장성

    • 이벤트 기반 아키텍처로 새로운 기능 쉽게 추가
    • 부가 기능들(알림, 통계 등)을 핵심 로직과 분리

Result 타입은 특히 다음과 같은 상황에서 큰 가치를 발휘합니다:

  1. 복잡한 비즈니스 규칙 검증

    • 회원 자격, 대출 자격, 도서 상태 등 다양한 규칙 검증
    • 규칙 위반 시 명확한 피드백 제공
  2. 트랜잭션 처리

    • 도서 대출과 같은 여러 단계의 작업을 안전하게 처리
    • 실패 시 일관성 있는 롤백 보장
  3. 외부 시스템 통합

    • 알림 발송, 통계 업데이트 등의 외부 시스템 통합
    • 실패 상황의 우아한 처리

추가 고려사항

  1. 성능 최적화

    • 대출 처리 시 필요한 데이터를 효율적으로 로딩
    • 불필요한 데이터베이스 조회 최소화
  2. 확장성 고려

    • 도서관 지점 확장에 대비한 설계
    • 새로운 대출 규칙 추가를 위한 유연한 구조
  3. 모니터링과 운영

    • 주요 대출 작업에 대한 모니터링 지표 정의
    • 문제 상황 신속 파악을 위한 로깅 전략

다음 장에서는 이러한 패턴들을 실제 프로젝트에 적용하는 과정에서 마주칠 수 있는 다양한 도전 과제들과 그 해결 방안을 살펴보겠습니다.

Last updated on