클린코드 #7 오류 처리
왜 클린 코드 책에 오류 처리가 나왔을까? 깨끗한 코드와 오류 처리는 확실한 연관성이 있기 때문이다. 실제로, 많은 코드들이 전적으로 오류 처리 코드에 좌우된다. 오류 코드가 막 흩어져있어, 실제 코드의 로직을 파악하는게 불가능하다.
오류 처리는 프로그램에서 반드시 필요한 요소 중 하나이다. 입력이 이상하거나 디바이스가 실패할 지도 모르기 때문이다. 다만, 오류 처리로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.
오류 코드보다 예외를 사용하라
과거에는 예외 처리를 지원하지 않는 프로그래밍 언어가 많았다. 예외를 지원하지 않으면 오류 처리 및 보고 방법이 제한적이다.
if (handle != DeviceHandle.INVALID) {
retrieveDeviceRecord(handle);
if (Record.getStatus() != DEVICE_SUSPENDED {
pauseDevice(handle);
//...
} else {
log("오류 정보");
}
} else {
log("오류 정보");
}
오류 처리로 인해 호출자 코드가 복잡해진다. 함수를 호출하자마자 오류를 즉시확인해야 하기 때문이다.
따라서, 오류가 발생하면 예외를 던지는 것이 낫다. 호출자 코드가 깔끔해진다. 논리가 오류 처리 코드와 뒤섞이지 않게 된다.
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
private void tryToShutDown() throws DeviceShutDownError {
// 여기에 모아서 논리적인 과정만 작성하면 됨
}
이처럼 보기 좋아질 뿐만 아니라 코드 품질이 나아졌다.
Try-Catch-Finally
try 블록은 트랜잭션과 같다. try 블록에서 어떤 일이 생겨도, catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.
따라서, 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다.
TDD로 레드-그린-리팩토링을 진행한다고 하자.
try {
예외가 발생할 수 있는 코드(파일 스트림 - 정보 읽기)
} catch (Exception e) {
throw new StorageException("retrieval error");
}
이런식으로, try-catch로 구현을 시작하여 초록불을 맞추자.
이후 리팩토링 과정에서 구체적인 예외를 잡아서 예외를 던져주자.
마틴은, 먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다.
트랜잭션 본질을 유지하기 쉬워진다.
언체크 예외를 사용하라
자바에 예외가 나온 처음에는 체크 예외를 사용했다. 메서드를 선언할 때는 메서드가 반환하는 예외를 시그니처에 모두 나열했다.
당시에는 체크 예외가 멋진 아이디어라고 생각했지만, 지금 우리는 체크 예외가 반드시 필요하지 않다는 사실을 알고 있다.
C#, C++, 파이썬, 루비 모두 체크 예외가 없지만 안정적인 소프트웨어를 구현하는데 무리가 없다.
그렇다면 체크 예외는 왜 사용을 지양하라는 것일까?
체크 예외를 잡아서 처리하는 비용이, 그만큼 상응하는 이익을 제공할까?
체크 예외는 OCP를 위반한다. 하위 단계에서 예외를 던지면 상위 단계 메서드 선언부를 다 고쳐야 한다.
최하위 함수에서 던지는 예외를 throws 경로에 있는 모든 함수가 알아야 하므로 캡슐화가 깨진다.
물론 체크 예외가 유용할 때도 있다. 중요한 라이브러리를 작성한다면 모든 예외를 잡아야 한다. 하지만 일반적인 Application에서는 의존성을 처리하는 비용이 이익보다 크다.
예외에 의미를 제공하라
예외를 던질 때는 전 후 상황을 충분히 덧붙여야 한다. 오류가 발생한 원인과 위치를 찾기 쉬워지기 때문이다.
(자바에서 call stack을 보여주긴 하지만, 해당 정보만으로 실패한 코드의 의도를 파악하기는 어렵다.)
오류 메시지에 정보를 담아 예외와 함께 던진다. 실패한 연산 이름, 유형도 담아준다. 로깅을 사용한다면 catch블록에서 충분한 정보를 로깅하자.
호출자를 고려해 예외 클래스를 정의하라
오류를 분류하는 방법은 매우 많다.
- 오류가 발생한 위치(컴포넌트)
- 오류 발생 유형
- ex) 디바이스 실패, 네트워크 실패, …
하지만, Application에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
예를 들어, 외부 라이브러리를 호출했을 때 발생하는 모든 예외를 try-catch-finally로 잡아버린다고 해보자.
try {
port.open();
} catch (디바이스 응답 예외 e) {
리포트;
로깅;
} catch (디바이스 언락 예외 e) {
리포트;
로깅;
} catch (언락 예외 e) {
리포트;
로깅;
}
코드의 중복이 많지만 더 중요한 건, 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일하다는 것이다.
그렇다면, 호출하는 라이브러리 API를 감싸서 예외 유형 하나를 반환해주면 된다.
- ex) LocalPort 라는 클래스로 라이브러리의 예외를 감쌌다고 해보자.
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (디바이스 실패 예외 e) {
리포트;
로깅;
} finally {
...
}
LocalPort 처럼 외부 라이브러리를 감싸는 클래스는 유용하다.
- 외부 라이브러리와 프로그램 사이의 의존성이 줄어들기 때문이다.
- 또한 감싼 클래스에서, 외부 API 호출 대신 테스트 코드를 넣어주면 프로그램을 테스트하기도 쉬워진다.
- 특정 업체가 API를 설계한 방식에 발목잡히지 않는다.
하나의 예외클래스 만을 사용하여, 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우가 많다.
한 예외는 잡고 다른 예외는 무시해도 괜찮은 케이스라면 여러 예외 클래스를 사용하는게 좋다.
정상 흐름을 정의하라.
위에서 말한 것들을 잘 지키면, 오류 처리와 비즈니스 로직이 잘 분리된 코드가 나온다. 하지만 이러다 보면 오류 감지가 프로그램 언저리로 밀려나게 된다. 외부 API를 감싸 독자적인 예외를 던지고, 해당 클래스에 예외 처리 코드를 넣어 중단된 계산을 처리하게 된다. 하지만 중단이 적합하지 않을 때도 있다.
try {
MealExpenses expenses = expensesReportDAO.getMeals(employee.getId());
m_total += expenses.getTotal();
} catch (MealExpensesNotFound e) {
m_total += getMealPerDim();
}
식비 비용을 청구했다면 총계에 더하고, 그렇지 않다면 기본 식비를 총계에 더하고 있다.
그런데, 이러한 특수 상황을 따로 처리할 필요가 없다면 코드가 더 깔끔할 것이다.
MealExpenses expenses = expensesReportDAO.getMeals(employee.getId());
m_total += expenses.getTotal();
이런 간결한 코드가 가능할까? 가능하다.
ExpenseReportDAO를 고치면 된다. 청구한 식비가 없으면 일일 기본 식비를 반환하는 MealExpense 객체를 반환하면 된다.
이를 특수 사례 패턴(Special Case Pattern)이라고 부른다. 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식이다.
이렇게 되면 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다. 클래스가 객체가 예외 상황을 캡슐화하기 때문이다.
null을 반환하지 마라.
오류를 유발하는 행위 자체를 막는 것도 중요하다.
null을 반환하게 되면, 클라이언트 입장에서 if (item != null)
과 같은 로직을 추가해야 한다.
null을 반환한다는 것은, 호출자에게 문제를 떠넘기는 것이다. 호출자 누구라도 null 확인을 빼먹으면 큰일난다.
만약 메서드에서 null을 반환하고 싶다면 예외를 던지거나 특수 사례 객체를 반환하자.
만약 사용하는 외부 API가 null을 반환하면 감싸기 메서드를 구현해 예외를 던지거나 특수 사례 객체를 반환하자.
(비어있는 컬렉션을 반환하는 것도 좋다)
참고
자바 8부터는 Optional이 등장하면서, nullable한 결과를 Optional에 감싸서 반환할 수 있게 되었다.
null을 전달하지 마라
메서드에서 null 반환하는 것도 나쁘지만 메서드에 null을 전달하는 방식은 더 나쁘다. 최대한 피하자.
물론, 메서드 내부에서 assert문을 적절히 사용하면 코드를 읽기 편하긴 하지만 문제를 해결하지는 못한다.
결론
깨끗한 코드는 읽기도 좋아야 하고, 안정성도 높아야 한다. 둘은 상충되는 목표가 아니다. 오류 처리와 프로그램 로직을 독자적으로 고려하면 읽기 좋고 안정성 높은 코드를 작성할 수 있다.
'Software Engineering > Clean Code' 카테고리의 다른 글
[클린 코드] #9 단위 테스트 (0) | 2025.09.03 |
---|---|
[클린 코드] #8 경계 (0) | 2025.09.03 |
[클린 코드] #6 객체와 자료구조 (0) | 2025.09.03 |
[클린 코드] #5 형식 맞추기 (0) | 2025.09.03 |
[클린 코드] #4 주석 (0) | 2024.11.09 |