본문 바로가기
Backend/Architecture

[Server] 여러 API 서비스의 인증을 Gateway에 모은 이유

Baspo8 2026. 8. 8.

여러 API 서비스가 같은 인증 정책을 따르도록 Gateway에 인증 책임을 모으고, 토큰 검증·재발급·내부 요청 전달 과정에서 생긴 문제를 해결한 기록이다.





시작하며

백엔드를 여러 서비스로 나누면 각 서비스의 책임은 선명해진다. 하지만 클라이언트 입장에서는 호출해야 할 서버가 늘어나고, 서버 입장에서는 인증 로직이 여러 곳으로 퍼지기 쉽다.

사용자·설정 관리, 데이터 조회, 별도 업무 기능을 서로 다른 API 서비스로 나누어 개발했다. 처음에는 각 서비스가 토큰을 직접 확인하는 구조도 생각할 수 있었지만, 서비스가 늘어날수록 다음 문제가 예상됐다.

  • 모든 서비스가 같은 JWT 검증 규칙을 구현해야 한다.
  • 로그인과 토큰 재발급 정책이 바뀌면 여러 서비스를 함께 수정해야 한다.
  • 클라이언트가 내부 서비스의 주소와 구성을 알아야 한다.
  • 인증되지 않은 요청이 내부 서비스에 직접 도달할 수 있다.
  • 사용자 정보를 서비스마다 다시 조회하면 중복 요청이 증가한다.

이 문제를 줄이기 위해 외부 요청의 진입점을 Gateway 하나로 제한하고, 사용자 인증을 Gateway의 책임으로 두었다.

이 글에서는 완성된 코드보다 그 구조를 선택한 이유와 개발하면서 마주친 문제를 중심으로 정리한다.





전체 구조

외부 클라이언트는 Gateway만 호출한다. Gateway는 토큰을 검증한 뒤 요청 성격에 따라 내부 API 서비스로 전달한다.

외부에는 하나의 API만 보이지만 내부에서는 기능별 서비스가 독립적으로 동작한다. 여기서 Gateway는 단순한 reverse proxy보다 조금 더 많은 책임을 가진다.

  1. access token 추출
  2. 토큰 유효성 확인
  3. 만료된 토큰의 재발급 시도
  4. 검증된 사용자 컨텍스트 생성
  5. 내부 서비스에 요청 전달
  6. 내부 서비스의 응답과 오류를 외부 계약에 맞게 변환




인증을 각 서비스에 넣지 않은 이유

각 서비스가 JWT를 직접 검증하는 구조는 처음에는 단순해 보인다. JWT 서명만 확인하면 별도 인증 서버 호출 없이 요청을 처리할 수 있기 때문이다.

하지만 실제 인증 정책에는 서명 검증 이상의 정보가 필요했다.

  • 로그아웃된 토큰인지
  • 사용자가 삭제되거나 비활성화되지 않았는지
  • 현재 로그인 세션과 연결된 토큰인지
  • access token이 만료됐을 때 refresh token으로 갱신할 수 있는지
  • 사용자에게 어떤 권한과 접근 범위가 있는지

각 서비스가 이 정책을 구현하면 인증 코드가 복제된다. 서비스별 배포 시점이 달라지면 같은 토큰을 두고 한 서비스는 허용하고 다른 서비스는 거부하는 상황도 생길 수 있다.

그래서 인증 판단은 Gateway와 인증 모듈에 집중시켰다.

  • Gateway는 외부 요청을 받고 인증 절차를 조정한다.
  • 인증 모듈은 토큰 발급, 검증, 로그아웃 상태와 사용자 정보를 관리한다.
  • 내부 API 서비스는 Gateway가 전달한 검증 결과를 사용해 비즈니스 로직에 집중한다.

Gateway가 JWT 자체를 완전히 신뢰해서 해석하는 대신 인증 모듈의 introspection 결과를 사용한 것도 같은 이유다. 토큰의 암호학적 유효성뿐 아니라 서버가 관리하는 현재 상태를 함께 판단하기 위해서다.





요청 하나가 처리되는 과정

일반적인 인증 요청은 다음 순서로 처리된다.

Gateway는 Authorization 헤더를 먼저 확인하고, 없으면 쿠키에서 access token을 찾는다. 브라우저와 API 클라이언트가 서로 다른 전달 방식을 사용해도 같은 인증 흐름으로 처리하기 위해서다.

토큰이 유효하면 검증 결과와 사용자 정보를 짧게 캐시한다. 이후 같은 토큰으로 들어온 요청은 매번 인증 모듈에 접근하지 않아도 된다.

