🟣 앱 서버를 직접 내보이면 생기는 일
가장 단순한 배포는 앱 서버를 공인 IP에 그대로 물려 클라이언트가 직접 접속하게 하는 것이다. 처음엔 잘 돌아간다. 문제는 트래픽이 늘고 서버가 하나로 부족해지는 순간 드러난다. 서버를 두 대로 늘리면 클라이언트가 둘 중 어디로 가야 하는지 알 방법이 없다. TLS 인증서도 앱 서버마다 따로 관리해야 하고, 앱 프로세스가 재시작되는 동안에는 그대로 접속이 끊긴다.
이 문제들의 공통점은 클라이언트가 실제 앱 서버의 위치와 개수를 알아야 한다는 데 있다. 앞단에 중개자를 하나 세우면 이 결합이 끊어진다. 클라이언트는 그 중개자 한 곳만 알고, 중개자가 뒷단의 여러 앱 서버로 요청을 나눠 준다. 이것이 리버스 프록시다.
포워드 프록시가 클라이언트를 대신해 바깥으로 나가는 대리인이라면, 리버스 프록시는 서버 쪽에 서서 들어오는 요청을 대신 받는 대리인이다. 방향이 반대다. Nginx가 웹서버로도 쓰이지만 실무에서 더 자주 맡는 역할이 바로 이 리버스 프록시다.
🟣 proxy_pass: 요청을 뒷단으로 넘긴다
리버스 프록시의 핵심 디렉티브는 proxy_pass 하나다. 특정 location에 들어온 요청을 지정한 뒷단 주소로 그대로 넘긴다.
location /api/ {
proxy_pass http://127.0.0.1:8080;
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가 중요하다. 프록시를 거치면 뒷단 앱 입장에서 요청을 보낸 상대는 언제나 Nginx(127.0.0.1)가 된다. 원래 누가 보냈는지, 어떤 호스트로 들어왔는지 정보가 사라진다. 그래서 실제 클라이언트 IP와 호스트를 헤더에 실어 넘긴다. 이 헤더를 빼먹으면 앱의 접속 로그에 전부 프록시 IP만 찍히는 흔한 사고가 난다.
클라이언트는 Nginx 하나만 안다. 뒷단에 앱 서버가 하나든 열이든, TLS를 어디서 푸는지, 어떤 서버가 재시작 중인지 전혀 몰라도 된다. 이 그림 하나가 앞 절에서 말한 결합을 어떻게 끊는지 보여 준다.
🟣 TLS는 앞단에서 한 번만 푼다
리버스 프록시를 세우면 자연스럽게 따라오는 이득이 TLS 종료다. HTTPS 복호화는 연산 비용이 있는 작업인데, 이걸 앱 서버마다 하는 대신 Nginx 앞단에서 한 번만 처리한다. Nginx가 클라이언트와는 HTTPS로 말하고, 안전한 내부망에서는 뒷단 앱과 평문 HTTP로 말한다.
이 구조 덕분에 인증서 관리 지점이 하나로 모인다. 인증서를 갱신할 때 앱 서버 여러 대를 건드릴 필요 없이 Nginx 설정만 바꾸면 된다. iOS 앱이 서버로 보낸 HTTPS 요청이 실제로 어디서 복호화되는지 궁금했던 적이 있는데, 대개 이 앞단 프록시가 그 지점이었다. 앱이 신뢰하는 인증서도 실은 앱 서버가 아니라 이 프록시의 것이다.
🟣 여러 대로 나누기: upstream
서버가 두 대 이상이 되면 요청을 어떻게 나눌지 정해야 한다. Nginx는 upstream 블록으로 뒷단 서버 묶음을 정의하고, 그 묶음으로 요청을 분산한다.
upstream backend {
server 10.0.0.11:8080;
server 10.0.0.12:8080;
server 10.0.0.13:8080 weight=2;
}
location /api/ {
proxy_pass http://backend;
}기본 분배 방식은 라운드 로빈이다. 요청을 순서대로 돌아가며 각 서버에 나눠 준다. weight를 주면 성능이 좋은 서버에 더 많이 보낼 수 있고, least_conn을 쓰면 현재 연결 수가 가장 적은 서버로 보낸다. 요청 처리 시간이 들쭉날쭉한 서비스라면 연결 수 기준이 대체로 더 고르게 분산된다.
여기서 한 가지 함정이 세션이다. 사용자의 로그인 상태를 특정 서버 메모리에 들고 있으면, 다음 요청이 다른 서버로 가는 순간 로그아웃된 것처럼 보인다. 그래서 로드 밸런싱을 전제로 한 서버는 세션 같은 상태를 각 서버 메모리가 아니라 공유 저장소(Redis 등)에 두어야 한다. 분산은 서버를 무상태로 만들 것을 요구한다. 앞서 리눅스 시스템 프로그래밍에서 본 공유 메모리 위의 캐시, 그 확장인 Redis가 여기서 다시 등장한다.
🟣 죽은 서버를 걸러내기
로드 밸런싱의 진짜 값어치는 부하 분산보다 고가용성에 있다. 뒷단 서버 한 대가 죽어도 서비스가 멈추지 않는 것이다. Nginx는 뒷단으로 넘긴 요청이 실패하면 다른 서버로 다시 시도할 수 있고(proxy_next_upstream), 상태 검사로 응답 없는 서버를 잠시 분배 대상에서 뺀다.
이 덕분에 앞 절에서 말한 "앱 재시작 중 접속 끊김" 문제가 풀린다. 서버를 한 대씩 순서대로 내렸다 올리면, 내려간 서버로 갈 요청을 Nginx가 살아 있는 서버로 돌린다. 사용자는 배포가 진행 중인지도 모른 채 계속 서비스를 쓴다. 무중단 배포의 가장 기본적인 형태가 이 구조 위에 선다.
🟣 정리
- 앱 서버를 직접 노출하면 클라이언트가 서버의 위치와 개수를 알아야 한다. 리버스 프록시는 이 결합을 끊어 클라이언트가 한 곳만 알게 한다.
proxy_pass로 요청을 뒷단에 넘기되,proxy_set_header로 원래 클라이언트 정보를 실어 보내지 않으면 로그와 인증이 깨진다.- TLS 종료를 앞단에서 한 번만 하면 인증서 관리가 한 지점으로 모인다. 앱이 신뢰하는 인증서는 대개 이 프록시의 것이다.
upstream으로 여러 서버에 분산하되, 분산은 서버가 무상태일 것을 요구한다. 세션은 공유 저장소로 뺀다.- 로드 밸런싱의 핵심 가치는 고가용성이다. 죽은 서버를 걸러내는 구조가 무중단 배포의 토대다.
리버스 프록시로 요청을 나누고 죽은 서버를 걸러내는 데까지 왔다. 그런데 뒷단으로 넘기는 요청 중 상당수는 매번 같은 응답을 만든다. 바뀌지 않는 이미지, 자주 조회되는 목록을 요청마다 앱 서버가 다시 계산하는 것은 낭비다. 다음 장은 이 낭비를 줄이는 캐시와 성능이다.