Clean Code 7. Error Handling


깨끗한 코드와 오류 처리는 확실히 연관성이 있다. 여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 파악하기 어려워진다면 깨끗한 코드라 부르기 어렵다.

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

Error를 반환하면 함수 호출 즉시 그 처리를 해야하므로 논리와 오류 처리 코드가 뒤섞이게 된다.

Bad : using return error
// Bad
public class DeviceController {
  ...
  public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    // Check the state of the device
    if (handle != DeviceHandle.INVALID) {
      // Save the device status to the record field
      retrieveDeviceRecord(handle);
      // If not suspended, shut down
      if (record.getStatus() != DEVICE_SUSPENDED) {
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
      } else {
        logger.log("Device suspended. Unable to shut down");
      }
    } else {
      logger.log("Invalid handle for: " + DEV1.toString());
    }
  }
}
Good : Exception
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());
    ...
  }
}

Try-Catch-Finally 문부터 작성하라

그러면 try블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다. 그러면 자연스럽게 try블록의 트랜잭션 범위부터 구현하게 되므로 범위 내 트랜잭션 본질을 유지하기 쉬워진다.

@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("invalid - file");
}

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>();
}

그런 뒤 예외 유형을 좁혀 실제 예외를 찾아내면서 리펙토링한다.

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>();
}

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

확인된 예외는 OCP(Open Closed Principle)를 위반한다. 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다. throw 경로에 위치하는 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.

예외에 의미를 제공하라

예외에 정보를 충분히 담아서 던지면, 오류가 발생한 원인과 위치를 찾기 쉬워진다.

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

오류를 분류할 수 있는 방법은 많다.

  • 발생한 위치
  • 발생한 컴포넌트
  • 유형 : 네트워크 실패, 디바이스 실패, 프로그래밍 오류 등

하지만 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.

외부 라이브러를 그대로 사용한 경우에는 외부 라이브러리가 던질 예외를 모두 잡아야 한다.

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 {
...
}

우리가 오류를 처리하는 방식은 오류 종류와 무관하게 비교적 일정하다

  1. log를 남긴다
  2. 계속 수행가능한지 확인한다

위 경우에도 예외 유형과 무관하게 모두 동일했다. 이 경우 외부 라이브러리를 호출하는 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);
        }
    }
}

외부 API를 감싸는 클래스는 매우 유용하다. 외부 API와 프로그램 사이에 의존성이 크게 줄어든다.

  • 나중에 다른 라이브러리로 갈아타기기 쉽다.
  • 외부 API 호출 대신 테스트 코드를 넣어주는 방법으로 테스트 하기도 쉬워진다.
  • 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다. 프로그램이 사용하기 편리한 API를 정의하면 그만이다.

정상 흐름을 정의하라 (Default값을 만들어라)

catch문에서 예외를 처리하는 경우 코드가 지저분해지는 일이 발생할 수 있다.

try {
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
    m_total += getMealPerDiem();
}

식비를 비용으로 청구했다면 그걸 더하고, 아니면 일일 기본 식비를 더하는 코드이다. 만약 청구식비가 없으면 일일 기본 식비를 반환하도록 DAO를 수정하면 아래와 같이 간결하게 된다.

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

Martin Fowler의 Special Case Pattern

  • 코드를 부르는 입장에서 예외적인 상황을 신경쓰지 않아도 된다.
  • 예외상황은 Special Case Object 내에 캡슐화된다.

null을 반환하지 마라

null을 반환하면 호출자가 null을 확인해야 한다. null확인 코드로 가득한 화면을 계속봐야 한다. 이건 호출자에게 문제를 떠넘기는 행위이다. 메서드에서 null을 반환하고픈 유혹이 든다면 그 대신 exception을 던지거나 SCO (special case object)를 반환해라. 외부 API가 null을 반환한다면 감싸기 메서드를 구현해라.

List<Employee> employees = getEmployees();
if (employees != null) {
    for(Employee e : employees) {
        totalPay += e.getPay();
    }
}

위 예의 경우 null이 아닌 빈 리스트를 반환한다면 더 깨끗해진다.

List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}

인수로 null을 전달하지 마라

인수로 null을 전달하면 함수 내에서 null 검사를 해야한다.

double xProjection(Point p1, Point p2) {
    if(p1 == null || p2 == null){
        throw InvalidArgumentException("Invalid argument for MetricsCalculator.xProjection");
    }
    return (p2.x - p1.x) * 1.5;
}

다른 대안으로 assert문을 사용하는 방법도 있지만, 여전히 NullPointException 문제를 해결해 줄 수는 없다.

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;
}

애초에 null을 넘기지 못하도록 금지하는 정책이 합리적이다.

double xProjection(Point p1, Point p2) {
    return (p2.x - p1.x) * 1.5;
}

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)