인증 구조가 무거워졌다는 신호는 보통 로그인 실패로 오지 않습니다. 오히려 로그인은 잘 됩니다. 토큰도 발급되고, 쿠키도 내려가고, 요청도 통과합니다. 그래서 한동안은 시스템이 별문제 없이 돌아가는 것처럼 보입니다. 실제로 구조가 무너지고 있다는 감각은 훨씬 늦게 옵니다.

대개는 이런 식으로 드러납니다. 만료된 토큰을 어디서 처리할지 경로마다 달라지고, 웹과 모바일이 서로 다른 예외를 만들고, 인증 이후 브라우저에 어떤 쿠키를 어떤 속성으로 남길지 결정하는 코드가 서비스와 컨트롤러, 필터 사이로 퍼집니다. 조직 초대나 SSO, 테스트용 진입점 같은 예외 흐름이 붙기 시작하면, 인증은 더 이상 필터 하나의 일이 아니게 됩니다. 시스템 전반에 걸친 후처리 문제가 됩니다.

이런 상태의 백엔드가 주는 불편함은 단순히 코드가 길어진다는 데 있지 않습니다. 인증 정책을 바꿀 때마다 컨트롤러, 필터, 쿠키 처리, 예외 경로가 같이 흔들리고, 무엇이 인증의 본질이고 무엇이 전달 형식인지 경계가 흐려집니다. 좋은 인증 구조는 “토큰을 잘 검증하는 구조” 이전에, 책임이 어디에 있어야 하는지가 선명한 구조여야 합니다.

이번 작업은 바로 그 경계를 다시 그리는 과정이었습니다. 토큰을 실제로 마주하는 가장 바깥 레이어인 Gateway가 인증 판단과 쿠키 관리를 맡고, Backend는 그 결과를 소비하는 쪽으로 역할을 바꿨습니다. 겉보기에는 인증 로직을 조금 정리한 리팩터링처럼 보일 수 있습니다. 실제로는 그보다 훨씬 구조적인 변화였습니다. 인증을 어디서 판단하고, 어디서 전달 형식으로 바꾸고, 어디서 정책으로 해석할지를 다시 나누는 작업이었기 때문입니다.

인증 책임이 한 레이어에 몰리면 시스템이 무거워집니다

인증 책임 집중 구조
인증 책임 집중 구조

기존 구조에서 백엔드는 인증의 중심이었습니다. 요청이 들어오면 토큰을 읽고, 검증하고, principal을 만들고, 컨트롤러와 서비스는 그 결과를 전제로 동작했습니다. 그 자체만 보면 이상한 구조는 아닙니다. 많은 웹 백엔드가 처음에는 그렇게 시작합니다.

문제는 인증이 JWT 검증으로 끝나지 않는다는 데 있습니다. 웹에서는 access token과 refresh token을 어떤 쿠키 속성으로 내려야 하는지 결정해야 했고, 모바일은 또 다른 진입 경로를 가졌습니다. 예외 진입점이 붙을수록 인증 이후의 후처리도 각 경로에 달라졌습니다. 같은 “로그인 성공”인데도 어떤 경로에서는 access token만 내려가고, 어떤 경로에서는 refresh token까지 같이 내려가고, 어떤 경로에서는 만료된 토큰을 401로 보내고, 어떤 경로에서는 별도의 refresh 진입점으로 넘겨야 하는 식입니다.

이 지점에서 구조는 꼬이기 시작합니다. 토큰 검증도, 쿠키 처리도, 만료 정책도, 사용자 컨텍스트 복원도 모두 안쪽 레이어에 남습니다. 이 구조는 짧게 보면 단순합니다. 하지만 시간이 지나면 인증 정책을 바꿀 때마다 여러 계층이 같이 움직입니다. 기능이 늘어난 것이 아니라 책임이 섞이기 시작한 상태입니다.

이런 구조가 특히 좋지 않은 이유는, 문제가 기능 단위로 드러나지 않기 때문입니다. “이 함수가 잘못됐다”가 아니라 “이 변경을 하면 왜 이렇게 많은 파일이 같이 흔들리지?”라는 식으로 드러납니다. 인증 정책이 바뀌는 순간, 필터와 컨트롤러, 쿠키 처리, 예외 경로, 테스트 코드가 한꺼번에 영향을 받습니다. 구조적으로 봐야만 설명이 되는 종류의 불편입니다.

이 사례에서 얻을 수 있는 첫 번째 원칙은 분명합니다.

토큰을 읽는 곳, 인증을 판정하는 곳, 브라우저와 쿠키로 계약하는 곳, 사용자 컨텍스트를 소비하는 곳이 모두 같아지면 인증은 안쪽 레이어를 더 빠르게 오염시킨다.

