AWS PDK에서 마이그레이션하기
이 가이드는 AWS PDK 프로젝트를 Nx Plugin for AWS로 마이그레이션하는 예제를 안내하고, 이 주제에 대한 일반적인 지침을 제공합니다.
Nx Plugin for AWS로 마이그레이션하면 PDK 대비 다음과 같은 이점을 얻을 수 있습니다:
- 더 빠른 빌드
- 더 쉬운 사용 (UI 및 CLI)
- 바이브 코딩 친화적 (MCP 서버를 사용해보세요!)
- 더 현대적인 기술
- 로컬 API 및 웹사이트 개발
- 더 많은 제어 (사용 사례에 맞게 제공된 파일 수정 가능)
- 그리고 더 많은 것들!
예제 마이그레이션: 쇼핑 목록 애플리케이션
섹션 제목: “예제 마이그레이션: 쇼핑 목록 애플리케이션”이 가이드에서는 PDK 튜토리얼의 쇼핑 목록 애플리케이션을 마이그레이션 대상 프로젝트로 사용합니다. 직접 따라하고 싶다면 해당 튜토리얼의 단계를 따라 대상 프로젝트를 생성하세요.
쇼핑 목록 애플리케이션은 다음 PDK 프로젝트 타입으로 구성됩니다:
MonorepoTsProjectTypeSafeApiProjectCloudscapeReactTsWebsiteProjectInfrastructureTsProject
워크스페이스 생성
섹션 제목: “워크스페이스 생성”시작하려면 새 프로젝트를 위한 새 워크스페이스를 생성합니다. 제자리 마이그레이션보다 더 극단적이지만, 이 접근 방식은 가장 깔끔한 최종 결과를 제공합니다. Nx 워크스페이스를 생성하는 것은 PDK의 MonorepoTsProject를 사용하는 것과 동등합니다:
pnpm create @aws/nx-workspace@1.0.0-rc.47 shopping-list --iac=cdkyarn create @aws/nx-workspace@1.0.0-rc.47 shopping-list --iac=cdknpm create @aws/nx-workspace@1.0.0-rc.47 -- shopping-list --iac=cdkbun create @aws/nx-workspace@1.0.0-rc.47 shopping-list --iac=cdk이 명령이 생성하는 shopping-list 디렉토리를 선호하는 IDE에서 엽니다.
API 마이그레이션
섹션 제목: “API 마이그레이션”쇼핑 리스트 애플리케이션에서 사용된 TypeSafeApiProject는 다음을 활용했습니다:
- 모델링 언어로 Smithy
- 작업 구현을 위한 TypeScript
- React 웹사이트와 통합하기 위한 TypeScript 훅 생성
따라서 동등한 기능을 제공하기 위해 ts#smithy-api 제너레이터를 사용할 수 있습니다.
TypeScript Smithy API 생성
섹션 제목: “TypeScript Smithy API 생성”framework를 smithy로 설정하여 ts#api 제너레이터를 실행하여 packages/api에 API 프로젝트를 설정합니다:
pnpm nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactiveyarn nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactivenpx nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactivebunx nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactive어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#api --name=api --framework=smithy --namespace=com.aws --auth=iam --no-interactive --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - ts#api - 필수 매개변수 입력
- name: api
- framework: smithy
- namespace: com.aws
- auth: iam
- 클릭
Generate
이렇게 하면 model 프로젝트와 backend 프로젝트가 생성됩니다. model 프로젝트에는 Smithy 모델이 포함되고, backend에는 서버 구현이 포함됩니다.
백엔드는 Smithy Server Generator for TypeScript를 사용합니다. 아래에서 이에 대해 더 자세히 살펴보겠습니다.
Smithy 모델 마이그레이션
섹션 제목: “Smithy 모델 마이그레이션”이제 Smithy API 프로젝트의 기본 구조가 있으므로 모델을 마이그레이션할 수 있습니다:
-
packages/api/model/src에서 생성된 예제 Smithy 파일을 삭제합니다 -
PDK 프로젝트의
packages/api/model/src/main/smithy디렉토리에서 모델을 새 프로젝트의packages/api/model/src디렉토리로 복사합니다. -
smithy-build.json에서 서비스 이름과 네임스페이스를 PDK 애플리케이션과 일치하도록 업데이트합니다:smithy-build.json "plugins": {"openapi": {"service": "com.aws#MyApi",... -
main.smithy의 서비스를 업데이트하여 Smithy TypeScript Server SDK를 사용할 때 필요한ValidationException오류를 추가합니다.main.smithy use smithy.framework#ValidationException/// My Shopping List API@restJson1service MyApi {version: "1.0"operations: [GetShoppingListsPutShoppingListDeleteShoppingList]errors: [BadRequestErrorNotAuthorizedErrorInternalFailureErrorValidationException]} -
packages/api/model/src에extensions.smithy파일을 추가하여 생성된 클라이언트에 페이지네이션 정보를 제공하는 트레이트를 정의합니다:extensions.smithy $version: "2"namespace com.awsuse smithy.openapi#specificationExtension@trait@specificationExtension(as: "x-cursor")structure cursor {inputToken: Stringenabled: Boolean} -
get-shopping-lists.smithy의GetShoppingLists작업에 새로운@cursor트레이트를 추가합니다:operations/get-shopping-lists.smithy @readonly@http(method: "GET", uri: "/shopping-list")@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize", items: "shoppingLists")@cursor(inputToken: "nextToken")@handler(language: "typescript")operation GetShoppingLists {input := with [PaginatedInputMixin] {@httpQuery("shoppingListId")shoppingListId: ShoppingListId}Nx Plugin for AWS에서 제공하는 클라이언트 제너레이터(
api-connection제너레이터를 통해)를 사용하는 경우 모든@paginated작업은@cursor도 사용해야 합니다. -
마지막으로 모든 작업에서
@handler트레이트를 제거합니다. 이는 Nx Plugin for AWS에서 지원되지 않습니다.ts#smithy-api를 사용하면 이 트레이트에 의해 생성되는 자동 생성 람다 함수 CDK 구성 및 번들링 타겟이 필요하지 않습니다. 모든 람다 함수에 대해 단일 번들을 사용하기 때문입니다.
이 시점에서 빌드를 실행하여 모델 변경 사항을 확인하고 작업할 생성된 서버 코드가 있는지 확인해 보겠습니다. 백엔드 프로젝트(@shopping-list/api)에서 일부 실패가 발생할 수 있지만 다음에 해결하겠습니다.
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target buildLambda 핸들러 마이그레이션
섹션 제목: “Lambda 핸들러 마이그레이션”api/backend 프로젝트는 Type Safe API의 api/handlers/typescript 프로젝트와 다소 동등하다고 볼 수 있습니다.
Type Safe API와 ts#smithy-api 제너레이터의 주요 차이점 중 하나는 핸들러가 Type Safe API의 자체 생성된 핸들러 래퍼(api/generated/typescript/runtime 프로젝트에 있음) 대신 Smithy Server Generator for TypeScript를 사용하여 구현된다는 것입니다.
쇼핑 리스트 애플리케이션의 람다 핸들러는 @aws-sdk/client-dynamodb 패키지에 의존하므로 @shopping-list/api 프로젝트에 설치해 보겠습니다:
pnpm add @aws-sdk/client-dynamodb --filter apiyarn workspace @shopping-list/api add @aws-sdk/client-dynamodbnpm install --legacy-peer-deps @aws-sdk/client-dynamodb -w packages/apibun add @aws-sdk/client-dynamodb --cwd packages/api그런 다음 PDK 프로젝트의 handlers/src/dynamo-client.ts 파일을 backend/src/operations로 복사하여 핸들러에서 사용할 수 있도록 합니다.
ts#smithy-api 제너레이터는 예제 Echo 작업을 스캐폴딩합니다. 모델에서 이를 제거했으므로 backend/src/operations/echo.ts의 해당 핸들러를 삭제합니다. 아래의 service.ts에서 마이그레이션된 작업을 등록하겠습니다.
핸들러를 마이그레이션하려면 다음 일반 단계를 따를 수 있습니다:
-
PDK 프로젝트의
packages/api/handlers/typescript/src디렉토리에서 핸들러를 새 프로젝트의packages/api/backend/src/operations디렉토리로 복사합니다. -
my-api-typescript-runtime임포트를 제거하고 대신 생성된 TypeScript Server SDK에서 작업 타입과ServiceContext를 임포트합니다. 예를 들어:import {deleteShoppingListHandler,DeleteShoppingListChainedHandlerFunction,INTERCEPTORS,Response,LoggingInterceptor,} from 'myapi-typescript-runtime';import { DeleteShoppingList as DeleteShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js'; -
핸들러 래퍼 내보내기를 삭제합니다
export const handler = deleteShoppingListHandler(...INTERCEPTORS,deleteShoppingList,); -
SSDK를 사용하도록 작업 핸들러의 시그니처를 업데이트합니다:
export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => {export const DeleteShoppingList: DeleteShoppingListOperation<ServiceContext> = async (input, ctx) => { -
LoggingInterceptor사용을ctx.logger로 교체합니다. (메트릭 및 추적 인터셉터에도 적용됩니다):LoggingInterceptor.getLogger(request).info('...');ctx.logger.info('...'); -
입력 매개변수에 대한 참조를 업데이트합니다. SSDK는 Smithy 모델과 정확히 일치하는 타입을 제공하므로(경로/쿼리/헤더 매개변수를 본문 매개변수와 별도로 그룹화하는 대신) 입력 참조를 적절히 업데이트합니다:
const shoppingListId = request.input.requestParameters.shoppingListId;const shoppingListId = input.shoppingListId; -
Response사용을 제거합니다. SSDK에서는 대신 일반 객체를 반환합니다.return Response.success({ shoppingListId });return { shoppingListId };또한 더 이상
Response를 throw하거나 반환하지 않고 대신 SSDK의 생성된 오류를 throw합니다:throw Response.badRequest({ message: 'oh no' });return Response.badRequest({ message: 'oh no' });import { BadRequestError } from '../generated/ssdk/index.js';throw new BadRequestError({ message: 'oh no' }); -
ESM 구문을 사용하도록 임포트를 업데이트합니다. 즉, 상대 임포트에
.js확장자를 추가합니다. -
service.ts에 작업을 추가합니다service.ts import { ServiceContext } from './context.js';import { MyApiService } from './generated/ssdk/index.js';import { DeleteShoppingList } from './operations/delete-shopping-list.js';import { GetShoppingLists } from './operations/get-shopping-lists.js';import { PutShoppingList } from './operations/put-shopping-list.js';// Register operations to the service hereexport const Service: MyApiService<ServiceContext> = {PutShoppingList,GetShoppingLists,DeleteShoppingList,};
쇼핑 리스트 핸들러 마이그레이션
Delete Shopping List
import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';import { deleteShoppingListHandler, DeleteShoppingListChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the DeleteShoppingList operation */export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info( 'Start DeleteShoppingList Operation', );
const shoppingListId = request.input.requestParameters.shoppingListId; await ddbClient.send( new DeleteItemCommand({ TableName: 'shopping_list', Key: { shoppingListId: { S: shoppingListId, }, }, }), );
return Response.success({ shoppingListId, });};
/** * Entry point for the AWS Lambda handler for the DeleteShoppingList operation. * The deleteShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = deleteShoppingListHandler( ...INTERCEPTORS, deleteShoppingList,);import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { DeleteShoppingList as DeleteShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the DeleteShoppingList operation */export const DeleteShoppingList: DeleteShoppingListOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info( 'Start DeleteShoppingList Operation', );
const shoppingListId = input.shoppingListId; await ddbClient.send( new DeleteItemCommand({ TableName: 'shopping_list', Key: { shoppingListId: { S: shoppingListId!, }, }, }), );
return { shoppingListId, };};Get Shopping Lists
import { DynamoDBClient, QueryCommand, QueryCommandInput, ScanCommand, ScanCommandInput } from '@aws-sdk/client-dynamodb';import { getShoppingListsHandler, GetShoppingListsChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor, ShoppingList,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the GetShoppingLists operation */export const getShoppingLists: GetShoppingListsChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info('Start GetShoppingLists Operation');
const nextToken = request.input.requestParameters.nextToken; const pageSize = request.input.requestParameters.pageSize; const shoppingListId = request.input.requestParameters.shoppingListId; const commandInput: ScanCommandInput | QueryCommandInput = { TableName: 'shopping_list', ConsistentRead: true, Limit: pageSize, ExclusiveStartKey: nextToken ? fromToken(nextToken) : undefined, ...(shoppingListId ? { KeyConditionExpression: 'shoppingListId = :shoppingListId', ExpressionAttributeValues: { ':shoppingListId': { S: request.input.requestParameters.shoppingListId!, }, }, } : {}), }; const response = await ddbClient.send(shoppingListId ? new QueryCommand(commandInput) : new ScanCommand(commandInput));
return Response.success({ shoppingLists: (response.Items || []) .map<ShoppingList>(item => ({ shoppingListId: item.shoppingListId.S!, name: item.name.S!, shoppingItems: JSON.parse(item.shoppingItems.S || '[]'), })), nextToken: response.LastEvaluatedKey ? toToken(response.LastEvaluatedKey) : undefined, });};
/** * Decode a stringified token * @param token a token passed to the paginated request */const fromToken = <T>(token?: string): T | undefined => token ? (JSON.parse(Buffer.from(decodeURIComponent(token), 'base64').toString()) as T) : undefined;
/** * Encode pagination details into an opaque stringified token * @param paginationToken pagination token details */const toToken = <T>(paginationToken?: T): string | undefined => paginationToken ? encodeURIComponent(Buffer.from(JSON.stringify(paginationToken)).toString('base64')) : undefined;
/** * Entry point for the AWS Lambda handler for the GetShoppingLists operation. * The getShoppingListsHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = getShoppingListsHandler(...INTERCEPTORS, getShoppingLists);import { QueryCommand, QueryCommandInput, ScanCommand, ScanCommandInput } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { GetShoppingLists as GetShoppingListsOperation, ShoppingList } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the GetShoppingLists operation */export const GetShoppingLists: GetShoppingListsOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info('Start GetShoppingLists Operation');
const nextToken = input.nextToken; const pageSize = input.pageSize; const shoppingListId = input.shoppingListId; const commandInput: ScanCommandInput | QueryCommandInput = { TableName: 'shopping_list', ConsistentRead: true, Limit: pageSize, ExclusiveStartKey: nextToken ? fromToken(nextToken) : undefined, ...(shoppingListId ? { KeyConditionExpression: 'shoppingListId = :shoppingListId', ExpressionAttributeValues: { ':shoppingListId': { S: input.shoppingListId!, }, }, } : {}), }; const response = await ddbClient.send(shoppingListId ? new QueryCommand(commandInput) : new ScanCommand(commandInput));
return { shoppingLists: (response.Items || []) .map<ShoppingList>(item => ({ shoppingListId: item.shoppingListId.S!, name: item.name.S!, shoppingItems: JSON.parse(item.shoppingItems.S || '[]'), })), nextToken: response.LastEvaluatedKey ? toToken(response.LastEvaluatedKey) : undefined, };};
/** * Decode a stringified token * @param token a token passed to the paginated request */const fromToken = <T>(token?: string): T | undefined => token ? (JSON.parse(Buffer.from(decodeURIComponent(token), 'base64').toString()) as T) : undefined;
/** * Encode pagination details into an opaque stringified token * @param paginationToken pagination token details */const toToken = <T>(paginationToken?: T): string | undefined => paginationToken ? encodeURIComponent(Buffer.from(JSON.stringify(paginationToken)).toString('base64')) : undefined;Put Shopping List
import { randomUUID } from 'crypto';import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';import { putShoppingListHandler, PutShoppingListChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the PutShoppingList operation */export const putShoppingList: PutShoppingListChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info('Start PutShoppingList Operation');
const shoppingListId = request.input.body.shoppingListId ?? randomUUID(); await ddbClient.send(new PutItemCommand({ TableName: 'shopping_list', Item: { shoppingListId: { S: shoppingListId, }, name: { S: request.input.body.name, }, shoppingItems: { S: JSON.stringify(request.input.body.shoppingItems || []), }, }, }));
return Response.success({ shoppingListId, });};
/** * Entry point for the AWS Lambda handler for the PutShoppingList operation. * The putShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = putShoppingListHandler(...INTERCEPTORS, putShoppingList);import { randomUUID } from 'crypto';import { PutItemCommand } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { PutShoppingList as PutShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the PutShoppingList operation */export const PutShoppingList: PutShoppingListOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info('Start PutShoppingList Operation');
const shoppingListId = input.shoppingListId ?? randomUUID(); await ddbClient.send(new PutItemCommand({ TableName: 'shopping_list', Item: { shoppingListId: { S: shoppingListId, }, name: { S: input.name!, }, shoppingItems: { S: JSON.stringify(input.shoppingItems || []), }, }, }));
return { shoppingListId, };};처음에 Smithy API 프로젝트를 api라는 이름으로 생성했는데, 이는 PDK 프로젝트와의 일관성을 위해 packages/api에 추가하기 위함이었습니다. 이제 Smithy API가 service Api 대신 service MyApi를 정의하므로 getApiServiceHandler의 모든 인스턴스를 getMyApiServiceHandler로 업데이트해야 합니다.
handler.ts를 다음과 같이 변경합니다:
import { getApiServiceHandler } from './generated/ssdk/index.js'; import { getMyApiServiceHandler } from './generated/ssdk/index.js';
process.env.POWERTOOLS_METRICS_NAMESPACE = 'Api';process.env.POWERTOOLS_SERVICE_NAME = 'Api';
const tracer = new Tracer();const logger = new Logger();const metrics = new Metrics();
const serviceHandler = getApiServiceHandler(Service); const serviceHandler = getMyApiServiceHandler(Service);그리고 local-server.ts도 변경합니다:
import { getApiServiceHandler } from './generated/ssdk/index.js';import { getMyApiServiceHandler } from './generated/ssdk/index.js';
const PORT = 3001;
const tracer = new Tracer();const logger = new Logger();const metrics = new Metrics();
const serviceHandler = getApiServiceHandler(Service);const serviceHandler = getMyApiServiceHandler(Service);또한 packages/api/backend/project.json을 업데이트하고 metadata.apiName을 my-api로 업데이트합니다:
"metadata": { "generator": "ts#smithy-api", "apiName": "api", "apiName": "my-api", "auth": "iam", "modelProject": "@shopping-list/api-model", "ports": [3001] },빌드로 확인
섹션 제목: “빌드로 확인”이제 프로젝트를 빌드하여 지금까지의 마이그레이션이 제대로 작동하는지 확인할 수 있습니다:
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target build웹사이트 마이그레이션
섹션 제목: “웹사이트 마이그레이션”쇼핑 리스트 애플리케이션에서 사용된 CloudscapeReactTsWebsiteProject는 CloudScape와 Cognito 인증이 내장된 React 웹사이트를 구성했습니다.
이 프로젝트 타입은 현재 더 이상 사용되지 않는 create-react-app을 활용했습니다. 이 가이드에서 웹사이트를 마이그레이션하기 위해 보다 현대적이고 지원되는 기술, 즉 Vite를 사용하는 ts#website 생성기를 사용할 것입니다.
마이그레이션의 일환으로, PDK에서 구성된 React Router에서 웹사이트 라우팅에 추가적인 타입 안전성을 제공하는 TanStack Router로 이동할 것입니다.
React 웹사이트 생성
섹션 제목: “React 웹사이트 생성”packages/website에 웹사이트 프로젝트를 설정하기 위해 framework를 react로 설정하여 ts#website 생성기를 실행합니다. 쇼핑 리스트 애플리케이션은 CloudScape 컴포넌트로 구축되었으므로 ux를 cloudscape로 설정합니다(기본값은 shadcn입니다):
pnpm nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactiveyarn nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactivenpx nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactivebunx nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactive어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#website --name=website --framework=react --ux=cloudscape --no-interactive --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - ts#website - 필수 매개변수 입력
- name: website
- framework: react
- ux: cloudscape
- 클릭
Generate
Cognito 인증 추가
섹션 제목: “Cognito 인증 추가”위의 React 웹사이트 생성기는 CloudscapeReactTsWebsiteProject처럼 기본적으로 cognito 인증을 번들로 제공하지 않으며, 대신 ts#website#auth 생성기를 통해 명시적으로 추가됩니다.
pnpm nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactiveyarn nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactivenpx nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactivebunx nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactive어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - ts#website#auth - 필수 매개변수 입력
- project: website
- cognitoDomain: shopping-list
- 클릭
Generate
이것은 사용자가 Cognito 호스팅 UI를 사용하여 로그인하도록 적절한 리디렉션을 관리하는 React 컴포넌트를 추가합니다. 또한 UserIdentity라는 packages/common/constructs에 Cognito 리소스를 배포하기 위한 CDK 구성을 추가합니다.
웹사이트를 API에 연결
섹션 제목: “웹사이트를 API에 연결”PDK에서는 제공된 Projen 프로젝트를 서로 전달하여 통합 코드가 제공되도록 트리거할 수 있었습니다. 이것은 쇼핑 리스트 애플리케이션에서 웹사이트가 API와 통합할 수 있도록 구성하는 데 사용되었습니다.
Nx Plugin for AWS에서는 API 통합이 connection 생성기를 통해 지원됩니다. 다음으로, 웹사이트가 Smithy API를 호출할 수 있도록 이 생성기를 사용합니다:
pnpm nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactiveyarn nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactivenpx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactivebunx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runyarn nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runnpx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runbunx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - connection - 필수 매개변수 입력
- sourceProject: website
- targetProject: api
- 클릭
Generate
이것은 웹사이트가 생성된 TypeScript 클라이언트를 통해 API를 호출하는 데 필요한 클라이언트 프로바이더와 빌드 타겟을 생성합니다.
AWS Northstar 의존성 추가
섹션 제목: “AWS Northstar 의존성 추가”CloudscapeReactTsWebsiteProject는 쇼핑 리스트 애플리케이션에서 사용되는 @aws-northstar/ui에 대한 의존성을 자동으로 포함했으므로, @shopping-list/website 프로젝트에 추가합니다:
pnpm add @aws-northstar/ui --filter websiteyarn workspace @shopping-list/website add @aws-northstar/uinpm install --legacy-peer-deps @aws-northstar/ui -w packages/websitebun add @aws-northstar/ui --cwd packages/website@aws-northstar/ui는 Vite가 해결할 수 없는 webpack 전용 import를 사용하는 ace-builds에 의존하는 코드 에디터 컴포넌트를 번들로 제공합니다. 쇼핑 리스트 애플리케이션은 이 컴포넌트를 사용하지 않으므로, packages/website/vite.config.mts의 기존 build 옵션 내 external 구성에 추가하여 번들에서 제외합니다:
build: { outDir: '../../dist/packages/website/bundle', emptyOutDir: true, reportCompressedSize: true, commonjsOptions: { transformMixedEsModules: true, }, rollupOptions: { external: ['ace-builds/webpack-resolver'], }, },컴포넌트와 페이지 이동
섹션 제목: “컴포넌트와 페이지 이동”쇼핑 리스트 애플리케이션에는 CreateItem이라는 하나의 컴포넌트와 ShoppingList 및 ShoppingLists라는 두 개의 페이지가 있습니다. TanStack Router와 Nx Plugin for AWS TypeScript 클라이언트 코드 생성기를 사용하므로 몇 가지 조정을 하면서 이들을 새 웹사이트로 마이그레이션하겠습니다.
-
PDK 프로젝트의
packages/website/src/components/CreateItem/index.tsx를 새 프로젝트의 정확히 동일한 위치로 복사합니다. -
packages/website/src/pages/ShoppingLists/index.tsx를packages/website/src/routes/index.tsx로 복사합니다.ShoppingLists가 홈 페이지이고 TanStack 라우터와 함께 파일 기반 라우팅을 사용하기 때문입니다. -
packages/website/src/pages/ShoppingList/index.tsx를packages/website/src/routes/$shoppingListId.tsx로 복사합니다.ShoppingList가/:shoppingListId라우트에 표시하려는 페이지이기 때문입니다.
이제 IDE에서 일부 빌드 오류가 표시될 것입니다. 새 프레임워크에 맞추기 위해 몇 가지 추가 변경이 필요하며, 아래에 설명되어 있습니다.
React Router에서 TanStack Router로 마이그레이션
섹션 제목: “React Router에서 TanStack Router로 마이그레이션”파일 기반 라우팅을 사용하고 있으므로, 웹사이트 로컬 개발 서버를 사용하여 라우트 구성을 자동으로 생성할 수 있습니다.
로컬 웹사이트 서버를 시작해 봅시다:
pnpm nx dev websiteyarn nx dev websitenpx nx dev websitebunx nx dev website일부 오류가 표시되지만, 로컬 웹사이트 서버는 포트 4200에서 시작되고 로컬 Smithy API 서버는 포트 3001에서 시작됩니다.
TanStack Router로 마이그레이션하려면 routes/index.tsx와 routes/$shoppingListId.tsx 모두에서 아래 단계를 따르세요:
-
각 라우트를 등록하기 위해
createFileRoute를 추가합니다:import { createFileRoute } from "@tanstack/react-router";...export default ShoppingLists;export const Route = createFileRoute('/')({component: ShoppingLists,});import { createFileRoute } from "@tanstack/react-router";...export default ShoppingList;export const Route = createFileRoute('/$shoppingListId')({component: ShoppingList,});파일을 저장하면
createFileRoute호출의 타입 오류가 사라진 것을 알 수 있습니다. -
useNavigate훅을 교체합니다.import를 업데이트합니다:
import { useNavigate } from 'react-router-dom';import { useNavigate } from '@tanstack/react-router';타입 안전 라우트를 전달하도록
navigate메서드(useNavigate에서 반환됨) 호출을 업데이트합니다:navigate(`/${cell.shoppingListId}`);navigate({to: '/$shoppingListId',params: { shoppingListId: cell.shoppingListId },}); -
useParams훅을 교체합니다.import를 제거합니다:
import { useParams } from 'react-router-dom';위에서 생성한
Route에서 제공하는 훅으로useParams호출을 업데이트합니다. 이제 타입 안전합니다!const { shoppingListId } = useParams();const { shoppingListId } = Route.useParams();
컴포넌트 Import 수정
섹션 제목: “컴포넌트 Import 수정”라우트 파일이 PDK 프로젝트만큼 파일 트리에 깊게 중첩되어 있지 않으므로, routes/index.tsx와 routes/$shoppingListId.tsx 모두에서 CreateItem에 대한 import를 수정해야 합니다:
import CreateItem from "../../components/CreateItem";import CreateItem from "../components/CreateItem";AppLayoutContext도 새 프로젝트에서 약간 다른 위치에 제공됩니다:
import { AppLayoutContext } from "../../layouts/App";import { AppLayoutContext } from "../components/AppLayout";새로 생성된 TypeScript 클라이언트 사용으로 마이그레이션
섹션 제목: “새로 생성된 TypeScript 클라이언트 사용으로 마이그레이션”이제 거의 다 왔습니다! 다음으로, Type Safe API에 비해 몇 가지 개선 사항이 있는 Nx Plugin for AWS에서 제공하는 TypeScript 클라이언트를 사용하도록 마이그레이션해야 합니다. 이를 위해 아래 단계를 따르세요
-
예를 들어, 이전 것 대신 새로 생성된 클라이언트와 타입을 import합니다:
import {ShoppingList,usePutShoppingList,useDeleteShoppingList,useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { ShoppingList } from "../generated/my-api/types.gen";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";routes/$shoppingListId.tsx는ShoppingList타입을_ShoppingList로 import합니다 - 해당 파일에서도 동일하게 하되,types.gen에서 import합니다.생성된 클라이언트는 훅 래퍼가 아닌 TanStack 쿼리 훅에 대한 옵션을 생성하는 메서드를 제공하므로,
@tanstack/react-query에서 직접 관련 훅을 import합니다. -
예를 들어, 새로운 TanStack Query 훅을 인스턴스화합니다:
const getShoppingLists = useGetShoppingLists({ pageSize: PAGE_SIZE });const putShoppingList = usePutShoppingList();const deleteShoppingList = useDeleteShoppingList();const api = useMyApi();const getShoppingLists = useInfiniteQuery(api.getShoppingLists.infiniteQueryOptions({ pageSize: PAGE_SIZE },{ getNextPageParam: (p) => p.nextToken },),);const putShoppingList = useMutation(api.putShoppingList.mutationOptions());const deleteShoppingList = useMutation(api.deleteShoppingList.mutationOptions(),); -
요청 본문에 매개변수를 받는 작업 호출에 대한 래퍼
<operation>RequestContent를 제거합니다:await putShoppingList.mutateAsync({putShoppingListRequestContent: {name: item,},});
TanStack Query v4에서 v5로 마이그레이션
섹션 제목: “TanStack Query v4에서 v5로 마이그레이션”TanStack Query v4(PDK에서 사용)와 connection 생성기가 추가한 v5 간의 차이로 인해 수정해야 할 몇 가지 오류가 남아 있습니다:
-
예를 들어, mutation에 대해
isLoading을isPending으로 교체합니다:putShoppingList.isLoadingputShoppingList.isPending -
쇼핑 리스트 애플리케이션은 TanStack Query v4의 타입을 기대하는
@aws-northstar/ui의InfiniteQueryTable을 사용했습니다. 이것은 실제로 v5의 무한 쿼리와 작동하므로 타입 오류를 억제할 수 있습니다:<InfiniteQueryTablequery={getShoppingLists}query={getShoppingLists as any}
로컬 웹사이트 방문
섹션 제목: “로컬 웹사이트 방문”이제 http://localhost:4200/에서 로컬 웹사이트를 방문할 수 있습니다
모든 것이 마이그레이션되었으므로 웹사이트가 로드되어야 합니다! 쇼핑 리스트 애플리케이션이 API, 웹사이트 및 Identity 외에 의존하는 유일한 인프라는 DynamoDB 테이블이므로 - 리전 내에 shopping_list라는 이름의 DynamoDB 테이블이 있고 이에 액세스할 수 있는 로컬 AWS 자격 증명이 있다면 웹사이트가 완전히 작동할 것입니다!
그렇지 않아도 괜찮습니다. 다음으로 인프라를 마이그레이션하겠습니다.
쇼핑 리스트 페이지 마이그레이션
쇼핑 리스트 페이지
/* eslint-disable @typescript-eslint/no-floating-promises */import { InfiniteQueryTable } from "@aws-northstar/ui/components";import { Button, Header, Link, SpaceBetween, TableProps,} from "@cloudscape-design/components";import { ShoppingList, usePutShoppingList, useDeleteShoppingList, useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { useContext, useEffect, useMemo, useState } from "react";import { useNavigate } from "react-router-dom";import CreateItem from "../../components/CreateItem";import { AppLayoutContext } from "../../layouts/App";
const PAGE_SIZE = 50;
/** * Component to render the ShoppingLists "/" route. */const ShoppingLists: React.FC = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedShoppingList, setSelectedShoppingList] = useState< ShoppingList[] >([]); const getShoppingLists = useGetShoppingLists({ pageSize: PAGE_SIZE }); const putShoppingList = usePutShoppingList(); const deleteShoppingList = useDeleteShoppingList(); const navigate = useNavigate(); const { setAppLayoutProps } = useContext(AppLayoutContext);
useEffect(() => { setAppLayoutProps({ contentType: "table", }); }, [setAppLayoutProps]);
const columnDefinitions = useMemo< TableProps.ColumnDefinition<ShoppingList>[] >( () => [ { id: "shoppingListId", isRowHeader: true, header: "Shopping List Id", cell: (cell) => ( <Link href={`/${cell.shoppingListId}`} onFollow={(e) => { e.preventDefault(); navigate(`/${cell.shoppingListId}`); }} > {cell.shoppingListId} </Link> ), }, { id: "name", header: "Name", cell: (cell) => cell.name, }, { id: "shoppingItems", header: "Shopping Items", cell: (cell) => `${cell.shoppingItems?.length || 0} Items.`, }, ], [navigate], );
return ( <> <CreateItem title="Create Shopping List" callback={async (item) => { await putShoppingList.mutateAsync({ putShoppingListRequestContent: { name: item, }, }); getShoppingLists.refetch(); }} isLoading={putShoppingList.isLoading} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <InfiniteQueryTable query={getShoppingLists} itemsKey="shoppingLists" pageSize={PAGE_SIZE} selectionType="single" stickyHeader={true} selectedItems={selectedShoppingList} onSelectionChange={(e) => setSelectedShoppingList(e.detail.selectedItems) } header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button loading={deleteShoppingList.isLoading} data-testid="header-btn-delete" disabled={selectedShoppingList.length === 0} onClick={async () => { await deleteShoppingList.mutateAsync({ shoppingListId: selectedShoppingList![0].shoppingListId, }); setSelectedShoppingList([]); getShoppingLists.refetch(); }} > Delete </Button> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Create Shopping List </Button> </SpaceBetween> } > Shopping Lists </Header> } variant="full-page" columnDefinitions={columnDefinitions} /> </> );};
export default ShoppingLists;/* eslint-disable @typescript-eslint/no-floating-promises */import { InfiniteQueryTable } from "@aws-northstar/ui/components";import { Button, Header, Link, SpaceBetween, TableProps,} from "@cloudscape-design/components";import { useContext, useEffect, useMemo, useState } from "react";import { useNavigate } from "@tanstack/react-router";import CreateItem from "../components/CreateItem";import { AppLayoutContext } from "../components/AppLayout";import { createFileRoute } from "@tanstack/react-router";import { ShoppingList } from "../generated/my-api/types.gen";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
const PAGE_SIZE = 50;
/** * Component to render the ShoppingLists "/" route. */const ShoppingLists: React.FC = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedShoppingList, setSelectedShoppingList] = useState< ShoppingList[] >([]); const api = useMyApi(); const getShoppingLists = useInfiniteQuery( api.getShoppingLists.infiniteQueryOptions( { pageSize: PAGE_SIZE }, { getNextPageParam: (res) => res.nextToken }, ), ); const putShoppingList = useMutation(api.putShoppingList.mutationOptions()); const deleteShoppingList = useMutation( api.deleteShoppingList.mutationOptions(), ); const navigate = useNavigate(); const { setAppLayoutProps } = useContext(AppLayoutContext);
useEffect(() => { setAppLayoutProps({ contentType: "table", }); }, [setAppLayoutProps]);
const columnDefinitions = useMemo< TableProps.ColumnDefinition<ShoppingList>[] >( () => [ { id: "shoppingListId", isRowHeader: true, header: "Shopping List Id", cell: (cell) => ( <Link href={`/${cell.shoppingListId}`} onFollow={(e) => { e.preventDefault(); navigate({ to: '/$shoppingListId', params: { shoppingListId: cell.shoppingListId },}); }} > {cell.shoppingListId} </Link> ), }, { id: "name", header: "Name", cell: (cell) => cell.name, }, { id: "shoppingItems", header: "Shopping Items", cell: (cell) => `${cell.shoppingItems?.length || 0} Items.`, }, ], [navigate], );
return ( <> <CreateItem title="Create Shopping List" callback={async (item) => { await putShoppingList.mutateAsync({ name: item, }); getShoppingLists.refetch(); }} isLoading={putShoppingList.isPending} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <InfiniteQueryTable query={getShoppingLists as any} itemsKey="shoppingLists" pageSize={PAGE_SIZE} selectionType="single" stickyHeader={true} selectedItems={selectedShoppingList} onSelectionChange={(e) => setSelectedShoppingList(e.detail.selectedItems) } header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button loading={deleteShoppingList.isPending} data-testid="header-btn-delete" disabled={selectedShoppingList.length === 0} onClick={async () => { await deleteShoppingList.mutateAsync({ shoppingListId: selectedShoppingList![0].shoppingListId, }); setSelectedShoppingList([]); getShoppingLists.refetch(); }} > Delete </Button> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Create Shopping List </Button> </SpaceBetween> } > Shopping Lists </Header> } variant="full-page" columnDefinitions={columnDefinitions} /> </> );};
export const Route = createFileRoute('/')({ component: ShoppingLists,});쇼핑 리스트 페이지
/* eslint-disable @typescript-eslint/no-floating-promises */import { Board, BoardItem, BoardProps,} from "@cloudscape-design/board-components";import { Button, Container, ContentLayout, Header, SpaceBetween, Spinner,} from "@cloudscape-design/components";import { ShoppingList as _ShoppingList, usePutShoppingList, useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { useEffect, useState } from "react";import { useParams } from "react-router-dom";import CreateItem from "../../components/CreateItem";
type ListItem = { name: string };
/** * Component to render a singular Shopping List "/:shoppingListId" route. */const ShoppingList: React.FC = () => { const { shoppingListId } = useParams(); const [visibleModal, setVisibleModal] = useState(false); const getShoppingLists = useGetShoppingLists({ shoppingListId }); const putShoppingList = usePutShoppingList(); const shoppingList: _ShoppingList | undefined = getShoppingLists.data?.pages[0].shoppingLists[0]!; const [shoppingItems, setShoppingItems] = useState<BoardProps.Item<ListItem>[]>();
useEffect(() => { setShoppingItems( shoppingList?.shoppingItems?.map((i) => ({ id: i, definition: { minColumnSpan: 4 }, data: { name: i }, })), ); }, [shoppingList?.shoppingItems]);
return ( <ContentLayout header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Add Item </Button> </SpaceBetween> } > Shopping list: {shoppingList?.name} </Header> } > <CreateItem isLoading={false} title="Add Item" callback={async (item) => { const items = [ ...(shoppingItems || []), { id: item, definition: { minColumnSpan: 4 }, data: { name: item }, }, ]; setShoppingItems(items); putShoppingList.mutate({ putShoppingListRequestContent: { name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }, }); }} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <Container> {!shoppingList ? ( <Spinner /> ) : ( <Board<ListItem> onItemsChange={(event) => { const items = event.detail.items as BoardProps.Item<ListItem>[]; setShoppingItems(items); putShoppingList.mutate({ putShoppingListRequestContent: { name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }, }); }} items={shoppingItems || []} renderItem={(item, actions) => ( <BoardItem header={item.data.name} settings={ <Button iconName="close" variant="icon" onClick={actions.removeItem} /> } i18nStrings={{ dragHandleAriaLabel: "Drag handle", dragHandleAriaDescription: "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard.", resizeHandleAriaLabel: "Resize handle", resizeHandleAriaDescription: "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard.", }} /> )} i18nStrings={{ liveAnnouncementDndCommitted: () => "", liveAnnouncementDndDiscarded: () => "", liveAnnouncementDndItemInserted: () => "", liveAnnouncementDndItemReordered: () => "", liveAnnouncementDndItemResized: () => "", liveAnnouncementDndStarted: () => "", liveAnnouncementItemRemoved: () => "", navigationAriaLabel: "", navigationItemAriaLabel: () => "", }} empty={<></>} /> )} </Container> </ContentLayout> );};
export default ShoppingList;// routes/$shoppingListId.tsx/* eslint-disable @typescript-eslint/no-floating-promises */import { Board, BoardItem, BoardProps,} from "@cloudscape-design/board-components";import { Button, Container, ContentLayout, Header, SpaceBetween, Spinner,} from "@cloudscape-design/components";import { useEffect, useState } from "react";import CreateItem from "../components/CreateItem";import { createFileRoute } from "@tanstack/react-router";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";import { ShoppingList as _ShoppingList } from "../generated/my-api/types.gen";
type ListItem = { name: string };
/** * Component to render a singular Shopping List "/:shoppingListId" route. */const ShoppingList: React.FC = () => { const { shoppingListId } = Route.useParams(); const [visibleModal, setVisibleModal] = useState(false); const api = useMyApi(); const getShoppingLists = useInfiniteQuery( api.getShoppingLists.infiniteQueryOptions( { shoppingListId }, { getNextPageParam: (p) => p.nextToken }, ), ); const putShoppingList = useMutation(api.putShoppingList.mutationOptions()); const shoppingList: _ShoppingList | undefined = getShoppingLists.data?.pages?.[0]?.shoppingLists?.[0]; const [shoppingItems, setShoppingItems] = useState<BoardProps.Item<ListItem>[]>();
useEffect(() => { setShoppingItems( shoppingList?.shoppingItems?.map((i) => ({ id: i, definition: { minColumnSpan: 4 }, data: { name: i }, })), ); }, [shoppingList?.shoppingItems]);
return ( <ContentLayout header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Add Item </Button> </SpaceBetween> } > Shopping list: {shoppingList?.name} </Header> } > <CreateItem isLoading={false} title="Add Item" callback={async (item) => { const items = [ ...(shoppingItems || []), { id: item, definition: { minColumnSpan: 4 }, data: { name: item }, }, ]; setShoppingItems(items); putShoppingList.mutate({ name: shoppingList?.name ?? 'my list', shoppingListId: shoppingList?.shoppingListId, shoppingItems: items.map((i) => i.data.name), }); }} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <Container> {!shoppingList ? ( <Spinner /> ) : ( <Board<ListItem> onItemsChange={(event) => { const items = event.detail.items as BoardProps.Item<ListItem>[]; setShoppingItems(items); putShoppingList.mutate({ name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }); }} items={shoppingItems || []} renderItem={(item, actions) => ( <BoardItem header={item.data.name} settings={ <Button iconName="close" variant="icon" onClick={actions.removeItem} /> } i18nStrings={{ dragHandleAriaLabel: "Drag handle", dragHandleAriaDescription: "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard.", resizeHandleAriaLabel: "Resize handle", resizeHandleAriaDescription: "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard.", }} /> )} i18nStrings={{ liveAnnouncementDndCommitted: () => "", liveAnnouncementDndDiscarded: () => "", liveAnnouncementDndItemInserted: () => "", liveAnnouncementDndItemReordered: () => "", liveAnnouncementDndItemResized: () => "", liveAnnouncementDndStarted: () => "", liveAnnouncementItemRemoved: () => "", navigationAriaLabel: "", navigationItemAriaLabel: () => "", }} empty={<></>} /> )} </Container> </ContentLayout> );};
export const Route = createFileRoute('/$shoppingListId')({ component: ShoppingList,});인프라 마이그레이션
섹션 제목: “인프라 마이그레이션”쇼핑 리스트 애플리케이션을 위해 마이그레이션해야 하는 마지막 프로젝트는 InfrastructureTsProject입니다. 이것은 TypeScript CDK 프로젝트이며, Nx Plugin for AWS에서 이에 해당하는 것은 ts#infra 생성기입니다.
Projen 프로젝트뿐만 아니라 PDK는 이러한 프로젝트가 의존하는 CDK 구성 요소도 제공했습니다. Nx Plugin for AWS에서 생성된 구성 요소를 사용하여 이러한 CDK 구성 요소에서도 쇼핑 리스트 애플리케이션을 마이그레이션할 것입니다.
TypeScript CDK 인프라스트럭처 프로젝트 생성
섹션 제목: “TypeScript CDK 인프라스트럭처 프로젝트 생성”ts#infra 생성기를 실행하여 packages/infra에 인프라스트럭처 프로젝트를 설정합니다:
pnpm nx g @aws/nx-plugin:ts#infra --name=infra --no-interactiveyarn nx g @aws/nx-plugin:ts#infra --name=infra --no-interactivenpx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactivebunx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - ts#infra - 필수 매개변수 입력
- name: infra
- 클릭
Generate
CDK 인프라스트럭처 마이그레이션
섹션 제목: “CDK 인프라스트럭처 마이그레이션”PDK 쇼핑 리스트 애플리케이션은 CDK 애플리케이션 스택 내에서 다음 구성 요소를 인스턴스화했습니다:
- 쇼핑 리스트를 저장하는 DynamoDB 테이블을 위한
DatabaseConstruct - PDK에서 직접 가져온 Cognito 리소스를 위한
UserIdentity - Smithy API를 배포하기 위한
MyApi, PDK의TypeSafeRestApiCDK 구성 요소를 기반으로 타입 안전 통합과 함께 생성된 TypeScript CDK 구성 요소를 사용했습니다. - PDK의
StaticWebsiteCDK 구성 요소를 래핑하여 웹사이트를 배포하기 위한Website
다음으로, 이들 각각을 새 프로젝트로 마이그레이션할 것입니다.
애플리케이션 스택 복사
섹션 제목: “애플리케이션 스택 복사”PDK 쇼핑 리스트 애플리케이션의 packages/infra/src/stacks/application-stack.ts를 새 프로젝트의 정확히 동일한 위치로 복사합니다. 아래에서 해결할 몇 가지 TypeScript 오류가 표시될 것입니다.
데이터베이스 구성 요소 복사
섹션 제목: “데이터베이스 구성 요소 복사”PDK 쇼핑 리스트 애플리케이션에는 packages/src/constructs/database.ts에 Database 구성 요소가 있었습니다. 이것을 새 프로젝트의 정확히 동일한 위치로 복사합니다.
Nx Plugin for AWS는 PDK Nag보다 조금 더 엄격한 보안 테스트를 위해 Checkov를 사용하므로 일부 억제를 추가해야 합니다:
import { suppressRules } from '@shopping-list/common-constructs';...suppressRules( this.shoppingListTable, ['CKV_AWS_28', 'CKV_AWS_119'], 'Backup and KMS key not required for this project',);application-stack.ts에서 DatabaseConstruct의 import를 ESM 구문을 사용하도록 업데이트합니다:
import { DatabaseConstruct } from '../constructs/database';import { DatabaseConstruct } from '../constructs/database.js';UserIdentity 구성 요소 마이그레이션
섹션 제목: “UserIdentity 구성 요소 마이그레이션”UserIdentity 구성 요소는 일반적으로 import를 조정하여 변경 없이 교체할 수 있습니다.
import { UserIdentity } from "@aws/pdk/identity";import { UserIdentity } from '@shopping-list/common-constructs';...const userIdentity = new UserIdentity(this, `${id}UserIdentity`);새로운 UserIdentity 구성 요소에서 사용하는 기본 구성 요소는 aws-cdk-lib에서 직접 제공되는 반면, PDK는 @aws-cdk/aws-cognito-identitypool-alpha를 사용했습니다.
API 구성 요소 마이그레이션
섹션 제목: “API 구성 요소 마이그레이션”PDK 쇼핑 리스트 애플리케이션에는 Smithy 모델에서 Type Safe API가 생성한 CDK 구성 요소를 인스턴스화하는 constructs/apis/myapi.ts의 구성 요소가 있었습니다.
이 구성 요소뿐만 아니라 PDK 프로젝트가 @handler 트레이트를 사용했기 때문에 생성된 람다 함수 CDK 구성 요소도 생성되었습니다.
Type Safe API와 마찬가지로 Nx Plugin for AWS는 Smithy 모델을 기반으로 통합에 대한 타입 안전성을 제공하지만 훨씬 더 간단하고 유연한 방식으로 달성됩니다. 빌드 시 전체 CDK 구성 요소를 생성하는 대신 최소한의 “메타데이터”만 생성되며, packages/common/constructs/src/app/apis/api.ts가 이를 일반적인 방식으로 사용합니다. 구성 요소 사용 방법에 대한 자세한 내용은 ts#smithy-api 생성기 가이드에서 확인할 수 있습니다.
다음 단계를 따르세요:
-
application-stack.ts에서Api구성 요소 인스턴스화stacks/application-stack.ts import { MyApi } from "../constructs/apis/myapi";import { Api } from '@shopping-list/common-constructs';...const myapi = new MyApi(this, "MyApi", {databaseConstruct,userIdentity,});const api = new Api(this, 'MyApi', {integrations: Api.defaultIntegrations(this).build(),});여기서
Api.defaultIntegrations(this).build()를 사용하는 것을 주목하세요 - 기본 동작은 API의 각 작업에 대해 람다 함수를 생성하는 것이며, 이는myapi.ts에서 가졌던 것과 동일한 동작입니다. -
람다 함수가 DynamoDB 테이블에 액세스할 수 있도록 권한 부여
PDK 쇼핑 리스트 애플리케이션에서는
DatabaseConsruct가MyApi에 전달되었고, 생성된 각 함수 구성 요소에 관련 권한을 추가하는 것을 관리했습니다.Api구성 요소의 타입 안전integrations속성에 액세스하여application-stack.ts파일에서 직접 이 작업을 수행할 것입니다:stacks/application-stack.ts // Grant our lambda functions scoped access to call DynamodatabaseConstruct.shoppingListTable.grantReadData(api.integrations.getShoppingLists.handler,);[api.integrations.putShoppingList.handler,api.integrations.deleteShoppingList.handler,].forEach((f) => databaseConstruct.shoppingListTable.grantWriteData(f)); -
인증된 사용자가 API를 호출할 수 있도록 권한 부여
PDK 애플리케이션의
myapi.ts내에서 인증된 사용자에게도 API를 호출할 수 있는 IAM 권한이 부여되었습니다.application-stack.ts에서 동등한 작업을 수행할 것입니다:stacks/application-stack.ts api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
웹사이트 구성 요소 마이그레이션
섹션 제목: “웹사이트 구성 요소 마이그레이션”마지막으로, PDK 쇼핑 리스트 애플리케이션의 packages/infra/src/constructs/websites/website.ts와 동등한 packages/common/constructs/src/app/static-websites/website.ts의 Website 구성 요소를 application-stack.ts에 추가합니다.
import { Website } from "../constructs/websites/website";import { Website } from '@shopping-list/common-constructs';...new Website(this, "Website", { userIdentity, myapi,});new Website(this, 'Website');웹사이트에 identity나 API를 전달하지 않는 것을 주목하세요 - 런타임 구성은 Nx Plugin for AWS에서 제공하는 각 구성 요소 내에서 관리되며, UserIdentity와 Api는 필요한 값을 등록하고 Website는 정적 웹사이트의 /runtime-config.json에 배포하는 것을 관리합니다.
이제 코드베이스의 모든 관련 부분을 새 프로젝트로 마이그레이션했으므로 프로젝트를 빌드해 보겠습니다.
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target build이제 완전히 마이그레이션된 코드베이스를 얻었으므로 배포를 살펴볼 수 있습니다. 이 시점에서 두 가지 경로를 선택할 수 있습니다.
완전히 새로운 리소스 (간단함)
섹션 제목: “완전히 새로운 리소스 (간단함)”가장 간단한 접근 방식은 이것을 완전히 새로운 애플리케이션으로 취급하는 것입니다. 즉, 새로운 DynamoDB 테이블과 Cognito User Pool로 “처음부터 시작”하여 모든 사용자와 쇼핑 목록을 잃게 됩니다. 이 접근 방식의 경우 다음과 같이 하면 됩니다:
-
shopping_list라는 이름의 DynamoDB 테이블을 삭제합니다 -
새 애플리케이션을 배포합니다:
Terminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/*
🎉 완료되었습니다! 🎉
중단 없이 기존 상태 저장 리소스 마이그레이션 (더 복잡함)
섹션 제목: “중단 없이 기존 상태 저장 리소스 마이그레이션 (더 복잡함)”실제로는 고객에게 다운타임 없이 기존 AWS 리소스를 새 코드베이스에서 관리하도록 마이그레이션하고 싶을 가능성이 높습니다.
쇼핑 목록 애플리케이션의 경우, 우리가 관심 있는 상태 저장 리소스는 사용자의 쇼핑 목록을 포함하는 DynamoDB 테이블과 등록된 모든 사용자의 세부 정보를 포함하는 User Pool입니다. 우리의 상위 수준 계획은 이 두 가지 핵심 리소스를 유지하고 새 스택에서 관리되도록 이동한 다음, DNS를 업데이트하여 새 웹사이트(및 고객에게 노출된 경우 API)를 가리키도록 하는 것입니다.
-
유지하려는 기존 리소스를 참조하도록 새 애플리케이션을 업데이트합니다.
쇼핑 목록 애플리케이션의 경우 DynamoDB 테이블에 대해 이렇게 합니다
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);그리고 Cognito User Pool의 경우
packages/common/constructs/src/core/user-identity.ts this.userPool = this.createUserPool();this.userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',); -
새 애플리케이션을 빌드하고 배포합니다:
Terminal window pnpm nx run-many --target buildTerminal window yarn nx run-many --target buildTerminal window npx nx run-many --target buildTerminal window bunx nx run-many --target buildTerminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/*이제 기존 리소스를 참조하는 새 애플리케이션이 구축되었지만 아직 트래픽을 받지 않습니다.
-
새 애플리케이션이 예상대로 작동하는지 확인하기 위해 전체 통합 테스트를 수행합니다. 쇼핑 목록 애플리케이션의 경우 웹사이트를 로드하고 로그인하여 쇼핑 목록을 생성, 보기, 편집 및 삭제할 수 있는지 확인합니다.
-
새 애플리케이션에서 기존 리소스를 참조하는 변경 사항을 되돌리되 아직 배포하지 마십시오.
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);그리고 Cognito User Pool의 경우
packages/common/constructs/src/core/user-identity.ts this.userPool = this.createUserPool();this.userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',);그런 다음 빌드를 실행합니다
Terminal window pnpm nx run-many --target buildTerminal window yarn nx run-many --target buildTerminal window npx nx run-many --target buildTerminal window bunx nx run-many --target build -
새 애플리케이션의
packages/infra폴더에서cdk import를 사용하여 가져오라는 메시지가 표시될 리소스를 확인합니다.New Application cd packages/infrapnpm exec cdk import shopping-list-infra-sandbox/Application --force엔터를 눌러 프롬프트를 진행합니다. 리소스가 다른 스택에서 관리되기 때문에 가져오기가 실패합니다 - 이것은 예상된 것이며, 유지해야 할 리소스를 확인하기 위해 이 단계를 수행했습니다. 다음과 같은 출력이 표시됩니다:
Terminal window shopping-list-infra-sandbox/Application/ApplicationUserIdentity/UserPool/smsRole/Resource (AWS::IAM::Role): enter RoleName (empty to skip)shopping-list-infra-sandbox/Application/ApplicationUserIdentity/UserPool/Resource (AWS::Cognito::UserPool): enter UserPoolId (empty to skip)shopping-list-infra-sandbox/Application/Database/ShoppingList/Resource (AWS::DynamoDB::Table): import with TableName=shopping_list (y/n) y이것은 실제로 새 스택으로 가져와야 할 리소스가 3개 있다는 것을 알려줍니다.
-
이전 단계에서 발견한 리소스에 대해
RemovalPolicy를RETAIN으로 설정하도록 이전 PDK 프로젝트를 업데이트합니다. 이 글을 작성하는 시점에서 이것은 User Pool과 DynamoDB 테이블 모두의 기본값이지만, 위에서 발견한 SMS Role에 대해서는 업데이트해야 합니다:application-stack.ts const userIdentity = new UserIdentity(this, `${id}UserIdentity`, {userPool,});const smsRole = userIdentity.userPool.node.findAll().filter(c => CfnResource.isCfnResource(c) &&c.node.path.includes('/smsRole/'))[0] as CfnResource;smsRole.applyRemovalPolicy(RemovalPolicy.RETAIN); -
제거 정책이 적용되도록 PDK 프로젝트를 배포합니다
PDK Application cd packages/infranpx projen deploy -
CloudFormation 콘솔을 살펴보고 위의
cdk import단계에서 요청받은 값을 기록합니다- User Pool ID, 예:
us-west-2_XXXXX - SMS Role Name, 예:
infra-sandbox-UserIdentityUserPoolsmsRoleXXXXXX
- User Pool ID, 예:
-
리소스를 생성하는 대신 기존 리소스를 참조하도록 PDK 프로젝트를 업데이트합니다
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);그리고 Cognito User Pool의 경우
application-stack.ts const userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',);const userIdentity = new UserIdentity(this, `${id}UserIdentity`, {// PDK construct accepts UserPool not IUserPool, but this still works!userPool: userPool as any,}); -
PDK 프로젝트를 다시 배포합니다. 이렇게 하면 리소스가 더 이상 PDK 프로젝트의 CloudFormation 스택에서 관리되지 않습니다.
PDK Application cd packages/infranpx projen deploy -
이제 리소스가 관리되지 않으므로 새 애플리케이션에서
cdk import를 실행하여 실제로 가져오기를 수행할 수 있습니다:New Application cd packages/infrapnpm exec cdk import shopping-list-infra-sandbox/Application --force메시지가 표시되면 값을 입력하면 가져오기가 성공적으로 완료됩니다.
-
이러한 기존 리소스(이제 새 스택에서 관리됨)에 대한 변경 사항이 적용되도록 새 애플리케이션을 다시 배포합니다:
Terminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/* -
새 애플리케이션에 대한 전체 테스트를 다시 수행합니다
-
새 웹사이트(및 필요한 경우 API)를 가리키도록 DNS 레코드를 업데이트합니다.
Route53 가중치 기반 라우팅을 사용하는 점진적 접근 방식을 권장합니다. 이를 통해 처음에는 일부 요청만 새 애플리케이션으로 전달됩니다. 메트릭을 모니터링하면서 이전 PDK 애플리케이션으로 트래픽이 전송되지 않을 때까지 새 애플리케이션의 가중치를 늘릴 수 있습니다.
DNS가 없고 웹사이트와 API에 자동 생성된 도메인을 사용한 경우 항상 요청을 프록시하는 방법을 고려할 수 있습니다(예: CloudFront HTTP origin 또는 API Gateway HTTP integration(s)를 통해).
-
PDK 애플리케이션 메트릭을 모니터링하여 트래픽이 없는지 확인하고 마지막으로 이전 CloudFormation 스택을 삭제합니다:
Terminal window cd packages/infranpx projen destroy
꽤 복잡했지만 사용자를 새 애플리케이션으로 원활하게 마이그레이션하는 데 성공했습니다! 🎉🎉🎉
이제 PDK에 비해 Nx Plugin for AWS의 새로운 이점을 얻게 되었습니다:
- 더 빠른 빌드
- 로컬 API 개발 지원
- 바이브 코딩 친화적인 코드베이스 (MCP 서버를 사용해 보세요!)
- 더 직관적인 타입 안전 클라이언트/서버 코드
- 그리고 더 많은 것들!
자주 묻는 질문
섹션 제목: “자주 묻는 질문”이 섹션은 위의 예제 마이그레이션에서 다루지 않은 PDK 기능에 대한 지침을 제공합니다.
PDK에서 이동할 때 일반적인 규칙으로, PDK Monorepo와의 유사성을 고려하여 Nx Workspace로 모든 프로젝트를 시작하는 것을 권장합니다. 또한 새로운 타입을 구축하는 기본 요소로 우리의 제너레이터를 사용하는 것을 권장합니다.
pnpm create @aws/nx-workspace my-projectyarn create @aws/nx-workspace my-projectnpm create @aws/nx-workspace -- my-projectbun create @aws/nx-workspace my-projectCDK Graph
섹션 제목: “CDK Graph”CDK Graph는 연결된 CDK 리소스의 그래프를 구축하며, 두 가지 플러그인을 제공했습니다:
Diagram Plugin
섹션 제목: “Diagram Plugin”CDK Graph Diagram Plugin은 CDK 인프라에서 AWS 아키텍처 다이어그램을 생성합니다.
유사한 결정론적 접근 방식을 위한 실행 가능한 대안으로는 CDK-Dia가 있습니다.
생성형 AI의 발전으로 많은 기반 모델이 CDK 인프라에서 고품질 다이어그램을 생성할 수 있게 되었습니다. AWS Diagram MCP Server를 사용해 보시기를 권장합니다. 자세한 안내는 이 블로그 게시물을 확인하세요.
Threat Composer Plugin
섹션 제목: “Threat Composer Plugin”CDK Graph Threat Composer Plugin은 CDK 코드에서 시작용 Threat Composer 위협 모델을 생성합니다.
이 플러그인은 예제 위협이 포함된 기본 위협 모델을 필터링하고, 스택에서 사용하는 리소스를 기반으로 필터링하는 방식으로 작동했습니다.
이러한 특정 예제 위협에 관심이 있다면 기본 위협 모델을 복사하고 필터링하거나, 기반 모델이 유사한 모델을 생성하는 데 도움이 되는 컨텍스트로 사용할 수 있습니다.
AWS Arch
섹션 제목: “AWS Arch”AWS Arch는 위의 CDK Graph에 대한 CloudFormation 리소스와 관련 아키텍처 아이콘 간의 매핑을 제공했습니다.
아이콘 관련 리소스는 AWS Architecture Icons 페이지를 참조하세요. Diagrams도 코드로 다이어그램을 구축하는 방법을 제공합니다.
이를 직접 사용하고 있었다면 프로젝트를 포크하여 소유권을 가져가는 것을 고려해보세요!
Pipeline
섹션 제목: “Pipeline”PDK는 CDK 인프라 프로젝트를 설정하고 일부 CDK Pipelines 리소스를 래핑한 CDK 구성 요소를 사용하는 PDKPipelineProject를 제공했습니다.
이를 마이그레이션하려면 CDK Pipelines 구성 요소를 직접 사용할 수 있습니다. 그러나 실제로는 GitHub actions 또는 GitLab CI/CD와 같은 것을 사용하는 것이 더 간단할 수 있습니다. 여기서 CDK Stages를 정의하고 적절한 스테이지에 대한 배포 명령을 직접 실행합니다.
PDK Nag
섹션 제목: “PDK Nag”PDK Nag는 CDK Nag를 래핑하며, 프로토타입 구축에 특화된 규칙 세트를 제공합니다.
PDK Nag에서 마이그레이션하려면 CDK Nag를 직접 사용하세요. 동일한 규칙 세트가 필요한 경우 여기 문서를 따라 자체 “팩”을 생성할 수 있습니다.
Type Safe API
섹션 제목: “Type Safe API”Type Safe API에서 가장 일반적으로 사용되는 컴포넌트는 위의 예제 마이그레이션에서 다루고 있지만, 다른 기능들도 있으며 아래에 마이그레이션 세부 정보가 나와 있습니다.
OpenAPI로 모델링된 API
섹션 제목: “OpenAPI로 모델링된 API”Nx Plugin for AWS는 Smithy로 모델링된 API를 지원하지만 OpenAPI로 직접 모델링된 API는 지원하지 않습니다. ts#smithy-api 생성기가 좋은 시작점이며 이를 수정할 수 있습니다. Smithy 대신 model 프로젝트의 src 폴더에 OpenAPI 사양을 정의하고, NPM에서 사용할 수 없는 경우 클라이언트/서버용 원하는 코드 생성 도구를 사용하도록 build.Dockerfile을 수정할 수 있습니다. 원하는 도구가 NPM에 있는 경우 Nx 워크스페이스에 개발 의존성으로 설치하고 Nx 빌드 타겟으로 직접 호출할 수 있습니다.
백엔드
섹션 제목: “백엔드”OpenAPI로 모델링된 타입 안전 백엔드의 경우 OpenAPI Generator Server Generators 중 하나를 사용하는 것을 고려할 수 있습니다. 이들은 AWS Lambda용으로 직접 생성하지 않지만 AWS Lambda Web Adapter를 사용하여 많은 경우 격차를 해소할 수 있습니다.
클라이언트
섹션 제목: “클라이언트”TypeScript 클라이언트의 경우 ts#website 생성기와 connection 생성기를 예제 ts#api(framework를 smithy로 설정)와 함께 사용하여 클라이언트가 생성되고 웹사이트와 통합되는 방법을 확인할 수 있습니다. 이는 open-api#ts-client 또는 open-api#ts-hooks 생성기를 호출하여 클라이언트를 생성하는 빌드 타겟을 구성합니다. OpenAPI 사양을 가리키도록 하여 이러한 생성기를 직접 사용할 수 있습니다.
다른 언어의 경우 OpenAPI Generator의 생성기 중 필요에 맞는 것이 있는지 확인할 수도 있습니다.
ts#nx-generator 생성기를 사용하여 맞춤형 생성기를 구축할 수도 있습니다. OpenAPI에서 코드를 생성하는 방법에 대한 자세한 내용은 해당 생성기의 문서를 참조하세요. Nx Plugin for AWS의 템플릿을 시작점으로 사용할 수 있습니다. 더 많은 영감을 얻기 위해 PDK 코드베이스의 템플릿을 참조할 수도 있습니다. 단, 템플릿이 작동하는 데이터 구조가 Nx Plugin for AWS와 약간 다르다는 점에 유의하세요.
TypeSpec으로 모델링된 API
섹션 제목: “TypeSpec으로 모델링된 API”TypeSpec의 경우 위의 OpenAPI 섹션도 적용됩니다. ts#smithy-api를 생성하는 것으로 시작하고, Nx 워크스페이스에 TypeSpec 컴파일러와 OpenAPI 패키지를 설치한 다음, 모델 프로젝트의 compile 타겟을 대신 tsp compile을 실행하도록 업데이트하여 dist 디렉토리에 OpenAPI 사양을 출력하도록 할 수 있습니다.
백엔드
섹션 제목: “백엔드”권장되는 접근 방식은 TypeSpec HTTP Server generator for JavaScript를 사용하여 서버 코드를 생성하는 것입니다. 이는 TypeSpec 모델에서 직접 작동하기 때문입니다.
AWS Lambda에서 생성된 서버를 실행하려면 AWS Lambda Web Adapter를 사용할 수 있습니다.
위의 OpenAPI 옵션 중 하나를 사용할 수도 있습니다.
클라이언트
섹션 제목: “클라이언트”TypeSpec은 Type Safe API가 지원하는 세 가지 언어 모두에 대한 자체 코드 생성기를 가지고 있습니다:
TypeSpec은 OpenAPI로 컴파일할 수 있으므로 위의 OpenAPI 섹션도 적용됩니다.
Smithy로 모델링된 API
섹션 제목: “Smithy로 모델링된 API”위의 예제 마이그레이션은 ts#smithy-api 생성기를 사용하도록 마이그레이션하는 방법을 설명합니다. 이 섹션에서는 Python 및 Java 백엔드와 클라이언트에 대한 옵션을 다룹니다.
백엔드
섹션 제목: “백엔드”Smithy code generator for Java. 이는 Java 서버 생성기와 AWS Lambda에서 생성된 Java 서버를 실행하기 위한 어댑터를 가지고 있습니다.
Smithy는 Python용 서버 생성기가 없으므로 OpenAPI를 통해 진행해야 합니다. 잠재적인 옵션에 대해서는 OpenAPI로 모델링된 API에 관한 위의 섹션을 참조하세요.
클라이언트
섹션 제목: “클라이언트”Smithy code generator for Java. 이는 Java 클라이언트 생성기를 가지고 있습니다.
Python 클라이언트의 경우 Smithy Python을 확인할 수 있습니다.
TypeScript의 경우 Smithy TypeScript를 확인하거나, OpenAPI를 통해 진행하는 ts#smithy-api에서 취한 것과 동일한 접근 방식을 사용하세요(TanStack Query 훅을 통해 tRPC, FastAPI 및 Smithy API 간의 일관성을 제공하기 때문에 이 방식을 선택했습니다).
Smithy Shape Library
섹션 제목: “Smithy Shape Library”Type Safe API는 여러 Smithy 기반 API에서 재사용할 수 있는 Smithy 모델을 포함하는 프로젝트를 구성하는 SmithyShapeLibraryProject라는 Projen 프로젝트 유형을 제공했습니다.
이에 해당하는 것은 type을 shapes로 설정한 smithy#project 생성기입니다:
pnpm nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapesyarn nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapesnpx nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapesbunx nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapes어떤 파일이 변경될지 확인하기 위해 드라이 런을 수행할 수도 있습니다
pnpm nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapes --dry-runyarn nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapes --dry-runnpx nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapes --dry-runbunx nx g @aws/nx-plugin:smithy#project --name=my-shapes --type=shapes --dry-run- 설치 Nx Console VSCode Plugin 아직 설치하지 않았다면
- VSCode에서 Nx 콘솔 열기
- 클릭
Generate (UI)"Common Nx Commands" 섹션에서 - 검색
@aws/nx-plugin - smithy#project - 필수 매개변수 입력
- name: my-shapes
- type: shapes
- 클릭
Generate
SmithyShapeLibraryProject의 shape를 생성된 프로젝트의 src 폴더로 이동한 다음, API 모델의 의존성으로 라이브러리를 연결하는 방법에 대해서는 Smithy 프로젝트 가이드를 참조하세요.
인터셉터
섹션 제목: “인터셉터”Type Safe API는 다음과 같은 기본 인터셉터를 제공했습니다:
- Powertools for AWS Lambda를 사용한 로깅, 추적 및 메트릭 인터셉터
- 포착되지 않은 예외를 처리하기 위한 Try-catch 인터셉터
- CORS 헤더를 반환하기 위한 CORS 인터셉터
ts#smithy-api 생성기는 Middy를 사용하여 Powertools for AWS Lambda로 로깅, 추적 및 메트릭을 계측합니다. Try-catch 인터셉터의 동작은 Smithy TypeScript SSDK에 내장되어 있으며, CORS 헤더는 handler.ts에 추가됩니다.
모든 언어에서 로깅, 추적 및 메트릭 인터셉터의 경우 Powertools for AWS Lambda를 직접 사용하세요.
사용자 정의 인터셉터를 마이그레이션하려면 다음 라이브러리를 사용하는 것이 좋습니다:
- TypeScript - Middy
- Python - Powertools for AWS Lambda Middleware Factory
- Java - 간단한 접근 방식을 위해 aws-lambda-java-libs를 사용하여 비즈니스 로직 전후에 메서드를 계측하거나, 미들웨어를 어노테이션으로 구축하기 위해 AspectJ를 고려하세요.
문서 생성
섹션 제목: “문서 생성”Type Safe API는 Redocly CLI를 사용한 문서 생성을 제공했습니다. 위와 같이 마이그레이션한 후 기존 프로젝트에 이를 추가하는 것은 매우 쉽습니다.
-
Redocly CLI 설치
Terminal window pnpm add -Dw @redocly/cliTerminal window yarn add -D @redocly/cliTerminal window npm install --legacy-peer-deps -D @redocly/cliTerminal window bun add -D @redocly/cli -
redocly build-docs를 사용하여model프로젝트에 문서 생성 타겟을 추가합니다. 예를 들어:model/project.json {..."documentation": {"cache": true,"outputs": ["{workspaceRoot}/dist/{projectRoot}/documentation"],"executor": "nx:run-commands","options": {"command": "redocly build-docs dist/packages/api/model/build/openapi/openapi.json --output=dist/packages/api/model/documentation/index.html","cwd": "{workspaceRoot}"},"dependsOn": ["compile"]}}
OpenAPI Generator documentation generators를 고려할 수도 있습니다.
Mock 통합
섹션 제목: “Mock 통합”Type Safe API는 생성된 인프라 패키지 내에서 mock을 생성했습니다.
JSON 스키마를 기반으로 mock 데이터를 생성할 수 있는 JSON Schema Faker로 이동할 수 있습니다. 이는 OpenAPI 사양에서 직접 작동할 수 있으며, model 프로젝트 빌드의 일부로 실행할 수 있는 CLI가 있습니다.
CDK 인프라를 업데이트하여 JSON Schema Faker가 출력한 JSON 파일을 읽고, 생성된 metadata.gen.ts(ts#smithy-api 생성기를 사용했다고 가정)를 기반으로 통합에 대한 적절한 API Gateway MockIntegration을 반환할 수 있습니다.
혼합 언어 백엔드
섹션 제목: “혼합 언어 백엔드”Type Safe API는 백엔드에서 다양한 언어를 혼합하여 API를 구현하는 것을 지원했습니다. 이는 CDK에서 API 구성을 인스턴스화할 때 통합에 “재정의”를 제공하여 달성할 수도 있습니다:
const pythonLambdaHandler = new Function(this, 'PythonImplementation', { runtime: Runtime.PYTHON_3_12, ...});
new MyApi(this, 'MyApi', { integrations: Api.defaultIntegrations(this) .withOverrides({ echo: { integration: new LambdaIntegration(pythonLambdaHandler), handler: pythonLambdaHandler, }, }) .build(),});ts#smithy-api와 TypeScript Server SDK를 사용하는 경우 서비스가 컴파일되도록 서비스/라우터를 “스텁”해야 합니다. 예를 들어:
export const Service: ApiService<ServiceContext> = { ... Echo: () => { throw new Error(`Not Implemented`); },};입력 유효성 검사
섹션 제목: “입력 유효성 검사”Type Safe API는 SpecRestApi 구성을 내부적으로 사용했기 때문에 OpenAPI 사양을 기반으로 요청 본문에 대한 네이티브 API Gateway 유효성 검사를 추가했습니다.
ts#smithy-api 생성기를 사용하면 유효성 검사는 Server SDK 자체에서 수행됩니다. 이는 대부분의 서버 생성기에서 동일합니다.
네이티브 API Gateway 유효성 검사를 구현하려면 packages/common/constructs/src/core/api/rest-api.ts를 수정하여 OpenAPI 사양에서 각 작업의 요청 본문에 대한 관련 JSON 스키마를 읽도록 할 수 있습니다.
WebSocket API
섹션 제목: “WebSocket API”안타깝게도 API Gateway 및 Lambda를 사용하는 Type Safe API의 websocket API에 대한 간단한 마이그레이션 경로는 없습니다. 그러나 이 가이드의 이 섹션은 최소한 몇 가지 아이디어를 제공하는 것을 목표로 합니다.
비동기 API를 처리하도록 설계되었으므로 OpenAPI 또는 TypeSpec 대신 AsyncAPI를 사용하여 API를 모델링하는 것을 고려하세요. AsyncAPI NodeJS Template은 예를 들어 ECS에서 호스팅할 수 있는 Node websocket 백엔드를 생성할 수 있습니다.
인프라를 위해 AppSync Events를 고려하고 Powertools를 사용할 수도 있습니다. 이 블로그 게시물을 읽어볼 가치가 있습니다!
또 다른 옵션은 AppSync에서 websocket과 함께 GraphQL API를 사용하는 것입니다. 이에 대해 +1을 할 수 있는 GitHub 이슈가 있습니다! 자세한 내용과 샘플 프로젝트 링크는 AppSync 개발자 가이드를 참조하세요.
Type Safe API와 동일한 벤더 확장을 해석하는 자체 코드 생성기를 구축하는 것도 고려할 수 있습니다. 사용자 정의 OpenAPI 기반 코드 생성기 구축에 대한 자세한 내용은 OpenAPI로 모델링된 API 섹션을 참조하세요. Type Safe API가 API Gateway Websocket API Lambda 핸들러에 사용하는 템플릿은 여기에서, 클라이언트는 여기에서 찾을 수 있습니다.
tRPC를 사용하려면 ts#trpc-api 생성기를 사용하도록 마이그레이션하는 것도 고려할 수 있습니다. 이 글을 쓰는 시점에는 아직 구독/스트리밍을 지원하지 않지만 필요한 경우 이를 추적하는 GitHub 이슈에 +1을 추가하세요.
Smithy는 프로토콜에 구애받지 않지만 아직 Websocket 프로토콜을 지원하지 않습니다. 지원을 추적하는 GitHub 이슈를 참조하세요.
Infrastructure in Python or Java
섹션 제목: “Infrastructure in Python or Java”PDK는 Python과 Java로 작성된 CDK 인프라를 지원했습니다. 현재 Nx Plugin for AWS에서는 이를 지원하지 않습니다.
권장되는 방법은 CDK 인프라를 TypeScript로 마이그레이션하거나, 우리의 제너레이터를 사용하고 공통 구성 요소 패키지를 원하는 언어로 마이그레이션하는 것입니다. Kiro CLI와 같은 생성형 AI를 사용하여 이러한 종류의 마이그레이션을 가속화할 수 있습니다. AI 에이전트가 합성된 CloudFormation 템플릿이 동일해질 때까지 마이그레이션을 반복하도록 할 수 있습니다.
Python 또는 Java로 생성된 Type Safe API의 인프라에도 동일하게 적용됩니다 - 공통 구성 요소 패키지에서 일반 rest-api.ts 구성 요소를 변환하고, 대상 언어에 대한 간단한 메타데이터 제너레이터를 직접 구현할 수 있습니다(OpenAPI로 모델링된 API 섹션 참조).
py#project 제너레이터를 사용하여 CDK 코드를 추가할 기본 Python 프로젝트를 생성할 수 있습니다(cdk.json 파일을 이동하고 관련 타겟을 추가). Java 프로젝트의 경우 Nx의 @nx/gradle 플러그인을 사용하거나, Maven의 경우 @jnxplus/nx-maven을 사용할 수 있습니다.
Use of Projen
섹션 제목: “Use of Projen”PDK는 Projen 위에 구축되었습니다. Projen과 Nx Generators는 상당히 근본적인 차이가 있어서 기술적으로는 결합이 가능하지만 안티 패턴일 가능성이 높습니다. Projen은 프로젝트 파일을 코드로 관리하여 직접 수정할 수 없도록 하는 반면, Nx generators는 프로젝트 파일을 한 번 생성한 후 코드를 자유롭게 수정할 수 있습니다.
Projen을 계속 사용하고 싶다면, 원하는 Projen 프로젝트 타입을 직접 구현할 수 있습니다. Nx Plugin for AWS의 패턴을 따르려면, 우리의 generators를 실행하거나 GitHub에서 소스 코드를 검토하여 원하는 프로젝트 타입이 어떻게 구성되는지 확인하고, Projen의 primitives를 사용하여 관련 부분을 구현할 수 있습니다.