시스템 프로그래밍 언어 생태계를 염두에 두고 오류의 표현·전파·처리·진화까지를 포괄적으로 다루는 오류 모델 설계 논고.
대상 독자: 프로그래밍 언어 설계에 관심이 있고 C의 오류 코드, 체크/언체크 예외, 태그드 유니언, 다형적 변형(variant) 등 여러 언어의 오류 표현에 익숙한 실무자.
예상 읽기 시간: 60~90분.
프로그래밍 언어 연구 논문에서는 특정 부류의 오류를 배제하기 위한 정교한 타입 시스템 기능에 초점이 맞춰지는 경우가 많지만, 그 중요성에도 불구하고 오류 처리 자체 는 상대적으로 주목을 덜 받곤 한다. 이는 데이터베이스 커뮤니티/문헌에서 문자열 표현 같은 덜 “멋진” 주제들이 중요성에 비해 상대적으로 덜 다뤄지는 문제와도 크게 다르지 않아 보인다. 참고: What are important data systems problems, ignored by research? 및 DuckDB 제작자 Hannes Mühleisen의 관련 논의인 CIDR 2023 keynote (학계에서 시스템 개발하기). 예를 들어, Simple testing can prevent most critical failures [PDF]에서는(급한 독자는 1, 2절을 훑고 4절의 굵은 글씨 ‘Findings’를 읽으면 된다), Yuan 등은 분산 데이터 집약 시스템 맥락에서 다음을 발견했다:
“거의 모든 치명적 실패(92%)는 소프트웨어에서 명시적으로 신호된 비치명적 오류를 잘못 처리한 결과이다” (Finding 10, p256)
반면, 실무자들이 쓴 오류 모델에 대한 훌륭한 장문 글들도 있다.
위의 것들 중 Armstrong의 논문이 아마 가장 총체적이지만, Erlang에 기반을 두고 있어 오늘날 우리가 가진 가장 널리 쓰이는 정적 분석 형태 중 하나인 타입 시스템을 고려하지는 않는다. 209쪽의 발췌는 다음과 같다:
우리는 프로그래밍 언어에 타입 시스템을 부과할 수도 있고, 메시지 패싱 시스템에서 임의의 두 컴포넌트 사이에 계약 검사 메커니즘을 부과할 수도 있다. 이 두 방법 중에서는 나는 계약 검사기를 사용하는 편을 선호한다
이 글에서는 오류 모델을 총체적 이면서도 시스템 PL 생태계에 맞춘 방식으로 접근하고자 한다.
총체적 이라는 말은, 오류 모델을 여러 관점에서 접근한다는 뜻이다:
시스템 PL 생태계에 맞춘 이라는 말은, “systems programming language”라는 용어가 댓글란에서 똑같은 “논쟁”을 반복하게 만드는 경향이 있음을 인정한 뒤—많은 사람이 참조 카운팅이나 추적 GC를 기본 메모리 관리 전략으로 쓰는 언어를 배제하기 위한 용어로 쓰는 듯하다—이 글을 위해 정의를 하나 택한다는 뜻이다. 이 정의가 마음에 들지 않으면, “systems PL”이라는 표현을 모두 “XYZ 메모리 관리 전략을 가진 언어”로 마음속에서 치환해도 되고, 아니면 글 읽기를 그만둬도 된다. 여기서는 오류 모델이 다음 필요를 고려해야 한다는 뜻이다:
이런 소프트웨어의 예로는 데이터베이스, 고성능 웹 서버, 인터프리터 등이 있다. 일회성 작업을 위한 소규모 스크립팅, 대화형 데이터 탐색 등은 무시할 것이다. 또한 ABI 고려 사항도 무시할 것이다. 이는 런타임 표현에서 다양한 수준의 간접화를 통해 대체로 해결될 수 있기 때문이다(예: Swift).
이 논문 블로그 글의 구조는 다음과 같다:
강경 팬 독자를 위해, (EDIT: Mar 9, 2025) 7 8개 절로 구성된 Appendix도 있다.
이 글은 작성 시점 기준 15K+ 단어로 꽤 길다. 경고했다.
그래도 아직 여기 있는가? 시작하자.
이 글의 목적을 위해 다음 정의를 사용한다:
Error: 이 용어를 두 가지 방식으로 사용하겠다:
error 값, (Rust의) Result::Err 케이스 등으로 표현될 수 있다. 이 값에는 스택 트레이스나 오류에 도달하기 전의 진행 상태 지표 같은 metadata가 붙을 수도 있고, 안 붙을 수도 있다.Error propagation: 호출한 함수에서 받은 오류를 자신의 호출자에게 전달하는 행위. 오류 전파 중에는 추가 metadata를 붙이거나, 리소스를 해제하는 등의 일을 할 수도 있다.
Error handling: 오류 값 및/또는 그 metadata를 검사하고, 그에 따라 어떤 결정을 내리는 행위. 예를 들어, 오류를 로깅할지, 전파할지, 버릴지, 혹은 다른 형태로 변환할지 등을 결정할 수 있다.
Error model: 프로그래밍 언어에서 오류를 표현·생성·전파·처리하는 전체 체계(모범 사례 및 일반적인 관용구 포함).
일반적인 용어로는 그냥 “error handling”이라 부르지만, 선언·전파·검사처럼 겉보기엔 꽤 다른 행동들이 모두 “handling” 아래 뭉쳐지는 것이 늘 조금 이상하게 느껴졌다. 그래서 Duffy의 글에서 이 용어를 빌려왔다.
Exhaustive: 오류(타입)가 exhaustive하다고 하는 것은 해당 오류에 가능한 모든 데이터가 사전에 알려져 있다는 뜻이다. 예를 들어 Int64를 UInt32로 변환하는 것은 몇 가지 방식으로만 실패할 수 있다: 값이 음수이거나 232 - 1을 초과하는 경우다. 따라서 이런 변환 연산은 실패 시 exhaustive한 오류를 기록하는 것이 가능하다. 반면, 서드파티 API 같은 외부 시스템의 오류 타입은 보통 non-exhaustive하다.
Exhaustiveness에는 두 축이 있다: fields와 cases. 여기서는 레코드 vs 변형(또는 struct vs enum/태그드 유니언, 혹은 class vs case class)에 대한 논의를 의도적으로 피하겠다; 더 뒤 절에서 다룬다.
위 정의들이 큰 논쟁거리는 아니길 바란다. 계속 가자.
이 글의 나머지는 몇 가지 핵심 논지에 기반한다:
각각을 하나씩 파고들어 보자.
파일을 여는 고전적인 예를 생각해 보자. 어떤 코드가 파일을 열려는데 파일이 없다. 이것은 오류인가? 음, 상황에 따라 다르다!
예를 들어, 여러분이 CLI 애플리케이션을 작성하고 있고, 작업 디렉터리에서 시작해 상위 디렉터리들로 재귀적으로 설정 파일을 찾는다고 하자.
먼저 파일 존재 여부를 확인(예: Linux에서 stat(2) 사용)한 다음 파일을 여는 대신, 파일을 곧바로 열도록 코드를 작성한다. 존재 확인 + open 전략은 두 연산 사이에 다른 프로세스가 파일을 삭제하면 open 단계에서 여전히 ‘File not found’로 실패할 수 있기 때문에, 이런 식으로 작성한다. 하지만 이 경우 ‘File not found’로 파일 열기가 실패하는 것은 바람직하지 않거나 잘못된 것이 아니라(즉 오류가 아니라), 정상 동작의 일부다.
이제 설정 파일 경로가 커맨드라인 인자로 주어지는 상황을 생각해 보자. 이 경우 파일을 열 때 ‘File not found’가 나오면, 사용자가 커맨드라인 인자를 잘못 준 것일 가능성이 크므로, 사용자에게 ‘File not found’를 드러내는 것이 타당하다.
애플리케이션 언어 맥락에서 흔히 복구 불가능하다고 여겨지는 프로그래밍 버그의 예로는 배열 범위 밖 접근, optional 언랩(또는 nil/null 역참조), out-of-memory가 있다. 이들은 Swift 문서와 Joe Duffy의 글에서도 예시로 언급된다.
Rust RFC 236은 오류를 세 가지로 구분한다: catastrophic errors, contract violations, obstructions. 이 중 out-of-memory는 catastrophic error로, index out of bounds는 contract violation으로 분류된다. 그렇다, 이것이 관례(conventions) RFC이고 라이브러리마다 필요에 따라 다르게 할 수 있다는 것은 안다. 하지만 관례는 언어 기능과 표준 라이브러리 API 설계에 강하게 영향을 미치므로, 여기서도 논의할 가치가 있다고 본다. 이 둘에 대해 RFC는 “관례의 기본 원칙은 다음과 같다: Catastrophic errors와 프로그래밍 오류(버그)는 거친(grain이 큰) 수준, 즉 task 경계에서만 복구될 수 있고 복구되어야 한다.”고 말한다.
나는 이런 오류들조차도 복구 가능성이 맥락적임을 인정하는 것이 중요하다고 생각한다. 예를 들어, 게임 코드에서 충돌 판정이나 조명에서 드물게 off-by-one 오류가 나더라도, 게임이 대체로 잘 동작할 수 있고, 그 정도면 충분할 수도 있다.
웹 서버에서 out-of-memory가 발생하면, 서버 프로세스가 메모리를 모두 소진하여 전체 서버가 종료되는 것을 막기 위해, 단일 task가 소비할 수 있는 메모리 양을 제한하고 싶을 수 있다.
요청별 제한이 없더라도, 어떤 요청이나 백그라운드 작업을 처리하는 특정 task의 맥락 밖에서 프로세스 전체 메모리 한도를 넘길 수도 있다. 서버가 이를 복구할 기회가 있다면, 전체 프로세스를 종료하는 대신, 몇 개 요청의 task를 종료하여 부하를 덜어내는 것(예: 전역 상태를 쓰지 않거나, 정리가 가능하도록 제한된 방식으로만 쓴다면)이 괜찮을 수도 있다.
분명히 하자면, 이런 예들이 대다수라고 말하는 것은 아니다. 다만 내 요지는, 비교적 명확해 보이는 경우조차도 오류를 복구 가능/불가능으로 분류하는 것이 깔끔하게 떨어지지 않는 상황이 존재한다는 것이다.
코드 규모가 일정 수준을 넘으면, 오류를 이해하려면 where/what/when/how/why에 대한 metadata 수집이 필요해진다. 예를 들어 웹 서버에서는 다음을 추적하고 싶을 수 있다:
또한, 이런 metadata를 활용하는 로직을 만들 수 있어야 한다(예: 언어가 어떤 형태로든 메서드를 지원한다면 오류 타입의 메서드로). 예컨대 관측성(observability)을 위한 키-값 생성, 동일성 검사를 위한 equality/hash 계산 등.
Corollary: 언어가 지원하는 1차 오류 처리 방식이 오류 코드인 것은 많은 사용 사례에 부적절하다(참고: Zig issue #2647 - Allow returning a value with an error).
Designing Data-Intensive Applications에서 Martin Kleppman은 데이터베이스 트랜잭션과 재시도 가능성에 대해 훌륭한 예를 든다:
인기 있는 객체-관계 매핑(ORM) 프레임워크는 [..] 중단(abort)된 트랜잭션을 재시도하지 않는다 [..]. 이는 안타깝다. abort의 핵심 목적이 안전한 재시도를 가능하게 하는 데 있기 때문이다.
중단된 트랜잭션을 재시도하는 것은 단순하고 효과적인 오류 처리 메커니즘이지만, 완벽하지는 않다:
- 트랜잭션은 실제로 성공했지만, 서버가 클라이언트에게 성공적인 커밋을 확인(ack)하는 동안 네트워크가 실패한 경우 [..], 트랜잭션 재시도는 [추가적인 앱 수준의 중복 제거 없이는] 부적절하다
- 오류가 과부하로 인한 것이라면, 트랜잭션 재시도는 문제를 악화시킨다 [..]
- 일시적 오류([..] 데드락, 격리 위반, 일시적 네트워크 단절, 페일오버) 이후에만 재시도할 가치가 있으며, 영구적 오류(예: 제약조건 위반) 이후에는 재시도가 무의미하다
SQL 데이터베이스와 상호작용하는 API를 제공하는 라이브러리/프레임워크가 중단된 트랜잭션의 자동 재시도를 지원하려면, 네트워크 오류와 DB 오류의 다양한 케이스를 구분할 수 있어야 한다.
물론 모든 오류 처리가 이런 수준의 엄밀함을 필요로 하지는 않는다. 맥락에 따라서는, 전체 시스템에 부정적 영향을 주지 않을 것이라 확신한다면 오류를 로그로 남기고 계속 진행해도 괜찮을 수 있다.
Corollary: API 명세 언어는 반환값에서 오류 정보를 숨기는 것을 억제하는 편이 좋을 것이다(참고: GraphQL의 default machinery for error handling).

이 점은 아마도 가장 논쟁의 여지가 적을 것이다.
오류 케이스 정보와 metadata를 문자열에 쑤셔 넣으면, 오류 처리를 신경 쓰는 클라이언트에게 API가 더 쓰기 어려워진다.
성실한 사용자가 오류에서 더 많은 데이터를 추출할 수 있는 유일한 방법이 문자열 파싱이라면, 그들은 오류 파서를 쓸 것이고, 그 오류 메시지를 바꾸고 싶어지는 순간 모든 선택지가 끔찍해질 것이다(참고: Hyrum’s Law).
Corollary: 표준 라이브러리는 나중에 회수할 수 없는 방식으로 오류에 임의 데이터를 쉽게 붙이도록 장려하지 않는 편이 좋을 것이다(참고: 사용자가 비구조적 오류를 만들도록 암묵적으로 장려하는 Go의 fmt.Errorf 함수**.
프로그램의 일부가 외부 시스템과 상호작용하지 않고, 가능한 동작 집합이 잘 이해된 순수 함수처럼 행동한다면, exhaustive 오류 타입을 사용하면 이런 확실성을 모델링할 수 있다.
반면, 네트워크 너머의 서드파티 API, OS API, 데이터베이스 등, 미래에 변할 수 있는 외부 시스템과 상호작용할 때는 모든 케이스와 필드를 사전에 알 수 없는 오류를 모델링하고 처리할 방법이 필요하다. 이런 상황에서는, 새로운 오류 케이스 및/또는 필드가 추가되어도 기존의 서드파티 클라이언트가 소스 수준에서 깨지지 않고, 합리적인 동적 동작으로 이어지는 것이 이상적이다.
안전 필수(safety critical) 맥락을 제외하면, 대부분의 프로덕션 시스템에서 그 시스템을 작업하는 대부분의 사람(나 포함)은 어떤 일이 잘못될 수 있는지에 대해 제한된 그림만 가지고 있다고 말해도 공정하다고 생각한다.
이것은 아마도 범위를, 자신이 작성한 기본 API만 포함하는 상황, 그리고 (복잡하지만) 순수 계산만 하는 상황, 또한 입력과 시스템 파라미터가 “허용 가능한” 범위 안에 있는 상황으로 제한해도 마찬가지일 것이다.
분명히 하자면, 이는 가치 판단이 아니라 관찰이다. 여러 기여 요인이 있을 것이다; 경쟁하는 우선순위, 높은 시스템 복잡도, 부실한 문서, 빈약한 언어/도구 지원, 그리고 어쩌면 낙관주의. 특히 낙관주의에 대한 더 많은 논의는 Appendix A7를 보라. 이것을 더 논의하려면 별도의 글이 하나 더 필요할 것 같으므로 여기서 멈춘다.
위 논지들과 개인적 경험을 바탕으로, 오류 모델은 다음 핵심 기준을 얼마나 잘 만족하는지로 평가되어야 한다고 믿는다. 다소 추상적으로 들린다면, 곧 더 자세히 논의하겠다.
오류 선언(error declarations)은 다음을 지원해야 한다:
Exhaustiveness annotation: 선언이 exhaustive인지(또는 아닌지) 표시할 수 있는 능력. 이는 두 축(필드와 케이스)을 모두 지원해야 한다.
Case extension: 오류가 케이스 축에서 non-exhaustive로 선언된 경우, 선언 위치 자체에서 새 케이스를 소스 수준 하위 호환을 유지하며 추가할 수 있는 능력.
Field extension: 위와 유사하되 필드에 대한 것.
Case refinement: 기존에 정의된 케이스를 시간이 지나 더 세분화된 케이스들로(선언 위치 자체에서) 하위 호환을 유지하며 정제(refine)할 수 있는 능력.
오류 전파(error propagation)는 다음을 지원해야 한다:
Explicit marking: 코드가 다음을 만족하도록 강제할 수 있는 능력. ‘능력(ability)’이라는 단어 선택은 의도적이다. 명시적 마킹 규율을 쓰는 것이 기본일 수도 아닐 수도, 관례일 수도 아닐 수도 있지만, 이를 따르는 것이 가능해야 한다.
명시적 표식이 없으면 국소화된 정적 오류가 발생해야 한다.
* **Structured metadata attachment**: 오류에 구조화된 metadata를 붙일 수 있는 능력. metadata를 붙이는 것은 오류가 여전히 오류임을 보존해야 한다.
* **Error combination**: 오류를 더 큰 오류로 결합할 수 있는 능력.
* **Erasure**: 세밀한 오류를 더 거친 오류로 추상화할 수 있는 능력.
오류 처리(error handling)는 다음을 지원해야 한다:
#[non_exhaustive] 속성이 같은 crate 안에서는 효과가 없다. exhaustive로 선언된 오류에 대해 모든 케이스를 exhaustive하게 매칭할 수 있는 능력.exhaustive 오류에 대한 non-exhaustive 매칭은 정적으로 진단되어야 한다.
또한 non-exhaustive 오류에 대해서는, 알려진 케이스들만을 대상으로 exhaustive 매칭을 시도하는 것도 정적으로 진단되어야 한다.
* **Structured metadata extraction**: 오류에 붙인 구조화 metadata를 추출할 수 있는 능력(Structured metadata attachment의 쌍대).
* **Error projection**: 결합된 오류에서 개별 하위 오류를 검사할 수 있는 능력(Error combination의 쌍대).
* **Unerasure**: 거친 오류에서 세밀한 정보를 다시 꺼낼 수 있는 능력(Erasure의 쌍대).
오류 관례(error conventions)에 대한 기준:
Error category definitions: 서로 다른 오류 범주들이 문서화되어야 하며, 생태계가 중앙 정의에 의존할 수 있어야 한다. 각 범주에는 어떤 상황에서 해당 범주로 분류해야 하는지, 혹은 다른 범주의 일부로 볼 수도 있는지를 보여주는 예시가 동반되어야 한다.
Guidelines on error definitions: 무엇을 문서화해야 하는지, 어떤 애노테이션을 고려/회피해야 하는지, 라이브러리의 다운스트림 사용자에 대한 고려사항 등을 포함해야 한다.
Guidelines on error propagation: erased된 오류를 반환할 때와 unerased된 오류를 반환할 때가 각각 언제 적절한지 다뤄야 한다.
Guidelines on error handling: 라이브러리 경계 안/밖에서 오류를 처리하는 것이 언제 적절하며 언제 부적절한지 다뤄야 한다.
가이드라인은 일반적으로 근거(rationale)와 함께, 가이드라인에서 벗어날 수 있는 잠재적 이유의 선별 목록도 동반되어야 한다.
도구(tooling)에 대한 기준:
Lint support: 어떤 부류의 오류는 타입 시스템 기능보다 린트/스타일 검사기로 피하는 편이 더 낫다.
(중요) 표준 단축 표기(예: _ = <expr>)로 오류 값을 버리는 것을, 명시적 애노테이션(주석이나, ‘Find references’를 가능케 하는 표식 함수 호출 등) 없이는 막는 린트.
(있으면 좋은) 케이스/필드 exhaustive가 언어 수준 기본이라면, 모든 타입에 수동 exhaustiveness 애노테이션을 요구하는 린트.
(있으면 좋은) 오류 타입의 필드와 케이스가 문서화되어야 함을 강제하는 린트.
Editing support:
각 기준에 대해, 다음 하위 절들은 왜 유용한지를 설명한다.
Exhaustiveness annotation은 Thesis 6 때문에 필요하다—일반적인 프로그램은 exhaustive와 non-exhaustive 오류를 모두 다룰 필요가 있다. 이는 두 개의 완전히 다른 타입 시스템 기능(예: non-exhaustive는 서브클래스, exhaustive는 일반 합 타입)을 두는 것보다 애노테이션 같은 형태가 더 좋을 것이다. 이유는:
non-exhaustive 오류를 허용한다면, 그런 오류를 프로젝트 경계를 넘어 사용 가능하게 만들기 위해, 언어는 소스 수준 하위 호환을 깨지 않고 non-exhaustive 오류 타입에 새 케이스와 필드를 추가할 수 있어야 함이 자연스럽게 뒤따른다.
마지막으로, API 개발자가 더 많은 지식을 얻게 될 때—Thesis 7 기억하라: 프로그래머는 대체로 오류에 대해 불완전한 지식으로 작업한다—이전의 넓은 오류 케이스에 대해, 기존 클라이언트를 깨지 않으면서 새 클라이언트에게 그것을 전달할 방법이 필요하다. 이는 “이 한 케이스는 실제로 N개의 다른 케이스이며, N >= 2”를 표현할 방법을 요구하는데, 이것이 바로 내가 case refinement라고 설명한 것이다.
오류 처리를 명시적 표식으로 작성하도록 강제할 수 있는 능력은, 제어 흐름과 오류 처리를 함수 단위로 모듈적으로 추론할 수 있게 해 주기 때문에 가치가 있다.
프로그래머가 어떤 함수가 어떤 오류를 조용히 반환할 수 있는지 머릿속으로 마법처럼 추적할 수 있다 해도, 그 프로그래머는 코드베이스를 떠날 수 있고, 다음 프로그래머는 자신이 물려받은 코드베이스에 대한 새 이론을 점진적으로 구성할 때 도움이 필요하다.
구조화 metadata를 붙일 수 있는 능력은, 모든 오류가 단순한 기본 값으로 설명될 수 없고, 오류가 왜 발생했는지 디버깅하려면 종종 문맥 정보가 필요하기 때문에 가치가 있다(예: JSON 파일에서 ,가 또 어디 빠졌는지, 맙소사).
오류를 결합할 수 있는 능력은, 배열/집합/맵 같은 컬렉션이 가치 있는 것과 같은 이유로 가치가 있다.
오류를 erase할 수 있는 능력은, 모든 코드가 오류의 세부사항을 신경 쓰지는 않기 때문에 가치가 있다. 예를 들어 슈퍼바이저 task/process 내부 코드는 잠재적으로 오류를 어떻게 로그로 남길지만 관심 있을 수 있다.
Exhaustiveness checking은 모든 케이스가 처리되었다는 명확성을 제공하기 때문에 가치가 있다. 하지만 어떤 경우에는 그 명확성을 갖는 것이 불가능하다. 이유는:
이는 non-exhaustive 오류도 exhaustiveness checking에서 적절히 다뤄져야 함을 의미한다.
구조화 metadata 추출, 오류 projection, 복구 같은 기능은, 그에 대응하는 쌍대 기능들이 함께 쓰일 때만 의미가 있기 때문에 필요하다.
이제 오류 모델 자체와 관련된 꽤 많은 땅을 밟았다. 이제 관점을 바꿔, 이런 관찰에 기반한 언어 설계가 어떤 모습일 수 있는지 이야기해 보자.
가상의 시스템 언어 Everr(“Evolving errors”)와 그 생태계 관점에서 오류 모델을 설명하겠다. 예시로 Everr를 시연할 것이다. 보통은 다들 알아듣겠지만, 인터넷이니 명시하겠다; 여기서 쓰는 구체 문법은 핵심이 아니다. 이것은 개념을 설명하기 위해 내가 문자 그대로 즉석에서 만들어낸 언어다. 그 후, 앞 절에서 제시한 기준들에 대해 Everr의 오류 모델이 기존 주류 언어들과 비교해 어떤지 보겠다.
Everr의 핵심 언어 구성 요소 요약은 다음과 같다:
Everr는 C++, Rust, Swift 등처럼 선언에 의미적 속성(semantic attributes)을 지원한다.
Everr는 struct(레코드/곱 타입)와 enum(변형/합 타입)을 지원한다. 이들은 exhaustive 또는 non-exhaustive일 수 있다.
Everr는 Cheng과 Parreaux의 “Ultimate Conditional Syntax”를 약간 변형한 스타일의 패턴 매칭을 지원한다.
Everr는 C++의 namespace와 Rust의 module처럼, 그룹핑을 위한 단순 네임스페이스를 지원한다. 타입 자체는 네임스페이스가 아니다. 이는 여기 설명을 단순화하기 위한 것이다. 만약 타입이 네임스페이스로 기능할 수 있다면(또는 네임스페이스가 타입 파라미터를 가질 수 있다면) “바깥” 제네릭 파라미터를 참조하는 것과 새로운 제네릭 파라미터를 도입하는 것을 구분하기 위한 다른 문법이 필요해지고, 주제에서 벗어나게 된다.
Everr는 유니언 타입을 지원한다. 내가 “유니언 타입”이라 말할 때는 C/C++의 비태그드 유니언이 아니라 타입 이론적 의미를 말한다. 하지만 반드시 상한(upper bound)이 필요하다. 간단히 이를 “bounded union types”라 부르겠다. bounded union type에는 exhaustive와 non-exhaustive 두 변형이 있다.
Everr는 Rust 같은 trait 메커니즘과 trait 파생(deriving) 메커니즘을 지원한다. trait 파생을 구현하는 구체 메커니즘이 컴파일러 내장인지, 매크로인지, 컴파일러 플러그인인지 등은 이 글에서 중요하지 않으므로 무시한다.
Everr는 한 타입에서 다른 타입으로의 필드 접근 및 메서드 호출 위임(delegation) 메커니즘을 지원한다.
먼저 이 핵심 언어 기능들을 둘러본 뒤, Everr의 오류 모델을 설명하겠다.
Everr에는 optional 값을 표현하는 Optional 타입이 있다:
@exhaustive
enum Optional[A] {
| None {}
| Some { value: A }
}
이는 다음 코드의 문법 설탕이다:
namespace Optional {
// ↓ None will never have new fields
@exhaustive(fields)
struct None {}
// ↓ Some will never have new fields
@exhaustive(fields)
struct Some[A] { value: A }
}
// ↓ Optional will never have new fields
@exhaustive(fields)
struct Optional[A] {
// ↓ Actual enum syntax, without the sugar
case: @exhaustive(cases) enum {
| type Optional.None
// ↑ refers to struct None
| type Optional.Some[A]
// ↑ refers to struct Some[A]
}
}
따라서 Some과 None은 Optional의 케이스가 아니라, 1급 타입을 나타낸다. Scala의 case class 및 Rust가 제안한 enum variant types와의 비교는 Appendix A1를 보라. 이런 설계는 niche optimizations을 배제하지 않는다. Appendix A2 참조.
Optional 값은 패턴 매칭을 지원한다:
fn demo0(x: Optional[Str]) -> Str {
if x.case is {
Optional.None {} -> return "Got None"
// _ represents a value being discarded
Optional.Some { value: _ } -> return "Got Some"
} // Compiler checks exhaustiveness for:
// 1. Cases of Optional
// 2. Fields of None
// 3. Fields of Some
}
패턴 매칭이 타입에 직접 이뤄지는 것처럼 보이지만, 내부적으로는 다른 언어들처럼 태그드 유니언 타입처럼 동작하므로, 열려 있는 클래스 계층에 대한 패턴 매칭과 달리 exhaustiveness checking이 가능하다.
장황함을 줄이기 위해 Everr에는 패턴 매칭에 대한 설탕 문법이 있다.
case라는 이름의 필드를 암묵적으로 투영(projection)할 수 있다. 이것이 Everr가 암묵적으로 필드 투영을 삽입하는 유일한 특례(흠흠)다.위 설탕을 써서 코드를 다시 쓰면:
fn demo1(x: Optional[Str]) -> Str {
if x is {
.None {} -> return "Got None"
.Some { value: _ } -> return "Got Some"
}
}
좋다! 😃 Everr의 non-exhaustive enum이 섞이면 더 흥미로워진다.
@non-exhaustive
enum Dessert {
| Cake { icing : Str }
| IceCream {}
}
위 타입 선언은 다음으로 디슈가된다:
namespace Dessert {
@non-exhaustive(fields)
struct Cake { icing : Str }
@non-exhaustive(fields)
struct IceCream {}
}
@non-exhaustive(fields)
struct Dessert {
case: @non-exhaustive(cases) enum {
| type Dessert.IceCream
| type Dessert.Cake
}
}
다른 프로젝트에서 이 타입들에 대한 문자열 변환 함수를 쓴다고 하자. Everr의 미래 케이스/필드에 대한 의무 처리 규칙은 Rust의 #[non_exhaustive]와 유사하게 접근 제어 경계를 넘어설 때만 적용된다.
fn cake_to_str(cake: Dessert.Cake) -> Str {
if cake is Dessert.Cake { icing: i, !__ } {
i.append(" cake");
return i
}
}
여기에는 !와 __라는 두 시길(sigil)이 있다.
__는 “(존재한다면) 어떤 라벨-값 쌍이든 무시하라”는 뜻이다. Cake에는 @non-exhaustive(fields) 애노테이션이 있으므로 __는 필수이며, 이는 enum 케이스에 대한 필수 catch-all 절과 유사하다.
!는 “다음 식별자가 알려진 라벨-값 쌍 하나 이상, 또는 케이스 하나 이상과 일치하면 경고를 내라”는 뜻이다. 이는 Swift의 @unknown의 일반화 버전이다(@unknown 문서). Cake가 @non-exhaustive(fields)이므로, 미래에 새 필드가 추가되면 cake_to_str 안에서 경고가 발생한다.
이 설계는 타입 작성자가 소스 수준 하위 호환을 깨지 않고 새 필드를 추가할 수 있게 하면서, 타입 사용자가 타입 정의 변화에 대한 “알림”을 받도록 옵트인할 수 있게 해 준다.
위 함수를 사용해 Dessert를 문자열로 변환하는 함수를 작성할 수 있다.
fn dessert_to_str(d: Dessert) -> Optional[Str] {
if d is Dessert { case: dcase, !__ }
and dcase is { // (1)
.Cake c ->
return .Some { value: cake_to_str(c) }
.IceCream { __ } ->
return .Some{value: "ice cream"} // (2)
!_ -> return .None{} // (3)
}
}
위 예시 코드에는 많은 것이 들어 있으니 단계별로 보자.
if d is Dessert { case: dcase, !__ }
and dcase is { // (1)
여기서 일어나는 일은 다음과 같다:
dcase는 d의 case 필드에 대한 새 바인딩이다. and 뒤에서, 앞서 말한 Ultimate Conditional Syntax로 곧바로 다시 매칭한다.
__는 패턴에서 다루지 않은 나머지 필드들과 그 값을 매칭하되 값은 무시한다. Dessert가 non-exhaustive struct이므로 __를 생략하면 컴파일러 오류가 난다.
대신 Dessert에 새 필드가 추가되는 것에 관심이 없다면, if d.case is를 직접 사용할 수도 있다.
! 때문에, __가 하나 이상의 필드를 실제로 매칭하면 cake_to_str와 유사한 로직으로 컴파일러가 경고를 낸다.(2) 분기를 보자:
.IceCream { __ } ->
return .Some{value: "ice cream"} // (2)
여기서도 __는 미래 필드를 무시한다는 뜻이다. !가 없으므로, IceCream 타입에 미래에 새 필드가 추가돼도 이 코드는 경고를 내지 않는다.
마지막으로 d.case는 non-exhaustive enum 타입이므로 catch-all 패턴이 필수다:
!_ -> return .None{} // (3)
_는 다른 언어들처럼 “단일 값을 무시”를 뜻한다. ! 때문에 이 줄은 Dessert에 새 케이스가 하나 이상 추가되면 컴파일러 경고를 낸다.
휴, 많았다! 잠깐 쉬는 XKCD를 보자.

계속하자. Alice와 Bob 두 사람이 있고, Dessert와 같은 프로젝트 안에서 그들이 좋아하는 디저트를 설명하는 두 함수를 쓰고 싶다고 하자. 다른 프로젝트라면, non-exhaustive 타입에 대한 초기화 문법은 사용할 수 없을 것이다. 선호는 다음과 같다:
이 선호는 Everr의 bounded union types로 모델링할 수 있다.
먼저 Alice의 선호를 모델링하자.
pub fn alice_likes() -> Array[Dessert:union[.Cake | .IceCream]] {
return Array.new(
.Cake{icing: "ganache"},
.IceCream{},
)
}
여기서 Dessert:union[.Cake | .IceCream]은 exhaustive (bounded) union type을 의미한다. Cake와 IceCream 타입 값이 허용되며, 미래에 Dessert에 새 케이스가 추가돼도 그것이 반환되지는 않는다는 뜻이다. exhaustive enum과 마찬가지로 호출자가 union 값을 패턴 매칭할 때 catch-all 패턴 없이 exhaustive하게 매칭할 수 있다.
이제 Bob의 선호를 모델링하자.
pub fn bob_likes() -> Array[Dessert:union+[.Cake]] {
return Array.new(.Cake{icing: "buttercream"})
}
여기서 Dessert:union+[.Cake]는 non-exhaustive (bounded) union type을 의미한다. Cake 타입 값만 허용되지만, 미래에는 상위 타입(여기서는 Dessert)의 다른 케이스도 등장할 수 있다는 뜻이다. non-exhaustive enum과 마찬가지로 호출자가 union 값을 패턴 매칭할 때는 추가될 미래 케이스(예: Bob이 마음을 바꾸면 IceCream도 포함)를 고려해야 한다.
union type의 exhaustiveness는 케이스에만 적용된다. 패턴 매칭 시에는 Cake와 IceCream이 모두 @non-exhaustive(fields)이므로, 알 수 없는 필드를 처리하기 위해(__ 또는 !__로) 여전히 명시적이어야 한다.
enum 케이스가 1급 struct 타입이므로, 서로 다른 enum이 케이스를 공유할 수 있다.
enum BakeryItem {
| Bread {}
| type Dessert.Cake // OK
}
이는 케이스 정의를 복사하지 않고도 케이스 타입을 재사용할 수 있게 한다.
Everr는 trait과 trait 파생을 지원한다. Everr에는 enum 케이스에서 enum을 포함하는 타입으로의 O(1) 변환을 나타내기 위한 내장 UpCast trait이 있다.
trait UpCast[From, To] {
fn up_cast(_: From) -> To
}
이 trait은 enum 케이스에 대해 자동 구현된다. 따라서 다음이 생긴다:
impl UpCast[Dessert.Cake, Dessert] { ... }
impl UpCast[Dessert.IceCream, Dessert] { ... }
impl UpCast[BakeryItem.Bread, BakeryItem] { ... }
impl UpCast[Dessert.Cake, BakeryItem] { ... }
다른 언어의 인터페이스처럼, 이것들은 수동으로 구현할 수도 있다.
마지막으로 Everr는 타입 간 위임 메커니즘을 지원한다. 예를 들어 다음과 같은 코드가 있다면:
struct BakeryProduct {
@delegate
base: BakeryItem
ingredients: Array[Ingredient]
price: Money
}
BakeryProduct 값에 대해 . 연산자로 필드 접근이나 메서드 호출을 할 때, 컴파일러는 먼저 BakeryProduct가 필드/메서드를 갖는지 확인하고, 없다면 BakeryItem이 갖는지 확인한다.
예측 가능성을 유지하기 위해, @delegate 애노테이션은 최대 한 필드에만 붙일 수 있다. 그렇지 않으면 필드 재정렬에 직면했을 때 ad-hoc tie-breaking 규칙이 필요해진다.
케이스는 특수한 .case 필드로 표현되므로, BakeryItem이 .case 필드를 갖고 있어 패턴 매칭이 가능하고, 따라서 BakeryProduct 값에 대해 직접 패턴 매칭이 동작한다.
좋다, 이것으로 Everr의 핵심 언어 기능 투어는 끝이다! 이제 Everr의 오류 모델을 이야기하자.
Everr의 오류 모델은, 서로 다른 오류의 처리와 복구는 대체로 맥락 의존적이라는 핵심 관찰에 기반한다. 따라서 서로 다른 맥락에서 서로 다른 오류 처리 전략을 쓸 수 있도록 유연하고 폭넓은 메커니즘을 제공한다.
오류 전파와 처리에는 두 가지 모드가 있다.
Result 타입을 사용하며, Rust와 Swift의 그것과 동등한 exhaustive enum이다.panic/recover, Rust의 panic!/catch_unwind와 유사한 panic 및 catch panic 프리미티브를 사용한다. 패닉과 패닉 캐칭은 패닉 중 out-of-memory를 피하기 위해 프로그램 시작 시 사전 할당된 메모리를 사용하되, 일부 관련 데이터를 버려야 할 위험을 수용한다.exit 프리미티브를 사용한다.OS 시그널은 콜백으로 처리되며, 정확한 시그널과 설정에 따라 위 방식 중 하나로 표출될 수 있다.
특히 Haskell, OCaml, Erlang의 비동기 예외처럼, 비협조적 task를 비동기 예외로 비동기 종료시키는 기능은 지원하지 않는다.
기본 연산에 대한 기본값은 다음과 같다:
하지만 이들은 커스터마이즈할 수 있다; 각 항목에 대한 자세한 내용은 곧 다룬다.
Everr 코드가 무엇이 허용되는지/되지 않는지를 협상하는 중요한 측면 중 하나는, 특정 핵심 언어 기능을 capabilities 로 지정한다는 것이다. capability의 예로는 힙 할당, 패닉, 프로그램 종료, 외부 함수 인터페이스(FFI) 사용 등이 있다.
capability는 Everr의 배포 단위인 패키지 경계에서 드러난다. 각 패키지는 매니페스트 파일을 가지며, 다음을 지정할 수 있다:
주어진 패키지가 기본값 외에 사용하는 capability. 각 capability에는 (Unavailable 외에) 3가지 레벨이 있다:
Implicit: 어떤 애노테이션도 없이 모든 코드가 capability에 접근 가능.
Explicit: capability를 사용하는 코드는 명시적 애노테이션이 필요.
Binding: capability를 사용하는 코드는 명시적 애노테이션이 필요하고, 그 애노테이션은 API 계약의 일부로 간주된다; 애노테이션을 완화하는 것은 호환성이 깨지는(breaking) 변경으로 간주된다.
표준 린터를 설정하면, capability를 사용하지 않는 함수도 “미래에도 capability를 사용하지 않을 것을 보장한다” 혹은 “나중에 사용할 권리를 유보한다”를 나타내는 명시적 애노테이션을 요구하도록 강제할 수 있다.
추상적으로 들린다면 걱정 말라. 예시와 함께 capability 시스템을 나중에 설명한다.
먼저 fail-slow와 fail-fast 오류 처리를 논의하자.
아주 기본적인 연산에서 실패가 하나만 가능한 경우(예: 맵 조회)에는 실패를 표준 Optional 타입으로 드러낸다. 대부분의 다른 경우에는 Result 또는 Hurdle 타입을 사용한다. Result와 Hurdle은 표준 Everr 린터가 특별히 인식하며, _로 Fail 케이스나 problem 필드를 무시하면 경고를 낸다.
// Result represents computations where errors block progress.
//
// Most commonly used for short-circuiting logic.
@exhaustive
enum Result[A, E] {
| Pass { value: A }
| Fail { error: E }
}
// Hurdle represents computations where problems do not
// block progress, but they still need to be bubbled up,
// such as in the form of warnings.
@exhaustive
struct Hurdle[A, E] {
data: A
problem: E
}
E 타입 파라미터에 대해서 Everr 프로그래머는 오류를 정의하는 도메인 특화 struct/enum 타입을 쓰는 것이 권장된다. enum과 struct 모두 exhaustiveness 애노테이션을 지원하므로, 오류 타입을 미래 진화에 대비해 쉽게 표시할 수 있다.
enum은 struct로 디슈가되므로:
case: enum { ... } 필드를 추가함으로써, 거친 케이스를 여러 하위 케이스로 정제(refine)할 수 있다.예를 들어, float 파싱 함수가 다음 타입의 오류를 반환했다고 하자:
@exhaustive(cases)
enum FloatParseError {
| UnexpectedChar { byte_offset: UInt64 }
| NotRepresentable {}
}
이것이 @exhaustive(cases)로 표시되어 있으므로, 새 필드를 추가하는 것은 호환성이 깨지는 변경으로 간주되지 않는다.
NotRepresentable 케이스에 필드를 추가하고, 이를 정제해도 하위 호환을 깨지 않는다:
@exhaustive(cases)
enum FloatParseError {
| UnexpectedChar { byte_offset: UInt64 }
| NotRepresentable {
// Represents the lower bound on the number of bits
// that would be needed for a floating point type
// to be able to represent the given value.
min_bits_needed: UInt64
case: @exhaustive enum {
| TooSmall {}
| TooLarge {}
}
}
}
타입이 정제된 뒤에는 두 형태의 패턴 매칭이 모두 동작한다.
if err is {
.UnexpectedChar { .. } -> ..
.NotRepresentable { __ } -> ..
}
// or more fine-grained
if err is {
.UnexpectedChar { .. } -> ..
.NotRepresentable nr and nr is {
.TooSmall {} -> ..
.TooLarge {} -> ..
}
}
Everr의 case refinement는, ML 계열 언어처럼 enum 정의를 enum과 케이스 struct 정의로 미리 분해하지 않고도 선택성을 제공한다. 정제가 도입될 때 추가 장황함 비용을 지불한다.
오류는 try 연산자로 전파할 수 있으며, 이는 여러 수준의 세분도에서 사용할 수 있다—한 문장 이상, 또는 특정(트리 형태의) 표현식(개별 호출 표현식 포함)에 사용할 수 있다.
enum ABCError {
| type SubError1
| type SubError2
| type SubError3
| type SubError4
}
fn abc() -> Result<Int, ABCError> {
try {
sub_op1(...)
sub_op2(...)
}
let a = try sub_op3(...).some_method(...)
let b = sub_op4(a).@try
return ok(b.other_method())
}
try는 앞서 언급한 UpCast trait을 통해 한 단계의 자동 오류 변환을 허용한다. 이는 Rust의 ?와 Try와 유사하다.
Everr 프로그래머는 구조화된 오류 타입을 정의·사용하고, struct 필드를 새로 정의하여 오류에 metadata를 붙이는 것이 권장된다. struct는 trait 파생과 동일한 메커니즘으로 최소한의 보일러플레이트로 쉽게 직렬화할 수 있다. 이는 구조화 로깅과 트레이싱 같은 관측성 라이브러리와 잘 통합된다.
Everr 언어 서버는, 호출되는 함수들의 오류 타입을 기반으로 특정 함수에 대한 오류 타입을 정의하는 코드 액션을 제공한다. 또한 함수 본문이 시간이 지나 진화할 때, 접근 제어 같은 문맥 규칙을 고려하여 오류 케이스에 대한 변경도 지능적으로 제안할 수 있다.
Everr 표준 라이브러리는 호출 트레이스(call trace)에 대한 표준 타입을 노출한다. 호출 트레이스는 스택 트레이스, Zig 스타일 오류 반환 트레이스, async 스택 트레이스 같은 다양한 형태의 트레이스를 포괄한다. 이들은 기록 방식이 다르지만, 모두 “어떻게 여기까지 왔는가”를 설명하는 소스 코드 위치의 시퀀스라는 같은 개념 아래에 있다.
구조화된 오류 사용이 권장되기는 하지만, Everr 표준 라이브러리는 비구조적 오류를 다루는 API도 제공한다.
AnyErrorContext 타입: 다음에 대한 편리한 API를 제공한다:
AnyError 타입: 오류 값과 AnyErrorContext 및 0개 이상의 자식 AnyError 값을 묶는다(Go의 error와 유사). 다음에 대한 편리한 API를 제공한다:
복구 가능한 fail-fast 오류 처리는 패닉을 통해 이뤄지며, 이는 Java와 C++ 같은 언어의 언체크 예외와 유사하다.
함수 선언과 타입에는 선택적으로 @panics 속성을 붙일 수 있는데, 이는 함수가 패닉할 수 있는지, 개발 모드에서만 패닉할 수 있는지(릴리즈 모드에서는 절대 패닉하지 않는지), 혹은 전혀 패닉하지 않는지를 다룬다.
패닉은 capability다. 패닉에 대한 기본 capability 레벨은 Implicit이므로, 대부분의 패키지는 @panics 애노테이션을 사용하지 않는다.
패닉 마킹이 Explicit 또는 Binding 레벨인 패키지에서는, Everr 컴파일러가 함수의 @panics 애노테이션(또는 부재)이 그 함수가 호출하는 다른 함수들의 애노테이션과 일치하는지 검사한다.
표준 린터는 @panics 애노테이션이 있는 함수를 인식하고, 함수가 언제 패닉할 수 있는지를 설명하는 절을 API 문서에 추가하라고 권고한다.
특히 Binding 레벨에서는, 표준 린터에 모든 함수에 대해 명시적으로 @panics(maybe) 또는 @panics(never) 애노테이션을 요구하는(옵션) 린트가 있다. 이는 @panics 애노테이션 없이 실수로 API가 추가되어 구현이 assertion을 사용할 수 없게 되는 상황을 피하기 위함이다.
Everr의 핵심 라이브러리(표준 라이브러리 포함)는 Explicit 패닉 마킹을 사용한다.
Everr 생태계의 일부—특히 임베디드 시스템 맥락에서 쓰이는 표준 라이브러리 대체 최소 구현 및 관련 패키지—는 기본적으로 패닉 마킹에 Binding 레벨을 사용한다.
패닉과 마찬가지로, exit 프리미티브를 이용한 프로그램 종료도 capability로 취급되며, 이에 대응하는 @exits 속성이 있다.
하지만 패닉과 달리, 종료 capability의 기본 레벨은 Explicit이다. 라이브러리 코드에서 이를 필요로 하는 경우가 꽤 드물기 때문이다.
Everr에서 bounded 정수 타입에 대한 수치 연산은 오버플로 시 패닉한다.
오버플로 시 대체 동작(흔히 wrapping)을 패키지, 하나 이상의 문장, 혹은 표현식 수준에서 옵트인할 수 있다. try처럼 문법적 수준에서 작동한다.
let sum_plus_one = @math.wrap { 1 + vec.sum() }
// Addition semantics in the sum() call itself are unaffected.
오버플로 동작이 패키지 수준에서 오버라이드되면, Everr 언어 서버는 함수 수준에서 오버플로 동작에 대한 인레이 힌트를 보여줄 수 있다.
Everr의 assertion은 커스터마이즈 가능하다. 여기서는 assertion의 정확한 API를 일부러 설명하지 않겠다. sometimes assertions 같은 흥미로운 API를 만들 여지가 있다. 기본적으로 assertion 실패는 panic을 유발한다. 하지만 바이너리 패키지는 패키지 매니페스트에서 “assertion overlay”를 지정해 assertion의 의미를 커스터마이즈할 수 있다. 이는 assertion 관련 기본 API에 대한 오버라이드로 동작하므로, 의존성 그래프의 모든 패키지가 사용자의 커스텀 assertion 구현을 쓰게 된다.
가장 흔히 쓰이는 assertion overlay는 다음과 같다:
기본적으로 힙 out-of-memory는 프로그램 종료를 초래한다.
힙 사용은 capability이며 기본 레벨은 Implicit이다.
힙 할당을 사용하는 프리미티브는 실패 가능한(fallible) 대안을 제공하여, 힙 할당이 항상 성공한다는 가정에 의존하지 않고도 그 위에 API를 구축할 수 있게 한다. 커스텀 할당자를 쓰면서 메모리 안전성을 유지(혹은 최소한 발목잡이를 줄이는) 성능 좋고 인체공학적인 API를 설계하는 것은 여러 경쟁 접근이 있는 열린 문제다. 자세한 내용은 Section 6과 Appendix A4를 보라.
기본적으로 스택 오버플로는 프로그램 종료를 초래한다.
재귀와 간접 호출 사용은 둘 다 capability로 취급되며 기본 레벨은 Implicit이다.
NASA의 안전 필수 코드 규칙을 따르는 코드처럼 스택 오버플로를 감당할 수 없는 코드는, 이 두 기능을 쉽게 비활성화할 수 있어 프로그램의 잠재 호출 그래프를 정적으로 계산할 수 있게 된다.
이는 컴파일 시점에 프로그램의 총 스택 사용량을 계산할 수 있게 한다.
스택 사용량은 Everr 컴파일러의 마이너/패치 버전 간에 안정적임이 보장되지는 않는다. 하지만 안전 필수 코드에서 컴파일러를 자격 인증(qualification)하는 일은 보통 컴파일러 릴리즈보다 빈도가 낮고 시간이 많이 드는 일이므로, 이는 수용 가능한 트레이드오프로 간주된다.
구현 복잡도는 늘지만 더 많은 표현력을 허용하는 대안 설계는 Appendix A5를 보라.
앞서 링크한 Swift Error Handling Rationale and Proposal 문서는 C, C++, Objective-C, Java, C#, Go, Rust, Haskell의 오류 모델을 다루고 있으므로, 여기서는 그들을 더 자세히 설명하지 않겠다.
그 목록에 없는 오래된 정적 타입 언어로는 D, OCaml, Ada가 있다. 이해할 수 있게도 빠진 새로운 언어로는 Pony, Nim, Zig, Odin, Roc, Jai, Hare가 있다.
Scala는 스스로를 시스템 프로그래밍 언어로 마케팅하지 않지만, 타입 시스템이 오류 표현에 흥미로운 방법을 제공하므로 아래에 포함했다. Dart, Kotlin, Gleam, Unison 같은 다른 언어는 오류 처리 관련 문서에서 특이하거나 흥미로운 아이디어를 찾지 못했고, 덜 엄격한 성능 요구를 가진 애플리케이션을 더 목표로 하는 듯하여 제외했다.
(EDIT: Mar 09, 2025) Common Lisp는 정적 타입 언어는 아니지만 재개 가능한(resumable) 예외에 대한 흥미로운 시스템을 지원하므로 포함했다. Lobste.rs에서 Manuel Simoni(@manuel), Tony Finch(@fanf), Hacker News에서 Gavin Howard(@GavinHoward)의 피드백에 감사한다.
여기서는 언어 자체 문서를 바탕으로 오류 모델을 아주 빠르게 요약하겠다. 내 요약은, 특히 1.0 이전 언어들에서는 관례가 유동적이고 문서가 낡았을 수 있으므로, 권위 있는 것으로 받아들이면 안 된다.
2025년 2월 기준, 공식 D 문서는 오류를 전달하는 주요 방식으로 언체크 예외 사용을 권한다.
하지만 DConf 2020에서 D 창시자 Walter Bright는 예외가 구식이라고 생각한다고 말했다.
기타 주목할 점:
Exception을 던지는 것은 nothrow 함수에서 허용되지 않는다.Error를 던지는 것은 nothrow 함수에서도 허용된다.assert 함수/빌트인은 Error를 던진다.OCaml의 오류 전파는 여러 방식이 있다:
option 또는 result를 반환하는 대체 변형을 제공한다.추가로 OCaml은 option과 result로 연쇄/단락 평가를 하기 위한 문법 설탕을 지원한다.
공식 OCaml 문서는 특정 오류 전파 전략을 처방하기보다는 중립적에 가깝지만, 다음을 말한다:
요즘은 함수가 버그가 아닌 경우(즉 assert false가 아니라 네트워크 실패, 키 부재 등)로 실패할 수 있다면, 예외를 던지기보다는 ’a option이나 (’a, ’b) result 같은 타입(다음 절 참고)을 반환하는 것이 좋은 관행으로 여겨지는 경향이 있다.
책 Real World OCaml은 표준 라이브러리 외에 Jane Street의 Base 라이브러리 사용을 권한다. 이 라이브러리는 Error.t(구축이 쉬운 여러 헬퍼를 가진 lazy string) 같은 보조 타입과, 예외를 던지는 계산을 result로 바꾸는 Or_Error.try_with 같은 헬퍼 함수를 포함한다.
Real World OCaml은 다음으로 결론낸다:
빠르게 끝내는 것이 핵심이고 실패 비용이 크지 않은 대충 쓰는 프로그램이라면, 예외를 광범위하게 사용하는 것이 맞을 수 있다. 반면 실패 비용이 큰 프로덕션 소프트웨어라면, 오류 인지 반환 타입을 사용하는 방향으로 기울여야 한다.
[..] 오류가 충분히 드물게 발생한다면, 예외를 던지는 것이 종종 올바른 동작이다.
또한 어디서나 존재하는 오류의 경우, 오류 인지 반환 타입은 과할 수 있다. 좋은 예가 out-of-memory 오류인데, 어디서나 발생할 수 있으므로 이를 포착하려면 모든 곳에서 오류 인지 반환 타입을 써야 한다.
Ada는 특정 타입 exception의 값을 이용한 언체크 예외를 지원하며, 선택적으로 문자열 페이로드를 담을 수 있다. 내가 이해한 바로는 예외는 문자열 페이로드와 분리된 정체성(identity) 개념을 가진다.
미리 정의된 예외에는 다음이 포함된다:
Constraint_Error: 범위 밖 접근, 오버플로, null 역참조 등Storage_Error: 할당 실패 및 스택 고갈Stack Overflow의 한 논의에서 서로 다른 두 실무자가 다음과 같이 말했다:
하지만 위키북스의 Ada 스타일 가이드에는 “추상화를 정의하는 데 예외를 사용하라”는 가이드라인이 있다. 이 절의 코드 예시 중 하나는, 스택이 비어 있을 때 Pop 연산이 예외를 발생시키는 스택이다.
Ada 표준 라이브러리의 I/O 연산은 광범위하게 예외를 사용한다. 다른 널리 쓰이는 표준 라이브러리 대안이 있는지는 확인하지 못했다.
GNAT(GCC의 Ada 컴파일러 툴체인)은 No_Exception_Handlers, No_Exception_Propagation, No_Exceptions 같은 예외 사용을 제한하는 설정을 지원한다. 예를 들어 No_Exception_Propagation은 함수가 피호출자(callee)의 예외를 처리하도록 요구한다.
Scala 3는 언체크 예외뿐 아니라, 예외를 쉽게 catch하고 합 타입 표현으로 바꾸게 해 주는 태그드 유니언 타입 Try를 지원한다.
Li Haoyi의 IO 라이브러리 같은 일부 유명 라이브러리는, 파일이 존재하지 않는 오류 같은 것을 예외로 표현한다.
생태계의 더 FP 지향 부분은 case class 및/또는 Result를 더 많이 쓸 것이라 추측하지만, 5~10분 검색으로 이를 검증하지는 못했다.
Nim 문서를 빠르게 훑어 본 결과 다음을 추론했다:
raises 애노테이션을 지원하여, 함수가 던질 수 있는 예외 타입을 나타낸다(예: {.raises: [ParseError]}).{.push raises: [].}를 두어 모듈 전반에서 명시적 raises 애노테이션을 강제할 수 있다. 이 설정은 system.Defect를 상속하는 예외는 무시하는데, 이는 Nim에서 0으로 나누기나 assertion 실패 같은 오류를 나타내는 방식이다.Nim 튜토리얼의 예외 절은 다음을 말한다:
관례상 예외는 예외적인 경우에만 발생해야 하며, 제어 흐름의 대안으로 사용되어서는 안 된다.
튜토리얼의 나머지는 서술적이며, 예외 관련 언어 기능 사용법을 다룬다.
비공식 Nim 스타일 가이드는 오류 모델링과 처리에 대해 더 자세한 권고를 한다. 공식 Nim 문서에서는 비슷한 처방적 언어를 찾지 못했다.
아래 언어들은 2025년 3월 기준 1.0 이전이다. 예외는 Odin인데, 현재 날짜 기반 버저닝을 사용한다. Odin이 10년 미만이므로 안정성 보장 측면에서 1.0 이전과 동등하다고 가정한다.
Pony 튜토리얼에는 error 표현식 페이지가 있으며, 이는 함수가 둘러싼 try 블록까지 실행을 중단하도록 한다. 모든 partial 함수는 ?로 애노테이션되어야 한다.
내가 보기엔 error 문을 사용할 때 어떤 데이터도 붙일 방법이 없는 듯하다. 이것이 맞다면 error 프리미티브는 명시적으로 언랩해야 하는 optional 타입과 유사하다.
비공식 자료를 보면, 이 블로그 글과 Pony에서 서로 다른 오류 타입을 구분하는 StackOverflow 논의를 바탕으로, Pony에서 가장 흔한 오류 처리 방식은 유니언 타입(예: Int64 | OverflowError)을 쓰는 것으로 보인다.
2025년 2월 기준, error를 제외하고 오류를 어떻게 모델링하고 처리해야 하는지에 대해 더 설명하는 공식 문서를 찾지 못했다. 성능 치트 시트는 성능 민감 코드에서 error와 유니언 타입을 피하라고 권하는데, 둘은 서로 다른 이유다.
문서로 보면 유니언 타입은 타입 ID 포인터를 태그로 하고 값에 대해서는 암묵적 박싱을 하는 방식으로 컴파일된다. 이는 자연스러운 서브타이핑 관계 를 런타임 비용 없이 구현할 수 있게 하므로 타당하다.
Zig의 공식적인 오류 처리 방식은 “error sets” 정의다:
const FileOpenError = error{
AccessDenied,
OutOfMemory,
FileNotFound,
};
error set은 구조적 타이핑이다—다른 파일의 서로 다른 error{...} 선언에서 동일한 이름의 케이스는 상호 교환 가능하다. 이 결정이 Zig 생태계가 성장하면서 어떻게 전개될지 궁금하다. 라이브러리 작성자들이 충돌을 피하기 위해 C나 Objective-C처럼 라이브러리 특화 오류에 유니크한 접두사를 붙이기 시작할까? 이는 오류를 부분집합에서 상위집합으로 강제 변환(coercion)할 수 있게 하고, || 연산자로 병합할 수도 있게 한다.
오류는 페이로드를 담을 수 없다. 대안으로 사람들은 out 파라미터, 표준 오류 처리 기계의 회피 등 여러 패턴을 사용한다.
함수는 반환하는 다양한 오류 케이스를 쓰거나 생략할 수 있다.
// Inferred error set
pub fn parse_f32(...) !f32 { ... }
// Explicit error set
pub fn parse_f32(...) FloatParseError!f32 { ... }
여기서 FloatParseError!f32는 “error union type”이다.
함수에서 반환된 error union 값을 처리할 때, catch 키워드와 기본값 또는 코드 블록을 사용할 수 있다.
Zig는 오류 전파를 위한 try를 지원하며, 이는 catch |err| return err의 설탕이다.
Zig는 non-exhaustive enum을 지원한다. 문서는 non-exhaustive 오류와 struct를 명시적으로 언급하지 않지만(다만 “error set은 enum과 같다”고는 한다).
Zig 프로그램은 struct의 필드를 컴파일 타임에 반복하는 등, 다양한 컴파일 타임 내성(introspection) 기능을 사용할 수 있다. Zig 문서는 컴파일 타임 내성이 non-exhaustive enum과 어떻게 상호작용하는지 말하지 않는다.
Zig는 whole program compilation을 사용한다. 이 과정에서:
서로 다른 오류 케이스에 대해 유니크한 정수 값이 선택된다.
호출 그래프의 최대 높이를 계산한다(재귀는 높이 2로 제한). 이를 Debug 및 ReleaseSafe 모드에서 error return traces 버퍼를 사전 할당하는 데 사용한다.
catch와 try가 return trace와 통합된다.Zig에는 리소스 정리를 위한 defer 문이 있다. 또한 errdefer 문이 있어, 둘러싼 함수가 오류를 반환할 때만 정리 연산을 실행한다.
Zig는 커스터마이즈 가능한 @panic 연산을 지원한다. 기본 구현은 스택 트레이스를 출력하고 프로그램을 종료한다. 하지만 “루트 파일”( main을 포함하는 파일)에서 이를 오버라이드해 다른 동작을 하게 만들 수 있다.
Odin은 union 키워드로 합 타입을, enum 키워드로 C 스타일 enum을 지원한다.
Odin 문서에는 오류 모델 전용 절이 없지만, 문서의 코드 예시는 오류에 합 타입을 사용하는 것을 보여준다.
Error :: union #shared_nil {
File_Error,
Memory_Error,
}
File_Error :: enum {
None = 0,
File_Not_Found,
Cannot_Open_File,
}
Memory_Error :: enum {
None = 0,
Allocation_Failed,
Resize_Failed,
}
Go처럼 Odin의 모든 타입에는 zero value가 있다. 일반적으로 nil은 합 타입에 대해 유효한 값이다. 선언에 #no_nil 애노테이션을 붙여 이를 오버라이드할 수 있으며, 그 경우 합 타입은 기본값을 가져야 한다. 개별 케이스 타입들의 nil 값은 위 예시처럼 #shared_nil로 병합할 수 있다.
2018년에 Odin 창시자는 Exceptions — And Why Odin Will Never Have Them라는 글을 썼다.
예외의 결과 중 하나는, 오류가 어디서든 발생하고 어디서든 잡힐 수 있다는 것이다. 이는 “누군가 다른 사람이” 처리하라고 오류를 스택 위로 넘기는 문화를 의미한다. 나는 이 문화를 싫어하며 언어 수준에서 장려하고 싶지 않다. 오류는 그 자리에서 처리하고 스택 위로 넘기지 말라. 네가 만든 난장판은 네가 치워라.
Odin 문서는 non-exhaustive struct나 union을 언급하지 않는다.
Roc의 합 타입은 구조적이다.
오류 처리로 Roc는 표준 Result 합 타입과, 오류를 위한 표준 대수적 타입을 권한다.
조금 검색했지만, Roc가 non-exhaustive struct와 enum을 표현하는 방법을 지원하는지는 알 수 없었다.
Roc는 의도적으로 Optional 타입을 정의하지 않으며, 오류 처리에는 일관되게 Result를 쓰라고 권한다.
Roc에서 정수 오버플로는 Swift처럼 복구 불가능한 프로그램 크래시로 이어진다.
몇 년 전 Jonathan Blow의 영상을 좀 봤는데, 오류 모델에 대한 특별한 논의는 기억나지 않는다.
비공식 Jai 문서에는 오류 처리에 대한 두드러진 언급이 없다.
Hare는 |를 사용해 암시적 태그를 가진 익명 태그드 유니언 타입을 선언할 수 있다.
type index = size;
type offs = size;
export fn main() void = {
let z: (index | offs) = 1337: offs;
assert(z is offs);
};
문서를 읽어보면 type A = B 문법은, 같은 문법을 쓰는 대부분 언어(Rust, Go, Swift, Haskell, OCaml)와 달리 타입 별칭이 아니라 Haskell식으로 newtype 을 도입하는 것으로 보인다. 대부분의 언어에서 타입 별칭 치환은 프로그램 의미에 영향을 주지 않는 것이 기대된다. 재귀 타입 별칭도 보통 허용되지 않는다.
Hare의 태그드 유니언 타입은 대부분 언어의 합 타입 의미론이 아니라, 타입 이론적 의미의 유니언 타입 의미론을 실제로 구현한다. 더 자세한 논의는 Appendix A3를 보라.
Hare의 오류 타입은 ! 접두사로 선언할 수 있다.
type error = !(io::error | invalid | unexpectedeof);
이런 오류 타입은 match로 처리할 수 있고, 개별 구성 타입에서 암묵적 주입을 지원한다.
오류를 반환하는 함수에서, 패턴 매칭 외에도, postfix ?로 호출자에게 오류를 전파할 수 있고, postfix !**로 크래시를 유발할 수 있다.
Hare 문서는 필드나 케이스에 대한 non-exhaustiveness를 언급하지 않는다.
(EDIT: Mar 9, 2025: 이 절은 새로 추가되었다):
Common Lisp에는 재개 가능한 예외를 허용하는 condition system이 있다. 조건은 호출 스택을 언와인드할 필요가 없다.
Rust는 역사적으로 condition system을 사용했지만, 제거되었다. Lobste.rs 스레드에서 이를 지적한 Steve Klabnik에게 감사한다.
링크된 PR에서 Alex Crichton은 다음과 같이 쓴다:
조건을 발생시키는 함수는 조건을 발생시키고 있다는 점이 항상 명확하지 않다. 이는 예외와 비슷한 문제를 겪는데, 실제로 어떤 함수가 조건을 발생시킬지 알 수 없다. 문서가 설명할 가능성은 있지만, 누군가 사후적으로 함수에 조건을 추가하면 업스트림 사용자에게 새로운 실패 지점을 인정하도록 강제하는 장치가 없다.
재개(resumption) 개념은 연속체(continuations) 및 더 최근 연구인 대수 효과(algebraic effects)와도 밀접히 관련된다. 소유권과 라이프타임 추적과의 통합에 대한 우려를 포함한 더 자세한 논의는 Appendix A8를 보라.
내 언어의 한계가 내 세계의 한계를 뜻한다.
– Ludwig Wittgenstein
이 절에서는 3절에서 제시한 핵심 기준을 바탕으로 Everr를 다른 기존 프로그래밍 언어들과 비교하겠다.
여기서는 native support 에 집중한다. 즉 특정 구성을 언어가 직접 표현할 수 있는지 여부이지, 다른 구성 요소로 에뮬레이션할 수 있는지 여부가 아니다.
어떤 언어는 언어 내부 복잡도를 최소화하고 이를 라이브러리 및/또는 애플리케이션으로 밀어내는 것을 선호한다(예: 매크로, 컴파일 타임 리플렉션 같은 범용 메커니즘 제공). 하지만 일반적으로, 네이티브로 지원되는 기능은 문법 설탕, 도구의 특별 취급, 전용 오류 메시지, 공식 문서에서의 더 많은 언급 등에서 특혜를 받는다. 그러므로 네이티브 지원에 집중하는 것이 합리적이다.
“이론적으로는 이론과 실천에 차이가 없다. 실천에서는 차이가 있다.”라는 말처럼, 이는 좀 이상한 사과-대-왁스 사과 비교가 될 것이다. 여기서 “왁스 사과”는 먹을 수 없는, 왁스로 만든 사과 모형을 뜻하는 것이지 wax apple 과일을 뜻하는 것이 아니다. Everr는 구현이 없으므로, 이 아이디어들이 큰 변경 없이 작동 가능하다는 실증 증거가 없다. 내가 제공할 수 있는 것은, Everr가 뻔뻔하게 베끼는 여러 기존 언어들의 사용이라는 간접 “성공” 증거뿐이다.
이 큰 단서를 붙인 채, 진행하자.
예외를 주된 오류 처리 방식으로 쓰는 언어는 보통 exhaustiveness checking을 네이티브로 가능하게 하는 방법이 부족하다: 관용적인 오류 처리가 구조적으로 non-exhaustive하기 때문이다.
반면 Zig, Roc, Hare 등 1.0 이전의 새로운 언어들은 non-exhaustive 오류를 위한 네이티브 지원이 부족하다.
Haskell과 OCaml 같은 대수적 데이터 타입이 있는 오래된 언어들도, 데이터 타입에 non-exhaustiveness 애노테이션을 네이티브로 지원하지 않는다.
Swift는 struct와 enum을 non-exhaustive로 표시하는 것을 지원하지만, 이는 ABI 호환성 보장 기능인 Swift의 상위 기능 ‘Library Evolution’과 결합되어 있다.
Rust는 struct와 enum을 non-exhaustive로 표시하는 것을 지원하고, 해당 애노테이션이 있을 때 각각 필드와 케이스를 확장할 수 있다. 하지만 case refinement는 enum 케이스별로 별도 struct를 미리 정의해야 한다.
대수적 데이터 타입을 네이티브로 지원하는 언어들에서는 합 타입과 곱 타입이 모두 1급이고, 합 타입은 공유 필드를 가질 수 없는 경우가 일반적이다. 이는 때때로, 모든 케이스에 공통 필드를 나중에 추가하고 싶을 때 하위 호환을 깨지 않기 위해, 네이티브 enum 대신 단일 필드 struct를 사용하라는 소프트 권고로 이어진다.
Scala는 기술적으로 case refinement를 잘 지원해야 한다. case class가 1급 타입이고 상속될 수 있기 때문이다. 하지만 내 이해로는 이는 매우금기시된다.
Scala는 sealed 키워드로 케이스 축에서의 exhaustiveness 애노테이션을 지원한다. 하지만 Scala 문서에는 case class 필드에 대한 non-exhaustiveness 애노테이션 지원이 언급되어 있지 않다.
Rust처럼 Everr도 필드와 케이스 모두에 대한 exhaustiveness 애노테이션을 지원한다.
Cap’n Proto처럼 Everr는 non-exhaustive enum에 공유 필드를 추가하여 소스 수준 하위 호환을 깨지 않고 struct로 진화시키는 것을 허용한다.
Everr는 케이스가 struct로 표현되고 바깥 타입의 exhaustiveness 애노테이션이 개별 케이스 struct로 전파되므로, case 필드를 사용해 하위 케이스를 추가하는 방식으로 case refinement를 지원한다.
앞서 ‘Error propagation’의 첫 하위 포인트에서 나는 다음을 썼다:
- Explicit marking: 코드가 다음을 만족하도록 강제할 수 있는 능력:
- 기본 연산에서 발생 가능한 오류는 명시적 표식으로 표시된다.
- 호출한 연산의 오류를 자신의 호출자에게 전파하려면 명시적 표식이 필요하다.
명시적 표식이 없으면 국소화된 정적 오류가 발생해야 한다.
먼저 둘째부터 논의하자.
예외로 오류 처리를 할 때 가장 흔한 접근은 예외가 조용히 전파되며 거의 모든 함수가 거의 모든 예외를 던질 수 있다는 것이다. 이 접근은 프로그래머가 문서 주석을 읽고, 쓰고, 유지하는 성실함에 의존한다.
예외 전파에 대해 더 세밀한 제어를 제공하는 언어로는 Java, D, Ada, Nim, C++가 있다.
Java의 체크 예외는 문법적 오버헤드와, 시간이 지나 새 오류 케이스를 반환하도록 코드를 변경하기 어렵다는 문제로 사용이 흔치 않다는 것이 내 이해다.
Go와 Rust는 예외와 유사한 패닉 메커니즘을 가지고, 일반적으로 “예외적” 또는 “파국적” 상황에만 이를 쓰라고 권한다.
Rust 생태계에는 최적화 빌드에서 패닉 전파에 대한 정적 강제를 가능케 하는 유명한 “링커 해킹”이 있다.
Swift, Pony, Zig, Roc, Odin은 예외 같은 메커니즘을 네이티브로 지원하지 않으며, 오류의 명시적 전파(또는 프로그램 크래시)를 요구한다.
Rust, Swift, Zig는 명시적 오류 전파를 위한 전용 문법을 가진다.
? 연산자가 있으며, 주로 오류 전파에 쓰인다. Rust에는 ?의 범위를 제한하기 위한 불안정 기능 try_blocks가 있다.try 키워드가 있어, 오류를 둘러싼 함수 밖으로 버블링한다. 이는 개별 문장과 하위 표현식 수준에서 동작한다. Swift는 또한 오류를 nil( Optional)로 바꾸는 try?, 오류를 만나면 프로그램 크래시를 유발하는 try!도 지원한다.catch와 try 키워드가 있다.Haskell의 do 표기도 비슷한 기능을 제공하지만, 효과 전파 방식(예: effect 타입이나 monad transformer 사용)에 따라 추가 보일러플레이트가 필요할 수 있다.
비슷한 방향의 진행 중/최근 작업도 있다:
? 설탕에 대한 진행 중 제안이 있다. 설탕 외에도, 프로그래머는 표준 라이브러리 함수들을 사용해 오류에 문맥을 명시적으로 붙이는 것이 권장된다.std::expected를 추가했다.Everr의 오류 전파는 어떤 의미에서는 기존 언어들의 혼합이다:
try를 허용하여 유연하다.noexcept처럼 “패닉할 수 없음”을 표시할 수 있다.또한 패닉이 capability이고 capability가 여러 레벨을 지원하므로, 서로 다른 맥락에서 서로 다른 엄밀함 수준을 사용할 수 있다.
정수 오버플로 오류에 대해, 제어를 제공하는 언어들은 보통 전용 문법보다는 컴파일러 플래그와 표준 라이브러리 API 형태로 제공한다.
대부분 언어는 정수 오버플로에 대해 wrapping 의미론을 사용하며, 대체 의미론을 표준 라이브러리 함수/빌트인/타입으로 제공하거나 아예 제공하지 않는다.
주목할 예외:
ReleaseFast 및 ReleaseSmall 빌드 모드에서는 미정의 동작.Debug 및 ReleaseSafe 빌드 모드에서는 패닉(즉 기본은 크래시지만 오버라이드 가능).Everr는 어떤 의미에서는 Swift와 Rust의 중간쯤이다; 빌드 모드와 무관하게 항상 패닉한다.
힙 할당에 대해, 대부분 언어는 매우 큰 구조를 메모리에 만들려 하다가(예: 매우 큰 파일을 통째로 메모리에서 처리하려는 실수) 할당 실패 시 프로그램 종료로 이어지는 것을 허용한다. (EDIT: Mar 9, 2025) - Lobste.rs 스레드의 Alex Kladov 관찰을 바탕으로, 힙 고갈보다는 메모리 사용량 급증에 초점을 맞추도록 표현을 변경했다. 공유 시스템에서 운영체제가 메모리를 너무 많이 쓰는 프로세스를 죽일 수 있고, “너무 많음”은 동적으로만 결정될 수 있다는 점은 이해한다. 하지만 서버 같은 맥락에서는 빌드 시점이나 프로그램 초기화 시점에 가용 메모리 양을 아는 경우가 많다.
Zig와 Odin은 여기서 다른 언어들과 다르다; 할당자를 거의 모든 곳에 파라미터로 내려보낸다(Zig는 명시적으로, Odin은 숨겨진 context 파라미터를 통해 종종 암묵적으로). 이는 다양한 수준에서 할당 실패를 처리할 수 있게 한다. Zig와 Odin으로 작성된 애플리케이션 중 실제로 out-of-memory를 별도로 처리하는 코드 경로를 가진 비율이 얼마나 되는지(그냥 스택 위로 전파하다 프로그램을 종료하는 대신), 그리고 그 중 테스트 커버리지를 가진 비율과 커버리지 품질이 얼마나 되는지 살펴보면 흥미로울 것이다.
Zig와 Odin에는 메모리 안전한 코드와 그렇지 않은 코드를 구분하는 표준 소스 수준 마커가 없다.
C++, Rust 등에서는 다양한 수준에서 그런 규율이 가능하지만 덜 흔하다.
std::string과 std::vector는 할당자 커스터마이즈를 위한 선택적 타입 파라미터를 가진다.Ada는 다소 특이하게도, 보조 스택(secondary stack)을 통해 동적으로 크기가 변하는 데이터를 반환할 수 있어, 특정 경우 힙 할당 필요를 줄일 수 있다.
추적 GC를 쓰는 오래된 언어들은 힙 할당 없는 코드를 더 쉽게 쓰기 위한 기능을 진화시켰다. Java, C#, Haskell, OCaml 등에서 언박싱 타입이 다양한 정도로 가능하다.
Everr의 메모리 관리 전략이나 타입 시스템을 내가 명시하지 않았으므로, 이 지점에 대해서는 Everr와 다른 언어의 비교를 하는 것이 의미가 없다.
나는 Rust의 현재 접근—서로 다른 메모리 관리 전략을 필요로 하는 사용자에게만 복잡도를 떠넘기는(예: Vec, bumpalo::Vec, smallvec::SmallVec)—가 괜찮은 스위트 스폿을 제공한다고 믿는다.
이 공간의 흥미로운 연구에 대한 자세한 내용은 Appendix A4를 보라.
나는 스택 오버플로의 부재를 정적으로 보장할 수 있는(연구 언어가 아닌) 언어를 모른다.
Ada의 GNAT 컴파일러는 정적 스택 사용량 분석을 지원하여 스택 사용량 데이터를 수집할 수 있다. 검색해 본 바로는 SPARK가 스택 오버플로 부재를 정적으로 보장하는지 명확하지 않다.
기술적으로는 동적 스택 프로빙을 한 뒤 더 공간이 필요할 것 같으면 힙을 이용해 스택을 성장시키는 기법을 쓸 수도 있다. Rust의 stacker 크레이트가 이 접근을 취한다.
recursion과 indirect call을 capability로 표시하는 capability 메커니즘을 통해, Everr는 스택 오버플로 위험을 크게 줄일 수 있다(아마 상당히). Ada처럼, Everr 컴파일러는 이 capability가 꺼져 있거나 사용되지 않는 함수에 대해 스택 사용량의 구체적 상한을 제공할 수 있다.
표현력을 유지하면서도 모듈성을 유지한 채 스택 오버플로를 정적으로 배제할 수 있는 한 아이디어는 Appendix A5를 보라.
엄밀히 말하면, 구조화 metadata attachment, 오류 결합, erasure는 어떤 주류 언어에서도 가능하며, 질문은 보일러플레이트가 얼마나 필요한지다.
상속이 없는 언어에서 구조화 metadata attachment를 위해 타입에 더 많은 데이터를 추가하려면:
어떤 타입 인지 컴파일 타임 메타프로그래밍 기능이 있느냐에 따라, 이런 류의 보일러플레이트를 크게 줄일 수 있다. Zig와 Nim 같은 언어가 여기에 속한다.
타입 확장을 메타프로그래밍으로 하면, 메타프로그램 디버깅이 어려울 수 있다. 메타프로그래밍은 또한, 메타프로그램이 추상화 경계와 어떻게 상호작용하는지에 대한 명확한 설계를 요구하는 언어 설계 도전도 제기한다. 예:
이론적으로, TypeScript나 Elm과 유사한 확장 가능한 레코드(extensible records)를 지원하면 이 문제를 깔끔하게 해결할 수 있다. 하지만 구조적 확장 레코드를 순진하게 쓰면 라이브러리 경계를 넘는 호환성 위험을 도입하고, 타입 시그니처에서 전체 구조를 노출해야 하며(캡슐화에 반함), 타입 시스템 복잡도가 증가한다.
Everr에서는 업스트림에서 정의된 합 타입을 다음처럼 “확장”할 수 있다:
@exhaustive
enum ImageProcessingError {
| DownloadingError { ... }
| ProcessingError { ... }
| StoringError { dbError: PgError }
// Suppose 'PgError' represents a Postgres error which is a complex
// data type with many fields and methods
}
관심 있는 특정 정보를 가진 새 타입을 정의해:
@exhaustive
struct DetailedStoringError {
@delegate
base: ImageProcessingError.StoringError,
dbName: Str,
dbURL: URL,
}
바깥 enum 타입을 대체하는 새 타입을 만들 수 있다:
@exhaustive
enum DetailedImageProcessingError {
| type ImageProcessingError.DownloadingError
| type ImageProcessingError.ProcessingError
| type DetailedStoringError
}
이것은 전체 enum 정의의 케이스를 여전히 복제해야 하지만, 그 안에 인라인으로 정의된 필드는 복제하지 않아도 된다. 공유 케이스의 리매핑은 여전히 별도 함수에서 “손으로” 하거나 T에서 A:union[.T | ...]로의 암묵적 주입을 의존해야 한다.
이는 다음에 의존한다:
유니언 타입과 위임(예: 상속)을 둘 다 지원하는 언어(예: Scala, Pony)에서는 비슷하거나 더 적은 보일러플레이트로 같은 일을 할 수 있다.
Everr의 모델링은 Odin에도 그대로 복사할 수 있는데, Odin의 태그드 유니언은 struct 위에 구축되어 있고 위임을 지원하기 때문이다.
1급 enum 케이스 타입을 지원하지 않는 합 타입 언어(Rust, Swift 등)에서는, 케이스별 전용 struct를 정의하고 인라인 필드 정의를 피해야 같은 것을 할 수 있다.
완전히 정적이고 보일러플레이트가 최소인 네이티브 해결책 한 가지는, 기존 타입으로부터 “diff”를 이용해 새 타입을 정의하는 기능을 추가하는 것이다. 이는 여러 버전의 데이터 포맷이나 API를 동시에 지원하는 등 독립적으로도 유용하겠지만, 자체적인 복잡도를 도입한다. 그래서 이 글에서는 제외했다. 이것이 어떤 모습일 수 있고 어떤 복잡도를 다뤄야 하는지에 대한 스케치는 Appendix A6를 보라.
오류 처리에는 4개의 하위 기준이 있다: exhaustiveness checking, 구조화 metadata extraction, 오류 projection, unerasure.
뒤의 세 가지는 본질적으로 특정 라이브러리 호출과 필드 투영/메서드 호출 문법 지원으로 귀결되며, 오늘날 대부분 언어에서(“객체지향”이라 자칭하지 않는 언어까지) 지원된다.
exhaustiveness checking은 대부분의 새 언어가 어떤 형태로든 지원한다. 패턴 매칭(또는 “switch”) 문법의 유연성은 언어마다 크게 다르다. 언어에 따라 다음 기능들의 지원 수준이 다를 수 있다:
Everr가 Ultimate Conditional Syntax를 사용함으로써, 이 모든 메커니즘을 일반화할 뿐 아니라, 다음을 통해 패턴 매칭을 더 간결하게 표현할 수 있다:
and 뒤에서 바인딩을 즉시 인라인으로 사용할 수 있게 하여, 중첩 없이 Rust/Swift의 if let 스타일 바인딩을 일반화한다.소프트웨어 개발은 단 하나의 반복적 행위로 축약될 수 있다. 우리가 하루 동안 하는 거의 모든 일—풀 리퀘스트, 회의, 화이트보드 다이어그램, 복도 대화—은 설명이다. 우리의 일은, 우리 소프트웨어의 의미를—그것이 무엇이며, 무엇이 되기를 기대하는지—계속해서 설명하는 것이다.
– Zach Tellman, Explaining Software
언어 문서는 일반적으로 오류 처리에 대해 서술적 입장을 취한다. 즉 오류 처리를 할 수 있는 다양한 방법을 설명하지만, 처방적이기를 피한다.
이는 프로젝트가 종종 (1) 표준 라이브러리가 하는 방식 또는 (2) 보일러플레이트가 가장 적은 방식 중 하나를 따르게 만든다. 때로는 둘이 같다.
서로 다른 상황에서의 오류 관례를 처방 하는 책임은 보통, 서드파티가 관리하는 스타일 가이드에 맡겨진다.
이 현상에 대한 가능한 이유(비포괄적) 목록:
생태계가 시간에 따라 진화함에 따라, 나중에 관례를 도입하는 일은, 그 관례가 기존 관행을 “모범 사례”로 성문화하는 수준이 아니라면, 반대에 직면할 가능성이 크다. 이는 새 관례에 유리한 근거가 제시되어도, 다양한 인지 편향 때문에 일어날 수 있다.
나는 언어가 오류의 정의·전파·처리 방법을 언제 어떤 맥락에서 쓰는 것이 적절한지에 대해 더 명확한 안내를 제공할 여지가 훨씬 크다고 믿는다.
또한 보통보다 언어 생애 초기부터 처방적 가이드를 제공하고, 가이드를 증거에 기반한 진화하는 산출물로 생각하도록 장려하는 것이 가치 있다고 믿는다. 예를 들어 가이드는, 가이드와 관련된 과거 증거의 긍정/무효/부정 결과에 대한 짧은 요약과, 추가 증거 수집에 대한 명시적 초대를 동반할 수 있다.
원칙적으로, 충분한 자원이 있다면 거의 어떤 언어에 대해서도 거의 어떤 도구든 만들 수 있다. 문제는 실제로는 “자원”이 대체로 결코 “충분하지” 않다는 점이므로, 올바른 도구를 가능한 한 쉽게 만들 수 있게 하는 것이 타당하다.
이 지점에서 Go 생태계는 좋은 예라고 믿는다. 아마도 “필요가 발명의 어머니”라는 말이 적용될까? 듣기로는 Java에는 훌륭한 힙 프로파일링 도구가 있는 반면, Rust와 C++ 같은 언어의 도구는 비교적 빈약하다고 한다. Go 컴파일러는 exhaustiveness checking 같은 다른 생태계에서는 기본으로 여겨지는 기능을 네이티브로 지원하지 않지만, 새 린터 만들기가 쉽고, 이를 다른 린터들과 통합하거나 Bazel 같은 빌드 시스템과 통합하는 것도 쉽다.
특히 생태계 전반에서 오류 전파 메커니즘을 표준화하면, 반복 작업을 단순화하기 위한 결정적 리팩터링에 투자할 동기가 생길 수 있다. 예를 들어 Everr에서는 언어 서버가 새 오류 타입 정의 같은 보일러플레이트를 처리해 주면서, 오류에 metadata를 붙이기 위한 리팩터링을 제공할 수 있다.
이것으로 Everr의 오류 모델과 다른 언어들의 오류 모델 비교는 끝이다.
많은 언어가 오류를 정의·전파·처리하는 서로 다른 메커니즘을 제공하지만, 이 모든 축에서 다른 언어보다 “엄격히 우월한” 언어는 없다.
나는 Everr와 유사한 설계가, 오류 케이스의 다양한 가능성과 그것을 처리하는 방식을 프로그래머가 표현하도록 도와, 대부분 축에서 대부분 언어에 필적하거나 개선할 수 있으면서도, 오랜 시간 동안 코드 유지 능력을 보존할 수 있다고 믿는다.
다음 절은 지금까지보다 더 철학적이므로, 취향이 아니라면 여기서 읽기를 멈춰도 된다. 노 저지먼트. 😆
이 절에서는 조금 다른 일을 해 보겠다. 내 생각을 공유하기 전에, 여러분에게 몇 가지 질문을 던져보자.
정적 타입 언어에 익숙한 프로그래머라면, 같은 언어 코드베이스에서 모든 함수가 성공 시 Any(또는 동등한 것)를 반환하는 것을 보면 눈썹이 올라갈 가능성이 크다.
그런데도, 다운캐스팅이 필요한 비정형 오류(untyped errors)의 사용은 여러 언어에서 광범위하다. 예를 들어 Rust에서는 애플리케이션에서 anyhow 크레이트를 쓰라는 권고가 흔하고, Go에서도 실패할 수 있는 함수는 대부분 error 인터페이스를 반환한다.
Q: 왜 이런 불일치가 존재한다고 생각하는가?
“좋은 코드”가 무엇인지에 대한 담론은 종종 오류에 대한 상세 논의를 전혀 피한다. 예를 들어 _A Philosophy of Software Design_에서, 나는 다음을 쓴다: _A Philosophy of Software Design_에서의 예를 여기서 논의하는 이유는, Hacker News와 Lobsters 같은 포럼에서 널리 추천되는 것을 자주 봤기 때문이다. John Ousterhout은 다음을 쓴다:
모듈의 인터페이스는 그 모듈이 시스템의 나머지에 부과하는 복잡성을 나타낸다: 인터페이스가 작고 단순할수록, 도입하는 복잡성도 작다. [..]
Unix 운영체제와 Linux 같은 후손들이 제공하는 파일 I/O 메커니즘은 깊은 인터페이스의 아름다운 예다. I/O를 위한 기본 시스템 콜은 다섯 개뿐이고 시그니처도 단순하다:
int open(const char *path, int flags, mode_t permissions); ssize_t read(int fd, const void* buffer, size_t count); ssize_t write(int fd, const void* buffer, size_t count); off_t lseek(int fd, off_t offset, int referencePosition); int close(int fd);[..] 현대의 Unix I/O 인터페이스 구현은 수십만 줄의 코드를 필요로 하며, 복잡한 이슈들을 다룬다 [..] Unix I/O와 가비지 컬렉터 같은 깊은 모듈은, 사용하기 쉽지만 상당한 구현 복잡성을 숨기기 때문에 강력한 추상화를 제공한다.
[..] 어떤 인터페이스가 많은 기능을 갖고 있지만 대부분 개발자가 그 중 일부만 알면 된다면, 그 인터페이스의 실질적 복잡성은 흔히 쓰이는 기능들의 복잡성일 뿐이다.
Ousterhout이 여기서 무시하는 방 안의 코끼리 하나는 오류다. 나는 Unix 파일 I/O API에 대해 더 깊은 비판이 있지만, 여기 여백에는 쓸 공간이 없다. 올해 후반에 Ousterhout 책에 대한 더 자세한 리뷰를 쓸 수도 있다. open만 보자. man7.org를 보면 open은 다음 오류 케이스들을 가질 수 있다:
EACCES, EBADF, EBUSY, EDQUOT,
EEXIST, EFAULT, EFBUBG, EINTR,
EINVAL, EISDIR, ELOOP, EMFILE,
ENAMETOOLONG, ENFILE, ENODEV, ENOENT,
ENOMEM, ENOSPC, ENOTDIR, ENXIO,
EOPNOTSUPP, EOVERFLOW, EPERM, EROFS,
ETXTBUSY, EWOULDBLOCK
26개다. 이 중 EACCES가 언제 발생하는지 보자:
파일에 대한 요청된 접근이 허용되지 않거나, pathname의 경로 접두사(path prefix) 중 어떤 디렉터리에 대한 탐색 권한이 거부되었거나, 파일이 아직 존재하지 않았고 상위 디렉터리에 대한 쓰기 권한이 허용되지 않았다. (path_resolution(7)도 보라.)
O_CREAT가 지정되었고,
protected_fifos또는protected_regular sysctl이 활성화되어 있으며, 파일이 이미 존재하고 FIFO 또는 일반 파일이며, 파일 소유자가 현재 사용자도 아니고 포함 디렉터리 소유자도 아니며, 포함 디렉터리가 world- 또는 group-writable이고 sticky인 경우. 자세한 내용은proc_sys_fs(5)의/proc/sys/fs/protected_fifos및/proc/sys/fs/protected_regular설명을 보라.
커널이 이들 문제 중 하나로 EACCES를 반환하려면, 그것을 탐지해야 한다. 즉 오류를 탐지하는 시점에 무엇이 실패했는지에 대한 문맥 정보가 있다. 하지만 API 시그니처 때문에 커널은 이 정보를 호출자에게 반환할 수 없다. 따라서 커널과 호출자는 추가 metadata를 전달하기 위한 “사이드 채널”을 따로 협력해야 하거나, 커널이 정보를 그냥 버려야 한다.
Q: 파일을 열 때 EACCES 오류를 맞아본 적이 있는가? 처음 그 오류를 디버깅할 때 무엇을 했는가? 그리고 만약 그 디버깅 도중의 과거 자신에게 돌아가, _A Philosophy of Software Design_이라는 평판 좋은 책이 그것을 “깊은 API의 아름다운 예”라고 말한다고 알려줄 수 있다면, 과거의 당신은 어떤 반응을 보일까? 그리고 그게 얼마나 중요한가?
언어를 배우는 데는 다섯 가지 필수 구성 요소가 있다:
문법(Syntax): [..]
의미론(Semantics): 의미론이란 프로그램의 동작을 정의하는 규칙을 뜻한다. [..] 동적 의미론은 프로그램이 실행 또는 평가될 때의 런타임 동작을 정의한다. 정적 의미론은 문법 요구사항 외에, 프로그램이 합법적인지 보장하기 위한 컴파일 타임 검사를 정의한다. 가장 중요한 정적 의미론은 아마도 타입 검사일 것이다: 프로그램이 잘 타이핑되었는지 정의하는 규칙.
관용구(Idioms): [..]
라이브러리(Libraries): [..]
도구(Tools): [..]
– Cornell CS 3110 프로그램 코스 노트
어떤 의미에서는 프로그램 의미론에는 시간성을 초월한 느낌이 있다—한 프로그램 과 한 의미론 을 말하기 때문이다. 하지만 우리의 프로그램(그리고 그 의미론)은 시간에 따라 변한다!
이는 두 가지 다른 개념으로 이어지는데, 나는 이를 각각 evolution semantics 와 migration semantics 라 부르겠다. 문헌에 표준 용어가 없다고 믿지만, 틀렸다면 기꺼이 정정되겠다! 느슨한 정의는 다음과 같다:
Evolution semantics: 시간이 지나며 진화하는 프로그램 조각들(예: 서로 다른 버전)과 그 정적 의미론 사이의 상호운용성 (또는 그 부재)을 다룬다. 이 범주에 속할 예시는:
Migration semantics: 시간이 지나며 진화하는 실행 중인 프로그램 조각들(예: 서로 다른 버전)과 그 동적 의미론 사이의 상호작용 (또는 그 부재)을 다룬다. 이 범주에 속할 예시는:
나는 이 두 영역이 그 자체로 연구할 가치가 있다고 믿는다. 시간에 대해 추론하는 것은 어렵고, 하지만 코드베이스와 데이터베이스가 더 오래되고 커질수록 시간에 따른 프로그램과 데이터에 대한 추론은 점점 더 중요해지기 때문이다.
“좋다,”라고 말할지 모르겠다. “그런데 시간과 오류 모델이 무슨 상관인가?” 잠깐만 기다려라.
이 글을 읽는 동안 Everr가 곱 타입을 1급으로, 합 타입을 2급으로 만든 선택이 미학적으로—어쩌면 깊게—불편하다고 느꼈을 수도 있다.
반대로 ML 계열 언어는 합 타입과 곱 타입을 (대체로) 동등하게 두므로 더 미학적으로 느낄 수도 있다. “대체로”라고 하는 이유는, 순수주의적 버전은 합 타입 케이스에 대한 인라인 레코드 문법을 금지할 것이기 때문이다(예: OCaml 4.03 이전).
이해한다. 솔직히 말해 나도 Cap’n Proto가 레코드를 1급으로, (태그드) 유니언 타입을 2급으로 만든 결정을 처음 읽었을 때 비슷한 반응을 했다(🧠: “분명 더 나은 방법이 있을 텐데!”). SQL DDL 명령에서 IF EXISTS를 지원하는 것을 처음 배웠을 때도 비슷했다(🧠: “이런 식으로 문법을 ad-hoc하게 늘리면 안 되지! 다른 기능들과 직교적(orthogonal)이지 않아 보이는데!”).
case라는 특수 필드 이름에 대한 암묵적 투영도 미학적으로 불편하다(🧠: “왜 어떤 필드 이름이 다른 것들보다 특권을 가져야 하지!”).
bounded union type 문법도 미학적으로 울퉁불퉁하다(🧠: “타입 이름의 존재가 일반적인 유니언 타입 문법에 있는 대칭성을 망치는데!”). 만약 “프로그래머가 텍스트 편집을 핵심으로 하는 자신이 좋아하는 편집 환경에서 Everr 코드를 읽고/쓸 수 있어야 한다”는 전제를 받아들인다면, ASCII 문자로 타입 문법을 표현할 방법이 필요하다. 그리고 나는 프로그래머가 다음처럼 쓰길 제안할 배짱은 없다:
Dessert
/----- union -----\
.Cake | .IceCream
각 결정은 특정 필요를 충족하지만, 그럼에도 많은 결정은 여전히 어색하게 느껴진다. (내가 0의 공을 가져가고, 논문에서 그대로 베껴왔을 뿐인 Ultimate Conditional Syntax를 제외한다면 말이다.)
개별 결정들보다도, 이 글 전반에서 사용된 추론 방식 자체가 미학적으로 불편했을 수도 있다. 많은 추론이 귀납(induction)이나 소수의 핵심 프리미티브를 우아하게 조합해 모든 도전을 해결하는 방식이 아니라, 케이스 분석에 의해 이뤄졌기 때문이다.
Everr의 언어 설계 자체는, 미켈란젤로 조각상을 박물관에서 마주치는 것이라기보다, 이글루나 모래성이 시간에 따라 덧칠되고 다듬어지는 패턴을 반영한다.
초보가 만든 이글루가 미켈란젤로 조각상만큼 아름다울 수 있을까? 답은, 여러분이 지금 눈보라 속에서 피난처를 찾는 중인지에 달려 있을까?
나는 컴퓨팅 환경의 “울퉁불퉁함”—하드웨어의 이질성, 패키지 생명주기의 편차와 패키지 유지관리자의 대인 역학, 혹은 쉬운 분류를 거부하는 “이상한 오류 케이스들”—이 범용 PL 설계의 항상 존재하는 배경이라고 믿는다.
이 울퉁불퉁한 배경의 어떤 부분을 그릴 캔버스로 대할지, 어떤 부분을 매끈하게 덮어버릴지—이는 감정과 가치의 문제다.
만약 우리가 구름 위 높은 곳의 어떤 부분을 그리고 싶고 시간이 촉박하다면, 혼자 힘으로 기어오르겠다고 고집하기보다 거인들의 어깨 위에 서야 할지도 모른다. 더 나아가 가능한 많은 거인과 친해져, 우리가 올라탈 수 있는 거인 피라미드를 만들도록 구슬려야 할지도 모른다. 문법 참고: 거인 피라미드(Giant pyramid)는 거인들이 정의상 거대하기 때문에 반드시 거대한 피라미드이지만, 거대한 피라미드(giant pyramid)가 거인 피라미드일 필요는 없다. 작은 것들이 모여 큰 피라미드를 만들 수도 있으니까.
오늘날 PL 설계에서 충분히 활용되지 않는 거인 둘을 꼽자면:
이 도구들 중 다수는 학계 기원이라 사용성 곡선이 가파르지만, 이를 탐구하고 더 사용자 친화적인 버전을 만들어보는 가치가 있다고 생각한다.
어디서 시작할지 모르겠고 설계 문제가 있다면, Alloy가 좋은 출발점일 것이다. 문법이 이해하기 쉽고 시각화 도구가 기본 제공된다. 반례(counter-example)의 원시 XML을 저장해 자신의 코드로 시각화할 수도 있다.
오류로 다시 줌인하면, 이제 마지막 질문에 도달한다.
행복한 가정은 모두 비슷하지만, 불행한 가정은 각자 나름대로 불행하다.
– Leo Tolstoy, Anna Karenina
“예외는 예외적인 경우에만 사용하라” 같은 경구들은 (EDIT: Mar 10, 2025): 여기서 “Let it crash” 언급을 제거했다. 원래는 포럼 논의에서 사람들이 시스템의 트레이드오프를 고려하지 않고 “그냥 Let it crash 철학을 따르라”고 가볍게 권하는 것을 의미했지만, 이를 충분히 풀어 쓰지 않았다. 이 언급은(이해할 수 있게도) Joe Armstrong의 작업에 대한 비판으로 해석되었다. 나는 Joe Armstrong에 대해 가장 깊은 존경만을 가지고 있다. 서론에서 썼듯이, Joe Armstrong의 논문은 오류에 대한 가장 총체적인 글 중 하나이며, 깊은 경험에 기반한다. (Hacker News의 namaria에게 비판적 피드백에 감사한다.) 매력적이지만, 프로그래머가 오류를 정의·전파·처리·추론하는 방식에서 마주치는 복잡성에 비하면 충분치 않다.
오류의 근본적 성격은, 다면적이며 다면적이기 때문에 추론이 복잡하다는 것이다. 소프트웨어가 만들어지고 전달되며 사용되는 매우 다양한 사회-기술적(socio-technical) 맥락은 복잡성을 더한다.
소프트웨어가 어떻게 동작하는지뿐 아니라, 어떻게 “완전히는 동작하지 않는지”도 추론할 수 있어야 한다. 추론은 우리가 소프트웨어를 조정할지, 수정할지, 비활성화할지, 삭제할지, 혹은 그대로 둘지 결정할 때 더 나은 의사결정을 가능하게 한다.
소프트웨어가 기대대로 동작하지 않을 때 이를 추론하고 디버깅하려면, 우리의 언어는 그 일을 할 수 있도록 힘을 실어줘야 한다. 프로그래밍 언어가 어떤 규모에서도 모든 소프트웨어 문제를 마법처럼 해결해 주지는 못하겠지만, 오류를 생각하는 종종 암묵적인 기준 프레임을 바꾸는 것으로, 상황을 조금이라도 개선할 수 있다고 믿는다.
점점 더 많은 세계의 부분이 소프트웨어가 올바르게 동작하는 것에 의존하게 되는 만큼, 업계로서 상황을 조금이라도 개선하려고 노력해야 한다고 믿는다.
Everr의 enum은 Rust, Swift, Haskell, OCaml 같은 언어의 enum/합 타입이라기보다 Scala의 case class에 더 가깝다.
Scala, Pony, TypeScript 같은 다른 언어의 유니언 타입처럼, Everr의 유니언 타입( exhaustive와 non-exhaustive 변형 모두)은 가환성, 결합성, 멱등성을 만족한다. 또한 exhaustive 유니언 타입은 대응하는 non-exhaustive 유니언 타입의 서브타입이다.
하지만 Scala, Pony, TypeScript와 달리 Everr의 유니언 타입은 더 제한적이다. 특정 상위 타입(top type)을 갖는 유니언의 원소는 다음 중 하나여야 한다:
Everr에는 보편적(top) 타입이 없으므로, 임의 타입들을 유니언으로 묶을 수 없다. 이는 whole-program 분석이나 JIT 없이도(그리고 enum과 같은) 효율적인 런타임 표현을 가능케 하면서, 임의 부분집합을 다룰 수 있는 장점도 제공한다.
Everr의 “enum 케이스 1급, enum 자체 2급” 선택은, enum은 1급으로 남고 enum 케이스가 2급인 Rust의 제안 enum variant types와 다르다.
주요 차이:
T:union[] 형태는 enum과 같은 레이아웃을 공유한다). Rust에서는 enum variant type이 enum과 같은 레이아웃을 공유한다.Everr의 위임 메커니즘은 상속과 유사하지만, 서브타이핑과의 연관은 없고, 따라서 업캐스팅/다운캐스팅 개념도 없다.
Go와 Rust 같은 명령형 언어는 다른 기법으로 유사 기능을 제공한다: Go에는 struct embedding이 있고, Rust에는 Deref coercions이 있다.
Rust는 배제된 상태(excluded states)를 고려하여 타입 레이아웃 최적화(“niche optimization”)를 할 수 있다. 예를 들어 Option<NonZeroU8>은 8비트가 되는데, 0 상태를 None에 재사용하기 때문이다.
Everr에서 MyEnum.MyCase 타입에서 MyEnum:union[.MyCase](그리고 0개 이상의 다른 케이스)로 가려면, 컴파일러가 해당 enum 태그를 붙이는 함수를 생성해야 한다. 앞서 논의한 Optional 타입의 경우 두 케이스 각각에 대해 이런 함수가 필요하며, 시그니처는 다음과 같다:
fn _none_to_union[A](v: Optional.None) -> Optional[A]:union[.None] {
// implementation elided
}
fn _some_to_union[A](v: Optional.Some[A]) -> Optional[A]:union[.Some[A]] {
// implementation elided
}
이 함수들의 생성은 전적으로 컴파일러 통제 하에 있으므로, 컴파일러는 모노모피제이션 중에 서로 다른 niche를 가진 서로 다른 타입에 대해 이 함수들의 특수화 버전을 직접 생성할 수 있으며, 하나의 버전을 만들려고 애쓸 필요가 없다.
즉 컴파일러가 None에서 Optional[A]:union[.None]로의 암묵적 변환을 보면, A의 인스턴스화에 따라 그 변환을 특수화할 수 있다.
Hare 문서는 태그드 유니언 타입이 “commutative, associative, and reductive”이고, “타입의 순서, 같은 타입을 여러 번 포함하는 것, 중첩 태그드 유니언을 사용하는 것은 최종 타입에 영향을 주지 않는다”고 말한다.
따라서 Hare의 태그드 유니언은 대부분 언어와 달리 합 타입이 아니라, 타입 이론적 의미의 유니언 타입 을 구현한다. Hare는 매개변수적 다형성을 지원하지 않으므로, 태그드 유니언을 통해 유니언 타입 의미론을 구현하는 선택은:
Verona에 대한 연구는 한 가지 방향을 보여준다: Reference Capabilities for Flexible Memory Management:
Verona는 프로그램의 모든 객체를 고립된 영역(isolated regions)들의 숲으로 조직하는 동시성 객체지향 프로그래밍 언어다. 메모리는 각 영역에 대해 로컬로 관리되므로, 프로그래머는 객체를 영역으로 어떻게 분할하는지, 그리고 각 영역의 메모리 관리 전략을 어떻게 설정하는지에 따라 프로그램의 메모리 사용을 제어할 수 있다.
전체 타입 시스템은 어떤 면에서 Rust보다 유연하지만, 자세히 보면 논문은 필드 접근이, 해당 필드의 영역이 이미 열려 있다면 예외를 던질 수 있음을 지적한다. 내가 이해하기로 이는 동적 borrow 체크를 하는 Rust의 Cell 타입과 유사하다.
영역에 들어가는 것은 브리지 객체를 가리키는 변수나 필드를 빌리거나(borrows) 묻는다(buries). 스택 변수의 경우, 영역이 여러 번 열리는 것을 방지하기 위해 변수를 묻는다
필드의 경우에는, 대신 영역 상태에 대한 동적 검사를 사용한다.
논문을 깊게 흡수하지는 못했지만, 이런 설계는 잠재적으로 우려스럽다.
기술적으로는 이를 더 로컬한 오류 반환으로 바꿀 수도 있겠지만, 일반 프로그램에서 이런 필요가 얼마나 널리 발생할지는 불분명하다.
나는 간접 호출을 제거하지 않고도 스택 사용량을 제어하기 위한 모듈식 정적 분석을 지원할 수 있다고 믿는다. 이는 map, filter 같은 기본 연산에서 유용할 수 있다.
간접 호출의 문제는 스택 사용량을 알 수 없다는 것이다. 따라서 가장 직접적인 “해결”은 호출에 스택 사용량 정보를 부여하는 것이다.
구체적으로 함수 타입에 두 종류의 스택 사용 예산(budget)을 부여할 수 있다:
함수 본문이 임시값을 명시하는 IR로 내려간 뒤, 비교적 안정적인 기준 최적화(예: 희소 조건 상수 전파, 복사 전파, 하지만 인라이닝은 없음) 몇 가지 이후 컴파일러는 다음을 검사할 수 있다:
마지막으로, 인라이닝과 다른 최적화 이후이지만 어셈블리를 생성하기 전, 총 예산에 대해서만(하지만 self 및 calls 예산에 대해서는 인라이닝 때문에) 검증 체크를 할 수 있다.
이 시스템에서 main에 스택 예산을 애노테이션하면, main 내부의 각 함수 호출에 대해 오류가 발생하고, 그 다음은 해당 함수들에 대해 스택 예산을 애노테이션할 때까지 이어져, 호출 그래프의 모든 함수에 스택 예산을 달게 된다. 그렇다, 이는 스택 사용량 인지 최소 표준 라이브러리를 직접 써야 함을 뜻한다.
나는 이런 시스템이 실무적으로 “작동 가능”하다고 믿는데, 제한된 의미에서, 컴파일러 최적화는 보통 호출 트리의 스택 사용량을 증가시키지 않고, 최적화를 더 돌릴수록 임시값 수는 일반적으로 줄어들기 때문이다. 따라서 최종 검증 체크는 그렇게 자주 실패하지 않을 것이라고 추측한다.
원하는 성질(예: 모노모피제이션 이후에 오류를 내도 되는가?)과 스택 예산과 함께 지원해야 하는 언어 기능(예: 예산을 모노모픽 함수에만 둘 수 있는가?)에 따라, 다음을 할 수도 있다:
+ 같은 기본 수치 연산 등을 허용나는 (1) 모듈성을 포기하거나 (2) 체크를 동적으로 옮기지 않는 한, 내가 위에서 묘사한 것보다 훨씬 단순한 해법은 어렵다고 본다.
물론 “모듈적으로 스택 오버플로를 정적으로 막기 위해 이만큼 복잡도가 가치가 있나?”라는 질문을 할 수 있다.
이에 대한 답은 모른다. 기존 언어들을 보면 대체로 No인 듯하다.
Everr가 타입 diff를 지원한다고 하자. 이는 업스트림에서 정의된 타입을 확장할 때 보일러플레이트를 줄일 수 있다. 예를 들어:
@exhaustive
struct DetailedStoringError {
@delegate
base: ImageProcessingError.StoringError,
dbName: Str,
dbURL: URL,
}
@exhaustive
enum DetailedImageProcessingError {
| type ImageProcessingError.DownloadingError
| type ImageProcessingError.ProcessingError
| type DetailedStoringError
}
를 다음처럼 바꿀 수 있다:
@exhaustive
enum DetailedImageProcessingError diff ImageProcessingError {
| ...
* | base: StoringError -> DetailedStoringError {
...,
+ dbName: Str,
+ dbURL: URL,
}
}
구체 문법을 떠나, 아래 문법을 파싱해 위 형태로 디슈가하는 것은 기술적으로 가능하다. 업스트림 타입에 케이스가 많다면 특히 유용할 수 있다.
Everr 언어 서버는 에디터 안에서 디슈가된 버전을 큰 다중 라인 “인레이 힌트”로 보여줄 수 있다. 하지만 업스트림 타입을 포함한 패키지가 특정 버전에 고정되지 않은 경우 업스트림 타입을 어떻게 표현할지라는 추가 문제를 낳는다.
하지만 이는 필드 투영과 메서드 호출 능력을 보존하기 위해 암묵적으로 @delegate를 붙이게 되므로, 이런 타입 정의를 연쇄하면 여러 수준의 위임이 생기기 쉬워 디버깅이 혼란스러울 수 있다(깊은 상속 계층과 유사). .case에 대한 암묵적 투영과의 상호작용도 고민해야 하며, 혼란을 줄 수 있다.
반대로, 타입 diff 정의의 다중 수준을 금지하면, 시간이 지나 3+ 버전의 데이터 타입을 지원해야 하는 코드 같은 목표 사용 사례에서는 너무 제한적일 수 있다.
이는 가산(diff-additive)만 고려한 것이다. 감산(diff-subtractive)은 추상화를 깨지 않기 위해 @delegate를 끼울 수 없으므로 더 단순하겠지만, 실무에서는 서드파티 타입의 확장 버전을 만들고 싶은 일이 더 흔하므로 덜 유용하다.
최근 회사에서, 우리의 DB가 “빠르게 돌던 쿼리는 계속 빠르게 돈다”라는 내 가정이 Postgres 메이저 버전 업그레이드에서 깨졌음을 발견했다. 통계(statistics) 손실이—autovacuum을 켜 두었음에도 불구하고—장애에 기여했다.
장애는 목요일 밤 늦게 해결되었다. 나는 다음 주에 휴가가 예정되어 있었다. 휴가 중 시간이 남아, 메일링 리스트 스레드, Postgres 소스 코드, 블로그 글을 뒤지고 Claude에게 물어보며 “autovacuum이 켜져 있는데도 Postgres 통계가 심각하게 오래될 수 있는 모든 상황은 무엇이며, 그것을 어떻게 탐지할 수 있는가?”라는 질문에 답하려고 했다.
복귀 후, 적어도 공개적으로는 아무도 같은 질문을 하지는 않았음을 알게 되었다. 처음에는 의아했다. “설마 사람들이 다시는 안 일어나길 바라는 건 아니겠지?” 그러다 나는 완전히 다른 정신 모델로 사고하고 있었다는 걸 깨달았다. Postgres가 통계를 유지해 줄 것이라는 내 신뢰는 확신에서 낮음으로 떨어졌고(빈약한 문서도 그 두려움을 가라앉히지 못했다), 반면 동료들은 이것이 메이저 버전 업그레이드에 특화된 일회성 문제일 가능성이 크다고 믿고 있었다.
두 믿음 모두 어떤 면에서는 타당하다.
더 낙관적 관점은, Postgres 12를 돌릴 때는 이런 통계 문제가 없었고, Postgres 업그레이드는 일반적으로 개선을 가져오므로, Postgres 16의 autovacuum은 적어도 Postgres 12만큼 좋거나 더 덜 버그가 있을 것이라는 가정이다.
더 비관적 관점은, 존재하는 하나의 바람직하지 않은 동작이 Postgres처럼 성숙하고 널리 쓰이는 DB 시스템에서도 아직 고쳐지지 않았다는 점을 고려할 때, 같은 영역에 더 많은 바람직하지 않은 동작이 숨어 있다가 언젠가 터질 수 있다는 가정이다.
이것을 생각하며 The Mythical Man Month 를 떠올렸다. 제목 챕터에서 Brooks는 첫 절에서 프로그래머의 낙관주의를 논한다.
모든 프로그래머는 낙관주의자다. [..]
단일 작업에서 모든 것이 잘 될 것이라는 가정은 일정에 확률적 영향을 준다. 실제로 계획대로 될 수도 있는데, 지연에 대한 확률 분포가 있고 “지연 없음”도 꽤 높은 확률을 가지기 때문이다. 하지만 큰 프로그래밍 노력은 많은 작업으로 구성되며, 일부는 끝에서 끝으로 연결되어 있다. 각각이 잘 될 확률은 점점 미미해진다.
여기서 Brooks는 계획 맥락의 낙관주의를 논한다. 전반적으로 이 절은 대체로 일화적이며 원인에 대한 추측이다. 계획 맥락의 이 낙관주의 포인트는 Kent Beck과 Jeff Atwood에 의해 반복되었다.
하지만 계획 오류(planning fallacy)는 직업 전반에서 흔하다.
나는 궁금하다: 프로그래머가 다른 직업보다 더 낙관적이라는 연구가 있는가? (🧠: “내가 이상한 건가?**) 그리고 이 낙관주의는 오류 케이스를 추론할 때도 적용되는가, 또 프로그래머가 버그를 발견하며 특정 코드에 대한 신뢰 수준을 어떻게 조정하는가에도 적용되는가? 조금 검색했지만 별 것을 찾지 못했다. 이 글을 읽는 당신이 관련 연구를 안다면 알려주면 좋겠다. 😃
(EDIT: Mar 9, 2025): 이 절은 Lobste.rs와 Hacker News 스레드 논의를 바탕으로 추가되었다.
Common Lisp의 condition system 같은 것은, 프롬프트(prompts)에서 핸들러로의 매핑을 담은 구조를 다양한 함수에 암묵적으로 전달하는 것과 유사하다고 볼 수 있고, 재개 가능한 예외 시점에서는 제공된 프롬프트와 사용 가능한 기존 핸들러들에 따라 적절한 핸들러를 식별해야 한다.
Common Lisp는 동적 타입이므로, 어떤 핸들러를 호출할지는 런타임에 결정되어야 한다.
내 이해(틀릴 수 있음)로는 이것은 대수 효과(algebraic effects)와 크게 다르지 않다. 다만 대수 효과의 경우, 예컨대 Generalized Evidence Passing for Effect Handlers처럼 평가 문맥(evaluation context)을 검색할 필요가 없도록 최적화할 수 있다.
Rust에서 condition system을 제거한 PR을 보면, Rust에 원래 있던 condition system은 “이 맥락에서 어떤 핸들러가 필요한지”를 추적하지 않았던 것으로 보인다. 기술적으로는, 대수 효과에 관한 최근 연구로 이 특정 문제를 해결할 수도 있었을 것이다.
재개 지점으로 타입 정보를 전달하지 않는다면, 어떤 형태의 동적 타입 검사가 필요하다. 이는 언어 설계 관점에서 두 가지 큰 선택지를 제시한다:
'static 값)을 condition handler로 전달하는 것을 허용. 이 경우 런타임 타입 검사 규칙에 따라, 런타임에 라이프타임/소유권/영역 정보를 실체화해야 할 수도 있다.스레딩/task 구조에 따라 추가 제한이 필요할 수 있다. 예를 들어 재개 함수가 “소유된” 값을 캡처할 수 있는가? 그렇다면 첫 호출 이후/중에 그 값을 파괴하면 어떻게 되는가? 재개 함수를 동시에 호출할 수 있는가?
Rust에는 다양한 종류의 함수를 표현하는 trait들이 있다: Fn, FnMut, FnOnce. 동시성 하에서의 함수 호출은 Send와 Sync trait도 관여한다.
본질적으로 타입 안전성과 메모리 안전성을 원한다면, 이 절 시작에서 말한 “핸들러”를 담은 구조를 생각할 때, 관련 함수들의 타입은 재개 시점에 동적으로(Common Lisp나 1.0 이전 Rust처럼) 또는 정적으로(대수 효과처럼) 체크되어야 한다. 이 포인트는 구현이 문자 그대로 숨은 파라미터를 전달하는 방식이 아니더라도 적용된다고 믿는다.
하지만 Rust와 Swift(데이터 레이스 자유를 보장하면서도 저수준 사용 사례를 목표로 하는 언어들)의 함수 타입 복잡도를 고려하면, 이 선택지들 중 어느 쪽을 구현하든 프로그래머에게 받아들여지기 어려울 가능성이 크다.
내가 아는 한, 대수 효과를 라이프타임/소유권/빌림과 통합한 널리 배포된 프로그래밍 언어는 없다.
이와 관련해 내가 아는 가장 가까운 작업은 Granule이지만, 나는 충분히 알지 못해 한계를 논할 수 없다. 2025년 3월 기준, 프로젝트 홈페이지는 타입 체커가 Z3 SMT 솔버를 사용한다고 언급하는데, 이는 정리 증명기 밖에서는 흔하지 않은 관행이다.