실무에서 자주 쓰는 HTTP 커스텀 헤더 정리

실무에서 자주 쓰는 HTTP 커스텀 헤더 정리


서론

HTTP 헤더는 클라이언트와 서버 사이에서 메타데이터를 전달하는 핵심 수단이다. Content-Type, Authorization 같은 표준 헤더 외에도, 실무에서는 프로젝트 요구사항에 맞게 커스텀 헤더를 정의해서 사용하는 경우가 매우 많다.

이 글에서는 커스텀 헤더가 무엇인지, 어떤 관례가 있는지, 그리고 실무에서 자주 쓰이는 커스텀 헤더들을 용도별로 정리한다.


1. 커스텀 헤더란?

HTTP 명세에 정의되지 않은, 애플리케이션이 자체적으로 정의하여 사용하는 헤더를 커스텀 헤더(Custom Header) 라고 한다.

GET /api/users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGci...        ← 표준 헤더
X-Request-ID: 550e8400-e29b-41d4-a716    ← 커스텀 헤더
X-Client-Version: 2.4.1                  ← 커스텀 헤더

표준 헤더는 IANA HTTP 헤더 레지스트리에 등록된 헤더를 의미하며, 그 외에 애플리케이션이 자유롭게 추가한 헤더가 커스텀 헤더다.


2. X- 접두사의 역사

2.1 관례의 시작

과거에는 커스텀 헤더에 X- 접두사를 붙이는 것이 일반적인 관례였다.

X-Forwarded-For: 203.0.113.50
X-Request-ID: abc-123
X-Custom-Auth: my-token

“이 헤더는 비표준이다”라는 것을 명시하기 위한 네이밍 규칙이었다.

2.2 RFC 6648 — X- 접두사의 폐기 권고

2012년에 발표된 RFC 6648X- 접두사 관례를 더 이상 사용하지 말 것을 권고했다.

이유는 명확했다:

문제설명
표준화 시 이름 변경X-Forwarded-For가 표준이 되면 Forwarded로 이름이 바뀌어야 하지만, 이미 널리 퍼진 이름은 바꿀 수 없다
이중 지원 부담서버가 X-Forwarded-ForForwarded 를 모두 지원해야 하는 상황이 발생한다
혼란X-가 붙었는지 여부만으로 표준/비표준을 판단할 수 없다 (X-Forwarded-For는 사실상 표준이다)

2.3 현재 실무의 현실

RFC 6648의 권고에도 불구하고, 현실에서는 X- 접두사가 여전히 널리 사용된다.

# 사실상 표준(de facto standard) — X- 를 떼기엔 이미 너무 늦었다
X-Forwarded-For
X-Forwarded-Proto
X-Request-ID

# 최근 정의된 표준 — X- 없이 등록되었다
Forwarded          (RFC 7239, X-Forwarded-For의 공식 후속)
Origin-Agent-Cluster

실무 권장: 새로운 커스텀 헤더를 정의할 때는 X- 접두사 없이 의미 있는 이름을 사용하되, 기존에 X-로 널리 퍼진 헤더는 그대로 사용한다.


3. 실무에서 자주 쓰는 커스텀 헤더

3.1 요청 추적 (Tracing / Observability)

분산 시스템에서 하나의 요청이 여러 서비스를 거치면서 처리되는 경우, 요청을 추적하기 위한 헤더가 필수다.

헤더용도비고
X-Request-ID요청 단위 고유 식별자UUID를 주로 사용
X-Correlation-ID여러 서비스에 걸친 트랜잭션 추적서비스 간 전파
traceparentW3C Trace Context 표준OpenTelemetry에서 사용
# 클라이언트 → API Gateway → 서비스 A → 서비스 B
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Correlation-ID: txn-20260316-001

X-Request-ID vs X-Correlation-ID

  • X-Request-ID는 개별 HTTP 요청을 식별한다. 서비스 A가 서비스 B를 호출하면 새로운 X-Request-ID가 생긴다.
  • X-Correlation-ID는 사용자의 하나의 동작(예: 주문)에서 발생한 모든 요청을 묶는다. 전체 흐름에서 동일하게 유지된다.

