Skip to content

현재 사용법 (2026-09-10, 6fbaee7f): 로그인·OTP·비밀번호 · 기능 마스터. 아래는 작성 당시의 변경 설명이며 현재 전체 동작·재측정 결과를 뜻하지 않습니다.

새로고침 연타 시 로그아웃되는 문제 ​

시트 화면(/admin/#embed/sheets)에서 브라우저 새로고침을 빠르게 여러 번 누르면 로그인 화면으로 튕기던 것을, 로그인 상태가 그대로 유지되도록 바꿉니다.

직관 ​

세션이 끊긴 두 경로는 근본 원인이 동일합니다. "세션이 죽었다"고 단정하는 기준이 양쪽 모두 느슨했던 것입니다. 서버는 정상적인 동시 요청 하나를 만료로 판정했고, 클라이언트는 중단된 요청 하나를 만료로 판정했습니다.

배경 / 지금 동작 ​

임베드 구조. admin(관리자 앱)은 시트·근태 같은 화면을 iframe으로 끼워 넣습니다. 시트 화면은 hr-system 앱이 iframe 안에서 도는 형태입니다. 배포 환경에서 두 앱은 같은 도메인의 경로 프리픽스(/admin, /hr-system)로 서비스되므로 origin이 같고, 따라서 localStorage를 공유합니다. 로그인 토큰은 hereby_access_token·hereby_refresh_token 두 칸에 저장되는데, admin과 iframe은 이 두 칸을 함께 쓰면서도 각자 별도의 API 클라이언트(HerebyClient) 인스턴스를 돌립니다. 즉 화면 하나를 여는 것만으로 같은 토큰을 쓰는 클라이언트가 둘 생깁니다.

토큰 회전. 액세스 토큰이 만료되면 클라이언트는 리프레시 토큰으로 새 쌍을 발급받습니다(이하 회전). 회전은 일회용입니다. 서버는 옛 리프레시 토큰을 USED로 표시하고 새 쌍을 내주므로, 같은 토큰으로 들어온 두 번째 요청은 그냥 두면 실패합니다. 이를 흡수하기 위해 서버에는 두 겹의 방어가 있었습니다. 회전 직후 10초 안에 도착한 요청에게 같은 결과를 돌려주는 유예 캐시와, 회전이 진행 중일 때 도착한 요청을 기다리게 하는 인플라이트 중복 제거입니다. 둘 다 모듈 전역 Map, 즉 프로세스 메모리였습니다.

리다이렉트. 각 앱의 index.html 최상위에는 x-effect로 handleAuthRedirect()가 걸려 있습니다. 스토어의 authenticated 값이 false로 바뀌는 순간 반응형으로 재실행되어 /signin으로 이동합니다. 사용자가 "로그아웃됐다"고 인식하는 화면 전환이 이것입니다.

문제 1 — 서버: 회전 방어가 프로세스를 넘지 못했습니다 ​

배포된 API는 replica 2개로 돕니다(rate-limit 카운터가 파드별로 갈리는 것으로 실측 확인). 유예 캐시와 인플라이트 중복 제거가 프로세스 메모리이므로, 같은 토큰을 든 두 요청이 서로 다른 파드에 착지하면 두 겹이 통째로 무력화됩니다. 진 쪽은 DB에서 USED를 보고 UNAUTHORIZED를 받습니다. 소스 주석에도 이 한계가 명시돼 있었습니다.

변경 전

변경 후

문제 2 — 클라이언트: 실패 이유를 구분하지 않았습니다 ​

부팅 시 스토어의 init()은 auth.me를 불러 세션을 복원합니다. 이 호출이 실패하면 예전 코드는 이유를 가리지 않고 clearTokens()를 실행했습니다. 새로고침을 연타하면 iframe이 부팅 도중 파괴되면서 이 요청이 중단되는데, 중단을 세션 만료와 동일하게 취급해 공유 localStorage 토큰을 지웠습니다. iframe 하나의 부팅 실패가 부모 창의 로그인까지 없앤 것입니다.

배포 환경에서 localStorage.removeItem을 계측해 확인한 호출 스택입니다.

frame: /hr-system/sheets   top: false
Proxy.init → HerebyClient.clearTokens → removeItem(hereby_access_token)

변경 전

변경 후

개념 흐름별 변경 ​

회전 방어를 프로세스 밖으로 옮깁니다. services/refresh-rotation.store.ts를 새로 만들어 유예 캐시를 Redis SET PX로, 상호 배제를 Redis 락(SET NX PX)으로 구현합니다. 락을 놓친 요청은 최대 3초 동안 유예 캐시를 폴링해 승자의 결과를 받아 갑니다. 락 해제는 값을 비교한 뒤 삭제하는 Lua 스크립트로 처리해, TTL이 먼저 만료돼 다른 프로세스가 같은 키를 잡았을 때 남의 락을 지우는 것을 막습니다. 같은 프로세스 안의 동시 요청은 기존 인플라이트 프로미스로 먼저 걸러 Redis 왕복을 줄입니다. auth.router.ts의 전역 Map 두 개는 이 스토어 호출로 대체하며, 회전 로직 자체는 변경하지 않습니다.

Redis 장애가 로그인을 막지 않게 합니다. services/redis.service.ts는 모든 명령이 실패 시 null/false를 돌려주고 예외를 던지지 않습니다. REDIS_ENABLED가 꺼져 있거나 접속이 끊기면 프로세스 메모리 폴백으로 동작합니다. 최악의 경우 이전과 같은 수준의 방어로 돌아갈 뿐입니다.

하나의 Redis를 여러 서비스·병원이 안전하게 나눠 씁니다. 키는 {REDIS_SERVICE}:{REDIS_TENANT}:... 형태이며, 테넌트가 비면 해당 세그먼트를 생략합니다(hereby-sign:..., hereby-works:bestrh:...). 세그먼트 정규화는 단사여야 합니다. 안전하지 않은 문자를 단순히 지우면 works(베스트)와 works(브래덤)이 둘 다 works가 되어 서로 다른 병원의 세션이 같은 키를 쓰게 되므로, 문자가 실제로 소실된 경우에만 원본의 해시 8자리를 덧붙입니다. 대소문자 접기는 소실로 보지 않습니다.

세션 종료 판정을 한 곳으로 모읍니다. client/auth-failure.ts를 새로 만들어 세 가지를 판정합니다. isDefinitiveAuthFailure()는 tRPC 에러의 data.code === "UNAUTHORIZED" 또는 httpStatus === 401만 인정합니다. 서버 메시지 문구는 보지 않습니다. 문구가 바뀌면 조용히 깨지기 때문입니다. isAbortError()는 브라우저별 이탈 중단 문구를 cause 체인까지 훑습니다. isUnloading()은 pagehide/beforeunload로 문서 소멸 여부를 표시하고 pageshow에서 되돌립니다.

저장된 토큰은 최상위 프레임만 지웁니다. isTopLevelFrame() 가드를 추가합니다. 임베드된 프레임은 공유 세션의 주인이 아니므로 자기 화면만 로그인 상태에서 내리고 저장된 토큰은 건드리지 않습니다. 부모가 살아 있으면 다음 로드에서 그대로 복구됩니다. 판정 1이 뚫려도 이것이 2차 방어선이 됩니다.

일시적 실패는 재시도합니다. plugin.ts의 init()은 auth.me·account.me 실패 시 300ms·600ms 간격으로 두 번 더 시도합니다. 이탈 중이거나 명시적 인증 실패면 재시도 없이 즉시 종료합니다. 재시도까지 실패한 경우에도 토큰은 남기고 화면만 로그인으로 되돌려, 원인이 걷히면 다음 로드에서 복구되게 합니다. 같은 기준을 HerebyClient의 갱신 실패 경로와 onAuthError 콜백에도 적용합니다. 이전에는 429나 타임아웃만으로도 화면이 로그아웃됐습니다.

회귀를 막는 테스트를 추가합니다. 429·500·403·타임아웃·네트워크·프록시 HTML 응답이 세션 사망이 아님을, 401만 사망임을 고정합니다. 401 모양을 달고 있어도 중단된 요청이면 사망이 아닌 경우를 포함합니다. 서버 쪽은 네임스페이스 충돌·안정성과, 동시 호출 시 회전이 한 번만 일어나는지를 검증합니다.

검토한 대안 ​

init()의 catch에서 clearTokens()만 지우는 방법을 먼저 검토했으나 채택하지 않았습니다. 그렇게 하면 리프레시 토큰이 실제로 폐기된 세션에서 토큰이 영구히 남아, 매 요청마다 실패하면서도 로그인 화면으로 가지 못합니다. 삭제 자체가 아니라 삭제 조건이 문제이므로 판정을 분리하는 방향으로 고쳤습니다.