nginx와 애플리케이션 코드의 리버스 프록시 구분하기
- CS
- nginx
- Node.js

들어가며
nginx뿐만 아니라 애플리케이션 코드 안에도 프록시가 있다.
현재 진행 중인 프로젝트에서 브라우저가 보낸 요청은 먼저 nginx를 거친다. nginx에서 TLS를 종료하고, 그 뒤에 있는 Node.js 애플리케이션 서버로 요청을 넘긴다. 여기까지는 익숙한 구조다. 그런데 애플리케이션 서버 코드를 살펴보니, 특정 경로로 들어온 요청을 다른 호스트로 그대로 전달하는 코드가 있었다. 이 코드도 문서에서는 “reverse proxy”라고 부르고 있었다.
nginx가 이미 요청을 프록시하고 있는데, 애플리케이션 코드 안에서 또 프록시를 한다는 말이 처음에는 잘 이해되지 않았다. nginx 뒤에 별도의 프록시 서버가 하나 더 있고, 그 서버가 다시 다른 서버로 요청을 넘기는 식으로 세 대의 서버가 순서대로 통신한다고 생각했다. 이 헷갈림을 풀려면 먼저 “reverse proxy”라는 말이 무엇을 기준으로 붙는 이름인지부터 다시 살펴봐야 했다.
forward proxy와 reverse proxy
둘을 가르는 기준은 "누구를 대신하는가?"
MDN은 forward proxy를 클라이언트 또는 요청을 보내는 호스트를 대신해 동작하는 프록시로, reverse proxy를 서버를 대신해 동작하는 프록시로 설명한다. 둘 다 누군가를 대신해 요청을 전달한다는 점은 같지만, 대신하는 대상이 클라이언트인지 서버인지에 따라 이름이 달라진다.
forward proxy는 클라이언트의 신원을 감춘다.

forward proxy는 클라이언트를 대신해 서버에 요청을 보낸다. 서버 입장에서는 자신이 forward proxy와 통신하고 있다는 사실을 알기 어렵고, 프록시를 실제 클라이언트로 인식한다.
회사 네트워크에서는 직원들의 외부 요청을 하나의 프록시로 모아 보낸다. 이런 구성에서는 외부 서버에 개별 직원의 IP가 그대로 노출되지 않고, 각 직원이 어떤 사이트로 나가는 요청을 보내는지도 프록시 한 지점에서 통제하고 감사할 수 있다.
reverse proxy는 그 반대로 서버의 신원을 감춘다.

reverse proxy는 서버를 대신해 클라이언트의 요청을 받는다. 클라이언트 입장에서는 자신이 reverse proxy와 통신하고 있다는 사실을 알기 어렵고, 프록시를 실제 서버로 인식한다. 브라우저와 애플리케이션 서버 사이에 있는 nginx도 이처럼 서버 대신 요청을 받아, 클라이언트가 자신을 실제 서버로 인식하게 하는 방식으로 동작한다.
실제 서버가 여러 대로 늘어나거나 배포 과정에서 바뀌더라도, 클라이언트는 내부 구성을 알 필요가 없다. reverse proxy가 서버를 대신해 요청을 받고, 적절한 내부 서버로 전달하기 때문이다.
reverse proxy는 위치를 요구하지 않는다
reverse proxy라는 정의에는 별도 프로세스라는 조건이 없다.
정의를 다시 읽어보면 reverse proxy가 어디에서 실행되어야 하는지는 정해져 있지 않다. 별도의 서버여야 한다거나, 전용 소프트웨어여야 한다는 조건도 없다.
정의상 필요한 것은 클라이언트의 요청을 대신 받아 실제 서버로 전달하고, 그 응답을 다시 클라이언트에게 전달하는 동작이다. 이 동작을 nginx 같은 전용 소프트웨어가 수행하든, Node.js 애플리케이션 코드가 수행하든, 서버를 대신해 요청을 전달한다면 같은 의미에서 reverse proxy라고 부를 수 있다.
이 기준으로 보면 nginx의 proxy_pass도, 애플리케이션 코드 안의 fetch도 reverse proxy의 동작을 구현할 수 있다. 둘의 차이는 “리버스 프록시인가 아닌가”가 아니라, 어디에서 실행되고 프로세스 경계를 어떻게 넘는가에 있다.

