본문 바로가기
IT 공부/객체지향 설계 공부

코드 컴플리트 8장 - 방어적 프로그래밍 요약

by exdus3156 2023. 11. 23.
8장은 방어적 프로그래밍을 다룬다. 방어적 프로그래밍이란 마치 고속도로에서 운전할 때 앞 차량과 안전 거리를 유지하는 것과 비슷하다. 설령 다른 모듈에 의해 야기된 잘못이라도 루틴이 문제 없이 동작하도록 대처하는 프로그래밍을 말한다. 즉, 다른 출처의 실수로부터 자신의 로직을 보호하는 것이다.

 

1. 잘못된 입력으로부터 보호

소프트웨어 공학에서는 "Garbage In, Garbage Out" 이라는 유명한 말이 있다. 잘못된 데이터가 입력되면 잘못된 결과가 초래된다는 뜻이다. 그러나 쓰레기가 들어왔다고 아무것도 하지 않고 쓰레기를 뱉어내어선 안 된다. 쓰레기가 입력되면 그에 맞는 대처를 해야 좋은 소프트웨어다.

 

1-1. 외부로부터 들어오는 모든 데이터의 값을 검사하라.

파일, 사용자 입력, 네트워크 통신, .. 등 외부 인터페이스로부터 데이터를 전달받을 때는 항상 허용 가능한 데이터인지 검사해야 한다. 숫자라면 허용 범위 안에 있는지, 문자열이라면 크기가 범위 안에 있는지, 목적에 부합하는 데이터 타입이 맞는지, ... 등을 확인한다. SQL 주입과 같이 보안에 있어 문제가 될 수 있는 것도 예측해서 미리 검사해야 한다.

 

1-2. 루틴의 모든 입력 매개변수 값을 검사하라.

데이터의 출처가 외부 인터페이스가 아니라 내부 프로그램의 메소드 호출이라는 점만 제외한다면 1번 조언과 맥락이 같다. 그러나 루틴의 바운더리 바깥에서 오는 데이터인지 아닌지 여부에 따라 대처가 다를 수 있으니 개발팀이 설정한 설계 원칙을 준수해야 할 필요도 있다. 예를 들면, 네트워크를 통해 들어오는 입력 데이터에 대해서는 validation(유효성) 클래스를 따로 둬서 모든 입력 데이터의 보안과 무결성을 확보하는 것이다.

 

1-3. 잘못된 입력을 처리하라.

유효하지 않은 데이터라고 증명되는 경우, 해당 루틴은 다양한 오류 처리 기법을 동원해 해결해야 할 책임이 있다.

그러나 방어적 프로그래밍의 최우선은 애초에 잘못된 값이 입력되지 않도록 코딩하는 것이다. 입력 오류의 대부분은 개발자가 자신이 개발한 인터페이스가 어떻게 사용될지 것이며, 입력 데이터가 어떤 형태일지 미리 지레짐작하는 데서 발생한다. 그러나 언제나 생각지도 못한 데이터가 입력되거나 생성되는 법이다. 잘못된 데이터로 인한 버그를 방지하는 다양한 테스트 방법들이 있다. 테스트 및 디버깅을 통해 개발 과정에서 오류 자체를 최소화하는 것이 더 우선되어야 한다.

 

 

2. Assertion

어설션이란, 프로그램이 실행되면서 스스로 검사하는 코드를 말한다. Assertion을 지원하지 않는 프로그래밍이라도 개발자 스스로 만들어 사용할 수 있다. 그러나 대부분의 프로그래밍 언어에서 assert 문법을 지원해준다. 값을 검사하여 결과가 false인 경우 오류를 뱉어내 개발자에게 알려준다. ( 특히 이 부분이 참 중요한데, 개발 과정에서 오류가 발생했을 때 그 오류를 개발자가 인식하게끔 바깥으로 드러나게 만드는 것이 버그를 빠르게 잡아내는 데 아주 유용하기 때문이다. )

예를 들어, 자바에서는 아래와 같이 사용될 수 있다.

