캐시 설정해서 응답 시간 개선하기

작업일 2026-08-08 · 대상 공개 화면 전체 · 결과 페이지 1.75초 → 0.62초, 이미지 0.47초 → 0.070초

1. 무엇이 느렸나

"별 내용도 없는 사이트인데 느리다"에서 출발해, 어느 층이 시간을 쓰는지 계층별로 분리해 측정했다.

무엇을 재나 TTFB 앞 단계와의 차이 그 차이의 정체
정적 자산 (엣지 캐시 HIT) 0.26s 순수 네트워크. 이 PC ↔ Netlify 엣지
/robots.txt (함수, 쿼리 0회) 0.55s +0.29s 엣지 → 함수 리전 왕복
/sitemap.xml (함수, 쿼리 1회) 0.74s +0.21s DB 쿼리 1회의 값
/ (함수, 쿼리 8회) 1.75s +1.20s 직렬 왕복 누적

TTFB(Time To First Byte): 웹 브라우저(클라이언트)가 서버에 요청을 보낸 후 첫 번째 데이터(1바이트)를 전달받기까지 걸리는 시간

핵심은 robots.txtsitemap.xml 의 차이다. 둘은 같은 함수·같은 리전·같은 경로인데 쿼리 하나 차이로 210ms 가 붙는다.
로컬 DB 라면 5ms 수준이다. 이 210ms 는 쿼리 계산 시간이 아니라 함수와 DB 사이의 이동 시간이다.

원인은 두 가지였다.

(1) 캐시가 아예 없었다. app/ 아래 32개 파일이 force-dynamic 이고 revalidate 는 한 곳도 없었다. 응답 헤더가 이랬다.

Cache-Control: private,no-cache,no-store,max-age=0,must-revalidate
Cache-Status: "Netlify Durable"; fwd=bypass

방문자 한 명 한 명이 매번 함수를 깨우고 쿼리 8개를 돌렸다.

(2) 그 8개가 상당수 직렬이었다. listPostsCOUNT(*) → 행 조회 → 태그 붙이기 → 댓글 수 세기를 차례로 await 했다. 210ms × 4 ≈ 0.84초가 이 한 줄기에서 나왔다.

이미지는 별개 문제였다. 본문 이미지 주소가 /uploads/... 였고, 이 경로가 저장소로 302 리다이렉트를 줬다. 브라우저는 우리 함수에 한 번 들렀다가(0.26초) 저장소로 다시 갔다(0.5초). 게다가 저장소가 다른 호스트라 연결을 통째로 하나 더 열었고, 그 호스트가 한국에서 LAX(로스앤젤레스)로 붙어 TLS 핸드셰이크에만 0.31초가 들었다.


2. 무엇을 설정했나

2.1 조회 결과 캐시 — src/web/lib/cache.js

공개 화면이 읽는 조회를 태그 하나(content)로 묶어 캐시한다.

export function cached(fn, keyParts) {
  return unstable_cache(fn, keyParts, { tags: [TAG], revalidate: MAX_AGE })
}

export function invalidateContent() {
  revalidateTag(TAG)
}

캐시 대상

조회 위치
히어로 문구 lib/site.js getHero
카테고리 목록 lib/taxonomy.js listCategories
태그 목록 lib/taxonomy.js listTags
글 목록·요약 lib/list.js postSummaries
글 상세 (발행글) lib/posts.js getBySlug
이전·다음 글 lib/posts.js neighbors
관련 글 lib/posts.js related
연재 목차 lib/posts.js seriesParts
이미지 실측 크기 lib/post-images.js imageSizes

2.2 캐시하지 않은 것

의도적으로 뺀 것들이고, 이 구분이 이 설계의 안전선이다.

  • 관리자 조회. getBySlug(slug, { isAdmin: true }) 는 캐시를 타지 않는다. 초안이 캐시에 담기면 로그인하지 않은 사람에게 그대로 나간다.
  • 검색. searchSummaries 는 질의 공간이 무한해서 캐시가 쌓이기만 한다.
  • 쓰기 경로의 재조회. putHero·reorderCategories 는 방금 쓴 값을 확인해야 하므로 캐시를 우회하는 내부 함수(readHero·readCategories)를 쓴다.
  • 댓글 본문. 클라이언트가 따로 불러오므로 애초에 서버 HTML 에 없다.

2.3 무효화 — 시간이 아니라 태그

랜딩이 지금까지 캐시를 못 걸었던 이유는 편집 화면이 "저장하면 바로 반영됩니다" 라고 약속하고 있어서였다. 시간 만료를 쓰면 그 약속이 깨지고, 짧게 잡으면 캐시가 무의미해진다.

그래서 글·분류·연재·히어로·댓글을 바꾸는 경로가 invalidateContent() 를 부른다. revalidate = 3600 은 만료 정책이 아니라 무효화를 놓쳤을 때의 안전망이고, 정상 경로에서는 도달하지 않는다.

무효화 호출은 라우트가 아니라 도메인 쓰기 함수에 붙였다.

