# 개요

Cloud Run으로 서비스를 돌리다가 특정 API가 느리다는 걸 발견하고 원인을 추적한 기록이다. 결론부터
말하면 원인은 쿼리가 아니라 GC였고, 메모리 한도를 올리자 사라졌다. 그 다음 "그럼 해외 리전에
배포된 게 한국 유저에게 얼마나 손해인가"를 재려다가, Cloud Run 로그의 latency 필드가 체감의
대부분을 안 보여준다
는 걸 알게 됐다.

이 글은 그 두 가지를 정리한다.

  1. 컨테이너 메모리가 지연을 만드는 경로 (힙 → GC → p95)
  2. 로그로는 못 보는 네트워크 구간을 어떻게 재는가

# 1부. 느린 API의 범인이 GC였던 건

# 증상

/feed 엔드포인트의 p95가 1,000ms 근처였다. 5만 행짜리 테이블을 랜덤 정렬하는 쿼리라서
당연히 쿼리를 의심했다. 인덱스를 걸까, 커서 방식으로 바꿀까 고민하던 참이었다.

그런데 같은 기간에 별개의 증상이 하나 더 있었다. 컨테이너가 하루 한 번꼴로 강제 종료됐다.

Memory limit of 512 MiB exceeded with 513 MiB used.

# 진짜 원인

둘은 같은 원인이었다. 컨테이너 메모리가 512Mi였고, Dockerfile은 이랬다.

ENTRYPOINT ["java", "-jar", "app.jar"]

JVM 옵션이 하나도 없다. 이 경우 JVM은 컨테이너 메모리의 25%를 최대 힙으로 잡는다.
512Mi × 25% = 힙 128MB다.

Spring Boot + Hibernate + 톰캣이 자리잡는 데 드는 고정비를 생각하면 128MB 힙은 너무 좁다.
요청이 만들고 버리는 객체(조회 결과, JSON 직렬화 버퍼)를 치우느라 GC가 쉴 새 없이 돌았고,
그게 응답 시간에 그대로 얹혔다. 그리고 힙 외 영역(메타스페이스·스레드·코드 캐시)까지 합치면
컨테이너 한도에 딱 붙어 있어서, 조금만 튀면 커널이 프로세스를 죽였다.

# 조치와 결과

메모리를 1Gi로 올린 것 하나다. 코드는 한 줄도 안 고쳤다.

지표 512Mi 1Gi
최대 힙 128MB 256MB
/feed p95 1,001ms 209ms
메모리 사용률 p50 94.8% p50 50.8%
OOM 강제종료 하루 한 번 없음

TIP

재시작이나 지연 이상이 보이면 스케일링부터 만지지 말고 OOM을 먼저 의심할 것.
"콜드스타트처럼 보이는 재시작"이 사실은 전부 OOM인 경우가 있다. 확인은 로그에서
textPayload:"Memory limit of" 한 줄이면 된다.

# 그럼 메모리는 트래픽 때문에 차는가

안 찬다. 배포 후 15시간 동안 메모리 사용률을 뽑아보니 이랬다.

933개 구간 | 최소 44.5%  중앙 50.8%  최대 52.0%

하루 트래픽 피크를 다 겪고도 7.5%포인트밖에 안 움직였다. 즉 이 앱의 메모리는 요청량이
아니라 켜두기만 해도 드는 고정비다. 프레임워크가 자리잡는 비용이고, 유저가 몇 배 늘어도
거의 그대로다.

그래서 512Mi가 터진 이유가 명확해진다. 트래픽이 몰려서가 아니라 가만히 있어도 한도 위였다.

메모리를 실제로 늘리는 건 트래픽이 아니라 한 번에 다 읽어오는 쿼리다.

// 이런 게 시한폭탄이다 — 행 수가 데이터 양에 비례한다
List<DeviceToken> all = tokenRepository.findAll();
for (DeviceToken t : all) { ... }

