글 목록으로개발 경험

Next.js의 라우팅과 캐시 문제를 추적한 기록

URL 변경, 쿠키 처리와 폼 초기화를 각 동작에 맞는 경로로 나눴어요.

URL 변경, 쿠키 처리와 폼 초기화를 각 동작에 맞는 경로로 나눴어요.

Back-Office 목록에서 검색 조건을 바꿨는데 화면이 그대로였어요. router.replace는 호출되고 있었고, 개발 서버에서는 같은 코드가 잘 돌아갔어요. 비슷한 시기에 Next.js를 올린 관리자 화면에서는 청크가 CORS에 막혔고, 오래 열어 둔 탭에서는 공지가 닫히지 않았어요.

겉으로는 라우팅이나 화면 갱신이 멈춘 문제였지만 원인은 달랐어요. 주소를 바꾸는 일에 서버 내비게이션이 필요한지, 쿠키를 읽는 데 서버 액션이 필요한지부터 다시 봤어요. 그렇게 옮긴 뒤에는 검색 폼과 프레임워크의 상태 보존이 부딪히는 문제도 남았어요.

이미 조건이 붙은 주소에서 검색이 멈췄어요

스테이지의 Back-Office 목록은 조건 없이 들어가면 검색이 됐어요. 그런데 조건이 붙은 링크를 열거나 그 주소에서 새로고침한 뒤에는, 검색을 눌러도 목록과 주소가 바뀌지 않았어요.

이 화면은 클라이언트가 목록을 조회해요. 서버 렌더에서는 searchParams를 읽지 않아요. 문제는 라우터 캐시가 요청 URL이 아니라 서버 렌더에서 사용한 검색 문자열을 key로 삼는 데 있었어요. 서버가 읽은 문자열이 비어 있으니 /a?foo=bar 요청도 /a가 찾는 캐시 자리에 들어갔어요. 그 안에는 원래 요청 URL이 남아 있어서, 나중에 router.replace('/a')가 그 캐시를 쓰면 foo=bar까지 주소창과 useSearchParams()에 되살아났어요.

업스트림에도 같은 증상을 다룬 #92187, #91658가 있었어요. 캐시가 이렇게 동작하는 이유는 수정 PR인 #94144에서 찾았어요.

상세 Drawer가 안 닫히던 일도 이 문제와 이어졌어요. 처음에는 닫기가 서버 응답을 기다리는 줄 알고 useTransition으로 감쌌지만 나아지지 않아 같은 날 되돌렸어요. 목록을 거쳐 열면 파라미터 없는 주소가 캐시에 있어 닫혔고, 상세 주소로 바로 들어오면 미리 프리페치한 ?id=… 주소가 남아 다시 살아났어요. 진입 경로가 달라 재현도 달랐던 거예요.

localhost의 dev에서는 이 증상이 보이지 않았어요. 같은 코드를 build하고 start로 실행하자 Back-Office 목록 7개 중 6개에서 주소가 바뀌지 않았어요. 업스트림에도 프로덕션 빌드에서만 재현된다는 설명이 있었어요.

검색 제출과 초기화, 상세 닫기는 네이티브 History API로 옮겼어요.

typescript
/**
 * 쿼리 파라미터만 바뀐 주소로 바꾼다.
 *
 * `router.replace` 를 쓰면 안 된다. 이 화면들은 서버 렌더에서 searchParams 를 읽지 않아
 * 라우터 캐시가 검색 문자열과 무관하게 재사용되고, 그 과정에서 캐시에 담긴 주소가 그대로 커밋된다.
 */
export function replaceUrl(url: string) {
  window.history.replaceState(null, '', url);
}
 
/** 뒤로가기로 되돌아갈 조건 변경(검색 등)은 엔트리를 남긴다. */
export function pushUrl(url: string) {
  window.history.pushState(null, '', url);
}

