28년 된 게임에 AI를 연결하는 법 — Anima의 기술적 도전

28년 된 게임에 AI를 연결하는 법 — Anima의 기술적 도전

이전 글에서 Anima 프로젝트의 동기와 방향을 이야기했다. 이번에는 기술적인 이야기를 해보려 한다. 28년 된 MMORPG에 AI 에이전트를 살게 하려면 실제로 어떤 문제를 풀어야 하는지.

결론부터 말하면, 어려운 건 AI가 아니라 게임과의 접점이었다.

4개의 층

Anima 에이전트의 내부 구조는 4개 층으로 나뉜다. 아래부터 위로.

연결(Connection) 층은 UO 서버와 TCP로 통신한다. 울티마 온라인은 자체 바이너리 프로토콜을 사용한다. 빅 엔디안, 가변 길이 패킷, 서버→클라이언트 방향은 허프만 압축.

로그인만 해도 6단계를 거친다 — 시드 전송, 계정 인증, 서버 선택, 리다이렉트, 게임 로그인, 캐릭터 선택. 이 층의 역할은 이 모든 바이너리 데이터를 파싱해서 위층이 이해할 수 있는 이벤트로 바꾸는 것이다.

인지(Perception) 층은 서버가 보내는 50종 이상의 패킷을 해석해서 월드 모델을 유지한다. 주변의 몬스터와 NPC, 바닥의 아이템, 내 체력과 마나, 스킬 수치, 장비 상태, 인벤토리, 저널 메시지 — 이 모든 것을 실시간으로 추적한다. 에이전트가 "세계를 보는 눈"이다.

두뇌(Brain) 층은 인지 정보를 바탕으로 다음에 무엇을 할지 결정한다. 현재는 우선순위 기반 플래너를 사용한다. 자세한 구조는 뒤에서 다룬다.

행동(Action) 층은 결정을 실제 게임 행동으로 실행한다. A* 경로 탐색으로 이동하고, 패킷을 조합해서 아이템을 사용하고, Gump(게임 내 UI 메뉴)에 응답한다.

각 층은 독립적이다. 연결 층은 결정에 관심 없고, 두뇌 층은 패킷 포맷을 모른다. 이 분리 덕분에 각 층을 독립적으로 테스트할 수 있고, 나중에 두뇌를 통째로 교체해도 나머지 층에 영향이 없다.

곡괭이 한 번에 패킷 다섯 개

에이전트가 광석을 캐는 과정을 패킷 수준에서 보면 이렇다.

  1. 클라이언트가 0x06 DoubleClick 패킷을 보낸다. 곡괭이의 시리얼 번호가 4바이트로 들어간다.
  2. 서버가 타겟 커서 모드로 진입한다.
  3. 클라이언트가 0x6C TargetResponse 패킷으로 바위 타일의 좌표(x, y, z)를 보낸다.
  4. 서버가 채굴 애니메이션을 실행한다. 3초 기다려야 한다.
  5. 서버가 0xAE UnicodeSpeech 패킷으로 결과를 알려준다. "You dig some iron ore" 또는 "There is no metal here to mine."

제련은 또 다르다. 광석을 더블클릭하고, 타겟 커서로 용광로 타일을 지정하고, 2초 기다린다. 제작은 도구를 더블클릭하면 0xB0 OpenGump 패킷으로 제작 UI가 열리고, 레이아웃을 파싱해서 버튼 ID를 계산한 뒤 0xB1 GumpResponse로 응답한다. 버튼 ID 공식은 1 + type + (index * 7) — type은 제작 카테고리, index는 해당 카테고리 내의 아이템 순번이다. ServUO 서버의 내부 구조를 역추적해서 알아냈다.

이 모든 과정에서 핵심은 타이밍이다. UO 서버의 틱 레이트는 약 250ms. 패킷을 너무 빨리 보내면 서버가 이전 요청을 아직 처리 중인데 다음 요청이 도착한다. 너무 느리면 에이전트가 멍하니 서 있는 시간이 길어진다.

기존 UO 봇 프레임워크들의 해법은 단순했다. Razor Enhanced는 패킷 사이에 350~650ms 딜레이를 강제한다. ClassicAssist는 서버 응답이 올 때까지 다음 패킷을 보내지 않는 글로벌 락을 건다. 현재 Anima는 asyncio.sleep(0.3)을 쓰고 있는데, 이게 문제의 원인이 되고 있다.