class Calculation {
    public static int divide(int num1, int num2) {
        assert num2 != 0 : "divide by zero.";
        
        int result = num1 / num2;
        return result;
    }
}

 

나누는 값인 num2가 0이 되면 안 된다. num2 != 0 연산의 값이 true이면 그대로 통과하고, false이면 작성한 오류문, "divide by zero"과 함께 AssertionError를 날린다. 이를 JVM이 잡아내어 콘솔에 출력해 개발자에게 디버깅이 필요하다고 경고해줄 것이다.

어설션은 배포되는 제품에는 포함되지 않아야 한다. 실제로 자바에서는 특별히 옵션을 설정하지 않으면 JVM은 assert 문을 실행하지 않는 것이 디폴트다. 왜냐하면 Assertion은 '제품 실행 도중 발생하는 오류'를 처리하는 용도가 아니기 때문이다. Assertion은 '개발 과정에서' 개발자가 현재 작성하는 코드에 숨어 있는 데이터에 대한 가정을 세우고, 그 가정을 검증하면서 혹시나 발생할 수 있는 버그를 잡아 디버깅하기 위한 용도다.

아래의 사용 지침을 보라. Assertion의 정확한 의미와 함께 그것을 언제 사용해야 하는지 판단할 수 있어야 할 것이다.

 

2-1. 어설션 사용 지침

2-1-1. 발생이 예상되는 상황에는 오류 처리를, 절대 발생해선 안 되는 상황에는 Assertion.

오류 처리는 개발자가 예상하는, "제품 실행 도중 발생할 수도 있는 비정상적인 상황"에 대처하기 위한 기능이다. 따라서 오류 처리는 제품 코드에 포함된다. 예를 들어, switch 문으로 case를 사용해 이벤트를 처리하는 코드가 있을 때 허용되지 않은 case 데이터 입력에 대해 default 문에서 잡아내어 필요한 오류 처리 코드를 작성하는 것이다.

오류 처리 코드와는 달리, Assertion은 애초에 발생해서는 안 되는, 만약 발생한다면 코드가 정상 실행될 수 없는 위험한 상태를 체크한다. 절대 발생해선 안 되는 상황에 빠지는 코드는 애초에 제품으로 출시되어서도 안 된다. 오류 처리 코드가 런타임 도중 발생이 예상되는 상황에서 그것을 시스템이 처리하게 만든다면, assertion은 최종 제품이 특정 오류를 만나지 않도록 조기에 발견하려는 시도다.

오류 처리는 비유하자면 일종의 에어백이다. 자동차에서 충돌 사고는 언제든지 발생할 수 있는 비정상적인 상황이다. 에어백을 통해 충돌 사고에서 운전자를 보호할 수 있다. 에어백은 자동차의 일부다. 그러나 Assertion은 자동자 자체의 결함을 포착하려는 테스트다. 브레이크가 안 된다거나 하는 고장이다. 결함 있는 자동차는 결코 출고되어서는 안 된다.

 

2-1-2. 실행해야 하는 코드는 Assertion에 추가하지 않는다.

Assertion의 용도와 처리 방식을 이해하고 있다면 Assertion 범위 안에 프로그램이 실행해야 하는 코드는 넣어서는 안 된다는 것을 알 수 있다. Assertion은 실제 제품이 배포되면 컴파일러가 컴파일하지 않는다. 코드를 제거하거나 혹은 무시한다. 

자칫 잘못하면 assertion을 예외 처리와 비슷하다고 오해하여 assert 문이 항상 실행된다고 착각할 수 있다. 이렇게 소프트웨어의 일부 메소드를 Assertion 연산 안에 넣는 실수를 저지르게 된다. 그러나 Assertion은 디버깅을 위해 값을 검사하는 코드일 뿐이다. 실행문은 따로 실행하고, 그 결과만 받아서 assert 해야 한다.

 

2-1-3. 선행 조건과 후행 조건을 문서화하고 검증하는데 사용하라.

선행 조건이란 루틴이나 클래스에서 다른 루틴을 호출하거나 생성할 때 반드시 참이어야 하는 특성이다. 호출하는 쪽에서 지켜야 하는 조건이다. 후행 조건이란 호출된 코드나 클래스가 지켜야 하는 특성이다. 

