티스토리 뷰

DOWNLOAD

힘내라 권대리 다운로드

 

012345

 

Android iOS


SUMMARY

힘내라 권대리 - AI 내러티브 게임 서버리스 백엔드

AWS CDK v2 + Lambda + DynamoDB + GPT로 구축한 AI 턴제 내러티브 게임 백엔드 개발기입니다.

매 턴마다 GPT가 직장인의 삶을 시뮬레이션하는 스토리를 생성하고, 플레이어의 선택에 따라 이야기가 분기됩니다.

주요 기술적 도전: 3단계 계층적 메모리 시스템, DynamoDB Single Table Design, AI 응답 안정화


TABLE OF CONTENTS

목차

프로젝트 소개 AWS 아키텍처 AI 파이프라인 기술적 도전과 해결 핵심 코드


PROJECT

프로젝트 소개

힘내라 권대리는 한국 직장인의 삶을 시뮬레이션하는 AI 기반 턴제 내러티브 게임입니다.

플레이어는 '권대리'가 되어 매일 벌어지는 직장 내 상황에 대응하고, 체력/스트레스/피로도 3가지 스탯을 관리하며 생존해야 합니다.

매 턴마다 GPT-4.1-mini가 이전 선택과 현재 상태를 반영한 새로운 에피소드를 생성하고, 플레이어에게 2-3개의 선택지를 제시합니다.

스탯이 한계에 도달하면 과로사, 번아웃, 실직 등의 엔딩을 맞이하게 됩니다.


기술 스택

IaC — AWS CDK v2 (TypeScript)

런타임 — Node.js 18.x (AWS Lambda)

데이터베이스 — DynamoDB (Single Table Design, 2 GSI)

AI — OpenAI GPT-4.1-mini (Structured Outputs)

인증 — Kakao OAuth + JWT

빌드 — Webpack 5 (멀티 엔트리 Lambda 번들링)


ARCHITECTURE

AWS 서버리스 아키텍처

┌─────────────────────────────────────────────────────────────┐
│                    AWS ap-northeast-2 (Seoul)                │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │              API Gateway (REST)                       │   │
│  │              kwonbackgame-api / Stage: dev            │   │
│  └──────────┬──────────┬──────────┬──────────────────┘   │
│             |          |          |          |            │
│        ┌────┴───┐ ┌────┴───┐ ┌────┴─────┐ ┌──┴────────┐ │
│        │  Game  │ │  User  │ │Character │ │Announce-  │ │
│        │ Lambda │ │ Lambda │ │  Lambda  │ │ment Lambda│ │
│        │ 512MB  │ │ 256MB  │ │  512MB   │ │  256MB    │ │
│        └───┬────┘ └───┬────┘ └────┬─────┘ └────┬──────┘ │
│            |          |           |             |        │
│        ┌───┴──────────┴───────────┴─────────────┴───┐    │
│        │           Lambda Layer (CommonLayer)        │    │
│        │           Node.js 18.x 공통 의존성           │    │
│        └────────────────────┬────────────────────────┘   │
│                             |                            │
│        ┌────────────────────┼────────────────────┐       │
│        v                    v                    v       │
│  ┌───────────┐    ┌──────────────┐     ┌──────────┐     │
│  │ DynamoDB  │    │   Secrets    │     │  OpenAI  │     │
│  │ Single    │    │   Manager    │     │  GPT API │     │
│  │ Table     │    │ - OpenAI Key │     │gpt-4.1   │     │
│  │ 2 GSI     │    │ - Kakao Key  │     │  -mini   │     │
│  └───────────┘    └──────────────┘     └──────────┘     │
└─────────────────────────────────────────────────────────┘

4개 Lambda 함수

GameFunction — 게임 세션/턴 처리, GPT 호출, 스탯 계산, 사망 판정

UserFunction — Kakao/Apple 로그인, 프로필 관리, 포인트/출석 시스템

CharacterFunction — GPT 기반 캐릭터 AI 생성, 캐릭터 CRUD

AnnouncementFunction — 공지사항, 앱 상태, 강제 업데이트 관리


