본문으로 건너뛰기

© 2026 Molayo

Lobste.rs헤드라인2026. 05. 22. 10:42

OxCaml이 선택하지 않은 길

요약

Jane Street의 OxCaml 프로젝트가 하위 호환성을 유지하기 위해 선택한 '모드(Modes)' 시스템의 복잡성과 그로 인한 언어 설계의 한계를 분석합니다. Rust의 소유권 모델을 OCaml에 이식하려는 시도가 타입 시스템을 어떻게 복잡하게 만드는지 다룹니다.

핵심 포인트

  • OxCaml은 OCaml의 하위 호환성을 유지하며 Rust 스타일의 동시성을 구현하려 함
  • 모드(Modes) 시스템을 통해 값의 사용 방식과 속성을 타입 시스템에 반영
  • 하위 호환성 약속이 언어의 복잡도를 급격히 증가시키는 원인이 됨
  • Rust의 대여 검사기 개념을 모드 어노테이션으로 구현 시도

OxCaml이 선택하지 않은 길

하위 호환성 (Backwards Compatibility)의 대가

OxCaml은 OCaml을 *산화(oxidize)*시키려는, 즉 더 Rust와 유사하게 만들려는 Jane Street의 노력입니다. 특히 여기서 집중하는 Rust의 측면은 컴파일러가 데이터 경합 (data races)으로부터 보호할 수 있는 "두려움 없는 동시성 (fearless concurrency)"이며, 이는 OCaml 5의 새로운 멀티코어 (multicore) 기능과 관련하여 중요합니다.

이 프로젝트는 언어에 상당수의 흥미로운 새로운 기능들 (modes, kinds, layouts 등)을 추가하지만, 언어의 "확장 (extension)"으로서만 작동하도록 주의를 기울이며, 메인 페이지에서 다음과 같이 약속합니다.

모든 유효한 OCaml 프로그램은 또한 유효한 OxCaml 프로그램이다

저는 이 약속이 숭고하기는 하지만, 궁극적으로는 잘못된 방향이었으며, 이로 인해 더 단순할 수 있었던 언어를 포기하게 만들었다고 주장하고자 합니다. 이를 위해 먼저 OxCaml에 대해 약간 살펴보고, 그것이 얼마나 복잡한지 이야기하며, 실제 사용 사례를 고려한 뒤, 마지막으로 대안적인 경로를 탐색해 볼 것입니다. 여러분이 이 글을 마칠 때쯤에는, 왜 이러한 약속에도 불구하고 Core 검증 라이브러리가 다음과 같은 모습에서

type result
type 'a check = 'a -> result
val combine : result -> result -> result
...

대신 다음과 같은 모습으로 변하게 되었는지 이해할 수 있기를 바랍니다 (간결함을 위해 주석은 생략함)

type result : value mod contended
type%template ('a : any) check = 'a -> result @ p [@@mode p = (portable, nonportable)]
val%template combine : result @ p -> result @ p -> result @ p [@@mode p = (portable, nonportable)]
...

모드 (Modes)

그렇다면 위에서 보이는 "mode"라는 단어는 무엇일까요? OxCaml의 모드 시스템은 모든 값 x : t @ m

정적으로 알려진 어떤 타입 t를 가지고

그리고 모드 m을 가진다는 점에서 일종의 타입 시스템 (type system)과 유사하며, 여기서 @는 모드 어노테이션 (mode annotation)을 위한 구문입니다.

모드는 깊은 속성 (deep properties)입니다 (따라서 mx의 내용물에 재귀적으로 적용됩니다).

) 및 이들은 일반적으로 타입 및 서로에게 직교하는(orthogonal) 서로 다른 모드 축(mode axes)의 격자(lattice)로 형성됩니다 (비록 일부 타입의 값들은 특정 모드를 무시하거나 "교차"할 수 있지만). 이를 구체화하기 위해, 이것들이 Rust의 타입 시스템에 어떻게 매핑될 수 있는지 몇 가지 예를 살펴보겠습니다.

