Place2Page에서 localization을 생성하다가 버튼이 오래 돌고 실패하는 문제가 있었다. 처음에는 번역 모델이 실패했거나 backend에서 예외가 난 것처럼 보였다. 그런데 에러 파일을 열어보니 앱이 만든 JSON 에러가 아니었다.
Cloudflare가 내려준 502: Bad gateway HTML 페이지였다.
example.page | 502: Bad gateway
Browser Working
Cloudflare Working
Host Error가볍게 풀어 쓰면 이렇다. 브라우저에서 Cloudflare까지는 도달했고, Cloudflare 자체도 요청을 처리하고 있었다. 하지만 Cloudflare가 origin 서버에서 정상적인 응답을 받지 못했다. 그래서 이 문제는 "번역 결과가 이상하다"보다 "긴 localization 요청이 proxy와 origin 사이 경계에서 끝까지 버티지 못했다"에 가까웠다.
이 글은 Computer Networking: A Top-Down Approach에서 말하는 top-down 관점처럼, application layer에서 시작해 아래 경계로 내려가며 가능성을 지운 기록이다. 다만 모든 네트워크 계층을 다 검사했다는 뜻은 아니다. 실제로는 관리 화면의 응답 형식, HTTP/proxy 경계, origin reachability, 그리고 localization workload 크기를 확인했다. link나 physical layer까지 내려갈 증거는 없어서 거기서는 멈췄다.
이번 이슈에 빌려올 네트워크 개념은 네 가지다.
- HTTP는 client-server application protocol이고 request/response가 끝나야 화면이 성공 또는 실패를 판단한다.
- end-to-end delay는 link의 전송 시간만이 아니라 end system의 처리 시간과 대기 시간도 포함한다.
- TCP의 reliable data transfer는 byte stream 전달을 안정적으로 해줄 뿐, origin application이 제한 시간 안에 response를 완성한다는 보장은 아니다.
- proxy는 browser와 origin 사이에 별도 대기 경계를 만들고, 이 경계에는 timeout 정책이 있다.
요약
- 증상: localization 생성 중 Cloudflare
502: Bad gatewayHTML이 반환됐다. - 판단: 앱 JSON 에러가 아니라 Cloudflare HTML이어서 handler 내부 예외보다 proxy와 origin 사이 경계를 먼저 봤다.
- 핵심 단서: 대상 HTML이 약
533,358자였고, 번역 segment가166개였다. - 구조적 문제: 여러 locale을 한 번의 HTTP 요청에서 한꺼번에 생성하고 있었다.
- 조치: 관리 화면에서는 locale별로 더 작은 request/response transaction을 보내도록 바꿨다.
요청 흐름을 sequence로 보면
Application layer: 먼저 앱 응답을 봤다
Top-down 접근에서는 먼저 사용자가 실제로 만지는 application과 application protocol을 본다. 이번 경우에는 관리 화면, localization 생성 버튼, HTTP response body가 출발점이었다.
관리 화면은 전형적인 client-server 구조다. 브라우저가 localization 생성 request를 보내고, backend가 response를 돌려줘야 화면이 성공 또는 실패를 판단한다. 이 모델에서는 서버 안에서 번역 작업이 계속 진행 중이어도 HTTP response가 제한 시간 안에 돌아오지 않으면 화면에서는 실패다.
문제가 발생한 화면은 root page를 기준으로 여러 locale을 한 번에 생성하는 상태였다. 버튼은 하나였고, 그 버튼은 missing locale 전체를 생성하는 흐름이었다. 한 번 클릭하면 여러 locale 전체가 생성 대상이 됐다.
여기서 바로 번역 로직을 고치지 않았다. 먼저 에러가 어느 층에서 났는지 확인했다. 첨부된 에러 파일은 Cloudflare HTML이었고, 앱 backend가 내려준 JSON response가 아니었다.
이건 application layer에서 중요한 분기다. backend가 만든 JSON 에러라면 API validation, provider exception, 저장 로직을 먼저 봤을 것이다. 하지만 body가 HTML이고 문구가 Browser Working, Cloudflare Working, Host Error였기 때문에 handler 내부 예외보다 HTTP proxy와 origin 사이 경계에 가까웠다.
HTTP/proxy boundary: 그 다음 아래로 내려갔다
다음으로 확인한 것은 "서비스 전체가 죽었는가"였다. 대표적인 health check와 관리 화면은 정상적으로 응답했다. 전체 서비스 장애라기보다는 특정 localization 생성 요청만 길어졌거나, 그 요청을 처리하는 동안 proxy 또는 origin 경계에서 실패했을 가능성이 더 컸다.
Cloudflare proxy는 교과서의 five-layer model에서 독립된 계층은 아니다. 하지만 browser-to-Cloudflare와 Cloudflare-to-origin 사이에 연결 경계를 하나 더 만든다. 그래서 이 문제에서는 "transport layer를 패킷 단위로 분석했다"기보다, HTTP proxy가 origin 응답을 기다리는 경계를 확인했다고 쓰는 편이 더 정확하다.
여기서 TCP와 HTTP timeout을 구분해야 한다. TCP는 손실되거나 순서가 바뀐 segment를 복구하는 reliable transport를 제공한다. 하지만 Cloudflare가 origin HTTP response를 얼마나 오래 기다릴지는 별개의 application/proxy 정책이다. TCP 연결이 가능하다는 사실만으로 긴 localization 작업이 브라우저 요청 안에서 끝난다는 뜻은 아니다.
network layer 아래를 깊게 파지는 않았다. DNS 전체 장애, 물리 연결 단절, host reachability 문제가 있었다면 health check와 관리 화면도 같이 흔들렸을 가능성이 컸다. 실제 증거는 "모든 요청이 안 된다"가 아니라 "특정 긴 관리 요청이 실패한다"에 가까웠다.
갑자기 발생한 이유
이 이슈가 갑자기 드러난 데에는 모델 교체가 영향을 준 것으로 봤다. 최근 생성 모델을 바꾼 뒤 landing HTML이 이전보다 훨씬 커지는 경향이 생겼다. localization은 최종 HTML을 다시 segment로 나누고 locale별로 번역한다. 그래서 page 생성 품질이나 표현력이 좋아지는 변화가, localization 단계에서는 곧 번역해야 할 입력 크기 증가로 이어졌다.
당시 확인한 workload 크기는 이랬다.
rendered_html_length: 533358
segment_count: 166
existing_localizations: 0
target_locales: multiple locales
translation_batch_size: 25166개 segment를 batch size 25로 나누면 한 locale에 7개 batch가 필요하다. 대상 locale이 여러 개이면 버튼 한 번이 여러 번의 번역 provider 호출을 포함하는 하나의 HTTP 요청이 된다. 품질 보정이나 retry가 붙으면 더 길어진다.
이때 병목은 네트워크 throughput이 아니었다. 533,358자 HTML은 크긴 하지만, 현대 웹에서 전송량만으로 설명하기 어려운 크기다. 더 큰 문제는 request 하나가 여러 번의 외부 provider round trip과 origin-side processing delay를 직렬로 기다린다는 점이었다.
전공책에서 delay를 processing delay, queuing delay, transmission delay, propagation delay로 나눠 보는 이유가 여기에 있다. 이번에는 physical link의 propagation delay나 packet transmission delay가 핵심이 아니었다. origin application이 segment를 나누고, provider 호출을 기다리고, 결과를 합치는 processing/waiting time이 HTTP response 시간을 밀어 올렸다. provider가 느리거나 backend worker가 바쁘면 queuing delay처럼 누적되는 대기도 생길 수 있다.
Cloudflare 공식 connection limits 문서에는 proxied traffic에서 origin 응답을 기다리는 Proxy Read Timeout이 120초로 정리되어 있다. Cloudflare의 502/504 문서도 502를 origin 또는 Cloudflare edge 쪽에서 발생할 수 있는 gateway 계열 실패로 설명한다. 실제 화면은 524가 아니라 502였기 때문에 "Cloudflare read timeout이 정확히 발생했다"고 단정하지는 않았다. 다만 이 구조가 public proxy 앞에서 긴 동기 작업을 한 요청에 몰아넣는 취약한 구조라는 점은 충분히 확인됐다.
실제 top-down 흐름
이번 디버깅은 에러 메시지 하나만 보고 결론을 낸 것이 아니다. 전공책식 계층 모델을 현장에 대입하면, 실제로 한 일은 이 순서에 가깝다.
- Application layer: 관리 화면 상태와 error body를 봤다. 앱 JSON이 아니라 Cloudflare HTML 502였다.
- Application protocol / HTTP boundary:
Browser Working,Cloudflare Working,Host Error문구로 browser-to-Cloudflare보다는 Cloudflare-to-origin 쪽을 의심했다. - Origin reachability: 대표 health check와 관리 화면이 정상인지 확인했다. 전체 origin 장애보다는 특정 요청 문제가 더 그럴듯했다.
- HTTP proxy / origin connection boundary: Cloudflare가 origin 응답을 기다리는 시간 제한을 확인했다. 긴 동기 요청이 위험한 구조라는 기준을 세웠다.
- Application workload: 실제 HTML 길이, segment 수, batch size, target locale 수를 계산했다.
- Delay source: 전송량보다 provider round trip과 origin processing/waiting time이 더 큰 병목이라고 판단했다.
- Request shape: missing locale 전체를 하나의 payload에 넣는 흐름을 확인했다.
- Transport vs application guarantee: TCP가 reliable하더라도 HTTP proxy timeout 안에 response가 완성된다는 보장은 없다고 정리했다.
- Fix boundary: network 설정을 늘리는 대신 browser-facing 요청을 locale별로 나눴다.
중요한 지점은 로그가 없다고 해서 추측으로 건너뛰지 않은 것이다. 해당 실패 요청의 origin log line을 직접 확보하지는 못했다. 그래서 "이 로그가 timeout을 증명했다"고 쓰지 않았다. 대신 현재 데이터와 설정으로 그 요청이 얼마나 무거웠는지 다시 계산했다.
고친 방식
문제는 관리 화면의 request shape이었다. 여러 locale을 한 번에 생성할 수 있는 backend 기능 자체가 항상 나쁜 것은 아니다. 별도 batch 작업에서는 한 번에 여러 locale을 넘기는 흐름이 편할 수 있다.
하지만 브라우저에서 Cloudflare와 reverse proxy를 지나 backend까지 가는 요청은 짧고 예측 가능해야 한다. 그래서 관리 화면에서는 여러 locale을 한 요청에 넣지 않고, locale별로 순차 처리하도록 바꿨다.
request 1 -> locale A
request 2 -> locale B
request 3 -> locale C네트워크 개념으로 말하면, 하나의 큰 request/response transaction을 여러 개의 작은 transaction으로 나눈 것이다. 전체 작업량이 갑자기 줄어든 것은 아니다. 대신 한 HTTP response가 proxy timeout 경계에 걸릴 확률을 줄였다. link bandwidth를 늘린 것이 아니라, application protocol 위에서 작업 단위를 다시 잡은 fix다.
같이 고친 부분도 있다. proxy가 HTML 에러 페이지를 내려줄 때 그 HTML을 화면에 그대로 노출하지 않고, HTTP 상태 문구로 정리해서 보여주도록 했다. 이건 root cause fix는 아니지만 다음 디버깅 품질을 올린다. 거대한 Cloudflare HTML을 그대로 보여주면 에러의 핵심을 놓치기 쉽다.
검증
검증은 두 가지를 중심으로 했다.
첫째, 여러 locale을 한 번에 담은 요청이 아니라 locale별 요청으로 나뉘는지 확인했다. 둘째, proxy가 HTML error body를 반환했을 때 화면이 raw HTML을 그대로 노출하지 않는지 확인했다.
그 뒤 lint, typecheck, frontend test, build/deploy pipeline을 통과시켰고, 배포 후 대표 health check와 관리 화면 접근도 다시 확인했다. 여기까지가 "코드상으로는 고쳤다"가 아니라 "배포 후 다시 접근 가능한 상태까지 확인했다"는 범위다.
이번에 배운 것
첫 번째, 에러 body의 형식을 먼저 봐야 한다. 502라는 status만 보면 backend 예외처럼 보일 수 있다. 하지만 body가 Cloudflare HTML이면 문제는 app handler 안쪽이 아니라 proxy와 origin 사이의 경계일 수 있다.
두 번째, 모델 교체는 생성 품질만 바꾸지 않는다. 생성된 HTML의 크기, segment 수, 후속 localization 작업량까지 같이 바꿀 수 있다.
세 번째, 긴 작업을 browser request 하나에 오래 붙잡아두면 edge timeout, reverse proxy timeout, upstream timeout 중 하나와 싸우게 된다. localization처럼 provider 호출이 여러 번 필요한 작업은 작은 단위로 쪼개거나, 더 나아가 background job과 polling으로 빼는 쪽이 맞다.
네 번째, TCP의 reliable transport와 application의 timely response는 다른 문제다. packet이 안정적으로 전달될 수 있어도, origin이 응답을 만들기 전에 proxy가 포기하면 화면에서는 실패를 본다.
다섯 번째, 로그가 사라졌을 때도 볼 수 있는 증거가 있다. 이번에는 live data의 HTML 길이, segment count, target locale 수, batch size가 그 역할을 했다. 이 값들이 없었다면 "느렸을 것 같다"에서 멈췄을 것이다.
남은 과제
이번 fix는 관리 화면 요청을 locale별로 나눠서 proxy boundary를 덜 밟게 만든 조치다. 하지만 localization 생성 자체가 무거운 작업이라는 사실은 그대로다. 한 locale의 page가 더 커지고 provider 응답이 느려지면 단일 locale 요청도 다시 길어질 수 있다.
장기적으로는 localization 생성도 background job으로 옮기고, 관리 화면은 job status를 polling하거나 stream으로 받는 구조가 더 안정적이다. 그때는 request가 "번역을 끝낼 때까지 기다리는 일"이 아니라 "작업을 등록하고 진행 상태를 보는 일"이 된다.
네트워크 관점에서도 이쪽이 더 자연스럽다. 짧은 HTTP request는 job을 만들고 바로 response를 돌려준다. 긴 처리 시간은 browser와 proxy가 붙잡고 있는 TCP/HTTP transaction 밖으로 빠진다. 그 뒤 화면은 별도의 짧은 request나 stream으로 상태만 받는다.
이번 변경은 그 전 단계다. 다시 같은 작업을 실행했을 때 여러 locale 전체를 한 HTTP 요청에 밀어 넣지 않도록 경계를 줄였다.
참고
- Cloudflare Docs: Connection limits
- Cloudflare Docs: Error 502 or 504
- James F. Kurose, Keith W. Ross, Computer Networking: A Top-Down Approach
