Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (3편 · 운영 검증)

Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (3편 · 운영 검증)

Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (3편 · 운영 검증)

로컬 데모에서는 됐다.
이제 중요한 건 실제 AWS Self-hosting 환경에서도 같은 방식으로 동작하느냐다.

1편에서는 왜 AWS Self-hosting + ALB + EC2 다중 인스턴스 환경에서 캐시 불일치가 생기는지 개념적으로 정리했다.
2편에서는 Docker Redis, 데모 앱, cacheHandlers, "use cache", revalidateTag 를 이용해 그 원리를 로컬에서 직접 재현하고 검증했다.

그리고 이번 3편에서는 그 로컬 데모를 넘어서,
실제 AWS + Redis 운영 환경에 메인 페이지를 붙여 보며 어떤 구조로 캐시를 일원화했는지,
그리고 그 과정에서 겪은 느낀점, 시행착오, 트러블슈팅, 배포 전략 변화까지 정리해보려 한다.

이번 편의 핵심은 단순히 “Redis를 붙였다”가 아니다. 정확히는, 로컬에서 검증한 구조를 운영으로 옮겼을 때 어디서부터 생각보다 현실이 복잡해지는지, 그리고 그 복잡함을 어떻게 하나씩 정리했는지를 남기고 싶었다.

이번 편에서 함께 보는 주소는 아래 세 가지다.

  • Staging: http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/

  • Dashboard (4편 실측 결과 요약): http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/dashboard

  • GitHub: https://github.com/leejpsd/next-redis-cache

  • 로컬에서는 잘 되던 것이 운영 환경에서는 왜 다르게 보이는지

  • 실제 메인 페이지에 붙일 때 어떤 기준으로 캐시를 나눴는지

  • 캐시가 빠르다보다 먼저 캐시가 일관되다를 확인해야 했는지

  • 왜 EC2 직접 배포보다 ECS + GitHub CI/CD 쪽으로 배포 전략을 옮겼는지

  • 그리고 성능 측정 전에 왜 이 검증이 반드시 필요했는지

이제부터는 데모가 아니라,
운영 환경 기준으로 캐시 구조를 검증한 기록이다.
조금 더 솔직하게 말하면, “이제 진짜부터 시작이었다”에 가까웠다.


14. 로컬 데모 다음에 바로 운영 검증으로 간 이유

2편까지 끝내고 나면 얼핏 이렇게 생각할 수 있다.

“Redis에 잘 쌓이고, revalidateTag 도 동작하네.
그럼 이제 끝난 거 아닌가?”

하지만 실제로는 전혀 그렇지 않았다.

로컬 데모는 어디까지나 동작 원리를 검증하는 단계였다.
운영 환경은 그보다 훨씬 더 복잡했다.

왜냐하면 로컬에서는 보통 이런 조건이 단순하기 때문이다.

  • Next 서버가 1대
  • Redis도 로컬 1개
  • 네트워크 지연 거의 없음
  • 모든 요청 흐름이 눈에 보임
  • “어느 인스턴스에 붙었는가” 자체를 고민할 필요가 없음

반면 운영 환경은 다르다.

  • ALB 뒤에 여러 EC2 인스턴스가 있음
  • 요청마다 다른 인스턴스로 분산될 수 있음
  • 캐시가 로컬 메모리/로컬 파일시스템에 남아 있으면 상태가 갈라짐
  • 메인 페이지는 섹션별 업데이트 주기가 다름
  • CMS 수정, 운영자 액션, 배포 시점 같은 실제 이벤트가 얽힘

로컬에서는 “된다/안 된다”를 보면 됐다.
운영에서는 그보다 한 단계 더 들어가야 했다.
“같이 뜬 여러 인스턴스가 정말 같은 세계를 보고 있나?”를 확인해야 했다.

즉,
2편이 “이론이 실제로 구현 가능한가”를 검증한 편이었다면,
이번 3편은 “그 구현이 운영에서도 일관되게 통하는가”를 검증한 편이다.


15. 운영 환경에서 가장 먼저 확인한 것

운영 환경에 붙이면서 가장 먼저 확인한 건 성능이 아니었다.

가장 먼저 확인한 건 단 하나였다.

어느 인스턴스에 붙어도 같은 캐시 상태를 보느냐

이게 핵심이었다.

왜냐하면 1편에서 설명했던 문제가 바로 이거였기 때문이다.

  • EC2 #1 은 최신 데이터
  • EC2 #2 는 옛날 데이터
  • EC2 #3 는 또 다른 상태

