진돌.dev
remixreact-router-v7auth2026.04.26 · 10분 읽기

react-rotuer-v7(remix)에서 auth 구현하기

여진석

처음 입사했을 때

처음 입사했을 때, FE 쪽 인증 플로우는 이미 구축되어 있었다. 클라이언트에서 모든 처리를 하고, RT는 auth 서비스 도메인의 HttpOnly 쿠키로, AT는 Zustand 메모리에 두는 방식이었다.

문제는 그 부근에서 버그가 끊이지 않았다는 점이다. 다른 탭에서 다른 계정이 보인다거나, 인증이 필요한 페이지를 새로고침하면 잠깐 빈 화면이 보이고 로그인 페이지로 튕긴다거나 하는 종류였다. 들여다볼 시간이 없어서 그때그때 땜빵으로 막았다.

이전 회사에서는 Next.js로 작업했었는데, remix(react-router-v7)는 처음이라 어디까지가 의도된 동작이고 어디부터가 버그인지 분간하기가 어려웠다.

기존 구조

전체 그림은 단순했다. 로그인하면 auth 서비스가 RT를 자기 도메인의 HttpOnly 쿠키로 내려주고, 응답 바디에 AT를 같이 실어 보낸다. 클라이언트는 받은 AT를 Zustand 메모리에 두고 API 호출 때마다 Bearer 헤더에 실었다. AT가 만료되면 POST /refresh로 새로 받아왔다.

mermaid-diagram_%281%29.svg

AT는 브라우저 메모리에만 있어서 노드 서버가 볼 수 없고, RT는 auth 서비스 도메인에 묶여 있어서 SSR 서버 도메인으로는 쿠키가 따라오지 않는다. 즉 SSR 서버에서 유저가 누구인지 알 방법이 없다. 인증 가드도, 페이지 단위 데이터 로딩도 전부 클라이언트가 마운트된 뒤에야 시작된다.

어떤 버그가 났나

이 구조 위에서 다음 증상들이 반복됐다.

1. 토큰이 없는 찰나에 API가 먼저 호출되는 경우가 있다. clientLoader 안의 토큰 검사는 비동기로 돌아갔고, HydrateFallback도 정의돼 있지 않았다. await으로 동기처럼 흐르게 짜둔 부분이 있었지만, 컴포넌트 트리 어딘가에서 토큰이 없는 짧은 시점에 API가 호출되는 경우가 계속 새어 나왔다.

2. 두 탭에서 서로 다른 유저가 보일 수 있다. AT는 메모리에만 있으니 새 탭을 열면 비어 있다. RT 쿠키로 /refresh를 호출해 AT를 새로 받아오는 데 시간이 걸리는데, 그 사이에 다른 탭에서 다른 계정으로 로그인을 해버리면 어떻게 되는가. 한 브라우저, 두 탭, 두 명의 유저가 동시에 떠 있는 상태가 만들어진다. 가끔 발생하는 일이 아니라, 미리 열어둔 탭이 있으면 거의 매번 일어났다.

3. 인증 가드 redirect에 HTML이 두 번 그려진다. 미인증 유저가 /my-account로 들어와도 SSR 서버는 인증 상태를 모르니 일단 HTML을 만들어 내려준다. 클라이언트가 받아 마운트하고, clientLoader가 redirect를 일으켜야 그제서야 /login으로 간다. 요청 한 번에 HTML이 두 번 그려지는 셈이다. 정상 동작인 줄 알았지, 사실 두 번 그릴 필요가 없는 일이었다.

mermaid-diagram_%282%29.svg

4. SSR 시점에 유저가 누구인지 알 수 없다. 페이지 응답이 나가기 전에 유저 상태가 필요한 요건이 들어왔을 때 막힌다. 예컨대 GPC(Global Privacy Control) 신호 같은 것은 HTML 응답이 나가기 전 단계에서 처리해야 하는데, 그 시점에 유저의 동의 상태를 알 방법이 없었다. SSR 서버에서 인증 상태에 손이 닿지 않는다는 건 단순한 불편이 아니라, 일부 요건은 아예 대응할 방법이 없다는 뜻이었다.

