3.3 Result 타입의 이해와 구현

3.3 Result 타입의 이해와 구현

3.3.1 오류 처리의 필요성과 접근 방식

소프트웨어 시스템을 개발할 때 오류 상황은 피할 수 없는 현실입니다. 네트워크 연결 실패, 잘못된 입력 데이터, 시스템 자원 부족 등 다양한 오류 상황이 발생할 수 있으며, 이러한 상황을 효과적으로 처리하는 것은 견고한 시스템을 구축하는 데 핵심적인 요소입니다.

전통적인 예외 처리의 특징

Java 생태계에서는 예외(Exception)를 통한 오류 처리가 널리 사용되어 왔습니다. 예외 처리는 다음과 같은 특징을 가지고 있습니다:

public class LibraryService {
    public CheckoutRecord checkoutBook(String isbn, String memberId) throws 
        BookNotFoundException, 
        MemberNotFoundException,
        BookNotAvailableException {
        
        // 1. 도서 찾기
        Book book = findBook(isbn);  // BookNotFoundException 발생 가능
        
        // 2. 회원 찾기
        Member member = findMember(memberId);  // MemberNotFoundException 발생 가능
        
        // 3. 도서 대출 가능 여부 확인
        if (!book.isAvailable()) {
            throw new BookNotAvailableException(isbn);
        }
        
        // 4. 회원의 연체 상태 확인
        if (member.hasOverdueBooks()) {
            throw new OverdueBooksException(memberId);
        }
        
        // 5. 대출 기록 생성
        return createCheckoutRecord(book, member);
    }
}

예외 처리 방식의 주요 특징은 다음과 같습니다:

  1. 제어 흐름의 분리: try-catch 블록을 통해 정상 흐름과 오류 처리 흐름을 명확히 구분합니다.
  2. 스택 트레이스: 오류 발생 시 상세한 디버깅 정보를 제공합니다.
  3. 리소스 관리: try-with-resources를 통해 자원의 안전한 해제를 보장합니다.
  4. 검사 예외: Java의 독특한 특징으로, 컴파일 시점에 예외 처리를 강제합니다.

Result 타입을 통한 새로운 접근

함수형 프로그래밍의 영향으로 등장한 Result 타입은 오류를 값으로 다루는 새로운 방식을 제시합니다. 앞선 예제를 Result 타입으로 재구성해보겠습니다:

public class LibraryService {
    public Result<CheckoutRecord> checkoutBook(String isbn, String memberId) {
        return findBook(isbn)  // Result<Book>
            .flatMap(book -> validateBookAvailability(book)  // Result<Book>
                .flatMap(availableBook -> findMember(memberId)  // Result<Member>
                    .flatMap(member -> validateMemberStatus(member)  // Result<Member>
                        .map(validMember -> createCheckoutRecord(availableBook, validMember)))))
            .flatTap(record -> updateBookStatus(record));  // 부수 효과 처리
    }

    private Result<Book> validateBookAvailability(Book book) {
        return book.isAvailable()
            ? Result.success(book)
            : Result.failure(ErrorCodes.BOOK_NOT_AVAILABLE, book.getTitle());
    }
}

Result 타입의 주요 특징은 다음과 같습니다:

  1. 명시적인 오류 처리: 타입 시스템을 통해 오류 처리를 강제합니다.
  2. 합성 가능성: 여러 연산을 체이닝하여 복잡한 로직을 표현할 수 있습니다.
  3. 문맥 보존: 오류 정보와 함께 처리 문맥을 유지합니다.
  4. 참조 투명성: 예측 가능한 제어 흐름을 제공합니다.

3.3.2 Result 타입의 기본 구조

Result 타입은 연산의 성공 또는 실패를 표현하는 대수적 데이터 타입입니다. Java 17부터 도입된 sealed 인터페이스를 활용하여 다음과 같이 구현할 수 있습니다:

public sealed interface Result<T> permits Result.Success, Result.Failure {
    // 값이 없는 성공 결과를 위한 상수
    Result<Void> SUCCESS = new Success<>(null);

    // 성공 결과를 나타내는 레코드
    record Success<T>(T value) implements Result<T> {}

    // 실패 결과를 나타내는 레코드
    record Failure<T>(ErrorCode errorCode, Object[] errorArgs, ErrorContext context) implements Result<T> {
        public Failure {
            Objects.requireNonNull(errorCode, "Error code cannot be null");
            Objects.requireNonNull(errorArgs, "Error args cannot be null");
            context = context == null ? ErrorContext.empty() : context;
        }
        
