Chapter 3

오류 처리와 경계

  • 3.1 오류 코드보다 예외를 사용하라
  • 3.2 Try-Catch-Finally 문부터 작성하라
  • 3.3 미확인 예외를 사용하라
  • 3.4 예외에 의미를 제공하라
  • 3.5 호출자를 고려해 예외 클래스를 정의하라
  • 3.6 정상 흐름을 정의하라
  • 3.7 null을 반환하지 마라
  • 3.8 null을 전달하지 마라
  • 3.9 외부 코드 사용하기
  • 3.10 경계 살피고 익히기
  • 3.11 학습 테스트
  • 3.12 아직 존재하지 않는 코드 사용하기
  • 3.13 깨끗한 경계

오류 처리와 외부 코드의 경계 — 둘 다 내 코드가 "통제할 수 없는 것"과 만나는 지점이야. 예외 상황을 깔끔하게 다루고, 외부 의존성을 안전하게 감싸는 게 견고한 코드의 핵심이지.

오류 코드를 반환하면 호출자가 즉시 처리해야 해서, 호출자 코드가 복잡해져. if 문이 중첩되고, 정상 로직과 오류 처리가 뒤섞이지. 예외를 쓰면 정상 알고리즘과 오류 처리 알고리즘을 분리할 수 있어. try 블록에 정상 로직, catch 블록에 오류 처리. 둘이 섞이지 않으니 코드가 깔끔해져.

예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작해. try 블록은 트랜잭션과 비슷하거든 — try 블록에서 무슨 일이 생기든 catch 블록이 프로그램 상태를 일관성 있게 유지해야 해. 저자는 TDD 방식을 추천해. 먼저 강제로 예외를 일으키는 테스트를 작성하고, 그 테스트를 통과하게 코드를 짜면 자연스럽게 try 블록의 트랜잭션 범위부터 정의하게 되거든.

자바의 **확인된 예외(checked exception)**는 처음에는 좋은 아이디어 같았어. 메서드가 어떤 예외를 던질 수 있는지 선언하니까 안전해 보였으니까. 근데 실제로 써보면 **OCP(개방-폐쇄 원칙)**를 위반해. 하위 함수에서 새로운 확인된 예외를 던지면, 그 함수를 호출하는 모든 함수에 throws 절을 추가해야 하고, 연쇄적으로 수정이 필요해. 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 건, "캡슐화가 깨졌다"는 뜻이야. C#, C++, Python, Ruby는 확인된 예외를 지원하지 않는데도 안정적인 소프트웨어를 만들잖아.

예외를 던질 때 충분한 정보를 함께 제공해. 실패한 연산 이름, 실패 유형, 에러 메시지에 전후 상황을 담아야 오류가 발생한 원인과 위치를 파악하기 쉬워져. 자바는 모든 예외에 호출 스택을 제공하지만, 그것만으로는 충분하지 않거든. 실패한 코드의 의도를 파악하려면 개발자가 직접 의미 있는 메시지를 담아야 해.

외부 라이브러리를 쓸 때 흔한 실수가 있어 — 라이브러리가 던지는 모든 예외를 각각 잡아서 처리하는 것. 이러면 catch 블록이 줄줄이 나열되는데, 대부분 같은 방식으로 처리하거든. 해결책은 외부 API를 "감싸는 클래스(wrapper class)"를 만드는 거야. 외부 라이브러리가 던지는 다양한 예외를 하나의 예외 타입으로 변환해서 던지면, 호출하는 코드가 깔끔해져. 나중에 라이브러리를 교체할 때 수정 범위가 작아지고, 테스트할 때 라이브러리를 가짜(mock)로 대체하기도 쉬워.

비즈니스 로직과 오류 처리가 잘 분리되면 좋긴 한데, 가끔은 오류 처리 자체가 비즈니스 로직에 끼어드는 경우가 있어. 식비를 청구한 직원의 총계를 계산하는데, 청구한 식비가 없으면 일일 기본 식비를 총계에 더한다고 하자. 예외를 쓰면 catch 블록에서 기본값을 설정하게 되는데, 코드가 읽기 불편하지. 이럴 때는 "특수 사례 패턴(Special Case Pattern)"을 써. 식비 청구가 없으면 기본 식비를 반환하는 MealExpense 객체를 만들면, 호출하는 쪽에서는 예외를 처리할 필요가 없어. 정상 흐름만 작성하면 돼.

null을 반환하는 메서드는 호출자에게 null 체크 의무를 떠넘겨. null 체크를 하나라도 빼먹으면 NullPointerException이 터지지. 메서드에서 null을 반환하고 싶다면, 대신 "예외를 던지거나 특수 사례 객체를 반환해". 리스트를 반환하는 메서드라면 빈 리스트를 반환하면 돼. 그리고 메서드에서 null을 반환하는 것도 나쁘지만, 메서드에 null을 전달하는 건 "더 나빠". 대부분의 프로그래밍 언어에는 호출자가 실수로 null을 넘기는 걸 막는 좋은 방법이 없어. 그래서 "애초에 null을 넘기지 않는 정책"이 합리적이야.

