오류 처리는 코드의 본래 흐름을 보호하기 위한 장치다. 하지만 오류를 다루는 코드가 정상 로직 사이사이에 흩어지면, 정작 프로그램이 무엇을 하는지 읽기 어려워진다. 깨끗한 오류 처리의 목표는 두 가지를 동시에 만족시키는 것이다 — 실패를 무시하지 않으면서, 정상 흐름은 선명하게 유지하기.

오류 코드보다 예외

오류를 반환값(코드)으로 알리면 호출자에게 매번 확인 책임을 떠넘긴다. 실제 로직과 오류 검사가 뒤섞여 흐름이 흐려지고, 검사를 한 번 빠뜨리면 그대로 버그가 된다.

// before — 반환 코드 검사가 로직 사이에 스며든다
if (deletePage(page) == E_OK) {
    if (registry.deleteReference(page.name) == E_OK) {
        if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
            logger.log("page deleted");
        } else {
            logger.log("configKey not deleted");
        }
    } else {
        logger.log("deleteReference failed");
    }
} else {
    logger.log("delete failed");
}
// after — 정상 흐름과 실패 흐름을 분리한다
try {
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
    logger.log(e.getMessage());
}

정상 경로는 try 안에서 위에서 아래로 곧게 읽히고, 실패 처리는 한곳에 모인다. 판단 기준: 실패를 값처럼 흘려보내면 쉽게 누락되고, 누락된 오류 처리는 나중에 더 찾기 어려운 버그가 된다. 실패는 값이 아니라 예외로 분명히 드러낸다.

try-catch-finally를 먼저 잡는다

예외가 발생할 수 있는 코드는 하나의 트랜잭션 경계를 만든다. try 안에서 무언가 시도되고, catch가 실패를 해석하며, finally가 정리를 보장한다. 그래서 이런 코드는 try-catch-finally부터 잡고 시작하면 흐름이 안정된다. 테스트도 같다 — 실패 상황을 먼저 테스트로 만들고 그걸 통과시키도록 구현하면, 실패 처리를 코드 밖으로 미루지 않게 된다. 특히 파일, 네트워크, 트랜잭션처럼 중간에 깨질 수 있는 작업에서 이 순서가 중요하다.

예외에는 맥락을, 클래스는 호출자 기준으로

확인된 예외(checked exception)는 호출 단계마다 throws 선언을 강제한다. 작은 예제에선 명시적이라 좋아 보이지만, 실제로는 하위 계층의 예외가 상위 인터페이스까지 밀고 올라와 의존성을 만든다 — 개방-폐쇄 원칙을 깨는 셈이다. 그래서 애플리케이션 대부분에서는 미확인 예외가 더 현실적이다.

예외를 던질 때는 원인을 추적할 정보를 담는다. “실패했다”만으로는 부족하고, 어떤 값으로, 어떤 작업을 하다가, 어떤 조건에서 실패했는지가 담겨야 로그만 보고도 문제를 좁힐 수 있다. 그리고 예외 클래스는 발생 위치가 아니라 호출자가 어떻게 처리할지를 기준으로 분류한다.

이 관점에서, 외부 라이브러리가 던지는 여러 예외를 그대로 노출하면 라이브러리 세부사항이 코드 곳곳에 퍼진다. 경계에서 하나의 애플리케이션 예외로 감싼다.

// LocalPort 가 외부 API 예외를 애플리케이션 언어로 감싼다
public class LocalPort {
    private final ACMEPort innerPort;
 
    public LocalPort(int portNumber) {
        this.innerPort = new ACMEPort(portNumber);
    }
 
    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException | ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e); // 원인(e)을 담아 감싼다
        }
    }
}

이제 호출자는 ACMEPort의 예외 종류를 알 필요 없이 PortDeviceFailure 하나만 다루면 된다. 라이브러리가 바뀌어도 변경 범위가 이 경계 안으로 줄고, 테스트에서 대역을 넣기도 쉬워진다.