캐시에는 단순한 true/false만 저장하지 않고 내부 서비스에 전달할 사용자 컨텍스트도 함께 저장한다. 인증 성공 이후 사용자 정보를 다시 조회하는 요청을 줄이기 위해서다.





인증 캐시에서 어려웠던 부분

토큰 검증 결과를 캐시하면 요청량은 줄어들지만 새로운 문제가 생긴다.

로그아웃한 토큰이 캐시에 남을 수 있다

검증 결과가 캐시에 남아 있으면 인증 모듈에서 로그아웃 처리된 토큰도 캐시 만료 전까지 유효한 것처럼 보일 수 있다.

이를 막기 위해 두 방향의 캐시 키를 관리했다.

token_status:{token}  -> 토큰 검증 결과
user_tokens:{userId}  -> 해당 사용자의 토큰 목록

특정 토큰만 로그아웃할 때는 해당 토큰의 상태 캐시와 사용자별 토큰 목록에서 그 토큰을 제거한다. 모든 세션을 무효화해야 할 때는 사용자별 토큰 목록을 이용해 관련된 상태 캐시를 함께 지운다.

이 구조의 핵심은 캐시를 빠르게 읽는 것만큼 무효화 경로를 먼저 설계하는 것이다.

TTL은 토큰 수명과 같은 의미가 아니다

캐시 TTL은 토큰의 실제 만료시간을 대신하지 않는다. 캐시가 사라졌다고 토큰이 만료된 것도 아니고, 캐시에 값이 있다고 서버 상태가 영원히 유지되는 것도 아니다.

따라서 캐시는 인증 판단의 원본이 아니라 반복 검증 비용을 줄이는 보조 계층으로 사용했다. 보안 정책에 따라 로그아웃이나 사용자 상태 변경 시 명시적인 무효화가 필요하다.





동시 요청과 refresh token 경쟁 조건

브라우저 화면 하나를 열 때 여러 API 요청이 거의 동시에 발생한다. 이때 access token이 만료되어 있으면 모든 요청이 동시에 refresh를 시도할 수 있다.

refresh token을 회전시키는 정책이라면 첫 번째 요청이 새 토큰을 발급한 직후 두 번째 요청이 이전 refresh token을 들고 실패할 수 있다. 사용자에게는 로그인 직후 일부 API가 401로 실패하거나 다시 로그인 화면으로 돌아가는 현상으로 보인다.

이 문제는 동일한 refresh token에 대해 진행 중인 갱신 작업을 공유하는 방식으로 해결했다.

개념적인 구현은 다음과 같다.

const refreshInFlight = new Map<string, Promise<RefreshResult>>();

async function refreshOnce(refreshToken: string) {
  const key = hash(refreshToken);

  const existing = refreshInFlight.get(key);
  if (existing) {
    return existing;
  }

  const promise = requestNewToken(refreshToken).finally(() => {
    refreshInFlight.delete(key);
  });

  refreshInFlight.set(key, promise);
  return promise;
}

첫 번째 요청만 실제 재발급을 수행하고, 같은 refresh token을 사용한 나머지 요청은 동일한 Promise를 기다린다. 이것은 흔히 single-flight 패턴이라고 부른다.

여기에 아주 짧은 grace period를 두어 갱신이 끝난 직후 도착한 요청도 직전 결과를 재사용하게 했다. refresh token 원문은 Map의 키로 사용하지 않고 hash로 변환해 메모리 덤프나 디버깅 과정에서 토큰이 직접 노출될 가능성을 줄였다.

갱신에 성공하면 Gateway는 세 가지를 함께 처리한다.

  1. 클라이언트 응답에 새 쿠키를 설정한다.
  2. 현재 내부 요청의 Authorization 헤더를 새 access token으로 교체한다.
  3. 검증된 사용자 컨텍스트를 현재 요청에 연결한다.

새 쿠키만 내려주고 현재 요청의 헤더를 바꾸지 않으면, 그 요청은 여전히 만료된 access token을 내부 서비스에 전달한다. 클라이언트가 새 쿠키를 사용하는 것은 다음 요청부터이기 때문이다.





검증된 사용자 정보를 내부 서비스에 전달하기

인증에 성공하면 내부 서비스는 사용자 식별자가 필요하다. 매 요청마다 사용자 테이블을 다시 조회할 수도 있지만, Gateway가 이미 얻은 검증 결과를 활용하는 편이 효율적이다.

