Architecture & Design/Software Architecture

Principles - 07. 오류 처리 전략

배송 실패와 화물 사고는 다르다 — 오류 처리 전략

택배를 보낸다고 하자. 수취인이 부재중이라 배송이 실패했다 — 이건 정상 흐름의 일부다. 내일 다시 배달하면 된다(복구 가능). 하지만 트럭이 사고를 내어 화물이 전소했다 — 이건 정상 흐름이 아니다. 운송 자체가 중단되고, 보상 청구라는 전혀 다른 경로로 넘어간다(복원 불가능).

소프트웨어 오류도 이 두 종류로 나뉜다. 복구 가능한 오류(입력 검증 실패, 리소스 부족)는 정상 흐름의 일부로 다뤄야 하고, 복원 불가능한 오류(디스크 고장, 메모리 부족)는 흐름을 중단시키고 상위로 알려야 한다. 이 둘을 구분하지 않고 모두 예외로 던지면, 호출자가 매번 try-catch를 쳐야 하고 정상 흐름이 예외 처리에 묻힌다.

예외 vs 결과 값 — 두 접근

예외(exception)는 "무언가 잘못됐다"를 알리는 전통적 메커니즘이다. 호출 스택을 따라 상위로 전파되며, 잡지 않으면 프로그램이 멈춘다. Java·C#·Python이 이 방식이다.

// 예외 방식 (Java)
public Order place(OrderRequest req) {
    if (req.items().isEmpty())
        throw new IllegalArgumentException("주문 항목이 없습니다");  // 예외 던짐
    return new Order(req.items());
}
// 호출자는 try-catch로 잡아야 함 — 정상 흐름과 예외 흐름이 분리됨

결과 값(result type)은 오류를 반환 값으로 다루는 방식이다. Go의 (value, error) 다중 반환, Rust의 Result<T, E>, Haskell의 Maybe가 이 방식이다. 오류가 반환 값의 일부이므로, 호출자가 무시할 수 없다 — 컴파일러가 강제한다.

// 결과 값 방식 (Java로 시뮬레이션 — sealed interface)
sealed interface OrderResult permits OrderResult.Success, OrderResult.Failure {}
record OrderResult.Success(Order order) implements OrderResult {}
record OrderResult.Failure(String reason) implements OrderResult {}

public OrderResult place(OrderRequest req) {
    if (req.items().isEmpty())
        return new OrderResult.Failure("주문 항목이 없습니다");  // 결과로 반환
    return new OrderResult.Success(new Order(req.items()));
}
// 호출자는 반드시 Success/Failure를 다뤄야 함 — 무시하면 컴파일 에러

복구 가능 vs 복원 불가능

오류 종류 처리 방식
복구 가능 (정상 흐름) 입력 검증 실패, 잔액 부족, 중복 주문 결과 값으로 반환 — 호출자가 판단
복원 불가능 (비정상) DB 연결 끊김, 설정 오류, 코드 버그 예외로 던지거나 로깅 후 중단

복구 가능한 오류를 예외로 던지면, 호출자가 매번 try-catch를 쳐야 하고 정상 흐름이 예외에 묻힌다. 반대로 복원 불가능한 오류를 결과 값으로 반환하면, 호출자가 "복구할 수 없는 걸 복구하려" 시도하다가 더 큰 문제를 만든다. 구분이 중요하다.

설계 사례 — 복구 가능한 오류를 결과로, 복원 불가능한 오류를 예외로

flowchart TD
    Input["주문 요청"] --> Valid{"입력 검증"}
    Valid -->|"통과"| Process["주문 처리"]
    Valid -->|"실패 (복구 가능)"| Result["결과 값 반환<br/>OrderResult.Failure"]
    Process --> DB{"DB 저장"}
    DB -->|"성공"| Success["OrderResult.Success"]
    DB -->|"연결 끊김 (복원 불가)"| Ex["예외 throw<br/>상위에서 처리"]
// 복구 가능한 오류 — 결과 값으로 (호출자가 판단)
public OrderResult place(OrderRequest req) {
    if (req.items().isEmpty())
        return new OrderResult.Failure("주문 항목이 없습니다");
    if (req.total().isNegative())
        return new OrderResult.Failure("주문 금액이 음수입니다");
    Order order = new Order(req.items());
    // DB 저장 실패는 복원 불가능 — 예외로
    try {
        repo.save(order);
    } catch (DataAccessException e) {
        throw new InfrastructureException("DB 저장 실패", e);  // 복원 불가 → 예외
    }
    return new OrderResult.Success(order);
}