모드(Modes) & 참조(References)

Rust의 핵심에는 대여 검사기(borrow checker)가 있으며, 이는 각 값 x: T

단일 소유자(single owner)를 갖도록 강제하지만,

&x: &T 또는 &mut x: &mut T로 일시적으로 대여(borrow)하는 것은 허용하며, 이러한 대여는 타입 시스템에 반영됩니다. OxCaml에서는 이와 유사한 것을 어떻게 표현할 수 있을까요?

글쎄요, 이러한 사항을 반영하기 위해 값의 타입을 강제로 변경하는 대신, 해당 값이 어떻게 사용될 수 있는지에 대한 추가 정보를 모드(modes)에 담을 것입니다:

&x: &T는 느슨하게 x : t @ local read aliased에 대응할 수 있고,

x: T는 느수하게 x : t @ global read_write unique에 대응할 수 있습니다.

여기서 우리는 처음 세 가지 모드 축을 볼 수 있습니다:

지역성(Locality): local은 자신의 스코프(scope)를 벗어날 수 없는 값을 나타내며, 반면 global 값은 그렇게 할 수 있습니다 (Rust의 라이프타임(lifetimes)과 유사함).

가시성(Visibility): read는 가변 상태(mutable state)로부터 읽을 수는 있지만 쓸 수는 없는 값을 나타내며, 반면 read_write 값은 둘 다 할 수 있습니다.

유일성(Uniqueness): aliased는 해당 값에 대한 다른 참조(references)가 있을 수 있는 값을 나타내며, 반면 unique 값은 Rust의 아핀 타입 시스템(affine type system)처럼 동작합니다.

지금까지 저는 이러한 모드들이 꽤 마음에 듭니다. 이들은 소유권(ownership)이라는 단일 개념 속에 뒤섞이는 대신 각각 무엇을 제어하는지에 대한 명확성을 제공하며, Rust의 방식이 간접 참조(indirection)를 가진 참조를 가지고 있는지 여부와 불가분하게 연결되어 있는 것과 달리, 이를 표현하기 위해 x의 타입을 오염시킬 필요가 없습니다. 하지만 여러분은 아마도 독립적으로 설정할 수 있는 축의 수가 증가함에 따라 가능한 상태가 조합 폭발(combinatorial explosion)을 일으킬 위험을 이미 눈치채셨을 것입니다. 만약 각 값이 타입, 지역성, 가시성, 그리고 유일성을 모두 가질 수 있다면, 평소보다 머릿속에 담아두어야 할 정보가 훨씬 많아지게 됩니다.

모드(Modes) & 트레이트(Traits)

OxCaml에는 Rust의 소유권 (ownership) 개념에 직접적으로 매핑되지 않고, 대신 Rust가 트레이트 (trait)에 넣었을 법한 개념과 더 유사해 보이는 몇 가지 모드 (modes)가 있습니다. 특히 여기서는 이식성 모드 (portability mode)에 집중해 보고자 합니다.

이식성 (Portability): portable

portable은 다른 스레드 (thread)와 공유될 수 있는 값을 나타내며, 반면 nonportable 값은 그렇게 할 수 없습니다.

이식성은 타입이 스레드 간에 공유되어도 안전한지를 결정하는 Rust의 SendSync 트레이트 (traits)와 유사하다고 생각할 수 있습니다.

여기서 중요한 차이점을 주목하십시오. 우리의 Rust 개념은 타입의 속성이었습니다. 즉, T: Send + Sync라면 T 타입의 모든 값은 스레드 간에 공유될 수 있었습니다. 반면, OxCaml에서 이것은 값의 속성입니다. 동일한 타입을 가짐에도 불구하고 서로 다른 이식성을 가진 값 x1 : t @ portablex2 : t @ nonportable을 갖는 것이 가능합니다.