        // 에러 컨텍스트 없이 생성하는 간편 생성자
        public Failure(ErrorCode errorCode, Object... errorArgs) {
            this(errorCode, errorArgs, null);
        }
    }

    // 패턴 매칭을 위한 메서드
    <R> R match(
        Function<? super T, ? extends R> successMapper,
        Function<? super Failure<T>, ? extends R> failureMapper
    );
}

ErrorCode와 ErrorContext의 기본 구조:

public record ErrorCode(String code, String message) {
    public ErrorCode {
        Objects.requireNonNull(code, "Error code cannot be null");
        Objects.requireNonNull(message, "Message cannot be null");
    }
}

public record ErrorContext(Map<String, Object> contextData) {
    private static final ErrorContext EMPTY = new ErrorContext(Map.of());
    
    public static ErrorContext empty() {
        return EMPTY;
    }
    
    public static ErrorContext of(String key, Object value) {
        return new ErrorContext(Map.of(key, value));
    }
    
    public ErrorContext with(String key, Object value) {
        Map<String, Object> newData = new HashMap<>(contextData);
        newData.put(key, value);
        return new ErrorContext(Collections.unmodifiableMap(newData));
    }
}

3.3.3 핵심 연산자 구현

Result 타입의 진정한 가치는 다양한 연산자를 통해 발휘됩니다. 이러한 연산자들은 함수형 프로그래밍의 개념을 기반으로 하며, 복잡한 로직을 명확하고 안전하게 표현할 수 있게 해줍니다.

map과 flatMap

가장 기본적인 연산자인 map과 flatMap은 성공 결과를 변환하는 데 사용됩니다:

public interface Result<T> {
    default <R> Result<R> map(Function<? super T, ? extends R> mapper) {
        return this instanceof Success<T> success
            ? new Success<>(mapper.apply(success.value()))
            : ((Failure<R>) this);
    }

    default <R> Result<R> flatMap(
            Function<? super T, ? extends Result<? extends R>> mapper) {
        return this instanceof Success<T> success
            ? (Result<R>) mapper.apply(success.value())
            : ((Failure<R>) this);
    }
}

이러한 연산자의 활용 예시를 살펴보겠습니다:

public class OrderService {
    public Result<Order> processOrder(String orderId) {
        return findOrder(orderId)                    // Result<Order>
            .map(this::calculateTotalPrice)          // Result<Order>
            .flatMap(this::validateInventory)        // Result<Order>
            .flatMap(this::processPayment)           // Result<Order>
            .map(this::generateConfirmation);        // Result<Order>
    }
}

결합 연산자

여러 Result를 하나로 결합하는 기능을 제공합니다:

public interface Result<T> {
   static <T1, T2, R> Result<R> combine(
           Result<T1> result1,
           Result<T2> result2,
           BiFunction<T1, T2, R> combiner) {

      if (result1 instanceof Failure<T1> failure) {
         return new Failure<>(failure.errorCode(), failure.errorArgs(), failure.context());
      }
      if (result2 instanceof Failure<T2> failure) {
         return new Failure<>(failure.errorCode(), failure.errorArgs(), failure.context());
      }

      return new Success<>(combiner.apply(
              ((Success<T1>) result1).value(),
              ((Success<T2>) result2).value()
      ));
   }
}

실제 활용 예시:

public class ShippingService {
    public Result<ShippingLabel> createShippingLabel(Order order) {
        return Result.combine(
            validateAddress(order.getShippingAddress()),  // Result<Address>
            calculateShippingCost(order),                 // Result<Cost>
            (address, cost) -> new ShippingLabel(address, cost)
        );
    }
}

3.3.4 실용적인 확장

Result 타입의 실용성을 높이기 위해 몇 가지 중요한 확장 기능을 추가할 수 있습니다.

컬렉션 처리

여러 Result를 동시에 처리하기 위한 traverse 연산자:

public interface Result<T> {
    static <T> Result<List<T>> traverse(Collection<Result<T>> results) {
        List<T> successValues = new ArrayList<>();

        for (Result<T> result : results) {
            if (result instanceof Failure<T> failure) {
                return new Failure<>(failure.errorCode(), failure.errorArgs());
            }
            successValues.add(result.getValue());
        }

        return new Success<>(Collections.unmodifiableList(successValues));
    }
}

활용 예시:

public class BatchProcessor {
    public Result<List<ProcessedItem>> processBatch(List<RawItem> items) {
        return Result.traverse(
            items.stream()
                .map(this::processItem)
                .collect(Collectors.toList())
        );
    }
}

예외 처리 통합

기존 코드와의 통합을 위한 예외 처리 기능:

public interface Result<T> {
    static <T> Result<T> attempt(
            Supplier<T> supplier,
            Function<Exception, ErrorCode> errorMapper) {
        try {
            return new Success<>(supplier.get());
        } catch (Exception e) {
            return new Failure<>(errorMapper.apply(e));
        }
    }

    default T orElseThrow() {
        if (this instanceof Success<T> success) {
            return success.value();
        }
        Failure<T> failure = (Failure<T>) this;
        throw new ResultException(failure.errorCode(), failure.errorArgs());
    }
}

3.3.5 모범 사례와 패턴

Result 타입을 효과적으로 활용하기 위한 몇 가지 모범 사례를 살펴보겠습니다.

단계적 검증

복잡한 검증 로직을 단계별로 구성하는 방법:

public class UserRegistrationService {
    public Result<User> registerUser(RegistrationRequest request) {
        return validateUsername(request.username())
            .flatMap(username -> validateEmail(request.email())
                .flatMap(email -> validatePassword(request.password())
                    .map(password -> createUser(username, email, password))));
    }

    private Result<String> validateUsername(String username) {
        return username != null && username.length() >= 3
            ? Result.success(username)
            : Result.failure(ErrorCodes.INVALID_USERNAME);
    }
}

부수 효과 처리

로깅이나 메트릭 수집과 같은 부수 효과를 처리하는 방법:

public class TransactionService {
    public Result<Transaction> processTransaction(TransactionRequest request) {
        return validateRequest(request)
            .flatTap(this::logValidation)        // 로깅
            .flatMap(this::processPayment)
            .flatTap(this::updateMetrics)        // 메트릭 업데이트
            .flatTap(this::notifyUser);          // 사용자 알림
    }
}

정리

Result 타입은 오류 처리를 위한 강력하고 유연한 방법을 제공합니다. 타입 시스템을 활용한 안전성, 함수형 스타일의 조합 가능성, 그리고 실용적인 확장성을 통해 복잡한 비즈니스 로직을 명확하고 유지보수하기 쉽게 표현할 수 있습니다.

핵심 포인트:

  1. 타입 안전성과 불변성을 통한 견고한 기반
  2. 함수형 연산자를 통한 유연한 조합
  3. 실용적인 확장을 통한 다양한 사용 사례 지원
  4. 기존 코드와의 원활한 통합

다음 장에서는 이러한 Result 타입을 실제 도메인 모델에 적용하는 구체적인 사례를 살펴보겠습니다.

3.3.6 실제 적용 사례와 고려사항

Result 타입을 실제 프로젝트에 도입할 때는 몇 가지 중요한 고려사항이 있습니다. 이를 실제 사례를 통해 살펴보겠습니다.

계층 간 오류 전파

도메인 계층과 인프라스트럭처 계층 사이의 오류 처리 방식을 살펴보겠습니다:

public class OrderRepository {
    public Result<Order> findById(String orderId) {
        try {
            Optional<OrderEntity> entity = orderJpaRepository.findById(orderId);
            return entity.map(this::mapToOrder)
                        .map(Result::success)
                        .orElse(Result.failure(ErrorCodes.ORDER_NOT_FOUND, orderId));
        } catch (DataAccessException e) {
            return Result.failure(ErrorCodes.DATABASE_ERROR, e.getMessage());
        }
    }

    public Result<Order> save(Order order) {
        return Result.attempt(
            () -> {
                OrderEntity entity = mapToEntity(order);
                entity = orderJpaRepository.save(entity);
                return mapToOrder(entity);
            },
            e -> e instanceof DataIntegrityViolationException
                ? ErrorCodes.DUPLICATE_ORDER
                : ErrorCodes.DATABASE_ERROR
        );
    }
}

이 예시에서 주목할 점은 다음과 같습니다:

  1. 인프라스트럭처 계층의 예외를 도메인 계층의 Result로 변환합니다.
  2. 적절한 오류 코드를 사용하여 문제의 성격을 명확히 전달합니다.
  3. 데이터 접근 계층의 구체적인 예외를 추상화된 오류 코드로 매핑합니다.

검증 로직의 조합

복잡한 비즈니스 규칙을 검증할 때 Result 타입을 활용하는 방법을 살펴보겠습니다:

public class PaymentService {
    public Result<Payment> processPayment(PaymentRequest request) {
        return validatePaymentAmount(request.amount())
            .flatMap(amount -> validatePaymentMethod(request.paymentMethod())
                .flatMap(method -> validateCurrency(request.currency())
                    .flatMap(currency -> {
                        if (isHighRiskTransaction(amount, currency)) {
                            return performEnhancedValidation(request);
                        }
                        return processStandardPayment(amount, method, currency);
                    })));
    }

    private boolean isHighRiskTransaction(BigDecimal amount, Currency currency) {
        return amount.compareTo(new BigDecimal("10000")) > 0 ||
               currency.equals(Currency.getInstance("BTC"));
    }

    private Result<Payment> performEnhancedValidation(PaymentRequest request) {
        return Result.combine(
            validateCustomerHistory(request.customerId()),
            validateDeviceFingerprint(request.deviceInfo()),
            validateGeolocation(request.location()),
            (history, device, location) -> 
                createEnhancedPayment(request, history, device, location)
        );
    }
}

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

  1. 단계적 검증: 각 검증 단계를 명확하게 구분하여 처리합니다.
  2. 조건부 로직: 상황에 따라 다른 검증 흐름을 적용합니다.
  3. 병렬 검증: 여러 검증을 동시에 수행하고 결과를 결합합니다.

비동기 처리와의 통합

현대 애플리케이션에서는 비동기 처리가 필수적입니다. Result 타입을 CompletableFuture와 함께 사용하는 방법을 살펴보겠습니다:

public class AsyncOrderProcessor {
    public CompletableFuture<Result<Order>> processOrderAsync(OrderRequest request) {
        return CompletableFuture.supplyAsync(() -> validateOrder(request))
            .thenCompose(result -> result.match(
                this::processValidOrder,
                failure -> CompletableFuture.completedFuture(Result.<Order>failure(failure))
            ));
    }

    private CompletableFuture<Result<Order>> processValidOrder(OrderRequest request) {
        return inventoryService.checkAvailabilityAsync(request.items())
            .thenCompose(availabilityResult -> availabilityResult.match(
                available -> paymentService.processPaymentAsync(request.payment()),
                failure -> CompletableFuture.completedFuture(Result.<Order>failure(failure))
            ))
            .thenApply(paymentResult -> paymentResult.map(
                payment -> createOrder(request, payment)
            ));
    }
}

이 패턴의 핵심 포인트는 다음과 같습니다:

  1. Result와 CompletableFuture의 자연스러운 조합
  2. 비동기 체인에서의 오류 전파
  3. 타입 안전성 유지

3.3.7 발전된 패턴과 확장

Result 타입을 더욱 효과적으로 활용하기 위한 고급 패턴들을 살펴보겠습니다.

컨텍스트 전파

오류 처리 과정에서 문맥 정보를 유지하고 전파하는 방법:

public interface Result<T> {
    default Result<T> withContext(String key, Object value) {
        if (this instanceof Failure<T> failure) {
            return new Failure<>(failure.errorCode(), failure.errorArgs(), 
                ErrorContext.of(key, value));
        }
        return this;
    }
}

public class TransactionProcessor {
    public Result<Transaction> processTransaction(String transactionId) {
        return findTransaction(transactionId)
            .withContext("transactionId", transactionId)
            .withContext("timestamp", LocalDateTime.now())
            .flatMap(this::validateTransaction)
            .withContext("validationTime", LocalDateTime.now())
            .flatMap(this::processPayment)
            .withContext("processingTime", LocalDateTime.now());
    }
}

복구 전략

실패한 연산을 복구하거나 대체하는 방법:

public interface Result<T> {
    default Result<T> recover(
            Function<Failure<T>, Result<T>> recoveryStrategy) {
        return this instanceof Failure<T> failure
            ? recoveryStrategy.apply(failure)
            : this;
    }
}

public class PaymentService {
    public Result<Payment> processPayment(PaymentRequest request) {
        return primaryProcessor.processPayment(request)
            .recover(failure -> {
                if (failure.errorCode() == ErrorCodes.PROCESSOR_UNAVAILABLE) {
                    return backupProcessor.processPayment(request);
                }
                return Result.failure(failure);
            })
            .recover(failure -> {
                if (failure.errorCode() == ErrorCodes.INSUFFICIENT_FUNDS) {
                    return handleInsufficientFunds(request);
                }
                return Result.failure(failure);
            });
    }
}