같은 URL인데 붙는 인스턴스에 따라 다른 결과를 보여주면,
그건 캐시 최적화 이전에 운영 신뢰성 문제가 된다.

실제로 메인 페이지는 사용자가 가장 먼저 보는 화면이기 때문에,
여기서 불일치가 생기면 체감이 훨씬 크다.

예를 들면 이런 식이다.

  • 어떤 사용자는 최신 배너를 봄
  • 어떤 사용자는 이전 배너를 봄
  • 어떤 사용자는 메인 랭킹이 갱신되지 않음
  • 운영자는 수정했는데 “왜 누구는 보이고 누구는 안 보이지?” 상태가 됨

운영에서는 “빠른 캐시”보다 먼저
“같은 응답을 보장하는 캐시”가 필요했다.

이 순서가 꽤 중요했다.
응답 속도가 아무리 좋아도, 사용자가 볼 때 누군 최신이고 누군 예전 상태라면 그건 최적화라기보다 불안정한 시스템에 가깝다.


16. 실제 운영 구조에서 내가 원한 그림

운영 환경에서 내가 원한 구조는 아주 단순했다.

캐시는 각 인스턴스가 따로 들고 있지 말고,
Redis 한 군데를 중심으로 움직이게 하자.

그림으로 생각하면 이렇다.