// 호출자 — 정상 흐름에서 깔끔하게 처리
OrderResult result = orderService.place(req);
switch (result) {
    case OrderResult.Success s -> sendConfirmation(s.order());
    case OrderResult.Failure f -> showUserError(f.reason());  // 사용자에게 안내
}
// try-catch 없이 정상 흐름으로 오류 처리 — 가독성 향상

Go는 이 철학을 언어 차원에서 강제한다 — 모든 함수가 (value, error)를 반환하고, error를 무시하면 컴파일러가 경고한다. Rust는 Result<T, E>로 오류를 타입 시스템에 넣어, 처리하지 않은 오류는 컴파일이 안 된다. Java는 예외가 기본이지만, 최근 Optional과 sealed interface로 결과 값 방식을 부분적으로 도입하고 있다. 어느 언어든 원칙은 같다 — 복구 가능한 오류는 정상 흐름에서, 복원 불가능한 오류는 예외 흐름에서 다룬다.

예외 계층 설계 — catch를 명확히

Java에서 예외를 쓸 때, 모든 예외를 Exception 하나로 던지면 호출자가 catch (Exception e)로 전부 잡아야 한다 — 복구 가능한지 불가능한지 구분이 안 된다. 예외를 계층으로 나누면, 호출자가 잡고 싶은 것만 선택적으로 잡을 수 있다.

// 예외 계층 — 복구 가능/불가능을 타입으로 구분
sealed interface OrderException extends Exception {}
record InvalidOrderException(String detail) implements OrderException {}       // 복구 가능
record InsufficientStockException(ProductId product) implements OrderException {}  // 복구 가능
record DatabaseUnavailableException(Throwable cause) extends RuntimeException {}   // 복원 불가능

// 호출자 — 복구 가능한 것만 잡음
try {
    orderService.place(req);
} catch (InvalidOrderException e) {
    showUserError(e.detail());          // 사용자에게 안내 → 복구
} catch (InsufficientStockException e) {
    suggestAlternative(e.product());    // 대체 상품 제안 → 복구
}
// DatabaseUnavailableException은 안 잡음 → 상위로 전파 → 로깅 후 중단

오류 처리 실무 패턴

오류 상황 패턴
일시적 실패 (네트워크 지연) 재시도 + 백오프 3회 재시도, 간격 1s → 2s → 4s
대체 가능 폴백(fallback) 결제 PG사 장애 시 다른 PG사로 우회
부분 성공 (주문 OK, 알림 실패) 나중에 재시도 (큐 적재) 이메일을 큐에 넣고 별도 처리
비즈니스 위반 (잔액 부족) 결과 값 반환 사용자에게 안내, 정상 흐름으로 처리
인프라 붕괴 (DB 다운) 빠른 실패 (fail fast) 더 진행해도 의미 없음 — 중단 + 알림
// 재시도 패턴 (일시적 실패)
public PaymentResult chargeWithRetry(Money amount, int maxRetries) {
    for (int i = 0; i <= maxRetries; i++) {
        try {
            return gateway.charge(amount);            // 성공 시 즉시 반환
        } catch (TransientException e) {
            if (i == maxRetries) throw e;             // 최대 재시도 도달 → 포기
            sleep(backoffMs(i));                      // 지수 백오프: 1s → 2s → 4s
        }
    }
}
// 폴백 패턴 (대체 가능)
public PaymentResult charge(Money amount) {
    try { return primaryGateway.charge(amount); }
    catch (GatewayUnavailableException e) {
        return fallbackGateway.charge(amount);        // 우회 → 사용자는 모름
    }
}

재시도는 일시적 실패(네트워크 지연)에만 쓴다 — 비즈니스 위반(잔액 부족)에 재시도하면 100번 시도해도 실패한다. 폴백은 대체 수단이 있을 때만 쓴다 — 결제 PG사를 못 쓰면 다른 PG사로, 하지만 상품 자체가 없으면 폴백할 게 없다. 오류 처리는 "모든 오류를 같은 방식으로"가 아니라, 오류의 성격에 따라 다른 전략을 고르는 일이다.


참고