타입이 스레드 안전성 (thread-safety)을 결정한다고 생각하는 데 익숙한 Rust 프로그래머에게는 이것이 처음에는 직관적이지 않을 수 있습니다. 전형적인 사례로, 원자적 참조 횟수 계산 (atomically reference counted) 방식인 Arc<_> 값은 공유될 수 있는 반면, 비원자적 (non-atomic) 방식인 Rc<_> 버전은 공유될 수 없었습니다. 하지만 기억해야 할 중요한 점은, OCaml에서 함수는 모두 동일한 화살표 타입 (arrow type)의 값이라는 것입니다:

val func : t1 -> t2

그리고 가능한 함수의 공간은 매우 방대하며, 여기에는 명확하게 스레드 간에 공유될 수 있는 함수뿐만 아니라, 가변 상태 (mutable state)를 캡처하여 데이터 경합 (data races)의 위험 없이 여러 스레드에서 호출될 수 없는 클로저 (closures)도 포함됩니다.

이는 화살표 타입 (arrow type)이 그 모든 값에 대해 하나의 결정론적인 이식성 (deterministic portability)을 적용할 수 없음을 의미합니다. 나아가, 어떤 추상 타입 (abstract type)이라도 잠재적으로 함수를 포함할 수 있기 때문에, 이는 함수를 포함하지 않는다고 명시적으로 주석이 달린 값(따라서 이식성을 "가로지르는" 값)을 제외한 거의 모든 OCaml 값이 어떤 이식성 상태를 유지해야 함을 의미합니다.

OxCaml은 현재 문서화된 9가지 모드를 앞서 살펴본 것과 같은 4가지 "과거 (past)" 모드와, 이와 유사한 이유로 함수를 포함하는 타입에만 관련이 있는 이식성 (portability) 같은 5가지 "미래 (future)" 모드로 나눕니다.

기본 모드 (Default Modes)

앞서 우리는 OxCaml이 모든 OCaml 코드와 하위 호환성 (backwards compatibility)을 보장한다고 말했습니다. 한 가지 즉각적인 관찰 결과는, 오래된 OCaml 코드에는 모든 곳에 모드 주석이 달려 있지 않았으며, 따라서 OxCaml은 모드를 지정하지 않은 코드도 처리할 수 있어야 한다는 점입니다. 이를 위해 OxCaml은 "기본 모드 (default modes)"를 사용합니다.

각 모드 축에는 명시적으로 모드가 주석 처리되지 않았을 때 가정되는 기본 위치가 있습니다. 이러한 기본값들은 하위 호환성을 깨뜨리지 않도록 신중하게 선택되어야 합니다.

지역성 (Locality): OCaml에서는 값이 자신의 스코프 (scope)를 벗어나는 것이 허용되는지 걱정할 필요가 없었습니다. 따라서 우리의 기본 모드는 global이어야 합니다.

가시성 (Visibility): OCaml에서는 가변성 (mutability)을 가진 타입의 값을 변경할 권한이 있는지 걱정할 필요가 없었습니다. 따라서 우리의 기본 모드는 read_write여야 합니다.

유일성 (Uniqueness): OCaml에서는 어떤 값이든 자유롭게 에일리어싱 (aliased)될 수 있었으며, 과거에도 그러했습니다. 따라서 우리의 기본 모드는 aliased여야 합니다.

일반적으로 지금까지 우리는 모든 모드를 최대한 허용적인 상태로 설정하여, 사용자가 자신의 값을 원하는 대로 다룰 수 있도록 했습니다. 이제 이를 이식성 (portability) 모드로 어떻게 확장해야 할지 생각해 봅시다.

기존의 OCaml 코드는 항상 싱글 코어 (single-core)였으므로, 우리가 하위 호환성을 유지해야 할 "값들이 코어 간에 공유될 수 있는지"에 대한 기존 개념 자체가 존재하지 않습니다. 따라서 허용적인 모드들을 계속 사용하면서, 기본적으로 값들이 스레드 간에 공유될 수 있도록 허용할 수 있다면 좋을 것입니다.