다만 클라이언트가 보낸 사용자 관련 헤더를 그대로 신뢰해서는 안 된다. Gateway는 외부에서 들어온 사용자 컨텍스트 헤더를 전달하는 것이 아니라, 인증 결과를 바탕으로 내부용 헤더를 새로 만든다.

const user = request.user;

headers['x-user-id'] = String(user.id);
headers['x-user-name'] = Buffer.from(user.displayName, 'utf8').toString(
  'base64',
);

여기서 사용자 이름을 Base64로 인코딩한 데에는 별도의 이유가 있다. 개발 후반에 영문 사용자로는 정상 동작하지만 한글 이름을 가진 사용자만 로그인 이후 화면으로 넘어가지 못하는 문제가 발견됐다.

로그인 요청 자체는 공개 경로여서 성공했다. 문제는 로그인 후 첫 내부 API 요청이었다. 검증된 사용자 이름을 HTTP 헤더에 그대로 넣으면서 Node.js가 비ASCII 문자를 유효하지 않은 헤더 값으로 판단한 것이다.

이 문제 이후 내부 헤더 정책을 다음처럼 정리했다.

  • 식별에는 ASCII 숫자인 사용자 ID를 사용한다.
  • 표시 이름이 필요하면 UTF-8 문자열을 Base64로 인코딩한다.
  • 내부로 전달되는 모든 헤더 값이 ASCII인지 테스트한다.
  • 내부 서비스는 클라이언트가 보낸 값이 아니라 Gateway가 생성한 값만 사용한다.

Base64는 암호화가 아니다. 여기서는 보안을 위한 것이 아니라 HTTP 헤더로 안전하게 운반하기 위한 인코딩으로만 사용한다.





내부 서비스 직접 접근 차단

Gateway에서 인증하더라도 클라이언트가 내부 서비스 주소를 직접 호출할 수 있다면 인증 경계를 우회할 수 있다.

운영 환경에서는 내부 서비스를 외부 인터페이스에 노출하지 않고, Gateway 요청임을 확인하는 내부 표식과 공유된 credential을 함께 검사하도록 구성했다.

여기서 사용자 인증과 서비스 간 인증을 구분해야 한다.

  • 사용자 access token은 누가 요청했는지 확인한다.
  • 내부 credential은 어떤 서비스가 요청을 전달했는지 확인한다.

특정 헤더 하나만 존재하는지 확인하는 것은 충분하지 않다. 외부 사용자가 같은 헤더를 만들 수 있기 때문이다. 네트워크 수준에서 내부 포트를 제한하고, 추측할 수 없는 credential을 검증하며, 가능하면 mTLS나 서비스 메시 같은 수단으로 발전시키는 것이 더 안전하다.

개발 환경에서는 각 서비스를 직접 호출할 필요가 있어 제한을 완화하되, 운영 환경에서는 Gateway를 거치도록 했다. 환경별 동작 차이는 편리하지만 테스트 누락의 원인이 될 수 있으므로 운영 설정을 재현하는 통합 테스트가 필요하다.





공개 API는 인증이 없는 API가 아니다

Gateway에는 로그인이나 상태 확인처럼 사용자 access token 없이 호출해야 하는 경로가 있다. 이를 위해 메타데이터 기반의 Public 표시를 사용했다.

const isPublic = reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [
  context.getHandler(),
  context.getClass(),
]);

if (isPublic) {
  return true;
}

주의할 점은 Public이 “아무 검증도 필요 없다”는 뜻은 아니라는 것이다. 사용자 JWT 대신 API key나 장치 credential을 사용하는 M2M API도 사용자 인증 Guard는 통과시켜야 한다. 이 경우 해당 라우트에는 별도의 기계 인증 Guard가 반드시 필요하다.

즉 인증 예외는 다음처럼 인증 수단의 전환으로 해석해야 한다.

일반 사용자 API  -> User JWT Guard
로그인·상태 확인 -> No user authentication
M2M API          -> Machine credential Guard
내부 API         -> Service credential verification

Public이라는 이름만 보고 완전히 열린 API로 오해하지 않도록 코드 리뷰와 API 문서에서 실제 인증 수단을 함께 표시해야 한다.





공통 응답과 예외 처리도 Gateway의 계약이다

Gateway는 인증뿐 아니라 외부 응답 형식의 경계이기도 하다. 일반 JSON API는 성공 응답을 공통 형태로 감싸고, 내부 서비스 오류는 외부 HTTP 상태와 오류 형식으로 변환한다.

{
  "success": true,
  "statusCode": 200,
  "data": {},
  "message": null
}