DynamoDB Single Table Design

6가지 엔티티 — User, Character, Session, Turn, Point, Announcement를 하나의 테이블에 저장

GSI1 — 이메일/Access Token/사용자 기반 조회

GSI2 — SNS 토큰/Refresh Token/활성 세션 기반 조회


키 패턴 설계

// 엔티티별 PK/SK 패턴
User:        PK=USER#{id}       SK=PROFILE
Session:     PK=SESSION#{id}    SK=METADATA#{id}
Turn:        PK=SESSION#{id}    SK=TURN#00001  (5자리 패딩)
Character:   PK=CHAR#{id}       SK=METADATA#{id}
Point:       PK=USER#{id}       SK=POINT#{id}
Announcement: PK=ANNOUNCE#{id}  SK=METADATA#{id}

AI PIPELINE

AI 내러티브 생성 파이프라인

사용자가 선택지를 고르면, 백엔드는 이전 턴들의 히스토리를 분석하고 GPT에게 다음 에피소드 생성을 요청합니다.

[Flutter 앱]
    |
    | POST /game/turn
    | { sessionId, selectedChoiceText }
    v
[API Gateway] --> [Game Lambda]
                       |
                  [TurnProcessorService]
                       |
           +-----------+-----------+
           |           |           |
     [DynamoDB에서]  [3-Level]  [사망 임박]
     [최근 8턴 로드]  [Memory]   [체크]
           |         [구축]        |
           +-----------+-----------+
                       |
              [프롬프트 구성]
              System: XML (캐릭터/규칙/시공간)
              User: 메모리 컨텍스트 + 선택 행동
                       |
              [GPT-4.1-mini API]
              Structured Outputs (JSON Schema)
              temperature: 0.72
                       |
              [응답 후처리]
              ResponseSafeParser + NameNormalizer
                       |
              [TurnResult 반환]
              에피소드 + 선택지 + 스탯 + 이미지

3단계 계층적 메모리 시스템

GPT의 토큰 한계(4K-16K) 내에서 장기 내러티브 일관성을 유지하기 위한 핵심 설계입니다.

Level 1: Immediate Memory (T0)

가장 최근 1턴 — 전체 에피소드 텍스트 + 모든 메타데이터

Level 2: Recent Memory (T1-T2)

최근 2-3턴 — 40-60음절 요약 + 메타데이터

Level 3: Context Memory (T3-T7)

이전 5턴 — 메타데이터만 (감정/캐릭터/위치/시간/활동)


정보량 시각화

턴 순서:  T7  T6  T5  T4  T3  T2  T1  T0  현재
          |---|---|---|---|---|---|---|---|--->
          Context (메타만)  Recent   Imm.  New

GPT에 전달되는 정보량:
  T0: ████████████████████████ (전체 텍스트)
  T1: ██████████████           (요약)
  T2: ██████████████           (요약)
  T3-T7: ████████              (메타데이터만)

TROUBLESHOOTING

기술적 도전과 해결

PROBLEM 01

GPT 토큰 한도 내에서 장기 내러티브 일관성 유지

턴이 30회 이상 진행되면 이전 모든 에피소드를 프롬프트에 포함할 수 없어 캐릭터 관계나 이전 사건의 연속성이 끊기는 문제가 발생했습니다. 단순 truncation은 중요한 맥락 손실을 초래합니다.

SOLUTION — 3단계 계층적 메모리 시스템

// turn-processor.service.ts
private buildLayeredMemory(recentTurns: GameTurn[]): LayeredMemory {
  const immediate = this.buildImmediateMemory(recentTurns[0]); // T0: 전체 텍스트
  const recent = recentTurns.slice(1, 3).map(t =>
    this.buildRecentMemory(t)  // T1-T2: 요약
  );
  const context = recentTurns.slice(3, 8).map(t =>
    this.buildContextMemory(t) // T3-T7: 메타데이터만
  );

  return { immediate, recent, context, subCharacterCount };
}

30턴 이상 장기 게임에서도 캐릭터 관계와 스토리 일관성을 유지하면서 토큰 사용량을 효율적으로 관리할 수 있게 되었습니다.


