혼자 정리

7. 오류 처리 본문

클린코드

7. 오류 처리

tbonelee 2021. 10. 12. 01:00

7장 오류 처리

오류 처리는 중요하지만 오류 처리 코드 때문에 프로그램의 논리를 파악하기 어려워지는 것이 좋지 못하다.

7장에서는 이를 방지하는 기법들을 살펴 본다.

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

예외 던지는 것을 지원하지 않던 프로그래밍 언어에서는 다음처럼 오류 코드를 통해 오류를 처리해야 했다.

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

하지만 이러한 방식은 함수 호출자가 호출 즉시 오류를 확인해야 하므로 호출한 쪽의 코드가 복잡해진다. 따라서 논리 파악이 힘들어진다.

대신 다음처럼 오류 발견시 예외를 던지자

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 블럭에서 무슨 일이 생기든지 catch 블럭은 프로그램 상태를 일관성 있게 유지해야 한다.

따라서 예외를 던지는 코드를 짤 때는 try-catch-finally 문으로 시작하면 try 블럭에서 무슨 일이 생기든지 catch 블럭을 통해 호출자가 기대하는 상태를 정의하기 쉬워진다.

구체적으로는 다음의 스텝을 따르는 것이 좋다.

  1. 먼저 예외를 일으켜야 통과하는 테스트 케이스를 작성한다.
  2. 예외를 일으키는 코드를 try문 안에 작성하고 catch문에 예외 처리를 추가
  3. 테스트를 통과하면 필요에 따라 예외 범위를 좁혀 나간다.
  4. 코드에 추가해야 될 로직이 있으면 이 때 추가.

예시를 통해 살펴보자.

다음은 파일이 없으면 예외를 던지는지 알아보는 단위 테스트다.

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

단위 테스트에 맞춰 다음 코드를 구현한다. 하지만 아직 예외를 던지지 않으므로 단위 테스트는 실패한다.

public List<RecordedGrip> retrieveSection(String sectionName) {
    // dummy return until we have a real implementation
    return new ArrayList<RecordedGrip>();
}

이제 예외를 던지는 코드를 구현하고 가장 넓은 범위의 예외를 catch한다.

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

예외의 범위를 좁혀서 FileInputStream생성자가 던지는 FileNotFoundException을 잡아내자.

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

미확인 예외를 사용하라(Use Unchecked Exceptions)

checked exception은 모든 예외를 잡게 해준다는 장점이 있다.

하지만 critical한 라이브러리가 아닌 이상 checked exception으로 인해 생기는 모듈간 의존성은 큰 비용이 될 수 있다.

미확인 예외는 OCP(Open Closed Principle; 개방-폐쇄 원칙)를 위반한다.

메서드에서 확인된 예외를 던졌는데 catch 블럭이 세 단계 위에 있다면 그 사이에 있는 메서드 모두 선언부에 해당 예외를 정의해야 한다.

즉, 하위 단계의 예외 추가로 상위 단계 메서드 선언부를 전부 고쳐야 한다. 그러면 모듈과 관련된 코드가 전혀 바뀌지 않았더라도 (선언부가 바뀌었으므로) 모듈을 다시 빌드한 다음 배포해야 한다.

캡슐화의 관점에서 보면 catch블럭과 예외를 추가한 하위 메서드 사이에 있는 함수 모두가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.

오류를 원거리에서 처리하기 위해 예외를 사용한다는 사실을 감안하면 checked exception은 목적에 반한다고 볼 수 있다.


예외에 맥락을 제공하라

예외를 던질 때는 전후 상황을 충분히 붙여주자. 그래야 오류가 발생한 원인과 위치를 찾기 쉬워진다.

기본적으로 제공되는 호출 스택 외에도 실패한 연산 이름과 실패 유형 등도 언급해주자.

logging을 사용한다면 catch블럭에서 기록할 수 있게 충분한 정보를 넘겨주자.


Caller의 니드를 고려해서 예외 클래스를 정의하라

오류를 정의할 때 어떻게 분류할지 고민하는 경우가 많다.

그렇지만 오류 정의할 때 최우선적인 고민은 어떻게 오류를 잡아낼지가 되어야 한다.

아래 코드는 외부 라이브러리가 던질 예외를 모두 잡아낸다.

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

