8. 방어적 프로그래밍
Edited by / (frog-slayer)
8.1. Protecting Your Program from Invalid Inputs
“Garbage in, garbage out”이라는 말이 있다. 올바르지 않은 데이터를 입력하면 올바르지 않은 출력이 나온다는 말이다. 하지만 실제 산업에서의 소프트웨어에서 위 말은 충분하지 않다. 좋은 프로그램은 어떤 입력이 들어오든 올바르지 않은 출력은 내보내지 않아야 한다.
잘못된 입력을 처리하는 방법에는 세 가지의 방법이 있다.
- 외부 소스로부터 오는 모든 데이터의 값을 확인
- 파일, 사용자, 네트워크, 혹은 그 외의 외부 인터페이스로부터 데이터가 입력되면, 그 데이터가 허용된 범위 내에 들어가는지를 확인한다.
- 예) 수가 범위 내에 있는지 확인. 허용된 길이보다 짧은 문자열인지 확인
- 입력을 확인하지 않으면, 버퍼 오버플로, SQL 인젝션 등에 취약해진다.
- 함수의 모든 입력 파라미터의 값을 확인
- 외부 인터페이스가 아니라 다른 루틴으로부터의 데이터를 검증한다는 점을 제외하고는 위와 유사
- 잘못된 입력을 어떻게 처리할지를 결정
- 상황에 따라 달라짐.
가장 좋은 방어적 코딩은 처음부터 에러를 만들지 않는 것이다.
- 반복적 설계에서 코드를 작성하기 전에 수도코드, 테스트 케이스를 작성하는 것, 저수준 설계 인스펙션을 수행하는 것 등
8.2. Assertions
어설션(assertion)은 개발 중에 쓰이는 코드로, 프로그램이 돌아가면서 스스로를 확인하는 데 쓰이며, 특히 크고 복잡한 프로그램, 신뢰성이 높은 프로그램을 만드는 데 유용하게 쓰인다.
- 참인 경우: 예상대로 제대로 돌아가고 있음
- 거짓인 경우: 코드에 예상치 못한 에러를 탐지
어설션은 보통 ‘참이 되는 경우를 설명하는 불리언 식’, ‘거짓인 경우에 나타낼 메시지’의 두 가지를 인자로 가진다. 자바 어설션의 예를 보자. 분모가 0이 아닌지 확인하고, 0인 경우 메시지를 띄우게 된다.
//Java Example of an Assertion
assert denominator != 0 : "denominator is unexpectedly equal to 0"
어설션을 사용해 확인할 수 있는 것들에는 대표적으로 다음과 같은 것들이 있다.
- 입력 파라미터의 값이 예상 범위 내에 있는지를 확인
- 루틴 실행/종료 시 파일/스트림이 열려/닫혀 있는지 확인
- 루틴 실행/종료 시 파일/스트림이 시작/끝에 있는지 확인
- 열린 파일/스트림이 읽기 전용/쓰기 전용/둘 다인지 확인
- 포인터가 non-null인지 확인
- 루틴에 전달되는 배열/컨테이너가 최소 X개 이상의 원소를 가지고 있는지
- 루틴이 실행/종료 시 컨테이너가 비어/차 있는지 확인
- 최적화된 복잡한 루틴의 결과와 느리지만 명확한 루틴의 결과가 서로 같은지 확인
사용자에게는 어설션 메시지를 보여주고 싶지 않을 것이기에, 어설션은 보통 개발 및 유지 보수 중에만 사용한다.
- 개발 중에는 어설션이 코드에 포함된다.
- 잘못된 가정이나, 예상치 못한 조건들을 제거하는 데 유용하게 쓸 수 있다.
- 실제 결과물에서는 제거한다.
- 어설션이 추가되어 시스템 성능을 저하되는 일을 방지한다.
Building Your Own Assertion Mechanism
C++, Java, MS Visual Basic 등의 많은 언어에는 어설션을 지원하는 기능이 포함되어 있다. 직접 어설션 루틴을 지원하지 않는 언어의 경우에도 쉽게 만들어 쓸 수 있다.
Guidelines for Using Assertions
- 일어날 것으로 예상되는 경우는 에러 핸들링 코드를 사용하고, 일어나지 말아야 할 일의 경우 어설션을 사용해라
- 에러 핸들링 코드
- 예상할 수 있는 비정상적 상황(예: 잘못된 입력 데이터)을 처리
- 프로그램이 비정상 상황에 직면할 때, 복구하거나 경고 메시지를 표시
- 어설션
- 절대 일어나지 말아야 할 상황을 확인
- 문제를 해결하는 용도가 아니라 코드 내 버그가 있을 경우를 확인하는 용도
- 개발자는 코드를 수정하고 재배포
- 에러 핸들링 코드
- 어설션에 실행가능한 코드를 삽입하는 일은 피해야 한다.
- 어설션에 실행가능한 코드를 넣는 경우, 나중에 어설션을 껐을 때 해당 코드까지도 삭제할 위험성이 있다.
- 어설션을 이용해 사전 조건(preconditions), 사후 조건(postconditions)을 문서화 및 검증하라
- 사전 조건: 클라이언트 코드가 어떤 루틴을 호출하거나 어떤 객체를 예화하기 전에 만족해야 하는 조건
- 사후 조건: 루틴이나 클래스의 실행이 종료될 때 참임이 만족되는 조건.
- 어설션은 사전/사후 조건을 문서화하는 데 유용하다. 물론 주석을 이용해도 좋지만, 어설션을 이용하면 사전/사후 조건이 참인지를 동적으로 체크할 수 있다.
- 극도로 견고한 코드가 필요하다면, 어설션과 에러 핸들링 코드를 모두 사용해도 좋다.
- 일반적으로는 하나의 오류 상황에 대해 어설션 또는 에러 핸들링 코드 중 하나만 쓰는 경우가 많다.
- 하지만 현실적인 대규모 소프트웨어 시스템은 매우 복잡하므로, 필요하다면 어설션과 에러 핸들링 코드를 모두 사용하는 것도 좋다.
- 대규모 시스템의 특징
- 긴 수명: 5~10년 이상 유지. 다양한 개발자와 팀에 의해 수정되고 발전
- 분산 개발: 다른 기술, 지역, 표준을 따르는 개발자들이 협력 개발
- 코드 품질의 불균형
- 테스트 제한: 제한된 시간/자원으로 테스트가 충분히 이루어지지 않을 수 있음
- 누적된 복잡성: 갈수록 점점 복잡해짐
- 대규모 시스템의 특징
- 위와 같은 환경에서는 한가지 오류에 대해 어설션과 에러 핸들링 코드를 조합해 사용하는 것이 더 안정적이다.
8.3. Error-Handling Techniques
- 중립 값 반환: 잘못된 데이터에 대한 연산을 수행하고, 안전한 기본값을 반환하는 것이 좋을 때가 있다.
- 치명적인 오류가 아닐 때.
- 예) 값이 없을 때
0
이나 빈 문자열을 반환
- 다음 유효 데이터로 대체: 데이터 스트림을 처리하는 경우, 잘못된 데이터를 건너 뛰고 다음 유효 데이터를 반환
- 일부 데이터가 잘못 되어도 전체 데이터에 큰 영향을 주지는 않는 경우
- 이전과 같은 답을 반환: 새로 유효한 결과를 계산할 수 없는 경우, 마지막으로 계산된 유효값을 반환
- 데이터가 연속적인 경우
- 예) 기온 등
- 가장 가까운 허용 가능한 값으로 대체
- 값이 특정 범위 내에 있어야 하는 경우, 범위를 벗어난 데이터를 가까운 범위 내로 제한
- 경고 메시지를 로그 파일에 기록: 오류 발생 시 프로그램 실행을 중단하지 않고, 로그 파일에 세부 내용을 기록
- 다른 테크닉과 조합해서 사용 가능.
- 로그를 어떻게 보관할지도 중요한 선택 사항
- 에러 코드 반환: 다른 상위 계층 루틴에 에러 핸들링을 위임.
- 각 파트는 각자 스스로 에러를 처리하지 않고, 에러 코드를 상위 루틴에 보고하기만 함.
- 메커니즘
- 상태 변숭의 값을 설정
- 상태를 함수 반환값으로 반환
- 언어에서 자체 제공하는 예외 메커니즘을 사용해 예외 발생시키기
- 에러 처리 루틴/객체 호출: 에러 핸들링을 글로벌 루틴/객체로 집중시키는 다른 방법.
- 디버깅이 쉬워짐.
- 결합도가 높아짐. 특정 코드를 다른 시스템에서 사용하고 싶은 경우, 에러 핸들링 루틴/객체까지 함께 가져가야 함
- 버퍼 오버런으로 해커가 해당 루틴/객체의 주소값을 알게 되면 위험해짐
- 에러 메시지를 발생 지점에서 출력
- 오류 발생 시 즉시 메시지를 출력하여 사용자에게 알림.
- 간단하고 오버헤드가 적음.
- 사용자 인터페이스 관련 코드가 시스템 전반에 흩어짐
- 자세한 오류 메시지가 노출되는 경우, 공격자가 유용하게 사용할 수도 있음
- 로컬에서 에러 처리
- 오류가 발생한 지점에서 각 개발자가 각자의 판단 하에 직접 처리
- 유연하고 빠름
- 시스템 일관성이 떨어지고, 사용자 인터페이스 코드가 시스템 전반에 흩어짐.
- 종료: 오류 발견시 프로그램을 즉시 종료
- 안전성이 중요한 시스템에서 사용
- 예) 방사선 치료 장비, 보안 시스템
- 안전성 보장. 보안 강화
- 사용자 경험 저하. 데이터 손실 가능성 있음
Robustness vs. Correctness
특징 | 정확성 (Correctness) | 견고성 (Robustness) |
---|---|---|
우선순위 | 정확한 결과 반환 | 지속적인 동작 보장 |
오류 처리 | 오류 발생 시 중단 | 오류 발생 시 가능한 대안 수행 |
적용 분야 | 안전/치명적 오류 방지 중요 | 사용자 경험이 우선 |
예 | 방사선 치료, 항공기 소프트웨어 | 워드 프로세서, 비디오 게임 |
High-Level Design Implications of Error Processing
프로그램 전반에 일관되게 에러 핸들링 옵션을 선택해야 하며, 이는 설계의 초기 단계에서 결정해, 이후 모든 개발 단계에서 반드시 준수해야 한다.
- 에러를 처리하는 고수준 코드와 단순히 에러를 보고하기만 하는 저수준 코드를 모두 사용한다면, 고수준 코드가 실제로 해당 에러를 처리하도록 만들어야 한다.
- 함수 반환값 검사: 어떤 언어의 경우, 함수가 에러 코드를 반환하더라도 무시할 수 있다. 함수의 반환값이 항상 정상일 것이라 가정하지 말고, 이를 확인해 처리하도록 설계 해야한다.
- 자체 작성한 함수뿐만 아니라, 시스템 함수 호출에서도 오류 코드를 확인해야 한다.
- 시스템 콜 에러를 확인하지 않겠다는 가이드라인이 있는 경우가 아니라면, 모든 콜에 대해 에러 코드를 확인해야 한다.
8.4. Exceptions
예외(Exception)는 코드가 자신을 호출한 코드에 에러나 예외적 이벤트를 전달하는 수단이다.
코드는 처리할 수 없는, 예상하지 못한 조건을 만나면 throw
문을 통해 예외 객체를 던지고, 이 예외에 대해 더 잘 알고 있는, 상위 호출 계층의 루틴은 try-catch
블럭에서 이를 캐치(catch
)해 예외를 처리한다.
- 널리 사용하는 언어들의 예외 구현 비교표
예외는 신중하게 사용하면 복잡도를 줄일 수 있지만, 그렇지 않으면 코드를 따라가는 일을 거의 불가능하게 만들 수도 있다. 아래는 예외 사용의 이점을 살리기 위한 제안들이다.
- 프로그램의 다른 부분에, 무시되어서는 안될 에러를 알리기 위해 예외를 사용하라.
- 정말 예외적인 조건에 대해서만 예외를 던져라.
- 단순히 ‘잘 일어나지 않는’ 이벤트가 아니라, ‘절대 일어나서는 안 ‘” 이벤트에 대해 사용해야 한다.
- 예외는 복잡도를 높이고, 캡슐화를 약화시킨다.
- 너무 잦은 예외의 사용은 가독성과 유지보수성을 떨어트린다.
- 로컬에서 처리할 수 있는 예외는 로컬에서 처리하라.
- 예외를 던지지 않고도 문제를 해결할 수 있다면, 더 간결하고 이해하기 쉽다.
- 그 자리에서 캐치하는 경우가 아니라면 생성자나 소멸자에서 예외를 던지는 일은 피하라.
- C++의 경우, 소멸자는 객체가 완전히 생성되지 않으면 호출되지 않기에, 생성자에서 예외가 발생하는 경우 소멸자는 호출되지 않고, 이는 메모리 누수로 이어질 수 있다.
- 적당한 추상화 수준에서 예외를 던져라
- 루틴이나 클래스가 제공하는 추상화 수준에 맞는 예외를 던져야 한다.
- 예외는 인터페이스의 일부로 간주되므로 호출하는 코드가 이해하고 처리할 수 있어야 한다.
- 예외 메시지에는 예외를 일으킨 모든 정보를 담아라.
- 예외가 일어나는 이유, 예외가 일어난 구체적인 환경을 메시지에 담아야 한다.
- 빈
catch
블럭은 피해라.try
블럭 내의 코드가 아무 이유 없이 예외를 일으키거나, 혹은 정당한 예외라도catch
블럭에서 아무런 처리를 하지 않는 경우다.- 문제의 근본적인 이유가 뭔지를 정하고
try
블럭이나catch
블럭을 수정해야 한다. - 드물게 하위 수준에서 던진 예외가 현재 수준의 추상화와 맞지 않는 경우가 있을 수 있다. 이때는 적어도 그 이유를 주석으로 남기거나 메시지를 파일에 로그 처리 해줘야 한다.
- 라이브러리의 코드가 던지는 예외에 대해 알아야 한다.
- 예외를 던지는 루틴이나 클래스를 직접 만들 필요가 없는 경우, 적어도 사용하는 라이브러리 코드가 던지는 예외에 대해서는 알아야 한다.
- 라이브러리의 문서를 확인하고, 만약 문서에 명시되지 않으면 프로토타입 코드를 자성해 예외를 테스트한다.
- 중앙 집중식 예외 처리기 구축을 고려하라.
- 중앙 집중형 예외 처리기를 이용해 예외 처리의 일관성을 높일 수 있다.
- 어떤 예외가 있는지를 정의하고, 예외 처리 방식을 통일시키고, 일관된 예외 메시지 형식을 제공한다.
- 프로젝트의 예외 사용을 표준화하라.
- 다양한 객체, 데이터, 포인터 등을 던질 수 있는 C++과 같은 언어를 사용하는 경우, 구체적인 무엇을 던지는지를 표준화하라.
- 프로젝트에 맞는 예외 클래스를 만드는 것을 고려하라.
- 어떤 경우에 코드가
throw-catch
문을 사용할 것인지, 구체적인 환경을 정의하기. - 로컬에서 처리될 수 없는 예외를 언제 던질지, 구체적인 환경을 정의.
- 중앙 집중형 예외 처리기를 사용할지를 결정.
- 생성자/소멸자에서 예외를 사용할지를 결정.
- 예외의 대용품을 고려하라
- 어떤 프로그래머들은 단순히 예외가 에러 핸들링 메커니즘을 제공해준다는 이유로 사용하기도 한다.
- 하지만 항상 그 대용품을 항상 고려하는 것이 좋다.
- 에러를 로컬에서 처리, 에러코드를 이용해서 에러 전달, 디버그 정보를 파일에 저장, 시스템 종료 등.
- 단순히 언어에서 예외를 제공해주기 때문이 아니라, 정말로 프로그램에 예외 처리가 필요한지를 생각할 것.
8.5. Barricade Your Program to Contain the Damage Caused by Errors
배의 격실이나 건물의 방화벽처럼, 소프트웨어에서도 어떤 부분에서 문제가 발생했을 때 다른 부분에는 영향을 미치지 않도록 설계하는 방법이 있다.
소프트웨어에서 바리케이드는 특정 인터페이스를 “안전한” 영역의 경계로 두는 방식으로 구현할 수 있다. 이 경계를 지나는 데이터의 경우 그 유효성을 확인하고, 그렇지 않은 경우 그에 맞게 적절히 대응하는 방식이다.
마찬가지의 방식이 클래스 수준에서도 가능하다.
public
메서드에서는 데이터가 안전하지 않다고 가정하고, 데이터를 검증 및 살균(sanitize)하는 역할을 한다.public
메서드에서 데이터가 받아들여지는 경우, 클래스의private
메서드는 그 안전한 데이터를 사용한다.- 여러 단계를 거쳐 데이터를 살균할 수도 있다.
입력 데이터는 입력되는 즉시 적절한 타입으로 변환하는 것이 좋다.
Relationship Between Barricades and Assertions
바리케이드의 사용은 어설션과 에러 핸들링의 차이를 명확히 구분하는 데 도움이 된다.
- 바리케이드 밖의 루틴은 에러 핸들링을 사용한다.
- 데이터가 잘못된 상태로 들어올 수 있으므로, 이에 대한 처리가 필요하다.
- 바리케이드 안의 루틴은 어설션을 사용한다.
- 데이터가 살균된 후 전달되므로, 데이터가 안전하다고 가정하고 어설션을 사용해 데이터가 기대되는 형식인지 확인할 수 있다.
- 만약 그럼에도 잘못된 데이터를 발견하면, 이는 프로그램 로직에 문제가 있다는 뜻이다.
8.6. Debugging Aids
Don’t Automatically Apply Production Constraints to the Development Version
운영 버전의 한계를 개발 버전까지 가져와 혼동하는 경우가 있다.
운영 버전 | 개발 버전 | |
---|---|---|
속도 | 빨라야 함 | 느려도 됨 |
자원 | 효율적이어야 함 | 보다 많이 사용해도 됨 |
위험 노출 | 사용자에게 위험한 작업을 노출시키지 않아야 함 | 개발 도구, 디버깅 코드를 이용해 오류를 더 쉽게 찾아내고 수정 |
개발 단계에서는 속도, 자원 사용을 타협하면서 개발 도구와 디버깅 코드를 적극적으로 사용해 오류를 더 쉽게 찾고 수정할 수 있다.
하지만 운영 버전에서는 성능 최적화와 자원 사용을 고려해, 불필요한 디버깅 코드 및 개발 도구를 제거하고, 사용자에게 안전하고 안정적인 경험을 제공해야 한다.
Introduce Debugging Aids Early
디버깅 도구는 빨리 도입할수록 많은 도움이 된다.
Use Offensive Programming
개발 단계에서 예외적인 경우를 좀 더 명확하게 보이게 하고, 운영 단계에서 그 오류를 더 원활히 처리되도록 하는 방법이다.
예를 들어 case
문의 default
를 생각해보자. 개발 단계에서는 눈에 띄는 경고를 표시하는 방법을 선택할 수 있다. 한편 운영 환경에서는 경고를 출력하기보다는 오류를 로그 파일에 기록하거나 시스템을 좀 더 스무스하게 회복시키는 방법을 선택할 수 있다.
- 어설션을 이용해, 조건을 만족하지 않으면 프로그램을 종료시키기
- 할당된 메모리를 완전히 채워 할당 오류를 빠르게 감지할 수 있도록 하기
- 할당된 파일이나 스트림을 완전히 채워, 파일 형식 에러를 방지
default
나else
문이 프로그램을 종료하게 만들게 해, 오류를 놓치는 경우가 없도록 하기- 객체가 삭제되기 전에 쓰레기 데이터로 채우기
- 프로그램이 에러 로그 파일을 이메일로 보내도록 만들어, 배포된 소프트웨어에서 발생하는 모든 종류의 에러를 확인하기
Plan to Remove Debugging Aids
나만 쓰는 경우에는 디버깅 코드를 프로그램에 남겨둬도 좋지만, 상업적인 용도의 코드의 경우, 사이즈와 속도의 개선이 필요하므로 디버깅 코드를 삭제하는 것이 좋다.
- 버전 관리 도구 및 빌드 도구 사용하기
- 버전 관리 도구를 이용하면, 같은 소스 파일의 다른 버전을 빌드할 수 있다.
- 개발 모드에서는 디버그 코드를 포함하도록, 운영 모드에서는 이를 제외하도록 만든다.
- 빌트인 전처리기 사용
- C++과 같이 전처리기를 제공하는 프로그래밍 환경의 경우, 컴파일러 스위치를 이용해 디버그 코드를 포함/제외시킬 수 있다.
- 전처리기를 직접 이용하거나, 매크로를 사용하면 된다.
//C++ Example of Using the Preprocessor Directly to Control Debug Code
#define DEBUG
...
#if defined( DEBUG )
// debugging code
...
#endif
단순히 DEBUG
매크로를 정의해 사용하지 않고 DEBUG
매크로에 값을 할당해, 필요한 로깅 레벨에 따라, 혹은 특정 목적을 가지는 조건문을 작성할 수도 있다. 아래는 #if defined()
문이 코드 여기저기에 흩어지지 않게 하면서 전처리기 매크로를 작성한 코드 예시다.
//C++ Example of Using a Preprocessor Macro to Control Debug Code
#define DEBUG
#if defined( DEBUG )
#define DebugCode( code_fragment ) { code_fragment }
#else
#define DebugCode( code_fragment )
#endif
...
DebugCode(
statement 1;
statement 2;
...
statement n;
);
...
- 자체 전처리기 작성하기
- 언어 자체에 전처리기가 없더라도 직접 작성하는 일은 간단하다.
- 디버그 코드에 대한 컨벤션을 만들고, 프리컴파일러가 해당 컨벤션을 따르게 만든다.
- 자바의 경우 프리컴파일러가
//#BEGIN DEBUG
,//#END DEBUG
라는 키워드에 반응 수 있도록 만들 수 있다.
- 디버깅 스텁(stub) 사용하기
- 디버깅 스텁은 디버깅 중에는 여러 작업을 수행하는 함수가 실제로 호출되게 하면서, 생산 환경에서는 그 함수가 빠르게 종료되도록 만드는 간단한 대체 함수다.
- 이를 통해 개발 중에는 충분한 디버깅을 수행하고, 운영 환경에서는 성능을 최적화할 수 있다.
- 작은 성능 페널티가 있기는 하지만, 자체 전처리기를 만드는 일보다 훨씬 간단하다.
8.7. Determining How Much Defensive Programming to Leave in Production Code
개발 중에는 즉시 오류를 인지해 수정할 수 있도록 해야하지만, 운영 중에는 오류가 사용자 경험에 최소한의 영향을 미치도록 처리해야 한다.
- 중요한 에러를 확인하는 코드는 남겨라
- 프로그램의 주요 기능에 큰 영향을 주는 경우는 남겨야 한다.
- 사소한 에러를 확인하는 코드는 제거해라
- 프로그램 기능에 거의 영향을 미치지 않는 경우, 에러 코드를 제거하거나 비활성화 해야한다.
- 하드 크래시를 일으키는 코드는 제거하라
- 개발 중에는 오류가 눈에 띄어야 하므로 크래시 코드가 필요할 수도 있지만, 우영 중에는 사용자가 데이터를 잃지 않도록 에러를 처리할 수 있는 방법을 제공해야 한다.
- 프로그램을 정상적으로 종료하도록 돕는 코드는 남겨라
- 디버깅 코드나 에러 감지 코드를 사용해 프로그램이 치명적인 오류를 만나더라도 정상적으로 종료되도록 한다.
- 기술 지원을 위한 에러 로그는 남겨라
- 디버깅 도구나 에러 메시지를 운영 코드에 남기되, 사용자에게는 영향을 미치지 않게 해야 한다.
- 친절하게 에러 메시지를 남겨라
- 에러메시지는 사용자에게 친절하고 명확하게 전달되어야 한다.
- 기술적 내용보다는 문제 해결을 위한 방향을 제시하는 것이 좋다.
8.8. Being Defensive About Defensive Programming
너무 방어적인 프로그래밍에만 집중하면 몇 가지 문제가 발생할 수도 있다.
- 전달되는 모든 데이터를 확인하면, 프로그램이 크고 느려질 것이다.
- 방어적 프로그래밍에 필요한 추가 코드들이 소프트웨어의 복잡도를 높일 수도 있다.
- 방어적 프로그래밍을 한다고 해서 결함이 없다는 것은 아니다.
따라서 어디에서 방어적 프로그래밍을 적용할지 신중하게 결정하는 게 필요하다. 모든 코드에 대해 방어적인 처리를 하는 것보다는, 중요하고 치명적인 오류가 발생할 수 있는 부분에 대해 집중하는 것이 더 효율적이다.