본문 바로가기

프로젝트/Techfork

[26/02/18] 오늘의 개발 일지 - Cloudflare 환경에서 Nginx 프록시 헤더 잘 사용하기

Cloudflare 환경에서 Nginx 프록시 헤더가 하는 일

개요

소셜 로그인의 리다이렉트 URL이 https가 아닌 http로 나가는 문제가 있었습니다. OAuth2 provider에 등록된 redirect URI와 불일치해서 로그인 자체가 실패하는 상황이었는데, 원인을 추적해보니 Cloudflare + Nginx 환경에서 프록시 헤더가 제대로 전달되지 않는 문제였습니다.

 

여기서는 TLS 종료 지점에 따른 Nginx 프록시 헤더에 대해 자세하게 다뤄보겠습니다. Nginx 설정에 있는 proxy_set_header 4줄이 각각 왜 필요한지, Cloudflare가 아니라 Nginx에서 직접 TLS를 처리하면 어떻게 달라지는지 비교합니다.

 


Nginx 설정 전체

먼저 TechFork의 실제 default.conf를 통째로 보겠습니다.

# Cloudflare Real IP
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
real_ip_header CF-Connecting-IP;

server {
    listen 80;
    server_name api.techfork.shop techfork.shop;

    client_max_body_size 10M;

    access_log /var/log/nginx/tech-fork-access.log;
    error_log /var/log/nginx/tech-fork-error.log;

    location / {
        proxy_pass http://springapp;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    location /nginx-health {
        access_log off;
        return 200 "ok";
        add_header Content-Type text/plain;
    }
}

