인증 · 미들웨어
로그인은 됐는데 화면은 로그인 전이었다 - router.replace()가 미들웨어를 건너뛴 이유
로그인에 성공했는데도 router.replace('/patient')가 먹히지 않았다. 분명 인증은 통과했는데 화면은
이전 "접근 불가" 상태 그대로 - 환자 등록 페이지로 계속 튕겼다. 미들웨어 로직을 의심해 로그부터
찍어봤지만, 미들웨어엔 로그조차 안 남았다. 아예 실행되지 않고 있었다. 코드가 틀린 게 아니라,
코드가 아예 안 도는 게 문제였다. 원인은 Next.js App Router의 라우터 캐시였다.
미들웨어가 실행되지 않은 이유
미들웨어는 서버 요청이 있을 때만 돈다. 그런데 router.replace()는 가려는 경로의 라우터 캐시가
있으면 서버 요청 자체를 건너뛴다. 서버 요청이 없으니 미들웨어도 통째로 스킵된 것이다. 흔히
"replace()는 미들웨어를 안 거친다"고들 하지만, 정확히는 캐시가 있을 때만 그렇다.
진짜 문제는 그 캐시에 무엇이 담기느냐였다. useRouter()(push/replace)로 어떤 경로를 처음 방문하면
캐시가 없어 서버 요청이 가고 - 이때 미들웨어가 내린 리다이렉트 결정("접근 불가")까지 결과로 캐시된다.
게다가 인증 상태가 바뀌어도 이 캐시는 스스로 갱신되지 않는다. 그래서 한 번 "접근 불가"로 찍힌 경로는
로그인을 마쳐도 캐시된 "접근 불가"를 그대로 재생하고 있었다.
정리하면 캐시가 생기고 미들웨어가 스킵되는 조건은 이렇다.
useRouter()로 방문한 경로에만 라우터 캐시가 생긴다 (주소창 입력·<a>링크 방문은 안 생긴다)- 캐시가 있으면
replace()는 서버 요청을 생략 → 미들웨어 미실행 - 인증이 바뀌어도 캐시는 그대로 → 옛 라우팅 결정이 살아남는다
캐시에 "접근 불가"가 박히는 지점
이 프로젝트의 인증은 미들웨어 한 곳에 모여 있다. 서버 요청이 들어오면 미들웨어가 getUsersMe로
현재 유저의 role과 name을 조회하고, 경로 접두어에 따라 도메인별 로더로 넘긴다.
// middleware.ts (요지)
const data = await usersApi.getUsersMe(kyInstance); // role·name 조회
return await match(pathname)
.with(P.string.startsWith('/patient'), () =>
patientPageLoader(request, response, data.role, data.name))
.with(P.string.startsWith('/admin'), () =>
adminPageLoader(request, response, data.role, data.agree_to_policy));환자 로더는 강원대 병동 요구사항 때문에 규칙이 하나 더 있다 - name이 null이면(아직 등록 안 된
병상 계정) 오직 환자 등록 페이지에만 접근할 수 있고, 그 외 경로는 전부 등록 페이지로 되돌린다.
// features/loader/utils/patient.ts (요지)
const isAdmissionPage = pathname === '/patient/patient-admission';
if (!name && !isAdmissionPage) {
return NextResponse.redirect(new URL('/patient/patient-admission', request.url)); // ← 이 결정이 캐시된다
}
if (name && isAdmissionPage) {
return NextResponse.redirect(new URL('/patient', request.url));
}바로 이 NextResponse.redirect가 문제의 씨앗이었다. 미들웨어가 내린 "/patient는 접근 불가, 등록
페이지로 가라"는 판단이 /patient 경로의 캐시에 결과로 저장된다. 다음에 같은 경로로 replace하면
미들웨어를 다시 돌려 최신 name을 보는 게 아니라, 캐시에 박힌 옛 결정을 그대로 꺼내 쓴다.
직접 부딪힌 두 경우
환자 로그인·등록. 새 병상 계정은 로그인 시점에 name이 null이다. 등록을 마치면 name이 채워지고
/patient 본화면으로 들어가야 하는데, 캐시가 그 전환을 막았다.
[환자 로그인 직후]
1. router.replace('/patient') — 첫 방문이라 /patient 캐시 없음
2. 서버 요청 발생 → 미들웨어 실행 → getUsersMe로 name 조회
3. name === null → /patient/patient-admission 으로 리다이렉트
4. 이 결과("/patient 접근 불가")가 /patient 경로 캐시에 박힘
[환자 등록 완료 후 (name이 채워진 상태)]
5. router.replace('/patient') — 이번엔 /patient 캐시가 있음
6. 서버 요청 생략 → 미들웨어 미실행 → 바뀐 name을 볼 기회가 없음
7. 박혀 있던 "접근 불가" 재생 → 등록을 마쳤는데도 등록 페이지로 다시 튕김4번에서 박힌 결정이 6번에서 그대로 재생되는 게 핵심이다. name은 이미 서버에서 바뀌었지만, 그걸
확인하는 유일한 통로인 미들웨어가 캐시 때문에 돌지 않는다.
관리자 로그인/로그아웃. 같은 함정이 방향만 바꿔 나타났다. 로그아웃은 "인증이 사라지는" 동작인데, 캐시는 로그인 시절의 결정을 붙들고 있었다.
[/admin에서 로그아웃 시도]
1. router.replace('/admin/login') — 첫 방문이라 캐시 없음
2. 서버 요청 → 미들웨어 실행 → "이미 로그인" 판단 → /admin 으로 되돌림
3. 이 결과("/admin/login 접근 불가")가 캐시에 박힘
[백엔드 로그아웃으로 쿠키 삭제 후]
4. router.replace('/admin/login') — 캐시가 살아 있음
5. 서버 요청 생략 → 미들웨어 미실행 → 쿠키가 지워졌는지 확인 못 함
6. 박혀 있던 "접근 불가" 재생 → 로그아웃했는데 로그인 페이지로 못 감쿠키 삭제는 백엔드 요청(mutation)으로 처리했을 뿐, 라우터 캐시와는 아무 상관이 없었다. 그래서 쿠키는 지워졌는데도 캐시된 "이미 로그인" 결정이 그대로 남아 있었다. 두 경우 모두 증상은 달랐지만 뿌리는 하나였다 - 인증은 서버에서 바뀌었는데, 그 변화를 읽어야 할 미들웨어가 캐시에 가려 안 돌았다.
해결 - 인증이 바뀌면 캐시를 버린다
답은 단순했다. 인증 상태가 바뀌는 동작(로그인·로그아웃·환자 등록) 뒤에는 라우터 캐시를 타지 않고
window.location.href로 전체 페이지를 새로 요청한다. 그러면 서버 요청이 강제되고 미들웨어가 다시 돈다.
const handleLogout = async () => {
await mutateAsync({}); // 백엔드에서 쿠키 삭제
window.location.href = "/admin/login"; // 캐시 무시하고 새 서버 요청
};router.replace()와 달리 window.location.href는 SPA 내비게이션이 아니라 브라우저의 하드 내비게이션이라,
라우터 캐시를 통째로 우회하고 새 문서 요청을 만든다. 그 요청이 미들웨어를 다시 태우고, 미들웨어는
바뀐 쿠키·name을 보고 이번엔 올바른 결정을 내린다.
다른 후보들은 왜 안 됐나
전체 새로고침은 SPA의 부드러운 전환을 한 번 포기하는 선택이라, 더 가벼운 방법부터 따져봤다.
| 후보 | 무엇을 하나 | 대상 경로 캐시 | 이 버그에 효과 |
|---|---|---|---|
router.refresh() | 현재 URL의 캐시만 갱신 | replace로 갈 대상 경로는 그대로 | ✗ 갈 곳의 옛 결정이 남음 |
document.cookie 삭제 | JS로 쿠키 제거 시도 | 라우터 캐시를 무효화 못 함 | ✗ (HttpOnly면 접근조차 불가) |
window.location.href | 전체 페이지 재요청 | 라우터 캐시 통째로 우회 | ✓ 클라이언트에서 가장 확실 |
revalidatePath() · cookies.set/delete | 서버에서 경로 캐시 무효화 | 관련 경로 캐시 자동 무효화 | ✓ 단, 서버 액션일 때 |
router.refresh()가 가장 아까웠다 - 이름만 보면 딱 맞아 보였지만, 갱신하는 건 지금 서 있는 URL의
캐시뿐이라 정작 replace로 이동할 대상 경로의 캐시는 손대지 않는다. 이 버그는 "갈 곳"의 캐시가
문제였으니 빗나갔다. document.cookie로 쿠키를 지우는 것도 캐시를 건드리지 못하고, 애초에 인증
쿠키는 HttpOnly라 JS로 읽을 수조차 없었다. 서버 쪽이었다면 revalidatePath()나 쿠키 변경 시 관련
경로 캐시를 자동으로 비워주는 cookies.set/delete로 풀렸겠지만, 이 흐름들은 클라이언트 컴포넌트의
mutation 이후였다. 결국 클라이언트에서 확실한 건 전체 새로고침이었다.

