
Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (1편 · 개념)
Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (1편 · 개념)
— AWS Self-hosting 환경에서 터지는 캐시 불일치, Redis CacheHandler로 진짜 해결해보자
Next.js 15~16의 Cache Components + cacheHandlers + Redis 를 직접 붙여본 경험을 정리한 글입니다. 특히 AWS + ALB + 다중 인스턴스 구조에서 “데이터 캐시가 인스턴스마다 갈라지는 문제”를 해결하는 게 목표입니다.
1. 왜 Next.js를 쓰면서 Vercel은 안 쓰는가?
현업에서 Next.js를 도입하는 이유는 다들 비슷하죠.
- ✅ SSR 기반 SEO
- ✅ 파일 기반 라우팅으로 개발 생산성
- ✅ App Router + React Server Components 덕분에 서버 컴퓨팅 적극 활용
그런데 인프라 현실은 이렇습니다.
- 이미 회사가 AWS / GCP / Kubernetes 위에 백엔드/서비스를 쌓아둔 경우가 많고
- 보안, 네트워크, 비용, 조직 구조 때문에
“새 서비스라도 Vercel 따로 쓰지 말고, 그냥 AWS 안에서 굴리자” 라는 결론이 자주 납니다.
그래서 구조가 이렇게 됩니다.
코드는 Next.js, 운영은 AWS(EC2 + ALB) → 즉, Self-hosting Next.js
그리고 여기서부터 캐시 얘기가 매우 귀찮아지기 시작합니다.
2. 그런데 Next.js는 Vercel을 기준으로 설계되었다
문제는 여기서 시작됩니다.
Next.js는 근본적으로 Vercel 팀이 만든 프레임워크라서, 많은 기능과 예제가 자연스럽게 “Vercel 환경”을 기준으로 설계되어 있습니다.
-
예제와 설명의 기본 가정이 대부분 Vercel 런타임
-
Edge / KV / revalidateTag / revalidatePath / Data Cache 의 상세 동작도 “단일 추상 런타임”을 전제로 설명
-
Self-hosting은 항상 문서 중간에 **“부모님 집 살던 시절 얘기”**처럼 곁다리로 슬쩍 언급되는 정도
게다가 커뮤니티(예: Reddit)에서도 이런 얘기가 종종 나옵니다.
-
Self-hosting 으로 Cache를 쓰면 비용이 얼마나 증가하는지 알 수 없다.
-
문서도 “Self-hosting 시 Redis 써라” 정도만 툭 던져주고,구체적인 예제/패턴은 생각보다 친절하지 않다는 피드백도 많음.
물론 “수익을 위해 일부러 Self-hosting 설명을 적게 쓴다”고 단정할 수는 없지만, 실제 개발자 입장에서는:
“Vercel에 올려야 가장 쉬운 길이 열리고, Self-hosting은 디테일을 꽤 많이 우리가 메꿔야 한다”
라는 느낌을 받게 되는 건 사실입니다.
그 중에서도 **가장 체감이 큰 영역이 바로 “데이터 캐시/ISR”**입니다.
3. ISR, 데이터 캐시, 그리고 AWS Self-hosting에서 생기는 문제
프론트엔드 개발자가 Next.js를 도입하는 가장 큰 이유는 위에서 설명했습니다.
하지만 회사의 요구는 대체로 이렇죠.
“SSR은 좋은데, 서버비는 되도록 안 오르게 해주세요. 트래픽 폭발한다고 서버 터지는 것도 안 됩니다.”
그래서 결국 답이 하나로 수렴합니다.
ISR + Data Cache를 최대한 적극적으로 써야 한다.
3-1. 시간 기반 vs 이벤트 기반 재검증
Next.js의 “데이터 캐시/ISR”은 크게 두 축으로 볼 수 있습니다.
1. 시간 기반 재검증 (Time-based ISR)
const res = await fetch("https://api.example.com/categories", { next: { revalidate: 3600 }, // 1시간마다 재검증 });
- 수정이 잦지만 “1~5분 늦게 반영돼도 되는 데이터” (카테고리, 공지 목록, 랭킹 등)
- “지금 바로 반영 안 돼도 된다”는 전제에서 사용
2.이벤트 기반 재검증 (On-demand ISR)
- revalidateTag('products')
- revalidatePath('/products')
- 캐시를 지우고 데이터를 즉시 반영하고 싶을 때 사용
이론만 보면 아주 좋아 보이죠.
3-2. 하지만 AWS + ALB + EC2 N대에서는…
문제는 “어디에 캐시가 저장되느냐”입니다.
Next.js의 기존 fetch 기반 데이터 캐시/ISR은:
- dev: 인메모리
- prod: 파일 시스템 (각 인스턴스 로컬 디스크)
- 즉, 인스턴스별로 완전히 분리된 스토리지에 저장됩니다.
구조를 다시 그려보면:
[Client] ↓ [ALB (로드 밸런서)] ↓ ↓ ↓ [EC2 #1] [EC2 #2] [EC2 #3] (Next.js) (Next.js) (Next.js)
여기서 CMS에서 이렇게 설정했다고 해볼게요.
“상품이 수정되면 https://example.com/api/revalidate 로 웹훅을 쏴라”
실제로 일어나는 일:
-
웹훅은 ALB를 통해 EC2 중 한 대로만 들어감
-
예를 들어 EC2 #1이 받았다고 치면:
-
그 서버에서만 revalidateTag('products') 실행
-
EC2 #1의 캐시만 무효화됨
-
-
EC2 #2, #3은 옛날 캐시 그대로
결국 이런 상황이 발생합니다.
- 유저 A → EC2 #1에 붙음 → 최신 상품 데이터
- 유저 B → EC2 #2에 붙음 → 옛날 상품 데이터
- 유저 C → EC2 #3에 붙음 → 또 다른 상태…
같은 URL인데, 어느 인스턴스에 붙느냐에 따라 데이터가 달라지는 상황. 일괄적인 데이터를 보여줘야 할 때 이건 굉장히 치명적입니다.
3-3. 그렇다면 모두 같은 데이터를 보게 하려면?
방법은 몇 가지가 있습니다.
- 중앙 캐시를 둔다
- Redis / Memcached / shared filesystem 등
- 모든 인스턴스가 같은 저장소를 바라보게 만들기
- 웹훅을 각 인스턴스로 팬아웃
- ALB 말고 NLB + 고정 주소, 또는 각 인스턴스용 Hook 등
- 현실적으로 매우 번거롭고, 스케일 아웃 시 피곤
- 캐시 전략을 사실상 포기
- cache: 'no-store', 아주 짧은 revalidate 시간 등으로 “일관성 대신 트래픽/비용을 더 낸다”
현실적인 선택은 결국 1번입니다.
“공용 캐시 서버(예: Redis)를 하나 두고, 모든 Next.js 서버가 거기에 캐시를 저장하도록 만드는 것.”
4. 우리가 원하는 그림: 캐시는 “Redis 한 군데에!”
이 문제를 해결하는 아이디어는 의외로 단순합니다.
“각 서버가 자기 메모리/디스크에 캐시를 들고 있지 말고,공용 Redis 한 군데에만 캐시를 저장하게 만들자.”
조금 더 구체적으로:
- Redis 같은 외부 캐시 서버를 하나 둔다.
- AWS ElastiCache, Upstash, Redis Cloud, 혹은 Docker로 띄운 Redis 등
- Next.js의 cacheHandlers API를 사용해,
- "use cache" 로 생성되는 Cache Components 계층의 캐시를 전부 Redis로 보내도록 구현
- 그 결과, 어느 인스턴스에
revalidateTag('products')를 호출하든 Redis에 있는 캐시가 무효화- 다른 인스턴스들도 모두 같은 Redis를 보기 때문에 항상 동일한 최신 데이터를 보게 됨
즉, 캐시 관점에서:
“여러 서버가 하나의 거대한 논리적 캐시(= Redis)를 공유하는 구조”가 됩니다.
5. Next 16 기준 캐시 계층 구조 쪼개기: use cache 이해하기
이제 핵심인 use cache + Cache Components 를 짚고 가야 합니다.
5-1. 캐시 계층이 “두 개”라고 보면 편하다
Next 15~16 기준으로, 캐시는 크게 두 계층으로 나눠서 생각하면 이해가 쉬워집니다.
| 케이스 | 캐시 계층 | 대표 API/옵션 | 저장 위치 (기본) |
| -------------- | -------------------- | ----------------------------------------------------- | ---------- |
| use cache 없음 | 기존 Incremental Cache | fetch(..., { next: { revalidate, tags } }) | FS / 메모리 |
| use cache 있음 | Cache Components 계층 | cacheTag, cacheLife, revalidateTag, updateTag | 메모리(LRU) |
여기에 cacheHandlers 를 붙이면:
| 케이스 | 저장소 커스터마이징 |
| ----------------------------- | ------------------------------------------- |
| use cache + cacheHandlers | Cache Components 캐시를 우리가 만든 핸들러(=Redis)로 위임 |
정리하면:
- 예전의 fetch 캐시/ISR → use cache 없이도 동작. 인스턴스마다 따로.
- 새로운 Cache Components → use cache 선언이 있어야 동작. 여기서부터 cacheTag, cacheLife, revalidateTag, updateTag 등이 진짜 힘을 발휘.
- Self-hosting + Redis 통합 캐시를 하고 싶다면 반드시 use cache + cacheHandlers 조합으로 가야 한다.
5-2. "use cache" 가 실제로 하는 일
"use cache"는 세 가지 레벨에 붙일 수 있습니다.
// 파일 상단 'use cache' // 컴포넌트 레벨 export async function MyComponent() { 'use cache' ... } // 함수 레벨 export async function getData() { 'use cache' ... }
이 directive가 붙으면:
-
Next가 이 함수/컴포넌트의 입력값(인자 + 클로저의 캡쳐 값) 을 직렬화해서 Cache Key를 만듭니다.
-
이 함수를 처음 호출할 때:
- 실제로 내부 코드를 실행하고
- 결과를 “Cache Components 계층”에 캐시 엔트리로 저장합니다.
-
두 번째부터:
- 같은 입력값 조합으로 호출되면 실행을 스킵하고 캐시된 결과를 그대로 반환합니다.
-
이때 저장 위치는 환경에 따라 다르지만, self-hosting 기본값은 인메모리 LRU입니다.
- 여기에 cacheHandlers를 등록하면 그 저장·조회 과정을 Redis 쪽으로 보내는 것.
즉:
"use cache" =
“이 함수의 결과를 캐시 엔트리로 관리해도 된다.
그리고 이 엔트리들은 Cache Components 규칙을 따른다.”
5-3. cacheTag, cacheLife, revalidateTag, updateTag 관계
"use cache" 영역에서 사용할 수 있는 중요한 함수는 대략 이 네 가지입니다.
1) cacheTag(tag)
import { cacheTag } from "next/cache"; export async function getRandomUser() { "use cache"; cacheTag("random-user"); ... }
- 이 함수/컴포넌트가 만들어내는 캐시 엔트리에 "random-user" 라는 태그를 붙입니다.
- 나중에 revalidateTag("random-user") 가 호출되면 이 태그와 연결된 캐시 엔트리들을 전부 무효화할 수 있습니다.
2) cacheLife(...)
import { cacheLife } from "next/cache"; export async function getRandomUser() { "use cache"; cacheTag("random-user"); cacheLife("hours"); ... }
- Cache Components 계층에서 캐시 수명 프로필을 지정합니다. (지정하지 않을 경우 기본값)
- 내부적으로는 stale, revalidate, expire 3가지 값을 조합해서 동작:
- stale: 클라이언트에서 “stale 상태로 인정하는 시간”
- revalidate: 서버에서 백그라운드 재검증을 시도하는 간격
- expire: 이 시간이 지나면 캐시를 더 이상 쓸 수 없게 됨
cacheLife("hours")같은 빌트인 프로필도 있고,{ stale, revalidate, expire } 직접 지정도 가능합니다.
3) revalidateTag(tag, profileOrOptions?)
- 서버 액션, Route Handler 등에서 호출:
import { revalidateTag } from "next/cache"; await revalidateTag("random-user", "max"); // 혹은 await revalidateTag("random-user", { expire: 0 });
- 해당 태그와 연결된 캐시 엔트리를 갱신/만료하도록 표시합니다.
- 이때 2가지 패턴이 많이 쓰입니다.
- "max" → Soft Stale: 기존 캐시는 잠깐 더 쓰면서 백그라운드에서 재검증
- { expire: 0 } → Hard Expire: 캐시 즉시 만료 (웹훅에서 자주 쓰는 패턴)
4) updateTag(tag)
- Next 16에서 추가된 API , 16 이전의 revalidateTag 와 같은 사용 방식이나, revalidateTag 처럼 Route Handlers 에서 사용 불가능.
- revalidateTag가 “다음 요청에서 재검증 해줘” 방향이라면, updateTag는 캐시를 즉시 최신 상태로 갱신하는 쪽에 가깝게 설계되어 있음.
| | revalidateTag(tag, 'max') | updateTag(tag) |
| ---------- | -------------------------------------------- | ------------------------------------------------------------ |
| 한 줄 요약 | 캐시를 stale 처리 → 다음 요청에서 SWR로 갱신 | 캐시를 즉시 만료 → 다음 요청은 새 데이터가 올 때까지 대기 |
| 추천 상황 | **웹훅/관리자 API(route.ts)**에서 “갱신 트리거만” 걸고 싶을 때 | 폼 저장 직후 “내가 방금 바꾼 게 바로 보여야” 할 때 (read-your-own-writes) |
| 호출 위치 | Route Handler / Server Action 등 서버 | Server Action 전용 (Route Handler 불가) |
| 사용자 체감 | 기존 캐시로 빠르게 보여줄 수 있고, 뒤에서 갱신됨 | 저장 직후 리다이렉트/재요청에서 무조건 최신 |
| 대표 예시 | 결제/재고/상품 웹훅 수신 → 상품상세/목록 태그 무효화 | 게시글 작성/수정 후 상세/목록 즉시 최신 반영, 프로필 수정 후 마이페이지 즉시 반영 |
5-4. 왜 fetch(..., { next: { revalidate, tags } }) 와 분리되었을까?
중요 포인트 하나:
fetch(..., { next: { revalidate, tags } })에 쓰는 revalidate/tags는 “기존 Incremental Cache 계층”용 옵션입니다.
반대로:
cacheTag, cacheLife, revalidateTag, updateTag는 “Cache Components 계층”용 API입니다.
그래서 다음 두 코드는 완전히 다른 캐시 계층으로 들어갑니다.
// ① 기존 Incremental Cache 계층 // - 인스턴스별 FS/메모리 await fetch("https://randomuser.me/api", { next: { revalidate: 3600, tags: ["posts"] }, }); // ② Cache Components 계층 // - "use cache" + cacheTag + cacheLife export async function getRandomUser() { "use cache"; cacheTag("random-user"); cacheLife("hours"); ... }
Self-hosting + Redis 통합 캐시를 기반으로 아키텍처를 잡고 싶다면:
1번 방식(fetch next.revalidate/tags) 만으로는 Redis에 캐시가 쌓이지도, 통합되지도 않습니다.
2번 방식("use cache" + cacheTag + cacheLife) 을 써야 우리가 cacheHandlers로 연결한 Redis 계층에 캐시가 쌓입니다.
그래서 이 글의 테마가 바로:
“AWS Self-hosting에서 Next 16의 "use cache" + cacheHandlers 를 이용해 Data Cache를 전부 Redis 하나로 통합하는 방법” 입니다.
6. 정리 & 2편 예고
1편에서는 **“왜 이 짓(?)을 해야 하는지”**를 끝까지 따라가 봤습니다.
- Next.js는 Vercel 기준으로 설계되어 있고,
- Self-hosting(AWS/ALB/다중 인스턴스)에서는
- 기존 Data Cache/ISR가 인스턴스별 로컬 캐시로 쪼개지며
- 웹훅 기반 revalidateTag가 한 인스턴스에만 적용되는 문제가 생긴다.
- 이를 해결하기 위해서는
- Redis 같은 외부 캐시 서버를 하나 두고
- Next 15~16의 Cache Components + use cache + cacheHandlers를 활용해
- Data Cache를 전부 Redis 하나로 통합해야 한다.
- 이때 중요한 키워드는:
- "use cache": Cache Components 계층에 진입시키는 스위치
- cacheTag, cacheLife: 태그·수명 관리
- revalidateTag, updateTag: 태그 단위 무효화/업데이트
- cacheHandlers: 캐시 저장소를 우리가 만든 Redis 핸들러로 위임하는 훅
🔜 2편에서 다룰 내용
2편에서는 진짜 손을 더럽히는 파트를 다룹니다.
- Docker로 로컬 Redis 띄우기 (테스트용)
- redis-handler.js 전체 코드
- next.config.ts에서 cacheComponents + cacheHandlers + cacheMaxMemorySize 설정
- getRandomUser()에 "use cache" + cacheTag + cacheLife 붙이고 Redis에 캐시가 쌓이는지 직접 확인
- revalidateTag("random-user", "max") vs revalidateTag("random-user", { expire: 0 }) → Soft Stale vs Hard Expire 차이 체감하기
- Server Action 버튼 / Webhook 시뮬레이션 / 캐시 상태 Live Badge + “마지막 업데이트 시간” 표시하는 데모 UI