원문 캡처 · nim-lang.org
원문 캡처 · nim-lang.org

님 팀이 2026년 10월 28일 안정 버전 Nim 2.4.0을 공개했다. 새 버전에는 문자열 처리 옵션, 병렬·증분 빌드, 여러 스레드에 걸친 순환 참조를 처리하는 메모리 관리 기능과 합 타입이 추가됐다.

주요 변경 사항은 다음과 같다.

  • --strings:sso: 문자열 처리 효율을 높이는 소형 문자열 최적화(Small String Optimization, SSO) 옵션이다.
  • --ic:on: 병렬·증분 빌드를 활성화한다.
  • --mm:yrc: 여러 스레드에 걸친 순환 참조를 지원하는 새 순환 참조 수집 알고리즘이다.
  • 더 간결하게 쓸 수 있는 합 타입이 추가됐다.
  • 순환 모듈 의존성을 지원한다. 권장되지는 않지만 {.cyclic.}으로 선언하면 사용할 수 있다.

Nim 1.6을 사용 중이라면 2.0 버전 출시 글에서 이후 추가된 기능을 확인할 수 있다. Nim 2.0 사용자는 2.2 버전 출시 글에서 2.2의 개선 사항을 볼 수 있다. 당분간 2.2를 유지하려는 사용자를 위해 버그 수정과 보안 수정이 포함된 패치 버전 2.2.14도 이날 공개됐다.

설치와 주요 변경 사항

운영체제의 패키지 관리자가 Nim 2.4.0을 제공하는지 확인하거나 설치 안내를 따르면 된다. 이전 버전을 choosenim으로 설치했다면 다음 명령으로 2.4.0을 받을 수 있다.

$ choosenim update self $ choosenim update stable

님 팀은 choosenim 저장소에서 제공하는 최신 버전인 v0.8.16을 설치해 사용할 것을 권장한다. 나이틀리 빌드에서 Nim 2.4.0을 내려받을 수도 있다.

문자열 API와 SSO

SSO 기반 문자열을 사용하려면 새 저수준 문자열 API를 이용해야 한다. 이 API는 Nimony의 API와 호환된다. --strings:sso가 코드 성능을 높이지 않더라도 새 API로 옮기는 것은 장래를 대비한 리팩터링이 될 수 있다.

새 API는 addr myString[offset] 사용을 단계적으로 없애고, 의도를 분명히 드러내는 연산으로 바꾸는 데 초점을 둔다. 읽기와 쓰기 작업도 구분한다.

목적사용할 API참고 사항
읽기 전용 원시 포인터readRawData(s, start = 0)s가 살아 있고 변경되지 않는 동안 유효하다
이동·복사 후에도 유지되는 원시 포인터readRawDataStable(s, start = 0)var string을 받으며, 먼저 소형 문자열을 힙으로 옮긴다
문자열에 일괄 쓰기beginStore(s, newLen, start = 0) … endStore(s)길이를 설정하고 고유 소유권을 확보한 뒤 쓰기 가능한 포인터를 반환한다

바이트를 읽는 작업(memcpy로 문자열에서 복사하기, memmem, 스캔)에서는 addr s[i] 또는 unsafeAddr s[i]를 readRawData(s, i)로 바꾼다. 바이트를 일괄 기록하는 작업(문자열에 memcpy 또는 fread를 쓰는 경우)에서는 setLen과 addr s[oldLen] 대신 beginStore(s, newLen, start)를 호출하고 이어서 endStore(s)를 호출한다. beginStore가 길이를 설정하고 쓰기 시 복사(copy-on-write, COW) 및 고유 소유권 처리를 하므로 별도의 prepareMutation은 필요하지 않다.

포인터가 현재 범위를 벗어나서도 유효해야 한다면(예를 들어 버퍼 옆에 커서를 저장하는 경우) readRawDataStable을 사용한다. 이 함수는 문자열을 힙에 올려 데이터 주소를 안정적으로 유지한다. s[i] = c와 같은 단일 요소 쓰기는 그대로 사용할 수 있다. 컴파일러가 이를 내부적으로 처리하는 nimStrPutV3로 변환하므로 인덱스 대입 전에 prepareMutation(s)를 호출할 필요가 없다.