cookies.set/delete가 라우터 캐시를 무효화한다.배운 점
- 증상이 특정 흐름에서만 재현되면, 로직보다 캐시를 먼저 의심한다. 미들웨어에 로그조차 안 남았다는 건 코드가 틀린 게 아니라 코드가 안 돌았다는 신호였다 - 버그를 "왜 이 값이 틀렸나"가 아니라 "왜 이 코드가 실행조차 안 됐나"로 다시 물어야 방향이 잡혔다.
- 서버에서 바뀐 상태와 클라이언트가 재생하는 결정은 별개의 층이다.
name도 쿠키도 서버에선 이미 바뀌었지만, 그걸 읽는 미들웨어가 캐시에 가려 안 돌면 화면은 옛 결정을 그대로 재생한다. - 미들웨어가 내린 리다이렉트는 "동작"이 아니라 "결과"로 캐시된다.
NextResponse.redirect가 경로 캐시에 박히는 순간, 그건 다음 방문에서 재실행되는 게 아니라 그대로 복사돼 나온다. - 이름이 맞아 보이는 API가 정답은 아니다.
router.refresh()는 현재 URL만 갱신해 대상 경로 캐시는 남긴다 - 캐시의 "범위"를 확인하지 않으면 맞는 도구처럼 보여도 빗나간다. - 프레임워크의 캐시 계층을 이해하면 "분명 바꿨는데 안 바뀌는" 버그를 구조적으로 설명하고 재발을
막을 수 있다. 클라이언트에선 하드 내비게이션, 서버 액션에선
revalidatePath/cookies- 상황에 맞는 무효화 지점을 고르는 문제였다.