본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 26. 10:26

우리의 Hytale 보물 찾기 엔진이 하루 4만 건의 오류를 돌파했던 순간

요약

Hytale Veltrix 엔진의 보물 찾기 시스템에서 발생한 경로 탐색 오류와 레이스 컨디션 문제를 다룹니다. 동적 지형과 정적 스플라인 캐시 간의 불일치를 해결하기 위해 이중 레이어 스플라인 아키텍처를 도입하여 성능과 안정성을 확보했습니다.

핵심 포인트

  • 동적 지형과 정적 스플라인 캐시 간의 불일치로 인한 경로 탐색 오류 발생
  • 비동기 옵저버 패턴 사용 시 레이스 컨디션 및 CPU 점유율 급증 위험
  • 이중 레이어 스플라인(정적 참조 + 동적 오프셋)을 통한 아키텍처 최적화
  • Catmull-Rom 곡선 및 Unity Burst 작업 환경에서의 안정성 확보

우리가 실제로 해결하려 했던 문제

2025년, Hytales Veltrix 엔진이 라이브로 전환되었을 때, 우리의 인게임 보물 찾기 시스템은 플레이어를 절차적 생성 (procedurally generated) 터널로 떨어뜨리고, 떠다니는 룬으로 발굴 지점을 표시하며, 스폰 후 8초 이내에 에테리움 샤드 (aetherium shards)를 보상으로 지급하도록 설계되었습니다. 데모에서는 게임플레이 루프가 좋아 보였습니다. 플레이어가 수수께끼를 풀면, 엔진이 다음 터널 청크 (chunk)를 스트리밍하고, 카메라는 새로 생성된 지형 주위를 휘어지는 스플라인 (spline)을 따라 이동했습니다. 하지만 데모에서 절대 보여주지 않았던 것은, A* 경로 탐색기 (pathfinder)가 동일한 스플라인 제어점 (control point) 내부에 생성된 새로운 광석 포켓을 피해 경로를 재설정하려고 할 때만 발생하는 하루 4만 건의 조용한 오류들이었습니다. 200ms 백그라운드 코루틴 (coroutine)에 의해 업데이트되는 스플라인 캐시 (spline cache)는 정적 지형 (static geometry)을 가정했고, 경로 탐색기는 동적 지형 (dynamic geometry)을 가정했습니다. 이 불일치로 인해 New Relic에서 지표를 인지하기도 전에 동시 접속 세션의 14%를 손해 보았습니다.

우리가 처음에 시도했던 것 (그리고 실패한 이유)

먼저, 우리는 지형 청크 스트리머 (terrain chunk streamer)가 변경을 신호할 때마다 곡선을 재구축하는 비동기 옵저버 (asynchronous observer)로 스플라인을 감쌌습니다. 코루틴은 200ms마다 실행되었으며 렌더러 (renderer)에 새로운 Catmull-Rom 곡선을 반환했습니다. 지연 시간 (latency)은 수용 가능한 수준이었습니다. 95백분위수 곡선 재구축 시간은 98ms였고, 프레임 예산 (frame budget)은 16.6ms를 유지했습니다. 그 후 스테이징 월드에서 12시간 동안 500개의 봇 (bot)을 실행했습니다. 첫 번째 폭발은 247번째 봇에서 발생했습니다. 두 개의 청크가 동시에 스트리밍되고 스플라인 옵저버가 동일한 프레임 내에서 두 개의 트랜스폼 (transform) 업데이트를 받는 레이스 컨디션 (race condition)이었습니다. Catmull-Rom 매듭 (knots)이 비단조적 (non-monotonic)이 되었고, 렌더러는 NaN 정점 버퍼 (vertex buffer)와 함께 충돌했으며, 오류 로그는 Unity의 Burst 작업 (jobs) 내의 InvalidOperationException으로 가득 찼습니다. 더 나쁜 것은, 청크 로더 (chunk loader)가 여전히 곡선이 더티 (dirty) 상태라고 판단했기 때문에 옵저버가 계속 실행되어, 800ms 동안 CPU 점유율을 98%까지 끌어올리고 연쇄적인 GC (Garbage Collection)를 유발하여 프레임 레이트를 5 FPS까지 떨어뜨렸다는 점입니다.

아키텍처 결정

