Playground는 브라우저 안의 작은 3D 마을이다. 방문자는 주민과 대화하고, 함께 걷고, 다시 방문했을 때 이전 대화가 이어지는 경험을 목표로 했다. Three.js 렌더러, LLM 대화 서버, Firebase 동기화를 붙이는 것보다 어려웠던 일은 이 기능들을 하나의 일관된 상호작용으로 만드는 것이었다.

이 글은 2026년 2월부터 5월까지의 개발 기록을 바탕으로 설계 선택과 실패 사례를 정리한다. 당시 구현에 대한 회고이며, 현재 서비스의 성능이나 안정성을 새로 측정한 보고서는 아니다.

1. 대사와 행동 사이에 명시적인 계약이 필요하다

초기 구현은 대사에 섞인 태그와 키워드로 행동을 추론했다. NPC가 “따라갈게”라고 답하는 것과 실제로 플레이어를 따라가는 것은 서로 다른 코드 경로였다. 스트리밍 경로에서는 구조화된 행동 데이터가 무시됐고, 행동 감지 함수가 정의만 된 채 호출되지 않는 문제도 있었다.

대화 응답을 아래와 같은 구조로 바꿨다. 예시는 당시 스키마의 일부다.

{
  "reply": "그 주민에게 안내해 줄게.",
  "emotion": "happy",
  "farewell": false,
  "action": { "type": "guide_npc", "target": "heo" },
  "suggestions": ["좋아, 가자", "다음에 가자"]
}

대사는 표시하고, action은 실행 경로로 넘기고, farewell은 대화 종료에 사용한다. 모델의 자연어를 다시 해석하는 대신 출력 필드와 게임 동작의 연결을 명시했다.

당시에는 부분 JSON이 채팅에 노출되는 문제를 줄이기 위해 스트리밍을 제거하고 완성된 응답을 파싱했다. 이는 structured output과 스트리밍이 원천적으로 양립할 수 없어서가 아니라, 당시 구현의 복잡도를 줄이기 위한 선택이었다. 대신 첫 응답을 기다리는 시간이 생긴다.

모델별 스키마 지원 차이 때문에 JSON 추출과 일반 텍스트 폴백도 남았다. 구조를 정했다고 모든 경로가 자동으로 일관돼지는 것은 아니다. 응답 파싱부터 상태 변경, 화면 표시까지 같은 계약을 지키는지 확인해야 한다. 이후의 검증도 JSON 파싱 성공 여부뿐 아니라 실제 동행 시작과 종료까지 포함해야 한다.

2. 기억은 저장, 식별, 회상으로 나눠 봐야 한다

초기 기억은 localStorage의 제한된 엔트리에 의존했다. 오래된 내용을 버렸고, 대화를 여러 번 해도 첫 만남과 비슷한 반응이 나왔다. 저장된 기억이 있어도 모델이 이를 대사에 쓰지 않으면 방문자는 기억을 체감할 수 없었다.

개선은 세 부분으로 나뉘었다. 오래된 여러 엔트리를 짧은 요약으로 압축하고, Firebase에 저장하며, 대화 횟수와 최근 기억을 프롬프트에 넣어 재인식을 유도했다. 다만 요약은 손실 압축이다. 오래된 사건의 세부 정보가 유지된다고 보장할 수 없으므로, 이를 “무한 히스토리”라고 부를 수는 없다.

식별자도 별도의 문제였다. playerId를 localStorage에 두는 구조에서는 해당 식별자까지 지워지면 서버에 데이터가 남아 있어도 같은 방문자로 자동 연결되지 않는다. 서버 저장과 사용자 복구는 서로 다른 기능이다.

나중에는 NPC가 공유하는 사건 기억과 방문자별 호감도를 분리했다. 같은 마을의 사건을 공유하더라도 모든 사람과 같은 관계가 되는 것은 아니기 때문이다. 공유 기억에 어떤 대화를 넣을지, 다른 방문자에게 무엇을 노출할지는 별도의 제품 설계 문제로 남는다.

기억이 대사에 사용되는 사례는 개발 기록에 있지만, 회상 정확도나 재방문율의 정량 평가는 없었다. 저장 성공을 기억 품질의 증거로 취급하지 않는 것이 이 부분의 교훈이다.

3. 동기화 데이터가 있어도 같은 세계를 보는 것은 아니다

Firebase로 위치를 전송하고 있었지만 다른 방문자는 미니맵의 점으로만 보였다. 3D 렌더러에는 원격 플레이어가 연결되지 않았기 때문이다. NPC도 각 클라이언트에서 독립적으로 시뮬레이션돼 서로 다른 위치에 있었다.

