같은 머신에 있다고 같은 런타임은 아니다
502가 존재한다는 사실이 웹 서버와 WAS의 분리를 증명한다
난이도: ★★★☆☆
핵심 요약
-
“머신 1대”와 “런타임 1개”는 세는 단위가 다르다. 머신은 하드웨어(또는 VM)를 세고, 런타임은 프로세스를 센다. 한 머신 위에 런타임은 여러 개 뜬다. Nginx는 C로 컴파일된 네이티브 프로세스고 Tomcat은 JVM 프로세스다. 둘은 변수 하나도 공유하지 못한다.
-
웹 서버는 WAS의 코드를 “실행시키는” 게 아니라 “물어본다”. 함수 호출이 아니라 HTTP 요청이다. 그래서 이 구간도 네트워크 통신이고, 그 순간 웹 서버가 클라이언트가 된다. 클라이언트와 서버는 기계에 붙은 이름표가 아니라 연결을 먼저 연 쪽에 붙는 역할이다. 같은 원리로 WAS → DB 구간에서는 WAS가 클라이언트다.
-
502 Bad Gateway가 존재한다는 사실 자체가 그 분리의 증거다.500은 WAS가 작성해서 보낸 응답이고,502는 WAS가 아무것도 못 보냈을 때 웹 서버가 대신 작성한 응답이다. 둘이 같은 런타임이었다면 502라는 코드는 성립할 수 없다.
왜 헷갈렸나
-
내가 그린 구조는 이랬다.
client <> server [web server / was] was <> db, storage그리고 이렇게 적었다. “web server와 was는 별도 서버가 아닌 동일 서버 내 같은 런타임에서 돌아감.”
-
앞부분은 맞다. 실제로 한 머신에 둘 다 올리는 배치는 흔하다. 틀린 건 **“같은 런타임”**이다. “머신 1대”와 “런타임 1개”를 같은 말로 쓰고 있었다.
-
왜 그렇게 생각했나. **“웹 서버가 동적 데이터가 필요하면 WAS 코드를 실행한다”**고 이해하고 있었기 때문이다. 실제로 내가 쓴 표현이 “WAS에 이 작업을 실행시키라고 위임함”이었다. 그 단어에 오해가 이미 들어 있었다 — 실행시킨다는 건 함수를 부른다는 그림이고, 함수를 부르려면 같은 런타임이어야 한다. 그래서 “동일 런타임”이라는 결론이 자동으로 따라 나온 것이다.
내 머릿속 : 웹 서버 ──함수 호출──▶ WAS 코드 (같은 런타임이어야 성립) 실제 : 웹 서버 ──HTTP 요청──▶ WAS 프로세스 (다른 런타임이라서 필요)실제로는 실행시키는 게 아니라 물어보는 것이다. Nginx는 WAS의 코드를 부를 수 없다. 요청을 보내고 응답을 기다릴 뿐이다.
-
그리고 한 번 더 틀렸다. “주방에 불이 나서 아무도 없으면 손님 화면에 뭐가 뜨나”라는 질문에 **
500**이라고 답했다. 끊긴 구간(웹 서버 → WAS)은 맞게 짚었는데 숫자가 틀렸다. WAS가 죽었으면 500을 만들어 보낼 주체 자체가 없다. -
오해를 풀고 나서 새로 보인 것 하나. 배치는 선택지이지 법칙이 아니다. 한 머신 안에 프로세스 둘로 둘 수도 있고, Vercel과 AWS처럼 아예 다른 회사·다른 리전으로 갈라놓을 수도 있다. 관계(누가 클라이언트인가)는 고정이고, 배치는 자유롭다. 내가 그렸던 그림은 흔한 배치 하나를 법칙으로 착각한 것이었다.
메커니즘
한 머신, 두 프로세스
┌─────────────── 서버 1대 · 같은 IP ────────────────┐
│ │
│ 프로세스 A · PID 1024 │
│ Nginx — C로 컴파일된 네이티브 바이너리 │
│ :443 을 듣는다 │
│ │ │
│ │ TCP :8080 또는 unix domain socket │
│ │ ★ 여기가 네트워크 경계 │
│ ▼ A = 클라이언트 · B = 서버 │
│ │
│ 프로세스 B · PID 2048 │
│ Tomcat(JVM) 또는 Node(V8) │
│ :8080 을 듣는다 │
│ │
└───────────────────────────────────────────────────┘
두 프로세스는 서로의 메모리 공간을 볼 수 없다.
전역 변수도 객체도 공유 불가. 소켓으로 바이트를 주고받을 뿐이다.
OS가 프로세스마다 독립된 가상 메모리 공간을 주기 때문이다. 같은 물리 메모리 위에 있어도 서로의 주소를 들여다볼 방법이 없다. 물리적으로 붙어 있다는 것과 논리적으로 같은 실행 환경이라는 건 완전히 다른 얘기다.
그래서 A가 B에게 뭔가 시키려면 방법이 하나뿐이다 — 요청을 보내는 것. 그 순간 A는 클라이언트가 된다.
500과 502를 가르는 것은 “누가 썼는가”
[ 정상 ]
브라우저 ─▶ Nginx ─▶ WAS ─▶ 200 ─▶ Nginx ─▶ 브라우저
[ WAS는 살아있고 로직이 실패 — 500 ]
브라우저 ─▶ Nginx ─▶ WAS
│ 예외 발생. 하지만 프로세스는 살아있다
▼
500 응답을 스스로 작성해서 반환
Nginx ─▶ 그대로 전달 ─▶ 브라우저
※ 이 500은 WAS가 쓴 문장이다
[ WAS가 죽음 — 502 ]
브라우저 ─▶ Nginx ─▶ WAS
✗ connection refused / 무응답
Nginx ─▶ 502를 스스로 작성해서 반환 ─▶ 브라우저
※ 이 502는 Nginx가 쓴 문장이다
WAS는 단 한 바이트도 보내지 않았다
502 Bad Gateway의 “Gateway”가 Nginx 자신을 가리킨다. **“내가 게이트웨이인데, 뒤쪽에서 제대로 된 응답을 못 받았다”**는 뜻이다. 게이트웨이와 뒤쪽이 같은 런타임이라면 이 문장이 성립하지 않는다.
같은 이유로 Nginx만 reload해도 WAS는 멀쩡히 살아있다. 프로세스가 다르니까.
상태 코드로 고장 위치 특정하기
| 코드 | WAS 상태 | 응답 작성자 | 프론트엔드가 할 일 |
|---|---|---|---|
500 | 살아있고, 로직이 실패 | WAS | 요청 payload를 첨부해 공유. 내가 보낸 값이 원인일 수 있다 |
502 | 죽었거나 연결 거부 | 웹 서버 | ”서버 떠 있나요?” payload는 볼 필요 없다 |
504 | 살아있는데 응답이 안 옴 | 웹 서버 | 타임아웃 설정, 느린 쿼리 의심 |
503 | 의도적 거절 (과부하·점검) | 웹 서버 or WAS | 배포 중인지 확인 |
이 구분을 못 하면 백엔드에 엉뚱한 걸 물어보게 된다. 502를 받고 요청 payload를 들여다보는 건, 문이 잠긴 가게 앞에서 주문서 내용을 고쳐 쓰는 것과 같다. 그 주문서는 주방에 닿지도 않았다.
다음에 이 상황을 만나면
5xx를 받으면 → 숫자를 먼저 읽고, 누가 그 응답을 작성했는지부터 판단한다.
-
502인가? → 내 요청은 WAS에 닿지도 못했다. payload 디버깅은 시간 낭비다. -
500인가? → WAS가 살아서 응답한 것이다. 내가 보낸 값이 원인일 수 있으니 payload를 첨부해서 공유한다. - 배포 직후에만 잠깐 502가 나는가? → WAS 재시작 구간일 수 있다. graceful reload / 헬스체크 설정을 확인한다.
- “서버 재시작했는데 여전히 502”인가? → 어느 프로세스를 재시작했는지 확인한다. 웹 서버만 재시작해도 WAS는 그대로 죽어 있다.
- 로컬에서는 502를 본 적이 없는가? → 로컬은 대개 웹 서버 없이 WAS 단독으로 뜬다. 502가 나올 구조 자체가 없다. 그래서 이 에러는 배포 환경에서 처음 만난다.
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 프로세스와 가상 메모리 공간 — OS는 프로세스마다 독립된 주소 공간을 준다. 한 프로세스의 전역 변수를 다른 프로세스가 읽을 방법은 없다. 이걸 알아야 “같은 머신인데 왜 통신을 하지?”가 풀린다.
- 클라이언트와 서버는 역할이다 — 기계 이름표가 아니라 연결을 먼저 연 쪽에 붙는 이름. 이 전제가 없으면 “Nginx가 클라이언트”라는 말이 이상하게 들린다.
→ 확장 개념 (여기서 더 나아가면)
- unix domain socket vs TCP loopback — 같은 머신 안 통신이라면
127.0.0.1:8080대신 유닉스 소켓을 쓸 수 있다. TCP 핸드셰이크·체크섬·라우팅을 통째로 건너뛰어 더 빠르다. “네트워크 통신”이라고 해서 반드시 네트워크 카드를 타는 건 아니다. - 서버리스에서의 변형 — Vercel의 Edge Network가 웹 서버 자리, Function이 WAS 자리다. 다만 함수는 평소에 꺼져 있다. 그래서 콜드 스타트라는 새 변수가 생기고, 인메모리 캐시·커넥션 풀 같은 “프로세스가 계속 살아있다”는 전제가 전부 깨진다.
- graceful reload와 무중단 배포 — 배포 중 502가 나는 걸 막으려면 새 WAS 프로세스를 먼저 띄우고, 헬스체크가 통과한 뒤에 옛 프로세스를 내려야 한다. 위 구조를 알면 왜 이 순서여야 하는지가 자명해진다.
↔ 같은 원리가 적용되는 곳
- WAS ↔ DB — 정확히 같은 관계다. WAS가 연결을 열므로 WAS가 클라이언트, DB가 서버다. 화살표가 그어질 때마다 역할이 새로 정해진다는 원리는 여기서도 그대로다.
- 막을 거면 차리기 전에 막아라 — Next 의 rewrite 는 곧 리버스 프록시다. 서버가 뒤에서 대신 받아오므로 브라우저 주소창은 그대로고, redirect 와는 다음 요청을 누가 보내느냐로 갈린다.
- 브라우저의 렌더러 프로세스와 네트워크 프로세스 — 같은 브라우저 앱인데 프로세스가 갈리면 남이다. 그래서 네트워크 프로세스가 받은 응답을 렌더러(JS)에게 넘겨줄지 말지를 따로 판정할 수 있다. **“같은 지붕 아래라도 프로세스가 다르면 경계가 생긴다”**는 원리가 서버 밖에서도 똑같이 작동하는 사례다.
부록: 그럼 왜 굳이 둘로 나누나
“웹 서버는 정적 파일만 줄 수 있으니까 동적 처리는 WAS에 넘긴다”고 이해하기 쉬운데, 인과가 반대다. 능력이 없어서 넘기는 게 아니라 역할을 나누려고 그렇게 배치한 것이다.
반례가 있다. Nginx + PHP-FPM 구성은 WAS 없이 웹 서버가 동적 처리를 직접 한다. Nginx에 Lua 모듈을 붙이면 로직도 돈다.
나누는 진짜 이유는 따로 있다.
- WAS 리소스 보호 — 정적 파일 I/O가 WAS의 스레드나 이벤트 루프를 잡아먹지 않게 한다
- TLS 종료 — 암복호화를 앞단에서 처리하고 뒤로는 평문으로 넘긴다
- 로드밸런싱 — WAS 인스턴스를 N개로 늘려 분산한다
- 공격면 축소 — WAS를 외부에 직접 노출하지 않는다
그런데 요즘은 다시 합쳐지는 중이다
Node·Next.js 단독 배포나 Vercel처럼, 앞단 웹 서버 없이 앱이 직접 서빙하는 구성이 흔해졌다.
그럼 502는 누가 뱉나? AWS의 ALB, Vercel의 엣지 같은 플랫폼 로드밸런서가 뱉는다. 웹 서버라는 이름의 프로세스가 사라졌을 뿐, “앞에 서서 뒤로 넘기는 존재”는 여전히 있다.
위치만 옮겼지 구조는 그대로다. 이름이 무엇이든, 502를 받았다면 “누군가가 뒤쪽에 물어봤는데 대답을 못 받았다”는 뜻이다.