"마케팅 팀에서 이벤트를 진행하자마자 서버가 마비되었습니다. CPU 점유율은 낮은데 동적 API 서버가 응답을 안 해요!"
원인을 분석해보니, 이벤트 페이지에 삽입된 대용량 고화질 이미지 파일들을 사용자들이 다운로드하면서 문제가 생겼습니다. 동적 자바 비즈니스 로직을 수행해야 할 WAS의 스레드 풀 200개가 이미지 파일을 디스크에서 읽어 보내는 '정적 전송 대기 상태'로 모두 잠겨버린 것입니다.
만약 앞단에 가볍고 분주하게 움직이는 Web Server(Nginx)를 두고 이미지 전송을 전담시킨 뒤, 진짜 동적 연산(API)만 WAS(Tomcat)로 흘려보내는 Web-WAS 2-Tier 구조를 구축했다면, 단 한 대의 서버로도 마비 없이 수천 명의 트래픽을 소화했을 것입니다.
- 1웹 서버(Nginx)와 웹 애플리케이션 서버(Tomcat)의 구조적 차이를 비교할 수 있다
- 2이벤트 기반(Event-driven)과 멀티스레드 스레드 풀(Thread Pool) 동작 모델을 이해할 수 있다
- 3리버스 프록시(Reverse Proxy) 연동 필요성을 이해하고 설정할 수 있다
- 4Nginx 가상 호스트와 Tomcat 연동용 proxy_pass를 구성할 수 있다
- 5클라이언트 실제 IP(X-Real-IP) 전송 헤더를 설정할 수 있다
- 6502 Bad Gateway와 504 Gateway Timeout 장애 패턴을 분석할 수 있다
sudo apt-get update && sudo apt-get install -y nginxsudo systemctl status nginxsudo nginx -tWeb-WAS의 핵심 분리 철학
안내원 Nginx와 요리사 Tomcat — 왜 역할을 나눠야 하는가
WAS(Tomcat)만 단독으로 운영하면 처음엔 잘 돌아가는 것처럼 보입니다. 그런데 이벤트 트래픽이 몰리거나 대용량 파일 요청이 겹치는 순간, 스레드 풀 200개가 이미지 전송 대기에 모두 묶여 정작 핵심인 API 처리가 멈춰버립니다. 이 문제를 구조적으로 해결하는 방법이 Web-WAS 분리입니다. 정적 파일은 Nginx가 초고속으로 처리하고, Tomcat은 오직 비즈니스 로직에만 집중하게 만드는 것이 핵심입니다.
확대
Nginx (Web Server) = 카페 접수원
Nginx는 이벤트 루프(Event Loop)를 사용하는 비동기 구조입니다. CPU 코어당 1개의 Worker 프로세스만으로 수만 건의 정적 요청을 동시에 처리할 수 있습니다. 파일 I/O가 발생해도 스레드를 블로킹하지 않고 이벤트 큐에서 관리하기 때문에 메모리와 CPU 소모가 극단적으로 적습니다.
Tomcat (WAS) = 전문 요리사
Tomcat은 요청 1개당 JVM 스레드 1개를 완전히 점유하는 Thread-per-request 구조입니다. 스레드 풀이 최대 200개로 설정된 상태에서 200개의 느린 요청이 유입되면, 201번째 요청은 스레드가 릴리즈될 때까지 대기하다 타임아웃됩니다.
인터넷 요청이 Web Server와 WAS를 거치는 흐름입니다.
- 인터넷 요청 → Nginx Web Server (Port 80) — HTML·CSS·Image 직접 처리
proxy_pass로 동적 요청만 전달 → Tomcat WAS (Port 8080) — 비즈니스 로직, DB 접근 집중
확대
요청 하나가 Web과 WAS를 지나 응답이 되기까지 — 6단계
브라우저에서 페이지를 열면 잠깐 사이에 화면이 뜹니다. 그 짧은 순간에 요청은 Nginx(Web)와 Tomcat(WAS)을 정적/동적으로 갈라져 지나가고, 응답은 그 역순으로 되돌아옵니다. 이 흐름을 단계로 알면 "어디서 깨졌나"를 상태 코드로 좁힐 수 있습니다 — 정적 파일을 못 찾아 Web 앞단에서 막힌 404인지, 요청이 WAS까지 갔다가 로직에서 터진 500인지가 단계로 구분됩니다.
[브라우저] GET /order/123 ← 동적 요청 예시
│
① Nginx(Web:80/443) 수신 — TLS 종단, location 규칙으로 정적/동적 판정
│
② 정적/동적 분기
│ ├─ 정적(/static/·.png·.css) → Nginx가 디스크에서 직접 응답하고 종료
│ └─ 동적(/order/·/api/) → proxy_pass로 아래 ③ 진행
│
③ Nginx → Tomcat(WAS:8080) 중계 — X-Forwarded-For에 실제 클라이언트 IP 부착
│
④ Tomcat 스레드 풀에서 워커 1개 배정 → 비즈니스 로직 실행(필요 시 DB 조회) → HTML/JSON 생성
│
⑤ WAS → Nginx로 응답 반환
│
⑥ Nginx가 헤더 보정·gzip 후 클라이언트로 전달
▼
[브라우저] 200 OK + 본문 (역순: WAS → Nginx → 브라우저)
각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 막히면 |
|---|---|---|
| ① Web 수신 | 클라이언트 TCP가 Nginx 80/443에 도달, TLS를 여기서 종단 | Nginx 다운·포트 미개방 → 브라우저에 Connection refused/타임아웃 (요청이 아예 안 들어옴) |
| ② 정적/동적 분기 | location 규칙으로 정적은 Nginx가 직접, 동적은 WAS로 넘길지 결정 | 정적 파일 경로·alias가 틀림 → Nginx가 곧장 404 (WAS까지 가지도 않음) |
| ③ WAS 중계 | proxy_pass http://127.0.0.1:8080으로 동적 요청 전달 | WAS 다운·포트 불일치·바인딩 엇갈림 → Nginx가 502 Bad Gateway |
| ④ 로직 실행 | 스레드 배정 후 애플리케이션 코드 실행, 필요 시 DB 조회 | 로직 예외(NPE 등) → WAS가 500 · 처리가 느려 지연 → proxy_read_timeout 초과 시 504 |
| ⑤ 응답 반환 | WAS가 생성한 응답을 Nginx로 되돌림 | 응답 도중 WAS가 커넥션을 끊음 → 502(upstream prematurely closed) |
| ⑥ Nginx 전달 | 헤더 보정·압축 후 클라이언트로 | 응답 버퍼·헤더 크기 초과 → 502(로그에 buffer 관련 에러) |
정리하면 상태 코드가 "어느 단계에서 깨졌는지"를 가리킵니다. 정적 자원의 404는 대개 ②(Nginx가 파일을 못 찾음)로 WAS와 무관하고, 502는 ③(Nginx는 살아있으나 WAS에 연결 못함), 500은 ④(요청이 WAS까지 가서 로직이 터짐), 504는 ④(WAS가 제때 응답 못 줌)를 뜻합니다. 즉 대략 4xx는 Nginx 앞단, 5xx는 WAS 뒷단 상태를 비추는 신호입니다(단, 동적 경로에 매핑이 없어 나는 404는 WAS가 낼 수도 있으니, 정적 404인지 동적 404인지부터 가릅니다). 진단은 이 단계 지도 위에서 curl -I http://localhost(②까지)와 curl -v http://127.0.0.1:8080/(③을 우회해 WAS 직접)로 끊긴 지점을 좁히는 것입니다.
Nginx 리버스 프록시 연동 실습
기존 기본 설정을 비활성화하고, 프록시 헤더를 정확히 전달하는 전용 서버 블록을 작성합니다.
# 기본 설정 비활성화
sudo rm -f /etc/nginx/sites-enabled/default
# 새로운 전용 설정 파일 생성
sudo tee /etc/nginx/sites-available/infra-platform.conf > /dev/null <<'EOF'
server {
listen 80;
server_name localhost;
# 정적 리소스는 Nginx가 직접 처리
location /static/ {
alias /var/www/infra-platform/static/;
expires 30d;
access_log off;
}
# 동적 API 요청은 WAS(Tomcat)로 중계
location / {
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 X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
EOF
sudo rm -f /etc/nginx/sites-enabled/default && sudo tee /etc/nginx/sites-available/infra-platform.conf > /dev/null- ls /etc/nginx/sites-available/infra-platform.conf 로 파일 존재 먼저 확인, 그 다음 grep proxy_pass /etc/nginx/sites-available/infra-platform.conf 로 proxy_pass 지시어 확인 — 파일이 있어도 proxy_pass가 없으면 프록시 설정이 안 된 것
- proxy_pass 기준: http://127.0.0.1:8080 이어야 정확 — localhost 대신 127.0.0.1을 써야 IPv6 혼용으로 인한 연결 실패 방지, 포트가 실제 WAS 포트와 다르면 즉시 수정
- proxy_pass가 127.0.0.1:8080인데 proxy_set_header X-Real-IP가 없으면 → Nginx를 경유해도 WAS 로그에 클라이언트 IP 대신 127.0.0.1만 찍힘 — 헤더 설정 추가 후 반드시 nginx -t && systemctl reload nginx
심볼릭 링크로 설정을 활성화하고 문법 오류가 없는지 검사한 뒤 리로드합니다.
# 심볼릭 링크 생성
sudo ln -sf /etc/nginx/sites-available/infra-platform.conf /etc/nginx/sites-enabled/
# 구문 검사
sudo nginx -t
# 성공 출력: nginx: configuration file /etc/nginx/nginx.conf test is successful
# 무중단 설정 리로드
sudo systemctl reload nginx
sudo ln -sf /etc/nginx/sites-available/infra-platform.conf /etc/nginx/sites-enabled/ && sudo nginx -t- curl -I http://localhost 로 Server 헤더 먼저 확인, 그 다음 Tomcat 액세스 로그에서 클라이언트 IP 확인 — Server: nginx 가 보여야 Nginx가 앞단에서 프록시 역할 중, 없으면 Nginx를 경유하지 않는 것
- 응답 코드 기준: 200=Nginx+WAS 모두 정상, 502=WAS가 다운이거나 8080 포트 연결 실패, 504=WAS 응답 시간 초과 — 502와 504는 Nginx 오류가 아니라 WAS 상태 문제
- curl 200인데 WAS 로그에 127.0.0.1만 찍히면 → X-Real-IP 헤더 설정 누락 또는 Tomcat의 액세스 로그 패턴에 %{X-Forwarded-For}i 가 없는 것 — 두 곳 모두 확인 필요
심화 — 스레드 풀이 꽉 차는 순간, WAS 안에서 벌어지는 일
심화: maxThreads·maxConnections·acceptCount — 포화의 3단 문턱
앞에서 "스레드 200개가 다 묶이면 201번째 요청은 대기하다 타임아웃"이라고 했습니다. 실제 Tomcat 안에는 그 '대기'를 결정하는 문턱이 하나가 아니라 셋입니다. 이 셋을 알아야 부하 상황에서 증상이 왜 단계적으로 바뀌는지가 보입니다.
- maxThreads: 동시에 요청을 '처리'하는 워커 스레드 상한(기본 200). 이 스레드들이 다 바쁘면 새 요청은 더 이상 처리 단계로 들어오지 못합니다.
- maxConnections: 동시에 '수락(accept)해 들고 있을' 수 있는 커넥션 총수. NIO 커넥터는 스레드보다 커넥션을 많이 붙잡을 수 있어, 처리할 스레드는 없어도 연결 자체는 유지한 채 순서를 기다리게 할 수 있습니다.
- acceptCount: 위 둘이 모두 찼을 때 OS 소켓 backlog 큐에 줄 세울 수 있는 대기 연결 수. 이 큐마저 넘치면 커널이 새 연결을 즉시 거절(connection refused)합니다.
그래서 부하가 오르면 증상이 여유 → 지연(스레드 대기) → 큐 대기(acceptCount) → 거절로 단계적으로 악화됩니다. Nginx 관점에서 큐에서 오래 기다리다 응답이 늦으면 504, 커널이 연결을 거절하면 502로 보입니다. 즉 같은 502라도 'WAS가 죽어서'와 'WAS가 포화라서'는 전혀 다른 상태입니다.
한 가지 함정: 그래서 maxThreads만 무작정 키우면 되지 않습니다. 스레드는 대개 느린 다운스트림(특히 DB 커넥션 풀)을 기다리느라 묶이는데, 스레드만 늘리면 그만큼 DB 커넥션 경쟁·JVM 힙·컨텍스트 스위칭이 먼저 터집니다. 스레드 풀은 '동시에 감당 가능한 다운스트림 크기'에 맞춰야 합니다. 정적·대용량 전송을 Nginx로 빼는 이유가 바로 이 귀한 스레드를 비즈니스 로직에만 쓰기 위함입니다.
상황: 트래픽이 몰리는 시간대에 Tomcat은 죽지 않았고 catalina.out에도 특별한 에러가 없습니다. 그런데 어느 임계점부터 응답 시간이 계단식으로 뛰고, 전체가 아니라 일부 요청만 간헐적으로 502로 떨어집니다. WAS를 재시작하면 잠깐 괜찮아졌다가 부하가 다시 차면 재현됩니다.
원인: 'WAS 다운'이 아니라 'WAS 포화' 입니다. maxThreads가 모두 바쁜 상태라 새 요청은 즉시 처리되지 못하고, 커넥션은 maxConnections까지 수락돼 backlog 큐(acceptCount)에서 대기합니다 — 이때 지연이 계단식으로 뜁니다. 그 대기 큐마저 순간적으로 넘치는 연결은 커널이 거절해 Nginx 눈에 502로 보입니다. 그래서 '전체가 아니라 일부만' 502가 나는 것입니다.
진단: jstack $(pgrep -f tomcat)로 스레드 덤프를 떠서 http-nio-8080-exec-* 워커가 거의 전부 바쁜지, 그리고 대부분이 같은 지점(예: DB 커넥션 획득 대기)에 몰려 있는지 봅니다. JMX/Tomcat manager의 currentThreadsBusy가 maxThreads에 붙어 있는지, ss -tln에서 8080 리슨 소켓의 Recv-Q가 acceptCount 근처까지 차 있는지도 확인합니다. 세 신호가 겹치면 포화가 확정입니다.
해결: 근본 원인은 대개 '스레드가 느린 다운스트림에 오래 붙잡혀 빨리 반납되지 않는' 것입니다. 슬로우 쿼리·외부 API 지연을 먼저 고치면 스레드 반납이 빨라져 같은 maxThreads로도 훨씬 많은 요청을 처리합니다. maxThreads를 늘려야 한다면 반드시 DB 커넥션 풀·JVM 힙과 함께 늘리고, 정적·대용량 응답은 Nginx로 넘겨 스레드를 아낍니다(WEB/WAS/DB 망 분리와 이중화 구조 설계).
실제 업무에서 이 지식이 쓰이는 상황:
주니어 인프라 엔지니어로 입사하면 시니어가 툭 던지는 두 가지 지시가 있습니다.
"Nginx max_connections랑 Worker 프로세스 설정 좀 봐줘"
최대 동시 접속 수는 worker_processes × worker_connections입니다. lscpu로 CPU 코어 수를 확인하고 worker_processes auto로 설정합니다. ulimit -n(파일 디스크립터 한계)보다 worker_connections가 크면 기동 시 에러가 나므로 반드시 확인합니다.
"WAS 실주소 로깅 똑바로 되고 있는지 확인해봐"
Nginx에 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for가 들어있는지 확인합니다. Tomcat의 server.xml AccessLogValve 포맷 패턴에 %{X-Forwarded-For}i가 지정되어 있어야 클라이언트 실제 IP가 로그에 남습니다. 테스트 요청을 직접 쏴서 로그에 실제 IP가 찍히는지 증빙합니다.
명령어·단축키 빠른 참조
이 모듈에서 다룬 Nginx 프록시 연동과 502/504 진단 명령을 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
nginx -t | 설정 문법 검사(반영 전 필수) | nginx -t && systemctl reload nginx |
systemctl reload nginx | 무중단 설정 반영 | 프로세스 끊김 없이 새 설정 적용 |
proxy_pass | 요청을 백엔드 WAS로 중계 | proxy_pass http://127.0.0.1:8080; (localhost 대신 IP) |
proxy_set_header | 클라이언트 정보 헤더 전달 | X-Real-IP $remote_addr, X-Forwarded-For $proxy_add_x_forwarded_for |
proxy_read_timeout | WAS 응답 대기 시간 | 초과 시 504 — proxy_read_timeout 60s; |
worker_processes / worker_connections | 최대 동시 접속(둘의 곱) | worker_processes auto;, ulimit -n보다 작게 |
ss -tlnp | grep 8080 | WAS가 8080에 바인딩됐는지 | 502 시 백엔드 생존 1차 확인 |
curl -I http://localhost | 프록시 응답·Server 헤더 확인 | 백엔드 직접: curl -v http://127.0.0.1:8080/ |
ps -ef | grep tomcat | WAS 프로세스 생존 확인 | PID만: pgrep -f tomcat |
jstack <PID> | 스레드 덤프(포화 진단) | jstack $(pgrep -f tomcat) > dump.txt |
tail -f /var/log/nginx/error.log | 프록시 오류 실시간 확인 | Connection refused 대상 파악 |
ln -sf …sites-available …sites-enabled/ | 가상호스트 활성화 | 활성화 후 nginx -t |
관련 모듈로 더 깊이:
- Nginx 리버스 프록시와 로드밸런싱 설정 — Web Server가 WAS로 요청을 넘기는 리버스 프록시 설정 실무
- WAR 배포부터 server.xml 튜닝, 장애 대응까지 — 이 구조에서 WAS 역할을 맡는 Tomcat의 배포·설정·튜닝
- WEB/WAS/DB 망 분리와 이중화 구조 설계 — 같은 구조를 실제 네트워크 망으로 분리한 3-Tier 이중화
다음 모듈에서는 Web/WAS/DB를 실제 네트워크 망으로 분리하는 3-Tier 이중화 구조를 다룹니다.