assert를 반드시 고쳐야 한다

2 hours ago 2
  • assert는 사전 조건·사후 조건·불변식을 코드로 명시하는 장치이며, 타입 시스템으로 강제 가능한 제약은 언어 기능으로 표현하는 편이 바람직함
  • Zig의 std.debug.assert는 매크로가 아닌 일반 함수로, unreachable을 통해 도달 불가능한 경로를 표시하고 최적화에도 활용됨
  • Debug·ReleaseSafe에서는 실패한 assert가 panic으로 크래시하지만, ReleaseFast·ReleaseSmall에서는 unchecked illegal behavior로 잘못 동작할 수 있음
  • 프로덕션에서 assert를 끄면 잘못된 가정을 빨리 발견할 기회를 잃고, 이후 코드가 틀린 assert에 의존해 취약점으로 이어질 수 있음
  • ReleaseSafe와 ReleaseFast 중 무엇을 고를지는 프로그램 우선순위에 달렸지만, 핵심은 assert를 덮어 끄지 말고 잘못된 assert를 고쳐야 한다는 데 있음

assert의 역할과 Zig의 기본 동작

  • assert는 “이 인자는 null일 수 없음”, “이 정수는 짝수일 수 없음” 같은 조건이 항상 참이어야 한다는 사실을 코드로 표현하는 장치임
    • 예: assert(my_arg != null);, assert(my_num % 2 != 0);
    • 타입 시스템으로 제약을 강제할 수 있다면 assert보다 언어 기능을 쓰는 편이 나음
    • Zig에서는 일반 포인터 *Foo가 null이 될 수 없고, 선택 포인터 ?*Foo는 null일 수 있지만 값에 접근하기 전 확인을 강제함
  • assert는 사전 조건, 사후 조건, 불변식을 명시하는 데 적합함
    • 좋은 assert는 프로그래밍 실수를 잡는 데 단위 테스트보다 강력할 수 있음
    • 퍼징과 함께 쓰면 assert의 효과가 더 커질 수 있음

Zig의 unreachable과 assert

  • Zig의 assert는 잘못된 코드 경로를 표시하는 언어 기능인 unreachable 에 기반함
    • switch에서 도달할 수 없는 분기를 .a => unreachable처럼 표시할 수 있음
    • unreachable은 문(statement)으로도, 어떤 타입의 표현식이 필요한 위치에서도 사용할 수 있음
    • 도달 불가능한 경우에 임시 값을 억지로 만들 필요가 없음
  • Zig 표준 라이브러리의 std.debug.assert는 다음처럼 구현됨 pub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure }
  • unreachable 정보는 최적화에 활용될 수 있음
    • 컴파일러는 도달 불가능한 경로를 제거할 수 있고, 이 정보가 전파되며 비지역적인 최적화가 가능해짐
    • 모든 assert가 성능 향상으로 이어지지는 않지만, 프로그래머가 쉽게 예상하지 못하는 최적화도 가능함

빌드 모드와 런타임 안전성

  • Zig에는 Debug, ReleaseSafe, ReleaseFast, ReleaseSmall 빌드 모드가 있음
    • 이 설정은 프로그램 전체에 반드시 전역으로만 적용되지 않음
    • 각 의존성을 서로 다른 모드로 빌드할 수 있고, @setRuntimeSafety를 쓰면 함수 내부 블록 단위로도 런타임 안전성을 조정할 수 있음
  • assert 실패는 Zig에서 “illegal behavior”가 됨
    • Checked 모드인 Debug, ReleaseSafe, @setRuntimeSafety(true)에서는 panic으로 프로그램이 크래시됨
    • Unchecked 모드인 ReleaseFast, ReleaseSmall, @setRuntimeSafety(false)에서는 “unchecked illegal behavior”가 발생해 프로그램이 잘못 동작함
  • unchecked illegal behavior의 결과는 보장되지 않음
    • 예시의 switch에서는 현재 생성되는 머신 코드 특성상 다른 분기로 넘어가는 것처럼 보일 수 있음
    • 다른 컴파일러 버전에서는 전혀 다른 잘못된 동작이 나올 수 있음
    • 관련 동작은 godbolt 예시에서 확인할 수 있음
  • assert와 이후 switch가 ReleaseSafe와 ReleaseFast에서 어떻게 달라지는지는 또 다른 godbolt 예시로 확인 가능함
    • ReleaseFast에서는 함수가 모든 비교를 건너뛰고 true를 반환하는 형태가 나타남
    • 이런 최적화는 비디오게임과 기타 실시간 미디어 애플리케이션이 크게 의존하는 종류의 동작임