문자열에 일괄 복사하는 코드의 변경 전후 예시는 다음과 같다.

# before result.setLen newLen copyMem(addr result[oldLen], buf, buflen) # after copyMem(beginStore(result, newLen, oldLen), buf, buflen) endStore(result)

문자열에서 바이트를 읽는 코드는 다음처럼 바꾼다.

# before copyMem(dest, addr(s.data[s.pos]), n) # after copyMem(dest, readRawData(s.data, s.pos), n)

문자열의 일부에 읽어들이는 경우의 변경 전후 예시는 다음과 같다.

# before prepareMutation(buffer) # COW literal guard under ARC result = s.readData(addr buffer[slice.a], count) # after result = s.readData(beginStore(buffer, slice.b + 1 - slice.a, slice.a), count) endStore(buffer)

string에서 cstring으로 변환하는 기능은 계속 사용할 수 있다. 다만 C 문자열은 원본 문자열이 살아 있고 수정되지 않는 동안에만 유효하다. C에 전달할 안정적인 널 종료 버퍼가 필요하다면 소유 문자열을 계속 유지하고, 포인터를 이동 이후까지 저장해 두는 대신 readRawDataStable 또는 일반 cstring 변환으로 그 문자열에서 포인터를 얻어야 한다.

문자열 API로 옮길 때 확인할 사항은 다음과 같다.

  1. 코드에서 addr s[, unsafeAddr s[, addr(...data[와 문자열을 가리키는 캐시된 포인터 또는 cstring을 검색한다.
  2. 읽기 접근은 readRawData로 바꾼다. 포인터가 현재 범위를 벗어나거나 이동 후에도 유지돼야 한다면 readRawDataStable을 사용한다.
  3. setLen과 원시 쓰기를 beginStore와 endStore로 바꾼다.
  4. 인덱스 쓰기와 beginStore 전에 호출하던, 이제 불필요한 prepareMutation을 제거한다.
  5. add, 재할당 또는 문자열을 값으로 전달한 뒤에도 addr s[0]이 안정적으로 유지된다고 가정하지 않는다.
  6. --strings:sso를 켠 빌드와 끈 빌드를 각각 수행해 코드가 두 경우 모두 컴파일되고 동일하게 동작하는지 확인한다.

도우미 API는 모든 문자열 백엔드에서 시그니처가 같으므로, 이 방식으로 작성한 코드는 SSO를 켜지 않고 refc·ARC·ORC를 사용해도 계속 동작한다. API를 점진적으로 적용한 뒤 준비가 되면 --strings:sso를 켤 수 있다. 코드 업데이트는 필수는 아니지만 도움이 된다. 새 API는 모든 mm 스위치에서 사용할 수 있으며 Nimony와 Nim 3에서도 제공된다.

병렬·증분 빌드

증분 컴파일(IC)이 정식으로 제공되며 --ic:on 스위치로 사용할 수 있다. 병렬 빌드는 멀티스레딩이 아니라 멀티프로세싱을 사용한다. 따라서 전역 .compileTime 변수를 사용하는 매크로는 IC의 빌드 속도 이점을 얻기 전에 수정이 필요할 수 있다. --ic:on으로 PolyWorld를 빌드할 수 있도록 한 PR 예시가 있다.

가져오기 그래프의 형태에 따라 IC를 켜면 전체를 처음부터 빌드하는 경우에도 빨라질 수 있다. 빌드에서 더 많은 병렬성을 활용하기 때문이다. 예를 들어 물리 코어 16개가 있는 컴퓨터에서 Nim 컴파일러 자체를 빌드하는 데 --ic:off는 31초, --ic:on은 25초가 걸린다.

YRC 메모리 관리

YRC(“ürk”)는 Nim의 ORC를 스레드 안전하게 만든 버전이다. 스레드 사이에서 공유 참조와 순환 참조를 다뤄야 한다면 --mm:yrc를 사용한다. YRC는 ORC와 같은 API를 제공한다.

메모리 관리 스위치스레드 안전순환 참조 처리
ARC아니요아니요
Atomic ARC예아니요
ORC아니요예
YRC예예

ORC는 하나의 스레드 안에 머무는 순환 참조만 처리할 수 있지만, YRC는 여러 스레드에 걸친 순환 참조도 처리한다. 순환이 없는 데이터에는 원자적 참조 카운팅을 사용하고, 순환 데이터에는 포인터 대입 시에만 실행되는 쓰기 장벽을 사용한다. 모든 실행을 멈추는 단계(stop-the-world)나 임의의 안전 지점(safepoint)은 없다. 전용 가비지 수집기 스레드도 두지 않으며, 필요할 때 어느 스레드든 순환 참조 수집기를 실행할 수 있다. 이는 데이터 지역성에 도움이 되고 모든 스레드가 중요한 임베디드 시스템에도 적합하다.

비용이 없는 것은 아니다. 님 팀의 orcbench에서 YRC는 ORC보다 약 2.0배 느리고 메모리도 두 배 사용한다. 님 팀은 스레드 경계를 넘는 순환 참조를 더 이상 걱정하지 않아도 된다는 점에서 감수할 만한 비용이라고 설명한다. 비용은 일정한 2배 수준이며, 공유 데이터를 처리하는 코어가 늘수록 이점이 커진다는 설명이다.

합 타입

님은 기존에도 에이다에서 영감을 얻은 판별 합집합(discriminated union)을 지원했다. 예를 들어 다음처럼 NodeKind 열거형으로 노드 종류를 선언할 수 있었다.

type NodeKind = enum # the different node types IntLit, # a leaf with an integer value FloatLit, # a leaf with a float value StringLit, # a leaf with a string value AddOp, # an addition SubOp, # a subtraction IfStmt # an if statement Node = ref object case kind: NodeKind of IntLit: intVal: int of FloatLit: floatVal: float of StringLit: strVal: string of AddOp, SubOp: leftOp, rightOp: Node of IfStmt: condition, thenPart, elsePart: Node

이 방식은 작동하지만, 간결한 표기법이 없다는 지적이 수년간 이어졌다. 2.4에서는 다음과 같이 쓸 수 있다.

type Node = ref object case of IntLit: intVal: int of FloatLit: floatVal: float of StringLit: strVal: string of AddOp, SubOp: leftOp, rightOp: Node of IfStmt: condition, thenPart, elsePart: Node

이제 NodeKind 열거형과 kind 필드는 필요하지 않다. 분기 이름을 타입 선언 자체에서 정의한다. 내부적으로 합 타입은 숨겨진 판별자를 가진 case 객체이므로 메모리 배치와 성능은 이전과 같다. 다만 앞으로 패턴 매칭을 통해 실행 시 검사를 없앨 수 있다.

분기 이름은 해당 분기의 필드에 이름을 붙여 객체를 생성하는 데 사용한다.

let n = AddOp(leftOp: IntLit(intVal: 40), rightOp: IntLit(intVal: 2))

제네릭 합 타입도 지원한다. 인스턴스는 필드 값이나 기대 타입에서 추론된다.

type Opt[T] = object case of None: discard of Some: val: T let a = Some(val: "abc") # Opt[string] let b: Opt[int] = None() # the expected type selects the instance

필드에는 패턴 매칭을 통해서만 접근할 수 있으므로 잘못된 분기의 필드에 접근해 발생하는 전형적인 FieldDefect는 더 이상 일어나지 않는다. 필드 이름은 분기 이름 뒤에 지정한다.

proc eval(n: Node): int = case n of IntLit(v): v of AddOp(a, b): eval(a) + eval(b) of SubOp(a, b): eval(a) - eval(b) else: raiseAssert "not an arithmetic expression"

_는 필드를 건너뛴다. {AddOp, SubOp}(a, b)는 필드 구성이 같은 여러 분기를 함께 매칭한다. case는 모든 분기를 다루거나 else를 포함해야 한다. 매칭하는 식이 변경 가능한 위치라면 바인딩은 객체를 직접 가리키므로 대입할 수 있다.

type Shape = object case of Circle: radius: float of Rect: w, h: float var shapes = @[Circle(radius: 1.0), Rect(w: 1.0, h: 2.0)] for i in 0 ..< shapes.len: case shapes[i] of Circle(r): r = r * 2 # updates shapes[i].radius of Rect(w, _): w = 3.0

그렇지 않은 경우 필드는 값으로 바인딩된다. case 바깥에서 필드에 접근해야 한다면 {.cast(uncheckedAccess).}: echo n.intVal이라는 우회 방법을 쓸 수 있다. 기존 case 객체도 계속 작동하며 더 이상 사용되지 않는 기능으로 지정되지 않았다.

`owned` 타입 한정자

이 기능은 --experimental:ownedRefs로 활성화해야 한다. 님 팀은 이 기능을 사용하는 코드가 향후 다시 변경될 수 있다고 밝혔다.

owned ref T와 owned 클로저는 객체를 소유하는 고유한 연결을 보유한 위치를 표시한다. 이 조건은 정적으로 검사한다. 소유 위치는 복사할 수 없고 이동만 가능하며, 객체 생성처럼 새로 만들어진 값만 초기화에 사용할 수 있다. 같은 객체를 가리키는 일반 참조는 여러 개 존재할 수 있고 객체의 수명을 유지할 수 있다. 런타임 표현은 ref T와 같으므로 이 기능을 쓰는 코드와 쓰지 않는 코드는 서로 연동된다.

{.experimental: "ownedRefs".} type Node = ref object next: owned Node data: int

이 기능은 순환 참조 수집과 관련이 있다. owned 연결은 포리스트를 이루므로, 참조가 모두 owned 또는 .cursor인 타입은 순환의 일부가 될 수 없다. 따라서 ORC와 YRC에서 순환 참조 수집기 대상에 포함되지 않는다. 자세한 내용은 실험 기능 설명서를 참고하면 된다.

순환 모듈 의존성

님은 모듈끼리 서로 가져오기를 허용해 왔지만, 순환이 생기면 한 모듈은 가져오기 선언보다 먼저 나온 다른 모듈의 선언만 볼 수 있었다. 이 때문에 가져오기와 선언의 순서를 조정하거나 모든 타입을 types.nim 모듈로 옮겨야 했다.

가져오기 순환은 가져오기 방향 그래프를 일반 그래프로 바꾸므로 설계상 좋지 않은 경우가 많다. 그러나 때로는 순환이 자연스러운 구조이므로 컴파일러가 이를 막지 않도록 했다. 순환을 이루는 가져오기에 cyclic pragma를 붙일 수 있다.

# module a import b {.cyclic.} type A* = object b*: B # a type of `b`, even though `b` imports `a` proc isEven*(n: int): bool = n == 0 or isOdd(n - 1)

# module b import a {.cyclic.} type B* = object x*: int a*: ref A proc isOdd*(n: int): bool = n != 0 and isEven(n - 1)

이처럼 연결된 모듈들은 하나의 순환 그룹을 이루며 단계별로 검사된다. 먼저 모든 모듈의 가져오기를 검사하고, 이어 모든 타입 선언, 루틴 선언과 본문, 각 모듈의 나머지 부분을 처리한다. 따라서 서로 다른 모듈에 있는 타입이 서로를 참조할 수 있고, 선언 순서와 관계없이 모듈의 프로시저끼리 호출할 수 있다.

순환 양쪽의 가져오기에 모두 주석을 붙이는 것이 바람직하다. 주석이 없는 가져오기 순환도 이전처럼 작동하지만 사용 중단 예정(deprecated)이다. --warning:ImplicitCyclicImport:on으로 해당 사례를 찾을 수 있다. 자세한 규칙은 설명서에 나와 있다.

전체 변경 사항은 Nim 2.2.0과 2.4.0의 비교에서 확인할 수 있다.