진단 보고와 제어 흐름 분기를 분리해 사고하고, Zig가 타입 시스템으로 오류 코드를 안전한 제어 흐름 도구로 만드는 방식을 설명한다. 에러 유니온, 명시적 폐기, 오류 집합 추적, anyerror, 그리고 진단 싱크 패턴을 다룬다.
2025년 11월 6일
오늘의 두 가지 아이디어:
말하자면, 진단 보고와 문자 그대로의 오류 처리(handling)는 별개로 생각할 가치가 있다. 일반적으로 모든 오류에는 두 가지 목적지가 있다. 오류를 격리 경계까지 버블링해 운영자에게 제시할 수 있다(예: HTTP 500 메시지나 stderr 출력). 또는, 적절한 복구 동작을 취해 오류를 처리할 수 있다.
첫 번째 경우(보고)에는, 오류가 스스로를 어떻게 표시할지 아는 인터페이스이면 충분한 때가 많다. 단, 표시 인터페이스는 고정되어 있지 않다: HTML 출력은 터미널 출력과 다르다. 최종 목적지를 미리 알고 있다면, 보통 그 자리에서 즉시 오류를 렌더링하는 편이 더 단순하다. 그렇지 않다면, 오류를 구조화된 곱타입으로 만들어 사용자에게 표시를 전적으로 통제(요구)할 수 있게 할 수 있다(오류 메시지의 지역화는 좋은 직관 펌프다).
오류에 따라 분기해서 처리해야 한다면, 일반적으로 합타입이 필요하다. 그런데 흥미롭게도, 호출 지점 전반을 통틀어 스택 위에서 실제로 분기가 일어나는 경우의 수는 유한하다. 그래서 최종 표현만 담는 간결한 보고 타입이 있을 수 있듯, 간결한 처리 타입도 서로 다른 코드 경로들의 열거, 즉 오류 코드가 될 수 있다.
늘 그렇듯, Zig의 설계는 (사고를) 도발한다. 이 언어는 “처리” 부분을 언어 차원에서 다루고, 보고의 거의 전부는 사용자에게 맡긴다. Zig는 타입 시스템을 사용해 오류 코드의 문제를 바로잡되, 런타임 의미론은 대부분 그대로 유지한다.
C에서는 오류 코드가 인밴드이고, 유효한 결과를 오류 코드와 혼동하기 쉽다(예: 실수로 kill(-1) 호출). Zig는 타입으로 검사되는 오류 유니온을 사용한다: ReadError!usize는 catch로 명시적으로 언팩해야 한다. 오류 코드는 실수로 무시되기 쉬운데, 컴파일러가 어떤 값이 오류인지를 알기 때문에 Zig는 오류를 무시하려면 특별한 형식을 요구한다: catch {}
멋진 점으로, Zig는 사용하지 않는 모든 값을 명시적으로 폐기해야 하지만, 오류가 아닌 값을 폐기하는 문법은 별도로 다르다:
pub fn main() void {
_ = can_fail();
// ^ error: error union is discarded
can_fail() catch {};
// ^ error: incompatible types: 'u32' and 'void'
_ = can_fail() catch {};
// Works.
}
fn can_fail() !u32 {
return error.Nope;
}
이는 처음에는 실패하지 않는 함수의 결과를 무시했다가, 나중에 함수에 실패 경로가 생겼을 때 오류가 조용히 무시되는 흔한 실수로부터 보호해 준다. 그게 바로 I power letter!
여담으로, 나는 한때 표준 라이브러리의 특정 API에만 #[must_use]를 붙이는 게 좋은지, 아니면 스위프트 스타일로 모든 반환값 사용을 의무화하는 게 좋은지 예전에는 확신이 없었다. 사소한 폐기를 많이 추가하면, 정말 중요한(하중을 지탱하는) 폐기가 소음에 묻힐까 봐 우려했다. Zig를 써 본 뒤로는, 사소한 폐기는 드물고 문제가 되지 않는다고 자신 있게 말할 수 있다(다만 값의 폐기와 오류의 폐기를 문법적으로 구분하는 건 확실히 도움이 된다). 그렇다고 해서 기존 언어에 “반환값 반드시 사용”을 소급해 끼워 넣는 게 좋은 생각이라는 뜻은 아니다! 이런 급진적 변화는 과거에는 타당했던 많은 API 설계 선택을 소급해서 무효화하곤 한다.
Zig는 더 나아가 타입 시스템을 활용해 API가 어떤 오류를 반환할 수 있는지도 추적한다:
pub fn readSliceAll(
r: *Reader,
buffer: []u8,
) error{ReadFailed, EndOfStream}!void {
const n = try readSliceShort(r, buffer);
if (n != buffer.len) return error.EndOfStream;
}
pub fn readSliceShort(
r: *Reader,
buffer: []u8,
) error{ReadFailed}!usize {
// ...
}
이 추적은 가법적(두 함수를 호출하면 오류 집합을 합집합)으로도, 감법적(함수는 오류의 부분집합을 처리하고 나머지를 전파)으로도 동작한다. Zig는 전체 프로그램 컴파일 모델을 활용해 오류 집합을 완전히 추론할 수도 있다. 폐쇄된 세계 모델이기 때문에 상징적 오류 상수에 모호하지 않은 숫자 코드를 부여할 수 있고, 그 결과 포괄형 anyerror 타입도 가능해진다.
하지만 오류 값에서 얻을 수 있는 것은 상징적 이름뿐이다. 언어는 보고를 위한 일급 도구를 제공하지 않으며, 진단 정보는 진단 싱크 패턴을 통해 밴드 밖으로 전달된다:
/// 주어진 슬라이스를 ZON으로 파싱한다.
pub fn fromSlice(
T: type,
gpa: Allocator,
source: [:0]const u8,
diag: ?*Diagnostics,
) error{ OutOfMemory, ParseZon }!T {
// ...
}
호출자가 오류를 처리하고 싶다면 null 싱크를 넘기고 오류 값에 대해 switch를 한다. 호출자가 사용자에게 오류를 표시하고 싶다면 Diagnostics를 넘기고 그로부터 서식화된 출력을 추출하면 된다.