오류 처리는 코드의 본래 흐름을 보호하기 위한 장치다. 하지만 오류를 다루는 코드가 정상 로직 사이사이에 흩어지면, 정작 프로그램이 무엇을 하는지 읽기 어려워진다. 깨끗한 오류 처리의 목표는 두 가지를 동시에 만족시키는 것이다 — 실패를 무시하지 않으면서, 정상 흐름은 선명하게 유지하기.
오류 코드보다 예외
오류를 반환값(코드)으로 알리면 호출자에게 매번 확인 책임을 떠넘긴다. 실제 로직과 오류 검사가 뒤섞여 흐름이 흐려지고, 검사를 한 번 빠뜨리면 그대로 버그가 된다.
// 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가 어떻게 분기를 잃고 곧게 펴지는지 확인해 보자.
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장