하지만 이것이 우리가 원하는 어떤 기본값이라도 마음대로 설정할 수 있다는 뜻은 아닙니다. 왜냐하면 우리는 또 다른 약속을 했기 때문입니다. 바로 OxCaml이 데이터 레이스 (data races)를 정적으로 방지할 것이라는 약속입니다. 앞서 논의했듯이, 스레드 간에 공유될 경우 데이터 레이스를 유발할 수 있는 일부 값들이 존재합니다. 따라서 우리는 반드시...

이식성 (Portability): OCaml에는 스레드 안전 (thread-safe)하지 않은 함수들이 존재합니다. 데이터 레이스 (data race)로부터 자유로워지기 위해서, 우리는 모든 값이 nonportable (이식 불가능)하다고 보수적으로 가정해야 합니다.

불행히도, 이번에는 더 제한적인 기본 모드를 선택해야만 합니다. 기본 모드가 모든 레거시 (legacy) OCaml 코드에 적용되기 때문에, 이는 OxCaml에서 사용되는 모든 OCaml 코드가 스레드 안전하지 않다고 간주되어야 함을 의미합니다. 이는 코드를 작성할 때 어느 정도의 고통을 수반할 것입니다.

이식화 (Portabilization)

그렇다면 모든 레거시 OCaml 코드가 nonportable이 될 것이라는 말은 우리에게 무엇을 의미할까요? 정의상으로는 해당 값들이 스레드 간을 넘어갈 수 없다는 것을 의미하지만, 그로 인한 추가적인 영향에 대해 생각해 봅시다.

OxCaml이 하위 호환성 (backwards compatibility) 이야기와 함께 강조하고 싶어 하는 것 중 하나는 바로 "사용하는 만큼 지불하는 (pay-as-you-go)" 복잡성입니다. 즉, 특정 새로운 고급 기능이 필요하지 않다면, 단순히 그것을 피하고 예전 방식 그대로의 평범한 OCaml을 계속 작성할 수 있다는 것입니다. 이것이 실제로 가능할까요?

이식성 상호운용성 (Portability Interoperability)

만약 이를 문자 그대로 받아들인다면, 우리는 대부분의 코드를 OCaml로 작성하고 싶을 것이며, OxCaml은 이를 nonportable로 해석할 것입니다. 그런 다음 성능이 중요한 애플리케이션을 확장하기 위해 필요한 시점에만 OxCaml의 동시성 (concurrency) 기능과 그에 따른 복잡성을 채택하게 될 것입니다.

이러한 환경에서 우리는 새로운 앱을 작성하거나 기존 앱을 portable (이식 가능)한 동시 OxCaml로 옮길 때, 여전히 더 큰 OCaml 생태계와 기존 라이브러리들의 이점을 누릴 수 있기를 희망할 수도 있습니다. 불행히도, 이는 어렵다는 것이 증명될 것입니다.

보시다시피 비이식성 (non-portability)은 전염됩니다. 앞서 모드는 깊게 (deep) 적용된다고 말씀드렸습니다. 즉, 값의 모든 내용물에 재귀적으로 적용된다는 뜻입니다. 따라서 portable 값은 nonportable한 것을 포함할 수 없으며, nonportable 함수를 호출하는 모든 함수 역시 그 자체로 nonportable해야 합니다.

기본 모드의 모든 값이 nonportable이라는 점을 고려할 때, nonportable에 의존하는 portable 동시 OxCaml을 작성하라고 요구하는 것은...

레거시 (legacy) OCaml은 마치 모든 함수가 비동기 (async)인 라이브러리에 의존하는 동기 (synchronous) 코드를 작성해 달라고 요청하는 것과 비슷합니다. 그것은 그냥 작동하지 않습니다!

하지만 이것이 진정한 하위 호환성 (backwards compatibility)에 대한 모든 희망이 사라졌음을 의미하지는 않습니다. 만약 당신이 OxCaml에 의존하는 OCaml 코드를 작성하고 싶다면, 반대 방향으로 한 줄기 희망이 남아 있습니다. nonportable 함수는 portable 함수를 호출할 수 있습니다. 이것은 우리의 OCaml과 OxCaml이 어떻게 공존할 수 있는지에 대한 경로를 열어줍니다.11

