Roc 완벽 가이드 | 빠르고 친절한 함수형 언어·플랫폼·타입·CLI
이 글의 핵심
Roc는 순수 함수형 프로그래밍, 명시적 플랫폼 기반 I/O, 강한 타입 추론을 갖춘 언어입니다. 이 글에서는 언어 핵심, 플랫폼과 애플리케이션 구조, Opaque 타입, 에러 처리, 성능 특성, basic-cli를 이용한 CLI 실전까지 연결해 설명합니다.
이 글의 핵심
Roc는 Elm에서 영감을 받아 설계된 순수 함수형 프로그래밍 언어로, 태그 유니온(tag union) 과 레코드, 강한 타입 추론, 그리고 플랫폼(platform)과 애플리케이션(application) 이라는 구조로 입출력을 명시합니다. 컴파일러는 LLVM을 타깃으로 하며, 실행 가능한 코드 생성과 빠른 피드백을 중시합니다.
이 글은 Roc를 처음 접하는 백엔드·시스템 개발자가 언어의 철학을 이해하고, basic-cli 플랫폼 위에서 CLI 도구를 설계할 수 있도록 돕는 것을 목표로 합니다. 문법은 공식 사이트의 플랫폼 예제(Hello World)와 basic-cli 저장소 예제를 기준으로 하되, 튜토리얼이 안내하는 대로 alpha4와 Zig 기반 나이틀리의 차이가 있을 수 있음을 명시합니다.
1. Roc이 다른 점: 한 문장으로 요약하면
Roc는 “코어 언어는 순수하고, 세상과의 상호작용은 플랫폼이 맡는다”는 전제를 둡니다. 대부분의 언어가 표준 라이브러리에 파일 읽기, 네트워크, 표준 출력을 함께 넣는 것과 달리, Roc의 내장(builtins) 은 데이터 구조 중심이며, 실제 I/O는 항상 선택한 플랫폼이 제공합니다.
이 설계의 결과로 다음이 기대됩니다.
- 도메인에 맞는 API만 노출되어, 예를 들어 브라우저용 플랫폼에서는 임의 파일 접근을 원천적으로 제한하는 식의 조합이 가능합니다.
- 호스트(host) 가 메모리 할당·해제, I/O 스케줄링을 구현하므로, 웹 요청 단위 아레나 할당처럼 영역별 최적화를 플랫폼 정책으로 넣을 여지가 있습니다.
- 애플리케이션은 플랫폼이 정의한 Roc API만 사용하므로, 외부 언어와의 경계가 플랫폼 경계로 정리됩니다.
이후 절에서는 문법·타입·에러·성능을 순서대로 짚고, 마지막에 basic-cli로 실전 CLI를 구성하는 흐름을 제시합니다.
2. 설치와 도구: 어디서부터 볼까
공식 사이트의 설치 안내에 따라 OS별로 Roc을 준비합니다. 정식 번호 매긴 릴리스보다는 알파·나이틀리를 따라가는 구조이므로, 팀 프로젝트에서는 컴파일러 버전과 플랫폼 tarball URL을 고정하는 것이 안전합니다.
개발 시에는 다음을 함께 두면 좋습니다.
- 온라인 REPL(roc-lang.org/repl): 표현식·작은 함수의 타입을 빠르게 확인할 때 유용합니다.
- 로컬
roc repl: 오프라인으로 동일한 실험을 반복할 때 사용합니다. - 공식 튜토리얼의 “AI용 텍스트”(roc-lang.org/llms.txt 등): 최신 문법·내장 설명을 검색할 때 보조 자료로 쓸 수 있습니다.
튜토리얼 서문에 적혀 있듯, alpha4(러스트 기반 컴파일러) 용 설명과 Zig 컴파일러용 미니 튜토리얼은 일부 문법이 다릅니다. 본문의 플랫폼 선언·main! 예제는 Platforms and Apps 문서에 맞춘 최신 플랫폼 스타일을 우선합니다.
3. 핵심 개념: 표현식, 태그, 패턴 매칭
3.1 표현식 중심
Roc는 모든 것이 잘 정의된 값으로 귀결되는 표현식이라는 쪽으로 읽기 쉽게 정리되어 있습니다. 함수는 이름 없는 함수와 이름 있는 함수를 모두 쓸 수 있으며, 타입은 가능한 한 생략해도 컴파일러가 역추론합니다.
3.2 태그 유니온과 when
태그(tag) 는 여러 가능한 형태를 가진 값을 표현합니다. 예를 들어 색을 Red, Yellow, Green 중 하나로만 두고, when으로 모든 경우를 처리하면 exhaustiveness(누락 없는 분기) 검사에 도움이 됩니다. 이는 대수적 데이터 타입을 일상적으로 쓰는 함수형 언어들과 같은 계열입니다.
실무적으로는 “가능한 상태를 타입으로 밀어 넣고, 처리하지 않은 분기를 컴파일러가 잡게 한다”는 패턴이 Roc의 가독성과 안전성의 중심에 있습니다.
3.3 레코드와 불변성
레코드는 필드 이름으로 데이터를 묶습니다. 같은 도메인을 한 번에 다루는 값을 작게 유지하면, 함수 시그니처가 의도를 드러내는 문서 역할을 합니다. Roc는 기본적으로 순수 함수형 스타일을 권장하므로, 참조를 통한 암묵적 변경보다 새 값을 돌려주는 패턴이 자연스럽습니다.
3.4 모듈 경계
파일·모듈 단위로 공개 목록을 정해 API를 제한합니다. Opaque 타입(다음 절)과 결합하면, 외부에서 건드릴 수 없는 불변 조건을 모듈 안에 두기 쉽습니다.
4. 플랫폼과 애플리케이션: “한 번에 하나의 플랫폼”
4.1 애플리케이션 선언
애플리케이션은 항상 단 하나의 플랫폼 위에 올라갑니다. 공식 문서의 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는 이 플랫폼을 가져오는 로컬 이름입니다. platform "…tar.br" URL은 해당 버전의 basic-cli 플랫폼 패키지를 가리킵니다. 팀에서는 이 URL을 의존성처럼 고정하고, 업그레이드 시 릴리스 노트와 호스트 호환성을 확인하는 것이 좋습니다.
4.2 플랫폼이 제공하는 것
플랫폼은 도메인 특화 API를 제공합니다. basic-cli는 명령행 인자, 표준 입출력, 파일, 환경 변수, HTTP, TCP 등 CLI·스크립트에 맞는 연산을 노출합니다. 반면 웹 서버 플랫폼은 요청·응답에 맞는 API를, 게임 엔진이라면 렌더링·입력에 맞는 API를 제공하는 식입니다.
4.3 호스트(Roc API 아래층)
플랫폼은 Roc로 쓰인 공개 API와, 그 아래에서 실제 OS·런타임과 대화하는 호스트 구현으로 나뉩니다. 문서에서는 호스트가 main을 가지고, 적절한 시점에 컴파일된 Roc 애플리케이션의 함수를 호출하는 그림으로 설명합니다. 빌드 시에는 Roc 코드가 객체 파일로 컴파일되고, 플랫폼 호스트 바이너리와 링크되어 단일 실행 파일이 됩니다.
이 구조는 임베디드 Roc(다른 코드베이스에서 Roc을 스크립트처럼 호출)이나 Node와의 점진적 연동 같은 사례와도 잘 맞습니다. 공식 문서에서도 외부 코드베이스 전체를 플랫폼처럼 두고 Roc을 얹는 전략을 소개합니다.
5. 타입 추론: “적게 쓰고, 많이 보장하기”
Roc의 타입 시스템은 주석을 줄여도 대부분의 경우 정확한 타입을 재구성합니다. 특히 태그 유니온과 when 분기가 있으면, 가능한 값의 집합이 좁혀지므로 추론이 강해집니다.
5.1 추론이 돕는 지점
- 작은 로컬 함수에서 반복되는 제네릭을 일일이 쓰지 않아도 됩니다.
- 리스트·레코드·태그를 섞은 중간 데이터가 길어져도, 필드 이름과 분기가 있으면 읽기 쉬운 시그니처가 유지됩니다.
- 공개 API에서는 명시적 타입을 두어 모듈 경계의 계약을 문서화하는 편이 좋습니다. 추론에만 맡기면 리팩터링 시 의도가 흐려질 수 있기 때문입니다.
5.2 숫자 타입: Num과 구체 크기
튜토리얼에서는 1 + 1이 Num *처럼 아직 구체 크기가 열린 형태로 나타나기도 합니다. 실제로는 고정 크기 정수(U8, I32, …) 와 부동소수(F32, F64) 등을 상황에 맞게 선택합니다. 오버플로는 Roc에서 정의된 대로 크래시로 이어지므로, 경계가 중요한 코드에서는 크기와 연산 순서를 명시적으로 검토해야 합니다.
6. Opaque 타입: 구현을 숨기고 계약을 드러내기
6.1 정의: := 와 @이름
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)|로 처리합니다.
6.2 모듈 밖에서의 규칙
다른 모듈에서는 Username을 타입 주석으로는 쓸 수 있지만, @Username 문법으로 직접 만들거나 분해할 수 없습니다. 따라서 검증된 생성 경로(from_str 등)만 공개하면, 호출자는 불변 조건을 반복 검사할 필요가 없습니다.
6.3 같은 이름, 다른 모듈
문서에서 강조하듯, 서로 다른 모듈에서 Username := Str을 각각 정의하면, 이름이 같아도 서로 다른 타입입니다. 비교 연산조차 타입 불일치로 막힙니다. 이는 네임스페이스를 실수로 섞는 버그를 줄이는 데 유리합니다.
6.4 실무 패턴
- 검증된 입력: 이메일, 경로, 비어 있지 않은 리스트 등 한 번 검증한 뒤 Opaque로 감싸기.
- 내부 표현 변경: 나중에
Str에서 구조화된 레코드로 바꿔도, 공개 함수 시그니처만 유지하면 의존 모듈 수정을 최소화할 수 있습니다.
7. 에러 처리: Result, 효과(!), 종료 코드
7.1 Result로 성공·실패를 태그로 표현
성공(Ok) 과 실패(Err) 를 같은 타입의 값으로 다루면, 실패 가능한 계산을 합성하기 쉽습니다. Roc에서는 실패의 종류를 태그 유니온으로 세분화해, 처리 누락을 줄이는 방향이 자연스럽습니다.
7.2 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 {} _ 를 돌려 성공 또는 종료 코드·메시지가 있는 실패를 표현합니다.
7.3 효과 연산자 !와 ?
플랫폼 API는 실제 입출력을 수행하는 효과를 나타내기 위해 !가 붙은 이름을 사용하는 경우가 많습니다. 예를 들어 Stdout.line!은 한 줄 출력이라는 효과입니다. 연속된 효과는 ?로 이전 단계의 실패를 전파하는 패턴이 예제에 자주 등장합니다.
파일 읽기·쓰기 예제(file-read-write.roc)에서는 다음과 같이 작은 헬퍼에 ? 전파를 모읍니다.
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 등으로 실패합니다. 바이너리가 필요하면 read_bytes! 쪽을 선택합니다.
7.4 사용자 정의 실패: Err(Exit(…))
command-line-args 예제는 인자가 없을 때 Err에 Exit 코드와 메시지를 실어 사용자에게 실행 방법을 안내합니다. CLI에서는 정상 종료와 비정상 종료를 타입으로 표현할 수 있다는 점이 실무적으로 중요합니다.
8. 성능 특징: 컴파일·런타임·메모리
8.1 LLVM과 단일 패스 컴파일 지향
Roc는 LLVM을 통해 기계어에 가까운 코드를 생성합니다. 함수형 스타일을 유지하면서도, 불필요한 박싱을 줄이고 데이터 표현을 단순하게 유지하려는 설계 철학이 문서와 커뮤니티 논의에 반복해서 등장합니다.
8.2 참조 카운팅과 단일 스레드 모델
Roc는 기본적으로 참조 카운팅 기반의 메모리 관리를 채택하며, 단일 스레드 모델을 전제로 합니다. 멀티스레드 공유를 언어 차원에서 허용하는 방향이 아니라, 불변 데이터와 메시지 패싱에 가까운 안전성을 우선하는 편입니다. 동시성이 필요하면 플랫폼이 제공하는 Task 스케줄링과 명시적 병렬성을 설계 문서에서 확인하는 것이 좋습니다.
8.3 플랫폼별 메모리 전략
앞서 언급했듯 호스트가 malloc/free에 해당하는 할당·해제를 제공합니다. 웹 서버 플랫폼은 요청마다 아레나를 쓰는 식으로 할당 비용을 줄이고 해제를 거의 없애는 전략을 문서에서 예시로 듭니다. 즉 “Roc 언어 하나의 GC 정책”이 아니라 플랫폼이 도메인에 맞게 메모리 정책을 선택할 수 있습니다.
8.4 실무에서의 기대치
- 짧은 CLI·배치는 시작 시간과 바이너리 크기가 중요합니다. Roc는 정적 링크에 가까운 배포를 염두에 둔 사례가 많습니다.
- CPU 바운드 수치 코드는 LLVM 최적화 이점을 볼 수 있지만, 벤치마크는 항상 플랫폼·호스트 버전과 함께 적어 두어야 재현됩니다.
9. 실전: CLI 도구 개발 with basic-cli
이 절에서는 배포 가능한 형태를 기준으로, 공식 문서의 플랫폼 URL을 사용하는 Hello World와, 저장소 예제를 참고한 인자 처리·파일 읽기 흐름을 정리합니다.
9.1 최소 실행 파일
Platforms and Apps에 있는 예제를 그대로 두면, 단일 진입점 main! 과 Stdout.line! 만으로 동작하는 CLI가 됩니다. 빌드·실행 명령은 사용 중인 컴파일러 문서를 따릅니다(예: roc build, roc run).
9.2 인자 다루기: Arg와 OS별 표현
Arg는 유닉스의 바이트 시퀀스와 윈도우의 U16 시퀀스를 추상화합니다. 패키지에 넘길 때는 Arg.to_os_raw 로 OS 중립 표현으로 바꾸는 패턴이 예제 주석에 설명되어 있습니다. 빠른 로깅에는 Arg.display로 UTF-8 가정 하 문자열을 얻을 수 있습니다(인코딩이 깨진 경우의 동작은 플랫폼 문서를 확인).
첫 번째 인자는 실행 파일 경로, 그다음이 사용자 인자라는 점에 유의합니다. command-line-args 예제는 List.get(raw_args, 1)로 두 번째 인자를 읽습니다.
9.3 파일을 읽어 표준 출력으로 흘리기(설계 스케치)
file-read-write 예제의 패턴을 응용하면, 경로 하나를 받아 UTF-8 파일을 읽어 출력하는 도구는 다음과 같은 구조를 가질 수 있습니다.
main!에서List Arg를 받아 사용자 인자 개수를 검사합니다.- 부족하면
Err(Exit(1, "…"))형태로 사용법을 돌려줍니다. - 충분하면
Path문자열을 얻어File.read_utf8!로 읽습니다. - 읽기에 성공하면
Stdout.line!또는Stdout.write!로 내용을 출력합니다.
실제 코드에서는 에러 타입을 main! 시그니처의 _에 맞게 구체화하거나, 작은 헬퍼 함수로 ? 전파를 모읍니다. 대용량 파일은 open_reader!와 read_line! 로 버퍼 읽기를 검토합니다.
9.4 패키징과 재현성
- 플랫폼 URL 고정:
basic-cli의 릴리스 tarball은 버전마다 해시가 다릅니다. CI와 로컬이 같은 URL을 쓰는지 확인합니다. - 예제는 상대 경로: 저장소 예제는
../platform/main.roc를 가리키므로, 복사해 쓸 때는 URL 방식으로 바꿉니다. - 크로스 플랫폼:
Arg와 파일 경로는 OS 차이를 드러냅니다. 윈도우 전용 테스트가 필요하면Windows분기 예제를 참고합니다.
10. 한계와 주의점: 지금 시점에서 알아둘 것
- 생태계 규모: npm·crates 수준의 패키지 밀도는 아니며, 필요한 라이브러리가 Roc에 없으면 플랫폼 FFI나 다른 언어와의 연동을 설계해야 합니다.
- 도구 체인 전환: alpha4와 Zig 컴파일러 병행 기간에는 문법·플랫폼 호환을 릴리스 노트로 추적하는 것이 안전합니다.
- 디버깅 경험: 작은 순수 함수로 쪼개는 것이 Roc에서 재현 가능한 버그를 줄이는 데 유리합니다. 효과가 섞인 함수는 입출력 순서를 명시적으로 적어 두면 나중에 읽기 쉽습니다.
11. 정리
Roc는 순수 함수형 코어, 명시적 플랫폼 기반 I/O, 태그와 패턴 매칭 중심의 에러 모델, Opaque 타입으로 모듈 경계를 단단히 하는 습관이 잘 맞는 언어입니다. 타입 추론은 보일러플레이트를 줄이고, 플랫폼·호스트 분리는 보안·성능·배포를 한 번에 고려하게 합니다.
CLI 도구를 Roc로 작성할 때는 basic-cli를 기준으로 main! → Result → ? 전파의 흐름을 익히고, 인자(Arg)와 파일 API를 예제에서 그대로 따라가며 에러 메시지와 종료 코드를 설계하는 것이 좋습니다. 그 다음 플랫폼 URL 고정과 컴파일러 버전 핀으로 재현 가능한 빌드를 완성하면, 팀 내에서도 “함수형이지만 배포는 단순한” 실행 파일을 유지보수하기 쉽습니다.