Skip to Content
기타AWSAmplify 캐싱13. DynamoDB로 구현하는 Next.js cacheHandler

이번 편의 결과물: 09편에서 재현했던 인스턴스별 값 불일치가, DynamoDB 기반 cacheHandler 적용 후에는 어느 인스턴스로 요청이 가도 같은 값이 나오는 것으로 해소됩니다. · 다루는 개념: cacheHandler 인터페이스(get/set/revalidateTag), AWS SDK v3로 DynamoDB 읽기·쓰기, Amplify SSR Compute Role 최소 권한

비용 주의: DynamoDB는 스토리지 25GB까지 상시 무료지만, 온디맨드(On-Demand) 테이블은 무료 제공량을 넘는 요청마다 소액이 과금됩니다(2026년 기준 대략 쓰기 100만 건당 1.25달러, 읽기 100만 건당 0.25달러). notice-board 트래픽으로는 사실상 무료지만, 실습 후 테이블 상태를 확인하는 습관을 들입니다.

12편에서 인스턴스 간 캐시 미공유 문제의 대안으로 DynamoDB를 고르고, 테이블 이름 notice-board-cache와 파티션 키 cacheKey(문자열)를 설계했습니다. 이 편에서 그 설계를 코드로 만듭니다.

이 편에서 만드는 파일

notice-board/ ├── cache-handler.js (+, Next.js cacheHandler 구현) ├── next.config.mjs (~, cacheHandler 등록) ├── lib/view-counts.js (~, Map 대신 DynamoDB에 저장) ├── app/api/notices/[id]/views/route.js (~, 비동기 bumpViewCount 반영) └── package.json (~, @aws-sdk/client-dynamodb, @aws-sdk/lib-dynamodb)

개념 정리

cacheHandler가 바꾸는 것과 바꾸지 않는 것

Next.js는 데이터 캐시와 전체 라우트 캐시(ISR)를 기본적으로 인스턴스의 로컬 디스크·메모리에 저장합니다. next.configcacheHandler 경로를 지정하면 저장·조회가 그 파일로 위임되어, 인스턴스가 여러 개여도 같은 외부 저장소(DynamoDB)를 보게 됩니다.

다만 cacheHandlerNext.js가 직접 관리하는 캐시만 대상입니다. 09편의 조회수(lib/view-counts.jsMap)는 Next.js 캐시 API를 거치지 않는 애플리케이션 자체 상태라 인스턴스마다 따로 존재합니다. 그 증상을 없애려면 조회수 저장소도 DynamoDB로 옮겨야 하므로, 이 편은 두 가지를 함께 합니다.

cacheHandler 인터페이스

메서드호출 시점역할
get(key)캐시 값이 필요할 때저장소에서 key 값을 찾아 반환, 없으면 null
set(key, data, ctx)새로 렌더링·fetch한 값을 저장할 때datactx.tags를 기록
revalidateTag(tags)revalidateTag/revalidatePath 호출 시태그가 일치하는 항목을 무효화
resetRequestCache()요청 하나가 끝날 때요청 단위 임시 캐시 정리(이 구현에서는 비워둠)

Amplify SSR Compute Role로 최소 권한 부여

액세스 키를 직접 넣는 대신 SSR Compute Role을 씁니다. 앱에 연결하면 컴퓨트 실행 중 임시 자격 증명이 자동 공급되고, AWS SDK v3가 별도 설정 없이 이를 찾아 씁니다. 역할에는 이 테이블 하나에 대한 GetItem/PutItem/UpdateItem/Scan 권한만 부여합니다.

실습

1. DynamoDB 테이블 만들기

DynamoDB 콘솔 → 테이블테이블 생성: 테이블 이름 notice-board-cache, 파티션 키 cacheKey(문자열), 용량 모드 온디맨드.

2. IAM 정책·역할 만들고 Compute Role로 연결

IAM 콘솔 → 정책정책 생성 → JSON 탭에서 아래 최소 권한 정책을 붙여넣습니다.

{ "Version": "2012-10-17", "Statement": [ { "Sid": "NoticeBoardCacheAccess", "Effect": "Allow", "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Scan"], "Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/notice-board-cache" } ] }

12편 설계에는 GetItem/PutItem/Scan까지만 있었지만, 5단계의 조회수 원자 증가에 UpdateItem이 필요해 이 정책에 추가했습니다. REGION/ACCOUNT_ID를 실제 값으로 바꾸고 저장합니다. 이어서 역할 생성사용자 지정 신뢰 정책에서 Principal.Serviceamplify.amazonaws.com을 허용하는 sts:AssumeRole 신뢰 정책을 넣고, 방금 만든 정책을 붙여 역할(notice-board-ssr-cache-role)을 만듭니다. 이어 Amplify 콘솔 → 앱 선택 → App settingsIAM rolesCompute roleEdit에서 Default role로 이 역할을 선택하고 저장합니다.

3. AWS SDK 설치와 cache-handler.js 작성

npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
// notice-board/cache-handler.js const { DynamoDBClient } = require('@aws-sdk/client-dynamodb') const { DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand, } = require('@aws-sdk/lib-dynamodb') const TABLE_NAME = process.env.CACHE_TABLE_NAME || 'notice-board-cache' const dynamoClient = new DynamoDBClient({}) const docClient = DynamoDBDocumentClient.from(dynamoClient) module.exports = class DynamoDbCacheHandler { constructor(options) { this.options = options } async get(key) { const result = await docClient.send( new GetCommand({ TableName: TABLE_NAME, Key: { cacheKey: key } }), ) if (!result.Item) { return null } return { value: JSON.parse(result.Item.value), lastModified: result.Item.lastModified, tags: result.Item.tags ?? [], } } async set(key, data, ctx) { await docClient.send( new PutCommand({ TableName: TABLE_NAME, Item: { cacheKey: key, value: JSON.stringify(data), lastModified: Date.now(), tags: ctx.tags ?? [], }, }), ) } async revalidateTag(tags) { const tagList = [tags].flat() const scanResult = await docClient.send(new ScanCommand({ TableName: TABLE_NAME })) const staleItems = (scanResult.Items ?? []).filter((item) => (item.tags ?? []).some((tag) => tagList.includes(tag)), ) for (const item of staleItems) { await docClient.send( new PutCommand({ TableName: TABLE_NAME, Item: { ...item, value: JSON.stringify(null), lastModified: 0 }, }), ) } } resetRequestCache() {} }