마이그레이션 (Migration)

우리의 portable OxCaml과 nonportable OCaml이 의존성 (dependencies)을 공유하기를 원한다면, 기존의 nonportable 코드에 의존하지 않는 의존성 스택 (dependency stack)의 맨 바닥부터 시작해야 합니다. 해당 라이브러리를 portable OxCaml로 변환하는 것은 안전합니다. 왜냐하면 기존의 OCaml 코드가 여전히 그것을 사용할 수 있고, 이제는 portable 코드도 사용할 수 있기 때문입니다.

그 작업을 완료하고 나면, 이제 우리가 마이그레이션할 수 있는 또 다른 몇 개의 라이브러리들이 열리게 됩니다. 왜냐하면 그 라이브러리들은 이제 오직 portable 라이브러리에만 의존하게 되어, 그들 스스로도 portable해질 수 있기 때문입니다. 점진적으로, 우리는 이 과정을 '포터빌라이제이션 (portabilization)'이라 부르며 생태계 전체로 마이그레이션 범위를 확장할 수 있습니다.

이 마이그레이션을 수행하면서, 우리는 효과적으로 다음 두 가지 상태 중 하나에 놓이게 될 것입니다12:

  • 우리의 코드가 이미 불변 지속 데이터 구조 (immutable persistent data structures)의 사용을 통해 스레드 안전 (thread-safe)하며, 시그니처 (signature)에 그렇게 주석을 달아주기만 하면 되는 상태.
  • 또는 우리의 코드가 다중 스레드 환경에서 데이터 경합 (data races)의 가능성이 있었으며, 이를 피하기 위해 근본적으로 다시 작성하거나 구조를 재조정해야 하는 상태.

전자는 물론 간단하지만, 후자는 실제적인 작업량을 만들어낼 수 있습니다. 특히 우리가 OxCaml로 옮기고 싶어 하는 성능 중심 (performance-critical) 코드의 경우 더욱 그렇습니다. 그것은 과도한 가변성 (mutation)을 사용할 가능성이 가장 높은 바로 그 코드입니다.13

하지만 우리는 터널 끝의 빛을 볼 수 있기에 인내합니다. 만약 우리가 가진 모든 라이브러리를 OCaml에서 OxCaml로 하나씩 옮길 수 있다면, 마침내 언어 간 상호 운용 (inter-operate)이 가능해질 것입니다. 즉, portable (이식 가능한) 방식이나 nonportable (이식 불가능한) 방식 중 하나를 선택하여 코드를 작성하고 동일한 의존성 (dependencies)을 공유할 수 있게 됩니다. 한꺼번에 모두 마이그레이션할 수 없는 방대한 양의 기존 OCaml 코드를 보유한 Jane Street와 같은 조직에게 이는 필수적입니다.14

분기 (Bifurcation)

그럼에도 불구하고, 제가 방금 여러분을 상대로 약간의 눈속임을 한 것처럼 느껴지시나요? 저는 이 포스트를 시작하며 이 언어가 완전히 하위 호환성 (backwards compatible)을 갖추고 있어 아무런 변경 없이도 모든 기존 코드를 컴파일할 수 있다고 말했습니다. 그런데 이제 와서 모든 라이브러리를 OCaml에서 OxCaml로 적극적으로 마이그레이션해야 하는 세상을 설명하고 있나요?

그리고 설령 우리가 그렇게 해서 모든 라이브러리가 portable OxCaml로 옮겨지고, 그에 따라 앱을 작성하는 방식을 자유롭게 선택할 수 있게 된다 하더라도, 여러분은 우리가 어떤 방식을 선택할 것이라고 생각하십니까? 아무도 우리에게 의존할 수 없고 미래에 다시 마이그레이션해야 할지도 모르는 방식일까요? 아닙니다. 우리는 아마도 이 새로운 portable OxCaml 방식을 고수할 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0