8장: 방어적인 프로그래밍
8장: 방어적인 프로그래밍
- 방어적인 프로그래밍은 자기 코드가 항상 올바르다고 주장하는 태도가 아님
- 방어 운전처럼, 다른 코드와 외부 입력이 잘못 움직여도 자기 루틴이 망가지지 않게 만드는 태도임
- 프로그램에는 잘못된 데이터, “절대 일어나지 않을” 사건, 다른 프로그래머의 실수가 들어올 수 있다고 보고 작성함
8.1 잘못된 입력으로부터 프로그램 보호
쓰레기 입력을 처리하기위한 세가지 방법
- 외부 입력은 파일, 사용자, 네트워크, 외부 인터페이스처럼 신뢰할 수 없는 경계에서 들어오므로 값의 범위와 형식을 확인함
- 숫자는 허용 범위 안인지, 문자열은 처리 가능한 길이인지, 식별자는 기대한 형식인지 확인하고 아니면 거부함
- 보안이 중요한 프로그램에서는 버퍼 오버플로, SQL 주입, HTML/XML 주입, 정수 오버플로, 시스템 호출에 전달되는 값까지 의심해야 함
- 루틴 입력 매개변수도 확인 대상임. 차이는 입력이 외부 인터페이스가 아니라 다른 루틴에서 온다는 점뿐임
- 잘못된 입력을 발견한 뒤 어떻게 반응할지도 미리 정해야 함
반복 설계, 코드 전 의사코드 작성, 코드 전 테스트 케이스 작성, 저수준 설계 검사는 결함 삽입 자체를 줄이므로 더 우선됨
8.2 어설션
- 어설션은 개발 중 프로그램이 스스로 가정을 확인하게 하는 코드임
- 코드의 가정을 문서화하고 예상 밖 조건을 찾는 용도로 사용함
어설션으로 확인하기 좋은 가정
- 입력 또는 출력 매개변수 값이 기대 범위 안에 있음
- 파일이나 스트림이 루틴 시작/종료 시점에 열려 있거나 닫혀 있음
- 파일이나 스트림의 위치가 시작/끝에 있음
- 파일이나 스트림 접근 모드가 읽기 전용, 쓰기 전용, 읽기/쓰기 중 기대한 상태임
- 입력 전용 변수 값이 루틴에서 바뀌지 않음
- 포인터가
null이 아님 - 배열이나 컨테이너가 필요한 개수만큼 데이터를 담을 수 있음
- 테이블이 실제 값으로 초기화되어 있음
- 컨테이너가 루틴 시작/종료 시점에 비어 있거나 가득 차 있음
- 고도로 최적화된 루틴의 결과가 느리지만 명확한 루틴의 결과와 일치함
어설션 사용 지침
- 발생할 수 있다고 예상하는 조건에는 오류 처리 코드를 사용하고, 절대 발생하면 안 되는 조건에는 어설션을 사용함
- 어설션 안에는 실행에 필요한 코드를 넣지 않음. 어설션이 꺼지면 그 코드도 사라질 수 있기 때문임
- 선조건과 후조건을 문서화하고 검증하는 데 어설션을 사용할 수 있음
- 신뢰성이 매우 중요한 코드에서는 어설션으로 잘못된 상태를 드러내고, 오류를 처리하라
8.3 오류 처리 기법
- 어설션은 발생하면 안 되는 오류를 다룸
- 실제로 발생할 수 있는 오류는 상황에 맞는 오류 처리 전략을 선택해야 함
대표적인 오류 처리 방식
- 중립값 반환: 숫자는
0, 문자열은 빈 문자열, 포인터는 빈 포인터처럼 해가 적은 값을 돌려줌 - 다음 유효 데이터 사용: 손상된 레코드나 일시적인 센서 오류를 건너뛰고 다음 정상 값을 사용함
- 이전 결과 재사용: 값이 짧은 시간에 크게 바뀌지 않는 맥락에서 직전 값을 사용함
- 가장 가까운 유효값으로 보정: 허용 범위를 벗어난 값을 최소/최대 경계로 보정함
- 경고 로그 기록: 나쁜 데이터를 발견했다는 사실을 기록하고 계속 진행함
- 오류 코드 반환: 상태 변수, 반환값, 예외 등으로 상위 호출자에게 오류를 알림
- 오류 처리 루틴/객체 호출: 오류 처리 책임을 중앙에 모음
- 오류 메시지 표시: 오류가 발생한 곳에서 사용자에게 직접 알림
- 지역적으로 가장 적합한 방식 사용: 각 코드 영역이 자기 상황에 맞게 처리함
- 종료: 잘못된 결과를 내는 것보다 멈추는 것이 나은 안전 중요 시스템에서 사용함
강건성 vs 정확성
- 정확성은 틀린 결과를 내지 않는 것을 우선함. 결과가 없더라도 틀린 결과보다 나음
- 강건성은 일부 결과가 부정확하더라도 프로그램이 계속 작동하는 것을 우선함
- 안전 중요 애플리케이션은 대체로 정확성을 선호함
- 소비자 애플리케이션은 대체로 강건성을 선호함
- 어떤 쪽을 우선할지는 아키텍처나 상위 설계에서 결정할 문제임
오류 처리의 상위 설계 영향
- 잘못된 매개변수를 어떻게 처리할지는 일관되게 결정해야 함
- 오류 처리 방식은 정확성, 강건성, 보안 같은 비기능 요구사항에 직접 영향을 줌
- 하위 코드는 오류를 보고하고 상위 코드가 처리한다는 방식을 택했다면, 상위 코드는 반드시 그 오류를 처리해야 함
- 반환값과 시스템 호출 오류를 무시하지 않음
- “절대 실패하지 않을 것”이라고 생각하는 호출도 확인하는 것이 방어적 프로그래밍의 핵심임
8.4 예외
- 예외는 코드가 직접 처리할 수 없는 오류나 예외적 사건을 호출 계층 위쪽으로 전달하는 수단임
- 잘 쓰면 복잡성을 줄이지만, 일반 흐름에 남용하면 코드를 따라가기 어렵게 만듦
- 예외는 무시되면 안 되는 오류를 알리는 데 유용함
지침
- 정말 예외적인 조건에만 던짐. 자주 발생하는 정상 흐름을 표현하는 데 쓰지 않음
- 지역적으로 처리할 수 있는 오류는 지역적으로 처리함
- 생성자와 소멸자에서 예외를 던지는 것은 규칙이 복잡해지므로, 같은 위치에서 잡을 수 있는 경우가 아니라면 피함
- 루틴이 던지는 예외도 인터페이스의 일부이므로 추상화 수준이 맞아야 함
- 낮은 수준의 파일 오류를 도메인 객체의 공개 인터페이스 밖으로 그대로 노출하면 캡슐화가 깨짐
- 예외 메시지에는 예외가 발생한 배경 정보를 충분히 넣음
- 빈
catch블록은 피함. 정말 무시해도 되는 경우라면 이유를 주석이나 로그로 남김 - 라이브러리가 어떤 예외를 던지는지 알아야 함
- 중앙 예외 보고기를 두면 예외 형식, 메시지, 로깅, 처리 정책을 일관되게 만들 수 있음
- 프로젝트 차원에서 무엇을 던질지, 언제 지역 처리할지, 언제 상위로 던질지, 생성자/소멸자에서 허용할지 표준화함
- 언어가 예외를 제공한다는 이유만으로 예외를 선택하지 말고, 오류 코드·로그·종료 등 대안을 함께 고려함
8.5 오류로 인한 손상을 막기 위한 방책
- barricade는 오류 손상을 제한하는 전략임
- 선박의 격실이나 건물의 방화벽처럼, 문제가 생긴 영역이 전체로 번지지 않게 막음
- 프로그램 안에 “안전한 영역”의 경계를 정하고, 그 경계를 넘는 데이터만 집중적으로 검사함
방책과 어설션의 관계
- barricade 바깥 루틴은 안전한 가정을 할 수 없으므로 오류 처리 코드를 사용함
- barricade 안쪽 루틴은 데이터가 이미 정제되었다고 기대하므로 어설션을 사용함
- 안쪽에서 나쁜 데이터가 발견되면 외부 입력 문제가 아니라 프로그램 내부 오류에 가까움
- 어떤 코드가 안쪽이고 바깥쪽인지 정하는 일은 아키텍처 수준 결정임
8.6 디버깅 보조 도구
- 제품 버전의 제약을 개발 버전에 자동으로 적용하지 않음
- 제품 버전은 빠르고 자원을 아껴야 하지만, 개발 버전은 느려도 오류를 빨리 드러내는 편이 나을 수 있음
- 개발 버전에는 내부 구조 무결성 검사, 디버그 전용 메뉴, 주기적 객체 검사 같은 보조 도구를 넣을 수 있음
- 디버깅 보조 도구는 가능한 한 일찍 넣어야 프로젝트 전체에서 이득을 봄
공격적인 프로그래밍
- 개발 중에는 예외적 상황을 눈에 띄게 만들고, 제품 코드에서는 회복 가능하게 다룸
- 어설션은 프로그램을 중단시켜 문제를 넘기기 어렵게 만듦
- 할당한 메모리와 파일/스트림을 특정 값으로 채워 형식 오류를 드러냄
case의 기본 분기나else는 개발 중 무시하기 어렵게 실패하게 함- 삭제 직전 객체에 쓰레기 값을 채워 잘못된 재사용을 드러냄
- 제품 코드의 오류 로그를 수집할 수 있다면 실제 오류 양상을 관찰함
디버깅 보조 도구 제거 계획
- 디버그 코드를 손으로 넣고 빼는 방식은 피함
- 버전 관리와 빌드 도구로 개발/제품 빌드 구성을 나눔
- 전처리기가 있으면 디버그 코드를 컴파일 스위치로 포함하거나 제외함
- 전처리기가 없는 언어라면 간단한 전처리 규칙을 만들어 사용할 수 있음
- 디버그 스텁을 두어 개발 중에는 깊게 검사하고 제품 버전에서는 빠르게 반환하게 할 수 있음
8.7 제품 코드를 얼마나 방어적으로 프로그래밍할 것인지 정하기
- 개발 중에는 오류가 시끄럽게 드러나는 것이 좋음
- 제품 버전에서는 사용자가 작업을 보존할 수 있게 회복하거나 우아하게 실패하는 것이 좋음
- 중요한 오류를 확인하는 코드는 남김
- 결과가 정말 사소한 오류 검사는 제거하거나 로그만 남김
- 사용자 데이터 손실을 일으킬 수 있는 하드 크래시용 디버그 코드는 제품 버전에서 제거함
- 치명적 오류를 우아하게 처리하도록 돕는 코드는 남김
- 기술 지원에 도움이 되는 로그는 제품 코드에 남길 수 있음
- 사용자에게 보일 내부 오류 메시지는 공격적이거나 개발자 중심적인 표현을 피함
8.8 방어적인 프로그래밍에 대해서 한 번 더 고민하기
- 방어적 프로그래밍도 과하면 문제가 됨
- 모든 매개변수를 모든 위치에서 가능한 모든 방식으로 검사하면 프로그램은 커지고 느려짐
- 방어 코드 자체도 결함이 생길 수 있으며, 대충 작성하면 일반 코드보다 더 위험할 수 있음
- 어디에서 방어해야 하는지 우선순위를 정하고, 너무 많지도 너무 적지도 않게 적용해야 함
참고 자료
- 보안: Howard & LeBlanc, Writing Secure Code
- 어설션: Maguire, Writing Solid Code / Stroustrup, The C++ Programming Language / Meyer, Object-Oriented Software Construction
- 예외: Meyer, Object-Oriented Software Construction / Stroustrup, The C++ Programming Language / Meyers, More Effective C++ / Arnold, Gosling & Holmes, The Java Programming Language / Bloch, Effective Java / Foxall, Practical Standards for Microsoft Visual Basic .NET
체크리스트: 방어적인 프로그래밍
일반
- 루틴이 잘못된 입력 데이터로부터 스스로를 보호하는가?
- 선조건과 후조건을 포함한 가정을 어설션으로 문서화했는가?
- 어설션은 발생하면 안 되는 조건에만 사용했는가?
- 아키텍처나 상위 설계가 오류 처리 기법을 명시하는가?
- 오류 처리가 강건성과 정확성 중 어느 쪽을 우선할지 정했는가?
- 오류 손상을 제한하고 오류 처리 책임 범위를 줄이기 위한 barricade를 만들었는가?
- 코드에 디버깅 보조 도구를 사용했는가?
- 디버깅 보조 도구를 쉽게 켜고 끌 수 있게 설치했는가?
- 방어적 코드의 양이 너무 많지도 적지도 않은가?
- 개발 중 오류를 놓치기 어렵게 하는 공격적 프로그래밍 기법을 사용했는가?
예외
- 프로젝트의 예외 처리 접근 방식을 표준화했는가?
- 예외 사용의 대안을 고려했는가?
- 가능하다면 비지역 예외를 던지지 않고 지역에서 오류를 처리하는가?
- 생성자와 소멸자에서 예외를 던지는 일을 피하는가?
- 모든 예외가 그것을 던지는 루틴의 추상화 수준에 맞는가?
- 각 예외에 관련 배경 정보가 충분히 포함되어 있는가?
- 빈
catch블록이 없는가? 필요하다면 이유가 문서화되어 있는가?보안
- 나쁜 입력 검사 코드가 버퍼 오버플로, SQL 주입, HTML 주입, 정수 오버플로와 같은 악의적 입력을 확인하는가?
- 모든 오류 반환 코드를 확인하는가?
- 모든 예외를 잡는가?
- 오류 메시지가 공격자에게 시스템 공격 힌트를 주지 않는가?
요점 정리
- 제품 코드는
garbage in, garbage out보다 정교하게 오류를 처리해야 한다. - 방어적 프로그래밍은 오류를 더 빨리 찾고, 더 쉽게 고치고, 제품 코드의 손상을 줄인다.
- 어설션은 대형 시스템, 고신뢰 시스템, 자주 바뀌는 코드에서 오류를 초기에 드러내는 데 도움이 된다.
- 잘못된 입력 처리 방식은 중요한 오류 처리 결정이자 상위 설계 결정이다.
- 예외는 정상 코드 흐름과 다른 차원에서 오류를 다루는 수단이다. 유용하지만 다른 오류 처리 기법과 비교해 신중하게 사용해야 한다.
- 제품 시스템의 제약이 개발 버전에 그대로 적용되는 것은 아니다. 개발 버전에는 오류를 빨리 드러내는 코드를 추가할 수 있다.