Zig assert는 매크로가 아님

  • Zig의 std.debug.assert는 매크로가 아니라 일반 함수
    • Zig에는 매크로가 없음
    • C/C++ 개발자가 Zig에 접근할 때 특히 놀라는 지점임
  • C/C++에서는 assert를 비활성화하면 assert 호출 전체와 전달된 표현식이 주석 처리된 것처럼 동작하는 방식이 흔함
    • 그래서 C/C++에서는 assert에 부작용이 있는 표현식을 넣으면 안 됨
    • assert가 비활성화될 때 해당 연산 자체가 사라질 수 있기 때문임
  • Zig에서는 함수 호출 규칙에 따라 인자가 함수 호출 전에 평가됨
    • std.debug.assert 내부 로직과 무관하게 인자 표현식은 평가됨
    • 따라서 다음처럼 부작용이 있는 표현식도 assert에 넣을 수 있음
    // assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist"));
  • 반대로 assert 조건을 계산하기 위해 복잡한 연산이 필요하면 unchecked 모드에서도 그 계산이 반드시 제거되지 않을 수 있음
    • 이런 경우에는 comptime if로 코드를 보호해야 함
    const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); }
  • C/C++ 의미론에 익숙하면 낯설 수 있지만, Zig에서는 assert를 일반적으로 비활성화하지 않는다는 전제가 깔려 있음

프로덕션에서 assert를 끄는 문제

  • assert에는 크게 세 가지 선택지가 있음
    • 런타임 체크로 유지하고 실패 시 프로세스를 panic으로 크래시하게 함
    • assert를 성능 최적화에 사용하되, assert가 틀렸을 때 프로그램 오동작을 감수함
    • assert를 완전히 비활성화함
  • std.debug.assert는 assert 완전 비활성화를 기본 지원하지 않음
    • 빌드 시점 플래그를 내부에서 확인하는 자체 assert를 구현하면 C/C++ 방식에 가까운 동작을 만들 수 있음
  • assert를 끄고 싶어지는 이유는 보통 두 가지가 결합된 결과임
    • 성능 비용이나 애플리케이션 크래시가 싫어서 런타임 체크를 유지하고 싶지 않음
    • assert가 항상 올바르다고 믿기 어려워, 최적화에 쓰였을 때 발생할 수 있는 오동작을 두려워함
  • matklad가 관련 논의에서 상기시킨 것처럼, 크래시를 피해야 할 정당한 엔지니어링 이유가 있는 상황은 존재함
    • 하지만 일반적인 소프트웨어에서 크래시 회피를 기본값으로 삼는 것은 나쁜 선택으로 평가됨
  • assert를 비활성화하면 불가능하다고 가정한 조건이 실제로 발생해도 프로그램이 계속 실행됨
    • 프로그램은 틀린 가정 아래 계속 실행되고, 이는 unchecked illegal behavior가 아니더라도 오동작의 한 형태임
  • unchecked illegal behavior나 C의 undefined behavior가 위험한 이유는 프로그램을 weird machine으로 바꾸는 경로가 될 수 있기 때문임
    • 충분히 복잡한 소프트웨어에서는 UIB가 없어도 프로그램이 의도하지 않은 방식으로 비틀릴 수 있음
    • 런타임에 assert가 거짓이 되는 것은 명세에서 벗어나는 일이며, 그 자체로 의도하지 않은 작업을 수행하게 만들 수 있음
    • SQL injection은 UIB 없이도 weird-machine급 오동작을 일으키는 구체적이고 널리 퍼진 예임
  • 프로그램 오동작 비용이 너무 높다면 assert를 켜 두는 편이 맞음
    • 성능이 매우 중요해 오동작 위험을 감수할 수 있다면 assert를 최적화 기회로 쓰는 편이 맞음
    • assert를 비활성화하면 성능을 놓치면서도 실제보다 더 안전하다고 착각하기 쉬움