Spring Boot에서 X-Request-ID 처리

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String requestId = request.getHeader("X-Request-ID");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }

        // MDC에 저장 → 로그에 자동 포함
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-ID", requestId);

        try {
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

logback-spring.xml에서 MDC 값을 포맷에 포함하면 로그 추적이 편해진다:

<pattern>%d{HH:mm:ss.SSS} [%X{requestId}] %-5level %logger{36} - %msg%n</pattern>

실무 사례: 장애 상황에서의 요청 추적

금요일 오후, 모니터링 대시보드에 주문 서비스의 500 에러가 급증한다. 어디서 문제가 생긴 걸까?

X-Request-ID가 없다면 수백만 줄의 로그에서 특정 요청을 찾아야 한다. 하지만 X-Request-ID가 있으면:

# 1. 에러 로그에서 Request ID 확인
[ERROR] [req-550e8400] OrderService - 주문 생성 실패: PaymentService timeout

# 2. 같은 ID로 다른 서비스 로그 검색
$ grep "550e8400" payment-service.log
[WARN] [req-550e8400] PaymentService - PG사 응답 지연: 30초 초과

# 3. 원인 파악: PG사 응답 지연 → 주문 서비스 타임아웃

Datadog, Grafana Loki 같은 로그 수집 도구에서 X-Request-ID로 검색하면 해당 요청이 거쳐간 모든 서비스의 로그를 한눈에 볼 수 있다.

실무 사례: MSA에서 X-Correlation-ID의 흐름

사용자가 “주문하기” 버튼을 클릭하면 내부적으로 여러 API가 호출된다:

사용자 → API Gateway → 주문 서비스 → 결제 서비스 → 재고 서비스 → 알림 서비스

X-Correlation-ID: order-user42-20260316  (전체 흐름에서 동일)

X-Request-ID: req-001  (Gateway → 주문)
X-Request-ID: req-002  (주문 → 결제)
X-Request-ID: req-003  (주문 → 재고)
X-Request-ID: req-004  (주문 → 알림)

나중에 “user42의 3월 16일 주문에서 뭐가 문제였지?”라고 할 때, X-Correlation-ID: order-user42-20260316으로 검색하면 관련된 모든 서비스의 로그를 한번에 조회할 수 있다.


3.2 프록시 / 로드밸런서 관련

리버스 프록시(Nginx, AWS ALB 등)가 원본 요청 정보를 백엔드에 전달하기 위해 사용하는 헤더들이다.

헤더용도예시 값
X-Forwarded-For원본 클라이언트 IP203.0.113.50, 70.41.3.18
X-Forwarded-Proto원본 프로토콜https
X-Forwarded-Host원본 Hostwww.example.com
X-Forwarded-Port원본 포트443
X-Real-IP클라이언트 실제 IP (Nginx)203.0.113.50
# 클라이언트(203.0.113.50) → ALB(10.0.0.1) → 서버
X-Forwarded-For: 203.0.113.50, 10.0.0.1
X-Forwarded-Proto: https

주의: X-Forwarded-For의 스푸핑

클라이언트가 직접 X-Forwarded-For 헤더를 조작해서 보낼 수 있다. 따라서 이 헤더 값을 신뢰하려면 신뢰할 수 있는 프록시 목록을 설정해야 한다.

Spring Boot에서 프록시 헤더 처리

# application.yml
server:
  forward-headers-strategy: native   # Tomcat이 X-Forwarded-* 처리
  tomcat:
    remoteip:
      internal-proxies: 10\\.0\\.\\d{1,3}\\.\\d{1,3}  # 신뢰할 프록시 IP

forward-headers-strategy: native로 설정하면 request.getRemoteAddr()X-Forwarded-For의 첫 번째 IP를 반환한다.

실무 사례: IP 기반 Rate Limiting에서의 함정

서비스에 IP 기반 Rate Limiting을 적용했는데, 모든 요청이 같은 IP(10.0.0.1)로 찍혀서 전체 사용자가 한꺼번에 차단당하는 사고가 발생할 수 있다. 이유는? 로드밸런서의 IP를 클라이언트 IP로 인식했기 때문이다.

# 잘못된 설정: 모든 요청의 IP가 ALB IP로 기록됨
요청 1: remoteAddr=10.0.0.1  (실제: 203.0.113.50)
요청 2: remoteAddr=10.0.0.1  (실제: 198.51.100.23)

# 올바른 설정: X-Forwarded-For의 첫 번째 IP 사용
요청 1: remoteAddr=203.0.113.50
요청 2: remoteAddr=198.51.100.23

실무 사례: HTTPS 리다이렉트 무한 루프

프록시 뒤에 있는 서버가 X-Forwarded-Proto를 확인하지 않으면, 무한 리다이렉트가 발생할 수 있다:

1. 클라이언트 → (HTTPS) → ALB → (HTTP) → 서버
2. 서버: "HTTP 요청이네? HTTPS로 리다이렉트!"
3. 클라이언트 → (HTTPS) → ALB → (HTTP) → 서버
4. 서버: "또 HTTP? 또 리다이렉트!" → 무한 반복

X-Forwarded-Proto: https를 확인하면 이 문제를 해결할 수 있다:

@Component
public class HttpsRedirectFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String proto = request.getHeader("X-Forwarded-Proto");

        // 프록시가 알려준 원본 프로토콜이 HTTPS면 리다이렉트 불필요
        if ("https".equals(proto) || request.isSecure()) {
            filterChain.doFilter(request, response);
            return;
        }

        // 실제로 HTTP로 접근한 경우만 리다이렉트
        String redirectUrl = "https://" + request.getServerName() + request.getRequestURI();
        response.sendRedirect(redirectUrl);
    }
}