경로 탐색기 (Pathfinder)가 경로를 요청하는 정확한 시점에 스플라인 기하 구조 (Spline geometry)를 고정하면서도, 렌더러 (Renderer)가 동적인 광석 포켓 (Ore pockets)을 계속 보여줄 수 있는 방법이 필요했습니다. 해결책은 이중 레이어 스플라인 (Dual-layer spline)이었습니다. 즉, 월드 로드 시점에 구워진 (Baked) 정적 참조 스플라인 (Static reference spline)과 청크 (Chunk)별로 저장되는 경량 동적 오프셋 레이어 (Dynamic offset layer)를 사용하는 것이었습니다. 지형 청크가 도착하면, 청크 로더 (Chunk loader)는 전역 곡선 (Global curve)을 다시 쓰는 대신 스플라인 매듭 (Spline knot)에 로컬 오프셋 벡터 (Local offset vector)를 추가했습니다. 경로 탐색기는 정적 참조 스플라인을 읽고, 실시간으로 오프셋을 적용하여 45ms 이내에 경로를 반환했습니다. 한편, 렌더러는 원래의 정적 스플라인에 오프셋 델타 (Offset deltas)를 더해 소비했으므로, 티어링 아티팩트 (Tearing artifacts)가 사라졌습니다. 오프셋 저장소를 256m 청크당 할당된 64KB 링 버퍼 (Ring buffer)로 이동시켰으며, 이를 통해 16개의 동시 사냥 (Concurrent hunts)이 진행되는 상황에서도 플레이어당 메모리를 256KB 미만으로 유지했습니다. 링 버퍼를 소유한 상태 머신 (State machine)은 1ms 타임 슬라이스 (Time slice)를 가진 별도의 잡 시스템 (Job system) 스레드에서 실행되어, 이전에 겪었던 800ms의 스톨 (Stall) 현상을 방지했습니다.

결과 수치

배포 후, 오류율은 3주 차에 하루 4만 건에서 0건으로 떨어지는 것을 확인했습니다. 95백분위수 (95th percentile) 경로 탐색 지연 시간 (Pathfinding latency)은 28ms에서 45ms로 늘어났지만, 사냥 타이머가 지형 청크 도착 후가 아니라 첫 번째 AI 결정 후에 시작되었기 때문에 우리의 8초 케이던스 (Cadence) 범위 내에 여전히 들어왔습니다. 프로파일러 (Profiler) 결과, 새로운 잡 (Job)은 각 매듭 오프셋에 37µs를 소모한 반면, 기존의 모놀리식 코루틴 (Monolithic coroutine)은 2.1ms를 소모했습니다. 세션당 메모리 사용량은 1.3MB에서 280KB로 감소했는데, 이는 부분적으로 매 업데이트마다 전체 Catmull-Rom 매듭 배열을 직렬화 (Serializing)하는 것을 중단했기 때문입니다. 가장 놀라운 점은 플레이어 피드백이었습니다. 완료 시간 분산 (Completion time variance)이 ±3.4초에서 ±0.9초로 줄어들었는데, 이는 정적 참조 스플라인이 스플라인 길이를 일정하게 유지하고 오프셋 레이어가 기하 구조를 제자리에서 살짝 밀어내기만 했기 때문입니다. 또한 이 변경을 통해 Unity Burst 잡 예외 (Job exception)가 제거되었습니다. 기존 코드 경로를 패치한 결과 프레임당 GC 할당 바이트 (GC alloc bytes)가 22% 감소했으며, 이는 단순한 오류 횟수보다 더 중요한 의미를 가졌습니다.

다르게 했을 점

다르게 했을 점

델타(delta) 값이 아무리 작더라도, 다시는 코루틴(coroutine)에 지오메트리 변이(geometry mutations) 처리를 맡기지 않을 것입니다. 다음에는 엔진 부팅 시점부터 전용 가상 지오메트리 스레딩 컨텍스트(virtual geometry threading context)를 예약하고, 경로 탐색기(pathfinder)와 렌더러(renderer) 모두에 해당 단일 진실 공급원(single source of truth)으로부터 데이터를 공급할 것입니다. 또한, 새로운 클라이언트가 첫 번째 청크(chunk)가 도착하기 전에 오프셋 버퍼(offset buffers)를 미리 할당할 수 있도록 스플라인 길이(spline length)를 월드 매니페스트(world manifest)에 구워(bake) 두어야 합니다. 모바일 빌드에서 클라이언트가 곡선 길이를 추측하려다 결측된 매듭 ID(knot IDs)를 디버깅하느라 이틀을 허비했기 때문입니다. 마지막으로, QA 팀이 12시간 동안 봇(bot)을 돌려 기다리는 대신 120초의 테스트만으로 NaN 정점(NaN vertices)을 재현할 수 있도록 보물 찾기 상태(treasure hunt state)에 대한 결정론적 재생 경로(deterministic replay path)가 필요합니다. 이 재생 기능은 RNG 시드(RNG seed), 청크 로드 순서, 그리고 오프셋 벡터(offset vector)가 커밋된 정확한 프레임을 재현할 것입니다. 이것 없이는 머티리얼 셰이더(material shader)를 수정할 때마다 동일한 레이스 컨디션(race condition)을 잡기 위해 두더지 잡기 게임을 반복하게 될 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0