어디부터가 잘못이었는가

처음에는 각각 다른 버그로 잡고 있었다. 탭 간 동기화 이슈, redirect 깜빡임 이슈, GPC 같은 요건은 "지금은 못 한다"로 미뤄두는 식이었다.

스터디에서 같이 보면서 그제서야 정리가 됐다. 위의 증상들은 전부 같은 문장에서 나왔다. "AT는 브라우저 메모리에만 있고, RT는 auth 서비스 도메인의 쿠키에 묶여 있다." SSR 서버에서 인증 상태에 닿을 수 없다는 것 — 이 한 가지가 네 개를 동시에 만들어내고 있었다. 그러면 다음 문제도 자연스럽게 정해진다. AT/RT를 SSR 서버가 닿을 수 있는 곳으로 옮긴다.

어떻게 수정해야할까

방향이 정해지자 어떻게 풀어야 할지를 봐야 했다. 여기서 과거 Next.js로 작업하던 시기가 떠올랐다.

Next.js에서는 NextAuth로 암호화된 쿠키에 토큰을 담아 관리했다. SSR에서는 쿠키를 통해 인증 상태에 바로 접근할 수 있었고, API Route를 BFF Proxy로 두면 클라이언트가 외부 인증 서비스에 직접 닿지 않고도 인증 플로우를 안전하게 가져갈 수 있었다. remix(react-router-v7)에서는 같은 그림을 어떻게 그릴 수 있을까.

문서를 같이 뒤지면서 두 가지 도구가 손에 잡혔다. createCookieSessionStorage 는 암호화된 세션 쿠키를 다룬다 — NextAuth의 쿠키 세션과 같은 자리다. Resource Route — 컴포넌트 없이 loader/action만 export하는 라우트 — 는 Next.js의 API Route와 같은 역할이다. 이 둘이면 BFF Proxy와 세션 쿠키를 모두 만들 수 있겠다는 그림이 나왔다.

새 구조

auth 서비스와 브라우저 사이에 BFF Proxy를 두고, AT/RT를 SSR 서버 도메인의 암호화 쿠키로 관리한다.

앞에서 정한 방향을 한 줄로 풀어쓴 것이다.

mermaid-diagram_%283%29.svg

브라우저 쪽에는 __session이라는 암호화된 HttpOnly 쿠키가 새로 생긴다. 도메인은 SSR 서버와 같다. 안에는 AT와 RT가 같이 들어 있고, 페이지 요청이든 API 요청이든 이 도메인으로 가는 모든 요청에 자동으로 따라붙는다. SSR 서버는 이 쿠키를 풀어서 인증 상태를 알 수 있다. SSR 서버가 비로소 외톨이를 면한다.

Zustand 메모리에는 여전히 AT가 남아 있다. 클라이언트가 바로 쓸 수 있는 형태가 필요하기 때문이다. 다만 이제는 진실의 출처가 아니라 캐시에 가깝다. 진짜 상태는 쿠키 안에 있고, 메모리는 그것을 비추는 표면이다.

BFF Proxy

