SmallLang에서 Sollang이 되기까지
안녕하세요. 디모이입니다.
한동안 작은 프로그래밍 언어를 하나 만들고 있었습니다. 처음 이름은 SmallLang이었는데요. 문법 몇 가지를 실험하는 작은 언어로 시작했지만, 하나씩 실제 동작으로 옮기다 보니 어느새 LLVM으로 Windows와 Linux 네이티브 실행 파일을 만들고, 언어 자신으로 작성한 컴파일러가 다시 자기 자신을 컴파일하는 단계까지 왔습니다.
그래서 지금은 이름도 Sollang으로 바꾸었습니다. 소스 확장자도 .sl에서 .slg로 바뀌었습니다.
이 글에서는 SmallLang이 어떻게 Sollang이 되었는지, 지금까지 무엇을 실제로 구현했는지, 그리고 가장 최근에는 어떤 문제를 풀고 있는지 한 번 정리해 보려고 합니다.
왜 Sollang인가
Sollang의 Sol에는 한 가지가 아니라 네 가지 의미를 담았습니다.
첫 번째는 태양입니다. 코드가 햇빛처럼 의도를 잘 드러냈으면 합니다. 값이 어디에서 와서 어디로 가는지, 누가 소유하는지, 오류가 왜 생겼는지를 가능하면 숨기지 않는 언어를 만들고 싶었습니다.
두 번째는 음악의 음이름인 솔입니다. 비슷한 개념은 비슷한 모양으로 표현되고, 코드가 앞에서 뒤로 자연스럽게 읽히는 리듬이 있었으면 합니다.
세 번째는 Solution입니다. 문법 자체가 멋있어 보이는 데 그치지 않고, 복잡한 문제를 실제로 더 단순하게 풀어 주는 언어여야 한다고 생각합니다.
네 번째는 S·O·L, 즉 Simple, Original, Logical입니다. 단순한 형식으로 독창적인 생각을 표현하되, 그 규칙은 논리적으로 조합되어야 한다는 뜻입니다.
이 네 가지 중 하나만 대표 철학으로 고른 것은 아닙니다. 밝고, 조화롭고, 문제 해결에 도움이 되며, 창작자의 생각을 담을 수 있는 언어를 함께 지향하고 있습니다.
Sollang 문법의 중심
Sollang은 값을 왼쪽에서 오른쪽으로 흘려보내는 표현을 좋아합니다.
getName: -> Text {
"dimohy"
}
square value: Int -> Int {
value * value
}
main {
getName => name
7 -> square => squared
"Hello, $name. square = $squared" -> println
}
->는 값을 함수나 연산으로 보내는 흐름이고, =>는 계산된 값을 이름이나 대상에 연결하는 바인딩입니다.
입력이 없는 함수는 getName()처럼 호출하지 않고 getName으로 사용합니다. 입력이 없는데 빈 괄호를 반복할 필요가 없다고 보았고, 지금은 getName()을 컴파일 오류로 처리합니다.
조건과 패턴 매칭도 같은 방향으로 읽힙니다.
result -> when {
Ok(value) => value
Err(error) => 0
} => answer
여기서는 result의 타입을 알고 있으므로 Result<Int, Text>.Ok(value)처럼 타입 이름을 반복하지 않습니다. Ok와 Err를 문맥에서 추론합니다.
범위와 컬렉션은 컴파일 시점에 펼칠 수도 있습니다.
[1..10] => numbers
[1..10 -> each { it + 1 }] => incremented
{1..3 -> each { it: it * 10 }} => lookup
결과를 컴파일 시점에 알 수 있다면 런타임 루프를 만들기보다 평범한 배열이나 사전 리터럴로 변환합니다.
지금까지 구현된 언어 기능
처음에는 함수와 출력부터 시작했지만, 현재는 다음 영역까지 실제 컴파일러와 누적 예제로 검증하고 있습니다.
문법과 제어 흐름
main 블록과 최상위 실행문
value -> function => result 형태의 흐름 호출과 바인딩
- 로컬 함수와 구조체 안의 중첩 구조체
- 표현식 기반
if, when, 범위 패턴, 간결한 guard와 break/continue
each, fold, repeat, parallel, tryParallel 같은 블록 함수
# 한 줄 주석과 공통 들여쓰기를 제거하는 """ 여러 줄 문자열
$name, $(expression) 문자열 보간
- 문맥으로 타입을 추론하는
Ok, Err, Some, None 패턴
- 인자가 없는 함수를 속성처럼 사용하는 괄호 없는 호출
타입과 제네릭
- 구조체, 열거형, trait, associated type과 타입 동등성 제약
- 배열처럼 보이지 않는
<T, R, E> 타입 제네릭
<N: Int> 형태의 컴파일 타임 값 제네릭
- 고정 배열
[T; N], 가변 배열 [T; ~], 사전 {K: V}
Option<T>와 Result<T, E>, postfix ? 오류 전파
- 구조적으로 추론되는 로컬/private 함수 입력·반환 타입
Int8, Int16, Int32, Int64와 대응하는 unsigned 타입
Float32, Float64, 안정적인 기본 별칭 Int와 Float
- Raspberry Pi Pico 같은 타깃에서도 의미가 흔들리지 않도록 한 고정 폭 기본 타입과, 포인터 크기를 따르는
Size/UIntSize
- UTF-8
Text와 Unicode CodePoint
배열과 사전
- 값 타입 전반에 적용되는 제네릭 배열과 Swiss-table 방식 사전
- 구조체 키를 포함한 정적 hash/equality witness 특수화
- 배열/사전의
each, 변환, 조회, 갱신과 소유권 이전
data![index] = value와 value => data![index] 두 형태의 인덱스 대입
- 문맥으로 원소 타입을 알 수 있을 때 구조체 이름을 반복하지 않는 리터럴
- 인덱스의 타입도 문맥에서 알 수 있으면
SymbolKey 같은 키 타입 이름을 생략하는 표현
- 컴파일 타임 범위와 변환으로 배열·사전 리터럴을 미리 생성하는 기능
메모리와 소유권
Sollang은 기본 GC를 두지 않고, 값의 소유권과 파괴 시점을 컴파일러가 정적으로 다루는 방향입니다.
- 작은 non-escaping readonly 배열과 사전은 자동으로 스택에 올리고, 크기나 생명주기상 필요할 때 힙으로 전환
- 함수 진입 시 계획된 스택 슬롯을 분기와 반복문에서 재사용
!가 붙은 이름으로 가변 소유자를 구분
- readonly view, mutable borrow, 명시적인
move와 take
box를 통한 명시적 힙 소유와 재귀 구조체 drop glue
- 배열·사전·구조체 필드의 부분 move, 재초기화, 정확히 한 번의 파괴
- 소유 배열/사전 원소 교체 시 이전 값을 먼저 파괴하고 새 소유자를 이전
- 빌린
Text가 원본보다 오래 살아남지 않도록 origin과 사용 구간을 추론
- 서로 다른 구조체 필드와 서로 다른 상수 인덱스를 구분하는 projected-place borrow 분석
- 안전한 코드에서는 raw pointer를 직접 노출하지 않으면서도 큰 파일을 다루는 언어 수준 memory map
- 위치 기반 byte arena와 affine 파일/작업 핸들
객체를 무조건 힙에 두거나 스택에 둔다는 규칙보다는, escape 여부와 크기, 소유권 이전 가능성을 보고 가장 싼 저장소를 선택하는 쪽을 지향합니다. 필요한 경우에는 명시적인 box로 힙 소유를 표현합니다.
비동기와 병렬 처리
async/await와 타입을 보존하는 Task<T>
- OS thread 하나를 Task 하나에 대응시키지 않는 cooperative executor
- tail await, 연속 await,
if/when/while 안의 suspension state
yield, typed Duration, timer queue, sleep과 cancellation
- cancellation 시 살아 있는 frame owner를 정확히 파괴하는 affine task
- 비동기 파일 열기·읽기·쓰기·sync
- Windows와 Linux가 공유하는 구조화된 실행 모델
- 순서를 보존하는
parallel/tryParallel과 bounded native worker pool
- worker 출력이 서로 섞이지 않도록 하는 메모리 출력 sink
- 첫 실패 이전 결과만 보존하고 나머지 소유 결과를 정리하는 결정적 실패 처리
- 병렬 callback이 가변 값이나 구조적으로 전송할 수 없는 값을 안전하지 않게 캡처하지 못하도록 하는 정적 검사
uses Console, File, Clock, ... 형태의 명시적 effect capability
파일, 프로세스와 큰 데이터
- Windows/Linux 네이티브 파일 owner와 결정적 close
readAt<T>, writeAt<T>, 비동기 random-access I/O
- shell 문자열을 거치지 않는 argv 기반 자식 프로세스 실행
- OS의 명령행 인자를 읽기 전용 host view로 제공
- 환경 변수에서 “없음”과 “빈 문자열”을 구분
mmap/file mapping을 언어 문법에 가까운 형태로 제공
- 매우 큰 데이터도 전체 복사를 전제로 하지 않고 bounded view와 affine owner로 처리
모듈, 프로젝트와 패키지
- 여러
.slg 파일을 하나의 프로그램으로 컴파일
- import 경로를 따라 파일을 자동 발견하고 순환/중복 모듈을 진단
- import의
as를 생략하면 마지막 namespace를 기본 별칭으로 사용
- 선언은 기본 internal, 필요한 API만
public
sollang.project의 root와 여러 product
- SemVer 기반 로컬 패키지 의존성
- 명시적인
sollang.workspace와 결정적인 workspace lock
- 전체 revision으로 고정한 Git 의존성과 source-tree SHA-256 검증
- checksummed ZIP을 사용하는 정적 registry index
--locked 재현 빌드와 canonical sollang.lock
빌드와 도구
- C# reference compiler
- Sollang으로 작성 중인 self-host compiler
- 하나의 언어 규칙에서 lexer/parser 자료를 생성하는 source generator와 grammar VM
- Windows x64, Linux x64 LLVM 네이티브 출력
- 브라우저용 WebAssembly 출력
- 현재
.slg 문법에 맞춘 VS Code syntax highlighting 확장
- 테스트 진행 상황을
n/total로 계속 보여 주는 병렬 테스트 runner
- 변경 계층에 따라 관련 회귀만 고르는 fast/affected suite
- exact-input warm build에서 parsing, semantic analysis, LLVM emission, linking까지 건너뛰는 content-validated cache
- 모듈 interface fingerprint, transitive invalidation, stable semantic identity
- 변경되지 않은 함수 body의 semantic 결과와 LLVM codegen unit 재사용
- 원자적 cache publication과 손상된 cache의 안전한 폐기
그리고 Sollang 0.1은 Windows x64와 Linux x64용 self-contained 패키지, 체크섬과 함께 공개되어 있습니다.
self-host는 어디까지 왔나
여기서 말하는 Stage는 다음 뜻입니다.
- Stage 1: C# reference compiler가 Sollang으로 작성된 Sollang compiler를 빌드합니다.
- Stage 2: 그렇게 만들어진 Sollang compiler가 다시 Sollang compiler와 실제 프로그램을 빌드합니다.
- Stage 3: Stage 2 compiler가 다시 compiler를 만들었을 때 Stage 2와 같은 결과가 나오는지 확인합니다.
현재 Sollang으로 작성된 compiler는 lexer, parser/CST/AST, semantic, typed IR, ownership analysis, module cache, LLVM emitter로 나뉘어 있습니다. 한 번 빌드한 native self-host compiler를 재사용하도록 검증 구조도 바꾸어서, 매 테스트마다 거대한 compiler를 다시 해석하던 병목을 줄였습니다.
공식 roadmap은 기능을 60개의 auditable gate로 나눕니다. 최신 완료 기준점은 다음과 같습니다.
- 49 complete, 8 partial, 3 missing
- 가중치 기준 53/60, 88.3%
- 남은 equivalent gate는 7개
- 핵심 문법과 제어 흐름은 10/10
- 모듈·visibility·build는 8/8
- 타입·trait·generic은 11/12
- compiler-construction primitive는 11.5/12
단순히 “컴파일된다”는 말로 완료 처리하지 않습니다. 각 gate는 누적 .slg 예제 또는 자동화된 compiler test가 있어야 완료로 계산합니다.
가장 최근에 한 작업
최근 작업의 중심은 소유권과 borrow analysis를 self-host compiler의 실제 production 진단으로 끌어올리는 일이었습니다.
직접 반환한 Text view가 어느 SourceText owner에서 왔는지 추론하는 것부터 시작해서 다음을 순서대로 구현했습니다.
- 마지막 사용 지점까지만 borrow를 유지하는 non-lexical region
if, when, loop의 CFG를 따라가는 borrow liveness
- 여러 입력 중 하나에서 반환될 수 있는 view의 union origin
- mutable reference 재대입 때 현재 origin을 교체·병합하는 분석
- 구조체·배열·사전 안에 들어간 borrowed
Text의 aggregate origin
- 서로 다른 field와 상수 index를 구분하는 projected place 충돌 분석
- 부분 move 후 도달 가능한 use만 E17로 차단하는 production 진단
- 병렬 callback 안의 local function call graph를 끝까지 따라가 mutable outer capture를 E18로 차단하는 분석
가장 최신 완료 checkpoint는 D213L입니다. parallel이나 tryParallel 본문이 local helper를 호출하고, 그 helper가 다시 다른 helper를 호출하더라도 바깥의 가변 binding을 숨길 수 없게 했습니다. 재귀 호출 그래프는 visited set으로 끝내고, 같은 binding을 여러 번 읽어도 callback당 진단은 한 번만 냅니다.
이 검사를 실제 self-host compiler에 켰더니 compiler 자신의 typed-IR parallelizer가 여섯 개의 큰 가변 table을 캡처하고 있었습니다. 검사를 약하게 만드는 대신, 그 table들을 복사하지 않고 move 기반 freeze helper로 불변 owner에 넘긴 뒤 worker가 readonly로 빌리도록 바꾸었습니다. 결과적으로 안전성 검사를 통과하면서 compiler 크기의 table을 추가 복사하지 않는 zero-copy 전환이 되었습니다.
최신 완료 기준점의 검증 결과는 다음과 같습니다.
- Release build: warning 0, error 0
- Windows 전체 예제: 666/666
- Linux 전체 예제: 666/666
- Windows Stage 2: 7/7
- Linux Stage 2: 6/6
- Stage 3: Stage 2와 byte-for-byte 동일
- Stage 3 LLVM text: 11,840,360 bytes
- SHA-256:
E0B91E9140B90D04F3417926■■0C3B2BE38CF5B35EC975D119757B8C75C2BBF9
즉 C# compiler가 만든 경로만 통과한 것이 아니라, Sollang compiler가 만든 compiler로도 같은 안전성 진단과 LLVM 출력을 확인하고 있습니다.
아직 끝난 것은 아닙니다
88.3%는 꽤 멀리 왔다는 뜻이지, 언어가 완성되었다는 뜻은 아닙니다.
지금 다음 경계는 병렬 callback의 구조적 non-sendable capture인 E19를 local helper 호출 그래프까지 정확히 추적하고, branch나 loop가 부분 이동 상태로 빠져나가는 E20을 production 진단으로 완성하는 일입니다.
더 넓게는 다음 영역이 남아 있습니다.
- ownership/storage의 남은 정밀도와 escape reference 규칙
- generic과 compiler primitive의 마지막 빈 gate
- 아직 partial인 표준 라이브러리 영역과 더 풍부한 진단
- 실사용에 필요한 package/registry 운영 경험과 tooling 완성도
- compiler 자체의 속도와 메모리 사용량을 계속 낮추는 일
특히 self-host compiler는 “동작하는 척하는 데모”가 아니라 실제로 자기 자신을 만들고, 그 결과가 다시 동일해지는지를 확인하는 쪽으로 진행하고 있습니다. 그래서 새 기능 하나를 넣을 때마다 C#과 Sollang 두 compiler의 결과를 비교하고, Windows와 Linux 전체 예제, Stage 2, 주기적인 Stage 3를 통과시킨 뒤 작은 checkpoint로 커밋하고 있습니다.
마무리
SmallLang은 정말 작은 문법 실험으로 시작했습니다. 그런데 간결한 문법을 고민하다 보면 타입 추론으로 이어지고, 타입을 진지하게 다루다 보면 소유권과 메모리로 이어지고, 거기까지 가면 결국 compiler가 자기 언어를 얼마나 정직하게 설명할 수 있는지를 보게 되는 것 같습니다.
그래서 Sollang으로 이름을 바꾼 것은 단순한 branding 변경이라기보다, 지금까지 쌓인 방향을 다시 선언한 일에 가깝습니다.
밝게 읽히는 코드, 리듬 있게 이어지는 표현, 실제 문제를 단순하게 푸는 도구, 그리고 Simple·Original·Logical한 창작의 언어.
아직 갈 길은 남았지만, 현재까지의 결과에는 만족하고 있습니다. 이 프로젝트는 GPT-5.6 Sol Medium과 함께 만들고 있으며, 그 협업 과정도 Sollang의 역사로 README에 기록해 두었습니다.
관심 있으신 분은 저장소의 examples부터 보시면 지금까지의 문법 변화와 compiler가 실제로 검증하는 범위를 순서대로 확인하실 수 있습니다. 의견이나 날카로운 지적도 환영합니다.