본문 바로가기
삵
Nginx

Nginx auth_request로 인증 게이트웨이 만들기

aappsroot·2026년 10월 24일·조회 0

여러 서비스를 Nginx 뒤에 두고 운영하다 보면, 인증을 각 애플리케이션마다 중복으로 구현하는 상황을 자주 만난다. 어떤 서비스는 Spring Security, 어떤 서비스는 직접 짠 필터, 또 어떤 서비스는 아예 인증이 빠져 있다. 그러다 보면 로그인 정책이 바뀔 때마다 모든 앱을 손봐야 한다. 이 글은 그 중복을 걷어내고 인증 판단을 한 군데로 모으는 방법을 다룬다.

결론부터 말하자면, Nginx의 ngx_http_auth_request_module을 쓰면 요청이 실제 백엔드로 가기 전에 별도의 인증 서버로 서브리퀘스트를 보내 통과 여부를 먼저 판단할 수 있다. 인증 서버가 2xx를 돌려주면 요청을 통과시키고, 401이나 403을 돌려주면 그 자리에서 접근을 막는다. 토큰 검증 로직은 인증 서버 한 곳에만 두면 된다.

1. 개요

대상 버전은 Nginx 1.5.4 이상이다(서브리퀘스트 인증 모듈이 이 버전에서 들어왔다). 설정은 공식 문서 ngx_http_auth_request_module 기준으로 작성했다. 버전 번호나 빌드 옵션은 설치 환경에 따라 다를 수 있다.

auth_request는 요청을 처리하기 전에 지정한 내부 URI로 서브리퀘스트를 한 번 보내는 지시어다. 서브리퀘스트란 Nginx가 클라이언트 요청을 처리하는 도중에 내부적으로 만들어 보내는 보조 요청을 말한다. 이 보조 요청의 응답 상태 코드만 보고 원래 요청을 통과시킬지 결정한다. 응답 본문은 쓰지 않는다.

즉 인증 게이트웨이의 뼈대는 이렇다. 보호할 경로에 auth_request를 걸어 두고, 그 서브리퀘스트를 받아 토큰이나 세션을 검증하는 인증 서버를 하나 둔다. 검증 결과를 상태 코드로 돌려주면 Nginx가 나머지를 처리한다. 아래에서는 모듈 확인, 기본 설정, 헤더 전달, 401/403 처리, 함정을 차례로 살펴본다.

2. 모듈이 들어있는지 먼저 확인한다

이 모듈은 기본 빌드에 포함되지 않는다. 소스에서 직접 빌드했다면 --with-http_auth_request_module 옵션이 있었는지 확인해야 한다. 데비안, 우분투, RHEL 계열의 배포판 패키지와 공식 nginx.org 패키지에는 보통 포함되어 있다. NGINX Plus는 기본 포함이다.

$ nginx -V 2>&1 | tr ' ' '\n' | grep auth_request
--with-http_auth_request_module

위 한 줄이 나오면 쓸 수 있다. 아무것도 안 나오면 모듈이 없는 것이고, 설정에 auth_request를 넣는 순간 다음 에러로 기동이 막힌다.

nginx: [emerg] unknown directive "auth_request" in /etc/nginx/conf.d/app.conf:12

이 경우 모듈이 포함된 패키지로 다시 설치하거나 소스를 다시 빌드해야 한다. 나는 가능하면 nginx.org 공식 저장소 패키지를 쓴다. 직접 빌드하면 보안 업데이트 때마다 다시 빌드해야 해서 손이 많이 간다.

3. 기본 설정

보호할 경로와 인증 서브리퀘스트 경로를 나눈다. 아래는 가장 단순한 형태다.

server {
    listen 80;
    server_name app.example.com;

    # 실제 서비스
    location / {
        auth_request /_auth;
        proxy_pass http://127.0.0.1:8080;
    }

    # 인증 서브리퀘스트 (외부에서 직접 못 들어오게 internal)
    location = /_auth {
        internal;
        proxy_pass http://127.0.0.1:9000/verify;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header X-Original-Method $request_method;
    }
}

요점을 하나씩 본다.

internal

location = /_auth에는 internal을 붙였다. 이걸 빼면 클라이언트가 /_auth를 직접 호출할 수 있다. 인증 검증 엔드포인트가 그대로 노출되면 곤란하므로 반드시 넣는다.

proxy_pass_request_body off

인증 서버는 토큰이나 쿠키만 보면 되므로 요청 본문을 넘길 필요가 없다. proxy_pass_request_body off로 본문 전달을 끄고, Content-Length를 빈 값으로 세팅해 길이 불일치를 막는다. 큰 POST 본문이 인증 서버로 중복 전송되는 낭비도 사라진다.

원본 요청 정보 전달