주석으로 쓰지 말고 Assertion 문을 사용하면 실제 디버깅에도 도움을 주고, 그 자체로 문서화가 되어 선행 및 후행 조건에 대해 다른 개발자가 쉽게 이해할 수 있다.

 

2-1-4. 견고한 코드를 작성하기 위해서는 먼저 Assertion을 사용한 뒤 오류 처리 코드를 작성하라.

개발하는 과정에서 assert를 모두 참으로 통과했다고 해서 앞으로도 계속 정확할 것이라는 희망은 착각이다. 물론 개발 하는 과정에서 오류를 최대한 잡아주기 때문에 Assertion은 필수다. 그러나 실제 배포 환경에서 개발 시에는 포착하지 못한 오류가 발생할 수 있다. 

따라서 먼저 Assertion으로 개발 시의 오류를 검증하고, 더 나아가 같은 오류가 혹시라도 배포 환경에서 발생할 경우 시스템이 먹통이 되지 않도록 오류를 잡아 처리하는 코드를 작성하라. 이러한 과정이 지나친 과업이라고 지적하는 전문가들도 있다. 이들은 오류 처리나 assertion 중 하나만 사용하라고 말한다. 하지만 현실 세계의 크고 복잡한 소프트웨어의 개발 환경을 고려하면 둘 다 최대한 활용해서 정확하면서도 견고한 시스템을 만드는 것이 장기적으로 너 낫다.

 

 

3. 오류 처리 기법

오류가 발생했을 때 대체적으로 어떤 식으로 처리해야 하는 것일까? 다음은 몇 가지 일반화된 조언들이다. 하지만 각 조언을 교조적으로 받아들이지 마라. 오류 처리는 본질적으로 정확성과 견고함 사이에서 우선 순위를 선택해야 하는 문제다. 어떤 프로그램은 버그를 일으켜도 되도록 실행을 유지하는 견고한 시스템을 지향하는 반면, 어떤 프로그램은 약간의 오류도 허용하지 않는 완벽한 정확성을 지향할 수도 있다.

 

3-1. 중립 값을 반환하라.

숫자의 경우 0, 포인터나 객체의 경우 null을 반환할 수도 있다. 가장 좋은 대응이지만, 프로젝트의 조건에 따라 중립 값을 반환해서는 안 되는 경우도 있다. 예를 들어, 병원의 환자 데이터 프로그램이라면 언제나 정확해야 한다. 이 프로그램에서 잘못된 데이터에 대해 중립 값으로 대처하는 것은 중립이 아니라 그저 환자의 상태를 오인하게 만드는 치명적인 오류일 뿐이다. 차라리 시스템이 견고함을 포기하고 오류를 뱉어내 종료하는 편이 낫다.

 

3-2. 다음에 오는, 혹은 이전에 사용한 유효 데이터로 대체한다.

예를 들어, 어떤 환경에서는 특정 데이터가 잘못된 경우 굳이 그 데이터를 그대로 복구해야 한다거나 정상 데이터가 무엇인지 계산하는 등의 조치가 필요하지 않은 경우도 있다. 예를 들어, 단 몇 초의 주기로 실시간 스포츠 스코어 데이터를 읽는 프로그램이라면 한 번의 실패가 있더라도 그냥 다음 주기를 기다려서 데이터를 받고, 잠시 이전의 데이터를 그대로 사용해도 별 문제가 없을 것이다.

 

3-3. 경고 메시지를 파일에 기록한다.

오류가 발생한 경우, 그 오류를 당장 처리하지 않고 일단 파일에 로그를 찍어 저장하는 조치도 생각해볼 수 있다. 이런 조치를 선택하려면 해당 오류로 인해 프로그램이 먹통이 되지 않도록 중립이나 유효 데이터로 대처하는 기법을 함께 활용한다. 이때 오류 로그 파일은 중요한 보안 사항이 될 수 있으므로 누가 접근할 수 있는지 고려해야 한다.

 