PROBLEM 02

AI 생성 JSON 응답의 파싱 안정성

GPT가 생성한 게임 턴 결과(에피소드, 선택지, 스탯 변화, 감정)를 JSON으로 파싱할 때 형식 불일치, 누락 필드, 잘못된 타입 등으로 런타임 에러가 발생했습니다. 특히 한국어 텍스트와 JSON 구조가 혼재할 때 파싱 실패 빈도가 증가했습니다.

SOLUTION — ResponseSafeParser + Structured Outputs

// response-safe-parser.ts
class ResponseSafeParser {
  parseText(value: any, defaultVal: string): string { ... }
  parseSummary(value: any): string {
    // 20자 미만 -> 기본값, 100자 초과 -> 잘라냄
  }
  parseEmotion(value: any): EmotionType {
    // 5개 enum 검증 (기본값: "tired")
  }
  parseSingleValue(value: any): number {
    // 0-25 범위 클램핑
  }
  parseChoices(value: any): Choice[] {
    // 2-4개 제한, 기본값 2개
  }
}

// OpenAI Structured Outputs (JSON Schema strict: true)
response_format: {
  type: "json_schema",
  json_schema: { strict: true, schema: ... }
}

JSON 파싱 성공률 99% 이상 달성. 실패 시에도 기본값으로 게임 진행이 가능합니다.


PROBLEM 03

캐릭터 이름/직급 호칭의 불일치

GPT가 생성한 텍스트에서 같은 캐릭터를 "이수연 사원", "수연 씨", "이 사원님" 등 다양한 형태로 호칭하여 일관성이 떨어지고, 대화문/서술문에서 존댓말 규칙이 혼란스러웠습니다.

SOLUTION — CharacterNameNormalizer (10단계 치환)

// character-name-normalizer.ts
class CharacterNameNormalizer {
  normalizeText(text: string, context: 'episode' | 'summary' | 'choice'): string {
    if (context === 'episode') {
      // 따옴표로 대화문/서술문 분리
      const parts = this.splitByQuotes(text);
      return parts.map(p => p.isDialogue
        ? this.normalizeWithHonorific(p.text)    // "이수연 사원님"
        : this.normalizeWithoutHonorific(p.text) // "이수연 사원"
      ).join('');
    }
    return this.normalizeWithoutHonorific(text);
  }

  // 10단계 치환 규칙
  // 1. 풀네임+직급+호칭 ("이수연 사원 씨" -> "이수연 사원님")
  // 2. 이름+직급+호칭 ("수연 사원 씨" -> "이수연 사원님")
  // ...
  // + 조사 교정 (받침 유무에 따른 이/가, 을/를, 은/는)
}

CORE CODE

핵심 코드

GPT 응답 스키마 (Structured Outputs)

{
  "episodeText": "150-250 한글 음절",
  "episodeSummary": "40-60 한글 음절",
  "location": "string",
  "currentDate": "yyyy-MM-dd",
  "currentTime": "HH:MM",
  "dayOfWeek": "Monday|...|Sunday",
  "subCharacterId": "han_director|lee_associate|...|none",
  "subCharacterEmotionType": "happy|annoyed|anxious|tired|calm",
  "mainCharacterEmotionType": "happy|annoyed|anxious|tired|calm",
  "hpDamageAmount": "0-25",
  "hpRecoveryAmount": "0-25",
  "stressDamageAmount": "0-25",
  "stressRecoveryAmount": "0-25",
  "fatigueDamageAmount": "0-25",
  "fatigueRecoveryAmount": "0-25",
  "nextActions": [
    { "id": 1, "text": "선택지 (10-30 음절)" },
    { "id": 2, "text": "..." },
    { "id": 3, "text": "..." }
  ],
  "deathMessage": "string|null"
}

3스탯 + 3사망 시스템

