오류 처리는 코드의 본래 흐름을 보호하기 위한 장치다. 하지만 오류를 다루는 코드가 여기저기 흩어지면 정작 프로그램이 무엇을 하는지 읽기 어려워진다. 깨끗한 오류 처리는 실패 상황을 무시하지 않으면서도, 정상 흐름을 가능한 선명하게 유지한다.
오류 코드보다 예외
오류 코드를 반환하는 방식은 호출자에게 계속 확인 책임을 떠넘긴다. 호출자는 매번 반환값을 검사해야 하고, 그 과정에서 실제 로직과 오류 처리 로직이 뒤섞인다. 예외를 사용하면 정상 흐름과 실패 흐름을 분리할 수 있다.
예외가 만능이라는 뜻은 아니다. 다만 실패를 값처럼 흘려보내는 방식은 쉽게 누락되고, 누락된 오류 처리는 나중에 더 찾기 어려운 버그가 된다. 실패가 발생하면 그 사실을 분명하게 표현하는 편이 낫다.
try-catch-finally를 먼저 생각하기
예외가 발생하는 코드는 하나의 경계를 만든다. try 블록 안에서 어떤 일이 시도되고, catch에서 실패를 어떻게 해석할지 결정하며, finally에서는 필요한 정리를 보장한다. 그래서 예외가 발생할 수 있는 코드를 작성할 때는 이 경계를 먼저 잡아두면 흐름이 안정된다.
테스트도 같은 방식으로 접근할 수 있다. 먼저 예외가 발생하는 상황을 테스트로 만들고, 그 테스트를 통과하도록 구현하면 실패 상황을 코드 밖으로 미루지 않게 된다. 특히 파일, 네트워크, 트랜잭션처럼 중간에 실패할 수 있는 작업에서는 이런 경계가 중요하다.
미확인 예외와 의미 있는 예외
확인된 예외는 호출 단계마다 선언을 강제한다. 작은 예제에서는 명시적이라 좋아 보이지만, 실제 애플리케이션에서는 하위 계층의 예외가 상위 계층의 인터페이스까지 밀고 올라와 의존성을 만든다. 이 때문에 많은 경우 미확인 예외가 더 현실적이다.
예외를 던질 때는 원인을 추적할 수 있는 정보를 함께 담아야 한다. 단순히 “실패했다”는 메시지는 별 도움이 되지 않는다. 어떤 값으로, 어떤 작업을 하다가, 어떤 외부 조건에서 실패했는지가 담겨야 로그를 보고도 문제를 좁힐 수 있다.
호출자를 고려한 예외 클래스
예외를 분류할 때 가장 중요한 기준은 호출자가 어떻게 처리할 수 있는가다. 외부 라이브러리가 던지는 예외를 그대로 애플리케이션 전체에 노출하면, 라이브러리의 세부사항이 내부 코드 곳곳에 퍼진다.
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportPortError(e);
logger.log("Device error", e);
}LocalPort가 외부 API의 여러 예외를 애플리케이션에 맞는 하나의 예외로 감싸면 호출자는 더 단순해진다.
public class LocalPort {
private ACMEPort innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResponseException e) {
throw new PortDeviceFailure(e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure(e);
}
}
}이런 래퍼는 외부 라이브러리와 애플리케이션 사이의 경계를 만든다. 라이브러리가 바뀌어도 변경 범위가 줄고, 테스트에서도 대체 객체를 넣기 쉬워진다.
정상 흐름을 유지하기
모든 특수 상황을 예외로 밀어내는 것이 좋은 설계는 아니다. 어떤 상황은 애플리케이션에서 자연스럽게 처리해야 하는 정상 흐름일 수 있다. 이럴 때는 특수 사례 객체나 빈 컬렉션을 반환해 호출자가 불필요한 분기 없이 로직을 이어가게 만들 수 있다.
특히 null 반환은 조심해야 한다. null은 호출자에게 “혹시 없을 수도 있으니 알아서 확인하라”는 책임을 넘긴다. 한 번의 누락이 NullPointerException으로 이어지고, 방어 코드가 곳곳에 늘어난다. 반환할 값이 없다면 빈 리스트, Optional, 특수 사례 객체처럼 의도를 드러내는 표현을 선택하는 편이 낫다.
마찬가지로 인수로 null을 전달하는 일도 피해야 한다. 메서드가 null을 허용해야 한다면 그 의미가 무엇인지 분명히 해야 하고, 그렇지 않다면 초기에 실패시키는 편이 낫다.
마무리
좋은 오류 처리는 안정성을 높이면서도 정상 로직을 흐리지 않는다. 예외에는 충분한 맥락을 담고, 외부 예외는 애플리케이션의 언어로 감싸며, null처럼 모호한 실패 표현은 줄여야 한다. 오류 처리를 비즈니스 로직과 분리할수록 코드는 읽기 쉬워지고 유지보수하기도 쉬워진다.
다음장으로 8장