3-4. 오류 코드를 반환한다.

오류 코드를 반환한다는 것이 무슨 말일까? 이것은 오류를 처리하는 코드를 루틴이 처리하지 않고 시스템에서 독립적으로 다루겠다는 뜻이다. 즉, 오류를 감지한 루틴이 그 오류를 처리하지 않고 외부로 오류를 넘기는 것이다. 이러한 조치는 오류 처리로 인한 오버헤드를 방지하여 시스템의 전체 성능을 올릴 수 있다.

이 방법은 오류 처리 담당 시스템에 루틴이 직접 어떤 오류인지 알려줘야 한다. 이를 위해 상태 변수에 잘 알려진 약속된 값을 설정하고 그 값을 넘기거나, 프로그래밍 언어가 예외를 지원해주는 경우 약속된 예외를 루틴이 생성하여 시스템에 던져야 한다.

이러한 방식은 프로젝트의 성격에 따라 세심하게 설계해야 하는 선택 사항이다. 오류를 직접 처리할 것인가, 아니면 오류를 보고하는 메커니즘을 설계할 것인가? 

후자는 오류 처리 객체에 오류 처리 책임을 집중시킴으로써 디버깅이 쉬워지고, 나머지 프로그램은 자신의 일에만 집중할 수 있다는 장점이 있다. 그러나 오류 처리를 외부에서 담당해주고 있기 때문에 재사용성이 떨어진다. 그 프로그램이 스스로 오류를 처리하지 않기 때문에 호출 모듈이 외부로 던지는 오류 코드나 예외 종류에 대해 다 알고 있어야 하기 때문이다.

 

3-5. 오류 메시지를 알려준다.

오류 처리 자체의 오버헤드를 방지하고 싶을 때, 최종 사용자에게 오류가 발생했음을 설명해주고 대신 처리를 생략하는 방법도 있다. 그러나 이는 잠재적으로 시스템의 취약점을 사용자에게 알려주는 방식이기 때문에 시스템의 보안이 중요한 경우 이런 방식은 해커들에게 취약점을 알려주는 치명적인 방법이 될 수 있다.

 

3-6. 모든 오류를 상황에 맞게 처리한다.

어떤 설계는 발생할 수 있는 모든 오류를 위와 같은 기법들로 처리하지 않고 완벽하고 정확하게 처리해 시스템의 견고함을 강조할 수도 있다. 이런 설계는 사실상 오류를 감지하는 루틴이 스스로 오류를 처리해야 한다. 왜냐하면 복잡한 시스템일수록 모든 오류를 세세히 파악할 수 없기 때문이다.

바로 이런 문제 때문에 단점도 명확하다. 각 개발자가 자신이 담당하고 있는 루틴의 성격에 맞는 오류 처리 코드를 알아서 작성하므로 서로 다른 오류 처리 기법이 남발하게 되고, 이에 따라 프로그램의 전체 성능이 떨어질 수 있다.

 

3-7. 종료시킨다.

견고함보다 정확성이 더 중요한 프로그램들이 있다. 이런 프로그램들은 잘못된 데이터나 버그를 감지하면 그냥 강제 종료를 시킨 후 사용자가 기계를 재부팅하도록 만든다. 혹은 전체 시스템 중 일부의 기능이 보안과 관련된 경우, 그 기능에 한해서 종료 조치라는 과감한 방법을 택할 수도 있다.

 

오류 처리 방법은 매우 다양하다. 따라서 오류를 일관적으로 처리할 수 있도록 상위 수준의 설계 단계에서 오류 처리 방식을 설계해야 한다. 즉, 오류 처리는 상위 수준 설계다. 일단 상위 수준에서 구체적인 오류 처리 방법을 결정했다면 모든 루틴과 클래스는 그 방식을 따라야 한다. 예를 들어, 모든 함수의 리턴값을 검증해야 한다는 설계가 내려졌으면 설령 함수의 리턴값이 오류가 없는 것 같아도 오류 검사 코드를 만들어야 한다.

 

 

4. 예외

