[PonsWarp 개념 교실 15] prepare-ahead 크레딧 — 생산자·소비자 큐
쉽게 말하면, prepare-ahead 크레딧은 암호화 워커가 미리 준비해 둘 수 있는 데이터 양을 제한하고, 소비된 만큼만 다시 만들게 하는 생산자·소비자 제어 장치다.

파일을 읽고 암호화하는 쪽은 CPU와 메모리만 충분하면 계속 앞서갈 수 있다. 반면 WebRTC 전송과 수신측 디스크 쓰기는 네트워크와 브라우저 상태에 묶여 있다. 둘의 속도가 다를 때 아무 제한 없이 앞서가면 준비된 암호문이 큐에 쌓인다.
PonsWarp는 이 문제를 "워커를 빠르게 만들자"가 아니라 워커가 앞서갈 수 있는 양을 계약으로 제한하자로 풀었다.
생산자와 소비자를 분리한다
전송 파이프라인을 나누면 역할이 명확해진다.
textFile / File.slice │ ▼ [생산자: bulk-encrypt.worker] │ 암호화된 Prepared Packet ▼ [대기 큐: queuedBytes] │ ▼ [소비자: WebRTC DataChannel] │ ▼ [수신자: decrypt worker + file writer]
생산자는 파일을 읽어 패킷을 만든다. 소비자는 그 패킷을 실제 채널로 보낸다. 소비자가 느리면 생산자도 멈춰야 한다. 이것이 생산자·소비자 모델의 기본이다.
크레딧이 없으면 생산자는 기다린다
PonsWarp의 기본 준비 한도는 12MiB다.
typescript// PonsWarp/src/utils/constants.ts export const PREPARE_AHEAD_BYTES = 12 * 1024 * 1024;
워커는 패킷 하나를 만들기 전에 암호화된 결과의 예상 크기를 계산한다.
typescript// PonsWarp/src/workers/bulk-encrypt.worker.ts const estPacket = (encryptionEnabled ? 38 + 16 : 22) + bytes; await waitForCredit(estPacket);
암호화 패킷은 평문 패킷보다 38바이트 헤더와 16바이트 인증 태그가 더 필요하다. 이 크기를 감안하지 않으면 실제 큐가 예상보다 커진다.
waitForCredit의 조건
typescriptfunction waitForCredit(needBytes: number): Promise<void> { if (queuedBytes + needBytes <= prepareAheadBytes) { return Promise.resolve(); } return new Promise(resolve => { const tryResolve = () => { if (cancelled || queuedBytes + needBytes <= prepareAheadBytes) { resolve(); return; } creditWaiters.push(tryResolve); }; creditWaiters.push(tryResolve); }); }
조건은 단순하다.
text현재 준비된 바이트 + 새 패킷 예상 크기 ≤ 준비 한도
이면 즉시 진행한다. 초과하면 Promise를 반환하고 creditWaiters에 대기 함수를 보관한다. 중요한 점은 기다리는 동안 워커가 계속 파일을 읽지 않는다는 것이다. 대기 자체가 메모리 상한을 지킨다.
소비자가 credit을 반환한다
메인 스레드가 준비된 패킷을 가져가 실제 전송을 진행하면, 소비한 바이트만큼 워커에 credit 메시지를 보낸다.
typescriptcase 'credit': queuedBytes = Math.max( 0, queuedBytes - Math.max(0, data.payload.bytes | 0) ); notifyCredit(); break;
queuedBytes를 줄인 뒤 notifyCredit()을 호출한다. 그러면 대기 중인 생산자가 다시 조건을 확인하고, 한도 안에 들어온 첫 패킷부터 준비를 재개한다.
text생산자: 12MiB까지 준비 ── 대기 ▲ 소비자: 2MiB 전송 ────────┘ credit 2MiB 반환 생산자: 다시 2MiB 준비
크레딧은 단순 카운터가 아니다
크레딧 시스템에는 세 가지 경계가 있다.
1. 워커 큐 경계
prepareAheadBytes보다 앞서 준비하지 않는다. 암호화 결과가 메모리에 쌓이는 것을 막는다.
2. WebRTC 채널 경계
워커 큐가 12MiB 이하여도 DataChannel bufferedAmount가 이미 높을 수 있다. 그래서 메인 스레드는 별도의 in-flight window를 적용한다.
3. 수신 writer 경계
송신 채널이 비어 있어도 수신측 디스크가 느릴 수 있다. receiver는 high/low watermark로 PAUSE/RESUME를 보낸다.
textWorker credit ↓ WebRTC bufferedAmount ↓ Receiver writer buffer ↓ Disk flush
prepare-ahead는 이 전체 흐름 중 첫 번째 메모리 경계다. 하나의 backpressure 장치로 모든 문제를 해결하지 않고, 각 경계에 별도의 예산을 둔 것이 핵심이다.
취소와 오류 처리
워커는 cancelled 상태를 확인한다. 대기 중인 Promise도 취소된 경우 resolve되어 더 이상 새로운 패킷을 만들지 않도록 한다. 전송 취소가 큐에 갇힌 워커를 계속 살려두지 않게 하는 장치다.
또한 prepareAheadBytes는 외부 입력값으로 설정될 수 있으므로 최소 1MiB로 보정한다.
typescriptprepareAheadBytes = Math.max( 1 * 1024 * 1024, payload.prepareAheadBytes | 0 );
0이나 음수 값이 들어오면 워커가 영원히 진행하지 못할 수 있기 때문에 최소값을 둔다.
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 기본 준비 한도 | 12MiB | constants.ts:43-44 |
| 워커 기본값 | 12MiB | bulk-encrypt.worker.ts:46 |
| 최소 준비 한도 | 1MiB | bulk-encrypt.worker.ts:136-139 |
| 크레딧 대기 | queuedBytes + needBytes <= prepareAheadBytes | bulk-encrypt.worker.ts:56-69 |
| 패킷 크기 추정 | 암호화 38B 헤더 + 16B 태그 + 데이터 | bulk-encrypt.worker.ts:206-208 |
| 큐 증가 | 준비 완료 패킷의 실제 바이트 | bulk-encrypt.worker.ts:220 |
| 크레딧 반환 | 소비 바이트 차감 후 대기자 깨움 | bulk-encrypt.worker.ts:258-263 |
| 메인 파사드 | ready/credit/next/close | bulkEncryptProducer.ts:1-190 |
숫자로 보는 크레딧의 의미
prepare-ahead를 단순히 “12MiB 큐”라고 부르면 중요한 부분을 놓친다. 이 값은 파일 전체 크기나 한 번에 읽는 블록 크기가 아니라, 아직 소비자에게 넘겨지지 않은 준비 완료 패킷의 총비용에 대한 예산이다. 예를 들어 암호화가 켜진 상태에서 데이터 128KiB를 준비하면 payload만 131,072바이트가 아니다. 패킷 헤더 38바이트와 AES-GCM 인증 태그 16바이트가 더해져 131,126바이트가 된다. 작은 차이처럼 보여도 수천 개 패킷이 쌓이면 경계 계산이 달라진다.
입력은 needBytes, 현재 상태는 queuedBytes와 prepareAheadBytes, 출력은 “지금 준비해도 된다”는 Promise 완료다. 정상 경로의 비용은 패킷 하나를 만들 때마다 카운터를 더하고, credit를 받을 때마다 빼는 O(1) 연산이다. 큐 자체를 매번 순회하거나 전체 파일을 다시 계산하지 않는 이유도 여기에 있다.
| 상태 | 생산자 행동 | 메모리 의미 |
|---|---|---|
queuedBytes + needBytes <= limit | 즉시 읽고 암호화 | 예산 안쪽 |
| 조건 초과 | creditWaiters에서 대기 | 새 버퍼를 만들지 않음 |
소비 완료 후 credit | 카운터 감소·대기자 재평가 | 빈 공간을 재사용 |
| 취소 | 대기자 깨움 후 루프 종료 | 중단된 작업이 잠들지 않음 |
그냥 패킷 개수로 제한하면 안 되나?
가능하지만 잘못된 근사다. 16KiB 패킷 100개와 192KiB 패킷 100개는 개수는 같아도 메모리 사용량이 열두 배다. 파일 조각 크기를 런타임에서 바꿀 수 있고 암호화 여부에 따라 오버헤드도 달라지는 PonsWarp에서는 개수보다 바이트가 안정적인 계약이다.
반대로 바이트 크레딧만 있으면 전송 전체가 자동으로 안전해진다고 생각해서도 안 된다. 생산자 큐가 비어도 DataChannel의 bufferedAmount가 높을 수 있고, 수신 writer가 느리면 디스크 앞에서 다시 쌓인다. 그래서 prepare-ahead는 워커와 메인 스레드 사이의 첫 번째 예산이고, in-flight 윈도우와 receiver watermark는 다음 경계의 예산이다. 각 예산을 하나로 합치면 어느 큐가 병목인지 관찰하기 어려워진다.
실제 실행 순서와 취소 경로
runStart는 새 전송마다 queuedBytes, creditWaiters, cancelled를 초기화한다. 파일 루프는 다음 조각의 크기를 계산한 뒤 예상 패킷 크기로 먼저 waitForCredit를 호출한다. 기다림이 끝났다고 해서 바로 성공한 것은 아니다. 취소가 그 사이 들어올 수 있으므로 다시 cancelled를 확인한 후 파일을 읽는다. 읽기와 암호화가 끝나면 실제 packet.byteLength를 카운터에 더하고 prepared 메시지를 메인 스레드로 보낸다.
typescript// bulk-encrypt.worker.ts의 순서 const estPacket = (encryptionEnabled ? 38 + 16 : 22) + bytes; await waitForCredit(estPacket); if (cancelled) break; const payloadBytes = await readChunk(fileIndex, fileOffset, bytes); const packet = encryptionEnabled ? await createEncryptedPacket(payloadBytes, sequence, offset) : createPlainPacket(payloadBytes, sequence, offset); queuedBytes += packet.byteLength; self.postMessage({ type: 'prepared', payload: packet }, [packet]);
여기서 예상값과 실제값이 다를 수 있으므로, 예약은 예상 크기로 하고 점유량은 실제 packet.byteLength로 기록한다. 이 차이를 무시하면 크레딧이 조금씩 틀어질 수 있다. 반대로 credit는 외부에서 받은 숫자를 그대로 빼지 않고 음수가 되지 않도록 Math.max(0, ...)로 보정한다. 제어 메시지가 중복되거나 순서가 어긋나도 카운터가 음수로 내려가지 않게 하는 방어선이다.
재구축할 때의 체크리스트
- 큐에 들어간 바이트와 이미 채널에 넘긴 바이트를 같은 숫자로 세지 않는다.
- 예약 시점에는 암호화 헤더·태그를 포함한 상한을 사용한다.
- 실제 패킷 생성 뒤에는 실제 길이로 점유량을 기록한다.
- 소비자가 보낸
credit의 단위를 payload가 아니라 계약된 packet bytes로 고정한다. - 취소 시 대기 Promise와 파일 읽기 루프를 함께 깨운다.
- 크레딧 한도, 채널 bufferedAmount, 수신 writer watermark를 별도로 관찰한다.
이 규칙을 지키면 prepare-ahead는 “더 빨리 읽는 최적화”가 아니라 속도 차이를 유한한 메모리 예산 안에 가두는 경계가 된다.
지금 돌아보는 판단
첫째, 미리 준비하는 것과 무한히 준비하는 것은 다르다. 암호화를 미리 해 두면 전송 지연을 줄일 수 있지만, 준비량을 제한하지 않으면 메모리 폭발로 이득이 사라진다. prepare-ahead는 성능 최적화에 메모리 예산을 함께 붙인 구조다.
둘째, 크레딧을 바이트 단위로 관리한 것이 중요하다. 패킷 개수로 제한하면 16KiB 패킷과 192KiB 패킷의 비용을 동일하게 취급한다. PonsWarp는 암호화 헤더와 태그까지 포함한 실제 바이트를 관리한다.
셋째, 이 장치는 단독으로 충분하지 않다. 워커 큐, DataChannel 버퍼, 수신 writer는 서로 다른 큐다. 각각 별도의 watermark와 재개 규칙이 필요하다. 이 계층을 섞지 않는 것이 재구축 시 핵심이다.
한 문장 정리
prepare-ahead 크레딧은 암호화 워커가 최대 12MiB만 앞서 준비하게 하고, 소비자가 실제로 보낸 바이트만큼 크레딧을 돌려줘 생산자·채널·수신 writer 사이의 속도 차이로 인한 메모리 폭증을 막는다.
관련 개념: [개념 14 — DataChannel] · [개념 16 — in-flight 윈도우] · [개념 19 — 백프레셔]