배경
BullMQ로 예약 작업(delayed job)을 구현했는데, delay가 짧으면 잘 동작하고 길면 간헐적으로 실행되지 않는 현상을 겪었다. 원인을 하나씩 추적해 나간 과정을 정리한다.
아키텍처 개요
먼저 현재 아키텍처는 다음과 같이 구성되어 있다.
(VPC 외부)"] VPCC["VPC Connector"] subgraph VPC["VPC Network"] Redis["Memorystore Redis"] end CR <-->|TCP| VPCC VPCC <-->|TCP| Redis end style GCP fill:#f5f5f5,stroke:#000,stroke-width:2px style CR fill:#e3f2fd,stroke:#1565c0,stroke-width:2px style VPC fill:#e8eaf6,stroke:#3949ab,stroke-width:2px style VPCC fill:#fff3e0,stroke:#e65100,stroke-width:2px style Redis fill:#ffebee,stroke:#c62828,stroke-width:2px
각 구성 요소를 간단히 설명하면:
| 구성 요소 | 설명 |
|---|---|
| GCP (Google Cloud Platform) | Google의 클라우드 인프라. 이 안에서 서버, 데이터베이스, 네트워크 등을 관리한다. |
| Cloud Run | GCP의 서버리스 컨테이너 실행 환경. 코드를 컨테이너로 패키징하면 서버 관리 없이 HTTP 요청을 처리할 수 있다. VPC 외부에서 실행된다. |
| VPC (Virtual Private Cloud) | GCP 안에서 격리된 사설 네트워크. Memorystore Redis 같은 내부 리소스는 여기에 위치하며, 외부에서 직접 접근할 수 없다. |
| VPC Connector | VPC 외부(Cloud Run)와 VPC 내부(Redis)를 연결하는 브릿지. Cloud Run이 Redis에 접근하려면 반드시 이 Connector를 경유해야 한다. |
| Memorystore Redis | GCP에서 관리하는 Redis 인스턴스. VPC 내부에 위치하며, BullMQ의 잡 저장소로 사용한다. |
이 글에서 다루는 문제는 Cloud Run → VPC Connector → Redis 사이의 TCP 연결에서 발생한다.
BullMQ란?
BullMQ는 Node.js용 메시지 큐 라이브러리다. Redis를 백엔드로 사용하여 “나중에 실행할 작업"을 안정적으로 관리한다.
왜 필요한가?
“2시간 뒤에 이메일을 보내줘"라는 요청을 처리하는 방법을 생각해보자.
| |
핵심 구성 요소
BullMQ는 세 가지 역할로 구성된다:
이메일 보내줘'"] end subgraph Redis["Redis (저장소)"] R1["email-74dea2
delay: 2h"] end subgraph Worker["Worker (작업 실행)"] W1["handleSend
→ 이메일 발송!"] end P1 -->|등록| R1 R1 -->|가져감| W1 style Producer fill:#f5f5f5,stroke:#000,stroke-width:2px style Redis fill:#fff3e0,stroke:#000,stroke-width:2px style Worker fill:#e8f5e9,stroke:#000,stroke-width:2px
| 역할 | 설명 |
|---|---|
| Producer | 잡을 큐에 등록하는 쪽 |
| Queue | Redis에 잡을 저장하고 관리 |
| Worker | 잡을 꺼내서 실제 작업을 수행 |
Delayed Job의 동작 원리
BullMQ에서 delay 옵션을 주면, 잡은 즉시 실행되지 않고 지정된 시간이 지난 후에 실행된다. 내부적으로는 이렇게 동작한다:
score: 현재시간 + delay"] B -->|"Worker polling
ZRANGEBYSCORE"| C{"시간 도래?"} C -->|No| C C -->|Yes| D["active list 이동"] D -->|"BRPOPLPUSH"| E["Worker 실행"] style B fill:#fff3e0,stroke:#000,stroke-width:2px style C fill:#e3f2fd,stroke:#000,stroke-width:2px style E fill:#e8f5e9,stroke:#000,stroke-width:2px
Redis의 sorted set을 사용하기 때문에 시간순 정렬이 O(log N)으로 효율적이고, 여러 Worker가 있어도 한 잡을 한 Worker만 가져가도록 원자적 연산(MULTI/EXEC)을 사용한다.
현상: 긴 delay의 Job이 간헐적으로 실행되지 않는다
| delay 시간 | 결과 |
|---|---|
| 5분 | 정상 실행 |
| 46분 | 간헐적 실패 |
| 2시간 | 높은 확률로 실패 |
잡 상태를 확인하면 SCHEDULED로 남아있고, Worker의 processing 로그가 전혀 없었다. delay가 길수록 실패 확률이 높아지는 패턴이었다.
인프라 구성
(API 서버)"] -->|TCP 연결| VPC["VPC Connector"] VPC -->|TCP 연결| RD["Memorystore Redis
(VPC 내부)"] style CR fill:#e3f2fd,stroke:#000,stroke-width:2px style VPC fill:#fff3e0,stroke:#000,stroke-width:2px style RD fill:#ffebee,stroke:#000,stroke-width:2px
Cloud Run에서 Memorystore Redis에 접근하려면 VPC Connector를 경유해야 한다. Redis는 VPC 내부 리소스이기 때문이다.
첫 번째 의심: VPC Connector의 Idle TCP Timeout
가설 수립
5분 delay는 성공하고, 46분이나 2시간 delay는 실패한다. “특정 시간을 넘기면 실패한다"는 패턴이 보였다. 시간 기반 제한이 있는 무언가가 연결을 끊고 있다는 뜻이다.
Cloud Run과 Redis 사이에는 VPC Connector가 있다. GCP 문서를 확인해보니, VPC Connector에는 약 10분의 idle TCP timeout이 존재했다. 5분은 10분 미만이라 통과하고, 46분이나 2시간은 한참 넘으니 끊기는 것 — 설명이 되는 가설이었다.
(idle 10분 → 연결 끊김?)"] VPCC -->|TCP| Redis["Memorystore Redis"] style CR fill:#e3f2fd,stroke:#000,stroke-width:2px style VPCC fill:#c62828,stroke:#c62828,stroke-width:3px,color:#fff style Redis fill:#ffebee,stroke:#000,stroke-width:2px
개념 이해
GCP VPC Connector는 약 10분간 데이터 전송이 없는 TCP 연결을 조용히 끊는다. “조용히 끊는다"는 말이 정확히 무엇을 의미하는지 이해하려면, TCP 연결의 본질부터 알아야 한다.
TCP 연결은 양쪽 endpoint에만 존재한다
TCP 연결이 성립되면(3-way handshake 완료), 연결 상태는 양 끝(Worker, Redis)의 OS 커널 메모리에만 존재한다. 전선이나 네트워크 장비가 “연결"을 유지하는 게 아니다.
Redis:6379
state: ESTABLISHED"] end NET["네트워크
(상태 없음)
물리적으로는 아무것도
흐르지 않아도 양쪽 다
'연결 중'이라 생각함"] subgraph RK["Redis 커널"] R1["TCP 상태 테이블
Worker:49152
state: ESTABLISHED"] end WK ~~~ NET ~~~ RK style WK fill:#e3f2fd,stroke:#000,stroke-width:2px style NET fill:#f5f5f5,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5 style RK fill:#ffebee,stroke:#000,stroke-width:2px
데이터를 안 보내도 양쪽 다 ESTABLISHED 상태를 무한히 유지한다. TCP 프로토콜 자체에는 “일정 시간 안 쓰면 끊는다"는 규칙이 없다.
VPC Connector는 “중간자"다
문제는 VPC Connector가 NAT(Network Address Translation) 장비처럼 동작한다는 점이다. Cloud Run은 VPC 외부에 있고, Redis는 VPC 내부에 있으므로, VPC Connector가 중간에서 **연결 추적 테이블(conntrack table)**을 유지한다:
Worker:49152 → Redis:6379
last_seen: 10:19:52"] end subgraph RD["Redis (Memorystore)"] RDI[" "] end CRI -->|"TCP 1"| CT CT -->|"TCP 2"| RDI style CR fill:#e3f2fd,stroke:#000,stroke-width:2px style VPC fill:#fff3e0,stroke:#000,stroke-width:2px style RD fill:#ffebee,stroke:#000,stroke-width:2px style CRI fill:none,stroke:none style RDI fill:none,stroke:none
VPC Connector는 TCP 1(Worker→Connector)과 TCP 2(Connector→Redis)를 매핑해서, Worker의 패킷을 Redis에 전달하고 응답을 되돌려준다.
“조용히 끊는다"의 정확한 의미
VPC Connector는 conntrack table의 각 엔트리에 idle timer를 유지한다. 약 10분간 패킷이 흐르지 않으면:
conntrack table에서 엔트리 삭제"] D --> E["FIN도 RST도 보내지 않음"] style C fill:#ffebee,stroke:#c62828,stroke-width:2px style D fill:#ffebee,stroke:#c62828,stroke-width:2px style E fill:#ffcdd2,stroke:#c62828,stroke-width:2px
정상적인 TCP 종료와 비교하면 차이가 명확하다:
정상 종료 (FIN/RST):
애플리케이션이 "연결 끊김"을 감지할 수 있음
VPC Connector의 조용한 끊김:
여전히 ESTABLISHED라고 믿음 Note over R: FIN/RST 수신 없음
여전히 ESTABLISHED라고 믿음
끊긴 후 어떤 일이 벌어지나
Worker가 Redis에 명령을 보내려고 하면:
1.6s → 3.2s → 6.4s → ... → 최대 120s
(Linux tcp_retries2 = 15, 총 약 13~20분) K->>W: ETIMEDOUT (연결 끊김 알림) Note over W: 이제서야 "연결이 죽었구나" 인지
→ reconnect 시도
핵심은 애플리케이션(ioredis)과 OS 커널 사이의 역할 분담이다.
패킷을 보낸 후 ACK가 돌아오지 않으면, OS 커널의 TCP 스택이 자체적으로 재전송을 시도한다. 이 과정에서 애플리케이션에는 아무런 알림이 가지 않는다. 커널은 지수 백오프로 재전송하며, 초기 RTO(Retransmission Timeout)는 측정된 RTT 기반으로 결정된다. VPC 내부 통신은 RTT가 매우 짧으므로 Linux 최솟값인 200ms부터 시작하며, 상한은 120초다. Linux 기본 설정 tcp_retries2 = 15는 커널이 포기 시점을 계산하는 임계값으로, 총 약 13~20분 동안 재시도한다.
이 모든 재시도가 실패해야 커널이 ETIMEDOUT 에러를 애플리케이션에 전달한다. 그제서야 ioredis가 “연결이 죽었다"는 것을 알게 되고 reconnect를 시작한다.
즉 타임라인은 이렇게 된다:
| 구간 | 소요 시간 | 상태 |
|---|---|---|
| VPC Connector idle timeout | ~10분 | 연결이 조용히 끊김 |
| OS 커널 TCP 재전송 | 13~20분 | 커널이 알아서 재시도, 앱은 모름 |
| ioredis reconnect | 수 초 | 새 연결 수립 |
| 총합 | 약 23~30분 | 해당 연결을 통한 통신이 지연됨 |
잡 자체는 Redis에 안전하게 남아있으므로 reconnect 후 처리되지만, 예정 시각보다 수십 분 지연될 수 있다.
다만 BullMQ Worker는 내부적으로 여러 Redis 연결을 사용한다:
| 연결 | 용도 | idle 가능성 |
|---|---|---|
| blocking | BRPOPLPUSH로 잡 대기 (수 초 timeout) | 낮음 (주기적으로 패킷 발생) |
| subscriber | pub/sub 이벤트 대기 | 높음 (이벤트 없으면 idle) |
| client | 일반 명령 | 높음 (트래픽 없으면 idle) |
blocking 연결은 짧은 timeout으로 반복 호출되므로 idle timeout에 잘 걸리지 않는다. 하지만 subscriber나 client 연결이 끊어지면 delayed job 알림 수신이나 상태 조회에 문제가 생길 수 있다. keepAlive는 모든 연결에 적용되므로, 이런 부분적 연결 끊김도 방지한다.
대응: TCP KeepAlive 설정
TCP keepalive는 연결이 idle 상태일 때 주기적으로 **빈 ACK 패킷(probe)**을 보내서 연결이 살아있음을 알리는 메커니즘이다. VPC Connector에게 “이 연결 아직 쓰고 있어"라고 알려준다.
idle timer 리셋 → 엔트리 유지 end
ioredis(BullMQ의 Redis 클라이언트)에서 keepAlive를 활성화한다:
| |
VPC Connector의 idle timeout(~10분)보다 훨씬 짧은 간격이므로, 연결이 끊어지는 일을 방지할 수 있다.
keepAlive를 적용하고 배포한 뒤 모니터링했다. 이 수정 자체는 필요했다 — keepAlive 없이는 장시간 idle 시 TCP 연결이 조용히 끊어져 잡 실행이 수십 분 지연될 수 있다. 하지만 여전히 간헐적으로 잡이 실행되지 않았다. keepAlive만으로는 부족했다.
이걸로 해결인 줄 알았다.
두 번째 의심: Cloud Run의 cpu_idle
가설 수립
keepAlive를 넣었는데도 실패한다. keepAlive probe 자체가 보내지지 않는 건 아닐까?
Cloud Run 로그를 다시 확인해보니, 이상한 점이 보였다. HTTP 요청이 없는 시간대에 Worker의 polling 로그 자체가 없었다. min_instances=1로 설정했으니 컨테이너는 항상 떠 있을 텐데, 왜 polling을 안 하는 걸까?
“컨테이너가 살아있으면 당연히 코드도 실행되고 있는 거 아닌가?” — 이 가정이 틀렸다. Cloud Run 문서를 읽어보니 cpu_idle(CPU allocation) 설정을 발견했다. min_instances는 컨테이너를 메모리에 유지할 뿐이고, CPU 할당 여부는 cpu_idle이 별도로 제어한다는 사실을 알게 됐다.
(cpu_idle=true → CPU 회수
→ Worker frozen?)"] VPCC["VPC Connector"] Redis["Memorystore Redis"] CR -->|TCP| VPCC -->|TCP| Redis style CR fill:#c62828,stroke:#c62828,stroke-width:3px,color:#fff style VPCC fill:#fff3e0,stroke:#000,stroke-width:2px style Redis fill:#ffebee,stroke:#000,stroke-width:2px
cpu_idle이란?
Cloud Run은 원래 HTTP 요청-응답 모델을 위해 설계된 서비스다. 기본적으로 요청을 처리하는 동안만 CPU를 할당하고, 요청이 끝나면 CPU를 회수한다. 이 동작을 제어하는 설정이 cpu_idle이다.
| 설정 | 동작 |
|---|---|
cpu_idle = true (기본) | HTTP 요청을 처리하는 동안만 CPU 할당 |
cpu_idle = false | 항상 CPU 할당 |
min_instances와 cpu_idle은 다른 레이어다
“min_instances를 1로 설정했으니 컨테이너가 항상 떠 있는 거 아닌가?“라고 생각할 수 있다. 하지만 컨테이너가 존재하는 것과 CPU를 사용할 수 있는 것은 다르다.
| 설정 | 컨테이너 존재 | 프로세스 존재 | CPU 사용 가능 |
|---|---|---|---|
min_instances=0 + 요청 없음 | X | X | X |
min_instances=1 + cpu_idle=true + 요청 없음 | O | O (frozen) | X |
min_instances=1 + cpu_idle=false + 요청 없음 | O | O (active) | O |
min_instances=1은 컨테이너를 메모리에 유지해서 cold start를 방지한다. 하지만 cpu_idle=true이면 요청이 없는 동안 CPU를 회수하므로, 프로세스는 존재하지만 얼어붙은(frozen) 상태가 된다.
CPU 회수됨 → 프로세스 frozen Note over W: polling 멈춤 ❌
keepalive probe 멈춤 ❌
setTimeout 콜백 멈춤 ❌ C->>CR: HTTP 요청 Note over CR: CPU 할당 → 프로세스 깨어남 Note over W: polling 재개, keepalive 재개 CR->>C: HTTP 응답 Note over CR: CPU 회수 → 다시 frozen Note over W: 다시 모든 것 멈춤 ❌
즉 cpu_idle = true이면:
- BullMQ Worker의 polling이 멈춤
- keepalive probe도 보내지 못함 → VPC idle timeout까지 유발
- delayed job 실행 시점을 놓침
아무리 keepAlive를 설정해도 CPU가 없으면 소용이 없다. Cloud Run에서 BullMQ Worker처럼 백그라운드 프로세스를 돌린다면, 반드시 cpu_idle = false로 설정해야 한다.
비용 영향
cpu_idle = false로 바꾸면 요청이 없어도 항상 CPU가 할당되므로 비용이 증가한다. 실제로 얼마나 차이가 나는지 보자.
Cloud Run 과금 단가 (Tier 1 리전 기준):
| cpu_idle=true (요청 시만 과금) | cpu_idle=false (항상 과금) | |
|---|---|---|
| CPU | $0.000024 / vCPU-초 | $0.000018 / vCPU-초 |
| 메모리 | $0.0000025 / GiB-초 | $0.0000025 / GiB-초 |
cpu_idle=false의 vCPU 단가가 25% 저렴하지만, 항상 과금되므로 총 비용은 높아진다.
월간 비용 비교 (1 vCPU, 512MiB, 24/7 운영, 요청 처리 비율 10% 가정):
| cpu_idle=true | cpu_idle=false | |
|---|---|---|
| CPU 과금 시간 | 전체의 10% (요청 처리 중만) | 전체의 100% |
| CPU 비용 | ~$6.22 | ~$46.66 |
| 메모리 비용 | ~$0.32 | ~$3.24 |
| 월 합계 | ~$6.54 | ~$49.90 |
약 7~8배 차이가 난다. BullMQ Worker를 위해 cpu_idle = false를 설정하면 비용이 증가하므로, Worker 전용 인스턴스를 분리하거나 적절한 vCPU/메모리 스펙을 선택하는 것이 좋다.
적용 후 확인
cpu_idle=false로 변경하고 배포했다. Cloud Run 메트릭에서 요청이 없는 구간에도 CPU가 할당되는 것을 확인했고, Worker의 polling 로그도 HTTP 요청과 무관하게 지속적으로 찍히기 시작했다. 이 수정도 필요했다 — cpu_idle=true이면 Worker가 frozen되어 polling과 keepAlive 모두 동작하지 않는다. 하지만 이전과는 다른 증상이 남아있었다. 연결이 끊기거나 Worker가 멈추는 게 아니라, Worker가 잡을 가져갔는데 처리에 실패하는 패턴이었다.
이것도 수정했다. 이제는 되겠지?
그런데 여전히 안 된다
keepAlive도 넣고, cpu_idle도 껐다. 네트워크 레벨(VPC idle timeout)과 런타임 레벨(CPU throttling) 모두 해결했다. 그런데 여전히 간헐적으로 잡이 실행되지 않았다.
“인프라 문제도 아니고, 런타임 문제도 아니면 대체 뭐지?”
다시 로그를 자세히 들여다보니, 이전과는 다른 패턴이 보였다. Worker가 잡을 가져가긴 하는데, DB에서 해당 데이터를 찾지 못하고 skip하는 로그가 있었다. 잡은 실행됐지만, 자기 서비스에서 등록한 잡이 아닌 것을 가져간 것이다. 인프라가 아니라 애플리케이션 레벨의 문제였다.
진짜 원인: 큐 이름 충돌
인프라를 다시 확인해보니, 두 개의 서비스가 같은 Redis 인스턴스에서 같은 큐 이름을 사용하고 있었다.
(같은 큐 이름으로
경쟁 소비 발생)"] end CR -->|TCP| VPCC -->|TCP| Redis style CR fill:#e3f2fd,stroke:#000,stroke-width:2px style VPCC fill:#fff3e0,stroke:#000,stroke-width:2px style Redis fill:#c62828,stroke:#c62828,stroke-width:3px,color:#fff style VPC fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
이 프로젝트는 모노레포 구조로, 여러 서비스가 공통 라이브러리를 공유한다. 공통 라이브러리에 BullMQ 큐 이름이 상수로 하드코딩되어 있었고, 각 서비스가 이 라이브러리를 그대로 가져다 쓰면서 동일한 큐 이름을 사용하게 됐다. 서로 다른 서비스를 각각 다른 개발자가 작업하다 보니, Redis를 공유한다는 사실과 큐 이름이 겹친다는 점을 아무도 인지하지 못했다.
(같은 큐 이름)"] end PA -->|"add()"| Q PB -->|"add()"| Q Q -->|"BRPOPLPUSH"| WA Q -->|"BRPOPLPUSH"| WB style 서비스A fill:#e3f2fd,stroke:#000,stroke-width:2px style 서비스B fill:#e8f5e9,stroke:#000,stroke-width:2px style Redis fill:#fff3e0,stroke:#000,stroke-width:2px
BullMQ의 BRPOPLPUSH는 원자적 연산으로, 아무 Worker나 먼저 가져간다. 서비스 A가 등록한 잡을 서비스 B의 Worker가 가져가면:
각 서비스는 별도의 데이터베이스를 사용하므로, 서비스 B의 Worker가 서비스 A의 잡을 가져가면 DB에서 데이터를 찾지 못하고 skip한다. 잡은 이미 active에서 빠졌으므로 다시 실행되지 않는다.
왜 delay가 길수록 실패 확률이 높았나
delay가 길수록 잡이 Redis의 delayed set에 오래 머물고, 그 사이 다른 서비스의 Worker가 active list에서 먼저 가져갈 기회가 많아진다. 5분 delay가 성공한 건 VPC idle timeout 때문이 아니라, 짧은 시간 안에 올바른 Worker가 먼저 가져갈 확률이 높았기 때문이다.
해결: 서비스별 큐 이름 분리
큐 이름을 서비스별로 분리하면, 각 Worker는 자기 서비스의 잡만 가져간다. 경쟁 소비 문제가 원천 차단된다.
정리
BullMQ delayed job이 실행되지 않을 때, 세 가지 레이어를 모두 확인해야 한다:
| # | 함정 | 증상 | 해결 |
|---|---|---|---|
| 1 | VPC Connector idle timeout | 10분 이상 idle 시 TCP 연결이 조용히 끊김 → Worker의 Redis 통신 지연 | enableKeepAlive: true, keepAliveInitialDelay: 30000 |
| 2 | Cloud Run cpu_idle | HTTP 요청 없으면 CPU 중단 → Worker와 keepalive 모두 멈춤 | cpu_idle = false |
| 3 | 큐 이름 충돌 | 여러 서비스가 같은 큐를 공유 → 경쟁 소비로 잡 유실 | 서비스별 큐 이름 분리 |
세 가지는 각각 다른 레이어의 문제이며, 모두 수정이 필요하다. 어느 하나만 고쳐서는 해결되지 않는다:
| 수정한 것 | 빠뜨린 것 | 결과 |
|---|---|---|
| 3번(큐 이름)만 수정 | 1번, 2번 미수정 | 경쟁 소비는 해결되지만, cpu_idle로 Worker가 frozen → 잡 실행 지연 |
| 1번(keepAlive) + 2번(cpu_idle)만 수정 | 3번 미수정 | 연결과 CPU는 정상이지만, 다른 서비스가 잡을 가져감 → 잡 유실 |
| 1번(keepAlive) + 3번(큐 이름)만 수정 | 2번 미수정 | CPU가 없어 keepAlive도 polling도 멈춤 → 잡 실행 지연 |
| 1번 + 2번 + 3번 모두 수정 | 없음 | 정상 동작 |
각 문제가 일으키는 증상도 다르다:
- 1번 (네트워크): 잡이 유실되지는 않지만 수십 분 지연된다 — TCP 연결이 조용히 끊기고, 커널 재전송 타임아웃 후 reconnect
- 2번 (런타임): 잡이 유실되지는 않지만 다음 HTTP 요청이 올 때까지 무기한 지연된다 — CPU가 없어 모든 백그라운드 작업 정지
- 3번 (애플리케이션): 잡이 완전히 유실된다 — 다른 서비스가 가져가서 skip 처리하면 복구 불가
이번 케이스에서는 세 가지 문제가 동시에 존재했고, 디버깅 과정에서 하나씩 발견하여 모두 수정했다. Cloud Run + Redis + BullMQ 조합을 사용한다면, 이 세 가지를 체크리스트로 점검하는 것을 권장한다.
후속 조치: 다시 밟지 않으려면
이번 문제를 해결한 것으로 끝이 아니다. 팀에서 새로운 서비스를 만들 때 같은 실수를 반복하지 않으려면, 각 문제에 대한 예방 체계가 필요하다.
VPC Connector idle timeout → 재발 방지
Cloud Run에서 VPC 내부 리소스(Redis, PostgreSQL 등)에 장시간 TCP 연결을 맺는 서비스를 만들 때마다 동일한 문제가 발생할 수 있다.
- Cloud Run 서비스 템플릿(boilerplate)에 keepAlive 설정을 기본 포함한다 — 새 서비스를 템플릿에서 시작하면 자연스럽게 적용된다
- 팀 내부 Cloud Run + VPC 사용 가이드에 “VPC Connector 경유 시 keepAlive 필수” 항목을 명시한다
cpu_idle → 재발 방지
Cloud Run에서 백그라운드 프로세스(Worker, cron, WebSocket 등)를 돌리는 서비스를 만들 때마다 동일한 문제가 발생할 수 있다.
- 서비스 생성 체크리스트에 “HTTP 요청 외 백그라운드 작업이 있는가?” 항목을 추가한다 — Yes이면
cpu_idle=false+min_instances≥1필수 - Terragrunt 모듈에 worker 용도 프리셋을 추가한다 —
worker = true같은 플래그 하나로cpu_idle,min_instances가 자동 설정되도록
큐 이름 충돌 → 재발 방지
모노레포에서 여러 서비스가 Redis, Kafka 등 공유 인프라를 사용할 때마다 동일한 문제가 발생할 수 있다.
- 큐/토픽 이름을 하드코딩하지 않고 환경변수로 주입하는 패턴을 표준화한다 — 공유 라이브러리에서 상수 하드코딩 금지
- 네이밍 규칙을 문서화한다 —
{서비스명}-{용도}형식으로 prefix 규칙 수립 (예:scheduled-push-sleep,scheduled-push-metabolic) - 공유 인프라 리소스 목록을 팀 내부에 공유한다 — 어떤 서비스가 어떤 Redis/큐를 사용하는지 한눈에 볼 수 있는 문서를 관리한다. Kafka의 schema registry처럼, 팀 내부에서 큐/토픽 네이밍을 중앙에서 관리하는 체계가 있으면 이런 충돌을 사전에 방지할 수 있다.
부록: AWS 환경이라면?
이 글의 내용은 GCP(Cloud Run + Memorystore Redis) 환경에서의 경험이다. AWS 환경에서 같은 구성을 한다면 어떨까?
GCP vs AWS 아키텍처 비교
GCP — Cloud Run + VPC Connector + Memorystore Redis:
(VPC 외부)"] -->|TCP| VPCC["VPC Connector"] -->|TCP| Redis_GCP["Memorystore Redis
(VPC 내부)"] style CR fill:#e3f2fd,stroke:#1565c0,stroke-width:2px style VPCC fill:#fff3e0,stroke:#e65100,stroke-width:2px style Redis_GCP fill:#ffebee,stroke:#c62828,stroke-width:2px
Cloud Run은 VPC 외부에서 실행되므로 VPC Connector를 경유해야 한다.
AWS — ECS Fargate + ElastiCache Redis:
(VPC 내부)"] -->|"TCP (직접 통신)"| Redis_AWS["ElastiCache Redis
(같은 VPC)"] style Fargate fill:#e3f2fd,stroke:#1565c0,stroke-width:2px style Redis_AWS fill:#ffebee,stroke:#c62828,stroke-width:2px
ECS Fargate는 VPC 내부 서브넷에 직접 배치된다. 중간자(VPC Connector)가 없으므로 Fargate와 Redis가 같은 VPC에서 직접 통신한다.
세 가지 문제가 AWS에서도 발생하는가?
| # | 문제 | GCP (Cloud Run) | AWS (ECS Fargate) |
|---|---|---|---|
| 1 | VPC idle timeout | 발생 — VPC Connector가 10분 idle 시 연결을 끊음 | 발생 안 함 — 중간자가 없고 같은 VPC에서 직접 통신 |
| 2 | cpu_idle | 발생 — 요청 없으면 CPU 회수 | 발생 안 함 — Fargate Task는 실행 중이면 항상 CPU 할당 |
| 3 | 큐 이름 충돌 | 발생 | 동일하게 발생 — 클라우드와 무관한 애플리케이션 레벨 문제 |
AWS ECS Fargate + ElastiCache 조합에서는 1번과 2번이 구조적으로 발생하지 않는다. Fargate는 VPC 안에 있어 중간자가 필요 없고, 실행 중에는 항상 CPU가 할당된다. 3번(큐 이름 충돌)만 동일하게 주의하면 된다.
다만 AWS에서도 주의할 점
- NAT Gateway idle timeout: Fargate가 VPC 외부(인터넷)로 나갈 때 NAT Gateway를 경유하는데, 350초(약 5분 50초) idle timeout이 있다. ElastiCache처럼 같은 VPC 안의 리소스에 접근할 때는 해당 없지만, 외부 Redis(예: Redis Cloud)를 사용하면 비슷한 문제가 발생할 수 있다.
- AWS App Runner: Cloud Run과 유사한 서버리스 모델이다. App Runner도 VPC Connector를 사용하므로, GCP와 동일한 idle timeout 문제가 발생할 수 있다. 서버리스 컨테이너 서비스를 사용한다면 클라우드와 무관하게 VPC Connector 경유 여부를 확인해야 한다.