부수 효과 처리를 위한 flatTap 연산자

부수 효과를 처리하면서 원래 값을 유지하기 위한 flatTap 연산자를 제공합니다:

public interface Result<T> {
    default Result<T> flatTap(Function<? super T, ? extends Result<?>> action) {
        if (this instanceof Success<T> success) {
            return action.apply(success.value())
                .map(ignored -> success.value());
        }
        return this;
    }
}

3.3.8 성능 고려사항

Result 타입과 전통적인 예외 처리 방식의 성능 차이를 이해하는 것이 중요합니다:

  1. 메모리 사용:

    • Result 타입은 항상 객체를 생성하므로 메모리 사용량이 예외 처리보다 예측 가능합니다.
    • 예외는 스택 트레이스 정보를 포함하므로 예외 발생 시 더 많은 메모리를 사용할 수 있습니다.
  2. CPU 사용:

    • Result 타입은 일반적인 객체 생성과 메서드 호출 경로를 따르므로 CPU 사용이 일정합니다.
    • 예외는 스택 되감기(stack unwinding) 과정이 필요하므로, 예외 발생 시 더 많은 CPU 자원을 사용합니다.
  3. 최적화 가능성:

    • Result 타입은 일반적인 코드 경로를 따르므로 JIT 컴파일러의 최적화 대상이 됩니다.
    • 예외는 최적화하기 어려운 특별한 제어 흐름을 사용합니다.

따라서:

  • 예외적인 상황(진짜 예외)에는 예외를 사용
  • 비즈니스 로직의 일부로 발생하는 오류 상황에는 Result 타입을 사용

3.3.9 동시성 처리 가이드라인

Result 타입을 동시성 환경에서 사용할 때 주의할 점들:

  1. 불변성 활용:
// Result 자체가 불변이므로 동시성 처리가 용이
public class ConcurrentProcessor {
    public Result<Data> processData(Data data) {
        return Result.attempt(() -> {
            Future<ProcessedData> future = executor.submit(() -> process(data));
            return future.get(1, TimeUnit.SECONDS);
        }, this::mapException);
    }
}
  1. 상태 공유 주의:
// 잘못된 예
public class SharedStateProcessor {
    private Result<Data> lastResult; // 공유 상태 - 위험!
    
    public void process(Data data) {
        lastResult = processData(data); // 동시성 문제 발생 가능
    }
}

// 올바른 예
public class StatelessProcessor {
    public Result<Data> process(Data data) {
        return processData(data) // 상태를 공유하지 않음
            .flatMap(this::validateData)
            .flatMap(this::saveData);
    }
}
  1. CompletableFuture와의 통합:
public class AsyncProcessor {
    public CompletableFuture<Result<Data>> processAsync(Data data) {
        return CompletableFuture
            .supplyAsync(() -> processData(data))
            .thenApply(result -> result.flatMap(this::validateData))
            .exceptionally(ex -> Result.failure(
                ErrorCodes.PROCESSING_ERROR, ex.getMessage()));
    }
}

정리

Result 타입은 오류 처리를 위한 강력하고 유연한 패턴을 제공합니다. 이를 통해 우리는:

  1. 타입 시스템을 활용하여 오류를 명시적으로 처리할 수 있습니다.
  2. 복잡한 비즈니스 로직을 명확하고 유지보수하기 쉽게 표현할 수 있습니다.
  3. 함수형 스타일의 연산자를 통해 다양한 상황에 대응할 수 있습니다.
  4. 기존 시스템과의 통합을 유연하게 처리할 수 있습니다.

오류 처리 방식의 선택은 프로젝트의 특성, 팀의 선호도, 구체적인 사용 사례에 따라 달라질 수 있습니다. 중요한 것은 선택한 접근 방식을 일관성 있게 적용하고, 팀 내에서 명확한 가이드라인을 수립하는 것입니다. 이 책에서는 Result 타입의 활용 사례를 중점적으로 다루겠지만, 이는 단순히 학습과 이해를 위한 선택임을 기억해주시기 바랍니다.

다음 장에서는 이러한 Result 타입을 활용하여 실제 도메인 모델을 구현하는 방법을 더 자세히 살펴보겠습니다. 특히 도메인 주도 설계(Domain-Driven Design)의 맥락에서 Result 타입이 어떻게 도메인 규칙을 효과적으로 표현하고 강제할 수 있는지 알아보겠습니다.

Last updated on