오류 처리가 내 코드 안의 방어선이라면, 경계는 내 코드 밖의 방어선이야. 외부 패키지나 프레임워크 제공자는 "적용성을 최대한 넓히려" 해. 반면 사용자는 자신의 요구에 집중하는 인터페이스를 원하지. 이 사이에 긴장이 생겨. java.util.Map이 좋은 예야. Map은 엄청나게 다양한 기능을 제공하거든 — clear(), put(), get() 등. Map<String, Sensor>를 프로그램 여기저기에 넘기면, 누구나 clear()를 호출해서 내용을 지울 수 있고, Sensor 타입이 바뀌면 수정할 곳이 많아져. 해결책은 Map을 직접 넘기지 말고 "감싸서 쓰는 것"이야. Sensors라는 클래스를 만들어서 내부에 Map을 숨기고, 필요한 인터페이스만 공개해. 모든 Map을 감싸라는 게 아니라, Map 같은 경계 인터페이스를 "여기저기 넘기지 마라"는 거야.

외부 코드를 가져다 쓸 때, 곧바로 우리 코드에 통합하지 말고 "학습 테스트"를 먼저 작성해. 외부 코드를 익히기도 어렵고, 통합하기도 어려운데, 두 가지를 동시에 하면 더 어렵거든. **학습 테스트(Learning Tests)**란 외부 API를 이해하기 위해 작성하는 테스트야. 프로그램에서 사용하려는 방식대로 외부 API를 호출해보는 거지. 저자가 log4j를 학습 테스트로 익히는 과정을 보여주는데, "hello"를 로깅하는 간단한 테스트부터 시작해서, ConsoleAppender가 필요하다는 걸 알아내고, PatternLayout을 설정하는 방법까지 단계적으로 이해해. 문서를 죽 읽는 것보다 훨씬 효율적이야.

학습 테스트는 공짜 이상이야. 어차피 API를 배워야 하니까 테스트를 작성하는 건 추가 비용이 아니거든. 그리고 이 테스트는 패키지가 "새 버전이 나왔을 때" 호환성을 검증하는 데 쓸 수 있어. 외부 패키지 업그레이드가 무서운 이유가 "뭐가 깨질지 몰라서"인데, 학습 테스트가 있으면 새 버전에서 뭐가 달라졌는지 바로 확인돼.

경계의 또 다른 유형이 있어 — 아직 존재하지 않는 코드를 사용하는 것. 다른 팀이 API를 아직 설계하지 않았거나, 외부 시스템의 세부사항이 확정되지 않았을 때야. 저자의 팀이 무선 통신 시스템을 개발할 때, "송신기" 하위 시스템의 API가 아직 정해지지 않은 상황이었거든. 그래서 자기들이 "원하는 인터페이스"를 먼저 정의했어. 나중에 송신기 API가 확정됐을 때, "어댑터 패턴(Adapter Pattern)"으로 간극을 메웠지. 우리가 정의한 인터페이스와 실제 API 사이의 변환기를 만든 거야. 이렇게 하면 우리 코드는 실제 API가 뭔지 모르고도 잘 돌아가고, API가 바뀌어도 어댑터만 고치면 돼.

경계에 위치하는 코드는 변경이 잦아. 외부 패키지가 업데이트되면 경계 코드를 고쳐야 하거든. 그래서 경계에서는 "변경 비용을 최소화"하는 설계가 필요해. 새로운 클래스로 감싸거나, 어댑터 패턴을 사용해 우리가 원하는 인터페이스로 변환하거나. 어느 쪽이든 코드 가독성이 높아지고, 외부 패키지가 변했을 때 수정할 곳이 줄어들어.


정리

3장 읽고 기억할 거 세 가지:

  1. 오류 처리를 비즈니스 로직과 분리하라. 예외를 쓰면 정상 흐름이 깔끔해지고, null 대신 특수 사례 객체나 빈 컬렉션을 반환하면 null 체크 지옥에서 벗어난다
  2. 외부 API는 감싸라. 경계 인터페이스를 직접 여기저기 넘기면 변경에 취약해진다. 래퍼 클래스나 어댑터 패턴으로 내가 원하는 인터페이스만 노출하라
  3. 학습 테스트로 외부 코드를 익혀라. 공짜로 API를 배울 수 있고, 패키지 업그레이드 시 호환성 검증에도 쓸 수 있다. 아직 없는 코드에는 원하는 인터페이스를 먼저 정의하라