Clean Code - 오류 처리

Park Suyong·2022년 1월 7일
0

Study

목록 보기
8/12

"깨끗한 코드를 다루는 데 있어 오류 처리는 매우 중요한 요소이다. 오류 처리 코드로 인해 프로그램의 논리를 이해하기 어렵다면, 깨끗한 코드라 부르기 어렵다."

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

"오류가 발생하면 예외를 던져라"

코드를 작성할 때 오류를 처리하고 보고하는 코드값을 만드는 것이 아니라 예외를 던지는 것이 좋은 방법이다. 그렇게 되면 호출자 코드가 더 깔끔해지며, 논리가 오류 처리 코드와 뒤섞이지 않는다.

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

"예외가 발생할 코드를 작성할 때는 try-catch-finally 문으로 시작하는 편이 낫다. 그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다."

코드를 작성할 때 TDD(Test Driven Development)를 사용한다. TDD는 테스트 주도 개발로, 테스트 코드를 먼저 작성한 뒤 리팩토링을 통해 실제 코드를 작성하는 방법론이다.

try-catch-finally문을 사용하여 테스트 코드를 작성하고 테스트 성공 시 리팩토링을 통해 코드를 수정, 보완, 개발한다. 즉, 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하라는 것이다.

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

"미확인 예외란, 런타임 시에 발생하는 에러이다. 즉, 코드를 작성할 때 강제하지 않는 에러다. 반대로 확인된 예외는 컴파일 시에 발생한다."

확인된(checked) 예외의 경우에는 OCP(Open Closed Principle)를 위반한다. 만약 코드가 종속 함수로 구성되어 있다고 하자. 가장 하위 계층에 있는 함수에서 확인된 예외를 던지게 되는 경우, 상위 계층의 모든 함수 코드가 수정되어야 한다. 캡슐화가 깨지게 되는 순간이기도 하다.

예외에 의미를 제공하라

"예외를 던질 때는 전후 상황을 충분히 덧붙인다. 오류가 발생한 원인과 위치를 찾기 쉬워질 것이다. 흔히, 오류 메시지에 정보를 담아 예외와 함께 던진다. 추가적으로 실패한 연산 이름과 실패 유형도 언급한다."

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

"오류를 분류하는 방법은 많다. 오류가 발생한 위치(컴포넌트), 오류 발생 유형 등으로 분류할 수 있다. 다만, 개발자는 오류를 잡아내는 방법에 제일 큰 관심을 두어야 한다."

오류를 형편없이 분류하는 대표적인 사례로, 외부 라이브러리를 호출하는 try-catch-finally 문을 포함한 코드에서 외부 라이브러리가 던질 예외를 모두 일일히 잡아내는 것이다.

val port = 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)
}
finally {
    ...
}
...

이 코드는 중복도 매우 심하다. 하지만, 고치는 것은 비교적 쉽다. 호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하면 된다.

val port = LocalPort(12)
try {
    port.open()
} catch(PortDeviceFailure e) {
    reportError(e)
    logger.log(e.getMessage(), e)
} finally {
    ...
}
...

여기서 LocalPort는 단순하게 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 감싸기(Wrapper) 클래스일 뿐이다.

LocalPort 클래스는 아래와 같다.

class LocalPort(portNumber : Int) {
    private val innerPort = ACMEPort(portNumber)

    fun open() {
        try {
            innerPort.open()
        } catch (e : DeviceResponseException) {
            throw PortDeviceFailure(e)
        } catch(e : ATM1212UnlockedException) {
            throw PortDeviceFailure(e)
        }
        ...
    }
}

LocalPort 클래스처럼 ACMEPort감싸는 클래스(Wrapper Class)는 매우 유용하다.
-> 외부 API를 감싸면 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어들 수 있다.
-> 또한, 감싸기 클래스(Wrapper Class)에서 외부 API를 호출하는 대신 테스트 코드를 넣어 주는 방법으로 테스트하기도 쉬워진다.
-> 마지막으로, 감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식에 발목 잡히지 않을 수 있다. 프로그램이 사용하기 편리한 API를 정의하면 된다. 위 코드에서는 port device fail 유형 하나를 정의했음에도 코드가 매우 깨끗해졌다.

흔히 예외 클래스 하나만 있어도 충분한 코드가 많다.

정상 흐름을 정의하라

"위와 같은 방식으로 비즈니스 로직과 오류 처리 코드를 잘 분리한다면 코드가 간결하고 깨끗해진다. 다만, 그러다 보면 오류 감지가 프로그램 언저리로 밀려날 수 있다."

예제를 살펴 보자.

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

위 코드는 식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더하며, 식비를 비용으로 청구하지 않았을 경우 일일 기본 식비를 총계에 더한다. 오히려 예외가 논리를 따라가기 어렵게 만들었다.

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

이처럼 간결한 코드로 재탄생시킬 수 있다. ExpensesReportDAO를 변경하여 항상 MealExpense 객체를 반환하게 한다. 청구한 식비가 없는 경우에는 일일 기본 식비를 반환하는 MealExpense 이다.

public class PerDiemMealExpenses implements MealExpenses {
    public int getTotal() {
        // 기본값으로 일일 기본 식비를 반환한다.
    }
}

이러한 방식을 특수 사례 패턴(Special Case Pattern)이라고 한다. 클래스를 만들거나 객체를 조작하여 특수 사례를 처리하는 방식이다. 클래스나 객체가 예외적인 상황을 캡슐화해 처리하므로 클라이언트 코드가 예외적인 상황을 처리할 필요가 없게 만들어 준다.

null을 반환하지 마라

"null을 반환하는 코드는 일거리를 늘릴 뿐 아니라 호출자에게 그 문제를 떠넘기게 된다. 만약 누군가 null check 과정을 깜빡하게 된다면 프로그램이 통제 불능에 빠질 수 있다."

만약 null을 반환하고자 한다면, null을 반환하는 대신 특수 사례 객체(Special Case Object)를 반환하도록 하자.
감싸기 메소드(Wrapper Method)
구현해 예외를 던지거나 특수 사례 객체를 반환하는 방식을 고려한다.

null을 전달하지 마라

"null을 반환하는 습관도 매우 좋지 않지만, null을 전달하는 것은 더 나쁘다. 정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피하도록 한다."

예제를 보도록 하자.

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

위 코드에서 만약 누군가 calculator.xProjection(null, new Point(12, 13));과 같이 전달한다면 NullPointerException이 발생하게 될 것이다.

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

이 코드가 기존의 코드보다 나은 점이 있을까? 물론, 오히려 NullPointerException보다는 나을 수 있다. 다만, InvalidArgumentException을 잡아내야 한다. 어떻게 해야 할까?

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는 조건식이 참이라면 실행 시 무시되며, 거짓일 경우 지정된 코드가 실행되는 키워드이다.

assert 키워드를 통해 null값이 들어오게 되는 경우에 대비해 처리했으나, 여전히 실행 오류는 막기 어렵다.

결론적으로, 최대한 null을 전달하지 않도록 금지하는 규칙이 필요하다.

profile
Android Developer

0개의 댓글