컨텐츠로 건너뛰기

AI 던전 게임

모듈 2: 게임 API 구현

게임 API 구현부터 시작하겠습니다. 총 4개의 API를 생성해야 합니다:

  1. createGame - 새 게임 인스턴스를 생성합니다.
  2. queryGames - 이전에 저장된 게임 목록을 페이지네이션으로 반환합니다.
  3. saveAction - 특정 게임에 대한 액션을 저장합니다.
  4. 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>;

이 프로젝트에서 사용하지 않을 ./procedures/echo.ts 파일은 삭제할 수 있습니다.

엔티티 모델링

애플리케이션의 ER 다이어그램은 다음과 같습니다:

dungeon-adventure-er.png

DynamoDB에 데이터베이스를 구현할 것이며, ElectroDB DynamoDB 클라이언트 라이브러리를 사용하여 작업을 단순화합니다. 시작하려면 먼저 다음 명령으로 electrodb를 설치합니다:

Terminal window
pnpm add -w 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 },
);

ElectroDB는 매우 강력하며 타입 정의뿐만 아니라 타임스탬프와 같은 특정 값에 대한 기본값도 제공할 수 있습니다. 또한 ElectroDB는 DynamoDB 사용 시 권장되는 단일 테이블 설계 방식을 따릅니다.

tRPC 컨텍스트에 DynamoDB 클라이언트 추가

모든 프로시저에서 DynamoDB 클라이언트에 접근할 수 있도록 컨텍스트를 통해 단일 인스턴스를 전달해야 합니다. 이를 위해 packages/game-api/backend/src 내에서 다음 변경사항을 적용합니다:

middleware/dynamodb.ts
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를 생성하고 컨텍스트에 주입합니다.

프로시저 정의

이제 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,
};
});

이 프로젝트에서 사용하지 않을 echo.ts 파일(packages/game-api/backend/src/procedures 내)도 삭제할 수 있습니다.

라우터 설정

정의한 프로시저를 API에 연결하기 위해 다음 파일을 업데이트합니다:

packages/game-api/backend/src/router.ts
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 내에서 다음 변경사항을 적용합니다:

constructs/electrodb-table.ts
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 });
}
}

배포 및 테스트

먼저 코드베이스를 빌드합니다:

Terminal window
pnpm nx run-many --target build --all

이제 다음 명령으로 애플리케이션을 배포할 수 있습니다:

Terminal window
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox

첫 배포는 약 8분 정도 소요됩니다. 이후 배포는 약 2분 정도 걸립니다.

모든 스택을 한 번에 배포할 수도 있습니다. 자세한 내용을 보려면 클릭하세요.

배포가 완료되면 다음과 유사한 출력을 확인할 수 있습니다 (일부 값은 편집됨):

Terminal window
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/
dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.execute-api.region.amazonaws.com/
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

다음 방법 중 하나로 API를 테스트할 수 있습니다:

  • tRPC 백엔드 로컬 인스턴스를 시작하고 curl로 API 호출
  • 배포된 API를 sigv4 활성화된 curl로 호출

다음 명령으로 로컬 game-api 서버를 시작합니다:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve

서버가 실행되면 다음 명령으로 호출할 수 있습니다:

Terminal window
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"

명령이 성공적으로 실행되면 다음과 같은 응답을 확인할 수 있습니다:

{"result":{"data":{"items":[],"cursor":null}}}

축하합니다. tRPC를 사용하여 첫 번째 API를 구축하고 배포했습니다! 🎉🎉🎉