해당 문서는 AWS EKS 인프라 환경에서 구축된 NestJS 백엔드 서버에서, Client의 IP 주소를 제대로 못 가져오는 이슈를 핸들링 했던 경험을 적은 글입니다.
@Ip 데코레이터
서비스의 비즈니스 기능을 작업하다 보면 Client 의 IP 주소를 알아야 하는 경우가 있다.
이를 위해 NestJS
에서는 @Ip
데코레이터를 지원하고 있다.
예를 들어, 다음과 같이 사용할 수 있다.
import { Controller, Get, Ip } from '@nestjs/common';
@Controller()
export class AppController {
@Get()
getIp(@Ip() ip: string): string {
return `Hello, your IP is ${ip}`;
}
}
위 @Ip() ip
에 Client 의 IP 주소가 담겨 있다.
Client의 요청이 NestJS
서버에 도달하면 , NestJS
는 내부적으로 Express 요청 객체(req)를 생성한다. (기본 설정으론 Express 이며, 빠른 성능을 위해 Fastity 를 사용할 수도 있음) 이 객체에는 요청과 관련된 여러 정보들이 담겨 있으며, 이 중 Client IP 주소도 포함되어 있다.
@Ip 데코레이터
는 이 요청 객체에 있는 req.ip
주소를 참조한다.
이는 Nest 서버에서 Client의 요청을 직접 받는 경우라면 잘 동작 하겠지만,
일반적인 웹서비스의 네트워크 구성도에서는 네트워크 경로상에 로드밸런서
나 Proxy
가 있으므로, 서버는 로드밸런서
나 Proxy
의 내부 IP 를 받게 된다.
백엔드 서버에서 원본 IP 를 제대로 받지 못하는 이슈가 발생하게 된다.
IP 마스커레이딩 (IP Masquerading)
이전에 백엔드 서버를 구축하면서 위와 같은 이슈를 만나게 되었을 때, 정보를 찾는 과정에서 IP 마스커레이딩
이라는 키워드를 접한 적이 있었고, IP 마스커레이딩
이 백엔드 서버에서 원본 IP 주소를 제대로 받지 못하는 원인이 아닐까 싶었었다.
내가 구축한 백엔드 인프라는 EKS 환경에 ALB(Ingress controller)와 Service 를 거쳐 Node의 Pod 에 트래픽이 보내지는 네트워크 경로를 갖고 있었다.
당시, IP 마스커레이딩
을 잘못 이해하고 있어, 이 때 IP 마스커레이딩
으로 인해, 원본 IP가 숨겨 진다고 잘못 생각 하였었다.
IP 마스커레이딩
은 NAT(Network Address Translation) 의 한 종류이다. 이 기술은 사설 IP 주소를 인터넷에 연결에 사용 되는 공인 IP 주소로 변환할 때 사용되는 기술이다.
예를 들어, 우리가 인터넷을 사용할 때, 공유기로 여러대의 호스트를 내부 네트워크로 구축하고, 하나의 인터넷 회선을 개통하여 공유기를 통해 인터넷에 접속한다. 그리고 바로 이 인터넷 공유기에 NAT 기술이 탑재되어 있다.
마스커레이딩은 가면이라는 의미로, 예를 들어 내부 네트워크의 192.168.0.1
과 192.168.0.2
이 외부 인터넷망 어딘가에 있는 233.xxx.xxx.x
이라는 곳에 요청을 보낸다고 할 때,
요청이 인터넷 공유기를 거쳐가면서, 공유기의 NAT 기술로, 요청 패킷의 IP가 192.168.0.1
가 아닌, 공유기의 이더넷 공인 IP 로 변환 되게 된다.
즉, 공인 IP 하위 네트워크상에 있는 모든 요청들은 공인 IP 211.xxx.xx.x
로 변환되어 모두 공인 IP 에서 보낸 것처럼 숨겨지게 된다.
그림으로 표현하면 다음과 같다.
flowchart LR
subgraph WEB[인터넷 망]
A[233.xxx.xxx.x]
E[xxx.xxx.xxx....]
end
subgraph GROUP[사설 내부 망]
B[인터넷 공유기 211.xxx.xx.x]
C[192.168.0.1]
D[192.168.0.2]
end
B -.- C
B -.- D
A -.-|공인 IP로 처리 됨| B
NAT 는 내부 네트워크의 여러 호스트가 하나의 공인 IP 를 사용하여 인터넷에 접속할 수 있게 해주어 주로, IP 주소의 낭비를 방지하는 것과, IP 주소가 변환(숨김) 되는 특징을 이용하여 네트워크 보안에 사용된다.
앞서 나는 백엔드 인프라를 AWS EKS 에 ALB(ingress controller) 로 구축하였다고 하였다.
그리고 IP 마스커레이딩
이 로드밸랜서(ALB) 에서 동작한다고 착각하고 있었다.
X-Forwarded-For 헤더
로드밸런서
, IP 마스커레이딩(NAT)
, Proxy
모두 네트워크 기술이지만, 각각은 분명 다른 역할을 하고 있고, 로드밸런서에 IP 마스커레이딩이 포함된 건 아니었다. 서로 별개의 개념이었다.
나의 경우, 트래픽이 로드밸런서를 거치면서 이슈가 발생했던 것이었다.
EKS 클러스터 외부에서 들어오는 트래픽은 Ingress Controller인 ALB(Application Load Balancer) 를 통해 처리된다. 그리고 Ingress는 Service 와 연결 되어 있고, Service는 각 Pod 으로 트래픽을 전달한다.
백엔드 서버에서 요청의 IP 를 조회 했을 때, 내부 IP 인 192.168.xxx.x
로 찍혔었는데, 이는 서버가 로드밸런서로부터 요청을 전달 받았기 때문에, 백엔드의 네트워크 경로에선, 요청의 소스 IP가 로드밸런서의 내부 IP로 되었기 때문이다.
이는 로드밸런서 뒤에 위치한 서버의 통상적인 동작 방식이다.
로드밸런서에서는 (프록시 nginx도 마찬가지로) 원본 IP 주소를 보존하기 위해 X-Forwarded-For
헤더를 사용한다.
이 헤더는 표준 HTTP 프로토콜에 공식적으로 정의되어 있는 표준 헤더는 아니고, 특정한 목적을 위해 사용 되는 비표준 HTTP 헤더이다. (임의의 목적으로 사용되는 헤더)
AWS 로드밸런서의 속성에서도 X-Forwarded-For
헤더 옵션을 확인할 수 있다.
NestJS 백엔드 서버에서는 다음과 같이 이 X-Forwarded-For
헤더를 이용해 올바른 원본 IP 주소를 가져올 수 있다.
아래는 API 를 호출한 Client 의 국가 정보를 얻기 위한 로직에서, 원본 IP 체크를 위해 X-Forwarded-For
를 확인 했던 인터셉터 구현 코드이다.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
import { getInfoFromIp } from '../../common/util';
@Injectable()
export class CountryInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const req: Request = context.switchToHttp().getRequest();
const ip = (req.headers['x-forwarded-for'] as string) || req.ip;
const ipInfo = await getInfoFromIp(ip);
req.body.country = ipInfo?.country_code || 'en';
req.body.ipInfo = ipInfo;
return next.handle().pipe(map((data) => data));
}
}
요청 객체의 헤더 리스트에서 x-forwarded-for
헤더를 찾아 원본 IP 를 저장 한 후, IP 를 기반으로 접속 국가 조회를 하고 있다.
그리고 이렇게 인터셉터에서 확인한 국가 정보를 @Country
데코레이터를 구현하여 간편하게 사용할 수 있다.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Country = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.body?.country;
});
Controller 단에서의 사용 예시이다.
@UseInterceptors(CountryInterceptor)
@Get('/')
@ApiHeader({
name: 'X-Language',
description: 'Lang',
})
async getMarketHome(@Lang() lang: string, @Country() country: string) {
try {
const result = await this.itemService.getItems(lang, country);
...
return result;
} catch (e) {
...
throw e;
} }
@UseIntercepteors()
로 API 요청을 가로채 IP 체크와 국가 조회 로직을 수행한 후, 이 결과를 요청 본문에 추가한다. 그 후 @Country
데코레이터를 사용하여 컨트롤러의 메소드에서 필요한 로직을 수행한다.
여기서 짚고 가야 할 것은, 아래 코드는 제대로 동작하지 않을 수 있다는 것이다.
const ip = (req.headers['x-forwarded-for'] as string) || req.ip;
X-Forwarded-For
는 Client와 서버 사이에 존재하는 각각의 프록시 서버의 IP 주소를 추적하기 위해 사용된다.
즉, Client와 서버 사이에 여러 프록시 or 로드밸런서가 존재하는 경우, 이 헤더에는 각 IP 주소가 쉼표로 구분되어 저장되게 된다. Ex) "Client IP, 프록시1 IP, 프록시2 IP, ...”
원본 IP 주소와 그 이후에, 네트워크 경로 상 요청을 전달하는 모든 중계 단의 IP 주소가 쉼표로 구분되어 저장되어 있다.
위 코드에서는 쉼표로 구분된 여러 IP 가 있을 경우에 대해서 제대로 된 문자열 파싱이 이뤄지지 않고 있다.
요청이 여러 프록시를 거쳐갈 때, X-Forwarded-For
헤더의 첫번째에 원본 IP 가 들어 있다.
아래 코드는 위의 문제점을 해결한 예시 코드이다.
const xForwardedFor = req.headers['x-forwarded-for'];
let ip;
if (typeof xForwardedFor === 'string') {
ip = xForwardedFor.split(',')[0]; // Client IP
} else {
ip = req.ip;
}
추가로, 나는 인터셉터에서 직접 X-Forwarded-For
헤더를 조회하는 방식으로 문제를 풀어 나갔지만,
NestJS (Express) 에서는 trust proxy
를 이용해 이 문제를 해결할 수도 있다.
app.set('trust proxy', true);
이 설정을 사용하면, NestJS(Express) 가 X-Forwarded-For
헤더를 해석하여, 올바른 원본 IP 를 가져오게 된다.
조금 더 정보를 찾다 보니, X-Forwarded-For
헤더는 악의적인 공격에 보안상 취약할 수 있기에 요청 경로상에 있는 프록시에 대한 보안에 유의해야 한다고 한다. 중간 프록시에서 헤더가 조작된다면 백엔드 서버에 잘못된 IP가 전달되어 보안을 우회할 수 있기 때문이다.
이를 위해, 신뢰할 수 있는 프록시 서버의 IP 주소 목록을 만든 후, 요청이 이 해당 목록에 있는 경우에만 X-Forwarded-For
헤더의 정보를 신뢰하는 로직으로 보완할 수도 있겠다.
모쪼록 이 글이 나와 비슷한 어려움을 겪었던 다른 분들에게 도움이 되길 바란다.
'개발' 카테고리의 다른 글
더 나은 코드를 고민 해보기 - Mapper & Builder 패턴 (0) | 2024.03.06 |
---|---|
Node 프로젝트 패키지 최적화 (bundle analyzer) (0) | 2024.03.04 |
백엔드 환경에서 GA4(google analytics4) 데이터 수집하기 (1) | 2024.02.21 |
백그라운드 프로세스 시스템 구축하기 (NestJS, CronJob 스케줄러) (0) | 2024.02.19 |
인프라 환경을 개선하여 서비스에 기여하기. (AWS 인프라 이전 & Docker) (1) | 2024.02.18 |