예산 시스템: 탈러, 바이트 및 밀리초
요약
Neander 프로그램의 안정적인 실행을 위해 자원 소모를 엄격히 제한하는 '예산 시스템(budget system)'을 소개합니다. 이 시스템은 계산량, 메모리 등 6가지 차원에서 정적 제한과 런타임 예산을 설정하여 프로그램의 무한 루프나 자원 고갈을 방지합니다.
핵심 포인트
- 예산 시스템은 언어 정의의 필수적인 부분으로 설계됨
- 탈러(Thaler) 단위를 사용하여 계산 비용을 측정
- 정적 제한 초과 시 Flaw, 예산 초과 시 Abort 발생
- 메모리 할당은 상한선 초과 전 거부되어 안정성 확보
네안더(Neander) 프로그램은 결국 멈추는 것이 보장됩니다. 지난번에서 봤듯이 last time. 하지만 '결국 멈춘다'는 것은 프로그램이 호스트 애플리케이션 내부에서 실행되면서 자원을 소모하는 실제 시나리오에서는 충분하지 않습니다.
그래서 서브튜링(sub-Turing) 언어 위에 네안더는 **예산 시스템(budget system)**을 특징으로 하는데, 이는 프로그램에 여섯 가지 다른 차원에서 엄격한 상한선을 설정합니다. 이 중 세 가지는 문자 그대로의 예산이며, 실행 중인 프로그램이 소비할 수 있는 양을 제한합니다. 나머지 세 가지는 프로그램 자체의 형태에 대한 정적 제한(static caps)으로, 아예 실행하도록 허용되기 전에 검사됩니다. 여섯 가지 모두 런타임(runtime)에 의해 설정되고 강제되며, 에이전트나 프로그램에 의해 선언되거나 요청되거나 협상되지 않습니다. 에이전트는 빈 프로그램을 제출함으로써 (어차피 cold start 중에 할 일) 런타임으로부터 현재의 예산 한도를 얻을 수 있습니다. Reference Response의 meta 블록에 런타임의 예산 구성이 포함되어 있습니다.
예산 시스템은 단순히 특정 런타임 구현의 부가 기능(bolted-on feature)이 아니라, _언어 정의(language definition)_의 필수적인 부분이라는 점을 강조할 가치가 있습니다.
런타임별로 특정한 것은 이 상한선들이 제출된 프로그램에 어떻게 그리고 어느 정도 양으로 할당되는지에 대한 세부 사항입니다. 예를 들어, Grotto 참조 구현은 현재 시작 시점에 정적 예산 구성을 요구하며 이를 모든 프로그램 제출에 적용합니다.
하지만 규범적인(normative) 것은 그 한계를 초과했을 때의 결과입니다. 어느 쪽이든 프로그램은 Failure 결과를 생성하지만, 두 가지 종류의 제한은 다르게 실패합니다. 정적 제한을 초과하는 것은 _Flaw_이며, 실행 전에 프로그램이 완전히 거부됩니다. 예산을 초과 소비하는 것은 _Abort_이며, 실행이 즉시 중단됩니다. 이 규칙에는 정확히 하나의 예외가 있으며, 곧 설명하겠습니다.
계산(Computation)
계산 (Computation)
**계산(Computation)**은 탈러(Thalers)로 측정됩니다. 탈러는 오래된 은화의 이름을 딴, Neander가 정의한 컴퓨팅 작업 단위입니다. 실제로 무언가를 계산하는 모든 연산에는 하나의 탈러 비용이 발생합니다. 이름 바인딩(binding a name), 값 반환(returning a value), 리터럴(literal)처럼 계산을 수행하지 않는 것은 무료입니다. Neander 사양에는 완전한 _탈러 비용표(Thaler Cost Table)_가 포함되어 있습니다:
| 연산 (Operation) | 비용 (Cost) |
|---|---|
산술 연산자 (+, -, *, /, %) | 1 탈러 |
| ... |
메모리 (Memory)
**메모리(Memory)**는 온전한 킬로바이트(kilobytes) 단위로 측정되며, 메모리 예산은 최대 할당량에 대한 상한선입니다. API 반환 값, 대규모 리스트 및 맵, 깊은 레코드 구조 등 모든 것이 비용 청구 대상이며, 이 상한선을 초과하는 할당은 발생하기 전에 거부됩니다.
언어에 의해 표준화되는 계산 비용과는 달리, 메모리 비용은 구현체(implementation)에 따라 크게 달라집니다. Neander 설계는 각 런타임 구현체가 자체 기반 플랫폼의 메모리 할당 특성을 고려할 수 있는 충분한 여유 공간을 제공합니다. 표준화된 것은 숫자 주위에 대한 계약입니다: 즉, 이 상한선이 최대 할당량을 제한하고, 이를 초과하는 것이 할당이 발생하기 전에 프로그램을 중단시키며, 보고되는 수치가 온전한 킬로바이트라는 것입니다. 탈러는 규격에 맞는 다양한 런타임에서 이식 가능합니다. 킬로바이트는 그렇지 않습니다.
시간 (Time)
**시간(Time)**은 벽시계 밀리초(wall-clock milliseconds) 단위로 측정되며, 두 가지 별도의 지속 시간 예산이 존재합니다. 첫 번째는 모든 API 호출을 포함하여 전체 프로그램 실행 지속 시간을 제한합니다. 두 번째는 개별 API 호출의 지속 시간을 제한하므로, 느린 하나의 API가 혼자서 전체 지속 시간 예산을 고갈시키는 것을 막아줍니다.
콜별 타임아웃(per-call timeout)은 위에서 언급된 예외 사항입니다. 이를 초과한다고 해서 프로그램이 멈추지는 않습니다. 대신, API 호출에서 복구 가능한 런타임 오류(recoverable runtime error)를 반환하며 실행을 계속합니다. 그 이유는 프로그램의 다른 예산들이 여전히 제한 범위 내에 있을 수 있기 때문입니다. 따라서 런타임은 이를 일반적인 실패한 호출로 처리하여 프로그램이 스스로 결정하도록 합니다.
크기 (Size)
**크기(Size)**는 바이트 단위로 측정되며, 제출되는 프로그램의 길이를 UTF-8 인코딩된 소스 코드를 기준으로 제한합니다. 표준은 이 제한이 어떠한 소스 코드 전처리(lexing, parsing 등)가 시작되기 전에 확인해야 한다고 규정합니다. 이러한 전처리는 런타임에 실제 작업을 발생시키지만, 계산량(computation), 메모리, 시간 예산으로는 처리되지 않습니다. 이 예산들은 프로그램 실행에만 적용됩니다. 크기 제한은 그 이전에 발생하는 모든 것을 경계하는 역할을 합니다.
깊이 (Depth)
**깊이(Depth)**는 단위가 없는 양의 정수이며, 프로그램의 추상 구문 트리에서 가장 긴 루트-대-리프 경로를 제한합니다. 이는 파싱, 타입 검사 및 실행 중 발생하는 스택 오버플로우(stack overflows)로부터 보호하며, 이는 프로그램 크기와는 독립적인 별도의 공격 벡터입니다. 즉, 짧은 프로그램이라도 매우 깊게 중첩될 수 있기 때문입니다.
반복 (Repeat)
**반복(Repeat)**은 단위가 없는 양의 정수이며, 모든 repeat 루프가 선언해야 하는 limit 리터럴을 제한합니다. 여기에는 두 가지 별도의 제한이 작용하므로 구별하는 것이 중요합니다. 첫 번째는 repeat 구문 자체의 일부로서 실제 반복 횟수에 대한 런타임 전제 조건(precondition)으로 작용합니다:
repeat pageCount limit 20 as i {
call orders.list(offset: i * 100, limit: 100)
}
만약 실행 중에 pageCount가 20보다 큰 것으로 판명되면, 런타임 오류가 발생하여 루프 자체가 시작되지 않게 합니다. 이 구문은 에이전트에게 기대를 표현하고 그 기대가 충족되지 않으면 루프가 실행되지 않도록 보장할 기회를 제공합니다.
반면에 예산 시스템의 반복 제한(repeat limit)은 리터럴 자체에 대한 절대적인 상한선이며, 이는 _validation time_에 확인됩니다. 따라서 limit 1000000000는 유효한 구문이고 그 자체로는 허용되지만, 설정된 최대치를 훨씬 초과할 가능성이 높으며 프로그램은 실행을 시작하지 못합니다. 이는 에이전트가 터무니없이 높은 숫자를 제한으로 선언하여 루프의 경계를 우회하는 것을 방지합니다.
Grotto의 예산 시스템 접근 방식
Grotto는 디스패처-워커 패턴(dispatcher-worker pattern)을 구현하는데, 프로그램 제출은 호스트 스레드에서 실행되는 디스패처가 처리하고 실제 프로그램 자체는 격리된 워커(isolated worker)가 처리합니다.
디스패처는 수신 시 프로그램 크기 제한과 격리된 워커의 타임아웃을 통한 전체 프로그램 실행 시간 제한을 강제하며, 이후 워커를 종료시킵니다.
워커가 나머지 작업을 수행합니다. 파서(Parser)와 검증기(validator)가 깊이 및 반복 제한을 강제하고, 계산량(computation)과 메모리 예산은 모든 연산 및 할당 시 협력적으로 확인되며, 타임아웃은 모든 인-스레드 API 호출을 보호합니다.
구분된 점에 주목하세요. 탈러(Thalers)와 메모리는 인터프리터가 작업을 수행하고 진행하면서 확인할 수 있다고 신뢰할 수 있기 때문에 협력적으로 계산될 수 있습니다. 실행 시간(Duration)은 같은 메커니즘에 맡길 수 없습니다. 체크포인트로 돌아오는 것을 멈추는 프로그램은 자체 시계를 확인하는 것을 멈춥니다. Grotto 역시 워커 내부에서 클럭을 샘플링하지만, 이에 의존하지는 않습니다. 실제로 구속력을 갖는 마감일(deadline)은 디스패처 스레드에 위치하며, 이는 예산 책정 문제라기보다는 격리(isolation)의 문제입니다.
Grotto의 다음 내용
이것으로 Neander 예산 시스템 개요를 마치고, Grotto에 대한 간략한 방문을 합니다. 어차피 여기에 왔으니 잠시 머물며 내부 작동 방식을 더 자세히 살펴보는 것이 합리적입니다. 이를 통해 Grotto가 임베딩 호스트 애플리케이션을 신뢰할 수 없는 코드의 실행으로부터 어떻게 격리하는지 더 잘 이해할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기