서버와 클라이언트가 다른 세상을 보는 문제

Anima 개발에서 가장 골치 아팠던 문제는 상태 동기화다.

에이전트의 월드 모델과 서버의 실제 상태가 어긋나는 것이다. 서버에서 아이템이 사라졌는데 클라이언트는 여전히 있다고 생각한다. NPC가 이동했는데 에이전트는 이전 위치로 찾아간다. 배낭에 광석이 30개인데 50개라고 착각하고 제작을 시도한다.

구체적인 시나리오를 보자.

t=0ms:   에이전트가 광석을 더블클릭해서 제련 요청
t=200ms: 서버가 처리 완료, 광석이 주괴로 변환됨
t=150ms: 에이전트가 이미 다음 광석에 더블클릭 (서버 응답 전)
t=400ms: 서버가 두 번째 요청을 받음 — 하지만 해당 광석은 이미 없음
         → 제련 실패. 에이전트는 "왜 안 되지?" 상태

패킷 간격이 서버 틱(약 250ms)보다 짧으면 이런 일이 벌어진다. 클라이언트가 보는 세상과 서버의 세상이 수백 밀리초 단위로 어긋나기 시작한다. 이건 버그가 아니다. 네트워크 게임의 본질적인 문제다.

해결책은 세 가지를 조합하는 것이다. 첫째, 패킷 최소 간격을 0.4초로 올린다 — 서버 틱 2개분의 여유. 둘째, Gump 응답을 고정 sleep이 아니라 이벤트 기반으로 바꾼다 — 서버가 Gump를 열어줄 때까지 기다리되, 타임아웃을 건다. 셋째, 5분마다 배낭을 다시 열어서 인벤토리를 강제 동기화한다. EasyUO 봇이 수십 년간 써온 검증된 방법이다.

의사결정의 90-8-2 법칙

에이전트의 모든 결정을 LLM에 맡기면 어떻게 될까. 답은 간단하다. 느려서 못 쓴다.

에이전트 하나가 시간당 약 100개의 결정을 내린다. 50명의 에이전트라면 시간당 5,000개. 모든 결정에 LLM을 쓰면 초당 1.4개의 LLM 호출이 필요한데, 3B 모델도 응답에 100ms는 걸린다. 에이전트는 결정을 기다리느라 멈춰 서고, 게임 속에서 멍하니 서 있는 NPC처럼 보일 것이다.

Anima는 90-8-2 규칙을 쓴다.

90%는 규칙 기반이다. 체력이 30% 아래면 도망치거나 포션을 먹는다. 무게가 85%를 넘으면 제련하러 간다. 광석이 있으면 제련을 우선한다. 도구가 없으면 만든다. 이런 판단에는 0ms, 비용 0이다. 코드에서 보면 이렇다.

async def tick(ctx):
    # 우선순위 1: 생존
    if ctx.self_state.hits < hits_max * 0.3:
        return flee_or_heal()
    
    # 우선순위 2: 무게 관리
    if get_weight(ctx) > 0.85:
        return smelt_ore()
    
    # 우선순위 3~7: 절차별 실행
    for proc in [craft, sell, bank, mine]:
        if proc.can_start(ctx):
            return await proc.run(ctx)

8%는 작은 로컬 LLM이 처리한다. 3~4B 파라미터의 경량 모델(gemma3:4b 등)을 Ollama로 돌린다. 응답 시간 약 100ms, API 비용 0. "이 아이템을 주울까 말까?", "몬스터가 나타났는데 도망칠까?" 같은 간단한 판단.

2%만 큰 LLM이 맡는다. 8~12B 모델로 응답에 1~3초. 3턴 이상의 대화, 길드 가입 제안에 대한 결정, 장기 전략 변경 같은 드문 결정이다.

왜 이렇게 나누는가. 게임 내 대부분의 결정은 반복적이다. 광석을 캐고, 제련하고, 만들고, 파는 것. 이 루프에 LLM이 필요한 순간은 거의 없다. LLM이 빛나는 건 예외 상황 — 예상치 못한 대화, 갈등 상황, 전략적 결정 — 이다. 90%의 비용을 아끼면서 그 8%와 2%에서 진짜 흥미로운 행동이 나온다.

왜 강화학습이 아니라 규칙인가

"AI 에이전트"라고 하면 강화학습(RL)부터 떠올리기 쉽다. Q-learning으로 최적의 행동을 학습하게 하면 되지 않을까?

