보물찾기 엔진이 깨진 약속의 캐시가 될 때
요약
Hytale의 Lua 기반 보물찾기 엔진 Veltrix에서 발생하는 설정 오류와 지연 시간 문제를 해결하기 위한 기술적 여정을 다룹니다. 단순 JSON 스키마 검증과 C++ FFI 방식의 한계를 극복하고, 컴파일 타임 파이프라인을 통해 성능과 안정성을 모두 확보한 아키텍처 결정 과정을 설명합니다.
핵심 포인트
- 설정 오류로 인한 헌트 실패율 43% 발생
- JSON 스키마 검증기의 높은 지연 시간 문제 확인
- C++ FFI를 통한 Lua 측 검증 시 세그멘테이션 오류 발생
- Go 기반 컴파일 타임 파이프라인으로 5ms 미만 검증 달성
우리가 실제로 해결하려 했던 문제
Hytale 운영자들은 게임 내에서 막힌 것이 아니었습니다. 그들은 커뮤니티 크리에이터들이 역동적인 스캐빈저 헌트 (scavenger hunts)를 스크립트할 수 있게 해주는 우리의 Lua 기반 보물찾기 엔진인 Veltrix에서 막혀 있었습니다. 모든 설정 (configuration) 오류는 지원 티켓의 증가로 직결되었고, 모든 실패한 헌트는 재방문 플레이어의 감소를 의미했습니다. 진짜 요구사항은 "헌트 스크립트를 어떻게 작성하나요?"가 아니라, "JSON 스키마 (JSON schema)를 건드릴 때마다 왜 헌트가 소리 없이 실패하는 것을 막을 수 없나요?"였습니다.
우리의 텔레메트리 (telemetry) 데이터에 따르면, 헌트 실패의 43%는 로직 오류가 아닌 설정 오류였습니다. 더 나쁜 것은, 기본 에러 메시지가 마치 로르샤흐 테스트 (Rorschach test)처럼 보였다는 점입니다. Veltrix는 "설정이 유효하지 않아 헌트를 실행할 수 없습니다"라는 메시지를 반환했지만, 로그는 어떤 필드인지 또는 왜 그런지에 대해 전혀 알려주지 않았습니다. 운영자들은 눈을 가린 채 편집하고, 다시 로드하고, 재시도했지만 여전히 도움이 되지 않는 동일한 응답만 받았습니다. 우리는 잘못된 설정이 Lua VM에 도달하기 전에 잡아낼 수 있는 관측성 (observability)이 필요했습니다.
우리가 처음에 시도했던 것 (그리고 실패한 이유)
첫 번째 시도: 입구에 JSON 스키마 검증기 (JSON Schema validator)를 배치하는 것이었습니다. 우리는 Node.js에 ajv를 통합하고 헌트 정의 위에 200줄짜리 스키마를 덧씌웠습니다. 검증기는 설정 오류의 60%를 잡아냈지만, P50 경로에서 80ms의 추가 지연 시간 (latency)을, P99 경로에서는 400ms의 지연 시간을 발생시켰습니다. 이는 우리의 지연 시간 허용 범위 (latency envelope)를 위반하는 것이었습니다. 더 중요한 점은, 검증기가 표면적인 구문 (syntax)만 지적했다는 것입니다. 헌트 타임아웃 (timeout)이 30분인데 타이머 값이 20시간으로 설정되었을 때나, 필수 리소스가 에셋 파이프라인 (asset pipeline)에 로드되지 않았을 때를 알려주지는 못했습니다.
두 번째 시도: 우리는 C++로 작성되어 FFI (Foreign Function Interface)를 통해 노출된 경량화된 규칙 엔진(rules engine)을 사용하여 Lua 측 검증 (Lua-side validation)을 시도했습니다. 이를 통해 지연 시간 (latency) 손실을 10ms로 줄였으나, 즉시 새로운 실패 모드에 직면했습니다. nil 키가 포함된 테이블을 전달하면 Lua VM (Virtual Machine)에서 세그멘테이션 오류 (segfault)가 발생했으며, 스택 트레이스 (stack trace)는 알아볼 수 없는 상태였습니다. 우리는 JavaScript에서는 완벽하게 유효하지만 Lua 테이블에서는 불법인 JSON 키로 인해 발생하는 세그멘테이션 오류를 디버깅하는 데 두 번의 스프린트 (sprint)를 소비했습니다. 운영자들은 우리가 VM을 패치하는 속도보다 더 빠르게 신뢰를 잃어갔습니다.
아키텍처 결정 (The Architecture Decision)
우리는 마침내 JSON 스키마 (schema)를 일련의 Lua 어노테이션 (annotations)으로 변환하는 컴파일 타임 파이프라인 (compile-time pipeline)으로 결론을 내렸습니다. 빌드 타임에 우리는 vxschema라고 불리는 Go 데몬 (daemon)을 실행하며, 이 데몬은 보물찾기 로직 (hunt logic)과 검증 규칙 (validation rules)을 Lua 함수로 포함하는 .lua 파일을 생성합니다. 이제 보물찾기 서비스 (hunt service)는 어노테이션된 보물찾기를 로드하고, 5ms 미만으로 프로세스 내에서 검증기 (validator)를 실행하며, '유효한 보물찾기 (Valid hunt)' 또는 '3단계에서 필수 필드 difficulty 누락 (Missing required field difficulty in step 3)'과 같은 상세한 에러 경로 (error path)를 반환합니다.
핵심적인 트레이드오프 (trade-off): 우리는 런타임 (runtime) 중에 스키마를 동적으로 재로드 (dynamic reload)하는 기능을 포기했습니다. 대신, S3 버킷에서 새로운 .lua 아티팩트 (artifact)를 감시하고 100ms의 조정 루프 (reconciliation loop)를 통해 보물찾기 정의 자체를 핫 리로드 (hot-reload)합니다. 이 트레이드오프의 대가로 배포 (deploy) 없이 스키마 변경 사항을 푸시할 수 있는 능력은 상실했지만, 결정론적인 실패 모드 (deterministic failure modes)와 밀리초 단위의 검증 성능을 얻었습니다. 또한 CI (Continuous Integration)에서 vxschema를 실행하고 스키마를 위반하는 모든 PR (Pull Request)을 차단하는 프리 커밋 훅 (pre-commit hook)을 내장했습니다. 이 단 하나의 훅 덕분에 첫 주 만에 지원 티켓 (support tickets)이 28% 감소했습니다.
이후의 수치들 (What The Numbers Said After)
어노테이션된 Lua 파이프라인을 배포한 후, P99 지연 시간 (latency)은 1.8초에서 160ms로 떨어져 우리의 목표치를 달성했습니다. 잘못된 보물찾기 실패 (False-positive hunt failures)는 48% 감소했으며, 잘못 설정된 보물찾기에 대한 평균 복구 시간 (mean time to recovery)은 15분에서 2분으로 단축되었습니다. 가장 결정적인 지표는 보물찾기 제작자들의 평균 세션 길이 (average session length)가 8분에서 14분으로 증가했다는 점인데, 이는 그들이 세 번의 편집마다 빨간색 에러 창을 마주치는 일이 사라졌기 때문입니다.
여전히 꼬리 문제(tail)가 남아 있습니다. 운영자가 JSON 필드 내부에 Lua 스니펫(snippets)을 삽입할 때, 검증기(validator)는 해당 문자열 내부로 내려갈 수 없습니다. 우리는 경고를 로그로 남기고 계속 진행하지만, 실제로 이러한 탐색(hunts)은 약 3%의 확률로 소리 없이 실패합니다. 다음 단계는 검증 시점에 WASM 샌드박스(sandbox)를 사용하여 임베디드된 Lua를 파싱하고, 추가적인 CPU 사이클에 대해 운영자에게 비용을 부과하는 것입니다. 공짜는 아니지만, 새벽 3시에 또 다른 지원 워룸(support war room)을 여는 것보다는 저렴합니다.
내가 다르게 했을 방식
우리는 컴파일러(compiler)부터 시작했어야 했습니다. 구성 파일(configuration file) 내부에 동적 언어(dynamic language)를 삽입할 때는, 사실상 자신도 모르는 사이에 컴파일러를 작성하고 있는 것과 같습니다. 스키마 언어(schema language)만으로는 충분하지 않습니다. JSON과 Lua 사이의 경계를 넘나들며 유지될 수 있는 타입이 지정된 중간 표현(typed intermediate representation)이 필요합니다. vxschema 도구는 결국 900줄의 Go 코드가 되었고, 우리는 이를 두 번이나 다시 작성했습니다. 다음번에는 그 컴파일러를 먼저 프로토타이핑하고, JSON 스키마를 신뢰할 수 있는 원천(source of truth)이 아닌 생성된 결과물(artifact)로 취급할 것입니다.
또한, 운영자가 저장하기 전에 검증 비용을 노출시켜야 합니다. 현재는 저장(Save)을 누르고 다시 로드한 후에야 경고가 나타납니다. 어노테이션(annotation) 규칙을 위반할 때 필드를 빨간색으로 표시하는 실시간 디프(live diff) 위젯을 제공하고, 실행 중인 지연 시간 예산(latency budget)을 보여줌으로써 각 변경 사항의 비용을 알 수 있게 할 수 있습니다. 그렇게 하면 부담이 사후 디버깅(reactive debugging)에서 사전 작성(proactive authoring)으로 전환되어, 우리와 제작자 모두에게 더 저렴해질 것입니다.
마지막으로, 우리는 Lua가 구성 언어(configuration language)인 척하는 것을 멈춰야 합니다. 그것은 스크립팅 언어(scripting language)이며, 우리는 그것을 타입 시스템(type system)으로 사용하고 있습니다. Lua를 있는 그대로 취급하고 컴파일 비용을 지불하거나, 아니면 Protocol Buffers와 같은 타입이 지정된 스키마 언어를 채택하여 런타임(runtime)을 위한 Lua로 컴파일해야 합니다. 동적인 Lua 편집이라는 연극(theatre)이 우리에게 실제 지연 시간과 지원 사이클(support cycles)이라는 비용을 치르게 하고 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기