Gateway는 이미 요청의 가장 바깥에서 토큰과 쿠키를 직접 마주합니다. 헤더를 sanitize하고, upstream으로 보낼 값을 걸러내고, 응답의 Set-Cookie를 최종적으로 통제할 수 있습니다. 이 레이어는 인증 판단과 쿠키 계약을 다루기에 자연스럽습니다. 반면 Backend는 비즈니스 로직과 도메인 규칙을 처리하는 안쪽 레이어입니다. 인증 판단과 전달 형식이 안쪽까지 깊게 들어가면, 인증 정책을 바꿀 때마다 도메인 레이어도 같이 흔들립니다.

즉, 이 전환의 핵심은 Gateway를 새로 세운 게 아니라, 인증 경계를 어디에 다시 그을 것인가를 정한 데 있습니다.

백엔드는 쿠키를 직접 만들지 않습니다

Gateway signal 기반 쿠키 책임 이동
Gateway signal 기반 쿠키 책임 이동

첫 번째 전환은 쿠키 관리였습니다. 이전에는 인증 성공 이후 access token과 refresh token을 어떤 도메인과 경로로 내려야 하는지에 대한 판단이 백엔드 응답 흐름 안에 남아 있었습니다. 기능적으로는 가능했지만 설계상으로는 어색했습니다. 브라우저와 직접 계약하는 쿠키 정책을 도메인 로직에 가까운 레이어가 함께 쥐고 있었기 때문입니다.

여기서 도입된 것이 signal 방식입니다. 백엔드는 쿠키를 직접 만들지 않습니다. 대신 Gateway가 이해할 수 있는 응답 신호를 남깁니다. 그러면 Gateway가 그 신호를 실제 쿠키로 바꿉니다.

이 변화가 주는 장점은 단순히 코드 위치가 바뀐다는 데 있지 않습니다. 쿠키라는 표현 형식이, 다시 브라우저와 가장 가까운 레이어로 돌아간다는 데 있습니다. Backend가 계속 쿠키를 직접 만들고 있으면, 도메인 로직과 브라우저 전달 형식이 한 흐름 안에 붙어 있게 됩니다. 반대로 signal 방식으로 바꾸면, 백엔드는 인증 결과를 기술하고 Gateway는 그것을 실제 전달 형식으로 바꿉니다. 쿠키 쓰기 책임이 더 이상 컨트롤러와 서비스 곳곳에 매달리지 않게 됩니다.

이 지점에서 설계적으로 더 중요했던 부분은, 쿠키 관련 값들을 helper 안에 감추지 않았다는 점입니다. 호출자가 필요한 값을 명시적으로 전달하게 둔 덕분에 요청 컨텍스트 의존이 서비스 깊숙한 곳까지 전파되지 않았습니다. 겉보기에는 덜 편해 보일 수 있습니다. 하지만 이런 종류의 명시성이야말로 인증 후처리가 다시 도메인 로직 안으로 스며드는 것을 막아 줍니다.

이 사례에서 얻을 수 있는 두 번째 원칙은 이것입니다.

브라우저와 직접 계약하는 표현 형식은 가장 바깥 레이어에 두고, 안쪽 레이어는 “무엇을 내려야 하는가”까지만 말하게 만드는 편이 더 오래 버틴다.

실무적으로 보면 이 전환은 두 가지를 동시에 얻습니다.

  • Backend는 인증 성공 이후의 도메인 판단만 남기고, 쿠키 쓰기 자체는 내려놓을 수 있습니다.
  • Gateway는 응답 Set-Cookie를 통제하는 위치이므로, 쿠키 발급과 삭제를 한 곳에서 일관되게 처리할 수 있습니다.

반대로 signal은 외부 클라이언트에 그대로 노출되지 않는 내부 계약으로만 남습니다. 즉, 쿠키는 도메인 로직이 아니라 전달 형식이라는 점이 더 선명해집니다.

요청은 토큰이 아니라 정리된 인증 상태로 안쪽에 들어갑니다

Gateway 인증 상태 전달 흐름
Gateway 인증 상태 전달 흐름

쿠키 관리의 이동이 첫 단계였다면, 그다음은 인증 판단의 이동입니다. Gateway는 더 이상 단순한 reverse proxy가 아니라, 인증된 컨텍스트를 만들어 Backend로 전달하는 레이어가 됩니다.

여기서 중요한 건 Gateway가 토큰을 검증한다는 사실 자체가 아닙니다. 더 중요한 건 검증 결과를 상태로 남긴다는 점입니다. valid, expired, invalid, missing 같은 상태를 남기고, Backend는 그 상태를 바탕으로 principal을 복원하거나 만료 정책을 적용합니다.