nginx가 요청을 프록시하는 방식
nginx의 reverse proxy 동작은 받고, 전달하고, 돌려주는 세 단계다.
nginx 공식 문서는 reverse proxy의 동작을 세 단계로 설명한다.
- 클라이언트의 요청을 받는다.
- 지정된 프록시 서버로 요청을 전달한다.
- 프록시 서버의 응답을 클라이언트에게 돌려준다.
이 동작은 애플리케이션 서버로 요청을 전달하거나, 여러 백엔드로 요청을 분산하거나, 여러 웹사이트의 콘텐츠를 하나의 진입점에서 제공하는 데 사용된다. 이 세 단계를 실제 설정으로 옮기면 한 줄이면 끝난다.
location / {
proxy_pass http://app_backend;
}proxy_pass 한 줄이 방금 본 세 단계를 그대로 수행한다. /로 들어온 요청을 app_backend로 보내고, 그 응답을 받아, 클라이언트에게 그대로 돌려준다. 설정 파일 한 줄로 끝난다는 점이 이후 나올 애플리케이션 코드 안의 프록시와 대비되는 지점이다.
nginx의 프록시는 애플리케이션과 별도의 프로세스에서 실행된다.
nginx는 애플리케이션과 별도의 소프트웨어이자 별도의 프로세스다. 하나의 마스터 프로세스와 여러 워커 프로세스로 구성되며, 실제 요청 처리는 워커 프로세스가 담당한다. 따라서 뒤에 있는 Node.js 애플리케이션이 재배포되거나 종료되더라도 nginx 프로세스 자체는 계속 실행될 수 있다.
물론 업스트림 애플리케이션이 응답하지 않으면 nginx가 정상적인 응답을 전달할 수는 없다. 그래도 nginx라는 프로세스까지 애플리케이션과 함께 종료되는 것은 아니다. 이 독립성은 TLS 종료, 정적 파일 제공, 캐싱, 로드밸런싱처럼 애플리케이션의 배포 주기와 관계없이 계속 동작해야 하는 역할과 잘 맞는다.

애플리케이션 프로세스가 재시작되는 짧은 구간에도 nginx는 요청을 받을 수 있다. 업스트림에 연결하지 못하면 에러 응답을 반환할 뿐이다. nginx와 애플리케이션이 서로 다른 프로세스라는 사실은 이런 상황에서 특히 분명하게 드러난다.
애플리케이션 코드 안에서도 프록시할 수 있다
Node.js의 요청 콜백은 애플리케이션 프로세스 안에서 그대로 실행된다.
Node.js의 http 모듈은 요청이 들어올 때마다 등록된 함수를 실행하는 서버를 제공한다. Node.js 공식 문서에 따르면 http.Server는 요청이 들어올 때 request 이벤트를 발생시키고, http.createServer(requestListener)로 등록한 콜백이 해당 요청을 처리한다.

이 콜백이 실제로 어느 프로세스에서 실행되는지는 pid를 찍어보면 확인할 수 있다.
const server = http.createServer((req, res) => {
res.end(`pid: ${process.pid}`); // 서버를 띄운 프로세스의 pid를 그대로 반환한다
});이 콜백이 응답하는 pid는 서버를 띄운 프로세스의 pid와 항상 같다. nginx처럼 마스터 프로세스가 워커 프로세스를 여러 개 fork해 요청을 나눠 맡기는 구조가 아니라, 하나의 이벤트 루프가 들어오는 연결을 그대로 순서대로 처리하기 때문이다.
같은 라우팅 안에서도 함수 호출과 프록시는 갈린다.
Fastify처럼 라우팅을 제공하는 프레임워크를 사용하면 경로별로 서로 다른 동작을 정의할 수 있다.
// A: 함수 호출 — 프로세스 밖으로 나가지 않는다
fastify.get("/page", (req, reply) => {
return renderPage(req); // 같은 프로세스 안의 함수를 그냥 호출
});A는 renderPage라는 함수를 호출한다. 이미 애플리케이션 프로세스의 메모리에 로드된 함수이므로, 호출과 결과 반환이 같은 프로세스 안에서 일어난다.
// B: 프록시 — 새 네트워크 연결이 생긴다
fastify.get("/assets/*", async (req, reply) => {
const upstream = await fetch(`https://cdn.example.com${req.url}`);
return reply.send(upstream.body);
});B는 fetch로 cdn.example.com이라는 외부 호스트에 요청을 보내고 응답을 받는데, 이 과정에서 애플리케이션 프로세스 밖의 서버와 네트워크 통신이 발생한다. 애플리케이션이 클라이언트의 요청을 대신 받아 CDN으로 전달하고 CDN의 응답을 다시 클라이언트에게 돌려준다는 점에서, B는 reverse proxy의 정의를 만족한다.
다만 nginx의 프록시는 애플리케이션과 별도의 프로세스에서 실행되지만, B의 프록시 코드는 애플리케이션과 같은 프로세스 안에서 실행된다는 차이점은 있다. 따라서 B는 애플리케이션과 같은 메모리와 배포 주기를 공유한다.
요청을 다른 곳에 맡긴다는 인상만으로는 둘을 구분할 수 없다.
겉보기엔 A와 B 모두 요청을 받아 다른 코드에 값을 넘기고 결과를 돌려받는 것처럼 보인다. 하지만 호출부 코드만 봐서는 둘을 구분할 수 없다. renderPage라는 이름만으로는 내부에서 외부 API를 호출하지 않는다고 단정할 수 없고, fetch라는 이름만으로도 같은 애플리케이션 내부의 주소를 요청하지 않는다고 단정할 수 없다. 결국 경계를 가르는 건 함수가 실제로 하는 일이다.
프로세스 경계로 다시 그린 구조
프로세스 경계를 기준으로 다시 그리면 구조가 달라진다.
이 구분을 명확히 하고 나서야 처음 그렸던 구조가 왜 잘못되었는지 이해할 수 있었다. nginx 뒤에 프록시 서버가 하나 더 있고, 그 프록시가 다시 다른 서버로 요청을 전달하는 구조만 있었던 것이 아니다. 실제 구조에는 프로세스 경계를 넘는 네트워크 홉과 프로세스 안에서 끝나는 함수 호출이 함께 존재한다.