null을 반환하지도, 전달하지도 않는다

null 반환은 호출자에게 “없을 수도 있으니 알아서 확인하라”는 책임을 넘긴다. 한 번의 누락이 NullPointerException으로 이어지고, 방어용 null 검사가 코드 곳곳에 번식한다. 반환할 값이 없으면 빈 컬렉션, Optional, 혹은 특수 사례 객체처럼 의도를 드러내는 표현을 쓴다. 인수로 null을 넘기는 것도 마찬가지로 피한다 — 허용해야 한다면 의미를 분명히 하고, 아니라면 초기에 실패시킨다.

어떤 실패를 예외로 올릴지, 정상 흐름으로 흡수할지는 다음 흐름으로 판단한다.

flowchart TD
    Q{"이 실패는 예외 상황인가,<br/>흔히 있는 정상 경로인가?"}
    Q -->|"예외 상황(호출자가 중단·복구해야 함)"| EX["예외를 던진다<br/>+ 충분한 맥락<br/>+ 호출자 기준 예외 클래스"]
    Q -->|"자주 있는 정상 경로"| NORMAL{"반환할 값이<br/>있는가?"}
    NORMAL -->|"컬렉션이 비었을 뿐"| EMPTY["빈 컬렉션 반환<br/>(null 금지)"]
    NORMAL -->|"기본 동작으로 대체 가능"| SPECIAL["특수 사례 객체 반환<br/>호출자의 분기를 없앤다"]
    NORMAL -->|"단순히 없음을 표현"| OPT["Optional 반환"]

특수 사례 객체 — 분기를 없애며 진화시키기

“못 찾으면 기본값”처럼 흔한 정상 경로는 예외로 밀어낼 필요가 없다. 특수 사례 객체(Special Case)를 돌려주면 호출자의 분기 자체가 사라진다. 식대 합계를 구하는 코드가 null 반환 → 예외 → 특수 사례 객체로 진화하는 과정을 단계별로 보자. Next로 넘기며 totalMealExpenses가 어떻게 분기를 잃고 곧게 펴지는지 확인해 보자.

Refactoring Step null 반환 — 호출자가 null 검사를 떠안는다
1/3
public class ExpenseReport {    private ExpenseDAO dao;    public int totalMealExpenses(Employee employee) {        Meals meals = dao.getMeals(employee.getId());        if (meals != null) {           // 못 찾으면 null 을 돌려준다            return meals.getTotal();        }        return getMealPerDiem();       // 없으면 일당으로 대체    }    private int getMealPerDiem() {        return 21_000;    }}

마지막 단계에서 “없으면 일당”이라는 규칙은 PerDiemMeals.getTotal() 안으로 옮겨졌다. 호출자는 더 이상 예외도, null도, 분기도 신경 쓰지 않는다. 판단 기준: 특수 사례 객체가 어울리는 것은 “없음”이 오류가 아니라 정의된 기본 동작일 때다. 함정: 진짜 예외 상황(중단하거나 복구해야 하는 실패)까지 특수 사례 객체로 삼키면, 실패가 조용히 묻혀 더 위험하다. 예외로 올릴 것과 흡수할 것을 흐름도 기준으로 갈라야 한다.

마무리

  • 좋은 오류 처리는 안정성을 높이면서 정상 로직을 흐리지 않는다.
  • 실패는 반환 코드가 아니라 예외로 드러내고, try-catch-finally를 먼저 잡는다.
  • 예외에는 충분한 맥락을 담고, 외부 예외는 경계에서 애플리케이션 언어로 감싼다.
  • null은 반환도 전달도 하지 않는다. 빈 컬렉션, Optional, 특수 사례 객체로 의도를 드러낸다.
  • 흔한 정상 경로는 특수 사례 객체로 분기를 없앤다. 단, 진짜 실패까지 삼키지는 않는다.

다음장으로 8장