현실적인 이유로 규칙을 먼저 선택했다.

학습 속도 문제. Q-learning이 수렴하려면 수천 번의 상태 전이가 필요하다. UO에서 채굴 한 번에 3초. 기본적인 "광석 캐기"를 학습하는 데만 몇 주가 걸린다. 규칙 기반이면 작성 즉시 동작한다.

보상 신호 문제. RL에는 명확한 보상 신호가 필요하다. UO의 보상은 지연되고 희소하다. 광석을 캐고 → 제련하고 → 제작하고 → 팔아야 비로소 골드라는 보상이 온다. 몇 시간에 걸친 과정에서 어떤 행동이 보상에 기여했는지 귀인하기 어렵다.

탐색 안전성. Q-learning은 초반에 랜덤 탐색을 한다. 아무 곳이나 클릭하고, 이상한 방향으로 걸어가고, 의미 없는 행동을 반복한다. 에이전트 10명이 동시에 랜덤 탐색을 하면 서버에 부하가 걸린다. 규칙 기반이면 항상 유효한 행동만 실행한다.

해석 가능성. 에이전트가 이상하게 행동할 때 Q-테이블을 보고 원인을 찾는 건 거의 불가능하다. "왜 광산 대신 주막에 갔지?"에 대한 답이 Q 값의 미묘한 차이에 있다면 디버깅이 악몽이 된다. 규칙은 if-then 구문이라 원인이 명확하다.

사실 게임 AI에서 RL은 생각보다 드물다. 림월드(RimWorld)는 XML 기반 ThinkTree로 폰의 AI를 돌린다. 드워프 포트리스(Dwarf Fortress)는 GOAP과 행동 트리의 조합이다. 수십 년간 검증된 방식이 규칙 기반인 데는 이유가 있다.

그렇다고 RL을 포기한 건 아니다. 3단계 로드맵에서 규칙 기반 시스템이 수 주간 쌓은 로그 데이터를 기반으로 Q-learning을 도입할 계획이다. 그때는 상태 공간이 충분히 파악되고, 보상 신호도 정의되고, 오프라인 학습 후 배포하는 안전한 방식을 쓸 수 있다.

절차(Procedure)라는 단위

설계 철학은 여기까지. 다시 구체적인 구현으로 돌아가자.

Anima의 행동 단위는 절차(Procedure)다. mine_ore, smelt_ore, craft_blacksmith, sell_to_vendor, bank_deposit 같은 것들이다. 각 절차는 상태 머신으로 되어 있고, 시작 조건, 실행 과정, 성공/실패 후 다음 행동 힌트를 가진다.

절차가 끝나면 ProcedureResult를 반환하는데, 여기에 next_suggestion 필드가 있다. 채굴이 성공하면 "mine_ore" — 자기 자신을 다시 제안한다. "광맥이 남아있으니 계속 캐라"는 뜻이다. 플래너는 다음 틱에서 이 힌트를 우선적으로 시도한다.

@dataclass
class ProcedureResult:
    success: bool
    reason: FailureReason | None = None
    message: str = ""
    next_suggestion: str | None = None

그러면 채굴 → 제련 → 제작 → 판매의 워크플로우 전환은 어떻게 일어나는가. 그건 next_suggestion이 아니라 플래너의 우선순위 규칙이 처리한다. 채굴을 계속하다 보면 무게가 85%를 넘는다. 그 순간 플래너의 우선순위 2번 "무게 관리" 규칙이 발동하고, 제련 절차가 시작된다. 제련이 끝나면 주괴가 있으니 제작 조건이 충족되고, 제작이 끝나면 판매 조건이 충족된다. 절차는 자기 연속성을 관리하고, 플래너는 상태 변화에 따른 전환을 관리한다. 역할 분담이 깔끔하다.

실패 처리도 이 구조로 해결한다. 제련을 시도했는데 용광로 근처가 아니면 FailureReason.BLOCKED와 함께 "find_forge"를 제안한다. 도구가 부서지면 MISSING_RESOURCE와 "make_tools"를 제안한다. 절차가 자기 실패를 진단하고 복구 경로를 제안하는 셈이다.

이벤트 버스와 구독

에이전트가 결과를 어떻게 아는지도 재미있는 문제다.

채굴을 시작하고 3초를 기다리면, 서버가 결과를 알려주는 방식이 독특하다. 별도의 "채굴 결과" 패킷 같은 건 없다. 대신 0xAE UnicodeSpeech 패킷 — 즉 채팅 메시지 — 로 "You dig some iron ore"나 "There is no metal here to mine"이 온다.