각 요청이 어느 프로세스에서 처리되고, 실제로 몇 번의 네트워크 경계를 넘는지를 함께 봐야 이 구조를 설명할 수 있다. 이 경계는 별도의 OS 프로세스인지로 갈리며, 물리적으로 몇 대의 서버에 나뉘어 있는지와는 무관하다. nginx와 Node 애플리케이션이 같은 머신에 떠 있어도 서로 다른 프로세스라 경계는 그대로 있다.
안쪽 타임아웃은 바깥쪽보다 짧아야 한다
프록시가 여러 겹이라면 타임아웃의 순서도 함께 봐야 한다.
프로세스 경계가 몇 번 존재하는지는 타임아웃을 설정할 때도 영향을 준다. nginx와 애플리케이션 코드처럼 reverse proxy가 앞뒤로 이어져 있다면, 각 레이어는 각자의 타임아웃을 갖는다.
예를 들어 nginx의 타임아웃이 애플리케이션 프로세스의 타임아웃보다 짧다고 해보자. 애플리케이션이 아직 요청을 정리하고 응답을 준비하는 중인데 nginx가 먼저 기다림을 포기할 수 있다. 이 경우 클라이언트는 애플리케이션이 준비한 응답이 아니라 nginx가 만든 타임아웃 응답을 받게 된다. 애플리케이션이 자체적으로 남기려던 에러 로그나 정리 로직도 실행되기 전에 연결이 끊길 수 있다.
안쪽 레이어가 먼저 정리하도록 하면 결과가 달라진다.
반대로 안쪽 레이어인 애플리케이션이 먼저 타임아웃에 도달하도록 설정하면, 애플리케이션이 요청을 정리하고 응답을 반환할 시간을 확보할 수 있다.

빨간색 영역에서는 애플리케이션이 아직 응답을 준비하는 중에 nginx가 먼저 연결을 끊어, 애플리케이션 쪽 로깅이나 정리 코드가 실행되지 못할 수 있다. 초록색 영역에서는 애플리케이션이 먼저 타임아웃에 도달해 요청을 정리하고, nginx는 그 결과를 받아 클라이언트에게 전달하거나 이를 기준으로 다음 동작을 수행한다.
따라서 프록시 레이어가 여러 개라면 안쪽 레이어의 타임아웃을 바깥쪽보다 짧게 설정하는 것이 일반적인 방향이다. 다만 실제로 어떤 타임아웃을 조정해야 하는지는 프레임워크와 서버의 타임아웃 종류, 연결 유지 방식, 에러 처리 방식에 따라 달라지므로, 숫자 자체보다 어느 레이어가 먼저 요청을 정리해야 하는지를 먼저 정하는 것이 중요하다.
정리하며
프로세스 경계를 먼저 확인하자.
앞으로 여러 겹의 reverse proxy를 마주치면, 각 요청이 프로세스 경계를 몇 번 넘는지부터 확인하려 한다. renderPage(req)처럼 프로세스 안에서 끝나는 함수 호출인지, fetch(url)처럼 외부 호스트와 통신하는 네트워크 요청인지 확인하면 요청의 실제 흐름을 그릴 수 있다. 그 흐름을 알아야 재배포 중에도 프록시가 살아남을 수 있는지, 어느 레이어의 타임아웃을 먼저 끝내야 하는지 판단할 수 있다.
결국 “reverse proxy인가?”라는 질문만으로는 충분하지 않다. 어떤 프로세스에서 실행되고, 요청이 어떤 네트워크 경계를 넘는가를 함께 물어야 이 구조를 제대로 이해할 수 있다.