LocalPort클래스는 단순히 ACMEPort클래스가 던지는 예외를 잡아 변환하는 wrapper 클래스일 뿐이다.

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 방식에 발목 잡히지 않는다.

또한 wrapper 클래스에서 외부 API 호출하는 대신 테스트 코드를 넣어주는 식으로 프로그램 테스트하기도 용이하다.

위 코드처럼 예외를 하나로 처리할지 나눠서 처리해도 되는 경우가 많다. 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우가 그렇다.

한 예외는 잡고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용해서 구분하자.


Define the Normal Flow

앞선 방법들을 사용해 오류 처리를 깔끔하게 할 수 있지만 예외가 있다면 논리를 따라가기 어려운 게 사실이다.

특수 상황을 처리할 필요가 없다면 훨씬 간결해질 수 있다.

다음 예시는 비용 청구 앱에서 총계를 계산하는 코드다.

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

식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더하지만 청구하지 않았다면 일일 기본 식비를 총계에 더한다.

ExpenseReportDAO를 고쳐서 언제나 MealExpense객체를 반환하게 하자. 청구한 식비가 없으면 일일 기본 식비를 반환하는 MealExpense객체를 반환한다.

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
public class PerDiemMealExpenses implements MealExpenses {
    public int getTotal() {
        // return the per diem default
    }
}

이러한 처리를 특수 사례 패턴(Special Case Patter)이라 부른다.

클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식이다.

클래스나 객체가 예외적 상황을 캡슐화해서 처리하므로 클라이언트 코드가 예외적 상황을 처리할 필요가 없어진다.


Null을 반환하지 마라

오류 처리만큼 중요한 것이 오류를 유발하는 행위를 안하는 것인데 그 중 하나가 Null을 반환하는 것이다.

Null을 반환하는 코드는 호출자에게 Null 체크 문제를 떠넘긴다.

체크를 안 하면 NullPointerException을 유발할 수 있다.

Null 체크 확인을 빼먹은 것이 문제라 볼 수 있지만 그럴 일을 만든 것이 문제다.

Null을 반환하고 싶다면 대신 예외를 던지거나 특수 사례 객체를 반환하게 하자. 사용하려는 외부 API가 Nul을 반환한다면 wrapper 메서드를 통해 예외를 던지거나 특수 사례 객체를 반환하게 하자.

Null을 반환하면 다음처럼 체크를 빼먹은 코드를 만들기 쉽다

public void registerItem(Item item) {
    if (item != null) {
        ItemRegistry registry = peristentStore.getItemRegistry();
        if (registry != null) {
            Item existing = registry.getItem(item.getID());
            if (existing.getBillingPeriod().hasRetailOwner()) {
                existing.register(item);
            }
        }
    }
}

다음에서 getEmployees는 null을 반환한다.

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

하지만 빈 리스트를 반환하게 하면 코드가 훨씬 깔끔해진다.

List<Employee> employees = getEmployees();
for(Employee e : employees) {
    totalPay += e.getPay();
}
public List<Employee> getEmployees() {
    if( .. there are no employees .. )
        return Collections.emptyList();
}

메서드에 Null을 전달하지 마라

메서드에 null을 넘기면 메서드에서 처리할 일이 많아진다. 처리하지 않으면 NullPointerException 예외가 발생할 것이다.

null 체크를 해서 새로운 예외를 던지는 방법, assert문을 사용하는 방법 등이 있을 수 있다. 하지만 제일 좋은 것은 애초에 인수로 null을 넘기는 것을 금지하는 정책을 합의하면 불필요한 null 처리 코드를 넣을 필요도 없고 인수로 null을 넘기는 부주의한 실수를 저지를 확률도 작아진다.


결론

클린 코드는 가독성과 안정성 모두 가져가야 한다.

오류 처리를 프로그램 로직과 분리하면 가독성도 좋아지며 오류 처리를 통한 안정성도 유지할 수 있다.

'클린코드' 카테고리의 다른 글

[클린코드] 10. 클래스  (2) 2021.11.02
9장. Unit Test  (0) 2021.10.28
6. 객체와 자료구조  (0) 2021.10.10
5. 형식 맞추기  (0) 2021.10.06
4. 주석  (0) 2021.09.27