7장 - 오류 처리

프로그램은 입력이 이상하거나, 기기에 문제가 생기는 등 뭔가 잘못될 가능성이 항상 존재한다. 따라서 프로그래머는 그 상황을 바로 잡을 책임이 있다.

하지만 흩어진 오류 처리 코드는 코드의 가독성을 낮출 수 있기에, 우아하고 고상하게 오류를 처리할 필요가 있다.

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

class DeviceController {
    // ...
    sendShutdown(): void {
        const handle: DeviceHandle = getHandle(DEV1);

        // 기기 상태 점검
        if (handle !== DeviceHandle.INVALID) {
            // 레코드 필드에 기기 상태 저장
            retrieveDeviceRecord(handle);

            // 기기가 일시 정지 상태가 아니면 종료
            if (record.getStatus() !== DEVICE_SUSPENDED) {
                pauseDevice(handle);
                clearDeviceWorkQueue(handle);
                closeDevice(handle);
            } else {
                logger.error("Device suspended. Unable to shut down");
            }
        } else {
            logger.error(`Invalid handle for: ${DEV1.toString()}`);
        }
    }
}

오류 플래그를 설정하거나 호출자에게 오류 코드를 반환하는 방법은 함수를 호출한 즉시 오류를 확인해야 하기 때문에 호출자 코드를 복잡하게 만든다. 심지어 이 단계를 잊어버리기도 쉽다.

class DeviceController {
    // ...
    sendShutdown(): void {
        try {
            tryToShutDown();
        } catch (error) {
            logger.error(error);
        }
    }

    tryToShutdown(): void {
        const handle: DeviceHandle = getHandle(DEV1);
        const record: DeviceRecord = retrieveDeviceRecord(handle);

        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }

    getHandle(id: DeviceId): void {
        // ...
        throw new Error(`Invalid handle for: ${id.toString()}`);
        // ...
    }
    // ...
}

오류가 발생했을 때 오류를 던지면 논리와 오류 처리 코드가 뒤섞이지 않아 호출자 코드를 깔끔하게 유지할 수 있다.

위 예시에선 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리해 각 개념을 독립적으로 살펴볼 수 있도록 리팩터링했다.

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

예외에서 프로그램 안에다 범위를 정의한다는 사실은 매우 흥미롭다. try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단되고 catch 블록으로 넘어갈 수 있다.

어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 에 외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다. 그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대 상태를 정의하기 쉬워진다.

it("Retrieve section should throw on invalid file name", () => {
    expect(() => {
        retrieveSection("invalid - file");
    }).toThrow();
});

파일을 열어 직렬화된 객체 몇 개를 읽어 들이는 코드를 구현하기 위해 단위 테스트를 구현했다.

function retrieveSection(sectionName: string): RecordedGrip {
    return [] as RecordedGrip;
}

단위 테스트에 맞춰 위 코드를 구현했으나, 오류를 던지지 않으므로 단위 테스트는 실패한다.

function retrieveSection(sectionName: string): RecordedGrip {
    try {
        const stream = fs.openSync(sectionName);
    } catch (e) {
        throw new Error("retrieval error");
    }

    return [] as RecordedGrip;
}

잘못된 파일 접근을 시도하여, 오류를 던지도록 구현을 변경했다. 이제 테스트가 성공하고, 이 시점에서 리팩터링이 가능하다.

function retrieveSection(sectionName: string): RecordedGrip {
    try {
        const stream = fs.openSync(sectionName);
        fs.closeSync(stream);
    } catch (e) {
        throw new Error("retrieval error");
    }

    return [] as RecordedGrip;
}

try-catch 구조로 범위를 정의했으므로 TDD를 사용해 필요한 나머지 논리를 추가한다. 나머지 논리는 openSynccloseSync 사이에 넣으며, 오류나 예외가 전혀 발생하지 않는다고 가정한다.

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

예외에 의미를 제공하라

오류를 던질 때는 전후 상황을 충분히 덧붙여 오류가 발생한 원인과 위치를 찾기 쉽도록 하자. 자바스크립트는 모든 오류에 호출 스택을 제공하긴 하지만, 실패한 코드의 의도를 파악하기엔 호출 스택으론 역부족이다.

오류 메시지에 실패한 연산 이름, 실패 유형 등을 담아 던지도록 하자.

const images = [
    "https://i.imgur.com/pnEPoyq.gif",
    "https://i.imgur.com/3K3TIIS.gif",
    "https://i.imgur.com/PfJXwd8.gif",
    "https://i.imgur.com/Bra4jgp.gif",
    "https://i.imgur.com/v0qRJlZ.gif",
    "https://i.imgur.com/EPWdCbp.gif",
];

const imagesWithError = [
    "https://i.imgur.com/pnEPoyq_invalid.gif",
    "https://i.imgur.com/3K3TIIS_invalid.gif",
    "https://i.imgur.com/PfJXwd8_invalid.gif",
    "https://i.imgur.com/Bra4jgp_invalid.gif",
    "https://i.imgur.com/v0qRJlZ_invalid.gif",
    "https://i.imgur.com/EPWdCbp_invalid.gif",
];