get/set은 공식 예제와 같은 모양(value, lastModified, tags)을 유지해야 정상 동작합니다. revalidateTag는 테이블 전체를 Scan하므로 항목이 많아지면 느려집니다. 이 규모에서는 문제없지만 실무에서는 태그별 GSI를 둡니다.

4. next.config.mjs에 연결

프로젝트가 ESM(next.config.mjs)이라 require.resolve를 쓸 수 없습니다. fileURLToPath로 절대 경로를 만듭니다.

// notice-board/next.config.mjs import { fileURLToPath } from 'node:url' /** @type {import('next').NextConfig} */ const nextConfig = { cacheHandler: fileURLToPath(new URL('./cache-handler.js', import.meta.url)), cacheMaxMemorySize: 0, } export default nextConfig

cache-handler.jsmodule.exports를 쓰는 CommonJS 파일입니다. package.json"type": "module"이 없다면 이 파일은 그대로 CommonJS로 동작합니다.

5. 조회수 저장소를 DynamoDB로 옮기기

09편의 Map을 지우고 같은 테이블에 views:<공지ID> 키로 조회수를 저장합니다. 동시 요청에도 값이 어긋나지 않도록 원자적 증가(UpdateExpression: 'ADD')를 씁니다.

// notice-board/lib/view-counts.js import { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb' const TABLE_NAME = process.env.CACHE_TABLE_NAME || 'notice-board-cache' const dynamoClient = new DynamoDBClient({}) const docClient = DynamoDBDocumentClient.from(dynamoClient) export async function bumpViewCount(noticeId) { const result = await docClient.send( new UpdateCommand({ TableName: TABLE_NAME, Key: { cacheKey: `views:${noticeId}` }, UpdateExpression: 'ADD viewCount :one', ExpressionAttributeValues: { ':one': 1 }, ReturnValues: 'UPDATED_NEW', }), ) return result.Attributes.viewCount }

ADD는 항목이 없으면 0에서 시작해 더하므로, 두 인스턴스가 동시에 호출해도 값이 겹치거나 사라지지 않습니다. bumpViewCount가 비동기로 바뀌었으므로 Route Handler도 await를 붙입니다.

// notice-board/app/api/notices/[id]/views/route.js import { NextResponse } from 'next/server' import { bumpViewCount } from '../../../../../lib/view-counts.js' export async function POST(request, { params }) { const { id } = await params const views = await bumpViewCount(id) const instanceId = process.pid return NextResponse.json( { id, views, instanceId }, { headers: { 'x-instance-id': String(instanceId) } }, ) }

6. 로컬 확인과 재배포

로컬에서 DynamoDB에 접근하려면 자신의 AWS 자격 증명(aws configure로 등록한 프로필)이 필요합니다.

npm run dev

공지 상세 페이지를 새로고침하며 DynamoDB 콘솔에서 cacheKeyviews:1인 항목의 viewCount가 늘어나는지 확인하고, 커밋·푸시로 재배포합니다.

배포가 끝나면 09편과 같은 방식으로 반복 요청을 보내 비교합니다.

for i in $(seq 1 20); do curl -s -X POST "https://main.d1234567890abc.amplifyapp.com/api/notices/1/views" & done wait

09편에서는 views가 인스턴스별로 따로 증가해 순서가 오르내렸습니다. 이번에는 instanceId는 여전히 여러 개지만(정상), views는 1부터 20까지 겹치거나 거꾸로 가지 않고 증가합니다.

직접 해보기

  1. set에서 만료 시각을 expiresAt으로 함께 저장하고, 테이블의 TTL 설정에 연결해 오래된 항목이 자동 삭제되게 만들어 보세요.

정답 보기

DynamoDB 콘솔 → 테이블 → 추가 설정Time to live(TTL)에서 속성 이름(expiresAt)을 등록하면, 그 값이 지난 항목을 백그라운드에서 자동 삭제합니다.

자주 하는 실수

증상원인고치는 법
next.config.mjs에서 ReferenceError: require is not definedESM 파일에서 require.resolve를 그대로 사용fileURLToPath(new URL(...))로 절대 경로를 만든다
ValidationException: Item size has exceeded the maximum allowed sizeDynamoDB 항목 400KB 제한 초과캐시 대상 라우트를 줄이거나 압축해서 저장한다
로컬 실행 시 CredentialsProviderError개발 환경에 AWS 자격 증명이 없음aws configure로 로컬 프로필을 등록한다
배포 후에도 views 값이 오락가락함, 또는 AccessDeniedExceptionCompute Role 미연결·신뢰 정책 오류, 또는 정책 리소스 ARN 불일치App settings → IAM roles에서 역할 지정을 확인하고, 정책 ARN을 테이블 상세 화면과 맞춘다

확인 문제

문제 14지선다
cacheHandler의 set 메서드가 반드시 저장해야 하는 세 가지는
문제 24지선다
Amplify SSR Compute Role을 쓰는 이유로 가장 알맞은 것은
문제 34지선다
이 편의 revalidateTag 구현이 실무에서 그대로 쓰기 어려운 이유는

참고 자료

Last updated on