개요
방어적 프로그래밍은 예측할 수 없는 입력, 잘못된 사용, 하드웨어 오류, 통신 장애 등 다양한 비정상적 상황 속에서도 소프트웨어가 계속 기능하도록 설계하는 방어적 설계의 한 형태이다. 목표는 소프트웨어가 예상치 못한 입력이나 사용자 행동에도 예측 가능한 방식으로 동작하도록 만드는 데 있으며, 이는 결과적으로 소프트웨어 전체 품질을 높이고 소스 코드의 가독성을 개선하는 효과로 이어진다.
방어적 프로그래밍은 특히 고가용성, 안전성, 보안이 요구되는 시스템에서 널리 사용된다. 이 가운데 보안에 초점을 맞춘 하위 집합은 별도로 시큐어 코딩이라 불리며, 이 경우의 관심사는 가용성이나 안전성이 아니라 보안 그 자체에 있다. 다만 시큐어 코딩 역시 다른 방어적 프로그래밍과 마찬가지로 버그를 줄이는 것을 1차 목표로 삼는다는 점은 동일하다.
방어적 프로그래밍은 단일 기법이 아니라 입력 검증, 어서션, 방어적 복사, 불변 객체, 널 객체 패턴, 신뢰 경계 설정, 오류 및 예외 처리 전략 등 여러 기법을 아우르는 하나의 프로그래밍 철학에 가깝다. 실무 개발자 커뮤니티에서 이 용어는 흔히 두 가지 상호 보완적인 의미로 함께 쓰인다. 첫째는 소프트웨어가 정상적으로 동작하는 한 항상 참이어야 할 가정을 어서션으로 명시적으로 드러내는 프로그래밍 스타일이며, 둘째는 더 넓은 범위의 입력을 수용함으로써 오류에 더 강건하게 만드는 프로그래밍 스타일이다. 이 두 해석은 서로 배치되지 않지만, 실제로는 상당히 다른 코드를 낳기 때문에 이 문서에서도 두 갈래를 구분하여 다룬다.
역사와 배경
초기 개념: 어서션과 프로그램 검증
방어적 프로그래밍의 뿌리는 프로그램의 정확성을 증명하기 위한 어서션 개념까지 거슬러 올라간다. 앨런 튜링은 1949년 케임브리지에서 열린 강연 “Checking a Large Routine”에서, 프로그램을 검사하는 사람의 부담을 줄이기 위해 프로그래머가 개별적으로 검증 가능한 명확한 단정문들을 남겨야 하며 이로부터 전체 프로그램의 정확성이 도출되어야 한다고 제안했다. 이는 오늘날 어서션이라 부르는 개념의 초기 형태로 평가되며, 이후 1960년대 로버트 플로이드의 루프 불변조건 연구와 토니 호어의 공리적 프로그램 검증 논문을 통해 형식적으로 다듬어졌다.
Code Complete와 대중화
방어적 프로그래밍이라는 용어와 구체적 기법들을 실무 개발자 사이에 널리 알린 저작으로는 스티브 맥코넬의 Code Complete가 꼽힌다. 이 책은 별도의 장을 할애하여 잘못된 입력으로부터 프로그램을 보호하는 방법, 어서션의 사용법, 오류 처리 기법과 예외, 오류로 인한 피해를 특정 영역에 가두는 배리케이드 기법, 그리고 프로덕션 코드에 어느 정도의 방어적 코드를 남겨야 하는지에 대한 지침을 체계적으로 다룬다. 앤드류 헌트와 데이비드 토마스의 실용주의 프로그래머 역시 이러한 태도를 프래그머틱 파라노이아라는 표현으로 설명한 바 있다.
시큐어 코딩과의 결합
웹 애플리케이션과 네트워크로 연결된 시스템이 보편화되면서, 방어적 프로그래밍의 상당 부분은 보안 취약점을 예방하는 시큐어 코딩 관행과 결합되었다. OWASP의 시큐어 코딩 관행 가이드는 애플리케이션과 상호작용하는 모든 대상, 즉 사용자 입력뿐 아니라 API 응답, 쿠키, 심지어 내부 서비스까지도 잠재적으로 신뢰할 수 없다고 가정할 것을 권고하며, 이러한 방어적 태도가 애플리케이션의 기본 동작이어야지 선택적으로 덧붙이는 기능이어서는 안 된다고 강조한다.
레거시 코드는 방어적 프로그래밍 원칙 없이 설계된 경우가 많아 새로 설계된 코드보다 품질이 낮을 수 있다. 예를 들어 ASCII 입력을 가정하고 작성된 코드가 이후 UTF-8 입력을 받게 되거나, 32비트 아키텍처에서 컴파일되고 검증된 코드가 64비트 환경에서 부호 판정이나 형 변환 문제를 새롭게 일으키는 경우, 혹은 오프라인 환경을 전제로 설계된 코드가 네트워크에 연결되면서 새로운 공격 표면에 노출되는 경우가 대표적인 사례로 꼽힌다.
기본 개념과 용어
신뢰 경계와 배리케이드
방어적 프로그래밍의 핵심 개념 중 하나는 신뢰 경계, 즉 배리케이드를 설정하는 것이다. 경계 바깥의 모든 데이터는 위험한 것으로 간주하고, 경계 안쪽은 이미 검증을 마친 안전한 영역으로 취급한다. 배리케이드에 해당하는 코드에서는 모든 입력 데이터의 형(type), 길이, 값의 범위가 올바른지 검사하며, 이때 한계값과 경계값을 이중으로 확인한다. 잘못된 데이터임이 확인된 이후에는 이를 어떻게 처리할지 결정하는 별도의 절차가 따른다.
외부는 사용자, 외부 시스템, 파일, 네트워크 등 프로그램이 통제할 수 없는 모든 데이터 발생원을 포괄하며, 이 경계를 어디에 그을 것인지는 모듈 단위, 컴포넌트 단위, 혹은 서비스 단위로 설계자가 정한다.
정확성과 강건성
방어적 프로그래밍을 설계할 때는 정확성과 강건성이라는 두 가지 가치가 종종 충돌한다는 점을 이해할 필요가 있다. 정확성은 부정확한 결과를 절대 반환하지 않는 것을 의미하며, 이 관점에서는 결과를 아예 반환하지 않는 편이 잘못된 결과를 반환하는 것보다 낫다. 반면 강건성은 설령 결과가 때때로 부정확하더라도 소프트웨어가 계속 동작하도록 만드는 것을 의미한다. 안전이 중요한 시스템은 대체로 강건성보다 정확성을 우선하는 반면, 소비자 대상 애플리케이션은 정확성보다 강건성을 우선하는 경향이 있다. 완전히 멈춰버리는 것보다는 어떤 결과라도 내놓는 편이 낫다고 보기 때문이다. 어떤 방어적 기법을 선택할지는 결국 이 두 가치 중 어느 쪽에 더 무게를 둘 것인가에 대한 설계 판단에서 출발한다.
내부 오류와 외부 오류
오프렌시브 프로그래밍 논의에서 제시되는 유용한 구분으로, 오류를 외부에서 유입된 예상 가능한 오류와 프로그램 내부의 논리적 결함에서 비롯된 예방 가능한 오류로 나누는 관점이 있다. 외부에서 유입되는 오류, 예를 들어 사용자가 잘못된 값을 입력하거나 외부 API가 예상 밖의 응답을 보내는 경우는 아무리 가능성이 낮아 보여도 정상적인 처리 경로의 일부로 다루어야 한다. 반면 프로그램 자신의 논리적 가정이 깨졌음을 나타내는 내부 오류는 그 자리에서 즉시 드러나야 하며, 마치 정상적인 상황인 것처럼 감추거나 우회해서는 안 된다. 이 구분은 뒤에서 다룰 어서션과 일반적인 오류 처리를 언제 각각 사용해야 하는지 판단하는 기준이 된다.
핵심 원칙
아무것도 신뢰하지 않는다
방어적 프로그래밍의 출발점은 프로그램과 상호작용하는 모든 대상, 즉 사용자 입력, 외부 시스템의 응답, 설정 파일, 심지어 같은 코드베이스 안의 다른 모듈까지도 잠재적으로 잘못되었거나 악의적일 수 있다고 가정하는 태도이다. 이 원칙은 OWASP의 시큐어 코딩 지침에서도 핵심 축으로 제시되며, 검증과 방어가 예외적으로 추가하는 옵션이 아니라 애플리케이션의 기본값이 되어야 한다는 입장으로 이어진다.
실패를 조기에, 명확하게 드러낸다
페일 패스트(fail fast) 원칙은 문제가 발생했을 때 이를 숨기거나 그럴듯한 기본값으로 얼버무리지 않고, 즉시 눈에 띄는 방식으로 실패시키는 접근이다. 예를 들어 설정 파일에서 어떤 값을 읽어오지 못했을 때 임의의 기본값을 반환하도록 만들면 당장은 프로그램이 정상적으로 보이지만, 실제 운영 환경에서는 원인을 알 수 없는 이상 동작으로 이어지기 쉽다. 반대로 그 즉시 예외를 던지도록 만들면 결함의 위치가 명확해진다. 즉시 그리고 눈에 띄게 실패하는 방식은 언뜻 소프트웨어를 더 취약하게 만드는 것처럼 보이지만, 실제로는 버그를 더 쉽게 찾고 고칠 수 있게 만들어 결과적으로 더 견고한 소프트웨어로 이어진다는 것이 이 원칙의 핵심 주장이다. 어서션은 이러한 페일 패스트 전략을 실현하는 대표적인 도구로 꼽힌다.
책임의 소재를 코드에 남긴다
어서션이나 사전조건 검사와 같은 방어적 장치는 단순히 오류를 막는 역할에 그치지 않고, 특정 시점에 어떤 조건이 성립해야 하는지에 대한 프로그래머의 가정을 코드 안에 문서로 남기는 역할도 한다. 이러한 조건은 코드가 스스로를 검사하도록 만들 뿐 아니라, 후속 개발자가 해당 코드의 전제와 보장 사항을 이해하는 데도 도움을 준다.
최소한의 신뢰와 경계의 명확화
어디까지를 검증된 안전한 영역으로 볼 것인지, 그 경계를 명확히 정의하는 일 자체가 하나의 원칙으로 다루어진다. 경계가 흐릿하면 어느 계층에서 검증이 이루어졌는지 알 수 없게 되어, 중복 검증으로 인한 낭비와 검증 누락으로 인한 취약점이 동시에 발생하기 쉽다.
주요 기법
입력 검증
입력 검증은 방어적 프로그래밍에서 가장 먼저 적용되는 방어선이다. OWASP의 입력 검증 가이드는 화이트리스트 방식, 즉 허용 목록 기반 검증을 권장한다. 허용 목록 검증은 무엇이 허용되는지를 명시적으로 정의하고 그 외의 모든 것을 거부하는 방식으로, 날짜나 우편번호, 이메일 주소처럼 구조가 명확한 데이터라면 정규 표현식을 이용한 강력한 검증 패턴을 정의할 수 있다. 반대로 금지 목록, 즉 블랙리스트 방식은 알려진 악성 패턴을 걸러내는 보조적인 방어선으로는 유용하지만, 주된 검증 수단으로 의존해서는 안 된다.
입력 검증은 값의 형, 길이, 범위뿐 아니라 경계값을 이중으로 점검하는 작업까지 포함한다. 서버 측에서 이루어지는 검증은 클라이언트 측 검증과 달리 우회할 수 없기 때문에 신뢰 경계의 실질적인 방어선 역할을 한다.
입력 검증과 짝을 이루는 개념으로 출력 인코딩이 있다. 검증을 통과한 데이터라도 이를 다른 컨텍스트, 예를 들어 HTML 본문이나 자바스크립트, URL 매개변수로 내보낼 때는 그 데이터가 실행 가능한 코드가 아니라 순수한 데이터로 취급되도록 맥락에 맞는 인코딩을 적용해야 한다. 잘못된 인코딩 방식은 효과가 없거나 오히려 새로운 취약점을 만들 수 있다.
어서션
어서션은 코드의 특정 지점에서 항상 참이어야 하는 조건을 명시하는 구문이다. 조건이 거짓으로 판명되면 프로그램은 스스로 결함이 있다고 간주하고 즉시 실패를 알린다. 에펠(Eiffel)과 같은 언어에서는 어서션이 설계 과정 자체의 일부를 이루는 반면, C나 자바 같은 언어에서는 런타임에 가정을 점검하는 용도로만 쓰인다. 대부분의 언어는 어서션을 전역적으로, 때로는 개별적으로 활성화하거나 비활성화할 수 있게 해 주며, 흔히 개발 단계에서는 활성화하고 출시 시점에는 비활성화하는 방식이 사용된다. 어서션 검사를 비활성화하면 평가 비용을 아낄 수 있지만, 비정상적인 상황에서는 어서션이 있었다면 잡아냈을 오류를 놓칠 위험도 함께 커진다.
사전조건, 사후조건, 불변조건
어서션은 문서화 수단으로도 기능한다. 루틴이 실행되기 전에 성립해야 하는 상태를 사전조건, 루틴 실행이 끝난 뒤 성립해야 하는 상태를 사후조건이라 부르며, 클래스 전체에 걸쳐 항상 유지되어야 하는 조건을 불변조건이라 부른다. 이 세 가지 개념은 뒤에서 다룰 계약에 의한 설계 방법론의 근간을 이루기도 하지만, 계약에 의한 설계를 채택하지 않은 언어에서도 어서션 형태로 널리 활용된다.
방어적 복사
방어적 복사는 클래스가 가변 객체를 매개변수로 받거나 내부의 가변 필드를 반환할 때, 원본이 아닌 복사본을 사용하는 기법이다. 이는 클래스를 사용하는 클라이언트가 의도적이든 실수든 그 클래스의 불변조건을 깨뜨리려 할 수 있다는 가정에서 출발한다. 예를 들어 시작일과 종료일을 받아 기간을 나타내는 불변 객체를 만들 때, 생성자에 전달된 날짜 객체의 참조를 그대로 저장해 버리면 호출자가 나중에 그 날짜 객체를 변경함으로써 불변이어야 할 내부 상태를 바꿔버릴 수 있다.
방어적 복사는 매개변수의 유효성을 검사하기 전에 이루어져야 한다는 점이 중요하게 강조된다. 검사와 복사 사이에 시간차가 있으면 그 틈을 노려 다른 스레드가 값을 바꾸는 경쟁 조건, 이른바 검사와 사용 시점 사이의 경쟁(time-of-check to time-of-use)이 발생할 수 있기 때문이다. 또한 복사할 객체의 타입이 신뢰할 수 없는 대상에 의해 서브클래싱될 수 있는 경우, clone 메서드를 이용한 복사는 피해야 한다는 지침도 함께 제시된다.
방어적 복사는 성능 비용을 수반한다. 이 비용과 안전성 사이의 상충 관계에 대해, 이 기법을 대중화한 조슈아 블로크는 성능 문제를 지나치게 이르게 공략하지 말아야 하며, 애초에 불변 객체를 구성 요소로 사용하면 복사할 필요 자체가 사라진다는 점을 대안으로 제시한 바 있다.
불변 객체
불변 객체는 한 번 생성된 뒤에는 상태가 변하지 않는 객체로, 방어적 프로그래밍의 여러 문제를 근본적으로 해소하는 수단으로 꼽힌다. 상태가 바뀌지 않으므로 여러 스레드가 동시에 접근해도 동기화가 필요 없고, 맵의 키나 집합의 원소로 사용해도 값이 바뀌어 자료구조의 불변조건이 깨지는 일을 걱정할 필요가 없다. 특별한 이유가 없는 한 클래스는 불변으로 설계해야 하며, 불변으로 만들 수 없다면 가변성을 최대한 제한해야 한다는 지침이 널리 받아들여진다.
불변 클래스를 만들 때는 세터를 제공하지 않고, 모든 필드를 한 번만 초기화되도록 선언하며, 클래스 자신이 서브클래싱되어 불변성이 깨지지 않도록 상속을 제한하고, 내부의 가변 필드에 대해서는 접근자가 원본이 아닌 방어적 복사본을 반환하도록 한다. 불변 객체의 단점으로는 값이 하나 바뀔 때마다 별도의 객체를 새로 만들어야 하므로, 크고 자주 변경되는 데이터에는 비용이 클 수 있다는 점이 지적된다.
널 객체 패턴
널 객체 패턴은 특정 타입의 유효한 인스턴스가 없는 상황을 널 참조 대신, 아무 동작도 하지 않는 특수한 객체로 표현하는 기법이다. 이 패턴은 제임스 코플린이 1995년 Pattern Languages of Program Design 시리즈를 통해 처음 발표했으며, 감마 등이 저술한 디자인 패턴 책에 실린 23가지 고전 패턴에는 포함되어 있지 않다.
호출하는 쪽에서 반환값이 널인지 아닌지를 매번 검사해야 하는 대신, 기대되는 인터페이스를 그대로 구현하되 내부 동작이 비어 있는 널 객체를 돌려주면 호출자는 그 객체가 진짜인지 널 객체인지 신경 쓰지 않고 동일한 방식으로 다룰 수 있다. 예를 들어 전화번호로 고객을 조회하는 함수가 일치하는 고객을 찾지 못했을 때 널을 반환하는 대신 빈 내용을 가진 특수한 고객 객체를 반환하도록 하면, 이후 코드에서 반복되는 널 검사를 줄일 수 있다. 다만 널 객체가 하는 일 없음의 의미를 상황마다 다르게 요구하는 경우에는 여러 종류의 널 객체 클래스가 필요해질 수 있으며, 이는 애초에 추상 인터페이스의 의미가 잘 정의되지 않았다는 신호로 해석되기도 한다. 널 객체는 진짜 객체로 전환되지 않는다는 점도 이 패턴의 특징으로 언급된다. 만약 하나의 객체가 아무 것도 하지 않는 상태에서 실제 동작을 하는 상태로 바뀌어야 한다면, 이는 널 객체 패턴이 아니라 상태 패턴이나 프록시 패턴으로 다루어야 할 문제다.
신뢰 경계와 배리케이드 구축
앞서 다룬 원칙을 실제 코드 구조로 옮기면, 시스템의 특정 지점에 명확한 검증 계층을 두고 그 안쪽에서는 이미 검증된 데이터만 존재한다고 가정할 수 있도록 설계하는 형태가 된다. 이렇게 하면 내부 로직 곳곳에 중복된 방어 코드를 흩뿌리는 대신, 경계를 지키는 소수의 지점에 검증 책임을 집중시킬 수 있다.
오류 및 예외 처리 전략
방어적으로 오류를 감지한 이후에는 이를 어떻게 처리할지 결정해야 한다. 대표적으로 다음과 같은 선택지가 있다.
- 오류가 발생한 상황에 가장 적합한 값을 반환한다.
- 다음으로 유효한 데이터로 대체한다.
- 이전의 정상 값을 재사용한다.
- 가장 가까운 유효한 값으로 대체한다.
- 오류를 로그로 남기고 그대로 계속 진행한다.
- 오류 처리 루틴이나 오브젝트를 호출한다.
- 지역적으로 오류를 처리할 수 있을 때만 오류 코드를 반환한다.
- 예외를 발생시켜 호출 스택 위쪽으로 오류를 전파한다.
- 오류 발생 즉시 프로그램을 종료시킨다.
이 가운데 무엇을 선택할지는 다시 정확성과 강건성 사이의 균형이라는 문제로 돌아간다. 예외는 신중하게 사용하면 오류 처리 코드가 정상 흐름의 코드에 뒤섞이는 것을 막아 복잡도를 낮추는 효과가 있지만, 남용하면 오히려 제어 흐름을 추적하기 어렵게 만들어 코드를 거의 따라갈 수 없게 만든다는 지적도 함께 제기된다.
코드 예시 (Node.js / TypeScript)
아래 예시들은 위에서 설명한 기법을 타입스크립트로 옮긴 것이다.
사전조건 검증과 커스텀 에러 클래스
입력이 예상 범위를 벗어나는 경우 즉시, 그리고 명확하게 실패시키는 페일 패스트 방식의 예시이다. 일반 Error 대신 의미가 드러나는 커스텀 에러 클래스를 사용하면 호출자가 오류의 종류를 구분해 처리할 수 있다.
class InvalidArgumentError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidArgumentError";
}
}
interface CreateUserInput {
email: string;
age: number;
}
function createUser(input: CreateUserInput): CreateUserInput {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
throw new InvalidArgumentError(`유효하지 않은 이메일 형식입니다: ${input.email}`);
}
if (!Number.isInteger(input.age) || input.age < 0 || input.age > 150) {
throw new InvalidArgumentError(`age는 0에서 150 사이의 정수여야 합니다: ${input.age}`);
}
return { email: input.email, age: input.age };
}
이 예시에서 검증은 함수 본문의 가장 앞부분, 즉 신뢰 경계에 해당하는 위치에서 이루어진다. 검증을 통과한 이후의 코드는 email과 age가 항상 유효한 값이라고 가정하고 작성할 수 있으며, 이는 함수 내부 곳곳에 산재한 방어 코드를 줄여 준다.
방어적 복사
가변 객체를 매개변수로 받거나 반환할 때 원본 참조 대신 복사본을 사용하는 예시이다.
class DateRange {
private readonly start: Date;
private readonly end: Date;
constructor(start: Date, end: Date) {
const startCopy = new Date(start.getTime());
const endCopy = new Date(end.getTime());
if (startCopy.getTime() > endCopy.getTime()) {
throw new RangeError("start는 end보다 앞선 시각이어야 합니다.");
}
this.start = startCopy;
this.end = endCopy;
}
getStart(): Date {
return new Date(this.start.getTime());
}
getEnd(): Date {
return new Date(this.end.getTime());
}
}
const start = new Date("2026-01-01");
const end = new Date("2026-12-31");
const range = new DateRange(start, end);
start.setFullYear(1999);
console.log(range.getStart());
생성자에서 매개변수를 복사한 뒤 유효성을 검사하는 순서에 주목할 필요가 있다. 검사를 먼저 하고 나중에 복사하면, 그 사이의 짧은 시간 동안 다른 코드가 원본 객체를 변경할 여지가 남는다.
불변 객체와 타입 시스템 활용
타입스크립트의 readonly 한정자와 Object.freeze를 함께 사용하면 컴파일 시점과 런타임 양쪽에서 불변성을 강제할 수 있다.
interface Money {
readonly amount: number;
readonly currency: "KRW" | "USD" | "EUR";
}
function createMoney(amount: number, currency: Money["currency"]): Readonly<Money> {
if (amount < 0) {
throw new RangeError("amount는 음수가 될 수 없습니다.");
}
return Object.freeze({ amount, currency });
}
function add(a: Readonly<Money>, b: Readonly<Money>): Readonly<Money> {
if (a.currency !== b.currency) {
throw new Error("서로 다른 통화는 더할 수 없습니다.");
}
return createMoney(a.amount + b.amount, a.currency);
}
add 함수는 매개변수를 변경하지 않고 새로운 Money 객체를 만들어 반환한다. 애초에 값이 불변이므로 방어적 복사가 필요하지 않다.
어서션 함수
타입스크립트는 asserts 키워드를 이용한 어서션 함수를 지원한다. 어서션이 실패하면 예외를 던지고, 성공하면 이후 코드에서 타입 내로잉의 이점까지 함께 얻을 수 있다.
class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = "AssertionError";
}
}
function assertIsDefined<T>(value: T, message: string): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new AssertionError(message);
}
}
function processOrder(order: { id: string; total: number | null }) {
assertIsDefined(order.total, `주문 ${order.id}의 total이 계산되지 않은 상태로 처리 단계에 도달했습니다.`);
console.log(order.total.toFixed(2));
}
여기서 total이 null인 상황은 주문 처리 로직 자체의 결함, 즉 내부 오류로 간주된다. 사용자 입력 오류처럼 우아하게 복구할 대상이 아니라, 개발 과정에서 원인을 찾아 고쳐야 할 가정 위반이므로 어서션으로 즉시 드러내는 편이 적합하다.
널 객체 패턴
로깅 기능이 설정에 따라 켜지거나 꺼질 수 있는 상황을 널 객체 패턴으로 구현한 예시이다.
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[LOG] ${message}`);
}
}
class NullLogger implements Logger {
log(_message: string): void {
// 의도적으로 아무 동작도 하지 않는다.
}
}
function createLogger(enabled: boolean): Logger {
return enabled ? new ConsoleLogger() : new NullLogger();
}
function runTask(logger: Logger) {
logger.log("작업을 시작합니다.");
}
runTask 함수는 로거가 실제로 동작하는지 아무 일도 하지 않는지 신경 쓸 필요 없이 동일한 방식으로 호출할 수 있다. 널 검사를 위한 조건문이 호출부에서 사라지는 것이 이 패턴의 핵심 이점이다.
방어적 프로그래밍과 계약에 의한 설계
핵심 차이점
계약에 의한 설계는 버트런드 마이어가 프로그래밍 언어 에펠의 일부로 고안한 방법론으로, 컴포넌트와 그 클라이언트 사이의 관계를 사전조건, 사후조건, 불변조건이라는 세 종류의 단정문으로 이루어진 공식적인 계약으로 규정한다. 계약이라는 비유가 시사하듯, 이 방법론의 핵심은 일관성 조건에 대한 책임을 클라이언트와 서플라이어 사이에 분배하되 결코 중복시키지 않는 데 있다. 예를 들어 어떤 연산이 음수와 비음수를 다르게 처리해야 한다면, 그 연산 자신이 두 경우를 모두 처리하고 그 결과를 사후조건으로 명시하거나, 아니면 클라이언트가 비음수만 전달하도록 책임지고 이를 사전조건으로 규정하는 두 방식 중 하나를 선택해야 한다.
이 지점에서 계약에 의한 설계는 방어적 프로그래밍과 정면으로 대비된다. 이 방법론을 창안한 마이어 본인의 표현을 빌리면, 방어적 프로그래밍은 적용 가능성을 검사하는 코드로 연산을 처음부터 끝까지 겹겹이 감싸는 관행이며, 검사가 많을수록 더 안전하다고 전제한다. 계약에 의한 설계의 관점에서 보면 이는 위험한 접근이다. 클라이언트와 서플라이어 양쪽 모두가 같은 조건을 검사하게 되면, 누구도 명확히 책임지지 않는 상황이 벌어져 오히려 오류가 스며들 여지가 생긴다는 것이다.
이러한 오해는 실무에서 드물지 않게 나타난다. 마이어는 진정한 계약에 의한 설계를 실천하고 있는지 가늠하는 간단한 기준을 제시한 바 있다. 어떤 루틴에 사전조건을 선언해 두었다면, 그 루틴의 본문에 남아 있던 대응 검사, 즉 조건문이나 예외 처리를 완전히 걷어낼 의향이 있는가 하는 것이다. 그렇지 않다면 계약이라는 용어를 실질적인 설계 규율이 아니라 표현상의 장식으로 쓰고 있을 뿐이라는 지적이다. 실제로 마이어는 마이크로소프트의 코드 컨트랙트가 사전조건을 선언하고도 여전히 런타임에 그 조건을 검사하고 예외를 던진다는 점에서, 이러한 기준을 충족하지 못한다고 비판한 바 있다.
두 접근의 비교
도널드 파이어스미스는 계약에 의한 설계와 방어적 개발을 정면으로 비교하는 연구를 통해 두 접근의 강점과 약점을 다음과 같이 정리했다.
| 관점 | 계약에 의한 설계 | 방어적 프로그래밍 |
|---|---|---|
| 책임의 소재 | 클라이언트와 서플라이어 사이에 분배하고 중복시키지 않는다 | 원칙적으로 양쪽 모두가 스스로를 방어한다 |
| 신뢰 전제 | 계약을 맺은 상대방이 자신의 의무를 이행할 것이라고 신뢰한다 | 상대방이 계약을 어길 수 있다고 가정하고 신뢰하지 않는다 |
| 서플라이어 코드의 복잡도 | 사전조건 검사를 생략할 수 있어 상대적으로 단순해진다 | 방어 코드가 늘어나 복잡도가 높아지기 쉽다 |
| 클라이언트 코드의 부담 | 사전조건을 만족시킬 책임이 클라이언트에게 넘어간다 | 서플라이어가 대부분의 검증을 대신 떠맡는다 |
| 관심사 분리 | 정상 처리 코드와 검증 코드가 뒤섞이기 쉽다 | 정상 처리, 위반 감지, 위반 처리를 물리적으로 분리할 수 있다 |
| 다중 클라이언트 상황 | 서플라이어 하나에 여러 클라이언트가 있으면 각자 사전조건을 중복 검사해야 할 수 있다 | 서플라이어가 한 번만 검증하면 되므로 중복이 줄어든다 |
파이어스미스는 이 비교를 바탕으로 방어적 개발 쪽이 더 완전한 명세를 제공하며, 계약에 의한 설계는 상대적으로 사전조건 위반과 그에 따른 예외 처리보다 단정문 자체를 강조하는 경향이 있다고 결론지었다. 다만 이 결론이 계약에 의한 설계 진영에서 폭넓게 받아들여진 정설이라기보다는, 두 방법론의 트레이드오프를 드러내는 하나의 관점으로 이해하는 편이 적절하다. 실무에서는 두 접근을 배타적으로 택하기보다, 시스템의 경계에서는 방어적으로 검증하고 내부 컴포넌트 사이에서는 계약에 가까운 방식으로 책임을 분배하는 절충적 설계가 흔히 관찰된다.
방어적 프로그래밍과 공격적 프로그래밍
공격적 프로그래밍은 소프트웨어 버그를 다루는 또 다른 철학으로, 오류를 숨기거나 복구하려 시도하는 대신 프로그램이 빠르고 눈에 띄게 실패하도록 만드는 접근이다. 예상하지 못한 내부 오류는 실행 중인 소프트웨어가 견뎌내야 할 대상이 아니라 프로그래머가 고쳐야 할 대상이라는 전제에서 출발한다. 이 접근은 방어적 프로그래밍의 한 갈래로 분류되는데, 오류를 다루는 하나의 전략이라는 점에서는 같은 뿌리를 공유하기 때문이다. 그러나 기본값으로 오류를 감추거나 저하된 상태로나마 계속 동작하도록 만드는 다른 방어적 기법들과는 상반된다. 공격적 프로그래밍은 대신 어서션과 같은 도구를 사용해 잘못된 상태가 감지되는 즉시 프로그램을 멈춤으로써, 문제의 원인을 더 쉽게 찾아내고 고칠 수 있게 만든다.
공격적 프로그래밍의 관심사는 프로그래머의 가정을 반증하는 데 있으며, 오류 메시지를 만들어내는 일은 그 다음의 부차적인 목표로 취급된다. 이 접근이 강조하는 또 하나의 원칙은 불필요한 검사를 만들지 않는 것이다. 다른 소프트웨어 컴포넌트가 명세된 대로 동작할 것이라고 신뢰함으로써, 알려지지 않은 문제를 검사 코드로 덮어버리지 않는다는 것이 이 원칙의 기본 발상이다. 이는 앞서 다룬 계약에 의한 설계의 정신과도 상당 부분 맞닿아 있으며, 결국 어디까지를 검증할 것이고 어디서부터는 신뢰할 것인지에 대한 판단이 방어적 프로그래밍 전체를 관통하는 공통 주제임을 보여 준다.
처리 흐름 다이어그램
신뢰 경계를 기준으로 입력을 검증하는 흐름을 도식화하면 다음과 같다.
오류를 발견했을 때 이를 예상 가능한 외부 오류로 처리할 것인지, 아니면 프로그램 자신의 결함으로 보고 어서션으로 즉시 실패시킬 것인지 판단하는 흐름은 다음과 같이 정리할 수 있다.
장점
방어적 프로그래밍을 적용하면 얻을 수 있는 이점은 다음과 같이 정리된다.
- 버그가 더 이른 시점에, 더 명확한 위치에서 드러나므로 디버깅 비용이 줄어든다.
- 잘못된 입력이나 예외적인 상황에서도 소프트웨어가 예측 가능한 방식으로 반응한다.
- 신뢰 경계가 명확해지면서 코드 내부의 나머지 부분은 이미 검증된 데이터를 다룬다고 가정할 수 있어 로직이 단순해진다.
- 어서션과 사전조건이 일종의 살아 있는 문서로 기능하여, 코드의 전제와 보장 사항을 후속 개발자에게 전달한다.
- 보안 관점에서 보면 입력 검증과 출력 인코딩이 인젝션이나 크로스 사이트 스크립팅과 같은 공격 표면을 줄여 준다.
- 여러 방어 계층을 겹겹이 두는 심층 방어 전략과 결합하면, 한 계층이 뚫리더라도 다른 계층이 피해를 막아줄 수 있다.
한계와 비판
과도한 방어적 코드의 문제
방어적 프로그래밍을 지나치게 적용하면 그 자체가 새로운 문제의 원천이 된다. 방어 코드로 삽입된 부분 역시 다른 코드와 마찬가지로 결함을 포함할 수 있으며, 서둘러 작성된 방어 코드일수록 오히려 결함이 발견될 가능성이 더 높다는 지적이 있다. 방어적으로 작성되었다는 사실 자체가 그 코드의 정확성을 보장해 주지는 않는다.
복잡성과 성능 비용
모든 함수의 모든 매개변수, 모든 변수 대입 뒤에 검사를 붙이면 코드는 정작 중요한 검사를 알아보기 어려운 잡음으로 뒤덮이게 된다. 성능이 민감한 경로에서는 반복적인 검증과 방어적 복사가 무시할 수 없는 비용을 유발하기도 한다. 이 때문에 실무에서는 어떤 검사를 프로덕션 코드에 남기고 어떤 검사를 제거할 것인지에 대한 판단이 별도의 설계 결정으로 다루어진다.
버그를 숨길 위험
일부 방어적 기법, 특히 오류를 감추고 그럴듯한 기본값으로 대체하는 방식은 근본 원인을 드러내지 않은 채 증상만 완화함으로써 오히려 문제를 발견하기 어렵게 만들 수 있다는 비판을 받는다. 이는 앞서 다룬 페일 패스트 원칙 및 공격적 프로그래밍이 등장한 배경이기도 하다. 계약에 의한 설계 진영에서는 한 걸음 더 나아가, 클라이언트와 서플라이어 양쪽이 동일한 조건을 중복해서 검사하면 누구도 명확히 책임지지 않는 상황이 벌어져 결과적으로 오류가 스며들 여지가 커진다고 지적한다.
실무 적용 가이드라인
어디에 방어선을 세울 것인가
방어적 검증은 시스템 전체에 균일하게 뿌리기보다, 신뢰 경계가 지나가는 지점에 집중시키는 편이 일반적으로 권장된다. 외부 세계와 맞닿는 API 경계, 파일과 네트워크로부터 데이터를 읽어들이는 지점, 서로 다른 팀이나 조직이 소유한 컴포넌트 사이의 경계가 대표적인 대상이다. 반대로 이미 검증을 마친 데이터만 흐르는 내부 모듈 사이에서까지 동일한 검증을 반복하면, 중복된 코드가 늘어날 뿐 아니라 계약에 의한 설계 관점에서 지적된 책임 분산의 문제도 함께 떠안게 된다.
프로덕션에 남길 방어 코드 결정하기
개발 단계에서 유용했던 어서션이나 디버깅 보조 장치를 출시 버전에서도 그대로 유지할지는 상황에 따라 판단해야 한다. 안전이 중요한 시스템이라면 런타임 검사를 프로덕션에서도 유지하여 잘못된 상태에서 계속 동작하는 것을 막는 편이 안전할 수 있고, 성능이 중요한 소비자용 소프트웨어라면 충분히 검증된 이후 일부 검사를 걷어내는 편이 합리적일 수 있다. 다만 사용자 입력처럼 개발 과정에서 완전히 통제할 수 없는 대상에 대한 검증은 출시 이후에도 남겨 두어야 한다는 점은 대체로 일치된 의견이다.
관련 원칙과의 연계
SOLID 원칙과 최소 권한
방어적 프로그래밍은 단일 책임 원칙이나 인터페이스 분리 원칙과 같은 SOLID 원칙과도 맞닿아 있다. 신뢰 경계를 명확히 나누는 작업은 결국 각 컴포넌트가 자신의 책임 범위를 명확히 하는 작업과 다르지 않으며, 최소 권한 원칙에 따라 각 컴포넌트에 필요한 최소한의 접근 권한만 부여하는 설계는 방어 대상이 되는 표면 자체를 줄여 준다.
정적 타입 시스템과의 관계
타입스크립트와 같은 정적 타입 시스템은 컴파일 시점에 형과 관련된 상당수의 오류를 미리 걸러내 준다는 점에서, 런타임 방어 코드의 일부를 대체하는 역할을 한다. 다만 타입 시스템은 값의 존재나 형만을 보장할 뿐, 이메일 형식이나 나이의 범위처럼 도메인 특유의 제약까지는 표현하지 못하는 경우가 많다. 이 때문에 실무에서는 타입 시스템으로 표현 가능한 제약은 타입으로, 그 외의 런타임 제약은 검증 함수나 어서션으로 나누어 다루는 방식이 흔히 사용된다.
참고 자료
- Wikipedia contributors, “Defensive programming”, Wikipedia, https://en.wikipedia.org/wiki/Defensive_programming
- Wikipedia contributors, “Defensive design”, Wikipedia, https://en.wikipedia.org/wiki/Defensive_design
- Wikipedia contributors, “Offensive programming”, Wikipedia, https://en.wikipedia.org/wiki/Offensive_programming
- Wikipedia contributors, “Null object pattern”, Wikipedia, https://en.wikipedia.org/wiki/Null_object_pattern
- Wikipedia contributors, “Assertion (software development)”, Wikipedia, https://en.wikipedia.org/wiki/Assertion_(software_development)
- McConnell, Steve (2004), Code Complete, Second Edition, Microsoft Press
- Bloch, Joshua (2018), Effective Java, Third Edition, Addison-Wesley
- Shore, Jim (2004), “Fail Fast”, IEEE Software, https://martinfowler.com/ieeeSoftware/failFast.pdf
- Meyer, Bertrand (1997), Object-Oriented Software Construction, Second Edition, Prentice Hall
- Meyer, Bertrand, “Design by Contract”, ETH Zurich, https://se.inf.ethz.ch/~meyer/publications/old/dbc_chapter.pdf
- Meyer, Bertrand (2012), “The Manhood Test”, Bertrand Meyer’s technology+ blog, https://bertrandmeyer.com/2012/07/30/the-manhood-test/
- Meyer, Bertrand (2022), “Right and Wrong: Ten Choices in Language Design”, arXiv:2211.16597, https://arxiv.org/pdf/2211.16597
- Firesmith, Donald G. (1999), “A Comparison of Defensive Development and Design by Contract”, Technology of Object-Oriented Languages and Systems (TOOLS USA’99) Conference Proceedings, IEEE Computer Society, pp. 258-267
- OWASP Foundation, “Input Validation Cheat Sheet”, OWASP Cheat Sheet Series, https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- OWASP Foundation, “Secure Coding Practices - Quick Reference Guide”, https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/stable-en/02-checklist/05-checklist
- Samek, Miro, “Design by Contract for Embedded Software”, state-machine.com, https://www.state-machine.com/dbc