Anima는 이걸 이벤트 버스 구독 패턴으로 처리한다.

_mine_flags = {"depleted": False, "los_fail": False}
 
def _check_speech(_topic, data):
    text = data.get("text", "")
    if "no metal here" in text.lower():
        _mine_flags["depleted"] = True
 
sub = ctx.bus.subscribe("avatar.speech_heard", _check_speech)
await asyncio.sleep(3.0)  # 채굴 애니메이션 대기
ctx.bus.unsubscribe(sub)

채굴 절차가 시작되면 음성 이벤트를 구독하고, 3초 동안 기다리면서 서버 메시지를 감시한다. "금속이 없다"가 오면 해당 타일을 고갈 처리하고 다음 타일로 이동한다. 폴링보다 반응이 빠르고, 불필요한 인벤토리 스캔을 피할 수 있다.

Phase 2로 가려면

현재 Phase 1 — 기본 생존 루프 — 이 거의 마무리 단계다. 에이전트 하나가 4시간 이상 인간 개입 없이 채굴-제련-제작-판매를 반복할 수 있으면 Phase 1은 완료다. 다음은 Phase 2, 규칙 문서화다.

지금의 문제는 모든 행동 규칙이 코드에 하드코딩되어 있다는 것이다. "무게 85%면 제련" 같은 규칙이 Python 코드 안에 박혀 있다. 광부 그림에게 새로운 행동을 가르치려면 코드를 고쳐야 한다. 나무꾼 뵤른을 추가하려면 또 코드를 고쳐야 한다. 이건 확장이 안 된다.

Phase 2의 목표는 코드를 건드리지 않고 새로운 행동을 추가할 수 있는 구조를 만드는 것이다. 구체적으로 필요한 것들이 있다.

규칙 엔진의 외부화. 하드코딩된 우선순위 규칙을 YAML 문서로 빼야 한다. "무게가 85%를 넘으면 제련하러 가라"를 코드가 아니라 설정 파일에 적는 것이다. 조건식 평가에는 CEL(Common Expression Language) 같은 경량 표현식 엔진을 쓸 계획이다. 이렇게 되면 새로운 직업 — 나무꾼, 어부, 재단사 — 을 YAML 프로필 하나로 정의할 수 있다.

페르소나와 규칙의 연결. 현재 8명의 페르소나가 YAML로 정의되어 있지만, 이건 아직 성격 설정일 뿐이다. Phase 2에서는 페르소나가 곧 행동 규칙이 되어야 한다. 광부 그림의 페르소나 파일에 "채굴 60%, 제작 20%, 판매 10%, 은행 10%"라는 활동 비율이 적혀 있는데, 이걸 실제 플래너가 읽고 따라야 한다. 스킬 락, 장비 선호, 이동 반경까지 전부 페르소나에서 나와야 한다.

위치 지식의 자동 확장. 지금은 "미녹 광산은 여기, 브리테인 용광로는 저기"라는 위치 정보가 코드에 박혀 있다. Phase 2에서는 에이전트가 돌아다니면서 발견한 장소를 자동으로 기록하고, 다른 에이전트와 공유해야 한다. "저쪽에 좋은 광맥이 있더라"라는 정보가 에이전트 간에 전파되는 것이다.

실패 패턴 학습. Phase 1에서 쌓인 수 주간의 행동 로그가 있다. 어떤 상황에서 절차가 실패했는지, 어떤 위치가 효율적이었는지. 이 데이터를 분석해서 규칙을 자동으로 보정하는 시스템이 필요하다. 직접 코드를 고치는 게 아니라, 규칙 문서의 파라미터를 조정하는 방식으로.

Phase 2가 완성되면 엔지니어가 아닌 사람도 문서만으로 새로운 에이전트 행동을 설계할 수 있다. 코드는 엔진이 되고, 콘텐츠는 문서가 된다. 그 다음인 Phase 3 — 강화학습과 LLM을 결합한 자율 에이전트 — 는 이 기반 위에서 비로소 가능해진다.

기술적으로 복잡하지만, 결국 하고 싶은 건 단순하다. 28년 된 게임 세계에 AI가 자연스럽게 어울려 사는 것. 패킷과 타이밍과 상태 동기화는 그 목표를 위한 배관 공사일 뿐이다.