Next.js가 네이티브 history와 연동하므로 usePathname과 useSearchParams도 바로 갱신돼요. Drawer 닫기 테스트는 새로고침으로 상세를 복원한 뒤 RSC 요청을 붙잡고 닫게 했어요. 서버 응답이 안 와도 닫혀야 통과해요. 개발 서버에서 실행하므로 캐시 버그 자체를 재현하는 테스트는 아니지만, 닫기에 서버 왕복을 다시 붙이는 변경은 막아줘요.

목록을 다시 조회하는 쪽은 클라이언트였어요

주소가 바뀌면 클라이언트 컴포넌트가 새 조회 조건으로 useQuery를 불러요. 쿼리 key가 달라지니 목록도 다시 받아요. 서버가 목록을 새로 렌더하지 않아도 되는 구조라 주소만 바꾸는 방법이 맞았어요.

서버가 searchParams로 데이터를 조회해 props로 내려주는 화면이라면 달라요. replaceState만 부르면 주소만 바뀌고 서버가 내려준 목록은 그대로 남아요. 그런 화면은 서버 내비게이션으로 새 결과를 받아야 해요.

캐시 버그가 고쳐져도 서버 왕복은 필요하지 않아요

router.replace가 정상 동작하더라도 이 목록에 다시 쓸 이유는 없었어요. 같은 경로에서 검색 조건만 바뀌는데 라우터 내비게이션을 시작하면, 캐시가 맞지 않을 때 서버가 RSC 페이로드를 새로 만들어요. 클라이언트가 필요한 목록 데이터만 받는 대신 서버 렌더와 페이로드 처리가 더해져요.

History API는 주소를 바꾸고, 그 주소를 읽는 쿼리가 데이터를 받아요. 검색과 필터, 탭을 바꿀 때 서버가 읽지 않는 조건을 굳이 서버로 보내지 않도록 이 방식을 남겼어요.

검색 캐시를 고친 버전에는 다른 문제가 있었어요

검색 파라미터 캐시 문제는 16.3.0에서 고쳐졌어요. 릴리스의 Core Changes에 #94144가 들어갔고, key도 요청 URL의 검색 문자열을 쓰도록 바뀌었어요.

하지만 관리자 서비스는 이미 16.3에서 내려온 뒤였어요. 8월 5일에 14.2에서 올렸다가 이틀 뒤 16.2.12로 돌아왔어요. 클라이언트 청크를 불러오지 못했기 때문이에요.

16.3.0부터 Turbopack은 client reference manifest의 moduleLoading.crossOrigin을 null 대신 문자열 "none"으로 직렬화했어요. 옵션이 enum으로 바뀌면서 기본값 None도 문자열로 나간 거예요.

text
16.2.12  "moduleLoading":{"prefix":"","crossOrigin":null}
16.3.0   "moduleLoading":{"prefix":"","crossOrigin":"none"}

React Flight는 use-credentials가 아닌 문자열을 빈 anonymous 값으로 처리해요. crossOrigin을 설정하지 않았는데도 SSR에서 preinit한 청크의 <script>에 crossorigin=""이 붙고, 브라우저는 CORS 모드로 요청하게 됐어요.

수정은 null로 돌리는 대신 CrossOrigin::None이면 필드를 직렬화하지 않는 방식이에요. 2026년 8월 12일에 canary로 머지돼 v16.3.1-canary.14에 들어갔지만, 확인한 안정 버전 v16.3.1의 소스에는 아직 이 처리가 없었어요. 16.3.0에서 16.3.1로 올리는 것만으로는 해결되지 않았어요.

이후 복지몰을 다른 이유로 16.3.6에 올렸을 때는 빌드 산출물에 이 필드가 없었어요. 미리 렌더된 HTML의 <script>에서도 crossorigin=""이 사라졌어요.

저희 환경은 업스트림의 cross-origin assetPrefix로 CDN을 쓰는 경우와는 달랐어요. assetPrefix는 쓰지 않았고 프록시가 요청을 다른 도메인으로 돌리고 있었어요.

병원 관리자의 proxy는 특정 페이지를 정해진 도메인으로 보내요. Next.js 16 이전에는 middleware였던 코드예요. 그런데 matcher가 _next 아래 번들까지 잡아 다른 도메인에서 요청한 JS를 원래 도메인으로 리다이렉트했어요. 이 리다이렉트에 crossorigin=""이 겹치며 CORS 오류가 났어요.