오류라는 단어와 예외라는 단어는 프로젝트에 따라 구분 없이 사용되기도 하지만, 서로 다른 개념이다. 예외는 말 그대로, "저에게 예외적인 상황이라 이걸 어떻게 처리해야 할지 모르겠습니다! 도와주세요!"라고 외치는 것과 같다. 즉, 예외라는 단어에는 루틴을 호출한 코드에 오류 처리를 던지겠다(throw)는 뜻이 내포되어 있다. 오류를 파악하고 감지는 했으나 그 처리 방법을 외부로 던지는 것이다.

프로그래밍 언어마다 예외를 다루는 방식은 미묘하게 다르다. 예를 들어, C++에서는 인터페이스에 굳이 예외를 던지는 코드를 작성하지 않아도 되지만, 자바는 인터페이스에 throws를 통해 예외를 던지는 코드를 규격화해야 한다.

아래는 프로그래밍 언어에 종속되지 않은, 일반적인 예외 코드 처리와 관련된 조언들이다. 예외 처리는 프로그래밍 언어마다 사용법이 다 다르지만 유일하게 공통되는 부분이 하나 있는데, 그것은 바로 예외 처리 코드는 읽기 복잡한 스파게티 코드를 낳는다는 것이다. 따라서 신중하게 사용해야 한다.

 

4-1. 무시되어서는 안 되는 오류를 프로그램의 다른 부분에 알린다.

예외의 가장 큰 가치는 일단 예외 처리 코드를 사용하는 순간 시스템이 절대 무시할 수 없다는 것이다. 루틴이 예외를 throw 한다면, 호출 코드는 반드시 어떤 식으로라도 try ~ catch 를 해야 한다. 하지 않으면 컴파일에서 걸러진다. 예외는 오류가 감지되지 못하고 통과해버리는 가능성 자체를 거부한다. 아주 아주 치밀한 시스템이므로 남발해서는 안 된다.

 

4-2. 정말로 예외적인 경우에서만 예외를 사용하라.

Assertion과 Exception 모두 절대 발생해서는 안 되는 오류를 다루지만, 전자(어설션)는 개발 과정에서 프로그램의 무결성을 검증하는 기법을 말하고, 후자(예외)는 일단 배포된 후 런타임에서 발생하는 치명적인 오류를 검증하는 기법이다.

즉, 어설션은 개발 과정에서 포착되면 코드 작성으로 버그를 해결할 여지가 있다. 그러나 예외는 그러한 방식이 통하지 않는다. 루틴 내부의 능력으로는 컨트롤할 수 없는 치명적인 오류다. 프로그램의 무결성을 해치는데도 예외는 해당 루틴이 컨트롤 하기 힘들기 때문에 이 오류를 처리할 강력한 방법을 알고 있는(있다고 여기는) 상위 모듈에 던지는 식이다. 

이런 이유로 예외 처리는 본질적으로 캡슐화를 해친다. 호출 모듈이 호출되는 모듈이 던지는 예외를 알고 있어야 하기 때문이다. 따라서 정말 예외적인, 즉 치명적이면서도 루틴이 해결할 수 없는 곤란한 문제에 한해서만 예외를 사용해야 한다.

예외는 남발하지 마라. 해당 오류를 루틴이 처리할 수 있는지, 처리할 책임이 있는지, 처리하는데 있어 필요한 정보가 있는지 물어라. 처리할 수 있으면 처리하고, 처리할 수 없거나 처리 책임이 다른 모듈에 있다고 판단되는 경우에만 던져라. 어떤 개발자는 분명히 해당 루틴이 처리할 수 있는 오류조차 코드 작성의 편의를 위해 예외를 무작정 던지는 코드를 남발한다.

 

4-3. 예외 또한 추상화 수준이 인터페이스와 같아야 한다.

아래의 자바 코드를 보자.

class Employee {
    public TaxID getTaxID() throws Exception {
        //...
    }
}