JPA는 조회된 행을 전부 객체로 만들고 트랜잭션이 끝날 때까지 붙들고 있다. 메모리는
가져온 행 수 × 객체 크기 × 동시 요청 수로 정해지지, 쿼리를 몇 번 쐈는지와는 무관하다.
LIMIT이 걸린 조회 100번보다 상한 없는 조회 1번이 훨씬 위험하다.

WARNING

"힙을 컨테이너의 70%로 올려서 메모리를 다 쓰자"는 흔한 다음 수순인데, 상황에 따라
오히려 위험하다. 힙 상한을 올리면 GC가 그만큼 쌓일 때까지 안 치우고 버티다가, 힙 외 영역과
합쳐 컨테이너 한도에 다시 닿을 수 있다. 힙 외 영역이 얼마나 쓰는지 먼저 보고 정할 것.

# 쿼리 비용은 청구서가 두 장이다

여기서 얻은 정리 하나. 같은 쿼리라도 비용이 두 군데로 갈린다.

  • 돌려받은 행 → 애플리케이션 힙
  • 훑고 정렬한 행 → 데이터베이스

문제의 피드 쿼리는 5만 행을 정렬해서 10행만 돌려준다. 앱 입장에선 10KB짜리 공짜 쿼리고,
DB 입장에선 5만 행 정렬이다. 그래서 이 쿼리를 튜닝하는 건 앱 메모리와 아무 상관이 없다.
두 예산을 섞어서 생각하면 엉뚱한 데를 고치게 된다.

# 2부. 로그의 latency는 서버 안쪽만이다

메모리 문제가 정리되고 나서, "서버가 해외 리전에 있는데 국내 유저에게 얼마나 손해인가"를
재보기로 했다.

# 실험

요청 하나를 쏘고, 클라이언트가 잰 시간과 그 요청의 서버 로그를 대조했다.

curl -s -o /dev/null \
  -w "connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
  "$URL/some-endpoint?probe=test123"

# 로그가 반영되길 기다린 뒤
gcloud logging read 'resource.type="cloud_run_revision" AND httpRequest.requestUrl:"probe=test123"' \
  --freshness=10m --format='value(httpRequest.latency)'

결과.

클라이언트가 실제로 겪은 시간 : 187ms
서버 로그의 latency          :  21.8ms

165ms, 체감의 88%가 로그에 안 잡힌다.

# 왜 그런가

Cloud Run 요청 로그의 httpRequest.latency서버가 요청을 받아서 응답을 내보낼 때까지
잰다. 여기에는 이런 게 안 들어간다.

  • 클라이언트에서 구글 엣지까지 가는 시간
  • TCP 핸드셰이크, TLS 협상 (왕복 2~3회)
  • 엣지에서 실제 서비스 리전까지 가는 구간
  • 응답이 돌아오는 길

WARNING

이건 Cloud Run만의 얘기가 아니다. 서버가 남기는 액세스 로그의 응답시간은 대체로 "서버 안에서
쓴 시간"이다. 서버 로그만 보고 "우리 서비스는 200ms대"라고 말하면 안 된다. 유저가 겪는
값은 그보다 훨씬 크고, 재는 위치에 따라 달라진다.

# 그래서 거리 비용은 어떻게 재는가

가장 정확한 건 실제 사용자 지역의 기기에서 재는 것이지만, 부탁할 사람이 없다면 대상 리전에
VM을 잠깐 띄우는 것
으로 하한선은 잡을 수 있다.

# 재고 싶은 지역에 최소 사양 VM 생성
gcloud compute instances create latency-probe \
  --zone=asia-northeast3-a --machine-type=e2-micro

# 거기서 우리 서비스로 요청
gcloud compute ssh latency-probe --zone=asia-northeast3-a --command='
  curl -s -o /dev/null \
    -w "connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n" \
    https://내서비스주소/endpoint'

# 다 쓰면 반드시 삭제
gcloud compute instances delete latency-probe --zone=asia-northeast3-a --quiet

TIP

