스스로 트립[wire]에 걸리지 마세요: Zig에서 오류 복구 테스트하기
Tripwire1라는 라이브러리를 만들었습니다. Zig 프로그램에 의도적으로 실패를 주입해 오류 처리 경로를 테스트하기 위한 목적입니다. 단위 테스트 밖에서는 완전히 최적화되어 사라지며 런타임 비용이 전혀 들지 않습니다(시간과 공간 모두).
Zig에는 블록을 빠져나갈 때 오류가 반환되는 경우에만 코드를 실행하는 errdefer라는 언어 기능이 있습니다. 아이디어는 단순합니다. 오류가 발생하면 그때까지 만든 부분적인 효과를 “되돌려야(undo)” 하며, 함수가 오류를 반환할 때 외부 상태가 잘 정의된 상태에 머물도록 해야 한다는 것입니다.
아이러니하게도 오류 정리 코드는 Zig 프로그램에서 가장 오류가 나기 쉬운 부분 중 하나이며, 리소스 누수와 메모리 손상의 단골 원인이 됩니다.
이유는 쉽게 이해할 수 있습니다. 오류 경로 코드는 실행 빈도가 훨씬 낮은 데다 테스트에서 의도적으로 발생시키기도 어렵기 때문입니다. 그 결과 개발 과정에서는 한두 번 머릿속으로만 검토하는 데 그치고, 실제 사용자가 프로덕션에서 해당 경로를 만나기 전까지는 제대로 실행조차 되지 않는 경우가 많습니다.
Zig의 오류
이 섹션에서는 Zig에서 오류가 일반적으로 어떻게 동작하는지에 대한 배경을 설명합니다. Zig의 오류 처리에 이미 익숙하다면 이 부분은 건너뛰어도 좋습니다.
Zig의 모든 함수는 오류 값을 반환할 수 있습니다. 오류 값은 일종의 enum처럼 동작하며 error.OutOfMemory와 같은 형태입니다. 오류 값들은 error set이라 불리는 이름 집합으로 모아집니다. 그리고 함수는 error union이라는 것을 이용해 오류나 성공 값을 반환할 수 있습니다.
error union(대개 함수 호출 결과)을 try로 감싸면 값을 꺼내 쓰거나 오류인 경우 그대로 반환할 수 있습니다.
그리고 마지막으로, 이 글의 핵심이 되는 부분인데, errdefer는 어디에나 둘 수 있으며 오류가 반환될 때(직접 반환하든 try를 통해 반환되든) 현재 스코프에 있는 그 이전의 모든 errdefer 문이 역순으로 실행됩니다.
종합하면 간단한 예제에서는 다음과 같이 보입니다.
fn create(alloc: Allocator) !*Widget {
const self = try alloc.create(Widget);
errdefer alloc.destroy(self);
self.* = try .init(alloc);
errdefer self.deinit();
const file = try std.fs.cwd().openFile("config.txt", .{});
// etc...
return self;
}이 예제에서 기본적인 흐름을 볼 수 있습니다.
Widget을 위한 공간을 할당한 뒤에는 오류가 발생하면 할당을 해제해야 합니다.Widget을 초기화한 뒤에는 오류가 발생하면 초기화를 되돌려야 합니다.- 등등.
이는 아주 전형적인 Zig 패턴으로, Zig 표준 라이브러리 전반과 대부분의 Zig 프로그램에서 볼 수 있습니다. 이 정도는 테스트가 없어도 errdefer가 명백히 올바르다고 할 수 있는 쉬운 경우입니다.
하지만 현실은 금세 복잡해집니다.2
왜 이렇게 취약할까요?
Zig에서 오류 처리 경로를 테스트하는 것이 왜 그토록 어려운지 배경을 이해해 두면 도움이 됩니다.
Zig는 프로그램 정확성을 위한 훌륭한 유틸리티를 제공합니다. 먼저 인덱스 범위 초과부터 null 역참조까지 다양한 런타임 안전 검사를 제공합니다. 다음으로 Zig의 파라미터화된 allocator 덕분에 메모리 부족 시나리오나 제한된 메모리 환경 등을 더 쉽게 테스트할 수 있습니다. 마지막으로 Zig에는 내장 테스트 프레임워크가 있고 문화적으로 테스트 작성을 권장합니다.
하지만 errdefer를 테스트하기 위해 오류를 발생시킬 메커니즘은 없습니다. 메모리 할당 오류의 경우 std.testing.failing_allocator 같은 전용 allocator를 사용할 수는 있지만, 할당이 많고 조건부로 일어나는 복잡한 코드에서 이를 정확히 설정하는 일은 까다롭고 취약합니다.
defer는 블록을 빠져나갈 때 무조건 실행되므로 코드를 단위 테스트하기만 해도 자연스럽게 테스트됩니다. 하지만 errdefer는 오류가 발생했을 때만 실행되므로, 오류를 발생시킬 수 없으면 해당 errdefer도 테스트할 수 없습니다!
게다가 도구 문제를 떠나서도, 오류 처리 코드는 본질적으로 실행 빈도가 낮고 오류가 발생했을 때의 외부 상태는 성공 경로보다 훨씬 복잡한 경우가 많습니다(오류가 어디서 발생했는지에 따라 달라질 수도 있기 때문입니다).
Tripwire
결국 오류 처리 코드를 눈으로 훑어보며 ‘제발 맞기를’ 바라는 일이나, 정확한 오류 경로 하나를 발생시키기 위해 완벽하지만 취약한 시나리오를 만드는 테스트를 몇 시간씩 붙잡고 있는 일에 지쳐 Tripwire를 만들었습니다.
Tripwire는 코드 곳곳에 이름이 붙은 지점을 만들어 테스트 중에 오류를 발생시킬 수 있게 해 주는 작고 단일 파일로 된 라이브러리입니다1. 테스트 밖에서는 완전히 최적화되어 사라지도록 작성되어 있어 메모리도 차지하지 않고 머신 코드도 생성되지 않습니다.
개념적으로는 이렇게 동작합니다.
tripwire가 실패를 주입하는 방식
클릭해서 발생시키기: .alloc_buffer .open_file
fn init(alloc: Allocator) !*Self {
try tw.check(.alloc_buffer);
const buf = try alloc.alloc(u8, 1024);
errdefer alloc.free(buf);
try tw.check(.open_file); ← error injected!
const file = try openFile("config");
errdefer file.close();
return self;
}.open_file에서 오류 발생: 버퍼가 이미 할당되었으므로 errdefer alloc.free(buf)가 반드시 실행되어야 합니다. 이 부분이 빠지거나 잘못되면 테스트는 메모리 누수로 실패합니다!
코드로 보면 다음과 같습니다.
const tripwire = @import("tripwire.zig");
// Define a tripwire module with named failure points. The second
// argument is the function itself to get its error set.
const init_tw = tripwire.module(enum {
alloc_buffer,
open_file,
}, init);
fn init(alloc: Allocator) !*Self {
// Check the tripwire before the fallible operation.
// In tests, this can be configured to return an error.
// In release builds, this compiles to nothing.
try init_tw.check(.alloc_buffer);
const buf = try alloc.alloc(u8, 1024);
errdefer alloc.free(buf);
try init_tw.check(.open_file);
const file = try std.fs.cwd().openFile("config.txt", .{});
errdefer file.close();
// ...
}
test "init error on open_file" {
// Configure the tripwire to fail at the open_file point.
try init_tw.errorAlways(.open_file, error.OutOfMemory);
// Call the function and expect the error.
try std.testing.expectError(error.OutOfMemory, init(std.testing.allocator));
// End the tripwire session and reset state.
// This also verifies the tripwire was actually hit.
try init_tw.end(.reset);
}핵심 아이디어는 std.testing.allocator가 메모리 누수가 발생하면 테스트를 실패시킨다는 점입니다. 따라서 .open_file에서 오류를 발생시키면 errdefer alloc.free(buf)가 실행되도록 강제할 수 있습니다. 만약 해당 errdefer가 빠졌거나 잘못되었다면 테스트는 메모리 누수로 실패할 것입니다. 더 복잡한 시나리오에서는 오류를 발생시킨 뒤 단위 테스트에서 추가적인 상태 검증을 덧붙이면 됩니다.
제가 자주 사용하는 또 다른 패턴은 모든 가능한 실패 지점을 순회하며 완전한 커버리지를 확보하는 것입니다.
test "init handles all error points" {
for (std.meta.tags(init_tw.FailPoint)) |point| {
try init_tw.errorAlways(point, error.OutOfMemory);
try std.testing.expectError(error.OutOfMemory, init(std.testing.allocator));
try init_tw.end(.reset);
}
}앞서 말했듯이 실제로는 tripwire 종료 후에 외부 상태가 정상인지 확인하는 추가적인 검증이 필요할 수 있습니다. 하지만 기본 수준에서도 이 방법만으로 런타임 안전 검사가 실패하거나 누수가 감지되지 않음을 쉽게 보장할 수 있습니다.
errorAlways 외에도 errorAfter를 사용하면 해당 실패 지점에 도달한 횟수가 일정 수준에 이르렀을 때만 오류를 발생시킬 수 있습니다. 그리고 end는 실제로 오류가 발생했는지 검증합니다. 이는 루프 정리 코드가 부실해서 생기는 리소스 누수나 상태 손상을 잡아내는 데 유용합니다.
테스트 밖에서는 비용이 들지 않습니다
테스트 밖에서 Tripwire는 머신 코드를 전혀 생성하지 않고 메모리도 사용하지 않습니다. 완전히 최적화되어 사라집니다.
이를 위해 Zig의 comptime을 이용해 테스트 프레임워크 여부를 감지합니다.
/// Whether our module is enabled or not.
pub const enabled = builtin.is_test;그리고 이를 이용해 조건부로 함수를 no-op으로 만듭니다.
pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
if (comptime !enabled) return;
// actually do stuff
}또한 comptime을 이용해 호출 규약을 설정하여, 활성화되지 않은 경우에는 함수가 인라인되도록 합니다.
/// Our calling convention is inline if our tripwire module is
/// NOT enabled, so that all calls to `check` are optimized away.
fn callingConvention() std.builtin.CallingConvention {
return if (!enabled) .@"inline" else .auto;
}이게 필요 없다는 말을 듣기도 했지만, 실제로 컴파일된 결과에서 본문이 비어 있고 ret 하나만 있는 함수인데도 함수 호출 오버헤드를 그대로 포함한 머신 코드가 생성되는 경우를 보았습니다. 원인을 파악하기 전까지는 인라이닝으로 이 문제를 해결합니다.
마지막으로 Zig 컴파일러는 실제로 참조된(혹은 volatile인) 선언만 분석하고 코드를 생성합니다. Tripwire가 비활성화된 상태에서는 어떤 코드도 이 상태를 참조하지 않으므로, 전역 상태 역시 바이너리에 포함되지 않습니다.
버그, 버그
Tripwire를 Ghostty의 몇 군데에만 적용했는데도 즉시 많은 버그가 드러났습니다. 첫 번째 PR에서만 errdefer 버그를 6개 정도 고쳤습니다. 실제 환경에서 발생했다고 알려진 적은 없었지만 어쨌든 버그인 것은 분명합니다.
그리고 가장 중요한 점은 이제 버그가 수정되었을 뿐만 아니라, 버그의 존재를 검증하는 단위 테스트와 함께 짝을 이루게 되었다는 것입니다! 만약 수정 내용을 되돌리면 테스트가 실패합니다!
앞으로 Ghostty 코드베이스의 더 많은 부분에 Tripwire를 적용하고, 저 자신이든 관리자든 기여자든 새로 작성하는 모든 코드에서 errdefer 테스트를 고려하도록 할 계획입니다.
유용하다고 생각된다면 파일을 그대로 복사해 자신의 프로젝트에서 사용해 보세요! Ghostty는 MIT 라이선스이며, Tripwire는 하나의 파일로 완전히 독립되어 있습니다. 마음껏 활용하시기 바랍니다!
각주
글을 무작위로 읽기