현대 프로그래밍 언어들이 예외 처리의 이전 합의에서 벗어나, 호출 지점에 실패 가능성을 표시하고 오류를 값으로 다루는 새로운 오류 관리 모델로 수렴하고 있다는 관찰.
2025년 12월 29일
이 이야기는 이전에도, 한 번 이상 누군가 말했던 것 같지만, 잠깐 짚고 넘어가고 싶다. 대부분의 현대 언어들이 Joe Duffy의 The Error Model에 설명된 오류 관리 접근법으로 수렴했다. 이는 예외 처리에 대한 이전의 합의에서 세대가 바뀌는 수준의 전환이다.
C++, JavaScript, Python, Java, C#은 대체로 동등한 throw, catch, finally 구문을 갖고 있고, 런타임 의미론과 타이핑 규칙도 대략 비슷하다. Haskell, OCaml, Scala 같은 함수형 언어들조차도(커뮤니티 일부에서는 사용을 꺼리긴 하지만) 문법에서 예외가 두드러진다.
그런데 Go, Rust, Swift, Zig도 마찬가지라고 할 수 있다! 이들의 오류 처리는 서로 비슷하며, 앞의 무리와는 꽤 다르다(코틀린과 다트는, 음, 주목할 만한 예외이긴 하지만). 현대적 오류 처리의 공통점 몇 가지는 다음과 같다.
첫째, 그리고 가장 눈에 띄는 점은, 실패할 수 있는 함수가 호출 지점에서 주석(표기)된다는 것이다. 예전 방식은 대략 이런 식이었다:
Widget widget = make_widget();
새 방식은 이런 식이다:
let widget = make_widget()?;``const widget = try make_widget();``let widget = try makeWidget()
widget, err := makeWidget()
if err != nil {
return err
}
특정 연산이 실패 가능하다는 것을 독자에게 알려 주는 문법적 표식이 있다(표식의 장황함은 언어마다 다르지만). 작성자 입장에서는, 이 표식 덕분에 함수 계약을 “실패하지 않음”에서 “실패할 수 있음”(또는 그 반대)으로 바꾸려면 함수 정의 자체뿐 아니라 호출 체인 전체를 바꿔야 한다. 반면, 실패 가능한 함수의 가능한 오류 집합에 새로운 오류 조건을 추가하는 것은 일반적으로 재-전파(rethrow) 호출 지점을 다시 검토하도록 강요하지 않는다.
둘째, 감지 가능한 버그가 발생했을 때 동작하는 별도의, 구분되는 메커니즘이 있다. Java에서는 인덱스 범위 초과나 널 포인터 역참조(프로그래밍 오류의 예)가 운영상의 오류(operational error)와 동일한 언어 메커니즘을 사용한다. Rust, Go, Swift, Zig는 별도의 패닉(panic) 경로를 사용한다. Go와 Rust에서는 패닉이 스택을 언와인드(unwind)하고, 라이브러리 함수를 통해 복구 가능하다. Swift와 Zig에서는 패닉이 전체 프로세스를 중단(abort)한다. 하위 레이어의 운영상 오류는 상위 레이어에서 프로그래밍 오류로 분류될 수 있으므로, 대체로 오류 결과 값을 패닉으로 격상시키는 메커니즘이 있다. 하지만 더 중요한 것은 그 반대다. “보통의(ordinary)” 계산만 하는 함수도 버그가 있을 수 있고 실패할 수 있지만, 그런 실패는 _재앙적(catastrophic)_으로 간주되며 타입 시스템에는 드러나지 않고, 런타임에서도 충분히 투명하다.
셋째, 실패 가능한 계산의 결과는 Rust의 Result<T, E>처럼 일급 값이다. 일반적으로 오류만을 위해 독점적으로 마련된 타입 시스템 장치는 많지 않고, try 식은 그 Go 주문(go spell)보다 약간 더한 수준의 문법 설탕인 경우가 많다. Swift는 예외적으로 오류를 특별 취급하므로 이는 Swift에는 해당하지 않는다. 예를 들어 제네릭 map 함수는 오류를 명시적으로 고려해야 하고, 조기 중단(bail early) 결정을 하드코딩한다:
func map<T, E>(
_ transform: (Self.Element) throws(E) -> T
) throws(E) -> [T] where E : Error
Swift는 오류를 위한 일급 분류자 타입도 제공한다.
예외를 전파하는 대신 처리하고 싶다면, 처리는 여러 문장 블록에서 어떤 오류든 다루는 방식이 아니라, 단일 특정 오류를 다루기 위해 단일 throwing 표현식에 국소화된다:
let widget = match make_widget() {
Ok(it) => it,
Err(WidgetError::NotFound) => default_widget(),
};
let widget = make_widget() catch |err| switch (err) {
error.NotFound => default_widget(),
};
Swift는 다시 전통적인 try/catch에 더 가깝게 남아 있지만, 흥미롭게도 Kotlin에는 try 식이 있다.
남아 있는 가장 큰 차이는 오류 값이 어떤 모양을 가지느냐이다. 이 부분은 여전히 연구 영역처럼 느껴진다. 근본적인 긴장 관계 때문에 어려운 문제다:
한편으로, 더 낮은 레벨에서는 오류를 철저히 열거하고 싶다. 그래야
다른 한편으로, 더 높은 레벨에서는 서로 다른 여러 하위 시스템의 광범위한 기능을 구체적 오류에 신경 쓰지 않고(다만 다음 정도만 고려하며) 이어 붙이고 싶다:
이 두 극단은 잘 알려져 있다. 철저함(exhaustiveness)에는 합 타입(sum type)이 최고다(Rust의 enum). 이것이, 내 생각엔, 왜 진자가 체크드 예외(checked exceptions) 쪽으로 다시 돌아온 것처럼 보이는지 설명하는 핵심 조각 중 하나다.
Java에서는 메서드가 여러 예외 중 하나를 던질 수 있다:
void f() throws FooException, BarException;
중요하게도, 이 쌍에 대해 추상화할 수가 없다. 호출 체인은 두 경우를 반복해서 나열해야 하거나, 정보를 잃고 슈퍼클래스로 타입 소거(type erase)해야 한다. 전자는 세 번째 변형이 추가되면 체인 전체를 업데이트해야 하는 불쾌한 부작용이 있다. Java 스타일의 체크드 예외는 “N에서 N+1로” 전이(transition)에 민감하다. 현대의 값 중심 오류 관리는 “0에서 1로” 전이에만 민감하다.
그럼에도, 내가 언젠가 다시 Java를 쓰게 된다면, 모든 throwing 메서드에 대해 throws Exception 같은 거친(granular하지 않은) 시그니처로 표준화하고 싶다는 유혹을 강하게 느낄 것이다. 이것이 정확히 _두 번째_로 잘 알려진 극단이다. 즉 타입 소거된 범용(universal) 오류 타입이 있고, 함수의 “던질 수 있음(throwableness)”은 1비트의 정보만 담는다. 우리는 그 함수가 던질 수 있는지 여부만 신경 쓰고, 오류 자체는 무엇이든 될 수 있다. 여전히 동적 오류 값을 다운캐스트하여 특정 조건을 처리할 수 있지만, 다운캐스팅은 컴파일러가 검사해 주지 않는다. 즉, 다운캐스팅은 “안전(safe)”하며 오류 처리 메커니즘 자체에서 패닉이 발생하지는 않겠지만, 내가 처리하고 있는 오류가 실제로 발생 가능한지, 그리고 어떤 오류는 처리되어야 하는데 처리되지 않고 있는지 확신할 수 없다.
Go와 Swift는 Midori처럼 일급 범용 오류를 제공한다. Swift 4부터는 타입을 더 좁힐 수도 있다.
Rust는 오류에 관해 아주 강한 관례가 있는 편은 아니지만, 초창기에는 대부분 enum이었고, 이후 failure와 anyhow가 범용 오류 타입에 스포트라이트를 비췄다.
하지만 전반적으로, “중간 지점(midpoint)” 오류 처리는 두 극단 중 어느 쪽에서도 잘 지원되지 않는 듯하다. 더 큰 애플리케이션에서는 오류 종류(kind)를 어느 정도는 신경 쓰게 되고, 처리에서 철저해야 하는 곳도 보통 몇 군데 있다. 하지만 그 몇 군데에 필요한 타입을 꿰어 넣는 것은 코드베이스의 나머지 부분까지 감염시키며, 결국 많은 “죽은(dead)” 변형을 가진 “모든 것이 들어 있는 자루(bag of everything)” 오류 타입으로 이어진다.
Zig는 흥미롭게도 대부분 폐쇄 세계(closed-world) 컴파일 모델을 가정하고, 함수 간 추론(cross-function inference)에 의존하여 누가 무엇을 던질 수 있는지 알아낸다.
이 이야기에서 내가 가장 매혹적으로 느끼는 것은 세대적 측면이다. 예외에 대해 강한 합의가 있었고, 이후 체크드 예외는 실패라는 합의가 있었는데, 이제 갑자기 “오류는 값이다(errors are values)” 철학의 형태로, 비틀린 방식이긴 하지만 “체크드 예외”로 돌아왔다. 2000년대의 침체기와 지난 10년의 산업적 PLT 르네상스 사이에 대체 무엇이 일어났던 걸까?