잘못된 assert가 코드베이스를 속이는 방식

  • 핵심 위험은 틀린 assert가 테스트에서는 드러나지 않고 프로덕션에서만 실패할 수 있다는 데 있음
    • 모든 assert가 항상 참이라고 보장할 수 있다면 assert를 최적화에 쓰는 것은 논란이 되지 않음
    • 테스트가 모든 잘못된 assert를 잡는다고 보장할 수 있다면 프로덕션 최적화도 안전해짐
    • 실제로는 잘못된 assert를 작성할 수 있고, 테스트가 반드시 잡아주지도 않음
  • assert를 프로덕션에서 끄면 잘못된 assert를 최대한 빨리 발견할 기회를 잃음
    • 더 심각한 문제는 이후 코드가 그 잘못된 assert에 의존해 계속 작성된다는 데 있음
  • 예시 코드에서는 processThing이 이미 시작된 thing에서만 호출되어야 한다는 가정을 assert로 둠 fn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... }
  • 이 assert가 테스트에서는 실패하지 않고, 프로덕션에서는 비활성화되어 실제로 거짓이 될 수 있다는 사실을 놓칠 수 있음
    • 사용자에게 관찰되는 오동작이 없으면 문제가 없는 것처럼 보이고 개발이 계속됨
  • 이후 누군가 thing이 이미 시작되었으므로 추가 준비 없이 baz를 호출해도 된다고 보고 코드를 추가할 수 있음 fn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... // Since thing is already started, we don't // need to foo the bar before bazzing the qux. // It would be really bad to baz the qux otherwise, // so we add an assert for good measure. assert(thing.is_fooed); thing.baz(qux); }
  • 두 번째 assert 자체가 논리적으로 맞더라도, 첫 번째 assert가 실제로는 거짓이 될 수 있다면 위험이 생김
    • 테스트에서는 첫 번째 assert가 실패하지 않으므로 두 번째 assert도 실패하지 않음
    • 프로덕션에서는 assert가 비활성화되어 취약점이 코드베이스에 들어오는 순간을 알아차리지 못할 수 있음
  • 코드 안의 assert가 개발자를 속이는 상태라면 올바른 코드를 작성하는 일이 불합리하게 어려워짐

선택지는 프로그램의 우선순위에 따라 달라짐

  • 프로그램마다 우선순위가 다르며, 어떤 프로그램은 오동작 위험 최소화보다 성능을 우선하는 것이 정당할 수 있음
    • 이 경우 assert를 최적화 기회로 바꾸는 선택은 자연스러움
  • 프로덕션에서 assert를 관성적으로 비활성화하는 것은 assert를 켜 두는 것보다도, 성능 최적화를 적극 활용하는 것보다도 열등한 선택으로 평가됨
    • ReleaseFast에 대해 매우 비판적이면서 assert 비활성화는 무비판적으로 받아들이는 태도는 모순적임
  • Zine은 정적 사이트 생성기이며, 현재 주로 개인 블로그 빌드에 사용됨
    • 위협 모델이 정의되어 있지 않고, 그것이 최우선순위도 아님
    • Hugo보다 한 자릿수(order of magnitude) 빠르게 실행되는 점을 선호해 ReleaseFast 빌드를 배포함
  • Awebo는 pre-alpha 단계의 셀프호스팅 가능한 Discord 대안임
    • 개인 정보를 다루고 인터넷에 노출될 소프트웨어라는 점이 이미 명확함
    • 배포 시점에는 ReleaseSafe 빌드를 제공할 계획임
    • 다만 FFmpeg, Xiph Opus, SQLite 같은 핵심 의존성 일부는 ReleaseFast로 빌드할 예정임
    • 해당 의존성에서는 성능 향상이 프로그램 오동작 위험을 더 줄이는 것보다 명확히 더 중요하다고 판단됨

실제 프로젝트들의 선택과 보안 사례

Zig에서 완전히 사라지지 않는 암묵적 assert

  • 자체 assert는 비활성화할 수 있더라도, Zig 언어 자체가 코드에 암묵적으로 추가하는 assert는 비활성화할 수 없음
    • 정수 오버플로, 0으로 나누기, 배열 범위 초과 등이 여기에 해당함
    • 이런 조건은 런타임 panic을 일으키거나 최적화 목적으로 사용됨
  • 프로덕션 assert 비활성화 관행은 잘못된 assert가 코드베이스 안에서 썩고 늘어나게 만들 수 있음
    • 그 결과 UIB에 대한 편집증이 커지고, 개발자들이 assert를 다시 켜서 결과를 마주하기를 무의식적으로 두려워하게 될 수 있음
  • 피할 수 없는 결론은 assert를 비활성화해 덮는 것이 아니라 잘못된 assert를 고쳐야 한다는 것임
    • 프로그램 정확성은 일부 하위 집합이 아니라 전체를 대상으로 추구해야 함
Read Entire Article