server {
    listen 80;
    server_name dev.techfork.shop;

    client_max_body_size 10M;

    access_log /var/log/nginx/tech-fork-dev-access.log;
    error_log /var/log/nginx/tech-fork-dev-error.log;

    location / {
        resolver 127.0.0.11 valid=30s;
        set $dev_upstream http://techfork-app-dev:8080;
        proxy_pass $dev_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    location /nginx-health {
        access_log off;
        return 200 "ok";
        add_header Content-Type text/plain;
    }
}

이 설정에서 집중할 부분은 딱 네 줄입니다.

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

그리고 이 네 줄을 이해하려면 그 위에 있는 set_real_ip_from + real_ip_header 블록을 먼저 알아야 합니다. 하나씩 파고 들어가겠습니다.

 


요청이 Spring Boot에 도착하기까지

헤더를 분석하기 전에, 요청이 어떤 경로를 거치는지 먼저 그려보겠습니다.

사용자 (203.0.113.50)
  │
  │ HTTPS 요청
  ▼
Cloudflare Edge (172.64.100.1)
  │ - TLS 종료 (HTTPS → 평문으로 변환)
  │ - CF-Connecting-IP: 203.0.113.50 헤더 추가
  │ - X-Forwarded-For: 203.0.113.50 헤더 추가
  │ - X-Forwarded-Proto: https 헤더 추가
  │
  │ HTTP 요청
  ▼
Nginx (:80)
  │ - set_real_ip_from으로 $remote_addr 복원
  │ - proxy_set_header로 헤더 설정
  │
  │ HTTP 요청
  ▼
Spring Boot (:8080)

Cloudflare가 TLS를 종료하기 때문에, Nginx는 80번 포트(HTTP)만 리스닝합니다. 인증서 관리가 필요 없는 대신, 원본 요청의 정보(클라이언트 IP, 프로토콜)가 Cloudflare가 붙여주는 헤더에 담겨옵니다. Nginx는 이 헤더들을 적절히 가공해서 Spring Boot에 전달하는 역할입니다.

 


set_real_ip_from + real_ip_header: $remote_addr 복원

프록시 헤더를 이해하려면 이 블록부터 짚어야 합니다.

set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... (Cloudflare 전체 IP 대역)
real_ip_header CF-Connecting-IP;

Nginx에게 TCP 커넥션을 맺는 상대방은 사용자가 아니라 Cloudflare 서버입니다. 그래서 $remote_addr에는 기본적으로 Cloudflare의 IP가 들어갑니다.

$remote_addr = 172.64.100.1  ← Cloudflare Edge IP (클라이언트 IP가 아님!)

ngx_http_realip_module이 이 문제를 해결합니다. 동작 순서는 이렇습니다.

1. 요청 도착: $remote_addr = 172.64.100.1
2. set_real_ip_from 확인: 172.64.100.1이 Cloudflare 대역(172.64.0.0/13)에 포함? → YES
3. real_ip_header 확인: CF-Connecting-IP 헤더에서 203.0.113.50 추출
4. $remote_addr = 203.0.113.50 으로 덮어쓰기
5. 원래 값은 $realip_remote_addr = 172.64.100.1 에 보존

이후로 $remote_addr를 참조하는 모든 곳(로그, 프록시 헤더)에서 실제 클라이언트 IP가 사용됩니다. 이 전처리가 끝난 상태에서 4개의 proxy_set_header가 동작합니다.

 


헤더 4줄 상세 분석

1. Host

proxy_set_header Host $host;

$host는 요청의 Host 헤더 값입니다. 사용자가 https://api.techfork.shop/posts를 호출하면 api.techfork.shop이 들어옵니다.

 

이걸 왜 명시적으로 설정하느냐면, Nginx는 기본적으로 proxy_pass의 upstream 이름(springapp)을 Host 헤더로 보내기 때문입니다. Spring Boot가 request.getServerName()을 호출했을 때 api.techfork.shop이 아니라 springapp이 나오면, OAuth2 redirect URI 생성이나 CORS 처리에서 문제가 생깁니다.

2. X-Real-IP

proxy_set_header X-Real-IP $remote_addr;

set_real_ip_from이 이미 $remote_addr을 실제 클라이언트 IP로 바꿔놨기 때문에, 여기서 전달되는 값은 203.0.113.50(실제 클라이언트)입니다.

 

Spring Boot에서 IP 기반 로직(rate limiting, 로그 등)을 쓸 때 가장 신뢰할 수 있는 헤더입니다. Nginx가 직접 설정한 값이라 클라이언트가 조작할 수 없기 때문입니다.

3. X-Forwarded-For

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for는 기존 X-Forwarded-For 헤더에 현재 $remote_addr을 이어붙입니다. Cloudflare가 이미 X-Forwarded-For: 203.0.113.50을 보내줬고, $remote_addr도 203.0.113.50으로 복원된 상태이므로 결과가 이렇게 됩니다.

 

X-Forwarded-For: 203.0.113.50, 203.0.113.50
                 ↑ Cloudflare가 넣은 값    ↑ Nginx가 $remote_addr 추가

클라이언트 IP가 두 번 찍히는 건 set_real_ip_from과 $proxy_add_x_forwarded_for가 조합될 때 생기는 부작용입니다. set_real_ip_from이 $remote_addr을 Cloudflare IP(172.64.100.1)에서 클라이언트 IP(203.0.113.50)로 바꿨는데, $proxy_add_x_forwarded_for는 Cloudflare가 이미 넣어둔 같은 IP를 모르고 또 추가하는 것입니다.

 

사실 현재 TechFork 구조에서는 X-Forwarded-For 없이 X-Real-IP만으로 충분합니다. X-Forwarded-For가 진짜 필요한 건 사용자 → CDN → L7 로드밸런서 → Nginx → App처럼 프록시가 여러 대 체인으로 걸려있어서 요청 경로 전체를 추적해야 할 때입니다. TechFork는 Cloudflare → Nginx → Spring Boot로 홉이 하나뿐이고, set_real_ip_from으로 이미 클라이언트 IP를 복원한 상태이므로 X-Real-IP 하나면 클라이언트 IP를 확실히 알 수 있습니다.

 

그럼에도 남겨두는 이유는 관례에 가깝습니다. 나중에 앞단에 로드밸런서가 추가되거나 아키텍처가 바뀔 때를 대비하는 것이고, 헤더 하나 더 붙는 게 성능에 영향을 주지 않으니 빼야 할 이유도 없습니다. 다만 "필수냐"고 물으면 현재 구조에서는 아닙니다.

4. X-Forwarded-Proto

proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

이 줄이 네 개 중 가장 중요합니다. $http_x_forwarded_proto는 요청에 들어있는 X-Forwarded-Proto 헤더 값을 그대로 읽습니다. Cloudflare가 https를 넣어줬으므로, Spring Boot에 X-Forwarded-Proto: https가 전달됩니다.

 

Spring Boot는 이 헤더를 보고 "원래 HTTPS로 온 요청"이라고 판단합니다. 이게 없거나 http로 들어오면 어떻게 되는지 구체적으로 보겠습니다.

# X-Forwarded-Proto가 https일 때
OAuth2 redirect URI → https://api.techfork.shop/login/oauth2/code/kakao ✅

# X-Forwarded-Proto가 http이거나 빈 값일 때
OAuth2 redirect URI → http://api.techfork.shop/login/oauth2/code/kakao ❌
→ Kakao OAuth에 등록된 redirect URI와 불일치 → 로그인 실패

TechFork는 카카오/애플 소셜 로그인을 쓰기 때문에, 이 헤더가 잘못되면 사용자가 로그인을 아예 할 수 없습니다.

Spring Boot에서 이 헤더를 인식하려면 설정도 필요합니다.

# application.yml
server:
  forward-headers-strategy: framework

framework로 설정하면 Spring의 ForwardedHeaderFilter가 X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Host 등을 해석해서 HttpServletRequest의 getScheme(), getRemoteAddr() 등의 반환값을 자동으로 보정합니다.


만약 Nginx에서 직접 TLS를 처리한다면?

Cloudflare 없이 Nginx가 직접 인증서를 들고 HTTPS를 처리하는 구조로 바꾼다고 가정해 보겠습니다. 동일한 4줄이 어떻게 달라져야 하는지 비교합니다.

구조 비교

[현재 — Cloudflare TLS 종료]
사용자 ──HTTPS──▶ Cloudflare ──HTTP──▶ Nginx(:80) ──HTTP──▶ Spring Boot

[비교 — Nginx 직접 TLS 종료]
사용자 ──HTTPS──▶ Nginx(:443) ──HTTP──▶ Spring Boot

달라지는 설정

# ─── 현재: Cloudflare TLS 종료 ───
listen 80;

set_real_ip_from 173.245.48.0/20;
# ... (Cloudflare 대역 전체)
real_ip_header CF-Connecting-IP;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;   ← Cloudflare가 넣어준 값


# ─── 비교: Nginx 직접 TLS 종료 ───
listen 443 ssl http2;
ssl_certificate     /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;

# set_real_ip_from 블록 전체 불필요 (사용자가 직접 연결)

proxy_set_header Host $host;                                   ← 동일
proxy_set_header X-Real-IP $remote_addr;                       ← 동일 (원래부터 클라이언트 IP)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;   ← 동일 (중복 문제 없음)
proxy_set_header X-Forwarded-Proto $scheme;                    ← 핵심 차이!

차이가 나는 건 두 군데입니다.

 

첫 번째: set_real_ip_from 블록 전체가 사라집니다. Cloudflare가 없으니 사용자가 Nginx에 직접 연결합니다. $remote_addr이 처음부터 실제 클라이언트 IP이므로 복원할 필요가 없습니다. X-Forwarded-For 중복 문제도 자연스럽게 해결됩니다.

 

두 번째: $http_x_forwarded_proto가 $scheme으로 바뀝니다. 이게 핵심입니다. Cloudflare가 없으면 요청에 X-Forwarded-Proto 헤더 자체가 존재하지 않습니다. $http_x_forwarded_proto는 빈 문자열이 되고, Spring Boot는 프로토콜을 판단할 수 없습니다. $scheme은 Nginx 자신이 받은 요청의 프로토콜을 반환하므로, 443에서 받았으면 https, 80에서 받았으면 http가 됩니다.

 

헤더 값 비교표

사용자 IP 203.0.113.50, Cloudflare Edge IP 172.64.100.1 기준입니다.

헤더  Cloudflare TLS 종료  Nginx 직접 TLS 종료
Host api.techfork.shop api.techfork.shop
X-Real-IP 203.0.113.50 (realip 모듈 복원) 203.0.113.50 (원본 그대로)
X-Forwarded-For 203.0.113.50, 203.0.113.50 (중복) 203.0.113.50 (단일)
X-Forwarded-Proto https ($http_x_forwarded_proto) https ($scheme)

 

Nginx 직접 TLS일 때 전체 설정 예시

참고로 Nginx에서 직접 TLS를 처리할 때의 전체 설정은 이런 모습이 됩니다.

server {
    listen 443 ssl http2;
    server_name api.techfork.shop;

    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    client_max_body_size 10M;

    location / {
        proxy_pass http://springapp;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name api.techfork.shop;
    return 301 https://$host$request_uri;   # HTTP → HTTPS 강제 리다이렉트
}

Cloudflare 구조 대비 set_real_ip_from 15줄이 사라지고 SSL 설정 4줄이 추가되는 구조입니다. 대신 Let's Encrypt 등으로 인증서를 직접 발급하고 갱신해야 하는 운영 부담이 생깁니다.

 


Spring Boot에서 클라이언트 IP 가져오기

어떤 구조를 쓰든, 프록시 뒤에서 클라이언트 IP를 꺼내는 코드는 여러 헤더를 순서대로 확인해야 합니다.

public String getClientIp(HttpServletRequest request) {
    // 1순위: Cloudflare 전용 헤더 (Cloudflare 구조에서만 존재)
    String ip = request.getHeader("CF-Connecting-IP");

    // 2순위: Nginx가 설정한 X-Real-IP
    if (ip == null || ip.isEmpty()) {
        ip = request.getHeader("X-Real-IP");
    }

    // 3순위: X-Forwarded-For의 첫 번째 IP
    if (ip == null || ip.isEmpty()) {
        String xff = request.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isEmpty()) {
            ip = xff.split(",")[0].trim();
        }
    }

    // 최종 fallback
    if (ip == null || ip.isEmpty()) {
        ip = request.getRemoteAddr();
    }

    return ip;
}

X-Forwarded-For에서 첫 번째 IP만 꺼내는 이유는 이 헤더가 클라이언트, 프록시1, 프록시2 형태의 체인이기 때문입니다. 다만 클라이언트가 이 헤더를 임의로 조작할 수 있다는 점에 주의해야 합니다. rate limiting이나 보안 로직에서는 X-Real-IP나 CF-Connecting-IP처럼 신뢰할 수 있는 프록시가 설정한 헤더를 우선 사용하는 것이 안전합니다.


정리

TechFork Nginx 설정의 프록시 헤더 4줄을 다시 보면, 각각의 역할이 명확합니다.

proxy_set_header Host $host;
# → Spring Boot가 원래 도메인을 알 수 있도록

proxy_set_header X-Real-IP $remote_addr;
# → realip 모듈이 복원한 실제 클라이언트 IP 전달

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# → 요청 경로의 IP 체인 기록 (Cloudflare 구조에서 중복 발생하지만 무해)

proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# → 원래 HTTPS였다는 사실 전달 (OAuth2 redirect URI 생성에 필수)

Cloudflare가 TLS를 종료하는 구조에서 가장 주의할 점은 X-Forwarded-Proto입니다. Nginx 직접 TLS에서는 $scheme을 쓰면 되지만, Cloudflare 구조에서는 $http_x_forwarded_proto로 Cloudflare가 넣어준 값을 받아서 써야 합니다. 이걸 혼동하면 OAuth2 로그인이 깨집니다.