실무 사례: 다중 프록시 환경에서의 X-Forwarded-For

실제 운영 환경에서는 프록시가 여러 단계일 수 있다:

클라이언트(203.0.113.50) → CDN(54.230.1.1) → ALB(10.0.0.1) → Nginx(10.0.1.1) → WAS

X-Forwarded-For: 203.0.113.50, 54.230.1.1, 10.0.0.1

이때 가장 왼쪽이 실제 클라이언트 IP다. 하지만 공격자가 처음부터 X-Forwarded-For: 1.2.3.4를 넣어 보내면:

X-Forwarded-For: 1.2.3.4, 203.0.113.50, 54.230.1.1, 10.0.0.1

가장 왼쪽 IP가 가짜가 된다. 그래서 신뢰할 수 있는 프록시 목록에서 오른쪽부터 제거하고 남은 가장 오른쪽 IP를 사용하는 것이 안전하다.


3.3 클라이언트 정보 전달

서버가 클라이언트의 버전, 플랫폼, 디바이스 정보를 파악하기 위해 사용하는 헤더들이다.

헤더용도예시 값
X-Client-Version앱 버전2.4.1
X-Platform플랫폼 종류iOS, Android, Web
X-Device-ID디바이스 고유 식별자device-abc-123
X-App-Name앱 이름MyApp-iOS
GET /api/config HTTP/1.1
X-Client-Version: 2.4.1
X-Platform: iOS
X-Device-ID: 5A3F2B1C-...

이 정보들은 서버 사이드에서 기능 분기(Feature Flag) 에 유용하다:

@GetMapping("/api/config")
public ResponseEntity<AppConfig> getConfig(
        @RequestHeader(value = "X-Client-Version", required = false) String clientVersion) {

    if (clientVersion != null && isVersionBelow(clientVersion, "2.0.0")) {
        return ResponseEntity.ok(legacyConfig());
    }
    return ResponseEntity.ok(currentConfig());
}

실무 사례: 강제 업데이트 (Force Update)

앱에 심각한 보안 취약점이 발견되었을 때, 특정 버전 이하의 앱 사용을 차단해야 한다:

@GetMapping("/api/health")
public ResponseEntity<?> healthCheck(
        @RequestHeader(value = "X-Client-Version", required = false) String version,
        @RequestHeader(value = "X-Platform", required = false) String platform) {

    if (version != null && isVersionBelow(version, "3.0.0")) {
        return ResponseEntity.status(HttpStatus.UPGRADE_REQUIRED)  // 426
            .body(Map.of(
                "message", "보안 업데이트가 필요합니다",
                "minVersion", "3.0.0",
                "storeUrl", "iOS".equals(platform)
                    ? "https://apps.apple.com/app/myapp"
                    : "https://play.google.com/store/apps/details?id=com.myapp"
            ));
    }
    return ResponseEntity.ok(Map.of("status", "ok"));
}

실무 사례: 플랫폼별 응답 분기

같은 API인데 iOS와 Android에서 다른 이미지 포맷을 내려줘야 하는 경우:

@GetMapping("/api/banners")
public ResponseEntity<List<Banner>> getBanners(
        @RequestHeader(value = "X-Platform", required = false) String platform) {

    String imageFormat = "Android".equals(platform) ? "webp" : "png";
    List<Banner> banners = bannerService.getBanners(imageFormat);
    return ResponseEntity.ok(banners);
}

실무 사례: 디바이스 ID를 이용한 비로그인 사용자 추적

로그인하지 않은 사용자도 X-Device-ID로 행동 패턴을 분석할 수 있다. 예를 들어 “장바구니에 담았지만 결제하지 않은 사용자”에게 푸시 알림을 보내는 시나리오에서 활용된다.

GET /api/recommendations HTTP/1.1
X-Device-ID: 5A3F2B1C-8D7E-4A9B-B2C1-3E5F6A7B8C9D
X-Platform: iOS
X-Client-Version: 4.2.0

주의: X-Device-ID는 개인정보 보호법에 따라 주의해서 다뤄야 한다. GDPR/개인정보보호법 대상이라면 사용자 동의 없이 추적 목적으로 사용하면 안 된다.


3.4 인증 / 보안 관련

헤더용도비고
X-API-KeyAPI 키 인증Authorization 대신 사용하는 경우
X-CSRF-TokenCSRF 토큰 전달폼 기반 인증에서 사용
X-Forwarded-User프록시가 인증된 사용자 정보 전달리버스 프록시 인증
Idempotency-Key멱등성 보장 키결제 API에서 중복 요청 방지

X-API-Key 실무 사례

외부에 공개하는 Open API에서 X-API-Key는 가장 흔한 인증 방식이다. 카카오, 네이버, 공공데이터포털 등 대부분의 한국 API가 이 방식을 사용한다.

# 공공데이터포털 API 호출 예시
GET /api/15012690/openapi/service/cctv HTTP/1.1
Host: api.odcloud.kr
X-API-Key: your-api-key-here
// Spring Boot에서 X-API-Key 검증 필터
@Component
public class ApiKeyFilter extends OncePerRequestFilter {

    @Value("${api.valid-keys}")
    private List<String> validKeys;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String apiKey = request.getHeader("X-API-Key");

        if (apiKey == null || !validKeys.contains(apiKey)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Invalid API Key\"}");
            return;
        }

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 공개 엔드포인트는 API Key 검증 제외
        return request.getRequestURI().startsWith("/api/public/");
    }
}

Authorization vs X-API-Key: OAuth2 토큰은 Authorization: Bearer ...에, 단순 API 키는 X-API-Key에 넣는 것이 일반적인 관례다. API 키와 Bearer 토큰을 동시에 사용하는 서비스도 있다 (예: API 키로 앱 식별 + Bearer 토큰으로 사용자 인증).

X-CSRF-Token 실무 사례

SPA(Single Page Application)에서 CSRF 공격을 방어하는 가장 흔한 패턴이다:

# 1. 페이지 로드 시 서버가 CSRF 토큰을 쿠키로 전달
Set-Cookie: XSRF-TOKEN=abc123; Path=/; SameSite=Lax

# 2. 클라이언트가 상태 변경 요청 시 헤더에 토큰 포함
POST /api/transfer HTTP/1.1
X-CSRF-Token: abc123
Cookie: XSRF-TOKEN=abc123; SESSION=xyz789

서버는 쿠키의 토큰과 헤더의 토큰이 일치하는지 확인한다 (Double Submit Cookie 패턴). 공격자는 쿠키는 자동 전송되지만 헤더에 토큰을 넣을 수는 없으므로 CSRF 공격이 차단된다.

// Spring Security 기본 설정으로 CSRF 토큰이 자동 관리된다
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            // withHttpOnlyFalse(): JS에서 쿠키를 읽어 헤더에 넣을 수 있도록
        );
        return http.build();
    }
}

Idempotency-Key 활용 예시

결제나 송금 같은 중요한 작업에서 네트워크 재시도로 인한 중복 처리를 방지한다.

POST /api/payments HTTP/1.1
Idempotency-Key: pay-20260316-user42-ord789
Content-Type: application/json

{"amount": 50000, "currency": "KRW"}
@PostMapping("/api/payments")
public ResponseEntity<PaymentResult> createPayment(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody PaymentRequest request) {

    // 이미 처리된 키인지 확인
    Optional<PaymentResult> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
        return ResponseEntity.ok(existing.get());  // 기존 결과 반환
    }

    PaymentResult result = paymentService.process(request, idempotencyKey);
    return ResponseEntity.status(HttpStatus.CREATED).body(result);
}

실무에서 Idempotency-Key가 필수인 이유

결제 API를 호출했는데 응답을 못 받은 상황을 생각해보자. 클라이언트는 결제가 됐는지 안 됐는지 알 수 없다. 재시도하면 이중 결제가 될 수 있고, 안 하면 결제가 누락될 수 있다. Idempotency-Key는 이 문제를 해결한다 — 같은 키로 여러 번 요청해도 한 번만 처리된다.

실제 서비스들의 Idempotency-Key 사용

Stripe — 가장 대표적인 사례다. 모든 POST 요청에 Idempotency-Key 헤더를 지원한다.

curl https://api.stripe.com/v1/charges \
  -H "Idempotency-Key: order-12345-attempt-1" \
  -d amount=5000 \
  -d currency=usd

Stripe는 키를 24시간 동안 보관하며, 같은 키로 재요청하면 원래 응답을 그대로 돌려준다. 단, 같은 키에 다른 파라미터로 요청하면 400 Bad Request를 반환한다.

토스페이먼츠 — 결제 승인 API에서 orderId가 멱등성 키 역할을 한다.

curl https://api.tosspayments.com/v1/payments/confirm \
  -H "Idempotency-Key: order-20260316-abc789"

PayPal — 주문 생성, 결제 캡처 등에 PayPal-Request-Id 헤더로 멱등성을 보장한다.

curl https://api.paypal.com/v2/checkout/orders \
  -H "PayPal-Request-Id: order-unique-id-123"

결제 외의 실무 사례

Idempotency-Key는 결제에만 쓰는 게 아니다. 상태를 변경하는 중요한 POST 요청에는 모두 적용할 수 있다.

시나리오중복 시 문제키 생성 전략
결제/송금이중 결제, 이중 출금pay-{orderId}-{timestamp}
주문 생성같은 주문이 2개 생성order-{userId}-{cartHash}
메시지 발송 (SMS/이메일)같은 메시지 2번 발송msg-{templateId}-{recipientId}-{date}
포인트/쿠폰 지급포인트 이중 지급reward-{eventId}-{userId}
외부 API 연동 (웹훅 등)같은 이벤트 2번 처리웹훅 제공자가 주는 event_id 사용

Idempotency-Key 구현 시 주의사항

1. 키 생성은 클라이언트 책임이다

