캐시 설정해서 응답 시간 개선하기
작업일 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.txt 와 sitemap.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개가 상당수 직렬이었다. listPosts 가 COUNT(*) → 행 조회 → 태그 붙이기 → 댓글 수 세기를 차례로 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 은 빈 객체가 된다. imageSizes 가 Map 을 반환했는데 그대로 캐시하면 되읽을 때 {} 가 된다. 행 배열까지만 캐시하고 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.js 가 next/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 |
이미지를 우리 도메인에서 내보내 연결 하나를 없앤다 |
댓글 0
불러오는 중…