matcher 자체는 14.2 때부터 같았어요. 배포 환경에서는 번들이 계속 리다이렉트되고 있었지만, 로컬에서는 도메인 강제를 건너뛰어 보이지 않았어요. 16.3의 변경이 기존 설정의 문제를 드러낸 셈이에요.

페이지에만 도메인을 강제하도록 _next 같은 정적 경로를 matcher에서 뺐어요. 그 뒤에는 cross-origin 리다이렉트가 없어져 문제가 재현되지 않았어요. 관리자 모노레포는 안정 릴리스의 수정이 준비될 때까지 16.2.x에 두기로 했어요. 다른 앱 설정에서 비슷한 조건이 만들어질 수도 있어, 청크에 이 속성을 붙이지 않는 버전에 잠시 머물렀어요.

오래 열어 둔 탭은 예전 서버 액션을 호출했어요

병원 관리자의 업데이트 공지는 쿠키로 다시 보여줄지 정해요. 물려받은 구현의 쿠키가 httpOnly와 secure였기 때문에 서버 액션으로 읽고 쓰고 있었어요.

배포 뒤에도 열려 있던 탭에서 이 액션을 호출하면 404가 났어요. 탭은 옛 빌드의 액션 ID를 가지고 있지만 새 서버에는 그 ID가 없었어요. 서버 액션 ID는 빌드에 묶여 있고, 문서에 따르면 소스가 그대로여도 최대 14일 주기로 바뀌어요. 사용자는 공지를 닫으려 했을 뿐인데 배포를 지나온 탭에서는 닫히지 않았어요.

우선 실패하면 페이지를 새로 불러오는 핫픽스를 넣었어요. 옛 번들로 같은 ID를 재시도해도 돌아오지 않기 때문이에요. deploymentId로 배포 차이를 감지해 하드 내비게이션하거나 롤링 배포와 재시도 경로를 준비하는 방법도 있지만, 옛 ID를 계속 쓰게 해주지는 않아요. 쿠키에 접근하는 방법을 바꾸기로 했어요.

Route Handler를 쓰면 /api/... 경로가 빌드 ID와 무관하게 남고 httpOnly도 유지할 수 있어요. 그런데 저는 쿠키를 클라이언트가 직접 읽고 쓰게 옮겼어요. 이틀 전에 액세스 토큰 쿠키를 같은 방식으로 바꾼 뒤였어요. 요청 인터셉터가 토큰을 읽을 때마다 서버 액션을 왕복하지 않게 한 선택을 공지에도 적용했어요. 아래에서는 액세스 토큰을 sessionToken, 리프레시 토큰을 renewalToken이라는 가명으로 불러요.

공지 쿠키에는 그 이유가 맞지 않았어요. 공지를 띄울지 판단하는 값은 요청마다 읽지 않아요. Route Handler로 옮겼다면 httpOnly를 남길 수 있었는데, 토큰과 같은 방식을 쓰려다 공지 쿠키까지 JavaScript에 노출했어요.

서버 액션 안에서 쿠키를 바꾸면 현재 라우트를 다시 렌더한 RSC 페이로드도 응답에 실려 와요. 전체 새로고침이나 언마운트는 아니어서 클라이언트 상태는 남지만, 공지를 닫는 데 서버 렌더가 따라와요. 이 왕복도 없애고 싶었어요.

토큰 재발급은 Route Handler가 맡는 BFF 방식으로 바꿨어요. renewalToken은 httpOnly로 남겨 브라우저가 재발급 요청에 보내고 서버가 읽게 했어요. 클라이언트에 sessionToken이 안 보일 때는 바로 로그아웃시키는 대신 renewalToken으로 한 번 재발급해요. 이전 세션의 httpOnly sessionToken은 클라이언트에서 안 보이므로, 이 처리가 없으면 배포 직후 정상 세션까지 끊길 수 있었어요.