위 코드는 직원의 납세 식별 번호(TaxID)를 얻는 코드다. 내부적으로 어떻게 식별 번호를 얻는지는 모르겠지만(알 필요도 없지만), 현재 이 코드는 예외를 던지고 있다. 그런데 무슨 예외인지 해석할 수 없다. 그냥 예외(Exception)이다. 이 인터페이스를 본 클라이언트는 해당 예외가 무슨 예외인지 모르기 때문에 결국 내부 코드를 봐야 한다. 캡슐화를 깨트리고 있는 것이다.

그렇다면 아래의 코드는 괜찮을까?

class Employee {
    public TaxID getTaxID() throws EOFException {
        //...
    }
}

이 코드도 나쁘다. 언뜻보면 어떤 예외인지 알려주는 코드라고 착각하기 쉽다. 하지만 이 예외는 무슨(what) 예외인지 알려주는 것이 아니라, 어떻게(how) 예외가 발생했는지 알려주므로 잘못되었다. 즉, 저수준의 내부 구현 방식이 예외로 인해 드러나고 있다. 정보 은닉 실패다. 내부 구현 코드를 아무리 감춰봤자 클라이언트는 파일에서 납세 식별 번호를 가져오고 있다는 것을 유추할 수 있다.

아래의 코드는 적절하다.

class Employee {
    public TaxID getTaxID() throws EmployeeDataNotAvailable {
        //...
    }
}

위 코드의 예외는 무슨 예외인지 적절한 추상화 범위에서 알려주고 있다. 클라이언트 코드는 어떻게 예외가 발생했는지는 모른다. 하지만 적어도 무슨 예외인지는 알게 된다. 어떤 이유에서인지 직원 데이터는 현재 이용할 수 있는 상태가 아닌 것이다. 

 

4-4. 예외 메시지를 활용하라

예외는 보통 메시지를 포함시켜 던질 수 있다. 예외 사항에 발생 이유를 적어주는 것은 좋은 습관이다. 예를 들어, 배열 인덱스 범위 초과 오류라면 범위가 어떤 숫자로 설정되었는지 알려주는 정보를 메시지에 포함한다.

 

4-5. 비어있는 catch 블록을 피한다.

catch 블록이 비어버리면 타당한 예외를 처리하지 않기 때문에 문제가 발생한다. 예외는 시스템이 무시할 수 없는 오류이므로 반드시 처리해야지, 비워버리면 안 된다. 만약 호출된 루틴이 try 블록 내에서 이유를 알 수 없는 예외를 발생시켜 catch에서 어떻게 처리해야 할지 모른다면 적어도 catch 블록 내에 주석을 사용하거나 파일을 통해 로그를 써야 한다. 절대 비우면 안 된다! 한 번 비워버리는 순간, 발생할 수 있는 오류를 개발자가 감지할 수 없게 만들며, 이것은 장기적으로 개발에 드는 시간과 비용을 폭발적으로 늘린다.

 

4-6. 라이브러리가 던지는 예외를 파악하라.

표준 라이브러리에 있는 예외를 그대로 사용하는 경우가 종종 있다. 이 경우 해당 라이브러리가 던지는 예외를 반드시 잡아야 한다. 던지는 예외가 코드 상에서 표시되지 않을 수 있으므로, 공식 문서를 통해 어떤 예외를 던지는지 보고 해당 예외를 처리한다.

 

4-7. 예외의 대안도 있다.

어떤 개발자들은 단지 프로그래밍 언어가 예외 처리 문법을 제공해준다는 이유 하나만으로 모든 오류를 예외로 처리한다. 그러나 예외의 대안이 훨씬 더 중요하다. 어설션을 사용해 미리 오류를 디버깅하거나, 오류 처리 코드를 직접 작성하기, 혹은 파일로 기록하거나 시스템을 종료하는 것, ... 등 최선의 방법이 언제나 존재한다.

 

 

5. 오류로 인한 손해를 막기 위한 정책

5-1. 바리케이드

방어적 프로그래밍의 한 방법은 일종의 살균실을 만드는 것이다. 특히 네트워크 통신 등 보안 및 정확성 면에서 매우 불결한 데이터가 올 수 있는 외부 인터페이스를 사용하는 경우, 내부 프로그램으로 들어와 메소드를 호출하기 전에 먼저 유효성을 검증하는 것이다.