원격 플레이어는 NPC와 별도의 mesh Map으로 관리했다. NPC와 달리 접속과 이탈에 따라 생성하고 제거해야 하므로 생명주기가 다르다. 네트워크 데이터 수신과 화면의 엔티티 생성은 각각 연결하고 검증해야 했다.

NPC 상태에는 호스트 권한 모델을 도입했다. 가장 오래 접속한 클라이언트만 NPC 시뮬레이션을 실행하고, 다른 클라이언트는 전송받은 상태를 적용한다. 호스트가 나가면 다음 클라이언트가 마지막 상태를 읽어 이어받는다.

이 설계는 각 클라이언트가 같은 상태를 독립적으로 계산하는 문제를 줄였다. 그러나 서버 권한 모델과 동등한 신뢰성이나 보안을 제공하는 것은 아니다. 호스트 전환, 연결 지연, 비정상 클라이언트에 대한 검증이 별도로 필요하다. 상태를 어디에 저장하는가와 누가 상태를 결정하는가를 구분해야 한다.

4. NPC의 개성은 프롬프트 길이보다 관찰 가능한 행동에 있다

“조용한 성격” 같은 한 줄 설정만으로는 대화가 쉽게 일반적인 응답으로 돌아갔다. 주민 사이의 관계와 일상에 관한 짧은 서사를 추가하고, 시스템 설정과 실제 대화 턴을 분리했다.

디버깅에서는 모델에 넘기는 맥락 자체가 문제인 경우도 발견했다. 자기 자신이 주변 NPC 목록에 들어가거나, 주민끼리 이야기하는데 “처음 만나는 사람”이라는 지시가 붙었다. 내부의 관계 점수나 “플레이어”라는 표현이 대사에 드러나기도 했다. 캐릭터가 알 필요 없는 내부 상태는 빼거나 자연어로 바꿨다.

더 큰 문제는 기능이 늘어나도 방문자가 다음에 무엇을 할지 알기 어려웠다는 점이다. 2월의 회고는 대화 대기, 낮은 응답 밀도, 약한 재방문 동기를 지적했다. 이후에는 기능 수보다 첫 장면의 NPC 활동, 안내, 환경음과 대화의 개성을 우선했다.

다만 이것이 체류시간을 늘렸다는 측정은 없었다. “살아 있는 마을처럼 느껴질 것”은 설계 가설이었다. 기능의 존재, 사용자가 느끼는 경험, 실제 행동 지표를 분리해서 평가해야 한다.

5. 재사용 가능한 패키지는 코드 이동만으로 만들어지지 않는다

마지막에는 엔진과 LLM 프록시를 ai-npc-world로 분리했다. 홈페이지는 버전이 고정된 패키지를 불러오고, 설정을 명시적인 init() 호출로 전달하도록 바꿨다. 다만 내부에서는 기존 전역 변수와 초기화 로직을 감싸는 얇은 래퍼였으므로, 완전히 독립적인 구조로 재설계한 것은 아니었다.

공개 전 점검에서는 모델이 생성한 추천 문장을 innerHTML로 렌더링하던 부분이 발견됐다. 이를 DOM 생성과 textContent로 바꿨다. 모델 출력도 외부 입력이므로 HTML로 신뢰해서는 안 된다. 프록시 입력에도 타입과 길이 제한을 추가했지만, 이런 제한만으로 프롬프트 인젝션이 해결되는 것은 아니다.

배포에서도 로컬 실행과 다른 문제가 드러났다. server workspace를 추가한 뒤 lockfile을 갱신하지 않아 CI의 npm ci가 실패했다. 패키지의 재사용 가능성을 확인하려면 기존 개발 환경이 아닌 깨끗한 설치와 최소 예제로 검증해야 한다. 당시 기록에는 독립 예제의 실제 동작 확인이 후속 작업으로 남아 있었다.

다음에 같은 시스템을 만든다면

가장 먼저 작은 end-to-end 시나리오를 만들겠다. 방문자가 NPC에게 안내를 요청하고, NPC가 실제로 이동하며, 대화를 종료하고, 재접속했을 때 적절한 기억을 불러오는 흐름이다. 대사 품질, 행동 실행, 상태 동기화, 기억 복구를 이 흐름에서 각각 확인한 뒤 기능을 늘리는 편이 낫다.

Playground에서 남은 교훈은 LLM 응답 하나의 품질만으로 상호작용의 품질이 결정되지 않는다는 것이다. 출력 계약, 상태의 소유권, 식별자의 수명, 렌더링과 실패 경로가 함께 맞아야 사용자가 일관된 세계를 경험할 수 있다.


이 글은 기존 개발 기록 #21, #24, #27, #33, #35, #38을 재구성했다. 개별 개발일지는 홈페이지에서 내렸으며 원문은 홈페이지 저장소의 소스와 Git 이력에 보존돼 있다.