본문으로 건너뛰기

인증을 붙이다 만난 무한 리다이렉트 루프

· 약 8분

로그인 한 번 붙이는 일이 왜 무한 리다이렉트 루프로 이어졌는지, 그리고 쿠키 크기 한계가 설계를 어떻게 바꿨는지 정리합니다.

지난 편에서 서비스를 여러 개로 나눈 이야기를 했습니다. 서비스가 나뉘면 화면(프론트엔드)도 여러 곳에 흩어집니다. 제품 사이트가 따로 있고, 로그인을 전담하는 인증 사이트가 따로 있습니다. 그러면 "한 곳에서 로그인하면 다른 곳에서도 로그인 상태가 유지되어야 한다"는 문제가 생깁니다.

이 문제를 풀다가 무한 리다이렉트 루프에 빠졌습니다. 그 과정을 적어 봅니다.

왜 인증 사이트를 따로 두었나

로그인 화면을 제품마다 만들면, 제품이 늘어날 때마다 로그인 코드도 복제됩니다. 그래서 로그인은 인증 사이트 한 곳에서만 처리하기로 했습니다.

제품 사이트들에서 안내 데스크(인증 사이트)로 보내 신원을 확인하고, 출입증(토큰)을 받아 원래 자리로 돌아오는 흐름

건물에 비유하면 이렇습니다. 제품은 여러 동(棟)으로 나뉜 건물이고, 출입증을 발급하는 곳은 별도의 안내 데스크 한 곳입니다. 어느 동에 들어가려 하든, 출입증이 없으면 먼저 안내 데스크로 보냅니다. 거기서 신원을 확인하고 출입증을 받아 다시 원래 동으로 돌아옵니다.

흐름으로 옮기면 다음과 같습니다.

  1. 사용자가 제품 사이트에서 로그인을 누릅니다.
  2. 인증 사이트로 보냅니다. 이때 "끝나면 어디로 돌아갈지"(return_to)를 함께 넘깁니다.
  3. 인증 사이트에서 신원을 확인합니다.
  4. 토큰을 그대로 넘기면 위험하니, 토큰을 암호화한 짧은 코드를 만들어 제품 사이트로 돌려보냅니다.
  5. 제품 사이트가 그 코드를 토큰으로 교환합니다(exchange).
  6. 토큰을 저장하고, 원래 보려던 화면으로 돌아갑니다.

이 방식을 BFF(Backend for Frontend) 패턴이라고 부릅니다. 토큰 자체를 주소창에 노출하지 않고, 한 단계 안전하게 교환하는 구조입니다.

그런데 회전문에 갇혔습니다

문제가 생겼습니다. 로그인을 누르면 인증 사이트로 갔다가, 다시 제품 사이트로 왔다가, 또 인증 사이트로 가기를 끝없이 반복했습니다. 회전문에 갇힌 것처럼 같은 자리를 빙빙 돌았습니다.

BFF 인증 코드 교환의 의도한 흐름과, 무한 리다이렉트 루프를 만든 세 가지 원인

원인을 파보니 한 가지가 아니라 세 가지가 겹쳐 있었습니다.

원인 1. 돌아갈 주소를 잃어버렸다

"끝나면 어디로 돌아갈지"(return_to)를 임시 저장소(sessionStorage)에 담아 두었습니다. 그런데 로그인이 한 번 실패하고 재시도하는 사이 이 저장소가 비워졌습니다. 돌아갈 주소를 잃으니, 인증이 끝나도 제품 사이트로 못 가고 인증 사이트 안에서만 맴돌았습니다.

비유로 보면

안내 데스크에 "저는 3동에서 왔어요"라고 적은 메모를 맡겼는데, 그 메모가 중간에 사라진 셈입니다. 출입증은 받았지만 어느 동으로 가야 할지 모르게 된 것이지요.

원인 2. 출입증이 주머니에 안 들어갔다

이게 가장 찾기 어려웠습니다. 토큰을 쿠키에 저장하려 했는데, 쿠키 하나의 크기 한계는 약 4KB(4096바이트)입니다.

그런데 우리가 담아야 할 토큰은 두 종류였습니다.

  • 신원 토큰(id token): 약 1,500바이트
  • 갱신 토큰(refresh token): 약 1,700바이트