새 구조의 한가운데에는 BFF Proxy가 있다. SSR 서버 안에 /api/auth/* 경로로 라우트를 하나 만들어두고, 클라이언트가 auth 서비스로 보낸다고 생각했던 요청을 거기서 받아 auth 서비스로 다시 보낸다.

mermaid-diagram_%284%29.svg

프록시는 다음 일을 한다. 들어온 요청에서 __session을 읽어 안에 든 RT를 꺼내고, auth 서비스가 기대하는 형태(Cookie: rt=xxx)로 변환한다. auth 서비스가 응답으로 새 RT/AT를 내려주면, 프록시는 그것을 다시 __session에 담아 Set-Cookie로 내려보낸다. auth 서비스의 도메인 쿠키는 응답에서 떼어낸다. 클라이언트 입장에서는 자기가 부른 endpoint의 응답 형태가 그대로 돌아온 것처럼 보인다.

덕분에 클라이언트 코드를 거의 건드리지 않아도 됐다. auth 호출의 base URL만 https://auth...에서 /api/auth/...로 바꾸면, 응답 형태도, 호출 시점도, 에러 처리도 그대로다. env 한 줄로 떼어졌다.

새 인증 플로우

mermaid-diagram_%285%29.svg

페이지 요청이 들어오면 SSR 서버의 미들웨어가 먼저 __session을 풀어서 인증 상태를 확인한다. 미인증이면 그 자리에서 302를 내려준다. 인증이라면 그대로 페이지를 그려서 내려준다. 어떤 경우든 HTML은 한 번 그려진다. 클라이언트가 마운트된 뒤에 redirect가 일어나는 일은 없다.

토큰 갱신은 SSR 서버 안에서 닫혀 있다. 클라이언트는 별도 endpoint로 세션 동기화를 요청하고, SSR 서버는 필요하면 BFF Proxy를 통해 auth 서비스에 /refresh를 보낸다. 새 AT/RT가 오면 __session을 갱신하고, 메모리의 AT도 같이 맞춘다.

네 가지 증상이 어떻게 풀렸는가

1번(토큰 부재 시점의 API 호출)과 3번(HTML 두 번 그려짐)은 SSR 미들웨어가 인증을 책임지면서 자연스럽게 사라졌다. 클라이언트로 내려오는 시점에는 토큰이 이미 정해져 있고, redirect도 미들웨어에서 끝난다. 2번(탭 간 다른 유저)은 진실의 출처가 메모리에서 쿠키로 옮겨가면서 같은 도메인의 탭들이 같은 상태를 본다. 4번(SSR 시점 인증 상태 부재)은 미들웨어에서 __session을 풀어 그 시점에 인증 상태가 있으니, GPC 같이 응답 헤더 단계에서 처리해야 하는 요건도 자리가 생겼다.

운영 측면

이 구조로 옮기면서 같이 짚어둬야 했던 운영 측면이 몇 가지 있었다.

항목 내용
SESSION_SECRET 관리 쿠키 암호화 키. Secrets Manager에서 런타임에 주입한다. 로컬 개발에서는 fallback 값이 자동 적용된다.
SSR 서버 부하 모든 auth 요청이 프록시를 거치므로 SSR 서버에 추가 부하가 붙는다. 다만 auth 요청 자체의 빈도가 낮다 (로그인 / 로그아웃 / refresh).
쿠키 크기 __session에는 AT+RT만 저장한다 (user 정보는 포함하지 않는다). 4KB 제한 이내.
세션 유효기간 쿠키 maxAge는 360일. 실제 세션 수명은 auth 서비스의 RT TTL이 결정한다 (일반 3시간 / Keep signed in 360일).

마무리

회원 스쿼드 왔을 때 react-router-v7(remix)를 처음 사용해봐서 걱정이 컸지만, 결국 동일한 개념을 각 프레임워크가 어떻게 풀어내고 있냐에 중점을 맞추니 적응하기 쉬웠다.

AI 시대에 이런 기본적인 개념을 쉽게 놓치고 갈 수 있을 것 같은데, 다시 한번 중요성을 깨닫는 것 같다.

다만 코드에서 본 그림이 운영 환경까지 가는 건 별개의 일이다. 이 개선은 아직 QA에 배포되지 못했다.

임박한 일정 때문에 QA 시간이 나지 않았고, 관련된 이슈들도 같이 미뤄졌다.

왜 해야 하는가, 하면 무엇이 개선되는가, 이 글의 내용을 입이 아프도록 여기저기 설명하고 다닌 것 같다.

기술적 방향을 모아주는 챕터 리드가 따로 없는 환경에서 바텀업으로 개선을 반영하려면, 코드만큼 의사소통의 몫이 크다는 걸 이번 일에서 다시 느꼈다.

# remix# react-router-v7# auth
여진석

커머스 도메인에서 제품과 팀의 개발 방식을 함께 개선하는 프론트엔드 개발자입니다.