VM은 구글 전용 백본을 타기 때문에 소비자 회선(통신사 인터넷·모바일)보다 빠르다.
그래서 이 값은 "최소한 이만큼은 걸린다"는 하한선이지 유저가 겪는 값이 아니다.
여기서 나온 숫자가 이미 크면 근거로 충분하지만, 작게 나왔다고 안심하면 안 된다.

# 실측 결과

서울 리전 VM에서 미국 중부 리전 서비스로 쐈다. 연결을 재사용한 상태의 값이다.

192ms  = 서버 처리 15ms + 왕복 약 178ms

요청 하나당 약 180ms가 순수하게 거리 때문에 붙는다.

# 뜻밖의 발견: TCP는 가까운 엣지에서 끊긴다

측정값 중 connect가 흥미로웠다.

connect=0.002849   ← TCP 접속 2.8ms
tls=0.053560       ← TLS 협상 완료 53ms
ttfb=0.192834      ← 첫 바이트 193ms

서울에서 미국 중부까지 TCP 왕복이 실제로 일어났다면 직선거리상 150ms 아래로 못 내려간다.
2.8ms가 나왔다는 건 TCP 연결이 목적지 리전이 아니라 근처의 구글 엣지에서 끊긴다는 뜻이다.

이게 왜 중요하냐면, 접속 설정에서 제일 비싼 부분(TCP + TLS 협상, 왕복 2~3회)을 가까운
거리로 치른다
는 뜻이기 때문이다. 태평양을 건너는 건 실제 요청/응답 한 번뿐이다.
"거리 × 왕복 3회"를 각오했는데 실제 손해는 그보다 작았다.

# 미해결로 남긴 관측

같은 연결로 10번 연속 요청했을 때 이런 패턴이 나왔다.

1.161  ← 첫 요청 (연결 수립 포함)
0.193  0.877  0.194  0.866  0.199  0.862  0.215  0.849  0.206
빠름   느림   빠름   느림   빠름   느림   빠름   느림   빠름

한 번 걸러 한 번씩 느리다. 확인한 것은 여기까지다.

  • 서버 로그는 10건 모두 12~25ms — 서버는 결백하다
  • num_connects=0연결 재수립이 아니다
  • 네 번 반복해도 같은 패턴이 나온다 — 우연이 아니다

원인은 클라우드 제공자 내부 경로에 있어서 밖에서 쓸 수 있는 수단으로는 더 못 들어간다.
그리고 더 중요한 한계가 있다 — 이건 합성 측정이다. VM 하나가 한 연결로 같은 엔드포인트를
연달아 때린 것이고, 실제 유저는 모바일 회선에서 띄엄띄엄 요청한다. 이 지연을 실제 유저가
겪는다는 근거는 아직 없다.

TIP

여기서 멈춘 이유. curl로 알아낼 수 있는 건 다 알아냈고, 더 돌려도 같은 패턴만 반복된다.
"실제 유저가 이걸 겪는가"는 앱에 계측을 넣는 것으로만 답할 수 있다. 이미 붙어 있는
분석 도구에 시작 시간 캡처 한 줄을 넣으면 진짜 기기·진짜 회선의 값이 쌓인다.
측정 수단이 답할 수 없는 질문에 매달리는 대신, 답할 수 있는 수단으로 갈아타는 게 맞다.

# 3부. 그래서 리전을 옮겼는가

안 옮겼다. 거리 비용 180ms는 실측으로 확인했지만, 정작 유저가 그걸 언제 겪는지를 보니
판단이 달라졌다.

# 지연이 어디에 붙는지가 전부다

사람이 "즉각적"이라고 느끼는 한계가 대략 100ms다. 180ms는 그 위라서 직접 조작에 대한
반응이라면 미세하게 느껴지는
구간이다. 다만 1초 아래라 흐름이 끊기지는 않는다.
"확실히 느리다"가 아니라 잘 만들면 안 보이고 못 만들면 거슬리는 크기다.

