브라우저에서는 둘 다 그냥 Waiting으로 보여요. 하지만 서버 안쪽에서는 남을 기다린 시간과 내가 만든 시간이 달라요.
End-to-End Request Debugging에서는 느린 요청 하나를 DNS, 연결, TLS, 프록시, 캐시, 오리진으로 나눠 읽었어요. 그리고 Server-Timing과 Request ID에서는 브라우저의 긴 Waiting을 서버가 남긴 시간 힌트와 로그 표식으로 이어 붙이는 방법을 봤죠.
근데요, request id로 오리진 로그까지 찾았는데도 이런 순간이 와요.
“앱이 오래 계산한 걸까요, 아니면 앱이 다른 서비스를 기다린 걸까요?”이 둘은 겉으로 비슷해 보여도 다음 행동이 달라요.
- 상품 API가 늦으면 그 API, 네트워크, timeout, retry를 봐야 해요.
- DB 쿼리가 늦으면 인덱스, 쿼리 계획, lock을 봐야 해요.
- 서버 사이드 렌더링이 늦으면 템플릿, 컴포넌트, 직렬화, 데이터 크기를 봐야 해요.
- CPU가 바쁘면 외부 API를 아무리 봐도 답이 안 나와요.
“이 긴 오리진 시간은 남을 기다린 시간일까요, 내가 응답을 만든 시간일까요?”
upstream은 보는 위치에 따라 뜻이 달라질 수 있어요. 프록시 기준에서는 오리진 앱이 upstream이고, 앱 기준에서는 앱이 호출하는 DB, 내부 API, 외부 API가 upstream처럼 불릴 수 있어요. 이 글에서는 앱 서버가 응답을 만들기 위해 기다리는 뒤쪽 의존성이라는 뜻으로 좁혀서 볼게요.주방이 늦은 건지, 재료가 늦게 온 건지부터 나눠요
식당에서 주문한 음식이 늦게 나왔다고 해볼게요. 손님 입장에서는 그냥 “음식이 늦다” 예요. 하지만 주방 안쪽은 다를 수 있어요.- 요리사는 바로 시작했는데, 재료 배달이 늦었을 수 있어요.
- 재료는 다 있는데, 요리 과정이 오래 걸렸을 수 있어요.
- 재료도 늦고, 요리도 복잡했을 수 있어요.
- 음식은 빨리 나왔는데, 포장이 너무 커서 전달이 오래 걸렸을 수도 있어요.
브라우저 바깥에서는 이 모든 시간이 하나의
Waiting으로 합쳐져요. 그래서 오리진 안쪽을 보려면 시간을 한 번 더 쪼개야 해요.
이 그림에서 upstream 대기와 render / compute가 오늘의 핵심이에요. 둘 다 오리진 시간 안에 들어가지만, 원인과 해결책은 꽤 달라요.
먼저 브라우저와 오리진 로그를 같은 요청으로 묶어요
가장 먼저 할 일은 request id로 같은 요청을 잡는 거예요.
이제 “서버가 느리다”에서 한 칸 더 들어갈 수 있어요. 다음 질문은 앱이 무엇에 시간을 썼는지예요.
upstream 대기가 길면 호출 기록이 길게 보여요
이번에는 앱 로그가 조금 더 자세하다고 해볼게요.inventory API 1120ms예요. 브라우저의 긴 Waiting은 앱이 HTML을 열심히 만든 시간이라기보다, 앱이 재고 서비스를 기다린 시간에 가까워요.
upstream 대기를 의심할 때는 이런 신호를 같이 봐요.
여기서 중요한 건
status=200이어도 느릴 수 있다는 점이에요. 성공 응답이 늦게 오면 사용자는 여전히 기다려요.
render가 길면 호출보다 만드는 시간이 길게 보여요
반대로 이런 로그도 있을 수 있어요.Server-Timing으로 브라우저에 힌트를 남길 수 있어요
서비스가 응답 헤더에 단계별 힌트를 남기면 브라우저에서도 방향이 빨리 잡혀요. upstream이 긴 경우는 이렇게 보일 수 있어요.app만 보지 말고 긴 조각을 찾아요.
다만 Server-Timing 글에서 본 것처럼, metric 이름은 서비스가 붙인 이름이에요.
render가 정확히 어디부터 어디까지인지, db가 병렬 쿼리를 어떻게 합친 값인지는 계측 정의를 확인해야 해요.
병렬 호출은 합계보다 벽시계 시간을 봐야 해요
upstream 호출이 여러 개 있으면 더 헷갈려요. 특히 병렬로 호출했다면 각각의 duration을 단순히 더하면 안 돼요.1380ms지만 전체 요청은 830ms예요. 두 upstream 호출이 거의 동시에 진행됐기 때문이에요.
그래서 로그를 볼 때는 세 가지를 구분해야 해요.
성능 개선도 critical path를 봐야 효과가 나요. 이미 병렬인 두 호출 중 짧은 쪽을 조금 줄이는 것보다, 가장 긴 호출이나 render 병목을 줄이는 게 더 크게 보일 수 있어요.
잘못 읽기 쉬운 함정
앱 로그 duration만 보고 render가 느리다고 보기
앱 로그의 전체 duration은 DB, 내부 API, 외부 API, queue, render가 섞인 값이에요. 세부 span 없이 “앱이 1.7초 걸렸으니 render가 느리다”고 단정하면 안 돼요.upstream이 느린데 서버 CPU부터 의심하기
외부 호출을 기다리는 동안 서버 CPU는 낮을 수 있어요. 이때 CPU profile만 보면 별일이 없어 보일 수 있어요. HTTP client timing, DB timing, dependency trace를 같이 봐야 해요.render가 느린데 외부 API timeout만 늘리기
호출은 짧고 CPU나 직렬화가 긴데 timeout을 늘리면 문제를 가릴 뿐이에요. 응답 크기, 템플릿, 컴포넌트, 객체 변환을 봐야 해요.병렬 span을 모두 더해서 전체 시간과 맞추기
병렬 작업은 시간이 겹쳐요. span 합계가 route duration보다 클 수 있어요. 합계보다 어느 작업이 critical path에 있는지를 봐야 해요.Server-Timing 이름을 표준 의미처럼 믿기
render, db, api 같은 이름은 팀이 붙인 이름이에요. 같은 이름이라도 서비스마다 측정 범위가 다를 수 있어요.
예시로 같이 읽어볼게요
1. 느린 upstream API인 경우
2. 느린 render인 경우
3. 앱은 짧고 앞단이 긴 경우
4. 다운로드가 긴 경우
자, 정리해볼까요?
-
브라우저의 긴
Waiting은 오리진 안쪽에서 다시 upstream 대기와 render/compute 시간으로 갈라질 수 있어요. - upstream 대기가 길면 DB, 내부 API, 외부 API, retry, timeout, dependency별 p95/p99를 봐요.
- render가 길면 템플릿, SSR, JSON 직렬화, 응답 크기, CPU, event loop lag를 봐요.
-
Server-Timing과 request id는 브라우저 요청을 앱 로그와 이어 붙이고, 어느 조각이 긴지 찾는 힌트예요. - 병렬 호출은 span duration을 모두 더하지 말고 critical path와 전체 route duration을 같이 봐야 해요.
-
앱 로그가 짧고 브라우저
Waiting이 길면 오리진 안쪽보다 앞단 대기를 먼저 봐야 해요.
이어서 보면 좋은 글
- Server-Timing과 Request ID는 왜 같이 봐야 할까요? — 브라우저 요청과 서버 로그를 같은 요청으로 묶는 방법을 먼저 볼 수 있어요.
- TTFB와 Content Download는 어떻게 다르게 읽을까요? — 첫 바이트 전과 뒤를 먼저 나눠 읽는 감각을 다시 볼 수 있어요.
- Connection reuse, Keep-Alive, Pooling은 왜 같이 봐야 할까요? — 앱 로그는 짧은데 브라우저
Waiting이 길 때 앞단과 오리진 사이 대기를 같이 볼 수 있어요. - 502, 503, 504는 어디서 만든 응답일까요? — upstream timeout과 앞단 오류가 상태 코드로 보이는 장면을 이어서 읽어봐요.