서버리스 블로그 댓글 API 구축하기: Workers KV로 30분 만에 만들어보자

들어가며
Workers와 간편하게 통합할 수 있으면서 Key-Value 방식으로 작동 구조가 단순한 Workers KV 스토리지, Worker API Gateway를 이용해서 REST API 방식으로 댓글 기능을 구현해 보도록 하겠습니다.
KV 스토리지는 기본적으로 오름차순 정렬을 지원합니다. 이 말은 키 설계만 잘하면 굳이 복잡한 SQL 문법 없이 단순 GET, PUT, LIST 명령만으로 사전 정렬된 댓글들을 방문자들에게 제공할 수 있습니다.
API, 댓글 타입
우선 저희 블로그는 로그인 기반이 아닌 관계로 방문자 누구나 댓글 보기, 작성 모두 가능하게 만들려고 하는데요, API Endpoint는 다음과 같습니다.
- 댓글 조회: GET /comments/:slug
- 댓글 생성: POST /comments/:slug
이제 생성된 각 댓글이 저장되는 타입과, 댓글 조회 시 전달해 줄 목록 타입도 설정해 주겠습니다.
// types.ts
export type Comment = {
id: string; // 댓글 id
data: string;
createdAt: string;
};
export interface ListComments {
postId: string;
comments: Comment[];
page: {
has_more: boolean;
next_cursor?: string;
};
}
여기서 next_cursor
는 Workers KV에서 가져올 key들이 많을 경우 pagination을 위해 사용할 식별값입니다.
추가로 KV는 List 명령 응답에 list_complete
값을 통해서 남은 페이지들이 있는지 확실하게 알려주는데요, 관리 편의를 위해 has_more
값으로 넣어주도록 하겠습니다.
키 설계
Workers KV는 prefix
기반 List 명령어를 지원하는데요, 이렇게 정렬 기능을 지원하는 Key-Value 구조의 DB를 사용할 때는 키 설계를 주의해서 해야 prefix(comment:${POST_SLUG}
)를 기반으로 사전 정렬된 댓글 목록들을 빠르게 가져올 수 있습니다.
// key
comment:${POST_SLUG}:${SORT_KEY}
최신 댓글이 위쪽에 오도록 하기 위해서는 정렬 키(SORT_KEY
) 값이 시간이 지날수록 작아져야 합니다. 저는 단순하게 작동할 수 있도록 UTC 기준 1970년 1월 1일부터 현재까지 몇 초가 지났는지 표현하는 Unix time을 이용하도록 하겠습니다.
JavaScript에서는 Unix Time이 milliseconds까지 포함한 13자리 정수형으로 표현되니, 최대치인 9_999_999_999_999에서 현재 시간을 빼주면 정렬 키는 자연스럽게 시간이 흐를수록 작아지는데요, 코드로 확인하도록 하겠습니다.
const epoch = new Date().getTime(); // 13자리(milliseconds (13-digit))
const POST_SLUG = "hello-world";
// 혹시 모를 상황 대비하여 padStart로 13자리 고정
const SORT_KEY = (9_999_999_999_999 - epoch).toString().padStart(13, "0");
const key = `comment:${POST_SLUG}:${SORT_KEY}`;
// 최신 날짜일수록 SORT_KEY 작아짐. 구분을 위해 공백 적용
// 2025-09-28T10:35:20.924Z => comment:hello-world:8240944 279075
// 2025-09-28T10:33:12.435Z => comment:hello-world:8240944 407564
댓글 생성
댓글 최대 길이를 500자로 제한해서 업데이트 함수를 만들어 주겠습니다.
// putComment.ts
import { Comment } from "./types";
interface Params {
postId: string;
data: string;
}
export const putComment = async (input: Params, env: Env): Promise<void> => {
const { postId, data } = input;
const now = new Date();
const epoch = now.getTime();
const sortKey = (9_999_999_999_999 - epoch).toString().padStart(13, "0");
const key = `comment:${postId}:${sortKey}`;
const value: Comment = {
id: sortKey, // 단순 식별값. sessionID 사용해도 됨
data: data.slice(0, 500), // 500자 제한
createdAt: now.toISOString(),
};
await env.WORKERS_KV.put(key, JSON.stringify(value));
return;
};
댓글 가져오기
이제 prefix(comment:${POST_SLUG}
)가 적용된 LIST 명령을 이용해서 키 목록을 가져오고, 여러 키들을 동시에 읽을 수 있는 GET 명령어를 이용해 댓글 목록을 가져올 수 있습니다.
// listComments.ts
import { Comment, ListComments } from "./types";
interface Params {
postId: string;
limit: number;
cursor?: string;
}
export const listComments = async (input: Params, env: Env): Promise<ListComments> => {
const { postId, limit, cursor } = input;
const prefix = `comment:${postId}`;
const listKeys = await env.WORKERS_KV.list({
prefix,
limit: Math.max(1, Math.min(20, limit)), // 최대 20개 제한.
...(cursor && { cursor }),
});
const { keys, list_complete } = listKeys;
// keys => 최신 날짜일수록 SORT_KEY 작아짐. 구분을 위해 공백 적용
// [
// { name: "comment:hello-world:8240944 279075" }, // 2025-09-28T10:35:20.924Z
// { name: "comment:hello-world:8240944 407564" } // 2025-09-28T10:33:12.435Z
// ];
let comments: Comment[] = [];
if (keys.length) {
const keyNames = keys.map((d) => d.name);
const result = await env.WORKERS_KV.get<Comment>(keyNames, { type: "json" });
comments = [...result.values()].filter((v) => !!v);
}
const output: ListComments = {
postId,
comments,
page: {
has_more: !list_complete,
next_cursor: "cursor" in listKeys ? listKeys.cursor : undefined,
},
};
return output;
};
캐싱 적용
Workers KV 무료 티어에서 List 요청에 하루 1,000개의 제한을 두고 있습니다. 댓글같이 실시간성이 중요하지 않은 기능에서는 하루 100,000개의 요청까지 무료인 GET 요청을 적극 활용해야 합니다.
개발자들은 기본적으로 Cash가 없으니까, 가져온 댓글 목록을 limit
, cursor
조합으로 Cache 해놓고 expirationTtl
옵션을 이용하여 자동으로 만료시킴으로써 무료 사용량과 응답 속도, 최종 일관성 모두 만족시킬 수 있습니다.
...
export const listComments = async (input: Params, env: Env): Promise<ListComments> => {
const { postId, limit, cursor } = input;
// limit, cursor에 의한 캐시 꼬임 방지
const cacheKey = `comment:cache:${postId}:${limit}:${cursor}`;
const cacheValue = await env.WORKERS_KV.get<ListComments>(cacheKey, { type: "json" });
// 캐싱된 댓글 목록이 있으면 바로 응답
if (cacheValue) return cacheValue;
// 없으면 기존과 동일
...
const output: ListComments = { ... };
// 캐시 업데이트(1분 후 만료됨)
await env.WORKERS_KV.put(cacheKey, JSON.stringify(output), { expirationTtl: 60 });
return output;
};
컨트롤러
컨트롤러로 이전 포스팅에서 소개해 드린 Express.js 스타일 API Gateway를 사용할 건데요,
// index.ts
...
app.get("/comments/:id", async (req, context) => {
const { env, params, query } = context;
const postId = params["id"];
const limit = query["limit"] || "10";
const cursor = query["cursor"] || undefined;
const output = await listComments({ postId, limit: Number(limit), cursor }, env);
return Response.json(output);
});
app.post("/comments/:id", async (req, context) => {
const { env, ctx, params } = context;
const postId = params["id"];
...
const { data } = await req.json() as { data?: string };
if (!data) return Response.json({ ok: false }, { status: 400 });
const command = putComment({ postId, data }, env)
ctx.waitUntil(command) // blocking 없이 사용자에게 즉시 응답 가능.
return Response.json({ ok: true }, { status: 201 });
});
app.export()
테스트
npm run dev
명령으로 로컬에서 먼저 테스트해 보겠습니다. 댓글 두 개 먼저 등록해 주고
curl -X POST -d '{"data": "이건 첫번째 레슨"}' http://localhost:8787/comments/hello-world
{"ok":true}
curl -X POST -d '{"data": "이제 두번째 레슨"}' http://localhost:8787/comments/hello-world
{"ok":true}
GET 메서드로 댓글 목록 조회해 보면 잘 나오고 있습니다.
curl -X GET http://localhost:8787/comments/hello-world
{
"postId": "hello-world",
"comments": [
{ "id": "xxxx", "data": "이제 두번째 레슨", "createdAt": "2025-09-28T10:35:20.924Z" },
{ "id": "xxxx", "data": "이건 첫번째 레슨", "createdAt": "2025-09-28T10:33:12.435Z" },
],
"page": {
"has_more": false,
}
};
limit
옵션 테스트를 위해 댓글 목록 1개로 제한해 보고
curl -X GET 'http://localhost:8787/comments/hello-world?limit=1'
# cacheKey: comment:cache:hello-world:1:undefined
{
"postId": "hello-world",
"comments": [
{ "id": "xxxx", "data": "이제 두번째 레슨", "createdAt": "2025-09-28T10:35:20.924Z" },
],
"page": {
"has_more": false,
"next_cursor": "Y29tbxxxx",
}
};
응답받은 cursor
옵션도 체크해 보면 정상적으로 작동합니다.
curl -X GET 'http://localhost:8787/comments/hello-world?limit=1&cursor=Y29tbxxxx'
# cacheKey: comment:cache:hello-world:1:Y29tbxxxx
{
"postId": "hello-world",
"comments": [
{ "id": "xxxx", "data": "이건 첫번째 레슨", "createdAt": "2025-09-28T10:33:12.435Z" },
],
"page": {
"has_more": false,
}
};
이제 컨트롤러가 동일한 옵션으로 요청 받으면 동일한 cacheKey
가 생성되니, 비싼 LIST 요청이 아닌 저렴한 GET 요청으로 캐싱된 댓글 목록들을 응답해 줄 수 있게 됩니다.
마치며
이렇게 복잡한 DB 설정, SQL 문법 없이 Key-Value 구조만으로도 개인 블로그에는 차고 넘치는 성능의 서버리스 댓글 API를 만드는 방법을 알아보았습니다.
다음 레슨 포스팅에서는 Next.js SSG 빌드 된 블로그에 댓글 API를 적용해 보도록 하겠습니다.
더보기
댓글