function loadImage(src) {
    return new Promise((resolve, reject) => {
        const image = new Image();

        image.src = src;

        image.addEventListener(
            "load",
            () => {
                resolve(image);
            },
            { once: true },
        );

        image.addEventListener(
            "error",
            () => {
                reject(new Error(`Invalid image: ${src}`));
            },
            { once: true },
        );
    });
}

function main() {
    Promise.all(images.map(loadImage)).then((result) => {
        console.log(result);
    });

    Promise.all(imagesWithError.map(loadImage)).then((result) => {
        console.log(result);
    });
}

main();

Promise 오류

비동기적인 처리 중 오류가 발생하면 스택이 사라지던 시절이 있었으나, 다행히도 이젠 옛말이 되었다.
throwrejectError 객체 외의 것을 사용하면 여전히 스택이 사라질 수 있으니, 항상 Error 객체를 사용하는 것만 유념하도록 하자.

정상 흐름을 정의하라

비즈니스 논리와 오류 처리를 잘 분리해 코드를 작성하면 코드 대부분이 깨끗하고 간결한 알고리즘으로 보이기 시작한다. 하지만 그러다 보면 오류 감지가 프로그램 언저리로 밀려난다.

때로는 오류를 던지고 중단된 계산을 처리하는 게 적합하지 않을 때도 있다.

try {
    const expenses: MealExpenses = expenseReportDAO.getMeals(employee.getId());
    total += expenses.getTotal();
} catch (error) {
    total += getMealPerDiem();
}

식비를 비용으로 청구했다면 청구한 식비를 총계에 더하고, 청구하지 않았다면 일일 기본 식비를 더하는 코드이다. 간단한 코드이지만, try-catch 문이 논리를 따라가기 어렵게 만든다.

const expenses: MealExpenses = expenseReportDAO.getMeals(employee.getID());
total += expenses.getTotal();

expenseReportDAO를 수정해 식비를 청구하지 않았을 때도 일일 기본 식비를 반환하는 MealExpense 객체를 반환하면 예외처리 없이 간결한 코드가 완성된다.

이를 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 특수 사례 패턴Special Case Pattern1이라 부른다. 이 패턴을 사용하면 클래스나 객체가 예외적인 상황을 캡슐화해서 처리하기에 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다.

null을 반환하거나 전달하지 마라

null의 반환

function registerItem(item: Item) {
    if (item !== null) {
        const registry: ItemRegistry = persistentStore.getItemRegistry();

        if (registry !== null) {
            const existing: Item = registry.getItem(item.getId());

            if (existing.getBillingPeriod().hasRetailOwner()) {
                existing.register(item);
            }
        }
    }
}

null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘긴다. null 확인을 빼먹는 순간 애플리케이션이 정지되거나, 혹은 더 안 좋은 상황에 부닥치게 될지도 모른다.

위 코드에서도 persistentStore에 관한 null 확인이 빠져있는데, 코드를 읽으며 눈치챘는가? 애플리케이션 저 아래서 날린 null pointer exception을 처리하기란 쉽지 않다.
이렇게 말하면 null 확인이 누락된 문제라 이해하기 쉬우나, null 확인이 너무 많아 나쁜 코드다. null을 반환하고 싶을 땐 예외를 던지거나 상술한 특수 사례 패턴을 사용해보자.


JavaScript엔 Optional Chaining이 있으니 이것도 확인해보자.

const registry: ItemRegistry = persistentStore?.getItemRegistry();

위와 같이 작성하면 persistentStore.getItemRegistry와 유사하게 작동하나, 참조가 nullish일 때 에러가 발생하지 않고 표현식의 반환 값을 undefined로 단락한다.

null의 전달

정상적인 인수로 null을 기대하는 API가 아니라면 null을 전달하는 코드는 최대한 피하도록 하자.

function calculateDistance(p1: Point, p2: Point): number {
    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}

위 코드는 좌표평면에서 두 점 사이의 거리를 구하는 간단한 함수다.
만약 Point 타입이 nullish일 수 있다면 위 코드는 오류가 발생하게 된다.

function calculateDistance(p1: Point, p2: Point): number {
    if (!p1 || !p2) {
        throw new Error("Invalid argument for calculateDistance");
    }

    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}

과연 이렇게 nullish를 확인하는 코드가 나을까?

대다수 프로그래밍 언어는 호출자가 실수로 넘긴 null을 적절히 처리하는 방법이 없다. 애초에 null을 넘기지 못하도록 하면 인수로 null이 넘어오면 코드에 문제가 있다는 말이 된다. 이런 정책은 부주의한 실수를 저지를 확률을 낮춰준다.

NodeBack 스타일 콜백

if (callback) {
    if (success) {
        callback(null, value);
    } else {
        callback(error, null);
    }
}

null의 반환과 전달 모두 바람직하지 않단 건 알겠으나, 어디에나 예외는 있는 법이다.
NodeBack 스타일에선 에러를 null로 전달하는 관습이 있다. 상황과 표준에 맞게 유연하게 작업하도록 하자.

null을 전달하지 마라

결론

'읽기 좋은 코드'와 '안정성 높은 코드'는 상충하는 목표가 아니다. 깨끗한 코드는 가독성뿐 아니라 안정성도 높아야 한다. 오류 처리를 프로그램 논리와 분리해 깨끗한 코드를 작성하도록 하자.

Footnotes

  1. https://martinfowler.com/eaaCatalog/specialCase.html