서버가 키를 생성하면 의미가 없다. 클라이언트가 요청 전에 키를 만들어야 재시도 시 같은 키를 보낼 수 있다.

// 프론트엔드 — 주문 버튼 클릭 시 키를 미리 생성
const idempotencyKey = `order-${orderId}-${Date.now()}`;

async function placeOrder() {
  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: {
      'Idempotency-Key': idempotencyKey,  // 재시도해도 같은 키
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(orderData),
  });
}

2. 키에는 TTL을 설정해야 한다

영구 보관하면 저장 공간이 무한히 늘어난다. 일반적으로 24~48시간이 적당하다. Stripe는 24시간, 일부 금융 API는 72시간을 사용한다.

// Redis로 TTL 관리 — 24시간 후 자동 만료
redisTemplate.opsForValue().set(
    "idempotency:" + key,
    result,
    Duration.ofHours(24)
);

3. 동시 요청(Race Condition)을 처리해야 한다

같은 키로 동시에 2개의 요청이 들어오면? 하나만 처리하고 나머지는 대기시키거나 409 Conflict를 반환해야 한다.

// Redis SETNX로 분산 락 구현
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("lock:idempotency:" + key, "1", Duration.ofSeconds(30));

if (Boolean.FALSE.equals(acquired)) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body("Request is already being processed");
}

4. 응답을 함께 저장해야 한다

키만 저장하면 안 된다. 응답 전체(상태 코드 + 바디)를 저장해야 재시도 시 동일한 응답을 돌려줄 수 있다.

@Data
@AllArgsConstructor
public class IdempotencyRecord {
    private int statusCode;
    private String responseBody;
    private LocalDateTime createdAt;
}

3.5 Rate Limiting 관련

API Rate Limiting 상태를 클라이언트에 알려주기 위한 응답 헤더들이다.

헤더용도예시 값
X-RateLimit-Limit허용된 최대 요청 수1000
X-RateLimit-Remaining남은 요청 수742
X-RateLimit-Reset제한 초기화 시각 (Unix timestamp)1742108400
Retry-After재시도 가능 시각 (표준 헤더)60 (초)
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 742
X-RateLimit-Reset: 1742108400

Rate Limit을 초과하면 429 Too Many Requests와 함께 Retry-After 헤더를 반환하는 것이 일반적이다:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Remaining: 0

참고: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset 형태(X- 없이)로 표준화 논의가 진행 중이다 (draft-ietf-httpapi-ratelimit-headers).

실무 사례: GitHub API의 Rate Limiting

GitHub API는 Rate Limit 헤더의 교과서적인 사례다:

$ curl -I https://api.github.com/users/octocat

HTTP/2 200
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 56
X-RateLimit-Reset: 1742108400
X-RateLimit-Used: 4
X-RateLimit-Resource: core

인증하지 않으면 시간당 60회, 토큰을 사용하면 시간당 5,000회까지 호출할 수 있다. X-RateLimit-Resource로 어떤 종류의 제한에 해당하는지도 알려준다 (core, search, graphql 등).

실무 사례: 클라이언트에서 Rate Limit 헤더 활용

서버가 Rate Limit 헤더를 내려주면, 클라이언트는 이를 활용해 지능적으로 요청을 조절할 수 있다:

async function fetchWithRateLimit(url) {
  const response = await fetch(url);

  const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
  const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));

  if (remaining === 0) {
    const waitMs = (resetTime * 1000) - Date.now();
    console.log(`Rate limit 도달. ${Math.ceil(waitMs / 1000)}초 후 재시도`);
    await new Promise(resolve => setTimeout(resolve, waitMs));
    return fetchWithRateLimit(url);  // 재시도
  }

  if (remaining < 10) {
    console.warn(`Rate limit 주의: ${remaining}회 남음`);
  }

  return response;
}

Spring Boot에서 Rate Limiting 구현 (Bucket4j)

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    private Bucket createBucket() {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1))))
            .build();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        String clientIp = request.getRemoteAddr();
        Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createBucket());

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        response.setHeader("X-RateLimit-Limit", "100");
        response.setHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));

        if (probe.isConsumed()) {
            filterChain.doFilter(request, response);
        } else {
            long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.setHeader("Retry-After", String.valueOf(waitSeconds));
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        }
    }
}

