Roc 언어 입문: 태그 유니온과 패턴 매칭, 플랫폼 기반 I/O, 타입 추론, CLI 만들기
이 글의 핵심
Roc는 파일 읽기나 네트워크 같은 I/O를 언어가 아니라 선택한 플랫폼이 제공한다는 점에서 대부분의 언어와 다릅니다. 이 구조가 애플리케이션 선언과 호스트 계층에 어떤 영향을 주는지 짚고, 아직 알파 단계라 문법과 도구가 바뀔 수 있다는 점을 전제로 지금 시도해 볼 만한 범위를 정리합니다.
Roc는 Elm에서 영향을 받은 순수 함수형 프로그래밍 언어입니다. 태그 유니온(tag union)과 레코드, 강한 타입 추론을 갖추고 있으며, 입출력은 언어가 아니라 애플리케이션이 선택한 플랫폼(platform)이 제공한다는 점이 가장 큰 특징입니다.
이 글은 Roc를 처음 접하는 백엔드·시스템 개발자가 언어의 설계를 이해하고 basic-cli 플랫폼으로 작은 CLI 도구를 만들 수 있도록 돕는 것을 목표로 합니다. Roc는 아직 정식 릴리스 전인 알파 단계이고, 튜토리얼도 alpha4(Rust로 작성된 기존 컴파일러)와 Zig로 새로 작성 중인 컴파일러용 설명이 나뉘어 있어 일부 문법이 다릅니다. 예제는 공식 사이트의 Platforms and Apps 문서와 basic-cli 저장소 예제를 기준으로 합니다.
Roc이 다른 점
Roc는 “코어 언어는 순수하고, 외부 세계와의 상호작용은 플랫폼이 맡는다”는 전제를 둡니다. 대부분의 언어는 표준 라이브러리에 파일 읽기, 네트워크, 표준 출력을 함께 넣지만, Roc의 내장(builtins) 모듈은 리스트, 문자열, 딕셔너리 같은 데이터 구조만 다루고, 실제 I/O는 항상 선택한 플랫폼이 제공합니다.
이 설계에서 기대할 수 있는 효과는 세 가지입니다. 첫째, 플랫폼이 도메인에 맞는 API만 노출하므로, 예를 들어 브라우저용 플랫폼에서는 임의 파일 접근 API를 아예 제공하지 않는 식으로 애플리케이션이 할 수 있는 일을 제한할 수 있습니다. 둘째, 메모리 할당과 해제, I/O 스케줄링을 플랫폼의 호스트(host)가 구현하므로, 웹 요청마다 아레나를 쓰는 것처럼 도메인에 맞는 메모리 전략을 플랫폼이 정할 수 있습니다. 셋째, 애플리케이션은 플랫폼이 정의한 Roc API만 쓰므로 다른 언어와의 경계가 플랫폼 경계 하나로 정리됩니다.
설치와 도구
공식 사이트의 설치 안내에 따라 OS별로 Roc를 설치합니다. 번호가 붙은 안정 릴리스보다 알파와 나이틀리 빌드를 따라가는 구조이므로, 팀 프로젝트에서는 컴파일러 버전과 플랫폼 URL을 고정해 두는 것이 안전합니다.
표현식이나 작은 함수의 타입을 빠르게 확인할 때는 온라인 REPL이나 로컬 roc repl이 유용합니다. 공식 사이트는 최신 문법과 내장 함수 설명을 한 파일로 모은 llms.txt도 제공하므로 문법을 검색할 때 보조 자료로 쓸 수 있습니다.
핵심 개념: 표현식, 태그, 패턴 매칭
표현식 중심
Roc에서 if와 when을 포함한 대부분의 구문은 값을 돌려주는 표현식입니다. 함수는 |x| x + 1 같은 람다 형태로 정의해 이름에 바인딩하며, 타입 주석은 대부분 생략해도 컴파일러가 추론합니다.
태그 유니온과 when
태그(tag)는 여러 형태 중 하나를 가지는 값을 표현합니다. 예를 들어 신호등 색을 Red, Yellow, Green 중 하나로 두고, when으로 분기할 때 하나라도 빠뜨리면 컴파일러가 누락된 경우를 알려 줍니다(exhaustiveness 검사). Haskell이나 OCaml의 대수적 데이터 타입과 같은 계열이지만, Roc의 태그는 미리 타입을 선언하지 않아도 쓸 수 있고 컴파일러가 사용된 태그로부터 유니온 타입을 추론한다는 점이 다릅니다.
color_to_str = |color|
when color is
Red -> "red"
Yellow -> "yellow"
Green -> "green"
가능한 상태를 타입으로 표현하고, 처리하지 않은 분기를 컴파일러가 잡게 하는 것이 Roc 코드의 안전성을 받치는 기본 패턴입니다.
레코드와 불변성
레코드는 { name: "Roc", year: 2024 }처럼 필드 이름으로 데이터를 묶습니다. 모든 값은 불변이므로, 필드를 바꾸고 싶을 때는 { user & name: "new" }처럼 일부 필드를 바꾼 새 레코드를 만듭니다. 컴파일러는 참조 카운트가 1인 값이라면 복사하지 않고 그 자리에서 고치는 최적화를 하므로, 불변 스타일로 써도 불필요한 복사가 생기지 않는 경우가 많습니다.
모듈 경계
모듈은 파일 단위이며, 모듈 헤더에서 공개할 이름을 지정해 API를 제한합니다. 다음 절의 Opaque 타입과 결합하면, 외부에서 깨뜨릴 수 없는 불변 조건을 모듈 안에 둘 수 있습니다.
플랫폼과 애플리케이션
애플리케이션 선언
애플리케이션은 항상 하나의 플랫폼 위에서 실행됩니다. 공식 문서의 Hello World는 다음과 같습니다.
app [main!] { cli: platform "https://github.com/roc-lang/basic-cli/releases/download/0.20.0/X73hGh05nNTkDHU06FHC0YfFaQB1pimX7gncRcao5mU.tar.br" }
import cli.Stdout
main! = |_args|
Stdout.line!("Hello, World!")
app [main!]은 플랫폼에 넘길 진입점을 지정하고, cli는 이 플랫폼을 가리키는 로컬 이름입니다. URL은 특정 버전의 basic-cli 플랫폼 패키지를 가리키며, 파일 이름에 콘텐츠 해시가 들어 있어 내용이 바뀌면 URL도 바뀝니다. 그래서 이 URL을 의존성 버전처럼 고정하고, 업그레이드할 때는 릴리스 노트에서 API 변경을 확인합니다.
플랫폼이 제공하는 것
basic-cli는 명령행 인자, 표준 입출력, 파일, 환경 변수, HTTP, TCP처럼 CLI와 스크립트에 필요한 연산을 제공합니다. 웹 서버용 플랫폼이라면 요청과 응답을 다루는 API를, 게임용 플랫폼이라면 렌더링과 입력 API를 제공하는 식으로 플랫폼마다 쓸 수 있는 효과가 다릅니다.
호스트
플랫폼은 Roc로 작성된 공개 API와, 그 아래에서 실제 OS와 대화하는 호스트 구현으로 나뉩니다. 호스트는 Rust, Zig, C 같은 언어로 작성되며 프로그램의 실제 main을 가지고, 적절한 시점에 컴파일된 Roc 애플리케이션의 함수를 호출합니다. 빌드할 때는 Roc 코드가 객체 파일로 컴파일되어 플랫폼 호스트와 링크되고, 결과는 하나의 실행 파일이 됩니다.
이 구조 덕분에 기존 코드베이스 전체를 플랫폼처럼 두고 그 안의 일부 로직만 Roc로 작성해 호출하는 방식도 가능하며, 공식 문서도 이런 점진적 도입 전략을 소개합니다.
타입 추론
Roc의 타입 시스템은 Hindley-Milner 계열이라 타입 주석 없이도 대부분의 표현식에서 정확한 타입을 추론합니다. 태그 유니온도 사용된 태그로부터 열린(open) 유니온 타입으로 추론되므로, 작은 로컬 함수에서는 타입을 거의 쓸 필요가 없습니다.
다만 모듈 밖으로 공개하는 함수에는 타입 주석을 명시하는 편이 좋습니다. 추론에만 맡기면 구현을 고칠 때 공개 타입이 의도치 않게 바뀌어도 그 모듈 안에서는 오류가 나지 않고, 호출하는 쪽에서야 문제가 드러나기 때문입니다.
숫자 타입
튜토리얼에서 1 + 1의 타입은 Num *처럼 구체적인 크기가 정해지지 않은 형태로 나타납니다. 실제 값은 사용처에 따라 U8, I32, I64 같은 고정 크기 정수나 F32, F64, 소수 연산용 Dec로 정해지고, 아무 제약이 없으면 기본값이 쓰입니다. 정수 오버플로는 조용히 값이 돌아가지 않고 프로그램을 크래시시키므로, 경계값이 중요한 코드에서는 크기를 명시하고 Num.add_checked처럼 Result를 돌려주는 연산을 쓰는 것이 좋습니다.
Opaque 타입: 구현을 숨기고 계약을 드러내기
정의: := 와 @이름
Opaque 타입은 내부 표현을 모듈 밖에 숨기는 타입입니다. 예를 들어 사용자 이름을 그냥 Str로 노출하지 않고 Username 타입으로 감쌀 수 있습니다.
Username := Str
from_str : Str -> Username
from_str = |str|
@Username(str)
to_str : Username -> Str
to_str = |@Username(str)|
str
Username := Str은 “Username은 내부적으로 Str이지만, 정의한 모듈 밖에서는 그 사실을 이용할 수 없다”는 뜻입니다. 같은 모듈 안에서는 @Username(str)으로 값을 감싸고, 패턴 |@Username(str)|으로 벗깁니다.
모듈 밖에서의 규칙
다른 모듈에서는 Username을 타입 주석에 쓸 수는 있지만 @Username 문법으로 직접 만들거나 분해할 수 없습니다. 따라서 검증을 거치는 생성 함수만 공개하면, Username 값을 받는 함수는 이미 검증된 값이라고 믿고 같은 검사를 반복하지 않아도 됩니다. 이메일 주소, 경로, 비어 있지 않은 리스트처럼 한 번 검증한 값을 Opaque 타입으로 감싸 두는 방식이 대표적입니다. 나중에 내부 표현을 Str에서 레코드로 바꾸더라도 공개 함수의 시그니처만 유지하면 다른 모듈은 고칠 필요가 없습니다.
서로 다른 모듈에서 각각 Username := Str을 정의하면, 이름이 같아도 서로 다른 타입이라 섞어 쓰면 타입 오류가 납니다.
에러 처리: Result, 효과(!), 종료 코드
Result로 성공과 실패를 태그로 표현
Roc에서 실패할 수 있는 계산은 Ok와 Err 태그를 가진 Result를 돌려줍니다. 실패의 종류도 [FileNotFound, PermissionDenied] 같은 태그 유니온으로 표현되며, 여러 함수의 실패 태그는 호출하는 쪽에서 자동으로 합쳐진 유니온으로 추론됩니다. 그래서 어떤 실패가 가능한지가 함수 시그니처에 드러나고, 처리하지 않은 종류를 컴파일러가 알려 줍니다.
효과 함수와 !
Roc는 순수 함수와 효과가 있는 함수를 구분합니다. 이름이 !로 끝나는 함수는 입출력 같은 효과를 수행하는 함수이고, 타입에서도 순수 함수는 ->, 효과 함수는 => 화살표로 구분됩니다. 순수 함수 안에서 효과 함수를 호출하면 컴파일 오류가 납니다. 예전 버전의 Roc는 효과를 Task 값으로 표현했지만, 현재는 이 방식으로 바뀌었으므로 오래된 예제를 볼 때 주의해야 합니다.
basic-cli의 main!과 Result {}
basic-cli 저장소의 hello-world 예제는 다음과 같습니다.
app [main!] { pf: platform "../platform/main.roc" }
import pf.Stdout
import pf.Arg exposing [Arg]
main! : List Arg => Result {} _
main! = |_args|
Stdout.line!("Hello, World!")
저장소 안의 예제는 플랫폼을 상대 경로로 가리키므로, 복사해서 쓸 때는 앞의 릴리스 URL로 바꿔야 합니다. main!은 명령행 인자 List Arg를 받고 Result {} _를 돌려주는 효과 함수입니다. Ok({})를 돌려주면 정상 종료하고, Err를 돌려주면 플랫폼이 오류 메시지를 출력하고 0이 아닌 코드로 종료합니다.
?로 실패 전파
호출 뒤에 붙는 ?는 그 결과가 Err이면 즉시 현재 함수에서 그 Err를 반환하는 연산자입니다. basic-cli의 파일 읽기·쓰기 예제는 여러 효과를 ?로 이어 씁니다.
file_write_read! : Str => Result {} [FileReadErr _ _, FileReadUtf8Err _ _, FileWriteErr _ _, StdoutErr _]
file_write_read! = |file_name|
Stdout.line!("Writing a string to out.txt")?
File.write_utf8!("a string!", file_name)?
contents = File.read_utf8!(file_name)?
Stdout.line!("I read the file back. Its contents are: \"${contents}\"")
반환 타입의 태그 유니온이 이 함수가 실패할 수 있는 모든 경우를 나열합니다. File.read_utf8!는 파일을 UTF-8 텍스트로 읽고, 잘못된 인코딩이면 FileReadUtf8Err로 실패합니다. 바이너리 파일은 File.read_bytes!로 읽습니다.
사용자 정의 실패: Err(Exit(…))
basic-cli의 command-line-args 예제는 필요한 인자가 없을 때 Err(Exit(1, "사용법 ..."))처럼 종료 코드와 메시지를 담은 실패를 돌려줍니다. 플랫폼은 이 값을 받아 메시지를 출력하고 지정한 코드로 종료합니다. CLI에서 정상 종료와 비정상 종료, 그 종료 코드까지 타입으로 표현된다는 점이 실무에서 유용합니다.
성능 특징: 컴파일·런타임·메모리
코드 생성
최적화 빌드는 LLVM을 통해 기계어를 생성하고, 개발 중 빠른 빌드를 위해 LLVM을 거치지 않는 자체 개발용 백엔드도 두고 있습니다. 함수형 언어이지만 값을 가능한 한 박싱하지 않고 단순한 메모리 표현으로 다루는 것을 설계 목표로 삼고 있습니다. 컴파일러를 Zig로 다시 작성하는 작업이 진행 중이어서, 이 부분의 세부 사항은 버전에 따라 달라질 수 있습니다.
참조 카운팅
Roc는 추적형 GC 대신 컴파일러가 삽입하는 참조 카운팅으로 메모리를 관리합니다. 모든 값이 불변이라 순환 참조가 생기지 않으므로 참조 카운팅만으로 메모리를 회수할 수 있고, GC 일시 정지가 없습니다. 참조 카운트가 1인 값은 복사 없이 제자리에서 수정하는 최적화(앞에서 말한 레코드 갱신 등)도 이 정보를 활용합니다. 동시성과 병렬 처리를 어떻게 제공할지는 언어가 아니라 플랫폼이 정합니다.
플랫폼별 메모리 전략
메모리 할당과 해제는 호스트가 제공하는 함수를 통해 이루어집니다. 그래서 공식 문서가 예로 드는 것처럼 웹 서버 플랫폼은 요청마다 아레나를 할당하고 요청이 끝나면 한 번에 버리는 식으로 개별 해제 비용을 없앨 수 있습니다. 메모리 정책이 언어 하나에 고정되지 않고 플랫폼이 도메인에 맞게 고를 수 있다는 점이 Roc 설계의 특징입니다.
짧게 실행되는 CLI나 배치 작업은 시작 시간과 배포 편의성이 중요한데, Roc 애플리케이션은 호스트와 링크된 실행 파일 하나로 배포되므로 이 점에서 유리합니다. CPU를 많이 쓰는 코드의 성능은 LLVM 최적화의 덕을 볼 수 있지만, 성능을 비교할 때는 컴파일러와 플랫폼 버전을 함께 기록해야 결과를 재현할 수 있습니다.
실전: basic-cli로 CLI 도구 만들기
최소 실행 파일
앞의 Hello World 예제를 main.roc로 저장하고 roc main.roc(또는 roc run main.roc)로 실행하면, 플랫폼을 내려받아 빌드한 뒤 바로 실행합니다. 실행 파일만 만들려면 roc build main.roc를 씁니다. 정확한 명령은 사용하는 컴파일러 버전의 문서를 따릅니다.
인자 다루기: Arg와 OS별 표현
Arg는 유닉스의 바이트 시퀀스 인자와 윈도우의 UTF-16 인자를 함께 추상화한 타입입니다. 다른 함수에 넘길 때는 Arg.to_os_raw로 OS별 원시 표현을 얻을 수 있고, 로그 출력처럼 간단히 문자열이 필요할 때는 Arg.display를 씁니다. display는 인자를 UTF-8로 가정해 문자열로 만들므로, UTF-8이 아닌 바이트가 섞인 경로까지 정확히 다뤄야 한다면 원시 표현을 쓰고 동작은 플랫폼 문서로 확인합니다.
인자 리스트의 첫 번째 요소는 실행 파일 경로이고 그다음부터가 사용자가 넘긴 인자입니다. command-line-args 예제가 List.get(raw_args, 1)로 두 번째 요소를 읽는 이유가 이것입니다.
파일을 읽어 출력하는 도구의 구조
파일 읽기·쓰기 예제의 패턴을 응용하면, 경로 하나를 받아 UTF-8 파일 내용을 출력하는 도구는 다음 순서로 구성할 수 있습니다. 먼저 main!에서 받은 List Arg의 길이로 사용자 인자가 있는지 확인하고, 없으면 Err(Exit(1, "Usage: cat <path>"))를 돌려 사용법을 안내합니다. 인자가 있으면 경로를 얻어 File.read_utf8!로 읽고, 성공하면 Stdout.write!로 내용을 출력합니다. 각 단계의 실패는 ?로 전파하고, main! 시그니처의 _ 자리는 컴파일러가 추론한 실패 태그 유니온으로 채워집니다. 큰 파일을 한 번에 메모리에 올리고 싶지 않다면 basic-cli의 버퍼 읽기 API(open_reader!, read_line! 등)를 검토합니다.
재현 가능한 빌드
basic-cli 릴리스 URL에는 콘텐츠 해시가 들어 있으므로, CI와 로컬 개발 환경이 같은 URL을 쓰는지 확인합니다. 저장소 예제의 상대 경로(../platform/main.roc)는 저장소 안에서만 동작합니다. Arg와 파일 경로는 OS 차이가 드러나는 부분이므로, 윈도우에서도 실행해야 한다면 해당 예제의 윈도우 분기를 참고해 따로 테스트합니다.
한계와 주의점
생태계는 아직 작습니다. 필요한 라이브러리가 Roc로 존재하지 않는 경우가 많고, 그럴 때는 플랫폼 호스트 쪽에서 다른 언어의 라이브러리를 감싸 효과 함수로 노출하는 방식을 설계해야 합니다. alpha4와 Zig 기반 새 컴파일러가 병행되는 동안에는 문법과 플랫폼 호환성이 바뀔 수 있으므로 릴리스 노트를 따라가야 합니다. 문법이 바뀐 이력(Task에서 ! 효과 함수로, 람다 문법과 문자열 보간 문법 변경 등)이 있어 검색으로 찾은 예제가 현재 컴파일러에서 동작하지 않는 경우도 흔합니다.
디버깅할 때는 로직을 작은 순수 함수로 나눠 두면 REPL이나 expect 테스트로 쉽게 확인할 수 있습니다. 효과 함수는 main! 근처의 얇은 층에 모으고, 판단 로직은 순수 함수로 두는 구성이 테스트와 재현 모두에 유리합니다.
참고 자료
- The Roc Programming Language
- Platforms and Apps
- Tutorial
- basic-cli (GitHub)
- Zig 컴파일러 미니 튜토리얼 (나이틀리용)