쿠키 이름이 같다고 덮어써지는 것은 아니에요. 브라우저는 이름과 도메인, 경로로 쿠키를 구분해요. document.cookie에서 path를 생략하면 문서 경로가 기본값이어서, 서버가 만든 쿠키와 다른 경로에 새 값이 생길 수 있어요. 그러면 클라이언트는 새 값을 읽는데 서버는 옛 값을 받을 수 있어요. 오래된 토큰이 존재하는 경우라 '토큰 없음' 폴백으로도 잡히지 않아요.

저희는 cookies().set과 js-cookie가 모두 기본 경로 /를 쓰고 도메인도 지정하지 않아 이 경우에는 해당하지 않았어요. 대신 JavaScript가 옛 httpOnly 쿠키를 덮어쓸 수 없었어요. sessionToken은 재발급 Route Handler의 Set-Cookie로 바꿀 수 있지만, 클라이언트에서만 쓰는 공지 쿠키는 옛 값을 읽지도 바꾸지도 못해요. 이름을 재사용하면 배포 전에 공지를 닫았던 사용자는 옛 쿠키가 만료될 때까지 하루 동안 닫을 수 없어서 이름을 바꿨어요. 옛 하루짜리 쿠키는 만료되게 뒀어요.

sessionToken이 안 보일 때 재발급하는 처리는 이전 세션을 넘기기 위한 것이에요. 옛 httpOnly 쿠키가 모두 만료된 뒤에도 남겨 두면 액세스 토큰이 없어도 renewalToken만 있으면 계속 세션을 이어가는 동작이 돼요.

배포 문제를 없애면서 토큰 노출은 늘었어요

JavaScript가 sessionToken을 읽을 수 있으면 페이지에서 실행되는 서드파티 태그와 의존성 코드도 읽을 수 있어요. XSS뿐 아니라 쿠키나 요청 헤더를 수집하는 클라이언트 도구도 토큰에 닿을 수 있어요.

액세스 토큰의 만료를 줄이면 탈취한 토큰 하나를 쓸 수 있는 시간은 줄어요. 하지만 악성 코드가 계속 실행되며 재발급 응답까지 읽을 수 있다면 새 토큰도 가져가요. 짧은 만료만으로 실행 중인 XSS를 막을 수는 없어요.

renewalToken의 httpOnly는 값을 직접 읽지 못하게 할 뿐, 브라우저가 쿠키를 붙이는 요청을 막지는 않아요. 재발급 응답을 읽을 수 있는지, 쿠키 값을 응답으로 돌려주는 경로가 있는지가 여전히 중요해요. 토큰 만료를 줄이거나 재발급 경로를 제한하는 완화책은 아직 추가하지 않은 상태예요.

뒤로가기는 목록만 되돌리고 검색창은 남겼어요

조회 조건을 주소에 두고 나니 폼도 그 주소와 맞아야 했어요. 처음에는 들어올 때의 주소만 초기값으로 썼어요.

typescript
// 진입 시점의 주소가 폼의 초기값이다. 이후 주소 변경은 폼을 건드리지 않는다.
const [initialValues] = useState(() => ({ ...defaultValues, ...currentParams }));

목록은 바뀐 주소로 다시 조회했지만 검색창은 처음 이후의 주소를 읽지 않았어요. 뒤로가기를 누르면 목록은 이전 조건인데 검색창에는 방금 입력한 값이 남았어요. 그래서 주소 변경에 맞춰 폼을 reset하도록 바꿨어요. rc-field-form은 initialValues만 바꿔서는 저장된 값을 갱신하지 않아요.

typescript
// currentParams 는 매 렌더 새 객체라, 내용이 달라졌을 때만 초기값을 다시 만든다.
const paramsKey = JSON.stringify(currentParams);
const initialValues = useMemo(() => ({ ...defaultValues, ...currentParams }), [paramsKey]);
 
useEffect(() => {
  form.resetFields();
}, [form, initialValues]);

