7장에서는 오류를 적절하게 처리하는 방법에 대해 알아봅니다.
들어가며
여기저기 흩어진 오류 처리 코드는, 실제 코드의 논리를 이해하기 어렵게 만든다. 이 장은 오류를 '우아하게' 처리하는 방법들을 안내한다.
오류 코드보다 예외를 사용하라
예전에는 예외를 지원하지 않는 프로그래밍 언어가 많았지만, 현재는 대다수의 언어들이 예외를 지원한다. 오류 플래그나 오류 코드를 반환하는 것은, 함수를 호출하면 바로 오류를 확인해야 한다. 그런데 이 단계는 잊어버리기 쉽다. 반면 예외를 던지는 경우, 논리와 오류 처리 코드를 구분할 수 있어 호출자 코드를 깔끔하게 가져갈 수 있다.
참고: Golang의 경우 try-catch-finally 대신 에러를 리턴하는 형태로 예외 처리를 한다. 이유는 1) 예외 처리가 느리고 메모리를 더 사용하는 경향이 있기 때문이다. 예외가 일반적으로 스택을 포함하기 때문이다. 2) 이 오버헤드로 인해 컴파일 속도가 느려지는데, Go는 컴파일 시간에 중점을 둔 언어이다. 3) 예외가 자주 남용되어 조잡한 코딩 관행으로 이어진다. 선언되지 않고 문서화되지 않아 메서드가 함수가 잠재적으로 throw 하는 모든 예외를 인식하지도 못하거나, 다른 오류에 대해서도 한 가지 예외가 사용되기도 한다.
Go 개발자는 대신 오류가 발생하면 처리하는 것이 가장 좋다고 생각한다. 따라서 모든 오류에 대해 생각하고, 오류 사례들을 문서화하는 형태로 코드가 나타난다. 또한 예외 처리만이 이유는 아니지만 Java, c++에 비해 훨씬 빠르게 컴파일된다.
Try-Catch-Finally 문부터 작성하라
예외가 발생하는 경우 try-catch-finally 문으로 시작하자. 그러면 try 블록에서 무슨 일이 생기든, 호출자가 기대하는 상태를 정의하기 쉬워진다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램은 상태를 일관성 있게 유지해야 한다. 아래 예시를 통해 확인해 보자.
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}
먼저 위처럼 단위 테스트 코드를 작성한다. 이 테스트는 파일이 없을 때 예외를 던지는지 확인한다.
public List<RecordedGrip> retrieveSection(String sectionName) {
// 실제로 구현할 때까지 비어 있는 더미를 반환한다.
return new ArrayList<RecordedGrip>();
}
단위 테스트에 맞추어 위와 같은 코드를 작성한다. 이 함수는 예외를 던지지 않으므로 단위 테스트가 실패한다. 잘못된 파일 접근을 시도하게 구현을 변경하자.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
이제 없는 "invalid-file"에 대한 접근을 시도하므로 StorageException이 발생한다. 코드가 예외를 던지므로, 이제는 테스트가 성공한다. 이 시점에서 리팩터링이 가능해 진다. catch 블록에서 예외 유형을 좁혀 실제 FileInputStream 생성자가 던지는 예외를 잡는다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileNotFoundException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}
이처럼 먼저 강제로 예외를 일으키는 테스트 케이스를 작성하고, 테스트를 통과하게 코드를 작성하는 방법을 권장한다. 이러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 트랜잭션 본질의 유지가 쉬워진다.
미확인(unchecked) 예외를 사용하라
checked 예외는 컴파일 단계에서 확인되며 반드시 처리해야 된다. 반면 unchecked 예외는 실행 단계에서 확인되며 명시적인 처리를 강제하지 않는다.
과거에는 모든 오류를 checked로 처리하는 것이 멋진 아이디어로 여겨졌다. 메서드를 선언할 때는 메서드가 반환할 예외를 모두 열거했다.
하지만 확인된 예외는 OCP를 위반한다. 어떤 메서드에서 checked 예외를 던졌는데, catch 블록이 세 단계 위에 있다면 그 사이 메서드 모두가 선언부에 예외를 정의해야 한다. 즉, 하위 단계에서 코드의 변경이 일어나면 상위 단계 메서드 선언부를 모두 고쳐야 한다는 것이다.
대규모 시스템에서는 최상위 함수가 아래 함수를 호출하는 구조가 반복된다. 만약 최하위 함수에서 수정이 일어나 오류를 던지면, 최상위 레벨까지 모든 함수에서 catch 블록을 사용하거나 throw 절을 추가로 선언하여야 한다. 또한 throw 경로에 위치하는 모든 함수가 최하위 함수의 예외를 알아야 하므로 캡슐화도 깨진다.
public void func(bool flag) {
printA(flag);
}
public void printA(bool flag) {
if(flag)
System.out.println("called");
}
위에서 만약 printA가 정상 작동하지 않을 때 exception을 던지면, 이 함수를 호출하는 상위 함수인 func도 throws NotPrintException을 선언해야 한다. 아래처럼 말이다.
public void func(bool flag) throws NotPrintException {
printA(flag);
}
public void printA(bool flag) throws NotPrintException {
if(flag)
System.out.println("called");
else
throw new NotPrintException();
}
예외에 의미를 제공하라
예외를 던질 때 전후 상황을 충분히 덧붙이도록 한다. 호출 스택만으로는 실패한 코드의 의도가 잘 설명되지 않을 수 있다. 따라서 오류 메시지에 정보(실패한 연산 이름 및 유형 등)를 담아 던지도록 한다.
호출자를 고려해 예외 클래스를 정의하라
오류를 정의할 때, 개발자에게 가장 중요한 관심사는 오류를 잡아내는 방법이어야 한다.
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("Device response exception");
} finally {
...
}
위의 코드는 외부 라이브러리를 호출하고, 모든 예외를 호출자가 잡아낸다. 위 코드는 중복이 심하지만, 우리가 대다수 상황에서 오류를 처리하는 방식 그대로이다.
- 오류를 기록한다.
- 프로그램을 계속 수행해도 좋은지 확인한다.
그런데 조금만 생각해 보면, 호출하는 라이브러리 API를 감싸 한 가지의 예외 유형만 반환하도록 하면 이 중복들을 없앨 수 있다. 예외 유형에 관계없이 처리 방식이 거의 동일하기 때문이다.
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
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);
} catch (GMXError e) {
throw new PortDeviceFailure(e);
}
}
...
}
위의 LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 Wrapper 클래스이다. 이렇게 외부 API를 감싸면 여러 장점들이 있다.
- 외부 라이브러리-프로그램 사이의 의존성도 함께 줄어든다.
- 나중에 다른 라이브러리로 갈아타는 것이 쉽다.
- 외부 API를 직접 호출하는 대신 Wrapper 클래스에서 테스트 코드를 넣어 테스트하기 쉽다.
- 특정 업체의 API 설계 방식에 발목 잡히지 않고, 우리 프로그램이 사용하기 편리한 API를 정의하면 된다.
정상 흐름을 정의하라
지금까지 설명한 방법을 이용하면 예외 처리가 비즈니스 로직으로부터 구분되어 프로그램 언저리로 밀려난다. 외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리한다.
하지만 가끔은 중단이 적합하지 않을 때가 있다. 아래의 예시를 보자.
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch (MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
위의 로직은 직원이 식비를 비용으로 청구한 경우, 청구한 식비를 총계에 더한다. 아닌 경우 일일 기본 식비를 총계에 더한다. 하지만 예외 처리로 인해 논리 구조의 파악이 어렵다. 특수 상황을 처리하는 것을 별도의 코드로 분리하면 아래와 같이 간결해진다.
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// 기본값으로 일일 기본 식비를 반환한다.
}
}
getTotal 함수는 청구한 식비가 없는 경우 기본 일일 식비를 반환한다. 이로써 예외 처리 없이 정상적인 흐름으로 처리할 수 있다.
이렇게 클래스를 만들거나 객체를 조작해 처리하는 방법을 특수 사례 패턴이라 부른다. 클라이언트 코드는 더 이상 예외 상황에 대한 처리를 하지 않아도 된다.
null을 반환하지 마라
public void registerItem(Item item) {
if(item!=null) {
ItemRegistry registry = persistentStore.getItemRegistry();
if(registry != null) ...
}
}
위와 같이 특정 조건일 때 null을 반환하는 함수(getItemRegistry)를 이용한다고 하자. 이것은 호출자에게 매번 이 값이 null인지를 확인하는 코드를 넣는 추가적인 작업을 요구하며, 실수로 넣지 않는 경우 통제 불능 등의 치명적 문제가 생길 수 있다.
null을 반환하고 싶다면, 대신 예외를 던지거나 특수 사례 객체를 반환하도록 한다.
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
이렇게 빈 리스트를 반환하는 것이 낫다.
public List<Employee> getEmployees() {
if ( /*.. 직원이 없을 경우 .. */)
return Collections.emptyList();
}
}
또는 미리 정의된 읽기 리스트인 Collections.emptyList()를 반환하는 방법도 있다.
null을 전달하지 마라
메서드에서 null을 반환하는 것도 나쁘지만 null을 인자로 전달하는 것은 더 나쁘다. 정상적인 인자로 null을 기대하는 경우가 아니라면, 메서드로 null을 인자로 보내지 않도록 한다. null에 대한 추가적인 처리를 빼먹거나, NullPointerException 등의 예외로 불필요하게 코드가 길어질 수 있다.
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
assert p1 != null : "p1 should not be null";
assert p2 != null : "p2 should not be null";
return (p2.x - p1.x) * 1.5;
}
}
assert 문을 쓰면 문서화가 잘 되어 코드 읽기는 편할지라도, 누군가 null을 전달하면 여전히 실행 오류가 발생한다. 그냥 애초에 null을 넘기지 못하도록 금지하도록 하자.
결론
오류 처리를 프로그램 논리와 구분하여, 독립적인 추론이 가능하게 하고 유지보수성을 높이도록 한다.
'개발 팁 > 클린 코드' 카테고리의 다른 글
클린 코드 정리 8 - 경계 (1) | 2022.09.26 |
---|---|
클린 코드 정리 6 - 객체와 자료 구조 (1) | 2022.09.23 |
클린 코드 정리 5 - 형식 맞추기 (1) | 2022.09.23 |
클린 코드 정리 4 - 주석 (1) | 2022.09.22 |
클린 코드 정리 3 - 함수 (3) | 2022.09.22 |
댓글