두 개만 해도 3,200바이트입니다. 여기에 토큰을 암호화하면 크기가 더 늘어납니다(암호화와 인코딩을 거치면 대략 1.3배). 쿠키에 붙는 부가 정보까지 더하니 4,096바이트를 넘어 버렸습니다.

문제는 브라우저가 이때 아무 오류도 내지 않고 조용히 쿠키를 버린다는 점입니다. 저장된 줄 알았는데 실제로는 비어 있었습니다. 그러니 다음 요청에서 "인증 안 됨"으로 막히고, 다시 로그인 화면으로 돌아갑니다. 회전문의 진짜 동력이 여기 있었습니다.

비유로 보면

출입증을 주머니에 넣었다고 생각했는데, 주머니가 작아서 출입증이 바닥에 떨어진 것입니다. 본인은 가진 줄 알고 다음 문에 갔다가 "출입증 없으시네요" 하고 다시 안내 데스크로 돌아가기를 반복합니다.

원인 3. 옆 건물 사물함은 못 연다

토큰을 인증 사이트 쪽 저장소에 넣어 두면, 제품 사이트에서는 그 값을 읽을 수 없습니다. 브라우저는 보안을 위해 사이트(도메인)마다 저장소를 격리하기 때문입니다. 인증 사이트의 사물함은 제품 사이트가 열 수 없습니다.

어떻게 풀었나

세 원인을 각각 막았습니다.

  • 돌아갈 주소는 쉽게 사라지지 않는 저장소(localStorage)로 옮겼습니다. 재시도나 탭 이동에도 유지됩니다.
  • 토큰 저장은 쿠키를 포기하고, 콜백 화면에서 직접 저장소(localStorage)에 넣는 방식으로 바꿨습니다. 쿠키 4KB 한계를 우회한 것입니다.
  • 저장 위치는 토큰을 써야 하는 제품 사이트 쪽으로 일치시켰습니다. 옆 건물이 아니라 자기 건물 사물함에 넣은 셈입니다.

이렇게 하니 회전문이 멈췄습니다. 로그인 한 번이면 토큰이 제대로 저장되고, 원래 보려던 화면으로 돌아갔습니다.

그래서 안전한가요

여기서 한 가지 짚을 점이 있습니다. localStorage는 편하지만 스크립트 공격(XSS)에 취약합니다. 쿠키의 일부 옵션(HttpOnly)은 스크립트가 토큰을 읽지 못하게 막아 주는데, localStorage에는 그런 보호가 없습니다.

그래서 완화책을 단계적으로 더했습니다.

  • 페이지가 실행할 수 있는 스크립트의 출처를 제한하는 보안 정책(CSP)을 적용했습니다.
  • 토큰 수명을 짧게 두어, 혹시 탈취되더라도 피해 시간을 줄였습니다.
  • 더 민감한 갱신 토큰은 스크립트가 못 읽는 쿠키(HttpOnly)로 옮기는 작업을 진행했습니다.

완벽한 한 방은 없었습니다. 편의(UX)와 보안 사이에서 한쪽을 택하면 다른 쪽을 보강하는 식으로, 조금씩 균형을 맞춰 갔습니다.

마무리

  • 화면이 여러 곳으로 나뉘면, "한 곳에서 로그인하면 다른 곳에서도 유지"라는 문제가 따라옵니다. 인증 사이트를 한 곳에 두고 코드를 교환하는 BFF 패턴으로 풀었습니다.
  • 무한 리다이렉트 루프는 원인이 하나가 아니었습니다. 돌아갈 주소 분실, 쿠키 4KB 한계, 도메인 저장소 격리가 겹쳐 있었습니다.
  • 특히 브라우저가 큰 쿠키를 조용히 버린다는 점은 오류 메시지가 없어 찾기 어려웠습니다. "오류 없이 실패하는" 동작이 가장 오래 사람을 붙잡습니다.
  • localStorage 저장은 편의를 위한 선택이었고, 그만큼 보안 완화책으로 보강했습니다.

다음 편에서는 서비스 사이를 잇는 이벤트 버스(NATS)와 워크플로우(Temporal) 이야기를 해보겠습니다. 비동기로 일을 흘려보낼 때 생기는 문제와, 한 번 크게 데인 메시지 보관 기간 설정 사고를 적어 볼 생각입니다.