이 설계가 주는 세 번째 원칙은 분명합니다.

토큰 검증 결과를 성공/실패 두 가지로만 남기면 정책은 안쪽으로 퍼진다. 상태를 남기면 판정과 정책을 분리할 수 있다.

예를 들어 expiredinvalid와 운영 의미가 다릅니다. expired는 refresh 흐름으로 이어질 수 있지만, invalid는 그렇지 않을 수 있습니다. 상태를 나누지 않으면 정책이 필터 곳곳에 퍼집니다. 상태를 명시하면 판단은 바깥에서 끝나고, Backend는 그 결과를 어떻게 처리할지만 결정하면 됩니다.

이 구조는 Backend를 더 단순하게 만듭니다. 동시에 더 위험한 전제도 생깁니다. Gateway 뒤의 서비스는 이제 전달된 인증 컨텍스트를 내부 신뢰 경계 위에서 받아들입니다. 즉, 백엔드는 가벼워졌지만 Gateway는 더 중요한 장애 지점이 됩니다.

이 점은 장점만큼이나 중요합니다. 구조가 좋아졌다는 말은, 종종 복잡도가 사라졌다는 뜻처럼 들립니다. 실제로는 그렇지 않습니다. 복잡도는 사라진 것이 아니라 더 바깥으로 이동한 것에 가깝습니다. 이전에는 인증 정책이 여러 레이어 안에 분산돼 조용히 망가졌다면, 지금은 계약이 선명해진 대신 계약 실패가 더 빠르게, 더 직접적으로 드러납니다.

이 선택은 “덜 위험한 구조”가 아니라, “더 위험한 실패를 더 앞단에서 붙잡는 구조”에 가깝습니다. 인증 정책이 안쪽 레이어까지 퍼져 조용히 망가지는 것보다, 경계가 선명한 대신 계약 실패가 빠르게 보이는 편을 택한 것입니다.

그래서 무엇이 달라졌나

이 전환 이후에는 백엔드가 토큰을 직접 해석하고, 인증 이후 쿠키 발급을 간접적으로 책임지고, 만료 처리와 redirect 정책까지 품는 구조에서 벗어날 수 있었습니다.

  • Gateway는 토큰을 읽고 검증합니다.
  • Gateway는 쿠키 발급/삭제 의도를 실제 브라우저 응답으로 바꿉니다.
  • Backend는 인증 결과와 사용자 컨텍스트를 소비합니다.
  • Backend는 경로별 정책과 도메인 로직에 집중합니다.

이건 코드 줄 수를 줄였다는 이야기보다, 어디에서 어떤 결정을 내려야 하는지가 더 명확해졌다는 이야기입니다. 이전에는 인증이 백엔드 내부 구현처럼 퍼져 있었다면, 이제는 Gateway와 Backend 사이에 놓인 명시적 계약으로 보이기 시작합니다.

만료 토큰 처리도 같은 맥락에서 더 일관되게 바뀝니다. Gateway가 expired 상태를 명시적으로 전달하면, Backend는 그 상태를 바탕으로 경로별 정책만 수행합니다. 브라우저 페이지 경로라면 refresh 흐름으로 연결하고, 일반 API 경로라면 401 TOKEN_EXPIRED를 돌려줍니다. 중요한 건 만료 여부는 Gateway가 판단하고, 그 결과를 어떻게 정책으로 바꿀지는 Backend가 정리한다는 점입니다. 이 분리 덕분에 만료 정책은 더 읽기 쉬워졌고, 어디서 바꿔야 하는지도 더 분명해졌습니다.

테스트도 기능이 아니라 계약을 붙잡는 쪽으로 바뀝니다. 토큰이 없으면 missing, 유효하면 사용자 컨텍스트 주입, 만료면 expired, 임의로 주입한 내부 헤더는 sanitize, 인증 신호는 실제 쿠키로 변환되지만 그대로 노출되면 안 됩니다. Backend 쪽에서는 만료 상태가 경로별로 다르게 처리되는지, 유효한 인증 컨텍스트가 있으면 principal 복원이 정상 동작하는지를 고정합니다. 이번 전환에서 더 위험한 것은 개별 함수보다 계약이 깨지는 쪽이기 때문입니다.

마치며

이 작업의 핵심은 Gateway를 하나 더 두었다는 사실이 아닙니다. 더 중요한 건, 인증을 어디서 판단하고 어디서 소비해야 하는지에 대한 기준을 다시 세웠다는 점입니다.

결국 바뀐 것은 구현 몇 줄이 아니라 책임의 위치였습니다. 더 정확히는, 인증이 더 이상 백엔드 내부 구현이 아니라 Gateway와 Backend 사이의 계약이 됐다는 점이 이번 전환의 핵심이었습니다.