서브리퀘스트는 원래 요청 경로를 그대로 들고 가지 않는다. 인증 서버가 "어떤 경로에 대한 요청인지"를 알아야 경로별 권한 판단을 할 수 있으므로 X-Original-URI와 X-Original-Method로 넘겨준다. Authorization 헤더나 Cookie는 서브리퀘스트에 기본으로 함께 전달되므로 토큰과 세션은 인증 서버에서 그대로 읽을 수 있다.

4. 인증 서버는 상태 코드만 정확히 돌려주면 된다

인증 서버가 할 일은 단순하다. 토큰이나 세션을 검증하고 결과를 상태 코드로 응답한다. 모듈은 서브리퀘스트 응답을 이렇게 해석한다.

  • 2xx: 접근 허용. 원래 요청이 백엔드로 넘어간다.
  • 401: 접근 거부. 서브리퀘스트가 돌려준 WWW-Authenticate 헤더가 클라이언트로 전달된다.
  • 403: 접근 거부.
  • 그 외 코드: 에러로 처리된다(클라이언트는 500을 받는다).

여기서 한 번 걸리기 쉽다. 인증 실패를 200 본문에 {"ok":false}처럼 담아 돌려주면 Nginx는 통과로 판단한다. 상태 코드로 말해야 한다. 또 인증 서버가 302 리다이렉트를 돌려주면 2xx도 401도 403도 아니라서 에러로 떨어진다. 인증 서버에서 로그인 페이지로 리다이렉트하는 습관이 있다면 여기서는 그렇게 하면 안 된다.

간단한 검증 서버 예시다(Python 표준 라이브러리만 사용).

from http.server import BaseHTTPRequestHandler, HTTPServer

def verify(headers):
    auth = headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return None
    token = auth[len("Bearer "):]
    # 실제로는 여기서 JWT 서명 검증, 만료 확인 등을 한다
    if token == "good-token":
        return {"user": "1001", "roles": "admin"}
    return None

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        claims = verify(self.headers)
        if claims is None:
            self.send_response(401)
            self.send_header("WWW-Authenticate", 'Bearer realm="api"')
            self.end_headers()
            return
        self.send_response(200)
        self.send_header("X-Auth-User", claims["user"])
        self.send_header("X-Auth-Roles", claims["roles"])
        self.end_headers()

HTTPServer(("127.0.0.1", 9000), Handler).serve_forever()

검증에 성공하면 200과 함께 X-Auth-User, X-Auth-Roles를 응답 헤더에 실어 보낸다. 이 값을 백엔드까지 흘려보내는 방법이 다음 절이다.

5. auth_request_set으로 인증 결과를 업스트림에 전달한다

인증 서버가 돌려준 응답 헤더는 그 자체로는 백엔드로 넘어가지 않는다. 서브리퀘스트 응답 헤더는 $upstream_http_헤더이름 변수로 읽을 수 있고, auth_request_set으로 일반 변수에 담은 뒤 proxy_set_header로 백엔드에 다시 넣어줘야 한다.

location / {
    auth_request /_auth;

    # 서브리퀘스트 응답 헤더를 변수로 캡처
    auth_request_set $auth_user  $upstream_http_x_auth_user;
    auth_request_set $auth_roles $upstream_http_x_auth_roles;

    # 백엔드로 신뢰된 사용자 정보를 전달
    proxy_set_header X-User  $auth_user;
    proxy_set_header X-Roles $auth_roles;

    proxy_pass http://127.0.0.1:8080;
}

이렇게 하면 백엔드는 토큰을 다시 파싱할 필요 없이 X-User, X-Roles 헤더만 신뢰하면 된다. 헤더 이름의 대응 규칙은 단순하다. 응답 헤더 X-Auth-User는 변수 $upstream_http_x_auth_user가 된다. 대문자가 소문자로 바뀌고 하이픈이 밑줄로 바뀐다. 이 규칙을 헷갈려서 변수가 늘 빈 값으로 나오는 경우가 많다.

한 가지 주의할 점이 있다. 클라이언트가 X-User 헤더를 직접 보내 백엔드를 속이려 할 수 있다. 백엔드가 외부에서 직접 접근 가능한 구조라면, Nginx 입구에서 클라이언트가 보낸 X-User를 먼저 지운 뒤 인증 결과로 새로 채워 넣는 것이 안전하다.

server {
    # 클라이언트가 위조해 보낸 신뢰 헤더를 입구에서 제거
    proxy_set_header X-User  "";
    proxy_set_header X-Roles "";
    # ... location에서 auth_request_set 값으로 다시 채운다
}

6. 401과 403을 원하는 방식으로 처리한다

API라면 401, 403을 그대로 클라이언트에 돌려주는 것으로 충분하다. 브라우저로 접근하는 화면 서비스라면 401이 뜰 때 로그인 페이지로 보내고 싶을 때가 있다. 그럴 때는 error_page로 받아 처리한다.

location / {
    auth_request /_auth;
    error_page 401 = @login_redirect;
    proxy_pass http://127.0.0.1:8080;
}

location @login_redirect {
    return 302 https://auth.example.com/login?next=$request_uri;
}

