수업 프로젝트로 OCaml로 컴파일러를 작성하며 겪은 불편함을 문법, 타입 추론, 타입 시스템 제약, 도구/생태계, 출력 방식 측면에서 정리하고, Rust와의 비교와 재사용 의사에 대해 이야기한다.
저는 지금 구직 중입니다! 2026년 신입을 Rust, TypeScript, 또는 React 포지션으로 채용 중이라면 serena (at) quamserena.com 으로 메일 주세요.
수업 과제로 드래곤 북에 나오는 언어를 대상으로 OCaml로 컴파일러를 작성하고 있는데, 절반쯤 진행하니 여기저기서 Rust가 그리워지더라. 순서는 상관없이, OCaml을 쓰면서 겪은 불편한 점들:
OCaml 문법은... 썩 좋지 않다. 그래도 장점이라면 공백이 의미를 가지지 않는다는 점 정도. ReasonML 같은 OCaml의 대체 문법 프런트엔드가 있어서 이런 문제들을 고치지만, 프로젝트를 시작하기 전에는 몰랐다. let...in 문법은 어색하지만 취향 문제라고 쳐도 된다; 진짜 문제는 match 문에 종결자가 없어서 중첩하면 때때로 알 수 없는 오류가 난다는 점이다. 전반적으로 OCaml은 구두점이 매우 적다(반드시 나쁜 건 아니지만), 그래서 어떤 경우에는 어디서 시작하고 끝나는지 파악하기가 어렵다. 내게는 C 스타일의 괄호가 코드의 문법적 계층을 더 분명히 해 준다.
또 다른 골칫거리는 자동 커링과 부분 함수 적용이다. 대체로 부분 적용은 버그인 경우가 많고 보통은 그 지점에서 린트가 친절히 잡아주지만, 타입을 명시적으로 주석 달지 않으면 오류가 매우 난해해질 수 있다. 나는 이런 문법 관련 문제를 디버깅할 때, 시작과 끝이 어디인지 짐작되는 곳에 무작정 괄호를 추가해 보는 편이고, 그러면 대개 어디가 문제인지가 드러난다.
OCaml의 타입 체커는 매우 영리해서 주석 없이도 보통은 변수의 올바른 타입을 알아낸다. 하지만 그럴 수 없을 때(대개는 다른 곳에서 저지른 실수 때문에) 오류 메시지는 대체로 난해하다. 정적 타입이면서도 타입 주석이 거의 필요 없다는 점은 멋지지만, 소프트웨어의 견고함 측면에서는 꽤 나쁘다 — 한 곳에서의 변수 사용이 다른 곳에서 그 변수의 타입에 불투명하게 영향을 줄 수 있고, 타입 추론이 왜 실패하는지 디버깅하는 일 자체가 고역이다. 의존 라이브러리 하나가 업데이트되며 함수 시그니처가 바뀌기라도 하면, 디버깅은 정말 운에 맡겨야 한다. 그래서 나는 명확성을 위해, 그리고 오류 메시지가 더 이해되도록 모든 함수의 매개변수와 반환 타입에 일일이 주석을 달고 있다. Rust가 힌들리–밀너(Hindley–Milner) 타입 추론을 바탕으로 하면서도 함수 매개변수와 반환값의 타입 주석을 명시하도록 한 것은 확실히 옳았다고 본다. 그래도 Rust는 반환 타입 다형성처럼, 원한다면 일부 지점에서 여전히 똑똑한 추론을 활용할 수 있게 해 준다.
짜증나는 점 하나: 선언되기 전의 타입을 사용할 수 없다 — 다른 언어에서 보이는 타입 호이스팅이 없다. 물론 매개변수화된 타입을 만든 뒤 파일 맨 아래에서 올바른 매개변수로 묶인 타입 별칭을 지정하면 비교적 쉽게 우회할 수 있지만, 코드에 군더더기가 생긴다. 열거 타입의 모든 생성자가 모듈 스코프에 그대로 풀려(특정 타입으로 네임스페이스 처리되지 않아) 다른 타입의 생성자를 조용히 가려 버릴 수 있다는 것도 당황스러운 선택이다. C에서 전역 #define이 네임스페이스 없이 흩뿌려지는 걸 떠올리게 한다고 느끼지만, 확신은 없다.
OCaml 도구 생태계는 최근 많이 좋아졌다. Cargo나 NPM 같은 다른 언어의 빌드 시스템에 견줄 만한 Dune이 있고, 내 경험상 전반적으로 꽤 잘 동작한다. OCaml 생태계는 조금 특이한데, Jane Street가 주도하며 표준 라이브러리 Core를 따로 유지한다(OCaml stdlib이 아쉬운 점이 많기 때문이다).
컴파일러를 작성하는 관점에서는 ocamllex와 Menhir가 있고, 나도 이것들을 쓰고 있다. (Menhir는 ocamlyacc의 문제점을 고친, 거의 드롭인 대체물이다.) 이들에 대한 내 불만은, OCaml 소스 파일로 불투명하게 컴파일되는 두 개의 DSL이며 각자(서로 다른) 의미론과 문법을 갖고 있다는 점이다. OCaml에 매치 문 등 온갖 기능이 있는데도, 이 도구들은 C 세계의 대응물에 더 가깝게 설계되어, 렉싱과 파싱에 대해 자체적인 ‘매치 문 비슷한’ 구문을 정의한다. 또 에러 메시지가 통째로 "Syntax Error"뿐인 경우가 있는데, OCaml 코드가 내장된 DSL이라는 점을 고려하면 디버깅이 어렵다. 보통은 Menhir 매뉴얼을 열어 예시를 보는 수밖에 없다.
OCaml에서 표준 출력으로 문자열을 찍는 일은 이상하리만치 성가시다. 객체를 그냥 받아서 출력하는 방법이 사실상 없다; 원시 타입에는 printf를 쓰고, 사용자 정의 타입에는 직접 출력 함수를 정의해야 한다 — Java의 toString이나 Rust의 #[derive(Debug)]에 해당하는 것이 없다. 개인적으로 이것을 표현하지 못하는 점은 OCaml 타입 시스템의 약점이라고 본다.
Rust와 OCaml을 비교하면, 대체로 ‘견고함 vs. 우아함’이라는 주제가 드러난다. OCaml의 많은 설계 선택(부분 함수 적용, 추론된 타입)은 우아한 함수형 프로그래밍(박사과정생이 신경 쓰는 것)의 관점에서 동기가 부여된 반면, Rust는 늘 장기적인 안정성과 유지보수성(소프트웨어 엔지니어가 신경 쓰는 것)에 초점을 맞춘다. 여기서도 흔한 격언이 적용된다. 일반적으로 어느 쪽이 더 낫다고 할 수 없고, 최선의 선택은 문제 도메인에 달려 있다.
다시 컴파일러를 쓴다면, 불편한 점과 각종 편의 기능의 부재에도 불구하고 OCaml을 다시 진지하게 고려할 것이다. 적어도 컴파일러의 첫 초안/MVP 단계에서는, 라이프타임과 메모리 관리에 신경 쓰지 않고 패턴 매칭과 함수형 접근의 우아함을 활용할 수 있다는 점이 마음에 든다. 다만 ReasonML은 좀 더 자세히 살펴볼 것 같다.