OCaml과 Eio를 사용해 보기
요약
이 글은 OCaml 언어와 동시성 프레임워크 Eio 사용 경험을 공유합니다. 저자는 OCaml의 함수형 및 명령형 프로그래밍 혼합 구조가 매력적이며, 특히 결정론적인 Eio를 통해 테스트에 도움이 된다고 언급했습니다. 학습 자료로는 'Real World OCaml'과 CS3110 강좌를 추천하며, 실습 위주의 접근이 중요함을 강조합니다.
핵심 포인트
- OCaml은 함수형/명령형 혼합 구조가 매력적입니다.
- Eio는 결정론적으로 설계되어 테스트에 유용합니다.
- 학습 시 'Real World OCaml'과 CS3110 강좌를 추천합니다.
- 실습 위주의 학습 자료를 선택하는 것이 중요합니다.
이 웹사이트의 첫 블로그 게시물은 Haskell에 관한 것이었습니다. 저는 항상 함수형 프로그래밍(functional programming)에 관심이 있었지만, Haskell의 복잡성은 그만한 가치가 없어 보였습니다.
오랫동안 OCaml에 대해 막연하게 알고만 있었을 뿐, 직접 다뤄본 적은 없습니다. 최근에는 멀티코어 지원, 효과(effects), 그리고 Jane Street에서 만든 OxCaml이라는 시스템 중심의 스핀오프가 생기면서 약간의 르네상스를 겪고 있습니다. 지금이 배울 좋은 시기라고 생각했고, 저는 동시성 프레임워크인 Eio에 대해 궁금했습니다. Eio는 앞서 언급한 효과를 사용하지만, 저에게 더 흥미로운 점은 결정론적(deterministic)으로 설계되었다는 것인데, 이는 테스트에 도움이 될 수 있습니다.
서적 및 튜토리얼
가장 먼저 할 일은 튜토리얼이나 책을 고르는 것입니다. 구글 검색 몇 번 끝에 Real World OCaml이라는 것을 찾았습니다. 이 책이 제가 즐거웠고 실습 위주였던 Real World Haskell과 비슷할 것이라고 가정하고, 이미 충분한 함수형 프로그래밍 지식이 있다고 가정하고 시작했습니다.
문법(syntax)과 의미론(semantics)을 보는 데 너무 급해서 서론(Prologue) 부분은 건너뛰었습니다. 만약 읽었더라면 이 책이 Jane Street의 Base 표준 라이브러리 대체품을 사용하기로 했다는 것을 알았을 것입니다. 역사적으로 실제 표준 라이브러리는 상당히 빈약하여 컴파일러의 필요성만을 다루도록 의도된 것 같았습니다. 하지만 이제는 그렇지 않으며 서서히 성장하고 있습니다. 그럼에도 불구하고, Real World OCaml이라는 책이 이 언어의 가장 큰 실사용자들의 라이브러리를 사용한다고 하는 것이 무리하지는 않아 보입니다.
어쨌든 저는 사물을 더 주의 깊게 읽어야 한다는 교훈을 얻고 처음으로 돌아가 다시 시작했습니다. 책의 전반부는 잘 쓰여 있어서 기쁘게 따라갔습니다. 하지만 GADTs와 일급 모듈(first class modules) 부분에 오자쯤부터 길을 잃었습니다. 이 책은 연습 문제가 부족하다고 생각합니다. 그래서 생소한 것이 나타났을 때 기본 지식을 완전히 파악하고 있지 못하면 대처할 수가 없습니다.
좀 더 쉬운 것을 찾아보다가 비디오와 텍스트를 모두 갖추어 모든 취향에 맞는 CS3110을 발견했습니다. 여기에는 도움이 많이 된 실습 과제도 있었습니다. 저는 사람들에게 이 강좌로 시작한 다음, 더 고급 기능을 위해 RWO로 넘어갈 것을 제안하고 싶습니다. 저는 RWO가 마음에 들지만, 만약 RWH 같은 프로젝트 기반의 책을 찾고 있다면 실망할 것입니다.
사용 경험
전반적으로 OCaml은 코드를 작성하는 것이 매우 즐겁습니다. 함수형(functional) 프로그래밍과 명령형(imperative) 프로그래밍이 보기 좋게 혼합되어 있습니다. 예를 들어, 리스트를 순회하려면 재귀 함수를 사용하거나 List.map 등을 사용합니다:
let print_recursive list =
let rec loop i list =
match list with ...
하지만 필요하다면 여전히 가변(mutable) 변수와 같은 것들을 사용할 수 있습니다:
type client = { mutable request_id : int }
let dispatch cli ~msg =
let request_id = cli.request_id in ...
문법이 다소 장황하게 느껴진다고 말하겠습니다. 예를 들어, let .. in이나 match .. with 같은 구문 구성 요소들이요. 저는 문법이 프로그래밍 언어에서 가장 흥미롭지 않은 부분이라고 생각합니다. Rust가 시길(sigil)을 가졌던 시절에도 만족했기 때문에 크게 신경 쓰이지 않습니다. 이름 있는 인자(named arguments, ~)를 사용하는 것은 매우 좋습니다.
컴파일러가 더 거슬립니다. 오류를 만났을 때 파일의 나머지 부분에 대해 포기하는 것처럼 보입니다. 이로 인해 반복 루프가 상당히 느려집니다. 코드를 작성하고, 오류를 얻고, 오류를 수정하고, 다시 빌드하는 과정이요. 특히 한 모듈에 집중하고 있다면, 오류를 일괄적으로 수정할 기회가 거의 없습니다.
Eio
Eio는 효과 기반(effects-based) IO 동시성 라이브러리입니다. 비교적 새롭지만 io_uring 지원과 같은 흥미로운 기능을 가지고 있습니다. 궁극적인 목표는 Raft 합의 알고리즘 구현을 작성하고 테스트하는 것입니다.
Raft 논문에서는 서버 간 통신을 위해 원격 프로시저 호출(RPC)을 사용하라고 말합니다. 단순한 RPC 클라이언트/서버는 훌륭한 첫 프로젝트가 될 것 같습니다. 매우 간단한 프로토콜을 만들어 봅시다: 프로시저는 요청 ID(int), 이름(string), 그리고 단일 인자(string)를 가집니다. 정수는 단일 바이트이며, 문자열은 길이로 바이트화되고 그 다음에 데이터로 직렬화됩니다. 예를 들어, Echo 프로시저를 요청 10으로 Hello와 함께 호출하려면 다음과 같이 보냅니다:
00000000: 0a04 4563 686f 0548 656c 6c6f ..Echo.Hello
응답은 요청 ID와 문자열 반환 값만 포함될 것입니다.
시작하기 전에, Eio가 빌딩 블록에 사용하는 몇 가지 전문 용어에 익숙해져야 합니다:
- Fiber - 실행 스레드(thread of execution). 하지만 시스템 스레드는 아닙니다!
- Switch - 파이버들을 그룹화하는 무언가. Go의
sync.WaitGroup와 약간 비슷합니다. - Flow - 읽거나 쓸 수 있는 무언가
- Stream - 경계가 지정되고, 스레드 안전한 큐. Go 채널과 같습니다
- Promise - 이것은 아실 겁니다.
서버부터 시작하여, 우리는 Flow를 가져와 루프를 돌면서 프로시저 호출을 읽고, 이를 호출하고, 응답을 보내야 합니다:
open Utils
type conn = {
src : Eio.Buf_read.t;
...
서버는 여러 개의 연결(connection)을 가질 수 있으므로, 연결을 담을 타입을 정의합니다. 여기서 우리는 Flow를 직접 사용하지 않고, 대신 Buf_read 또는 Buf_write 타입을 사용한다는 점에 주목하세요. 이렇게 해야 버퍼링된 읽기/쓰기를 사용하여 더 효율적일 수 있습니다. Flow에 대한 작업은 기본 리소스(소켓, 파일 등)에서 직접 수행됩니다. 다행히 이러한 버퍼링된 리더/라이터에는 요청 ID를 보내고 받기 위해 사용하는 유용한 헬퍼 함수가 있습니다.
read_string/write_string는 Utils에서 가져옵니다:
let read_string src =
let len = Eio.Buf_read.uint8 src in
Eio.Buf_read.take len src
...
다음으로, 버퍼링된 Flow를 얻고 루프를 실행하는 헬퍼가 필요합니다:
let handle_conn flow =
let src = Eio.Buf_read.of_flow ~max_size:1024 flow in
Eio.Buf_write.with_flow flow @@ fun sink ->
...
그리고 테스트 실행 파일:
open Dumb_rpc
open Eio.Std
let port = 1470
...
이제 테스트할 준비가 되었습니다:
> printf '\x00\x04Echo\x05Hello' | nc localhost 1470 -q0
Hello⏎
> printf '\x01\x0aCapitalise\x07matthew' | nc localhost 1470 -q0
...
좋습니다!
클라이언트
클라이언트를 위해 좀 더 정교하게 만들고 동시 사용에 안전하도록 만들어 보겠습니다. 그렇게 하려면 한 번에 소켓으로 단일 프로시저 호출만 작성한다는 것을 확실히 해야 합니다. 이것은 읽기용과 쓰기용의 두 개의 파이버(fiber)를 사용하여 수행될 것입니다. 쓰기용 파이버는 메서드가 호출될 때 추가하는 스트림에서 작업을 가져올 것입니다:
type request = { procedure : string; arg : string; }
type t = {
mutable request_id : int;
...
하지만 응답을 호출자에게 어떻게 돌려받을까요? 그러기 위해 스트림에 또한 프라미스(promise)를 보내고, 키가 요청 ID인 해시 테이블에 저장할 것입니다. 이것은 서버가 응답했을 때 나중에 해결될 수 있습니다.
Eio에서 프라미스는 실제로는 두 개의 절반으로 분리된다는 점에 유의하십시오 - 기다리는 쪽(Promise.t)과 해결하는 쪽(Promise.u).
type waiter = { resolver : string Eio.Promise.u; }
type request = {
procedure : string;
...
그러면 수신 루프(receive loop)는 단순히 응답을 읽고 프라미스를 해결할 수 있습니다:
let rec recv_loop cli r =
let (request_id, body) = read_response
traceln "rx %d => %s" request_id body;
...
여러 다른 파이버에서 다음과 같이 사용할 수 있습니다:
open Eio.Std
let port = 1470
let () =
...
그리고 작동하는 것을 볼 수 있습니다:
> ./_build/default/bin/client.exe
+Invoking Echo(arg-0)
+Invoking Echo(arg-1)
...
결론
전반적으로 OCaml은 배우기 재미있고 사용하기 쉽습니다. 함수형 프로그래밍(FP)의 장점을 가지면서도 언제든 명령형 코드를 작성할 수 있고, 컴파일되지만 빠르며, 예외 처리와 에러 값을 선택할 수 있는 등 여러 면에서 강점이 있습니다. 사용자 기반이 꽤 작다는 것이 느껴지기도 합니다. 어디서부터 시작하는 것이 가장 좋은지 명확하지 않고, 공부할 만한 코드가 많지 않다는 점도 그렇습니다.
Eio는 Go를 사용해 본 사람이라면 누구나 익숙하게 느낄 수 있을 것입니다. 이것을 가지고 실험하다가 흐름(flow)이 닫히는 부분에서 자꾸 막혔는데, 알고 보니 run_server의 콜백처럼 블로킹해야 할 상황에서 파이버(fiber)를 생성하고 있었기 때문이었습니다.
곧 작동하는 Raft 구현체와 함께 돌아오기를 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기