JWT 인가 체계와 토큰 갱신 동시성 제어
여러 서비스가 하나의 인증 체계를 공유해야 했고, 동시에 화면·버튼 단위까지 read/write 권한을 나눠야 했습니다. 여기에 더해 프론트엔드에서는 여러 API 요청이 동시에 토큰 만료(401) 응답을 받을 때, 각 요청이 개별적으로 갱신을 시도하면서 서로 경합하는 문제가 있었습니다.
발급은 하나, 인가는 각자
토큰 발급은 전담 서버 하나로 집중하고, 인가 판단은 각 서비스가 담당하는 구조를 택했습니다. 발급 서버를 분리한 이유는 신규 서비스가 추가될 때마다 인증 로직을 중복 구현하지 않게 하기 위해서였습니다. 새 서비스는 공통 인증 전략만 상속받고, 자신만의 인가 가드만 구현하면 됩니다.
JWT 페이로드에는 접근 가능한 서비스 목록을 포함시켰고, 각 서비스의 가드는 자신의 서비스 식별자가 그 목록에 있는지만 검증합니다(서비스 단위 인가). 그 위에 요청 헤더의 페이지 코드로 화면별 read/write 권한을 조회하는 2단 인가 구조를 얹었습니다. 서비스 단위 인가와 화면 단위 인가를 분리한 이유는, 전자는 거의 바뀌지 않는 정보(어떤 서비스에 접근 가능한가)이고 후자는 관리자가 자주 조정하는 정보(어떤 화면을 볼 수 있는가)라서, 두 정보의 변경 빈도와 신뢰 수준이 다르다고 판단했기 때문입니다.
토큰 갱신을 “1회로 묶는” 이유
프론트엔드에서 화면 하나를 그릴 때 여러 API를 동시에 호출하는 경우가 흔합니다. 토큰이 만료된 상태로 화면에 들어오면, 이 요청들이 거의 동시에 401을 받고 각자 갱신을 시도합니다. 매 요청이 갱신을 허용하는 방식은 구현이 단순하지만, 짧은 시간에 갱신 API가 여러 번 호출되면서 리프레시 토큰이 조기에 무효화되거나 서버 부하가 불필요하게 늘어나는 문제가 있었습니다.
그래서 첫 번째 401 요청만 갱신을 실행하고, 그 사이에 도착한 나머지 요청은 대기시켰다가 갱신이 끝나면 한 번에 재시도하는 방식(single-flight)을 선택했습니다. axios 응답 인터셉터에 “갱신이 진행 중인가”를 나타내는 플래그와, 대기 중인 요청을 담아두는 큐를 두었습니다. 갱신이 끝나면 큐에 쌓인 요청을 새 토큰으로 한 번에 재시도합니다. 재시도 요청에는 별도 플래그를 달아, 재시도한 요청이 다시 401을 받아도 갱신을 또 시도하지 않도록 해 무한 루프를 차단했습니다.
페이지 식별 정보 암호화로의 전환
인가에 쓰이는 페이지 식별 정보를 평문으로 헤더에 담아 보내던 것을, 서버사이드 암호화 처리로 전환했습니다. Next.js Route Handler를 경유시켜 클라이언트에는 암호화된 값만 노출되도록 바꾼 것입니다. 이 전환 과정이 순탄하지는 않았습니다. 헤더 전달 방식이 바뀌면서 쿠키가 유실되는 문제, 토큰 검증 순서가 어긋나는 문제 등이 연쇄적으로 발생해 약 1주간 20여 회의 수정을 거쳤습니다.
돌이켜보면 이 과정에서 가장 크게 배운 것은, 인증·인가처럼 여러 계층(브라우저 쿠키 → Next.js Route Handler → NestJS 가드)을 관통하는 기능은 한 계층만 바꿔서는 검증이 끝나지 않는다는 점이었습니다. 이후로는 인증 관련 변경을 할 때 계층별로 나눠 하나씩 배포하고 확인하는 방식으로 작업 순서를 바꿨습니다.