3.6 캐시 / 성능 관련

헤더용도비고
X-Cache캐시 히트 여부CDN이 응답에 추가
X-Cache-TTL캐시 TTL디버깅 용도
X-Response-Time서버 응답 시간모니터링 용도
HTTP/1.1 200 OK
X-Cache: HIT from edge-server-tokyo
X-Response-Time: 45ms

실무 사례: CDN 캐시 디버깅

“이미지를 업데이트했는데 안 바뀌어요” — CDN을 사용하면 자주 듣는 문의다. X-Cache 헤더로 즉시 원인을 파악할 수 있다:

$ curl -I https://cdn.example.com/images/logo.png

X-Cache: HIT        # CDN 캐시에서 응답 → 아직 이전 이미지
X-Cache-TTL: 3600   # 1시간 후 만료 예정
Age: 2400           # 캐시된 지 40분 경과

CloudFront에서는 더 상세한 정보를 제공한다:

X-Cache: Hit from cloudfront
X-Amz-Cf-Pop: ICN54-C1          # 서울 엣지 서버에서 응답
X-Amz-Cf-Id: abc123...          # 디버깅용 요청 ID

X-Cache 값의 의미:

의미
HIT캐시에서 응답 (원본 서버 호출 없음)
MISS캐시에 없어서 원본 서버에서 가져옴
REFRESH HIT캐시 만료 후 원본에서 재검증, 콘텐츠 동일
ERROR원본 서버 에러, 이전 캐시로 응답

실무 사례: X-Response-Time으로 SLO 모니터링

서비스의 SLO(Service Level Objective)가 “API 응답 시간 99%ile < 200ms”라면, X-Response-Time을 모니터링에 활용할 수 있다:

@Component
public class ResponseTimeFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        long start = System.nanoTime();

        filterChain.doFilter(request, response);

        long duration = (System.nanoTime() - start) / 1_000_000;  // ms
        response.setHeader("X-Response-Time", duration + "ms");

        // Prometheus 메트릭으로도 기록
        if (duration > 200) {
            log.warn("Slow API: {} {}ms", request.getRequestURI(), duration);
        }
    }
}

이 헤더가 있으면 프론트엔드 개발자도 네트워크 탭에서 서버 처리 시간을 바로 확인할 수 있어, “느린 건 서버야 네트워크야?”를 빠르게 판단할 수 있다.


4. 커스텀 헤더와 CORS

커스텀 헤더는 CORS(Cross-Origin Resource Sharing) 와 밀접한 관계가 있다. 이 부분을 모르면 프론트엔드에서 커스텀 헤더를 보내거나 읽을 때 에러가 발생한다.

4.1 Preflight 트리거

브라우저는 Simple Request 조건을 벗어나는 요청에 대해 Preflight(OPTIONS) 요청을 먼저 보낸다. 커스텀 헤더를 포함하면 Simple Request 조건을 벗어나므로 Preflight가 발생한다.

# Simple Request에서 허용되는 헤더 (커스텀 헤더는 포함되지 않음)
Accept, Accept-Language, Content-Language, Content-Type(일부)

따라서 X-Request-ID를 프론트엔드에서 보내려면:

// 프론트엔드
fetch('https://api.example.com/users', {
  headers: {
    'X-Request-ID': crypto.randomUUID(),  // ← Preflight 발생
  }
});

서버에서 해당 헤더를 허용해야 한다:

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://frontend.example.com")
                .allowedHeaders("X-Request-ID", "X-Client-Version")  // 커스텀 헤더 허용
                .exposedHeaders("X-Request-ID", "X-RateLimit-Remaining");  // 프론트에서 읽기 허용
    }
}

4.2 Access-Control-Expose-Headers

