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사로, 하지만 상품 자체가 없으면 폴백할 게 없다. 오류 처리는 "모든 오류를 같은 방식으로"가 아니라, 오류의 성격에 따라 다른 전략을 고르는 일이다.
참고
- Martin, Robert C. —
(Pearson, 2008), Ch.7 (오류 처리) — 접근 2026-07-15 - Ousterhout, John — (2nd ed, 2021), Ch.13 (예외 vs 결과) — 접근 2026-07-15
'Architecture & Design > Software Architecture' 카테고리의 다른 글
| Principles - 08. 복잡성의 원천 (0) | 2026.07.16 |
|---|---|
| Principles - 06. 계약 설계와 API (0) | 2026.07.16 |
| Principles - 05. 캡슐화와 정보 은닉 (0) | 2026.07.16 |
| Principles - 04. 의존성 규칙 (0) | 2026.07.16 |
| Principles - 03. 패러다임의 함의 (0) | 2026.07.16 |