5분마다 실행되는 장중 손절 작업의 기록을 확인해 보니, 2주 동안 예정된 870회 중 63회가 비어 있었어요. 이 글에서는 PR #530과 PR #534의 기록을 바탕으로 작업이 실패 로그도 없이 사라진 이유와 동시성을 높인 뒤 드러난 경합을 다뤄요. 정확한 BullMQ 버전과 변경 후 운영 환경의 생성률은 기록에서 확인할 수 없어요.
실패하지 않았는데 실행 기록도 없었다
누락률은 63 ÷ 870 × 100 = 7.24%이고, 소수점 첫째 자리로 반올림하면 7.2%예요. 같은 기간 눈에 띈 시세 조회 실패는 3건이었지만 알림조차 남지 않은 미실행은 그보다 20배 많았어요. 예정된 회차 자체가 생성되지 않아 실패 로그도 원장 행도 없었어요.
문제가 생긴 큐에는 성격이 다른 cron 작업이 함께 들어 있었어요. 장중 손절은 약 2초 안에 끝났지만, PR 리뷰나 스터디 딥다이브는 LLM 호출을 포함해 한 번에 약 13분이 걸렸어요. 당시 소비자는 이 작업들에 공통 옵션을 사용했어요.
@Processor(AUTOPILOT_CRON_QUEUE, AUTOPILOT_WORKER_OPTIONS)
AUTOPILOT_WORKER_OPTIONS에는 lockDuration만 있었고 concurrency는 따로 명시하지 않았어요. PR의 분석에 따르면 이 구성에서는 워커가 한 번에 작업 하나만 처리했어요. 13분짜리 작업이 실행되는 동안 5분 주기의 장중 손절 작업은 같은 큐에서 차례를 기다려야 했죠.
시작만 늦어진 거라면 기록이 남아야 해요. 여기서 중요한 점은 반복 작업의 다음 회차가 독립적으로 계속 쌓인다고 볼 수 없다는 거예요. BullMQ 공식 문서에 따르면 Job Scheduler는 이전 작업이 처리를 시작하는 시점과 연계해 다음 작업을 생성해요. 큐가 바쁘거나 워커의 동시 처리 능력이 부족하면 작업 생성 빈도가 설정한 간격보다 낮아질 수 있어요. BullMQ Job Schedulers
관측된 상황을 시간순으로 단순화하면 이래요.
00분 긴 LLM 작업 시작
05분 장중 손절 실행 시각
10분 다음 장중 손절 실행 시각
13분 긴 작업 종료
워커가 하나뿐이면 5분에 실행돼야 할 작업이 곧바로 시작할 수 없어요. 다음 회차의 생성도 처리 시작 시점과 맞물려 있다면, 기다리는 사이 중간 회차가 아예 만들어지지 않을 수 있어요. 만들어지지 않은 작업에는 실패 이벤트도 남지 않아요.
이번 문제는 에러 로그가 아니라 예정 횟수와 원장 행 수를 맞춰 보는 과정에서 드러났어요.
예정 회차 870회
기록된 회차 807회
누락 회차 63회
누락률 7.24%
관측 기간 2주
이 수치는 작업 성공률이 아니라 발생했어야 할 회차가 실제로 존재했는지를 나타내요. 실패율과 생성 누락률은 별도의 운영 지표로 관리해야 해요.
동시성을 2로 높이자 드러난 경합
PR #530에서는 autopilot 워커의 동시 처리 수를 1에서 2로 명시했어요. 실제 변경의 핵심을 단순화하면 이래요.
const workerOptions = {
lockDuration: /* 기존 설정 */,
concurrency: 2,
};
이제 13분짜리 작업 하나가 외부 API나 LLM 응답을 기다리는 동안에도 두 번째 처리 슬롯에서 짧은 장중 손절 작업을 시작할 수 있어요. BullMQ의 로컬 동시성은 워커 인스턴스 하나가 동시에 처리할 작업 수를 정해요. 네트워크나 데이터베이스 호출처럼 I/O 대기가 많은 작업의 처리량을 높이는 데 적합하죠. BullMQ Concurrency
PR에 제시된 근거로 확인할 수 있는 것은 concurrency: 2가 대부분의 굶은 회차를 회수하기 위한 최소 변경이었다는 점까지예요. 2와 3 이상의 부하 비교나 Redis 사용량, 외부 API 제한, CPU 사용률을 측정한 결과는 남아 있지 않아요. BullMQ 문서도 CPU 집약적인 작업은 동시성을 높일수록 이벤트 루프 경합과 오버헤드 때문에 처리량이 떨어질 수 있다고 경고해요. 이 큐에는 LLM 호출처럼 대기 시간이 긴 작업과 2초 안팎의 짧은 작업이 섞여 있었어요. BullMQ Parallelism and Concurrency
모의투자에는 같은 주문 원장을 다루는 작업이 두 개 있었어요.
| 작업 | 주기 | 역할 | 체결 가격 |
|---|---|---|---|
paper-order-fill | 10분 | 대기 중인 주문 체결 | 당일 시가 |
paper-intraday-stop | 5분 | 손절 주문을 만들고 같은 회차에서 체결 | 현재가 |
원래 두 작업은 서로 다른 분에 실행되도록 배치했어요.
paper-order-fill */10 → 0, 10, 20, 30, 40, 50분
paper-intraday-stop 2-57/5 → 2, 7, 12, 17, 22, ...분
겉으로는 2분 간격을 둔 cron 설정이지만, 단일 워커에서는 이 간격이 사실상 실행 순서까지 보장했어요. 앞선 작업이 오래 걸려도 워커가 한 번에 하나만 처리했으므로 체결기와 장중 손절이 동시에 실행될 수 없었어요.
동시성이 2가 되자 큐에 들어간 시각과 실제 실행 시각의 차이가 중요해졌어요. 두 작업이 서로 다른 분에 큐에 들어왔더라도 긴 작업 뒤에서 함께 밀려 있었다면, 비어 있는 처리 슬롯 두 개를 차지해 같은 시점에 시작할 수 있어요. 2분 간격은 큐에 들어가는 시점만 벌릴 뿐 실제 실행 구간이 겹치는 것까지 막지는 못해요.
경합은 두 작업이 같은 주문을 처리할 때 생겨요.
장중 손절 체결기
│ │
├─ 손절 주문 생성 │
│ ├─ 대기 주문 조회
│ ├─ 방금 생성된 주문 선택
│ └─ 당일 시가로 체결
└─ 현재가 체결 시도
장중 손절이 주문을 만든 직후 체결기가 그 주문을 조회하면, 현재가로 체결돼야 할 주문이 당일 시가로 처리될 수 있어요. 원장에는 손절 사유가 남지만 가격은 다른 경로에서 결정돼, 의미상 모순된 기록이 생겨요. 단일 실행을 전제로 삼았던 안전장치가 동시성을 높이면서 사라진 거예요.
실행 구간을 분리한 두 번째 변경
PR #534은 코드 리뷰에서 이 위험을 발견한 뒤 paper-order-fill과 paper-intraday-stop이 서로 배타적으로 실행되도록 바꿨어요. 다른 cron 그룹은 처리 슬롯 두 개를 계속 쓰되, 같은 주문 원장을 상충하는 규칙으로 갱신하는 두 작업만 동시에 실행되지 않게 했어요.
구조를 의사 코드로 나타내면 이래요. 아래 코드는 PR의 정확한 식별자를 그대로 옮긴 diff가 아니라, 달라진 실행 관계를 설명하기 위해 축약한 표현이에요.
if (isPaperOrderFill(job) || isPaperIntradayStop(job)) {
await runExclusively(() => process(job));
return;
}
await process(job);
이 설계는 몇 분 차이로 실행되도록 예약했는가보다 어떤 작업끼리 동시에 실행하면 안 되는가를 조건으로 삼아요. 운영 지연으로 실제 실행 시각이 흔들려도 배타 조건은 유지돼요. 다만 PR 설명만으로는 이 장치가 단일 프로세스 안에서만 작동하는지, 여러 워커 인스턴스까지 아우르는 분산 잠금인지 확인할 수 없어요. 프로세스 내부 잠금이라면 워커 인스턴스를 두 개로 늘렸을 때 같은 경합이 다시 생길 수 있어요.
2분 간격을 더 벌려도, 약 13분이 걸리는 작업이 관측된 큐에서는 실행 구간이 분리된다고 보장할 수 없어요. 긴 LLM 작업과 짧은 매매 작업을 별도 큐와 전용 워커로 나누면 장애 영역을 분리할 수 있지만 큐와 배포 단위, 모니터링 대상이 늘어나요. 큐를 나눈다고 두 매매 작업 사이의 상호 배제가 자동으로 보장되는 것도 아니에요.
여러 워커 인스턴스를 운영한다면 Redis 기반 분산 잠금이나 BullMQ의 전역 동시성 제약을 검토할 수 있어요. 이때 잠금 만료 시간, 작업이 잠금보다 오래 실행됐을 때의 처리 방식, 소유권 확인, 장애 후 잠금 회수까지 설계해야 해요. 로컬 동시성과 전역 동시성은 서로 다른 제약이에요. BullMQ Global Concurrency
더 근본적인 방법은 주문 상태를 조건부로 갱신하는 거예요. 예를 들어 체결기가 PENDING 상태와 주문 유형을 함께 확인한 뒤 하나의 트랜잭션 안에서 체결 권한을 얻도록 만들 수 있어요. 구현 범위는 커지지만 중복 실행이나 워커 재시작까지 고려하면 시간표나 프로세스 잠금보다 강한 도메인 불변식을 세울 수 있어요. 이번 PR은 큐 전체의 동시성을 다시 1로 낮추는 대신 충돌하는 두 작업만 직렬화했어요.
운영 효과를 확인하려면
회고에는 스케줄 어긋남 문제가 해소됐다고 기록돼 있어요. 하지만 관련 worklog의 머지 26건에는 전체 테스트 결과와 실환경 검증 근거가 남아 있지 않아요. 동시성 변경 전후의 실제 생성률, 잘못된 가격으로 체결된 주문 수, 재시작 시 중복 처리 여부를 측정해야 운영 효과와 회귀 부재를 판단할 수 있어요.
누락 문제를 재현하려면 Redis와 BullMQ를 실행하고 같은 큐에 5분보다 오래 걸리는 작업과 5분 주기의 짧은 반복 작업을 등록해요. PR에서 관측된 조건에 맞추려면 긴 작업은 약 13분, 짧은 작업은 약 2초로 설정하면 돼요. CI에서는 각각 13초와 5초처럼 비율만 유지해 소요 시간을 줄일 수 있어요. 재현 보고서에는 사용한 Node.js, NestJS, BullMQ 버전을 고정해야 해요.
완료 건수만 봐서는 충분히 검증할 수 없어요.
예정 회차 수
생성된 job 수
처리를 시작한 job 수
완료·실패한 job 수
원장에 반영된 회차 수
이 다섯 값을 따로 봐야 생성 누락과 대기, 실행 실패, 원장 반영 실패를 구분할 수 있어요. 이번 유형의 누락을 찾으려면 예정 - 생성이 0인지 확인해야 해요.
경합 테스트에서는 두 작업을 의도적으로 같은 시점에 시작하고 손절 주문이 현재가로 정확히 한 번만 체결되는지 확인해야 해요. 두 함수가 모두 성공했다는 사실만으로는 가격과 사유가 잘못 조합되는 문제를 잡을 수 없어요. BullMQ 워커는 적어도 한 번 처리될 수 있는 실행 특성이 있으므로, 주문을 생성한 직후 프로세스를 종료하거나 잠금을 갱신하기 전에 워커를 재시작하는 조건도 시험해야 해요.
다음 실험에서는 2주와 동일한 계산 기준으로 예정 회차, 생성 회차, 원장 반영 회차를 다시 집계해요. 이어서 두 매매 작업의 동시 시작, 작업 재시도, 워커 중단과 재시작, 복수 워커 인스턴스에서도 주문 사유와 체결 가격, 체결 횟수 및 배타 조건이 유지되는지 확인해야 해요.