이 글에서는 Upstash Redis와 자바스크립트 Performance API를 활용해 Deno 앱의 성능을 극대화하는 방법을 살펴봅니다. Upstash for Redis는 서버 사이드 캐싱에 최적화된 서버리스 데이터베이스입니다. 실제로 제가 작업하던 웹 앱은 Lighthouse에서 초기 서버 응답 시간(Initial Server Response Time)이 500ms로 측정되어 감사를 통과하지 못했습니다. 그런데 Upstash 캐시를 추가한 뒤 이 수치를 150ms 미만으로 낮추며 감사를 통과할 수 있었습니다.
흥미로운 점은 어려운 부분이 캐시 추가 자체가 아니었다는 것입니다. 오히려 어디에 캐시를 적용할지 파악하는 것이 핵심이었습니다. 성능을 측정해야만 병목 지점을 정확히 식별하고, 최소한의 작업으로 최대의 성능 향상을 얻을 수 있었습니다. 이번 글에서는 성능 측정 방법까지 자세히 다뤄보겠습니다.
제 프로젝트는 Deno Fresh 웹 앱이었습니다. Deno는 빌드와 배포가 즉각적으로 이루어지기 때문에 최적화 작업에 이상적인 환경입니다. 피드백 루프가 짧아서 로컬에서 최적화 코드를 작성하고 서버에 푸시한 직후 바로 원격 사이트를 테스트할 수 있습니다.
기술 스택
여기서는 스켈레톤 앱을 기준으로 성능 개선 과정을 설명합니다. 사용된 도구는 다음과 같습니다.
upstash_redis: Upstash for Redis를 다루기 위한 Deno 모듈- Deno Fresh: Deno로 서버 사이드 렌더링(SSR) 앱을 구축할 수 있는 프로덕션 레벨 프레임워크
- 서버리스 로깅: 여기서는 콘솔을 사용하지만, 배포된 앱에서 실시간 측정값을 확인하려면 Logtail 같은 서비스가 필요합니다
프로젝트 설정
Node.js와 Deno의 핵심적인 차이 중 하나는 서드파티 모듈을 불러오는 방식입니다. Deno는 package.json 대신 URL과 임포트 맵(import map)을 사용합니다. 예를 들어 upstash_redis의 전체 URL은 https://deno.land/x/upstash_redis@v1.20.0입니다.
먼저 새로운 Deno Fresh 앱을 생성합니다.
deno run -A -r https://fresh.deno.dev upstash-redis-deno-perfDeno를 처음 사용한다면 몇 가지 터미널 명령어로 간단히 설치할 수 있습니다.
이제 프로젝트 루트 디렉터리의 import_map.json에 Upstash를 추가합니다.
{
"imports": {
"@/": "./",
"$fresh/": "https://deno.land/x/fresh@1.1.2/",
// ...생략
"$std/": "https://deno.land/std@0.177.0/",
"upstash/": "https://deno.land/x/upstash_redis@v1.20.0/"
}
}@/: 편의를 위한 임포트 별칭입니다. 소스 파일이 어느 폴더에 있든@/components형태로 루트의components폴더를 불러올 수 있습니다.$std/: Deno 표준 라이브러리 별칭으로,.env환경 변수 파일을 읽는 유틸리티 함수가 포함되어 있습니다.upstash/: 프로젝트 내 어떤 TypeScript나 JavaScript 파일에서도 Upstash for Redis 라이브러리에 접근할 수 있게 해줍니다.
스켈레톤 앱 구조
스켈레톤 앱은 다음 두 가지 데이터를 가져옵니다.
- Tinybird(서버리스 Clickhouse) 웹 분석 기반 최근 28일간 페이지 조회수
- Webmentions 기반 페이지 좋아요 수
두 데이터 모두 fetch 요청으로 가져오기 때문에 실제 비즈니스 앱의 서버 작업을 충분히 대변할 수 있습니다. likes와 views 변수에 각 API의 응답이 담기며, 이 값들은 프론트엔드에 표시됩니다.
해당 페이지의 서버 handler 코드는 대략 다음과 같습니다.
export const handler: Handlers<Data> = {
async GET(request, context) {
const { url } = request;
const { pathname } = new URL(url);
const likes = await getWebmentionLikes(pathname);
const views = await getTinybirdViews({ days: 28 });
return context.render({ likes, views });
},
};요청 객체에서 pathname을 추출하고, 이를 데이터 헬퍼 함수에 전달한 뒤 원격에서 가져온 값을 반환하는 구조입니다.
효율성을 높이기 위해 헬퍼 함수 호출은 다음과 같이 재구성할 수 있습니다.
const [likes, views] = await Promise.all([
getWebmentionLikes(pathname),
getTinybirdViews({ days: 28 }),
]);자바스크립트 Performance Web API 활용하기
최적화의 첫걸음은 반드시 성능 측정이어야 합니다. 병목 지점이 늘 예상한 곳에 있는 것은 아니기 때문입니다. 측정 없이 진행하면 결국 차선책에 시간과 리소스를 낭비하기 쉽습니다. Performance API는 이런 문제를 해결하는 데 큰 도움이 됩니다. 이 섹션에서는 Performance API를 활용해 앱의 어느 부분에 Upstash for Redis를 적용하는 것이 가장 효과적인지 판단하는 방법을 알아보겠습니다.
클라이언트 브라우저에서는 window.performance로 Performance Web API에 접근할 수 있습니다. 특히 Deno는 서버에서도 Web API를 지원하기 때문에 performance 객체가 서버 사이드 코드에서 전역적으로 사용 가능합니다. 주요 메서드 두 가지를 살펴보겠습니다.
performance.mark('마크 이름'): 특정 시점을 나타내는PerformanceMark객체를 생성합니다. 마크 이름은 이후 measure를 만들 때 사용됩니다.performance.measure('설명', 시작 마크, 종료 마크):PerformanceMeasure객체를 생성합니다. 시작과 종료 시점을 라벨과 연결해주므로 로깅 및 실행 시간 계산에 유용합니다.
timeEvent: 성능 측정 헬퍼 함수
기본기를 익혔으니 timeEvent 함수를 만들어 보겠습니다. 이 함수는 PerformanceMeasure 객체 배열과 측정 대상 함수를 입력받습니다. 시작 마크를 생성하고, 전달받은 함수를 실행한 직후 종료 마크를 만든 다음, 입력받은 배열에 새 측정 결과를 추가합니다. utils/performance.ts 코드는 다음과 같습니다.
export async function timeEvent<EventReturnType>(
eventFunction: () => Promise<EventReturnType>,
{
description,
performanceMeasures,
}: { description: string; performanceMeasures: PerformanceMeasure[] },
): Promise<EventReturnType> {
// 준비
const startName = `${description}-started`;
const finishName = `${description}-finished`;
// 측정
performance.mark(startName);
const result = await eventFunction();
performance.mark(finishName);
// 기록
performanceMeasures.push(
performance.measure(description, startName, finishName),
);
return result;
}함수는 제네릭으로 작성했지만, 스켈레톤 앱에서는 EventReturnType이 항상 숫자입니다.
측정 코드로 서버 핸들러 업데이트
새로 만든 timeEvent 함수를 핸들러에 적용하고 비교 측정을 시작해 보겠습니다. 업데이트된 서버 핸들러 코드는 다음과 같습니다.
import type { Handlers, PageProps } from "$fresh/server.ts";
import "$std/dotenv/load.ts"; /* 가독성을 위해 포함했으며, 일반적으로는
`dev.ts`에서 프로젝트당 한 번만 임포트하면 됩니다 */
import { timeEvent } from "@/utils/performance.ts";
// ...생략
export const handler: Handlers<Data> = {
async GET(request, context) {
// ...생략
const performanceMeasures: PerformanceMeasure[] = [];
const [likes, views] = await Promise.all([
timeEvent<number>(() => getWebmentionLikes(pathname), {
description: "web-mention-likes",
performanceMeasures,
}),
timeEvent<number>(() => getTinybirdViews({ days: 28 }), {
description: "analytics-views",
performanceMeasures,
}),
]);
// 프로덕션에서는 서버리스 로깅 서비스로 교체하세요
console.log({ performanceMeasures });
return context.render({ likes, views });
},
};timeEvent 함수는 전달된 함수의 결과를 그대로 반환합니다. 덕분에 기존 데이터 헬퍼 함수를 timeEvent 호출로 감싸기만 하면 됩니다. 여기서는 로컬 환경에서 실행했지만, 실제 프로덕션 앱이라면 반드시 라이브 사이트에서 측정해야 합니다. 서버의 백본 네트워크는 로컬과 성능 특성이 다르기 때문입니다. 서버에서 측정할 때는 Logtail 같은 로깅 서비스로 결과를 기록하세요.
콘솔 로그 화면에서 PerformanceMeasure 객체가 제공하는 측정값들을 확인할 수 있습니다.
- name (이름)
- start time (시작 시간)
- duration (소요 시간, 밀리초)
앱을 렌더링하려면 좋아요와 조회수 두 데이터가 모두 필요합니다. 이번 측정에서는 두 함수가 비슷한 시간(약 2초)이 걸렸습니다. 만약 한쪽이 현저히 느렸다면 가장 느린 값에만 Upstash for Redis 캐싱을 적용하면 되지만, 여기서는 둘 다 적용하겠습니다. 참고로 실제 프로덕션 환경에서는 최소 수백 개의 데이터 포인트를 확보한 뒤 평균이나 P90 같은 집계 지표로 비교하는 것이 바람직합니다.
분석 헬퍼 코드에 Upstash for Redis 적용하기
이제 분석(analytics) 헬퍼 함수에 Upstash for Redis를 추가하는 코드를 살펴보겠습니다. Webmentions 헬퍼 함수도 구조가 유사하며, GitHub 저장소(아래 링크)에서 전체 코드를 확인할 수 있습니다.
import { Redis } from "upstash/mod.ts";
const UPSTASH_REDIS_REST_TOKEN = Deno.env.get("UPSTASH_REDIS_REST_TOKEN");
if (typeof UPSTASH_REDIS_REST_TOKEN === "undefined") {
console.error("env `UPSTASH_REDIS_REST_TOKEN` must be set");
}
const UPSTASH_REDIS_REST_URL = Deno.env.get("UPSTASH_REDIS_REST_URL");
if (typeof UPSTASH_REDIS_REST_URL === "undefined") {
console.error("env `UPSTASH_REDIS_REST_URL` must be set");
}
const redis = new Redis({
token: UPSTASH_REDIS_REST_TOKEN,
url: UPSTASH_REDIS_REST_URL,
});먼저 Upstash for Redis 객체를 초기화해야 합니다. 필요한 UPSTASH_REDIS_REST_TOKEN과 UPSTASH_REDIS_REST_URL 값은 Upstash 콘솔에서 확인할 수 있습니다. 아직 계정이 없다면 회원가입도 몇 분이면 끝납니다. 두 값을 프로젝트 루트의 .env 파일에 추가하세요.
첫 줄에서 앞서 설정한 임포트 맵의 upstash 키를 사용한 점에 주목하세요. 매 파일마다 전체 임포트 URL을 작성하는 것보다 훨씬 편리합니다. upstash_redis의 새 버전이 출시되면 버전 번호를 한 곳에서만 수정하면 됩니다.
다음은 getTinybirdViews 함수입니다.
export async function getTinybirdViews({
days,
}: {
days: number;
}): Promise<number> {
try {
// ...생략
const cachedCount = (await redis.get("view-count")) as number | null;
if (cachedCount != null) {
return cachedCount;
}
// ...생략
const response = await fetch(
`https://api.tinybird.co/v0/pipes/${TINYBIRD_PIPE_NAME}.json?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${TINYBIRD_TOKEN}`,
},
},
);
const {
data: [{ count_sessions: count = -1 }],
} = await response.json();
if (typeof count === "number" && count > 0) {
const CACHE_TTL_SECONDS = 14_400;
await redis.set("view-count", count);
await redis.expire("view-count", CACHE_TTL_SECONDS);
}
return count;
} catch (error: unknown) {
// ...생략
}
}동작 흐름은 다음과 같습니다.
redis.get('view-count')호출로view-count에 대한 캐시된 값이 이미 있는지 확인합니다.- 캐시가 있으면 해당 값을 반환하고, 없으면 Tinybird에서 새 값을 가져옵니다.
redis.set('view-count', value)로 새 값을 저장한 뒤redis.expire(TTL)로 만료 시간을 설정합니다. 이렇게 하면 TTL(Time To Live)이 지정되어 그 이후에는 데이터가 오래된 것으로 간주됩니다. 여기서는 TTL을 4시간으로 설정했으며, 4시간이 지난 후의getTinybirdViews호출은 Tinybird에서 새 값을 가져오게 됩니다.
두 데이터 쿼리 모두에 Upstash for Redis를 통합한 상태에서 페이지를 몇 번 새로고침하면 속도가 눈에 띄게 빨라집니다. 약 2초 이상 걸리던 응답 시간이 약 0.29초로 단축되었습니다. 물론 로컬 환경에서 측정했고 데이터 포인트도 많지 않으므로 이 수치 자체에 너무 의미를 두지는 마세요. 직접 여러분의 앱에 적용해 어느 정도의 개선 효과가 있는지 확인해 보시기 바랍니다.
마무리
이번 글에서는 자바스크립트 Performance API를 활용해 최적화 작업의 우선순위를 데이터 기반으로 결정하는 방법을 살펴봤습니다. 나아가 Deno Fresh 앱에 Upstash for Redis를 구현하는 과정도 함께 다뤘습니다. 마지막으로 Deno가 서버 사이드에서 Web API를 지원한다는 점도 확인했습니다. 이는 학습 곡선을 크게 완만하게 만들어 줍니다. 게다가 Deno의 즉각적인 배포 덕분에 짧은 피드백 루프를 유지하며 성능 튜닝을 더 빠르게 반복할 수 있다는 점도 최적화 관점에서 큰 장점입니다.
이 글이 Upstash for Redis와 Performance API를 이해하는 데 도움이 되었기를 바랍니다. Deno나 Deno Fresh가 처음이라면 제가 작성한 Deno 입문 콘텐츠도 함께 참고해 보세요. 앱의 전체 코드는 GitHub에서 열어볼 수 있습니다.