// turn-processor.service.ts
private checkDeathCondition(stats: Stats): DeathResult | null {
  if (stats.hp <= 0) {
    return {
      type: 'hp_death',
      message: '결국 쓰러졌다. 누군가 119를 불렀다.'
    };
  }
  if (stats.stress >= 100) {
    return {
      type: 'stress_death',
      message: '더는 무리였다. 사직서를 던졌다.'
    };
  }
  if (stats.fatigue >= 100) {
    return {
      type: 'fatigue_death',
      message: '책상이 마지막 침대가 되었다.'
    };
  }
  return null;
}

// 스탯 계산
const hpChange = hpRecovery - hpDamage;
const stressChange = stressDamage - stressRecovery;
const fatigueChange = fatigueDamage - fatigueRecovery;
const newHp = Math.max(0, Math.min(100, currentHp + hpChange));

CDK 스택 정의

// cdk/lib/kwonbackgame-stack.ts
export class KwonbackgameStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDB Single Table
    const table = new dynamodb.Table(this, 'GameTable', {
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });

    // GSI 추가
    table.addGlobalSecondaryIndex({
      indexName: 'GSI1',
      partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
    });

    // Lambda 함수 (Game)
    const gameFunction = new lambda.Function(this, 'GameFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('dist/lambda/game'),
      memorySize: 512,
      timeout: cdk.Duration.seconds(30),
      environment: { TABLE_NAME: table.tableName },
      layers: [commonLayer],
    });
  }
}

FEATURES

주요 기능 요약

7명 캐릭터 시스템

메인(권대리) + 6명 서브캐릭터(한 이사/이 대리/정 과장/오 부장/임 선임/강 전무). 각 캐릭터별 성격/아키타입 정의, 감정 기반 이미지 태그 시스템 (5감정 x 7캐릭터).


포인트 이코노미

가입 보너스 200pt, 출석 체크 100pt 획득. 턴 진행 시 10pt 소비. 게임 플레이를 위한 재화 시스템.


시공간 연속성

날짜/시간/요일이 자연스럽게 흐르고, 미완료 업무(pendingTask)와 마감일(dueDate)을 추적하여 스토리에 반영.


서브캐릭터 다양성 추적

캐릭터별 누적 등장 횟수를 추적하여 특정 캐릭터의 과다 등장을 방지하고 다양한 상호작용 유도.



CONCLUSION

결론

AWS CDK v2를 활용해 Lambda + DynamoDB + API Gateway로 구성된 서버리스 게임 백엔드를 구축했습니다.

특히 3단계 계층적 메모리 시스템을 통해 GPT 토큰 한계 내에서도 30턴 이상의 장기 게임에서 내러티브 일관성을 유지할 수 있었습니다.

DynamoDB Single Table Design으로 6가지 엔티티를 하나의 테이블에서 효율적으로 관리하고, ResponseSafeParser로 AI 응답의 안정성을 99% 이상으로 확보했습니다.

AI 기반 게임에서 가장 어려운 점은 "일관성 있는 스토리 생성"과 "예측 불가능한 AI 응답 처리"였는데, 이 두 가지 문제를 시스템 설계로 해결한 경험이 가장 값진 배움이었습니다.


DOWNLOAD

힘내라 권대리 다운로드

 

012345

 

Android iOS


긴 글을 읽어주셔서 감사합니다!

이 프로젝트를 진행하면서 가장 고민했던 부분은 "GPT가 이전 스토리를 기억하게 하는 방법"이었습니다.

단순히 모든 히스토리를 넣으면 토큰 한계에 걸리고, 너무 적게 넣으면 맥락이 끊어집니다. 이 균형점을 찾기 위해 3단계 메모리 시스템을 설계했고, 실제 30턴 이상 플레이해도 캐릭터 관계와 사건의 연속성이 유지되는 것을 확인했을 때 큰 보람을 느꼈습니다.

또한 DynamoDB Single Table Design을 실제 프로젝트에 적용해보면서 NoSQL의 사고방식을 체득할 수 있었습니다. "어떤 쿼리 패턴이 필요한가?"를 먼저 정의하고 그에 맞춰 키를 설계하는 접근법은 RDB와 완전히 다른 경험이었습니다.

이 글이 서버리스 아키텍처나 AI 기반 게임 개발에 관심 있으신 분들께 도움이 되었으면 합니다.