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);
}
}예외 처리 방식의 주요 특징은 다음과 같습니다:
- 제어 흐름의 분리: try-catch 블록을 통해 정상 흐름과 오류 처리 흐름을 명확히 구분합니다.
- 스택 트레이스: 오류 발생 시 상세한 디버깅 정보를 제공합니다.
- 리소스 관리: try-with-resources를 통해 자원의 안전한 해제를 보장합니다.
- 검사 예외: 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 타입의 주요 특징은 다음과 같습니다:
- 명시적인 오류 처리: 타입 시스템을 통해 오류 처리를 강제합니다.
- 합성 가능성: 여러 연산을 체이닝하여 복잡한 로직을 표현할 수 있습니다.
- 문맥 보존: 오류 정보와 함께 처리 문맥을 유지합니다.
- 참조 투명성: 예측 가능한 제어 흐름을 제공합니다.
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 타입은 오류 처리를 위한 강력하고 유연한 방법을 제공합니다. 타입 시스템을 활용한 안전성, 함수형 스타일의 조합 가능성, 그리고 실용적인 확장성을 통해 복잡한 비즈니스 로직을 명확하고 유지보수하기 쉽게 표현할 수 있습니다.
핵심 포인트:
- 타입 안전성과 불변성을 통한 견고한 기반
- 함수형 연산자를 통한 유연한 조합
- 실용적인 확장을 통한 다양한 사용 사례 지원
- 기존 코드와의 원활한 통합
다음 장에서는 이러한 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
);
}
}이 예시에서 주목할 점은 다음과 같습니다:
- 인프라스트럭처 계층의 예외를 도메인 계층의 Result로 변환합니다.
- 적절한 오류 코드를 사용하여 문제의 성격을 명확히 전달합니다.
- 데이터 접근 계층의 구체적인 예외를 추상화된 오류 코드로 매핑합니다.
검증 로직의 조합
복잡한 비즈니스 규칙을 검증할 때 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)
);
}
}이 구현의 주요 특징은 다음과 같습니다:
- 단계적 검증: 각 검증 단계를 명확하게 구분하여 처리합니다.
- 조건부 로직: 상황에 따라 다른 검증 흐름을 적용합니다.
- 병렬 검증: 여러 검증을 동시에 수행하고 결과를 결합합니다.
비동기 처리와의 통합
현대 애플리케이션에서는 비동기 처리가 필수적입니다. 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)
));
}
}이 패턴의 핵심 포인트는 다음과 같습니다:
- Result와 CompletableFuture의 자연스러운 조합
- 비동기 체인에서의 오류 전파
- 타입 안전성 유지
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 타입과 전통적인 예외 처리 방식의 성능 차이를 이해하는 것이 중요합니다:
-
메모리 사용:
- Result 타입은 항상 객체를 생성하므로 메모리 사용량이 예외 처리보다 예측 가능합니다.
- 예외는 스택 트레이스 정보를 포함하므로 예외 발생 시 더 많은 메모리를 사용할 수 있습니다.
-
CPU 사용:
- Result 타입은 일반적인 객체 생성과 메서드 호출 경로를 따르므로 CPU 사용이 일정합니다.
- 예외는 스택 되감기(stack unwinding) 과정이 필요하므로, 예외 발생 시 더 많은 CPU 자원을 사용합니다.
-
최적화 가능성:
- Result 타입은 일반적인 코드 경로를 따르므로 JIT 컴파일러의 최적화 대상이 됩니다.
- 예외는 최적화하기 어려운 특별한 제어 흐름을 사용합니다.
따라서:
- 예외적인 상황(진짜 예외)에는 예외를 사용
- 비즈니스 로직의 일부로 발생하는 오류 상황에는 Result 타입을 사용
3.3.9 동시성 처리 가이드라인
Result 타입을 동시성 환경에서 사용할 때 주의할 점들:
- 불변성 활용:
// 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);
}
}- 상태 공유 주의:
// 잘못된 예
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);
}
}- 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 타입은 오류 처리를 위한 강력하고 유연한 패턴을 제공합니다. 이를 통해 우리는:
- 타입 시스템을 활용하여 오류를 명시적으로 처리할 수 있습니다.
- 복잡한 비즈니스 로직을 명확하고 유지보수하기 쉽게 표현할 수 있습니다.
- 함수형 스타일의 연산자를 통해 다양한 상황에 대응할 수 있습니다.
- 기존 시스템과의 통합을 유연하게 처리할 수 있습니다.
오류 처리 방식의 선택은 프로젝트의 특성, 팀의 선호도, 구체적인 사용 사례에 따라 달라질 수 있습니다. 중요한 것은 선택한 접근 방식을 일관성 있게 적용하고, 팀 내에서 명확한 가이드라인을 수립하는 것입니다. 이 책에서는 Result 타입의 활용 사례를 중점적으로 다루겠지만, 이는 단순히 학습과 이해를 위한 선택임을 기억해주시기 바랍니다.
다음 장에서는 이러한 Result 타입을 활용하여 실제 도메인 모델을 구현하는 방법을 더 자세히 살펴보겠습니다. 특히 도메인 주도 설계(Domain-Driven Design)의 맥락에서 Result 타입이 어떻게 도메인 규칙을 효과적으로 표현하고 강제할 수 있는지 알아보겠습니다.