lib/posts.js     create · update · setStatus · softDelete
lib/taxonomy.js  createCategory · updateCategory · deleteCategory · reorderCategories
lib/site.js      putHero
lib/comments.js  create · remove

라우트에 두면 새 라우트가 생길 때 빠뜨린다. 도메인 함수에 두면 어느 경로로 들어와도 걸린다.

조회수 증가(POST /view)에는 붙이지 않았다. 페이지를 볼 때마다 불리므로 여기서 무효화하면 캐시가 영원히 비어 있게 된다.

2.4 캐시가 만든 함정 두 가지

Date 가 문자열이 된다. 캐시는 값을 JSON 으로 구웠다가 되읽으므로 Date 객체가 ISO 문자열로 바뀐다. 캐시에 담긴 결과와 아닌 결과의 모양이 달라지면 캐시가 비어 있는 동안에만 통과하고 두 번째 요청부터 깨지는 코드가 생긴다 (post.published_at.toISOString()). 경계에서 되돌린다.

const DATE_FIELDS = ['published_at', 'created_at', 'updated_at', 'deleted_at']
export function reviveDates(value) { /* 문자열이면 Date 로 */ }

Map 은 빈 객체가 된다. imageSizesMap 을 반환했는데 그대로 캐시하면 되읽을 때 {} 가 된다. 행 배열까지만 캐시하고 Map 조립은 캐시 밖에서 한다.

2.5 함께 고친 직렬 왕복

캐시가 비어 있을 때의 비용을 줄인다.

위치
listPostRows 개수 → 행 (직렬) 함께
postSummaries 태그 붙이기 → 댓글 수 (직렬) 함께 (둘 다 행의 id 만 필요)
neighbors 이전 글 → 다음 글 (직렬) 함께

listPosts 는 태그를 붙이지 않는 listPostRows 로 바뀌었다. 부르는 쪽이 태그와 댓글 수를 동시에 조회해야 하기 때문이다.

2.6 이미지를 우리 도메인으로

Netlify 엣지가 저장소를 대신 읽어 같은 호스트로 내보낸다 (netlify.toml).

[[redirects]]
  from = "/uploads/*"
  to = "https://stroage.ksh.ai.kr/uploads/:splat"
  status = 200
  force = true

페이지가 이미 열어 둔 연결을 그대로 쓰므로 추가 핸드셰이크가 사라진다. 바이트는 함수를 통과하지 않는다 — 원래 302 를 택한 이유가 함수 호출 수·응답 크기 제한이었는데, 엣지 프록시는 그 제약과 무관하다. force 가 없으면 앱의 /uploads 라우트가 먼저 잡아 302 를 준다.

저장된 값은 여전히 상대 경로다 (R-99). 앱의 302 라우트는 netlify.toml 이 적용되지 않는 로컬 개발용으로 남는다.

2.7 하려다 되돌린 것 — 전체 라우트 캐시

랜딩은 요청마다 달라지는 값을 읽지 않으므로 완성된 HTML 자체를 캐시할 수 있다(0.62초 → 0.26초). 실제로 해 봤고 되돌렸다.

Error occurred prerendering page "/"
connect ECONNREFUSED 127.0.0.1:5432

정적으로 구우면 빌드 시점에 렌더해야 하고, 그러면 빌드가 DB 에 의존한다. DB 가 잠깐 안 되면 배포가 통째로 실패하고, DB 가 없는 곳에서는 빌드 자체가 안 된다. 조회가 이미 캐시를 타므로 남는 비용은 렌더뿐이고, 0.36초를 위해 배포 안정성을 걸 이유가 없다고 판단했다.


3. 어떻게 시험했나

3.1 단위 테스트

npm test   # node --test --import ./tests/alias-hook.mjs "tests/**/*.test.js"
→ tests 103, pass 103, fail 0

캐시 도입 과정에서 lib/cache.jsnext/cache 를 임포트하자 테스트 2개가 모듈 해석에 실패했다. next 패키지에 exports 필드가 없어 Node ESM 이 확장자를 붙여 보지 않기 때문이다. 라이브러리 임포트를 바꾸지 않고 테스트 리졸버(tests/alias-resolver.mjs)가 번들러와 같은 규칙을 주도록 고쳤다 — 훅의 원래 취지가 그것이었다.

3.2 빌드

npm run build   # next build (Turbopack)
→ ✓ Compiled successfully, 정적 페이지 7/7

TypeScript 검사 포함. 2.7절의 되돌림은 이 단계가 잡아냈다.

3.3 측정 — 계층 분리법

추측을 배제하기 위해, 한 층씩만 다른 경로를 골라 차이를 뺐다.