이렇게 중간 단계를 두면, 내부 클래스들은 자신들에게 입력되는 데이터를 무결한 것으로 받아들이고 오류 처리라는 복잡한 프로세스에서 벗어날 수 있다. 이렇게 유효성 검증 클래스를 따로 만들어 외부로부터 진입하는 데이터를 검증하는 방식은 유효성 검사에 대한 일괄적인 처리가 가능하다는 장점이 있다.

이를 위해 특정한 인터페이스를 두어 외부에서 이 인터페이스를 거치게 만드는 방법을 사용할 수 있다. 혹은 유효성을 검증해주는 프레임워크를 사용할 수도 있다.

 

5-2. 적절한 타입 변환

외부로부터 데이터가 입력되는 경우, 해당 시스템에 맞게 적절한 데이터 타입으로 인코딩되어야 한다. 만약 인코딩되지 않은 상태로 오랫동안 시스템에 머무르는 경우 프로그램이 망가질 수도 있다. 시스템의 다른 부분에서 색상 데이터에 true 라는 값을 집어넣을 수도 있기 때문이다. 따라서 외부 데이터가 입력되는 즉시 변환 작업도 빠르게 해야 한다.

 

5-3. Assertion 함께 사용하기

바리케이드로 보호된 내부 시스템의 클래스와 루틴은 오류 처리 대신 어설션을 사용해야 한다. 왜냐하면 유효성 검증 클래스들이 미리 데이터를 살균시켜 무결성을 보장해주기 때문이다. 따라서 특별한 오류 처리가 필요하진 않다. 그럼에도 어설션을 사용해 잘못된 데이터 값을 검사하는 이유는 만약 잘못된 데이터가 감지될 경우, 외부 입력 데이터 오류가 아니라 루틴의 프로그래밍 오류임을 확실하게 검증할 수 있기 때문이다.

 

 

6. 디버깅 보조 도구

위에서 잠깐 언급되었지만, 오류나 예외 처리보다 더 중요한 것이 어설션처럼 미리 개발 과정에서 오류를 발견해 고치는 것이다. 개발 중에 가혹하게 실패하는 것이 제품이 배포되어 오류가 발생하는 것보다 백배 천배 낫다.

 

6-1. 제품의 제약 사항을 개발 버전에 적용하지 않는다.

많은 개발자들이 배포되는 소프트웨어의 제약 사항들을 개발 버전에도 그대로 적용하는 식으로 코딩한다. 이는 개발 과정이 반드시 배포로 완벽하게 이어져야 한다는 착각에서 발생한다. 그러나 개발 버전은 느리게 실행되어도 된다. 개발 버전은 자원을 마음껏 써도 된다. 

이것을 "디버그 모드"라고 부른다. 개발 중에는 오류를 검사하는 코드를 작성해 퍼포먼스를 늦춰도 된다. 오류와 버그를 빠르게 발견하는 것이 언제나 훨씬 더 도움이 된다. 개발을 지원하는 문법 및 도구를 사용하는 데 속도와 자원을 아낌없이 양보하라. 많은 개발 툴이 디버깅과 배포 버전이 분리된 빌드, 혹은 정적 상태를 추적하게 만드는 디버깅 모드를 지원한다.

 

6-2. 개발 중에는 공격적으로 프로그래밍한다.

아주 가혹한 상황을 의도적으로 연출하는 것도 오류를 잡아내는데 도움이 된다. assert가 프로그램을 중단시키게 하거나, 메모리를 전부 쓰레기 데이터로 채워보는 등 가혹한 환경에서 실패를 유도하는 코드를 짤 수 있다.

즉, 핵심은 개발 도중에는 실패를 하도록 유도하는 것이 오히려 실수를 잡아내는데 도움을 준다는 원칙이다. 따라서 마치 제품 버전의 코드처럼 예외나 오류를 핸들링하지 마라. 우아하게 오류를 잡아내어 시스템을 견고하게 만드는 것은 배포되는 서비스에서만 적용되어야 한다.