앞서 말한 대로 인증 서버가 직접 302를 돌려주면 에러가 된다. 리다이렉트는 인증 서버가 아니라 이렇게 Nginx 쪽 error_page에서 만들어야 한다. 이 차이를 처음에 놓치기 쉽다.

동작을 확인해 본다. 토큰 없이 요청하면 401, 올바른 토큰이면 200이 나온다.

$ curl -s -o /dev/null -w "%{http_code}\n" http://app.example.com/
401

$ curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer good-token" http://app.example.com/
200

7. 자주 걸리는 함정

서브리퀘스트가 매 요청마다 인증 서버를 때린다

요청 하나당 서브리퀘스트 하나가 나간다. 트래픽이 많으면 인증 서버가 병목이 될 수 있다. 1.7.3부터는 서브리퀘스트 응답을 proxy_cache로 캐시할 수 있다. 토큰 문자열을 캐시 키로 삼아 짧은 시간(예: 몇 초에서 몇십 초) 캐시하면 부하를 크게 줄일 수 있다. 다만 캐시 시간만큼 토큰 폐기 반영이 늦어지므로 TTL은 짧게 잡는 편을 권한다.

인증 서버가 느리면 전체가 느려진다

서브리퀘스트는 원래 요청을 막고 진행된다. 인증 서버 응답이 느리면 그대로 사용자 응답 지연으로 이어진다. location = /_auth에 proxy_connect_timeout, proxy_read_timeout을 짧게 걸어 두고, 인증 서버는 가볍게 유지한다. JWT라면 서명 검증만으로 끝낼 수 있어 외부 조회 없이 빠르게 응답할 수 있다.

POST 본문이 사라진 것처럼 보인다

proxy_pass_request_body off는 서브리퀘스트에만 적용된다. 원래 요청의 본문은 그대로 백엔드로 간다. 혹시 본문이 비어 보인다면 서브리퀘스트 설정이 아니라 실제 location /의 proxy_pass 쪽을 확인한다.

$upstream_http_* 변수가 늘 비어 있다

대소문자와 하이픈 변환 규칙을 다시 확인한다. 인증 서버가 정말 그 헤더를 응답에 실었는지도 본다. curl -i로 인증 서버를 직접 때려 응답 헤더를 눈으로 확인하는 것이 가장 빠르다.

8. 정리

auth_request는 인증 판단을 Nginx 입구로 끌어올려 백엔드마다 중복되던 검증 코드를 한 곳으로 모아준다. 인증 서버는 상태 코드(2xx/401/403)만 정확히 돌려주면 되고, auth_request_set으로 검증 결과를 X-User 같은 헤더에 실어 백엔드에 넘긴다. 성능이 걱정되면 서브리퀘스트 캐시를, 리다이렉트가 필요하면 Nginx쪽 error_page를 쓴다. 토큰 검증 정책이 바뀌어도 인증 서버 한 곳만 고치면 된다는 점이 이 방식의 가장 큰 이점이다.

자주 묻는 질문

auth_request 모듈이 빌드에 포함됐는지 어떻게 확인하나?

nginx -V 출력에 --with-http_auth_request_module이 있는지 본다. 없으면 설정에 auth_request를 넣는 순간 'unknown directive "auth_request"' 에러로 기동이 막힌다. nginx.org 공식 저장소 패키지나 NGINX Plus에는 포함되어 있다.

인증 서버가 어떤 상태 코드를 돌려줘야 하나?

통과시킬 때는 2xx, 막을 때는 401 또는 403이다. 401이면 서브리퀘스트의 WWW-Authenticate 헤더가 클라이언트로 전달된다. 그 외 코드는 에러로 처리되어 클라이언트가 500을 받는다. 인증 실패를 200 본문에 담으면 Nginx는 통과로 본다.

인증 서버가 돌려준 사용자 정보를 백엔드로 어떻게 넘기나?

서브리퀘스트 응답 헤더는 $upstream_http_헤더이름 변수로 읽힌다. auth_request_set으로 변수에 담은 뒤 proxy_set_header로 백엔드에 다시 넣는다. 예를 들어 응답 헤더 X-Auth-User는 $upstream_http_x_auth_user로 참조한다.

인증 실패 시 로그인 페이지로 리다이렉트하려면?

인증 서버가 직접 302를 돌려주면 2xx/401/403이 아니라서 에러가 된다. 리다이렉트는 location에 error_page 401 = @login_redirect를 걸고, named location에서 return 302로 만든다.

매 요청마다 인증 서버를 호출하는 부하를 줄일 수 있나?

Nginx 1.7.3부터 서브리퀘스트 응답을 proxy_cache로 캐시할 수 있다. 토큰을 캐시 키로 삼아 짧은 TTL로 캐시하면 호출을 줄인다. 다만 캐시 시간만큼 토큰 폐기 반영이 늦어지므로 TTL은 짧게 둔다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.