이번에는 초기화할 범위가 너무 넓었어요. 주소 전체를 보고 있어서 페이지 번호나 Drawer의 id가 바뀌어도 reset했어요. 검색어를 쓰다가 제출 전에 행을 열었다 닫으면 작성 중인 값이 사라지고, 페이지를 넘겨도 같은 일이 나요.

폼을 주소에 맞추면서 이 동작을 알았지만 그대로 뒀어요. 주소 변경마다 폼을 초기화해야 하는 것은 아니었어요. 이미 구분해 둔 폼 입력 key와 주소 전용 key 중에서 입력 key만 바뀔 때 초기화하면 되는데, 이 수정은 아직 남아 있어요.

제출한 폼을 프레임워크가 보관하고 있었어요

복지몰에서는 반대로 지워져야 할 값이 남았어요. 6월에 사이트맵 생성 비용 때문에 Cache Components를 켠 뒤, 예약과 상담 신청을 제출하고 뒤로가면 예전 입력이 복원됐어요.

Next.js가 이전 페이지를 언마운트하지 않고 React의 <Activity>로 숨기기 때문이에요. display: none인 DOM과 React 상태가 문서에 남아요. 문서에는 최근 라우트를 최대 3개 보관한 뒤 오래된 것부터 버린다고 돼 있어요.

입력 폼이 공통으로 쓰는 useSchemaForm에서 초기화를 처리했어요. 이 이름은 공개용 가명이에요. 7월에는 다시 보일 때 초기화했다가, 이전 입력이 잠깐 보이는 문제 때문에 8월 5일에 숨겨질 때 초기화하도록 바꿨어요.

tsx
export function useSchemaForm({ schema, ...props }) {
  const form = useForm({ ...props, resolver: zodResolver(schema) });
  const { reset } = form;
 
  // Cache Components 는 떠난 라우트를 언마운트하지 않고 <Activity> 로 숨겨 입력값을 그대로 보존한다.
  // 폼은 들어올 때마다 새로 시작해야 하므로 화면이 숨겨질 때 초기값으로 되돌린다.
  useIsomorphicLayoutEffect(() => reset, [reset]);
 
  return form;
}

reset을 cleanup으로 넘겼어요. Activity가 화면을 숨길 때도 React가 cleanup을 실행하므로 그때 초기값으로 돌아가요. react-use의 useIsomorphicLayoutEffect는 브라우저에서 useLayoutEffect로 동작해요. 다시 보인 다음 초기화하면서 이전 값이 한 프레임 나타나던 일을 피하려고 이 시점을 골랐어요.

초기값은 빈 폼과 달라요. 예약 화면에서는 혜택이 가장 큰 쿠폰을 미리 고르는데, 모두 비우면 그 선택도 없어져요. 마운트할 때의 defaultValues로 돌아가게 하고, 서버 데이터로 초기값을 정하는 폼은 데이터가 준비된 뒤에 마운트했어요.

테스트에서도 입력 중이거나 단순히 다시 렌더될 때는 값을 유지하고, cleanup 뒤에는 빈 값이 아닌 초기값으로 돌아가는지 봤어요. 예약 화면은 다른 화면에 갔다 돌아오기, 앞으로가기, 제출 후 재진입에서도 예전 입력은 사라지고 최대 혜택 쿠폰은 다시 선택되는지 확인해요.

처음에는 bfcacheId를 key로 쓰면 될 줄 알았어요. 그런데 문서를 보니 뒤로가기는 해결하지 못했어요.

bfcacheId는 push나 replace로 세그먼트가 새로 만들어질 때 바뀌고, 뒤로가기, 앞으로가기와 router.refresh(), 검색 파라미터나 해시만 바뀌는 이동에서는 유지된다.

제출할 때는 폼 세그먼트를 새로 만드는 게 아니라 그곳을 떠나요. 뒤로가면 같은 세그먼트와 bfcacheId가 돌아오니 key도 같아요. 완료 화면에서 폼을 새로 push하면 달라질 수 있지만, 문서도 새 코드에서는 명시적인 reset이나 데이터에서 얻은 key를 먼저 권해요. 이 폼에는 떠날 때 defaultValues로 되돌리는 처리가 필요했어요.

참고