그러나 모든 응답을 무조건 감싸면 안 된다. 파일 스트림이나 이미 계약이 정해진 M2M 응답은 원본 형식을 유지해야 한다. 공통 interceptor를 도입할 때는 적용 대상뿐 아니라 예외 대상도 명시적으로 관리해야 한다.

인증과 직접 관련 없는 부분처럼 보이지만, 클라이언트가 보는 최종 계약을 Gateway가 책임진다는 점에서는 같은 설계 문제다.





현재 구조의 한계

이 구조가 모든 상황에 적합한 것은 아니다.

Gateway가 새로운 병목이 된다

모든 요청과 인증 판단이 Gateway를 거치므로 Gateway 장애가 전체 API 장애로 이어진다. timeout, connection pool, streaming, 대용량 body 같은 프록시 세부 설정도 Gateway가 책임져야 한다.

인메모리 single-flight는 한 프로세스에서만 동작한다

동시 refresh를 합치는 Map과 짧은 결과 캐시는 현재 프로세스 안에서만 공유된다. Gateway를 여러 인스턴스로 수평 확장하면 동일한 refresh 요청이 서로 다른 인스턴스로 전달될 수 있다.

여러 인스턴스가 필요하다면 다음 대안을 검토해야 한다.

  • refresh 요청에 대한 분산 lock
  • Redis를 이용한 짧은 결과 공유
  • refresh token rotation 정책 변경
  • 클라이언트에서 refresh 요청을 하나로 합치기

내부 헤더는 전송 수단이지 권한 판단의 원본이 아니다

Gateway가 생성한 사용자 ID 헤더는 편리하지만 내부 네트워크가 침해되면 위조될 수 있다. 중요한 권한 판단에는 서명된 내부 토큰이나 서비스 간 인증을 함께 사용하는 편이 안전하다.

중앙화는 결합도를 만든다

인증 정책을 한곳에 모으면 일관성은 좋아지지만 모든 서비스가 Gateway의 사용자 컨텍스트 형식에 의존하게 된다. 헤더 이름과 데이터 형식을 외부 API 못지않은 내부 계약으로 관리해야 한다.





개발하면서 얻은 교훈

1. 인증 경계는 토큰을 검사하는 위치만의 문제가 아니다

인증 경계를 정한다는 것은 다음을 함께 결정하는 일이다.

  • 외부에 공개할 진입점
  • 토큰 상태의 정본
  • 토큰 갱신 주체
  • 사용자 컨텍스트 전달 방식
  • 내부 서비스 직접 접근 정책
  • 공개 API와 M2M API의 구분

2. 캐시는 조회보다 무효화가 어렵다

토큰 검증 결과를 캐시하는 것은 간단하지만 로그인, 로그아웃, 재발급, 사용자 비활성화가 일어날 때 어떤 키를 지워야 하는지 먼저 설계해야 한다.

3. refresh는 단일 요청 문제가 아니다

브라우저는 여러 API를 동시에 호출한다. refresh 구현은 반드시 동시 요청을 전제로 검증해야 한다. 정상 경로만 테스트하면 실제 화면에서는 간헐적인 401이 발생할 수 있다.

4. 내부 헤더도 명시적인 데이터 계약이다

문자 인코딩, 크기, 필수 여부, 신뢰 주체를 정하지 않으면 단순한 문자열 전달도 장애가 된다. 특히 영문 테스트 데이터만 사용하면 비ASCII 헤더 문제를 발견하기 어렵다.

5. 인증 우회와 인증 수단 변경을 구분해야 한다

사용자 JWT를 사용하지 않는다고 인증이 필요 없는 것은 아니다. M2M 요청에는 사용자 인증과 다른 종류의 credential이 필요하다.





마치며

Gateway를 둔다고 인증 문제가 자동으로 해결되지는 않았다. 오히려 토큰 검증 결과 캐시, 로그아웃 무효화, 동시 refresh, 사용자 컨텍스트 전달, 내부 서비스 보호처럼 이전에는 각 요청에 흩어져 있던 문제가 하나의 경계에 모였다.

하지만 책임이 모이면서 정책을 한곳에서 설명하고 테스트할 수 있게 됐다. 클라이언트는 내부 서비스 구성을 알 필요가 없어졌고, 내부 서비스는 인증 절차보다 자신의 비즈니스 로직에 집중할 수 있었다.

이번 개발에서 가장 중요했던 결론은 다음과 같다.

Gateway 인증은 JWT를 한 번 검사하는 기능이 아니라, 외부 요청을 신뢰 가능한 내부 요청으로 바꾸는 경계다.