7. 오류 처리

Posted by yunki kim on January 18, 2022

  깨끗한 코드와 오류 처리는 연관성이 있다. 오류 처리 코드가 흩어져 있으면 실제 코드가 하는 일을 파악하기 거의 불가능 하다. 또 한 오류처리 코드로 인해 프로그램 논리를 이해하기 어려워 질 수 있다. 따라서 다음과 같은 방법을 통해 제대로된 오류 처리를 하면서도 깨긋한 코드를 작성해야 한다.

 

오류 코드보다 예외를 사용하라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DeviceController {
    public void sendShutDown() {
        DeviceHandle handle = getHandle(DEV1);
        // 디바이스 상태를 점검한다
        if (handle != DeviceHandle.INVALID) {
            // 레코드 필드에 디바이스 상태를 저장한다.
            retrieveDeviceRecord(handle);
            // 디바이스가 일시정지 상태가 아니라면 종료한다.
            if (record.getStatus() != DEVICE_SUSPENDED) {
                pauseDevice(handle);
                clearDeviceWorkQueue(handle);
                closeDevice(handle);
            } else {
                logger.log("Device suspended. Unable to shuot down");
            }
        } else {
            logger.log("Invalid handle for: " + DEV1.toString());
        }
    }
}
cs

  위와 같은 코드는 함수를 호출하는 즉시 오류를 확인해야 해서 호출자 코드가 복잡해진다. 따라서 오류가 발생하면 예외를 던지는 것이 좋다. 그러면 호출자 코드가 더 깔끔해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DeviceController {
    public void sendShutDown() {
        try {
            tryToShutDown();
        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }
 
    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
 
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }
 
    private DeviceHandle gethandle(DeviceID id) {
        ...
        throw new DeviceShutDownError("Invalid handle for:" + id.toString());
        ...
    }
}
cs

 

try-catch-finally 문부터 작성하라

  try 블록에서 무슨 일이 생이든 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 이때 try-catch-finally문을 먼저 작성하면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

 

미확인(unchecked) 예외를 사용하라

  예외는 크게 checked Exception, unchecked Exception 두 개로 나뉜다. Checked Exception은 컴파일 시점에 체크된 예외로 RuntimeException을 상속하지 않는다. 따라서 반드시 명시적으로 처리해야 한다. checked exception은 다음과 같은 종류가 있다.

    1. IOException

    2. SQLException

    3. DataAccessException

    4. ClassNotFoundException

    5. InvoactionTargetException

    6. MalformedURLException

  Unchecked exception은 다음과 같은 종류가 있다.

    1. NullPointerException

    2. ArrayIndexOutOfBound

    3. IllegalArgumentException

    4. IllegalStateException

  현대 소프트웨어를 제작하기 위해서 checked exception이 반드시 필요하지 않다. 따라서 checked exception이 치르는 비용에 상응하는 이익을 제공하는지를 고려해야 한다.

  Checked exception은 개방 폐쇄 원칙(Open Closed Principle)을 위반한다. 메서드가 확인된 예외를 던졌는데 catch 블록이 여러 단계 아래에 있다면 상위 메서드들은 모두 해당 예외를 정의해야 한다. 또 한 하위 단계의 코드가 수정되면 상위 단계의 코드 역시 수정해야 한다. 따라서 throws 경로에 존재하는 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하기 때문에 캡슐화가 깨진다. 

  아주 중요한 라이브러리를 작성한다면 모든 예외를 잡는게 올바르지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크다.

 

예외에 의미를 제공하라

  예외를 던질 때는 전후 상황을 충분히 덧붙여야 오류 발생 원인과 위치를 찾기 쉬워진다. 물론 자바가 모든 예외에 대해 호출 스택을 제공하지만 이걸로는 부족하다.

  오류 메시지에 실패한 연산 이름과 실패 유형을 언급하라.

 

호출자를 고려해 예외 클래스를 정의하라

  오류를 분류하는 방법은 컴포넌트 기중, 유형을 기준으로 분류 등 아주 다양하다. 하지만 애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아낸는 밥법이 되야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 reponse exception");
finally {
    ...
}
cs

  위 코드를 보면 오류 처리를 위해 코드에 중복이 발생한다. 만약 호출하는 라이브러리 API를 감싸(wrapper)면 코드가 다음과 같이 깔끔해 진다. 아래 예제에서 LocalPort는 그저 ACMEPort가 던지는 클래스를 잡아 변환하는 래퍼 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Local 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 (ATM121UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        }
    }
 
    ...
}
cs

  이런 식으로 외부 API를 감싸면 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어든다. 따라서 나중에 다른 API로 갈아타도 비용이 적다. 또한 외부 API를 호출하느 대신 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기도 쉬워진다.

 

정상 흐름을 정의하라

  위에서 말한 바와 같이 비즈니스 논리와 오류 처리가 잘 분리된 코드는 깨끗하고 간결하다. 하지만 그러다 보면 오류 감지가 프로그램 언저리로 밀려난다. 외부 API를 감싸 독자적인 예외를 던지고 코드 위에 처리기를 정의해 중단된 계산을 처리한다. 이 방식은 좋지만 때로는 중단이 적합하지 않다.

  아래의 예제는 비용 청구 애플리케이션에서 총계를 계산하는 코드다. 

1
2
3
4
5
6
7
8
try {
    // 식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더한다
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    total += expenses.getTotal();
catch (MealExpensesNotFound e) {
    // 식비를 비용을 청구하지 않았다면 일일 기본 식비를 총계에 더한다
    total += getMealPerDiem();
}
cs

  만약 위 코드에서 expensesReportDAO를 수정해서 언제나 MealExpense를 반환하게 한다면, 청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환하게 한다면 코드는 다음과 같이 깔끔해 진다.

1
2
3
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
total += expenses.getTotal();
 
cs

  이를 특수 사례 패턴(Special Case Pattern)이라 한다. 이 패턴은 클래스를 만들거나 객체르 조작해 특수 사례를 처리한다. 그러면 클라이언트 코드가 예외적인 상황을 처리하지 않아도 된다.

 

null을 반환하지 마라

  null을 반환하는 코드는 일거리를 늘리고 호출자에게 문제를 떠넘긴다. 하나라도 null을 확인하지 않는다면 애플리케이션은 통제불능이 된다. 메서드에서 null을 반환하지 말고 예외를 던지거나 special case pattern을 사용하라. 외부 API가 null을 반환한다면 래퍼 클래스를 구현해 예외를 던지거나 special case pattern을 사용하는 방식을 고려하라.

  아래의 예제를 보자. 이예제의 getEmployees()는 굳이 null을 반환하지 않아도 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//개선 전
List<Employee> employees = getEmployees();
if (employee != null) {
    for (Employee e : employees) {
        totalPay != e.getPay();
    }
}
 
//개선 후
public List<Employee> getEmployees() {
    // 직원이 없다면
    if(employees.isEmpty()) {
        // 빈 리스트 반환
        return Collections.emptyList();
    }
}
 
List<Employee> employees = getEmployees();
for (Employee e : employees) {
    totalPay != e.getPay();
}
 
cs

 

null을 전달하지 마라

  정상적인 인수로 null을 기대하는 경우가 아니라면 메서드에 null을 인자로 넘기지 말라. Null을 인자로 넘기면 어떤 식으로든 예외를 처리해야 하는 코드가 생긴다. 대다스 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다. 따라서 애초에 null을 넘기지 못하게 금지하는 정책이 합리적인다. 

 

출처 - 클린 코드