기본적으로 프론트엔드 JavaScript는 응답의 CORS-safelisted 헤더만 읽을 수 있다:

Cache-Control, Content-Language, Content-Length,
Content-Type, Expires, Last-Modified, Pragma

서버가 X-RateLimit-Remaining 같은 커스텀 응답 헤더를 프론트엔드에서 읽게 하려면, Access-Control-Expose-Headers에 명시해야 한다:

Access-Control-Expose-Headers: X-RateLimit-Remaining, X-Request-ID

이 설정이 없으면 response.headers.get('X-RateLimit-Remaining')null을 반환한다.

CORS 설정 전체를 다루는 글: CORS를 이해하기: 브라우저 보안 정책부터 Spring Boot 설정까지


5. 커스텀 헤더 네이밍 가이드

5.1 좋은 이름을 짓는 원칙

원칙좋은 예나쁜 예
용도가 명확해야 한다X-Request-IDX-ID
일관된 네이밍 컨벤션X-Client-Versionx_client_ver
충돌을 피한다MyApp-Trace-IDTrace-ID (다른 시스템과 충돌 가능)
대소문자 무관하게 동작한다HTTP 헤더는 case-insensitive

5.2 조직 전용 접두사 사용

X- 대신 조직/프로젝트 접두사를 사용하면 충돌을 방지할 수 있다:

MyApp-Request-ID: abc-123
MyApp-Client-Version: 2.4.1
MyApp-Tenant-ID: tenant-42

GitHub, AWS 등도 이 패턴을 사용한다:

X-GitHub-Request-Id: ABCD:1234:5678
X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793

6. 주의사항

6.1 헤더에 민감한 정보를 담지 말 것

HTTP 헤더는 로그에 기록되기 쉽고, 프록시를 거치면서 노출될 수 있다. 비밀번호, 주민번호, 카드번호 같은 민감한 정보는 절대 커스텀 헤더에 담지 않는다.

# 절대 하지 말 것
X-User-Password: mySecret123
X-SSN: 900101-1234567

6.2 헤더 크기 제한

대부분의 웹 서버와 프록시는 헤더 크기에 제한이 있다:

서버/프록시기본 헤더 크기 제한
Nginx4KB (단일 헤더), 8KB (전체)
Apache8KB (전체)
Tomcat8KB (전체)
AWS ALB16KB (전체)

큰 데이터(JSON 객체 등)를 헤더에 담으려 하면 431 Request Header Fields Too Large 에러가 발생할 수 있다. 큰 데이터는 요청 본문(body)에 담는다.

6.3 표준 헤더가 있다면 그것을 사용할 것

커스텀 헤더를 만들기 전에, 동일한 용도의 표준 헤더가 이미 있는지 확인한다.

용도표준 헤더 사용커스텀 헤더 (불필요)
인증 토큰Authorization: Bearer ...X-Auth-Token
콘텐츠 협상Accept: application/jsonX-Response-Format
캐시 제어Cache-Control: no-cacheX-No-Cache: true
원본 프로토콜Forwarded: proto=httpsX-Forwarded-Proto (레거시)

정리

분류대표 헤더핵심 용도
요청 추적X-Request-ID, X-Correlation-ID분산 시스템 로그 추적
프록시X-Forwarded-For, X-Forwarded-Proto원본 클라이언트 정보 전달
클라이언트 정보X-Client-Version, X-Platform기능 분기, 통계
인증/보안X-API-Key, Idempotency-KeyAPI 인증, 멱등성 보장
Rate LimitingX-RateLimit-Limit, X-RateLimit-Remaining사용량 제어
캐시/성능X-Cache, X-Response-TimeCDN 디버깅, 모니터링

커스텀 헤더는 단순해 보이지만, CORS 설정(allowedHeaders, exposedHeaders)과 함께 관리해야 프론트엔드-백엔드 연동에서 문제가 생기지 않는다. 새로운 커스텀 헤더를 정의할 때는 표준 헤더 확인 → 의미 있는 이름 → CORS 설정 반영 순서로 진행하자.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.