경로 함수 DB 쿼리 이 경로가 격리하는 것
/_next/static/chunks/*.js 안 탐 0 순수 네트워크
/robots.txt 0 함수 왕복
/sitemap.xml 1 쿼리 1회의 값
/ 8 누적

각 경로를 5회 호출해 중앙값을 쓴다. 첫 회는 콜드 스타트가 섞이므로 판단에서 제외한다.

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{time_starttransfer} " "https://ksh.ai.kr$u"
done

time_starttransfer(TTFB)를 쓴다. 전송 크기에 좌우되는 time_total 과 달리 "서버가 첫 바이트를 내기까지"만 재므로 서버 시간 비교에 맞다. 연결 수립 비용을 따로 보려면 time_appconnect(TLS 완료)를 함께 본다.

이미지는 연결 재사용 여부로 갈렸다. curl 한 번에 URL 두 개를 주면 같은 연결을 쓰므로 브라우저와 같은 조건이 된다.

curl -s https://ksh.ai.kr/ -o /dev/null -w "페이지: %{time_starttransfer}s\n" \
     "$IMG"                -o /dev/null -w "이미지: %{time_starttransfer}s\n"

이 구분이 없었으면 "이미지 0.47초"만 보고 캐시 문제로 오진했을 것이다. 실제 원인은 핸드셰이크였다.

3.4 캐시가 실제로 붙었는지 확인

시간만 보면 우연히 빨라진 것과 구분되지 않는다. 헤더로 직접 확인했다.

Cache-Status: "Netlify Edge"; hit; ttl=31535991
Cache-Control: public,max-age=31536000,immutable

hit 이 나온다는 것은 엣지가 프록시 응답까지 캐시하고 있다는 뜻이다 — 이번 변경에서 가장 불확실했던 지점이었고, 여기서 확정됐다.

3.5 배포 반영 확인

Netlify 배포는 즉시가 아니므로, 코드가 바뀌었을 때만 나타나는 관찰 가능한 표식을 정해 그것이 나타날 때까지 폴링했다 (/uploads/... 의 응답 코드가 302 → 200). 시간 측정은 표식이 확인된 뒤에만 했다.

3.6 회귀 확인

캐시가 Date 를 문자열로 바꾸는 문제는 두 번째 요청부터 터지므로 한 번 열어 보는 것으로는 못 잡는다. 상세 페이지를 5회 연속 호출해 전부 200 인지, 날짜와 조회수가 실제로 렌더되는지 확인했다.

200:1.939 200:0.696 200:0.779 200:0.641 200:0.632
2026.08.08   ← <time> 과 본문 날짜 모두 정상

4. 전후 비교

모두 같은 PC·같은 회선에서 5회 측정한 중앙값(TTFB, 초)이다.

페이지

대상 변화
랜딩 / 1.75 0.62 −65%
API /api/v1/posts 1.60 0.60 −63%
글 목록 /posts 미측정 ※ 0.62
글 상세 /p/[slug] 미측정 ※ 0.63
/sitemap.xml 0.74 0.74 변화 없음 (캐시 대상 아님)
/robots.txt 0.55 0.55 변화 없음 (DB 를 안 씀)

※ 개선 전 이 두 경로는 재지 않았다. 랜딩·API 와 같은 조회 경로를 쓰므로 비슷했을 것으로 보이나, 측정하지 않은 값이라 수치를 적지 않는다.

이미지

조건 변화
브라우저 조건 (페이지 열린 뒤, 연결 재사용) 0.47 0.070 −85%
단독 요청 (매번 새 연결) 0.47 0.26 −45%
302 경유 (개선 전 실제 경로, 리다이렉트 포함 총시간) 1.01

읽는 법

개선 후 페이지가 전부 0.62초 근처에 모였고, 쿼리를 하나도 쓰지 않는 /robots.txt 가 0.55초다. DB 비용이 사실상 0이 됐다는 뜻이다.

남은 0.62초의 구성은 이렇다.

0.26s  네트워크 (이 PC ↔ Netlify 엣지, 싱가포르)
0.29s  엣지 → 함수 리전 왕복
0.07s  렌더

즉 남은 시간은 함수를 한 번 깨우는 값이지 우리 코드가 쓰는 시간이 아니다. 이 아래로 내려가려면 함수를 아예 안 부르는 방법(2.7절)뿐이고, 그 대가는 빌드가 DB 에 묶이는 것이다.


5. 검토했으나 하지 않은 것

항목 판단
Cloudflare 캐시 규칙 손보기 불필요. 확인해 보니 이미 cf-cache-status: HIT 였다. 이미지 비용의 정체는 캐시가 아니라 연결이었다
Supabase 리전을 함수 리전에 맞추기 불필요. 쿼리 왕복이 캐시로 사라져 이전 비용 대비 효과가 안 맞는다
Cloudflare Pro (한국 트래픽 ICN 라우팅) 불필요. LAX 거리 문제는 이미지를 우리 도메인으로 옮기면서 우회했다
랜딩 전체 라우트 캐시 보류. 0.36초를 얻고 배포 안정성을 잃는다 (2.7절)

관련 커밋 (근데 리파지토리 프라이빗임)

커밋 내용
22d6e3f 공개 화면 조회를 캐시해 요청당 DB 왕복을 없앤다
5ba0d79 이미지가 저장소를 직접 가리키게 한다
33c9946 공개가 목적인 값을 시크릿 스캔에서 뺀다
941d7ea 5ba0d79 되돌림 — 같은 오리진으로 내보내므로 상대 경로가 맞다
5867ee0 이미지를 우리 도메인에서 내보내 연결 하나를 없앤다