클라이언트 코드를 열어보니 두 주요 동작 모두 이미 지연을 숨기고 있었다.

목록 페이징 — 끝에서 3개 남았을 때 미리 다음 페이지를 받는다.

guard currentIndex >= feedList.count - 3 else { return }
await loadMore()

항목 하나가 10~30초짜리 콘텐츠라서, 아무리 빨리 넘겨도 몇 초의 여유가 있다.
그 안에 400ms를 끝내니 유저는 기다리는 순간 자체를 안 만난다.

좋아요 버튼 — 화면을 먼저 바꾸고 서버는 나중에 본다.

feedList[index].isHyped = target          // 즉시 반영
Task {
    do { try await api.send(...) }
    catch { feedList[index].isHyped = !target }   // 실패하면 되돌림
}

누르는 즉시 반응하고 응답을 안 기다린다.

# 남는 곳과 더 싼 해결책

실제로 기다리는 건 앱을 켤 때 하나다. 토큰 갱신 → 프로필 → 설정 → 첫 데이터가 순차로
돌면 호출마다 180ms가 각각 붙어서 4번이면 700ms가 된다.

그런데 이건 리전을 안 옮기고도 대부분 없앨 수 있다. 순차 호출을 병렬로 바꾸면
4번 × 180ms1번 × 180ms가 된다. 클라이언트 수정 몇 줄이면 되고, DB 이전도
주소 변경도 필요 없다.

# 옮기는 비용이 생각보다 크다

리전 이전을 접은 실질적인 이유는 두 가지였다.

DB도 같이 가야 한다. 서버만 옮기면 모든 쿼리가 태평양을 건넌다. 요청 하나에 쿼리가 여러 개
나가므로 지금보다 확실히 더 느려진다. 그래서 DB 이전이 세트로 따라오고 그게 작업의 실제
무게다. 참고로 Cloud Run도 Cloud SQL도 리전 변경 기능이 없다. 새 리전에 새로 만들고
데이터를 옮기는 것뿐이다.

이미 배포된 앱은 못 따라온다. 새 리전이면 주소가 새로 생기는데, 클라이언트에 주소가
박혀 있으면 이미 설치된 버전은 계속 옛 리전을 부른다. 소급이 안 된다.

WARNING

클라이언트에 리전이 박힌 주소를 그대로 쓰지 말 것. <서비스>-<해시>.<리전>.run.app
형태를 앱에 박아두면, 나중에 리전을 옮길 때 이미 배포된 버전을 전부 버려야 한다.
커스텀 도메인을 하나 두고 그걸 보게 하면 주소만 돌려서 끝난다.
지금 내보내는 빌드 하나하나가 나중의 이전 비용을 쌓는 구조다.

# 정리

  • 컨테이너 메모리가 작으면 힙이 좁아지고, 좁은 힙은 GC로 응답시간에 나타난다. 쿼리를
    의심하기 전에 메모리를 볼 것. 컨테이너에서 JVM 기본 힙은 메모리의 25%다.
  • 앱 메모리는 대체로 트래픽이 아니라 고정비다. 늘리는 건 상한 없는 조회다.
  • 서버 로그의 응답시간은 서버 안쪽만이다. 실측 예로 체감 187ms 중 로그엔 21.8ms만 남았다.
    유저 체감을 말하려면 클라이언트에서 재야 한다.
  • 거리 비용은 대상 리전에 VM을 띄워 잴 수 있다. 단 백본을 타므로 하한선이다.
  • 지연의 크기보다 어디에 붙는지가 중요하다. 미리 받기와 낙관적 UI가 있으면 180ms는
    안 보이고, 없으면 그대로 드러난다. 리전을 옮기기 전에 클라이언트가 지연을 숨기고
    있는지부터 볼 것.
  • 측정 수단이 답할 수 없는 질문이 나오면 수단을 바꿀 것. 합성 측정으로 알 수 있는 것과
    실제 유저 계측으로만 알 수 있는 것은 다르다.