AI 던전 게임
모듈 2: 게임 API 구현
게임 API 구현부터 시작하겠습니다. 총 4개의 API를 생성해야 합니다:
createGame
- 새 게임 인스턴스를 생성합니다.queryGames
- 이전에 저장된 게임 목록을 페이지네이션으로 반환합니다.saveAction
- 특정 게임에 대한 액션을 저장합니다.queryActions
- 게임과 관련된 모든 액션의 페이지네이션 목록을 반환합니다.
API 스키마
Zod를 사용하여 API 입출력을 정의하기 위해 packages/game-api/schema/src
프로젝트 내에 다음처럼 스키마를 생성합니다:
import { z } from 'zod';
export const ActionSchema = z.object({ playerName: z.string(), timestamp: z.string().datetime(), role: z.enum(['assistant', 'user']), content: z.string(),});
export type IAction = z.TypeOf<typeof ActionSchema>;
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
import { z } from 'zod';
export const GameSchema = z.object({ playerName: z.string(), genre: z.enum(['zombie', 'superhero', 'medieval']), lastUpdated: z.string().datetime(),});
export type IGame = z.TypeOf<typeof GameSchema>;
export * from './procedures/echo.js';export * from './types/action.js';export * from './types/common.js';export * from './types/game.js';
이 프로젝트에서 사용하지 않을 ./procedures/echo.ts
파일은 삭제할 수 있습니다.
엔티티 모델링
애플리케이션의 ER 다이어그램은 다음과 같습니다:

DynamoDB에 데이터베이스를 구현할 것이며, ElectroDB DynamoDB 클라이언트 라이브러리를 사용하여 작업을 단순화합니다. 시작하려면 먼저 다음 명령으로 electrodb
를 설치합니다:
pnpm add -w electrodb @aws-sdk/client-dynamodb
yarn add electrodb @aws-sdk/client-dynamodb
npm install --legacy-peer-deps electrodb @aws-sdk/client-dynamodb
bun install electrodb @aws-sdk/client-dynamodb
이제 ER 다이어그램에 따라 ElectroDB 엔티티를 정의하기 위해 packages/game-api/backend/src/entities
폴더 내에 다음 파일들을 생성합니다:
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Action', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, timestamp: { type: 'string', required: true, readOnly: true, set: () => new Date().toISOString(), default: () => new Date().toISOString(), }, role: { type: 'string', required: true, readOnly: true }, content: { type: 'string', required: true, readOnly: true }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: ['timestamp'] }, }, }, }, { client, table: process.env.TABLE_NAME }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createGameEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Game', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, genre: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: [], }, }, }, }, { client, table: process.env.TABLE_NAME }, );
ElectroDB는 매우 강력하며 타입 정의뿐만 아니라 타임스탬프와 같은 특정 값에 대한 기본값도 제공할 수 있습니다. 또한 ElectroDB는 DynamoDB 사용 시 권장되는 단일 테이블 설계 방식을 따릅니다.
tRPC 컨텍스트에 DynamoDB 클라이언트 추가
모든 프로시저에서 DynamoDB 클라이언트에 접근할 수 있도록 컨텍스트를 통해 단일 인스턴스를 전달해야 합니다. 이를 위해 packages/game-api/backend/src
내에서 다음 변경사항을 적용합니다:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext { dynamoDb?: DynamoDBClient;}
export const createDynamoDBPlugin = () => { const t = initTRPC.context<IDynamoDBContext>().create(); return t.procedure.use(async (opts) => { const dynamoDb = new DynamoDBClient();
const response = await opts.next({ ctx: { ...opts.ctx, dynamoDb, }, });
return response; });};
이 플러그인은 DynamoDBClient
를 생성하고 컨텍스트에 주입합니다.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEventV2WithIAMAuthorizer } from 'aws-lambda';import { ILoggerContext } from './logger.js';import { IMetricsContext } from './metrics.js';import { ITracerContext } from './tracer.js';import { IDynamoDBContext } from './dynamodb.js';
export * from './dynamodb.js';export * from './logger.js';export * from './metrics.js';export * from './tracer.js';export * from './error.js';
export type IMiddlewareContext = CreateAWSLambdaContextOptions<APIGatewayProxyEventV2WithIAMAuthorizer> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
IMiddlewareContext
에 IDynamoDBContext
를 추가합니다.
import { initTRPC } from '@trpc/server';import { createDynamoDBPlugin, createErrorPlugin, createLoggerPlugin, createMetricsPlugin, createTracerPlugin, IMiddlewareContext,} from './middleware/index.js';
process.env.POWERTOOLS_SERVICE_NAME = 'GameApi';process.env.POWERTOOLS_METRICS_NAMESPACE = 'GameApi';
export type Context = IMiddlewareContext;
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure .unstable_concat(createDynamoDBPlugin()) .unstable_concat(createLoggerPlugin()) .unstable_concat(createTracerPlugin()) .unstable_concat(createMetricsPlugin()) .unstable_concat(createErrorPlugin());
DynamoDB 플러그인이 적용되었습니다.
프로시저 정의
이제 API 메서드를 구현할 차례입니다. packages/game-api/backend/src/procedures
내에서 다음 변경사항을 적용합니다:
import { createActionEntity } from '../entities/action.js';import { ActionSchema, IAction, QueryInputSchema, createPaginatedQueryOutput,} from ':dungeon-adventure/game-api-schema';import { publicProcedure } from '../init.js';import { z } from 'zod';
export const queryActions = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ActionSchema)) .query(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const result = await actionEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data as IAction[], cursor: result.cursor, }; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame, QueryInputSchema, createPaginatedQueryOutput,} from ':dungeon-adventure/game-api-schema';import { publicProcedure } from '../init.js';
export const queryGames = publicProcedure .input(QueryInputSchema) .output(createPaginatedQueryOutput(GameSchema)) .query(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
import { ActionSchema, IAction } from ':dungeon-adventure/game-api-schema';import { publicProcedure } from '../init.js';import { createActionEntity } from '../entities/action.js';import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure .input(ActionSchema.omit({ timestamp: true })) .output(ActionSchema) .mutation(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go(); await gameEntity .update({ playerName: input.playerName }) .set({ lastUpdated: action.data.timestamp }) .go(); return action.data as IAction; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame } from ':dungeon-adventure/game-api-schema';import { publicProcedure } from '../init.js';
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb);
const result = await gameEntity.put(input).go(); return result.data as IGame; });
이 프로젝트에서 사용하지 않을 echo.ts
파일(packages/game-api/backend/src/procedures
내)도 삭제할 수 있습니다.
라우터 설정
정의한 프로시저를 API에 연결하기 위해 다음 파일을 업데이트합니다:
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { echo } from './procedures/echo.js';import { t } from './init.js';import { APIGatewayProxyEventV2WithIAMAuthorizer } from 'aws-lambda';import { queryActions } from './procedures/query-actions.js';import { saveAction } from './procedures/save-action.js';import { queryGames } from './procedures/query-games.js';import { saveGame } from './procedures/save-game.js';
export const router = t.router;
export const appRouter = router({ echo, actions: router({ query: queryActions, save: saveAction, }), games: router({ query: queryGames, save: saveGame, }),});
export const handler = awsLambdaRequestHandler({ router: appRouter, createContext: ( ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEventV2WithIAMAuthorizer>, ) => ctx,});
export type AppRouter = typeof appRouter;
인프라스트럭처
마지막 단계로 DynamoDB 테이블을 생성하고 Game API에서 작업을 수행할 수 있는 권한을 부여하기 위해 인프라스트럭처를 업데이트합니다. packages/infra/src
내에서 다음 변경사항을 적용합니다:
import { CfnOutput } from 'aws-cdk-lib';import { AttributeType, BillingMode, ProjectionType, Table, TableProps,} from 'aws-cdk-lib/aws-dynamodb';import { Construct } from 'constructs';
export type ElectrodbDynamoTableProps = Omit< TableProps, 'partitionKey' | 'sortKey' | 'billingMode'>;
export class ElectrodbDynamoTable extends Table { constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) { super(scope, id, { partitionKey: { name: 'pk', type: AttributeType.STRING, }, sortKey: { name: 'sk', type: AttributeType.STRING, }, billingMode: BillingMode.PAY_PER_REQUEST, ...props, });
this.addGlobalSecondaryIndex({ indexName: 'gsi1pk-gsi1sk-index', partitionKey: { name: 'gsi1pk', type: AttributeType.STRING, }, sortKey: { name: 'gsi1sk', type: AttributeType.STRING, }, projectionType: ProjectionType.ALL, });
new CfnOutput(this, 'TableName', { value: this.tableName }); }}
import { GameApi, GameUI, StoryApi, UserIdentity,} from ':dungeon-adventure/common-constructs';import * as cdk from 'aws-cdk-lib';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props);
// The code that defines your stack goes here const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi'); const storyApi = new StoryApi(this, 'StoryApi');
gameApi.routerFunction.addEnvironment( 'TABLE_NAME', electroDbTable.tableName, ); // gameAPI에 DynamoDB 읽기/쓰기 권한 부여 electroDbTable.grantReadWriteData(gameApi.routerFunction);
// 인증된 역할에 API 호출 권한 부여 [storyApi, gameApi].forEach((api) => api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole), );
// runtime-config.json 자동 구성 위해 마지막에 인스턴스화 new GameUI(this, 'GameUI'); }}
배포 및 테스트
먼저 코드베이스를 빌드합니다:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
이제 다음 명령으로 애플리케이션을 배포할 수 있습니다:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
첫 배포는 약 8분 정도 소요됩니다. 이후 배포는 약 2분 정도 걸립니다.
배포 명령
CDK 애플리케이션에 포함된 모든 스택을 다음 명령으로 배포할 수 있습니다:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
이 방법은 인프라 프로덕션과 같은 별도의 배포 단계로 스택을 분리할 경우 원치 않는 배포가 발생할 수 있으므로 권장되지 않습니다!
배포가 완료되면 다음과 유사한 출력을 확인할 수 있습니다 (일부 값은 편집됨):
dungeon-adventure-infra-sandboxdungeon-adventure-infra-sandbox: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.execute-api.region.amazonaws.com/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
다음 방법 중 하나로 API를 테스트할 수 있습니다:
- tRPC 백엔드 로컬 인스턴스를 시작하고
curl
로 API 호출 - 배포된 API를 sigv4 활성화된 curl로 호출
Sigv4 활성화된 curl
.bashrc
파일에 다음 스크립트를 추가하거나(추가 후source
실행) 동일한 터미널에 직접 붙여넣을 수 있습니다.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}sigv4 인증된 curl 요청은 다음 예시처럼 실행할 수 있습니다:
API Gateway
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxx스트리밍 람다 함수 URL
Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
다음 명령으로 로컬 game-api
서버를 시작합니다:
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api-backend:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api-backend:serve
서버가 실행되면 다음 명령으로 호출할 수 있습니다:
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"
acurl ap-southeast-2 execute-api -X GET \ https://xxx.execute-api.region.amazonaws.com/games.query\?input\="\{\}"
명령이 성공적으로 실행되면 다음과 같은 응답을 확인할 수 있습니다:
{"result":{"data":{"items":[],"cursor":null}}}
축하합니다. tRPC를 사용하여 첫 번째 API를 구축하고 배포했습니다! 🎉🎉🎉