[ ALB ] | --------------------------------- | | | [EC2 #1] [EC2 #2] [EC2 #3] | | | ----------- same Redis ----------- | [ Redis ]

즉, 중요한 포인트는 이것이다.

  • 각 EC2 인스턴스가 자기만의 캐시 기준점을 가지면 안 된다
  • 캐시 무효화는 특정 인스턴스 내부에서만 끝나면 안 된다
  • 메인 페이지에서 공유되는 데이터는 Redis를 기준점으로 삼아야 한다
  • 무효화도 Redis 기준으로 전체가 함께 반응해야 한다

이걸 나는 이번 작업에서 캐시 일원화라고 이해했다.

여기서 말하는 일원화는 단순히 “한 기술을 쓴다”가 아니라,

  • 저장 위치를 한곳으로 모으고
  • 무효화 기준을 태그 중심으로 맞추고
  • 인스턴스별 차이를 줄이는 것

을 의미한다.


17. 왜 EC2 직접 배포가 아니라 ECS 쪽으로 정리했는가

이번 작업을 하면서 캐시 구조 못지않게 중요했던 게 배포 방식이었다. 생각보다 빨리 깨달은 건, 캐시 일원화를 하고 싶다면 배포도 그에 맞는 방식으로 정리되어 있어야 한다는 점이었다.

처음에는 당연히 이런 선택지를 먼저 떠올리기 쉽다.

  • EC2 한 대 혹은 여러 대에 직접 붙어서 배포
  • SSH로 서버 접속
  • git pull
  • npm install
  • npm run build
  • 프로세스 재시작

작은 프로젝트에서는 충분히 가능한 방식이다.
하지만 이번 작업은 단순히 “사이트 하나 올리기”가 아니라,

  • Next.js 16 기반 서버
  • Redis 연동
  • ALB 뒤 다중 인스턴스
  • 캐시 일관성 검증
  • 운영 중 재배포 안정성

이 함께 걸려 있었다.

여기서 느낀 건, EC2에 직접 배포하는 방식은 실행 자체보다 운영 반복성이 더 문제라는 점이었다.

왜냐하면 직접 배포 방식은 결국 이런 부담을 계속 안게 된다.

  • 어느 서버에 어떤 버전이 올라가 있는지 사람이 계속 신경 써야 한다
  • 여러 인스턴스가 있으면 순차 배포와 상태 확인을 직접 관리해야 한다
  • 배포 중 일부 인스턴스만 새 코드, 일부는 옛 코드인 순간이 생기기 쉽다
  • Next.js 캐시 구조처럼 “인스턴스마다 상태가 달라질 수 있는” 문제와 만나면 더 위험해진다
  • 롤백도 결국 다시 SSH 접속과 수동 작업으로 돌아가기 쉽다

즉, 캐시 일원화를 하려는 상황에서 배포만 수동이면
운영 전체의 일관성이 다시 흔들릴 수 있었다.

조금 과장해서 말하면,
캐시는 Redis로 모아놓고 배포는 사람 손으로 흩뜨리는 구조가 될 수도 있었다.

그래서 최종적으로는 EC2 직접 배포보다 ECS 기반 배포 흐름으로 정리하는 편이 더 맞다고 판단했다.

내가 중요하게 본 이유는 다음과 같다.

17-1. 배포 단위를 “서버”가 아니라 “이미지”로 고정할 수 있다

ECS로 가면 배포의 기준이 서버 접속이 아니라 컨테이너 이미지가 된다.

즉:

  • 로컬에서 한 번
  • GitHub Actions에서 한 번
  • 운영에서 한 번

같은 이미지가 같은 방식으로 실행된다.

이건 Next.js처럼 빌드 결과물과 런타임 환경이 민감한 프레임워크에서 특히 중요했다.

17-2. 다중 인스턴스 롤링 배포가 훨씬 자연스럽다

ALB 뒤에 여러 태스크가 있을 때

  • 새 태스크 기동
  • 헬스체크 통과
  • 기존 태스크 교체

같은 흐름이 표준화되어 있다.

직접 EC2에 붙어서 배포할 때보다
“일부만 최신 버전, 일부는 이전 버전” 상태를 훨씬 덜 불안하게 관리할 수 있다.

17-3. 캐시 문제와 배포 문제를 분리해서 볼 수 있다

이 부분이 생각보다 컸다.

직접 EC2 배포를 하면:

  • 이슈가 캐시 문제인지
  • 빌드 산출물 문제인지
  • 프로세스 재시작 문제인지
  • 특정 서버만 배포가 덜 된 문제인지

서로 엉켜 보일 때가 많다.

반면 ECS + 이미지 중심 배포로 가면,

  • 배포 산출물은 동일하고
  • 태스크 교체 흐름도 같고
  • 서버 진입 없이 배포가 재현 가능하니

남는 문제를 캐시 구조 자체에 더 집중해서 볼 수 있었다.


18. 배포 과정도 생각보다 쉽지 않았다

이번에 더 실감한 건,
AWS에 올린다는 말 한 줄 안에 생각보다 많은 난이도가 숨어 있다는 점이었다. 블로그에서는 배포를 한 문장으로 쓰기 쉽지만, 실제로는 그 한 문장 안에 꽤 많은 판단과 확인 과정이 들어 있었다.

특히 Next.js 16 + Redis + Self-hosting 조합은 단순 정적 사이트 배포와는 결이 많이 달랐다.

중간에 부딪힌 포인트를 정리하면 대략 이렇다.

18-1. 로컬에서 되던 것이 운영 런타임에서 그대로 되지는 않는다

로컬에서는:

  • Redis 주소도 단순하고
  • 네트워크도 단순하고
  • 서버가 하나라 흐름이 눈에 보인다

운영에서는:

  • Redis 연결 주소와 보안 설정
  • 컨테이너 런타임 환경 변수
  • Next 빌드 타이밍과 런타임 타이밍의 차이
  • 여러 태스크가 동시에 떠 있는 상황

이 전부 함께 들어온다.

결국 “코드가 맞다”와 “운영에서 잘 돈다” 사이에는 꽤 큰 간격이 있었다.

18-2. 배포 중간 상태가 가장 헷갈렸다

실제 운영 환경에서는 배포가 원자적으로 한 번에 끝나는 게 아니라, 짧게라도 전환 구간이 생긴다.

이때 특히 헷갈린 건:

  • 새 태스크는 떴는데 이전 태스크도 아직 살아 있고
  • ALB는 점진적으로 분산하고
  • Redis는 공용인데 앱 코드는 버전이 섞여 있고
  • 무효화 시점까지 겹치면 체감이 애매해지는 순간

즉, “캐시가 이상하다”라고 느껴지는 순간이 사실은 배포 전환 구간 문제인 경우도 있었다.

이 경험 이후에는 캐시 이슈를 볼 때도 반드시 현재 몇 개 태스크가 떠 있는지, 어떤 리비전이 살아 있는지를 같이 보는 습관이 생겼다.

18-3. 운영 검증은 코드만으로 끝나지 않았다

결국 이 편에서 다루는 운영 검증은 코드 적용만이 아니라:

  • 어디에 배포할지
  • 어떤 방식으로 교체할지
  • 누가 어떤 순서로 배포를 트리거할지
  • 실패하면 어디서 되돌릴지

까지 함께 정리해야 비로소 완성됐다.

이 지점에서 “배포도 아키텍처의 일부”라는 걸 더 강하게 느꼈다.


19. GitHub CI/CD로 옮긴 이유

배포 방식을 정리하면서 자연스럽게 따라온 변화가 GitHub 기반 CI/CD 워크플로였다.

이건 단순히 편해서 옮긴 게 아니라, 이번 캐시 검증 작업과도 직접 연결돼 있었다.

내가 GitHub Actions 중심으로 옮기고 싶었던 이유는 크게 세 가지였다.

19-1. 사람이 서버에 직접 들어가는 횟수를 줄이고 싶었다

운영 문제를 다루다 보면 이상하게도
“서버에 직접 들어가서 확인해봐야 안심되는” 구간이 생긴다.

하지만 그 방식은 반복될수록 다음 문제를 만든다.

  • 특정 사람만 배포 과정을 안다
  • 배포 절차가 문서가 아니라 기억에 남는다
  • 급할 때 더 실수하기 쉬워진다

그래서 배포는 최대한:

  • 커밋
  • 푸시
  • 워크플로 실행
  • 이미지 빌드
  • ECS 업데이트

처럼 재현 가능한 순서로 남기고 싶었다.

19-2. 배포 이력을 GitHub 커밋 흐름과 붙이고 싶었다

이번 작업도 커밋 흐름을 보면 UI 수정, 이슈 수정, 기능 추가, 알고리즘 보정처럼 변화가 꽤 빠르게 이어졌다.

이런 상황에서 배포가 수동으로 따로 움직이면
“어느 커밋이 실제 운영에 반영됐는지” 추적이 금방 흐려진다.

GitHub Actions 기반으로 옮기면:

  • 어떤 커밋에서 배포가 시작됐는지
  • 어느 workflow run이 돌았는지
  • 실패/성공이 어디서 갈렸는지

를 GitHub 안에서 한 번에 추적하기 쉬워진다.

19-3. 운영 검증과 성능 측정의 기준점을 통일할 수 있다

4편에서 성능을 보려면, “어떤 코드가 어떤 방식으로 배포된 상태인지”가 고정되어 있어야 한다.

CI/CD를 통하지 않은 수동 배포가 섞이면 같은 main 브랜치라도 실제 운영 상태가 미묘하게 다를 수 있다.

반면 GitHub Actions로 배포 흐름을 고정해두면 성능 측정 대상도 더 분명해진다.

즉, 이번 배포 자동화는 편의성뿐 아니라
운영 검증의 기준점을 고정하는 작업이기도 했다.


20. 이번에 정리한 배포 워크플로

최종적으로 내가 정리하고 싶었던 운영 배포 흐름은 이런 그림에 가깝다.

git push main -> GitHub Actions 실행 -> Next.js 빌드 -> Docker 이미지 생성 -> AWS ECR 푸시 -> ECS 서비스 업데이트 -> 태스크 기동 -> ALB 헬스체크 통과 -> 기존 태스크 교체

이 흐름의 장점은 분명했다.

  • 배포 기준이 사람의 접속이 아니라 커밋과 이미지가 된다
  • 여러 인스턴스 환경에서도 롤링 업데이트를 더 안정적으로 가져갈 수 있다
  • 캐시 이슈와 배포 이슈를 로그 기준으로 분리해 보기 쉬워진다
  • 운영 검증 결과를 다음 배포에도 반복 가능하게 남길 수 있다

결국 이번 편에서 말하는 운영 검증은 Redis와 캐시 구조만의 이야기가 아니라, 그 캐시 구조를 안전하게 운영 환경에 실어 나르는 배포 체계까지 포함한 이야기였다.


21. 메인 페이지에 붙일 때 데모와 달랐던 점

2편의 데모는 일부러 단순했다.

  • 랜덤 유저 데이터 1개
  • 태그 1개
  • 버튼으로 직접 무효화
  • 결과가 눈에 바로 보이는 형태

하지만 메인 페이지는 그렇지 않았다.

실제 메인 페이지는 보통 하나의 데이터가 아니라 여러 섹션으로 이루어진다.

예를 들면:

  • 메인 배너
  • 랭킹 영역
  • 추천 섹션
  • 큐레이션 모듈
  • 운영 문구나 이벤트 블록

이걸 운영에 붙이면서 느낀 건,
메인 페이지는 “한 페이지”로 보면 안 되고
캐시 정책이 다른 여러 조각의 조합으로 봐야 한다는 점이었다.

그래서 접근 방식도 이렇게 바뀌었다.

21-1. 페이지 단위보다 섹션 단위로 쪼개기

페이지 전체를 한 번에 캐시하고 한 번에 무효화하면 너무 거칠다.

배너 하나 바뀌었다고 메인 전체를 통으로 다시 다루기 시작하면
불필요한 캐시 무효화가 많아지고 운영 제어도 어려워진다.

그래서 메인 페이지는 섹션별로 나눴다.

  • home:banner
  • home:ranking
  • home:curation
  • home:feed

이런 식으로 태그 단위를 쪼개면 운영에서 훨씬 명확해진다.

21-2. fetch 캐시보다 "use cache" 계층 중심으로 보기

운영에서 가장 헷갈리기 쉬운 건
“지금 이 데이터가 어느 캐시 계층에 들어가 있지?”라는 문제였다.

이걸 줄이기 위해 메인 페이지의 핵심 공유 데이터는 최대한
"use cache" + cacheTag + cacheLife 패턴으로 정리했다.

이렇게 해야:

  • 태그 무효화 지점이 분명해지고
  • Redis 기반 저장 흐름도 명확해지고
  • 운영 중 원인 추적도 쉬워진다

21-3. cache: 'no-store' 를 명시해 의도를 분명히 하기

실제로는 Next의 다른 캐시 계층과 혼선이 생기지 않게
fetch 레벨에서는 명시적으로 no-store 를 두고,
캐시 정책은 상위 "use cache" 계층에서 관리하는 방식이 훨씬 읽기 쉬웠다.

이건 단순히 동작 문제를 넘어서
운영 중 코드를 읽는 사람 입장에서 의도가 분명하다는 장점이 있었다.


22. 운영 적용 시의 실제 코드 방향

메인 페이지의 데이터 함수는 대략 이런 식의 패턴으로 가져갔다.

import { cacheLife, cacheTag } from 'next/cache' export async function getHomeBanner() { 'use cache' cacheTag('home:banner') cacheLife('hours') const res = await fetch(`${process.env.API_BASE_URL}/banner`, { cache: 'no-store', }) if (!res.ok) { throw new Error('Failed to fetch home banner') } return res.json() }

랭킹처럼 더 자주 바뀌는 영역은 다른 life profile을 사용했다.

import { cacheLife, cacheTag } from 'next/cache' export async function getHomeRanking() { 'use cache' cacheTag('home:ranking') cacheLife('minutes') const res = await fetch(`${process.env.API_BASE_URL}/ranking`, { cache: 'no-store', }) if (!res.ok) { throw new Error('Failed to fetch home ranking') } return res.json() }

그리고 운영 이벤트나 CMS 수정 시점에는 태그 기준으로 무효화했다.

import { revalidateTag } from 'next/cache' export async function revalidateHomeBanner() { revalidateTag('home:banner', 'max') }

핵심은 복잡한 코드를 짜는 게 아니라,
운영에서 “이 데이터는 어떤 태그로 묶이고 어디서 무효화되는가”가 분명한 구조를 만드는 것이었다.


23. cacheHandlerscacheHandler 를 보면서 느낀 것

실제로 붙이면서 가장 헷갈렸던 지점 중 하나가 이것이었다.

이름이 너무 비슷하다.

  • cacheHandlers
  • cacheHandler

하지만 실제 역할은 꽤 다르다.

cacheHandlers

이건 "use cache" 계층, 즉 Cache Components 계층을 어디에 저장할지 정하는 설정이다.
이번 작업에서 Redis로 보낸 핵심 대상은 바로 이쪽이었다.

cacheHandler

이건 기존 서버 캐시 계층, ISR/서버 캐시 쪽과 연결되는 축이다.

운영 환경에 붙이면서 느낀 건,
이 둘을 헷갈리면 구조 이해가 한 번에 무너진다는 점이었다.

처음엔 “어차피 Redis 하나 쓰니까 다 같은 거 아닌가?” 싶었는데,
실제로는 어느 캐시 계층을 어떤 저장소로 보낼 건지 명확히 나눠서 봐야 했다.

이걸 한 번 정확히 이해하고 나니까,
왜 로컬 데모가 쉬웠고 운영 환경이 어려웠는지도 더 잘 보였다.

로컬에서는 “동작한다”만 보면 되지만,
운영에서는 “어느 캐시 계층이 어디로 가는가”까지 이해해야 한다.


24. 운영에서 확인한 핵심: 진짜 중요한 건 무효화였다

운영에 붙이고 나서 느낀 건,
캐시 문제의 본질은 저장보다 무효화에 더 가깝다는 점이었다.

Redis에 잘 저장되는 것 자체는 생각보다 빨리 된다.
정말 중요한 건 이 다음이다.

바뀌었을 때, 정확히 어디까지, 어떤 방식으로, 언제 반영되는가?

특히 메인 페이지는 대부분 다음 두 경우가 많았다.

24-1. 약간 늦어도 되는 갱신

예를 들면:

  • 랭킹
  • 큐레이션
  • 추천 목록
  • 메인 피드

이런 건 revalidateTag(tag, 'max') 와 궁합이 좋았다.

즉시 블로킹 갱신보다는 stale-while-revalidate 성격이 오히려 자연스럽다.
사용자 입장에서는 빠르게 보이고, 다음 요청부터 최신화되기 때문이다.

24-2. 더 강한 즉시성이 필요한 갱신

예를 들면:

  • 프로모션 시작/종료 시점
  • 운영자가 직접 수정한 메인 노출 데이터
  • 절대 이전 상태가 보이면 안 되는 케이스

이런 경우는 더 강한 무효화 전략이 필요했다.
이 지점에서 updateTag 또는 만료 정책을 더 강하게 가져갈 필요가 생긴다.

즉, 운영에서는 단순히 “Redis를 쓴다”보다
무효화 성격을 콘텐츠 종류에 맞게 나누는 것이 훨씬 중요했다.


25. 실제로 겪은 트러블슈팅

여기부터는 이번 작업에서 특히 기억에 남았던 지점들이다.

25-1. 로컬에서는 잘 되는데 운영에서는 체감이 불안정했다

처음엔 Redis를 붙였으니 바로 깔끔하게 정리될 줄 알았다.
그런데 운영에서 보면 어떤 요청은 잘 반영되고, 어떤 요청은 체감이 이상했다.

이때 느낀 건
“캐시를 Redis로 보냈다”와
“운영 전체가 같은 캐시 모델을 따른다”는 전혀 다른 이야기라는 점이었다.

중간중간 다른 캐시 계층이 섞여 있거나,
페이지 단위/섹션 단위 경계가 애매하면
결국 체감은 불안정하게 남는다.

이걸 해결하려면 기술보다 먼저
캐시 경계를 설계 문서처럼 분명히 정리해야 했다.

운영에서는 “대충 이쯤 캐시되겠지”가 제일 위험했다.
실제로는 어디서 캐시되고, 어디서 무효화되고, 어느 요청에서 Redis를 타는지까지 꽤 집요하게 봐야 했다.

25-2. 메인 페이지를 한 번에 다루려다 오히려 복잡해졌다

처음에는 메인 페이지 전체를 하나의 큰 캐시 대상으로 보기 쉬웠다.
하지만 그렇게 하면 작은 수정에도 영향 범위가 너무 커진다.

배너 하나 바뀌었는데 전체를 다시 생각해야 하고,
랭킹만 바뀌었는데 큐레이션까지 함께 흔들릴 수 있다.

결국 해결책은 단순했다.

  • 페이지가 아니라 섹션으로 나눈다
  • 섹션마다 태그를 준다
  • 섹션마다 life profile을 둔다
  • 무효화도 섹션 단위로 한다

너무 당연한 이야기 같지만,
운영 환경에서는 이게 생각보다 큰 차이를 만들었다.

25-3. 인스턴스는 두 개를 띄웠는데, 체감상 계속 하나만 보는 것 같았다

이건 운영에서 꽤 헷갈렸던 장면 중 하나다.

분명히 인스턴스는 두 개 이상 떠 있었고,
ALB 뒤에서 분산도 되고 있다고 생각했는데, 확인해보면 자꾸 같은 인스턴스 응답만 보는 것처럼 느껴지는 순간이 있었다.

그 순간에는 원인을 하나로 단정할 수 없었다.
바로 “로드밸런싱 문제다”라고 말하기도 어려웠고,
그렇다고 “캐시 문제다”라고 단정하기도 어려웠다.

그때 중요했던 건 추측을 늘리는 게 아니라,
지금 보고 있는 현상이 정확히 어느 층위의 문제인지 분리해서 보는 일이었다.

  • 지금 응답이 어느 인스턴스에서 온 것처럼 보이는지
  • 배포 전환 구간은 아닌지
  • Redis 기준으로 캐시가 정말 공용화되어 있는지
  • 메인 페이지의 특정 섹션만 따로 어긋나는 건 아닌지

이 경험이 꽤 중요했던 이유는,
그 순간부터 “ALB가 골고루 보내는가”만 보는 게 아니라
지금 보고 있는 응답이 어느 태스크에서 왔는지, 그 태스크가 어떤 캐시 상태를 보고 있는지를 같이 봐야 한다는 걸 체감했기 때문이다.

결국 여기서도 결론은 같았다.

  • 로드밸런싱만 확인해서는 부족했고
  • 태스크 상태만 확인해서도 부족했고
  • Redis 기준으로 캐시가 정말 공용화되었는지까지 함께 봐야 했다

운영에서는 원인이 하나만 있는 경우가 드물었다.
이 이슈도 “인스턴스 분산처럼 보이는 현상”을
캐시 구조, 배포 상태, 운영 확인 방식까지 함께 놓고 봐야 했던 사례에 가까웠다.

25-4. “빠른지”보다 “같은지”를 먼저 확인해야 했다

이건 이번 작업에서 가장 크게 배운 부분이다.

프론트엔드 개발을 하다 보면 성능 개선이라고 하면 자꾸 숫자부터 보고 싶어진다.

  • 응답 속도
  • 캐시 hit ratio
  • TTFB
  • LCP

그런데 이번에는 그 전에 먼저 확인해야 할 게 있었다.

지금 모든 인스턴스가 같은 기준으로 움직이고 있는가?

이게 확보되지 않으면 그 다음 숫자는 오히려 의미가 흐려진다.

운 좋게 특정 인스턴스에서 빠른 것인지,
구조적으로 안정적인 것인지 구분이 안 되기 때문이다.

25-5. 배포 체계가 정리되기 전까지는 캐시 이슈도 깨끗하게 보이지 않았다

이건 운영 검증 과정에서 꽤 크게 체감한 부분이다.

처음에는 캐시만 잘 붙이면 문제를 바로 읽을 수 있을 줄 알았다.
그런데 배포 방식이 덜 정리된 상태에서는

  • 지금 떠 있는 코드가 최신인지
  • 특정 태스크만 이전 리비전인지
  • 롤링 교체가 완전히 끝났는지

같은 요소가 캐시 체감에 섞여 들어왔다.

즉, 캐시 트러블슈팅을 하면서 동시에 배포 워크플로를 정리해야 했고, 이 둘은 별개 작업이 아니라 사실상 한 묶음에 가까웠다.

그래서 이번 편에 배포 이야기를 꼭 넣고 싶었다.
운영에서는 캐시 아키텍처와 배포 아키텍처가 실제로 따로 놀지 않았기 때문이다.


26. 느낀점: 로컬 성공과 운영 성공은 정말 다르다

이번 작업을 하면서 가장 많이 든 생각은 이거였다.

“로컬에서 성공한 구조를 운영에서도 성공시키는 일은,
구현보다 정리가 더 중요하다.”

2편에서는 구현이 중심이었다.

  • Redis 띄우고
  • 핸들러 만들고
  • 캐시 저장하고
  • 무효화하고
  • 눈으로 확인하고

이 흐름이 중요했다.

반면 3편에서는 구현보다
경계 정리, 정책 정리, 저장소 정리, 무효화 기준 정리가 더 중요했다.

결국 운영 적용은 이런 질문에 답하는 과정이었다.

  • 이 데이터는 공유 캐시인가?
  • 이건 어느 태그로 묶어야 하나?
  • 얼마나 자주 stale 되어도 되는가?
  • 누가 이 데이터를 무효화하는가?
  • 어느 인스턴스에 붙어도 같은 응답이 보장되는가?

즉,
로컬은 “기술이 되는지”를 보는 단계라면,
운영은 “이 기술을 계속 믿고 쓸 수 있는 구조인지”를 보는 단계였다.

그리고 그 차이는 생각보다 컸다.
코드 한두 줄보다 배포 흐름, 캐시 경계, 무효화 전략, 검증 방식이 더 큰 영향을 줄 때도 많았다.

이 단계까지 내려온 뒤에야 비로소 4편의 실측이 의미를 가졌다.
공유 캐시가 제대로 붙었는지, 두 태스크가 정말로 같은 키를 보고 있는지,
무효화가 몇 ms 안에 전파되는지 — 이런 숫자들은 위 질문에 답을 한 뒤에야 해석이 가능하다.


27. 왜 4편 전에 이 검증이 꼭 필요했는가

이제 다음 편과 바로 연결되는 이야기다.

3편을 먼저 한 이유는 단순하다.

성능 측정의 전제가 되는 구조를 먼저 고정해야 했기 때문이다.

만약 이 작업 없이 바로 성능 측정으로 갔다면 이런 문제가 생긴다.

  • 어떤 요청은 Redis hit
  • 어떤 요청은 로컬 메모리 hit
  • 어떤 요청은 다른 인스턴스에 붙어서 miss
  • 어떤 요청은 무효화 타이밍이 달라서 stale

이 상태에서 나온 성능 수치는 신뢰하기 어렵다.

그래서 3편에서 먼저 확인한 것이다.

  • 메인 페이지 캐시를 어떻게 나눌지
  • Redis를 단일 기준점으로 어떻게 둘지
  • 어떤 태그 전략으로 무효화할지
  • 어느 인스턴스에 붙어도 같은 결과를 볼 수 있는지

이걸 먼저 잠가놓고 나서야,
다음 4편에서 비로소 진짜 의미 있는 성능 비교가 가능해진다.


28. 정리

1편에서는 왜 AWS Self-hosting 환경에서 캐시 불일치가 생기는지 개념적으로 정리했다.
2편에서는 Docker Redis와 데모 앱으로 그 문제를 로컬에서 재현하고 구현했다.
그리고 이번 3편에서는 그 구조를 실제 AWS + Redis 운영 환경으로 옮겨,
메인 페이지 기준으로 캐시를 어떻게 일원화했는지를 검증했다.

이번 편의 핵심을 다시 정리하면 이렇다.

  • 로컬 데모 성공은 출발점일 뿐이다
  • 운영에서는 “어느 인스턴스에 붙어도 같은 데이터를 보느냐”가 가장 중요하다
  • 메인 페이지는 페이지 단위보다 섹션 단위 캐시가 더 현실적이다
  • "use cache" 계층과 태그 무효화를 중심으로 설계해야 운영 제어가 쉬워진다
  • Redis를 붙이는 것보다 중요한 건 캐시 경계와 무효화 기준을 명확히 하는 것이다
  • EC2 직접 배포보다 ECS + GitHub CI/CD 쪽이 이번 운영 검증 흐름과 더 잘 맞았다
  • 성능 측정은 이 구조 검증이 끝난 뒤에 해야 의미가 있다

즉, 이번 3편은
캐시를 빠르게 만드는 이야기라기보다,
캐시를 운영에서 신뢰할 수 있게 만드는 이야기
였다.

조금 더 개인적으로 표현하면,
이번 편은 “Redis를 붙인 후기”라기보다
“운영에서 캐시를 믿을 수 있는 상태까지 끌어올리는 데 무엇이 필요했는가”에 대한 기록에 가깝다.


참고 링크

  • 1편 · 개념: https://www.eddy-dev.xyz/blog/Next.js-16-Redis-AWS-Self-hosting-%EC%BA%90%EC%8B%9C-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1%ED%8E%B8-%EA%B0%9C%EB%85%90
  • 2편 · 구현: https://www.eddy-dev.xyz/blog/Next.js-16-Redis-AWS-Self-hosting-%EC%BA%90%EC%8B%9C-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2%ED%8E%B8-%EA%B5%AC%ED%98%84
  • Staging: http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/
  • Dashboard (4편 실측 결과): http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/dashboard
  • GitHub: https://github.com/leejpsd/next-redis-cache

🔜 4편으로 이어짐

이 3편에서 정리한 “캐시 경계 · 일원화 · 배포 워크플로” 위에서, 4편은 이제 진짜 숫자를 본다.

  • 서버 응답 시간: k6로 평상시 / 스파이크 / 30분 소크 세 프로파일을 다 돌려 min · p50 · avg · p90 · p95 · max 전부 기록
  • 사용자 체감: Lighthouse로 모바일 4G와 Slow 3G 두 프로파일에서 LCP · FCP · TBT · Speed Index · TTI · CLS · Score를 전략별로 비교
  • 브라우저 관점: Playwright로 CSR/BFF의 pageReadyMs · browserFetchMs · readyToPaintMs
  • SEO: 같은 URL을 Browser / Googlebot / naked curl로 요청해서 HTML 본문 길이 측정
  • 일관성 실측: 두 태스크가 같은 Redis를 공유할 때 revalidateTag 전파가 몇 ms 걸리는지
  • CSR의 숨은 비용: 브라우저가 origin을 몇 번 직접 호출하는지 Playwright로 카운트
  • 그리고 위 수치들을 한 화면에 설득력 있게 배치한 실시간 대시보드까지

즉, 4편은 구조 설명의 편이 아니라 실측 · 비교 · 트레이드오프의 편이다. 그 결과는 staging 대시보드에 그대로 실려 있고, 읽는 사람이 “가정치가 아닌 실측”임을 바로 재현할 수 있도록 각 수치마다 원본 JSON 경로와 실행 명령을 같이 붙여 뒀다.

JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기