적당히 돌아가게 만들지 마라. 공격적인 프로그래밍이라는 말은 실패를 통해 오류를 잡아내겠다는 방법이다. 예를 들어, case가 5가지인 이벤트 처리 switch문이 있다고 하자. 적절한 이벤트가 아닌 이벤트는 default 케이스에서 잡힐 것이다. 이때, 개발 중인 소프트웨어라면 default 케이스에서 오류를 뱉어내게 만들어라. 나중에 제품이 배포될 때만 해당 default 부분을 중립 이벤트로 처리하는 식으로 코딩하라.

 

6-3. 버전 관리 도구를 사용하라.

당연히 디버깅 코드를 배포되는 코드에 집어 넣으면 안 될 것이다. 오류를 잡아내는 디버깅 코드는 개발 과정에서만 필요한 것이고, 충분히 무결성에 대해 확신할 수 있다면 배포되는 코드에서 제거되어야 한다. 그렇게 하지 않으면 성능과 크기 면에서 손실이 발생한다. 프로그래밍 언어와 개발 툴은 빌드 모드를 변경할 수 있다. 같은 소스 파일이라도 다른 버전의 프로그램을 빌드할 수 있다. 

 

6-4. 디버깅 루틴을 작성하라.

디버깅 검사 루틴을 작성하면 디버깅을 위한 코드의 재사용성을 높일 수 있다. 루틴이 디버깅을 직접 수행하지 않고 일괄적으로 공통된 디버깅 루틴을 호출하면 일반화된 검사를 수행한다. 예를 들어, 입력 값으로 온 포인터 값이 NULL은 아닌지, 유효한 값인지, 다른 메모리 범위에 침범하고 있지는 않은지 검사한다.

특히 제품용으로 코드를 빌드할 때, 이 디버깅 루틴의 내부를 전부 주석처리해 코드를 비워버리면 아주 쉽게 제품용 소스코드를 빌드할 수 있다. 물론 루틴이 호출되고, 아무것도 안 한 뒤, 다시 제어가 호출한 쪽으로 넘어간다. 그러나 성능 면에서 아주 심각한 손해는 아니다.

 

 

7. 얼마나 방어적으로 프로그래밍할 것인가?

방어적 프로그래밍에서 중요한 점은 개발 시에는 일부러 실패하는 것이 낫고, 배포 시에는 실패하지 않고 우아하게 오류를 처리하는 것이 낫다는 점이다. 게다가 배포 소프트웨어는 서비스 실행 환경이라는 제약 사항에 따라 충분히 가벼워야 한다. 따라서 디버깅용과 배포용 소스코드를 어떻게 구분할지가 관건이 된다.

 

7-1. 중요한 오류라면 검사하는 코드를 남겨둬라.

어떤 오류는 발생하더라도 문제가 되지 않을 수도 있다. 워드 프로그램에서 잠깐 폰트가 깨지는 것은 별로 문제가 안 된다. 이러한 사소한 오류라면 굳이 오류 검사 및 처리 도구를 배포용 소스 코드에 포함시키지 않아도 된다. 그러나 반대로 스프레드시트 프로그램에서 함수의 적용과 같이 절대 실패해서는 안 되는 기능이라면 검사하는 코드, 혹은 처리 코드를 그대로 남겨야 한다. 되도록 오류를 포착해서 우아하게 처리하는 편이 낫다.

 

7-2. 의도적으로 발생시킨 충돌 코드를 제거하라.

개발 시에는 보통 공격적으로 프로그래밍한다. 하지만 배포되는 환경에서는 충돌은 좋지 않다. 따라서 해당 부분을 찾아 우아하게 처리하거나, 혹은 제거해야 한다. 사소한 오류에도 충돌하는 코드를 작성했다면, 배포용에서는 제거하라. 특히 공격적 프로그래밍에서 가혹한 환경으로 일부러 유도하는 코드가 있다면 더더욱 제거해야 한다. 예를 들어, 워드 프로그램에서 일부러 데이터 손실을 유도해보는 디버깅 코드가 있다면 반드시 제거해야 할 것이다.