웹 요청은 브라우저에서 Gateway를 거쳐 Backend로 전달됩니다. 기존에는 Backend가 브라우저의 access token과 refresh token을 직접 검증했지만, Redis 세션을 단계적으로 도입하면서 토큰과 서버 세션이 한 요청에 함께 존재하는 전환 구간이 생겼습니다.
전환 구간에서는 refresh가 정상적으로 끝난 뒤 권한 API가 401을 반환하는 문제가 나타났습니다. 로그인과 token refresh를 각각 확인하는 것만으로는 원인을 설명할 수 없었습니다.
JWT 검증 코드를 옮기는 것만으로도 부족했습니다. 외부 요청을 해석하는 위치와 Backend에 전달할 계약을 먼저 정해야 했습니다.
요청 흐름은 브라우저 → Gateway → Backend로 고정했습니다. 브라우저는 쿠키와 토큰을 보내고, Gateway는 외부 헤더와 Redis 세션을 해석해 내부 인증 정보를 만듭니다. Backend는 그 결과를 바탕으로 멤버십과 리소스 권한을 판단합니다. 이 순서를 먼저 정해야 같은 요청을 여러 레이어가 다르게 해석하는 일을 줄일 수 있었습니다.
토큰 발급보다 먼저 어긋난 기준
refresh 직후의 access token과 Redis 세션, Gateway 내부 헤더를 한 요청 흐름에서 비교했습니다. 새 토큰에는 최신 권한이 있었지만 내부 헤더는 이전 세션 값을 따랐고, 바로 다음 권한 API에서 401이 재현됐습니다.
따라서 문제의 시작점은 토큰 발급이 아니라 서로 다른 상태 기준이었습니다. 외부 요청을 누가 해석할지와 Backend에 어떤 형태로 전달할지를 먼저 정한 뒤, 토큰과 세션 중 하나를 최종 기준으로 선택해야 했습니다.
Gateway와 Backend 사이의 책임 경계
토큰만 검증하던 시기에는 Backend에서 사용자 정보를 만드는 방식으로도 충분했습니다. 그러나 쿠키 발급과 삭제, refresh, SSO와 조직 초대처럼 진입 경로가 늘어나자 인증 판단과 브라우저 응답 규칙이 여러 필터와 컨트롤러에 흩어졌습니다. 정책 하나를 바꿀 때마다 수정할 위치와 회귀 범위가 함께 커졌습니다.
외부 요청을 해석하고 브라우저와 계약하는 일은 Gateway로 옮겼습니다. 쿠키와 토큰, Redis 세션을 한곳에서 확인해 내부 서비스가 사용할 인증 상태를 만들고, Set-Cookie 역시 같은 위치에서 처리했습니다. 그 결과 Backend는 멤버십과 역할, 리소스 소유권처럼 도메인을 알아야 판단할 수 있는 권한에 집중할 수 있었습니다.
복잡도가 없어진 것은 아닙니다. 대신 브라우저 정책과 도메인 규칙이 서로 다른 이유로 바뀐다는 점이 드러났고, 401이 생겼을 때 어느 경계부터 따라가야 하는지도 선명해졌습니다.
인증과 함께 만든 request context
Gateway는 인증 필터만 둔 위치가 아니었습니다. 여러 서비스가 공통으로 사용할 사용자, 조직과 앱 정보를 묶은 request context도 만들었습니다. routes는 host와 path에 따라 요청 경로를 고르고, upstreams는 실제 호출 대상과 timeout, connection, keep-alive를 관리했습니다. app과 brand는 진입 도메인에 맞는 앱과 브랜드 컨텍스트를 계산했습니다.
조직 범위가 있는 요청에서는 orgContext가 path의 orgSlug 또는 orgId를 읽었습니다. 필요한 멤버와 조직 정보는 additionalHeaders가 내부 헤더로 전달해, 각 Backend가 같은 정보를 반복 조회하지 않도록 했습니다.
조직 컨텍스트는 쿠키가 아니라 path에서
브라우저 쿠키의 현재 선택 조직을 사용하면 구현은 간단해 보입니다. 하지만 사용자가 여러 조직을 탭으로 동시에 열거나 쿠키 갱신이 늦은 경우, 요청이 가리키는 조직과 쿠키 값이 달라질 수 있습니다.
조직 범위가 있는 API는 path에 orgSlug 또는 orgId를 명시하고, Gateway는 그 값만 조직 컨텍스트로 사용했습니다. path에 조직 정보가 없는 요청은 쿠키나 이전 요청 상태로 추론하지 않았습니다. 덕분에 조직별 접근 제어와 로그, 모니터링, 내부 헤더가 모두 같은 요청 경로를 기준으로 맞춰졌습니다.
여기까지는 여러 서비스가 공통으로 사용하는 요청 컨텍스트입니다. 멤버십과 역할, 리소스 소유권, 기능별 권한은 해당 도메인을 아는 Backend가 계속 최종 판단했습니다. 비즈니스 권한까지 Gateway로 올리면 앞단이 도메인 데이터를 과도하게 알아야 하고, 요청 비용과 캐시 무효화 범위도 함께 커지기 때문입니다.
설정 오류를 배포 전에 드러내기
Gateway 설정 오류는 해당 host나 path에 실제 요청이 들어올 때까지 숨어 있을 수 있습니다. 존재하지 않는 upstream을 가리키는 route, 조직 값을 뽑지 못하는 orgContext 패턴, orgContext 없이 조직 의존 additionalHeaders를 사용하는 설정은 애플리케이션 시작 단계에서 실패하도록 했습니다. 앱과 브랜드의 host가 충돌하는 경우도 같은 시점에 확인했습니다.
런타임에서는 오래된 캐시가 다른 종류의 문제를 만들 수 있습니다. 멤버나 조직 정보가 바뀌었는데 Gateway 캐시가 그대로라면 내부 서비스에 이전 컨텍스트가 전달됩니다. 변경 신호가 들어오면 관련 캐시를 비우고, 캐시 무효화 실패는 일반 조회 실패와 따로 남겨 원인을 구분했습니다.
startup validation이 모든 장애를 막는 것은 아닙니다. 정적인 설정 오류는 앞당겨 찾을 수 있지만, Redis 지연이나 누락된 캐시 무효화처럼 운영 중에 생기는 문제는 별도의 지표와 로그가 필요합니다.
내부 헤더를 믿기 위해 먼저 버린 것
클라이언트가 내부 인증 헤더와 비슷한 값을 붙여 보낼 수 있으므로, Gateway는 외부에서 들어온 값을 먼저 제거하고 자신이 계산한 헤더만 다시 만들었습니다. Backend는 그 헤더를 사용하되 쿠키와 토큰을 다시 해석하지 않았습니다. 두 레이어가 같은 요청을 다시 판단하면 기준이 다시 갈라질 수 있기 때문입니다.
기존 토큰 경로는 세션 쿠키가 없는 요청에만 남겼습니다. 세션 쿠키가 있는데 Redis 조회에 실패한 경우에는 오래된 토큰으로 우회하지 않았습니다. 가용성만 보면 fallback이 편하지만, 삭제되거나 갱신된 세션을 이전 토큰으로 되살려 강제 로그아웃과 권한 회수를 무력화할 수 있습니다.
이 계약은 Backend가 외부에서 직접 호출되지 않는다는 네트워크 전제 위에서만 성립합니다. Gateway를 거치지 않고 Backend에 접근할 수 있다면 내부 헤더는 더 이상 신뢰 경계가 될 수 없습니다.
신뢰 경계는 네 가지로 나눴습니다.
- 브라우저 계약: 쿠키 발급과 삭제를 Gateway에서 처리
- 내부 헤더: 외부 값을 제거한 뒤 Gateway가 다시 생성
- 인증 상태: 만료, 위조, 누락을 Gateway에서 구분
- 도메인 권한: 멤버십과 리소스 소유권을 Backend에서 최종 판단
이 경계에 맞춰 외부 헤더를 제거한 뒤 다시 만들었고, 기존 토큰 경로는 세션 쿠키가 없는 요청에만 남겨 단계적으로 전환했습니다.
브라우저 쿠키의 책임 위치
기존에는 Backend가 인증 성공 뒤 access token과 refresh token의 도메인, 경로와 만료 속성까지 결정했습니다. 기능은 동작했지만 브라우저 정책이 컨트롤러와 서비스 가까이에 남아 있어 변경 범위가 넓었습니다.
Backend는 인증 결과를 나타내는 내부 신호만 반환하고, Gateway가 그 신호를 Set-Cookie나 쿠키 삭제 응답으로 바꾸도록 나눴습니다. Backend에는 도메인 판단이 남고, Gateway에는 브라우저 응답 규칙이 모였습니다. 내부 신호는 서비스 사이의 계약이므로 외부 클라이언트에는 노출하지 않았습니다.
valid, expired, invalid, missing
Gateway가 모든 실패를 같은 401로 넘기면 Backend와 운영 로그에서는 다음 행동을 정하기 어렵습니다. 만료된 인증은 refresh로 이어질 수 있지만, 위조되거나 형식이 잘못된 값은 즉시 거부해야 합니다. 인증 정보가 없는 공개 요청도 별도의 상태로 다뤄야 했습니다.
그래서 결과를 valid, expired, invalid, missing으로 나눠 전달했습니다.
| 상태 | 의미 | Backend에서의 처리 기준 |
|---|---|---|
valid | Gateway가 인증 가능한 사용자 컨텍스트를 만들 수 있음 | 내부 헤더를 바탕으로 인증 사용자 정보인 principal 복원 |
expired | 인증 정보는 있었지만 만료됨 | 경로에 따라 refresh 유도 또는 만료 응답 |
invalid | 형식이 잘못됐거나 신뢰할 수 없는 인증 정보 | refresh 대상으로 보지 않고 인증 실패 처리 |
missing | 인증 정보가 없음 | 공개 경로 여부에 따라 통과 또는 인증 요구 |
expired와 invalid는 모두 인증 실패지만 다음 행동은 다릅니다. 만료된 정보는 refresh로 이어질 수 있고, 위조되거나 형식이 잘못된 값은 바로 거부해야 합니다. 상태 구분은 Gateway에서 끝내고, Backend에는 경로별 권한 판단만 남겼습니다.
이제 남은 질문은 하나였습니다. 최신 access token과 이전 권한의 Redis 세션이 함께 있을 때 무엇을 최종 기준으로 삼을 것인가였습니다.
access token과 Redis 세션 중 무엇을 믿을 것인가
세션을 도입한 뒤에는 브라우저의 토큰과 서버가 관리하는 세션이 서로 다른 상태를 담을 수 있었습니다. 권한 변경과 강제 로그아웃을 다음 요청에 바로 반영하려면 둘 가운데 하나를 최종 기준으로 정해야 했습니다.
최종 기준은 Redis 세션으로 정했습니다. 오래된 토큰이 남아 있어도 세션의 권한을 갱신하거나 삭제하면 다음 요청부터 반영할 수 있기 때문입니다. 반대로 세션 쿠키가 있는 요청에서 Redis 조회가 실패하면 인증 상태를 확인할 수 없으므로 실패를 드러내고, 오래된 토큰으로 대체하지 않았습니다.
Redis 세션을 최종 기준으로 삼으면서 매 요청 조회 비용이 생겼습니다. 대신 저장 범위와 쓰기 빈도를 제한했습니다.
- 요청마다 읽는 값은 사용자 식별 정보와 현재 권한 상태로 한정
- 마지막 접근 시각은 10분 단위로 갱신
- 권한 변경과 강제 로그아웃은 세션 갱신 또는 삭제로 즉시 반영
- 일반 요청의 접근 기록은 권한 정보와 분리해 저장
이렇게 나누면 불필요한 Redis 쓰기와 오래된 요청의 권한 덮어쓰기를 함께 줄일 수 있습니다. 조회 지연, 접근 시각 갱신 실패, 세션 무효화 누락도 서로 다른 로그로 구분했습니다.
그 대가로 Redis는 인증 경로의 핵심 의존성이 됐습니다. 가용성 저하를 오래된 토큰으로 숨기기보다, 권한 회수와 로그아웃의 일관성을 지키는 쪽을 택했습니다.
refresh 성공 뒤에도 남은 이전 권한
전환 과정에서는 refresh 직후 권한 API가 401을 반환하는 문제가 나타났습니다. 새 토큰 발급은 성공했지만 Redis 세션에는 이전 권한이 남아 있었습니다.
Gateway는 세션을 최종 기준으로 사용했기 때문에 새 토큰을 받은 뒤에도 Backend에는 이전 권한이 전달됐습니다. 문제는 토큰 발급이 아니라 토큰과 세션을 같은 시점에 갱신하지 않은 데 있었습니다.
같은 세션에서 refresh 요청 → 권한 API 호출 흐름을 반복하며 새 토큰의 권한, Redis 세션 값, Gateway 내부 헤더를 함께 비교했습니다. 수정 전에는 401이 이어졌지만, 세션 갱신 순서를 바꾸자 같은 흐름이 200으로 끝났습니다.
세션 식별자는 유지하되 내부 사용자 상태와 권한을 최신 값으로 교체했습니다. 처리 순서는 사용자 상태 계산 → Redis 세션 갱신 → 응답 쿠키와 내부 헤더 생성으로 고정했고, refresh 응답뿐 아니라 바로 다음 요청에 새 권한이 전달되는지까지 회귀 범위에 넣었습니다.
refresh 응답만 확인하면 이 문제를 놓칠 수 있습니다. 같은 세션의 권한 값과 다음 요청에 전달되는 내부 헤더까지 함께 확인해야 했습니다.
병렬 요청이 세션을 되돌리는 경우
refresh 순서를 고친 뒤에는 동시 요청을 확인했습니다. 브라우저에서 여러 API가 함께 호출되면 refresh 요청과 일반 요청이 거의 동시에 Gateway를 지날 수 있고, 먼저 들어온 요청이 반드시 먼저 끝나는 것도 아닙니다.
문제는 다음 순서에서 재현됐습니다.
- 기존 세션에는 이전 권한이 남아 있습니다.
- refresh 요청이 새 권한을 계산합니다.
- 거의 동시에 들어온 일반 요청이 아직 갱신되지 않은 세션을 읽습니다.
- refresh는 성공하지만 일반 요청은 이전 권한으로 판단될 수 있습니다.
각 요청만 따로 보면 정상이어도 사용자에게는 토큰을 갱신한 직후 401이 발생한 것으로 보입니다. 따라서 단일 응답보다 같은 세션의 최종 상태를 기준으로 검증했습니다.
일반 요청은 세션의 권한 값을 다시 쓰지 않고 조회 결과만 사용하도록 했습니다. 세션을 갱신할 수 있는 경로는 refresh와 권한 변경, 로그아웃처럼 상태를 실제로 바꾸는 흐름으로 제한했습니다. 접근 시각 갱신도 권한 정보와 분리했기 때문에, 늦게 끝난 일반 요청이 이전 권한으로 세션 전체를 덮어쓰지 않았습니다.
refresh 뒤에는 새 토큰과 Redis 세션, 내부 인증 헤더가 같은 권한을 가리키는지 확인했습니다. 로그아웃이나 계정 차단 뒤에는 늦게 도착한 요청이 삭제된 세션을 다시 만들거나 이전 권한을 계속 사용하지 않는지도 함께 검증했습니다.
이 문제는 기능을 하나씩 떼어 테스트하면 잘 보이지 않습니다. 로그인과 refresh, 권한 API의 개별 성공 여부보다 같은 세션이 여러 요청을 거치며 어떤 상태로 바뀌는지를 기준으로 삼았습니다.
회귀 검증은 다음 요청까지
로그인 성공이나 refresh 응답의 200만으로는 전환을 검증할 수 없었습니다. 실제 장애는 같은 세션의 다음 요청에서 이전 권한이 사용되는 형태로 나타났기 때문입니다.
| 회귀 흐름 | 변경 전 신호 | 변경 후 확인 |
|---|---|---|
| refresh 후 권한 API | 새 토큰 발급 후에도 401 재현 | Redis 세션과 내부 헤더가 최신 권한으로 바뀌고 200 응답 |
| 세션 없는 기존 사용자 | 전환 중 요청 실패 위험 | 기존 토큰 경로로 정상 처리 |
| 외부 내부 헤더 위조 | 클라이언트 값이 내부로 전달될 위험 | Gateway가 제거한 뒤 계산한 값만 전달 |
| 로그아웃과 계정 차단 | 토큰 만료 전 접근이 남을 수 있음 | 세션 무효화가 다음 요청부터 반영 |
| refresh와 일반 요청의 병렬 실행 | 일시적으로 이전 권한 사용 | 일반 요청이 세션 권한을 되돌려 쓰지 않음 |
운영 로그에는 세션 쿠키 유무, 세션 조회 결과, 권한 변경 전후, 기존 토큰 경로 사용 여부와 내부 헤더 생성 결과를 따로 남겼습니다. 이 정보를 연결하면 한 요청이 어느 인증 경로를 탔고, 어디에서 상태가 어긋났는지 다시 구성할 수 있었습니다.
401이 나면 어디부터 볼 것인가
401이 늘면 토큰 발급부터 모든 레이어를 한꺼번에 보지 않았습니다. 먼저 세션 쿠키 유무와 Redis 조회 결과를 확인하고, Gateway가 내부 인증 헤더를 만들었는지까지 따라갔습니다. 여기까지 정상이면 Backend의 도메인 권한 판단으로 범위를 좁혔습니다.
이 순서만으로도 세션 조회 실패, 기존 토큰 경로의 오류, 내부 헤더 전달 문제를 빠르게 분리할 수 있었습니다. 전체 401 수보다 어떤 인증 경로와 상태 변화에서 실패했는지가 더 유용한 신호였습니다.
기준을 모은 뒤 달라진 운영 범위
Backend의 인증 코드는 단순해졌지만 Gateway와 Redis 세션은 모든 인증 요청이 거치는 핵심 경로가 됐습니다.
얻은 단순함
- Gateway 한곳에 모인
Set-Cookie와 삭제 규칙 - 토큰 해석 대신 도메인 권한에 집중하는 Backend
- path의
orgSlug또는orgId를 이용한 공통 조직 컨텍스트 - 트래픽 유입 전에 드러나는 route, upstream,
orgContext오류
계속 확인할 부담
- 여러 Backend로 번질 수 있는 Gateway 지연과 장애
- Redis failover, 복제 지연, 동시 세션 갱신에서의 최신성
- domain, path, SameSite, Secure 설정과 refresh 응답 쿠키 전달 실패
- refresh token 재사용, 세션 고정, 장시간 연결의 재인증 기준
- 서비스 간 호출을 위한 별도 service identity
additionalHeaders, 내부 헤더 version,orgContext의 배포 호환성- 네트워크 정책과 mTLS를 통한 Backend 직접 접근 차단
세션 쿠키가 있는데 Redis가 응답하지 않을 때 오래된 access token으로 우회하면 가용성은 높아질 수 있습니다. 그러나 로그아웃과 권한 회수가 약해지므로 현재 경로는 실패를 숨기지 않는 쪽을 택했습니다.
단일 클라이언트가 토큰만 사용하는 서비스라면 이 구조는 과할 수 있습니다. 반면 쿠키와 refresh, 강제 로그아웃, 권한 변경, 조직별 요청이 함께 있는 환경에서는 판단 기준이 흩어진 비용이 더 컸습니다.
이번 변경은 401 한 건보다 넓은 경계를 남겼습니다. 공통 인증과 request context는 Gateway가 맡고, 멤버십과 역할, 리소스 권한은 Backend에 남습니다. 인증 상태를 한곳에서 확정하되 앞단이 도메인 규칙까지 떠안지 않기 위한 구분입니다.
가장 큰 변화는 401을 하나의 숫자로 보지 않게 된 점입니다. 세션 쿠키, Redis 상태, 내부 헤더와 Backend 권한 판단을 연결하면 어느 경계에서 상태가 어긋났는지 다시 구성할 수 있습니다.