Cloud Run 컨커런시 1의 함정: 16초 페이지 로딩을 해결한 이야기
글 저장에 16초가 걸린다?
커뮤니티에서 게시글을 저장하는데 체감상 좀 느렸다. "원래 이랬나?" 정도였는데, Cloud Run 로그를 까보고 놀랐다.
GET /boards/free?_rsc=1hj5o → 16,401ms
16.4초. 게시글 저장 후 목록으로 돌아가는 데 16초.
백엔드 API는 멀쩡하다. PUT 요청 85ms, 목록 조회 30ms. 문제는 프론트엔드 Cloud Run 쪽이었다.
범인: containerConcurrency = 1
ff-frontend(Next.js SSR 서버)의 Cloud Run 설정이 이랬다.
┌──────────────────────────────┐ │ ff-frontend Cloud Run (변경 전) │ ├──────────────────────────────┤ │ CPU : 0.5 vCPU │ │ Memory : 512 Mi │ │ Concurrency : 1 │ │ CPU Throttle : true │ └──────────────────────────────┘
containerConcurrency: 1은 인스턴스 하나가 동시에 요청 1개만 처리한다는 뜻이다. 2개가 동시에 오면? Cloud Run이 인스턴스를 하나 더 띄운다.
이게 왜 문제가 되냐면, Next.js에는 _next/image 요청이 있다.
Next.js 이미지 최적화가 만든 병목
Next.js의 이미지 최적화(_next/image)는 꽤 무거운 작업이다. CPU 0.5 vCPU 환경에서 한 장 처리하는 데 6~9초씩 걸렸다.
이미지 최적화가 도는 동안 concurrency=1이니까 다른 모든 요청이 차단된다.
시간축 → ───────────────────────────────────────────────── 인스턴스 A (concurrency=1): [===== _next/image 최적화 (6s) =====] [RSC 응답] 인스턴스 B (cold start): [=== cold start (3~5s) ===][=== _next/image (7s) ===] 인스턴스 C (cold start): [=== cold start (4~6s) ===][== 페이지 렌더링 ==] 사용자가 보는 화면: 글 저장 완료 → ...로딩중... → ...아직?... → 16초 후 목록 표시
글을 저장하면 Next.js 클라이언트가 RSC(React Server Component) 네비게이션을 시작한다. 그런데 마침 다른 사용자의 이미지 최적화 요청이 돌고 있어서, 새 인스턴스가 뜰 때까지 대기. 그 인스턴스의 cold start까지 합치면 16초.
해결: 설정 3줄 변경
gcloud run services update ff-frontend \ --region=asia-northeast3 \ --concurrency=80 \ --cpu=1 \ --memory=1Gi
┌──────────────┬───────────────┬──────────────┐ │ 설정 │ 변경 전 │ 변경 후 │ ├──────────────┼───────────────┼──────────────┤ │ Concurrency │ 1 │ 80 │ │ CPU │ 0.5 vCPU │ 1 vCPU │ │ Memory │ 512 Mi │ 1 Gi │ └──────────────┴───────────────┴──────────────┘
concurrency=80이면 인스턴스 하나가 동시에 80개 요청을 처리할 수 있다. 이미지 최적화가 돌고 있어도, 페이지 렌더링은 같은 인스턴스에서 바로 처리된다.
CPU를 1로 올린 건, 동시 요청을 처리하려면 연산 능력이 필요해서다. 0.5 vCPU로 80개 요청을 받으면 오히려 더 느려질 수 있다.
비용은 오히려 줄었다
"CPU 2배, 메모리 2배면 비용도 2배 아닌가?"
아니다. 인스턴스 수가 줄어들기 때문이다.
변경 전 (concurrency=1)
동시 접속 5명이면 인스턴스 5개가 필요하다.
요청 5개 동시 → 인스턴스 5개 필요 인스턴스 1: [요청 A] 0.5 vCPU 인스턴스 2: [요청 B] 0.5 vCPU 인스턴스 3: [요청 C] 0.5 vCPU 인스턴스 4: [요청 D] 0.5 vCPU 인스턴스 5: [요청 E] 0.5 vCPU ──────────────────────────────── 총 CPU: 2.5 vCPU
변경 후 (concurrency=80)
인스턴스 1개면 된다.
요청 5개 동시 → 인스턴스 1개 인스턴스 1: [요청 A][요청 B][요청 C][요청 D][요청 E] 1 vCPU ──────────────────────────────── 총 CPU: 1 vCPU
Cloud Run은 cpu-throttling: true라서 요청을 처리하는 동안만 과금된다. 인스턴스 5개가 각각 CPU를 쓰는 것보다, 1개가 모아서 처리하는 게 저렴하다.
여기에 cold start 비용까지 고려해야 한다. 인스턴스가 뜨면서 Node.js를 부팅하고 Next.js 서버를 초기화하는 3~5초 동안에도 CPU 요금이 나간다. concurrency=1이면 이 cold start가 수시로 발생하는데, concurrency=80이면 이미 떠 있는 인스턴스가 처리하니까 cold start 자체가 줄어든다.
전체적으로 40~60% 정도 비용이 줄어들 것으로 보고 있다.
결과
변경 전: GET /boards/free?_rsc=... → 16,401ms 변경 후: GET /boards/free?_rsc=... → ~200ms
16초에서 0.2초. 약 80배.
이미지 로딩도 체감이 크게 달라졌다. concurrency=1일 때는 이미지 최적화 요청이 들어올 때마다 새 인스턴스가 떠야 했고, 그때마다 cold start 3~5초가 붙었다. 게시글 목록에 썸네일이 10개 있으면 인스턴스가 10개 뜨면서 각각 cold start를 겪는 상황이었다. concurrency=80으로 바꾸니까 이미지 요청들이 이미 떠 있는 인스턴스에서 바로 처리된다. cold start 없이 순수 이미지 변환 시간만 걸리니까, 6~9초씩 걸리던 이미지가 1~2초 안에 나온다.
"안전하게 1로 두자"의 함정
containerConcurrency: 1은 "한 번에 하나만 처리하니까 안전하겠지?"라는 생각에서 설정한 것이었다. 단순한 API 서버라면 큰 문제가 없을 수도 있다.
하지만 Next.js처럼 이미지 최적화 같은 무거운 작업이 섞여 있으면, concurrency=1은 모든 요청을 직렬화시켜 버린다. 가벼운 HTML 응답도 무거운 이미지 처리 뒤에 줄을 서야 한다.
"인스턴스 1개가 여러 요청을 처리하면 비싸지 않을까?" 하는 걱정이 있을 수 있는데, 인스턴스가 요청마다 하나씩 뜨는 게 훨씬 비싸다. Cloud Run에 Next.js를 올린다면 concurrency는 40~80 사이, CPU는 1 vCPU 이상으로 잡아야 이번 같은 일이 안 생긴다.

댓글
댓글을 작성하려면 이 필요합니다.