Heroku에서 상태 저장형 세션 기반 워커 티어 구축 (2015년 기준)
Source: Dev.to
2015년, 실시간이며 연산량이 많은 웹 애플리케이션을 구축하려면 일시적인 클라우드 환경의 제약을 피할 수 없었습니다. Heroku는 PaaS의 절대적인 왕이었지만, 라우터가 30초라는 엄격한 타임아웃을 강제했습니다. 사용자가 활성 세션 내에서 무거운 상태 기반 데이터 세트를 처리해야 할 때, 해당 작업을 웹 dyno에서 수행할 수 없었습니다.
해결책은 맞춤형 클라우드 네이티브 워커 티어를 도입하는 것으로, 다음과 같은 특징을 가집니다:
- 사용자 세션당 전용 프로세스를 생성합니다.
- 세션 데이터를 메모리에 유지합니다.
- Redis를 통해 웹 dyno와 비동기적으로 통신합니다.
아래는 Node.js, Socket.IO, Redis, 그리고 Heroku Platform API를 사용해 이 시스템을 설계하는 실용적인 가이드입니다.
아키텍처 개요
전통적인 백그라운드 작업 큐(예: Celery, Resque)와 달리 익명 워커가 상태 없는 작업을 가져가는 방식이 아니라, 이 설계는 사용자 세션과 워커 프로세스 사이에 1:1 매핑을 요구합니다. 사용자가 WebSocket을 통해 연결하면 시스템은 전용 워커를 할당하여 사용자‑특정 데이터셋을 메모리에 로드하고 명령을 기다립니다. 이후의 계산 및 필터링 작업은 거의 지연이 없는 상태로 수행됩니다.
환경 전략
| 환경 | 프로비저닝 메커니즘 | 예시 |
|---|---|---|
| 로컬 개발 | Node.js 자식 프로세스 | child_process.fork('worker.js') |
| Heroku 프로덕션 | Heroku 플랫폼 API를 통한 일회성 다이노 | POST /apps/{app}/dynos (예: node worker.js --session=xyz) |
추상적인 WorkerFactory는 차이점을 감추고, 애플리케이션 나머지 부분에 일관된 인터페이스를 제공합니다.
워커 프로비저닝 흐름
- Web dyno가 새로운 세션을 감지 → 인증된 HTTP 요청을 Heroku Platform API에 보내 일회성 dyno를 시작합니다.
- 개발 환경에서는, 웹 프로세스가 단순히 로컬 자식 프로세스를 포크하여 API 속도 제한을 피하고 테스트 속도를 높입니다.
Redis를 이용한 메시지 큐
Heroku dyno 간 직접 프로세스‑대‑프로세스 통신은 기본적으로 지원되지 않으므로 Redis가 브로커 역할을 합니다.
메시지 흐름
- Ingress – 클라이언트가 Socket.IO를 통해 웹 dyno에 명령을 보냅니다.
- Queue – 웹 dyno는 명령을 JSON‑RPC 2.0 페이로드로 포맷하고
session:xyz:queue와 같은 세션 전용 Redis 리스트에 푸시합니다 (LPUSH사용). - Polling – 워커 dyno가 이 리스트를 지속적으로 폴링합니다.
트랜잭션을 이용한 안전한 디큐
워커가 충돌할 경우 메시지를 잃지 않도록, 워커는 모든 대기 중인 명령을 원자적으로 읽고 큐를 비우는 Redis 트랜잭션을 사용합니다.
// worker-poll.js (Node.js)
redis.multi()
.lrange('session:xyz:queue', 0, -1) // Get all pending messages
.ltrim('session:xyz:queue', 1, 0) // Empty the list
.exec((err, results) => {
if (err) {
console.error('Redis transaction failed:', err);
return;
}
const messages = results[0];
if (messages && messages.length) {
processMessages(messages);
}
});
*processMessages*는 각 JSON‑RPC 페이로드를 파싱하고 적절한 인‑메모리 연산을 호출합니다.
인‑메모리 비트마스크 필터링
복잡하고 다면적인 필터링(예: 중복 기준이 있는 수천 개 아이템의 카탈로그)의 경우, 워커는 바이너리 마스킹을 사용합니다:
- 각 항목에 속성을 나타내는 비트마스크를 할당합니다.
- 사용자 필터를 목표 비트마스크로 변환합니다.
- 인‑메모리 배열에 비트 연산자(예:
AND)를 적용합니다.
// Example: filtering with bitmasks
const results = items.filter(item => (item.mask & filterMask) === filterMask);
V8이 비트 연산을 네이티브 속도로 실행하기 때문에, 워커는 수만 개의 레코드를 십밀리초 이하의 시간에 필터링할 수 있습니다. 결과로 얻은 ID는 Redis pub/sub 채널을 통해 웹 다이노에 다시 전달됩니다.
워커 라이프사이클 관리
One‑off dynos are billed by the second, so orphaned workers must be cleaned up promptly.
원‑오프 다이노는 초 단위로 청구되므로, 고아 워커는 즉시 정리해야 합니다.
장부 관리
-
Mapping – The web dyno stores a Redis hash:
Session_ID → Worker_Dyno_ID(or local PID).
Mapping – 웹 다이노는 Redis 해시를 저장합니다:Session_ID → Worker_Dyno_ID(또는 로컬 PID). -
Heartbeats – Each worker periodically writes a heartbeat key with a TTL (e.g.,
worker:xyz:hb).
Heartbeats – 각 워커는 주기적으로 TTL이 설정된 하트비트 키를 기록합니다 (예:worker:xyz:hb).
정리 전략
| Trigger | Action |
|---|---|
| 클라이언트 연결 해제 | 웹 다이노가 워커에게 "terminate" JSON‑RPC 명령을 보냅니다. |
| 웹 다이노 충돌 | 워커가 하트비트 누락을 감지하고 자체 유휴 시간을 모니터링한 뒤, 유휴 상태가 되면 process.exit(0)을 호출합니다. |
| 유휴 시간 초과 | 워커가 자동으로 종료되어 Heroku가 원‑오프 다이노를 종료하고 청구를 중단합니다. |
결론
다음 요소들을 결합함으로써:
- Heroku’s Platform API를 사용한 온‑디맨드 dyno 프로비저닝,
- Redis transactional polling을 통한 신뢰성 있는 메시지 전달, 그리고
- In‑memory bitwise computation을 이용한 초고속 필터링,
2015년대 Node.js 애플리케이션은 HTTP 타임아웃을 우회하고, 실시간 고부하 데이터 처리를 제공하며, 비용을 억제하면서도 막대한 성능 향상을 달성할 수 있었습니다.