☰ 목차
0. 읽는 법 · 파일 지도
각 파일은 왼쪽 = 실제 코드(줄 번호), 오른쪽 = 그 줄의 설명으로 되어 있습니다. 회색 줄은 빈 줄·닫는 괄호처럼 따로 설명할 것이 없는 줄입니다. 본문의 index.ts 41 같은 링크를 누르면 해당 줄로 이동해 깜빡입니다.
처음이라면 이 순서를 권합니다: 1장 문법을 훑고 → 3장 타임라인을 Lab B로 한 단계씩 넘기며 "코드 보기"를 눌러 따라가기 → 4장 · 5장을 처음부터 읽기.
어떤 파일이 어디서 실행되나
C 툴체인과 비교하면
| 이 프로젝트 | C로 치면 | 차이 |
|---|---|---|
src/*.ts | *.c | 기계어가 아니라 JavaScript로 바뀌어 V8 엔진에서 실행 |
worker-configuration.d.ts | 자동 생성 헤더 *.h | 선언만 있고 구현은 런타임에 있음 |
npx tsc --noEmit | gcc -fsyntax-only -Werror | 출력 파일 없이 검사만 |
tsconfig.json | CFLAGS | |
package.json + npm install | Makefile의 의존성 + 패키지 관리자 | 라이브러리가 프로젝트 안 node_modules/에 설치됨 |
| wrangler 번들 | 정적 링크 | import를 따라가 파일 하나로 합침 |
wrangler.jsonc | 링커 스크립트 + 배포 설정 | 심볼(클래스·함수)을 자원(DO·큐·버킷)에 연결 |
Dockerfile | 루트 파일시스템을 만드는 빌드 스크립트 | 결과물이 이미지 — 컨테이너의 / |
.dev.vars / Secret | execve()의 envp | Worker는 getenv() 대신 env.X로 읽음 |
바인딩 env.AUDIO 등 | 부모가 미리 열어 넘겨준 fd | 경로·키로 직접 열 수 없고, 받은 핸들만 씀 |
export default { fetch, queue } | struct file_operations | main()이 없음. 런타임이 이벤트마다 등록된 함수를 부름 |
1. C 프로그래머를 위한 TypeScript
TypeScript(TS) = JavaScript + 타입 표기. tsc가 컴파일 때 타입을 검사하고, 실행될 때는 타입이 모두 지워진 JavaScript가 돕니다. if · for · while · 연산자는 C와 거의 같으므로 생략하고, 다른 점만 모았습니다.
C에 없는 다섯 가지 — 먼저 읽기
① 이벤트 루프와 async / await
스레드는 하나이고, 블로킹 read() 같은 함수가 없습니다. 모든 I/O는 "요청만 걸고 바로 돌아오는" 방식입니다.await p = "이 함수만 여기서 잠시 빠져나가고, p가 끝나면 다음 줄부터 이어서". C로 직접 쓰면 epoll 루프 + 콜백 + 상태 저장 구조체가 필요한 코드를, 컴파일러가 상태 머신으로 대신 만들어 줍니다.async 함수는 항상 Promise(결과를 나중에 받을 수 있는 핸들, future와 같음)를 돌려줍니다.
// C라면: read 요청 → 콜백에서 다음 단계 → 또 콜백 …
const obj = await env.AUDIO.get(key) // 여기서 양보, 결과 오면 재개
const text = await obj.text() // 또 양보② 클로저
화살표 함수(x) => …는 함수 포인터와 같지만, 바깥 지역 변수를 붙잡고 다닙니다. 함수가 끝난 뒤에도 그 변수는 살아 있습니다 (C라면 void *ctx로 직접 넘겨야 하는 것).
const id = job.id
runJob(job).finally(() => active.delete(id))
// 나중에 실행돼도 id를 기억함③ GC와 참조
malloc/free가 없습니다. 더 이상 아무도 가리키지 않는 객체는 GC가 회수합니다.객체·배열을 담은 변수에는 항상 포인터가 들어 있다고 보면 됩니다. 함수에 넘겨도 복사되지 않고, 받은 쪽에서 바꾸면 원본이 바뀝니다. 복사하려면
{ ...obj }(얕은 memcpy).④ 타입은 실행 시 사라짐
sizeof, 메모리 레이아웃, 정수 크기가 없습니다. 숫자는 number(= double) 하나뿐이고 큰 정수는 bigint(≈ uint64_t).x as Job은 캐스트처럼 보이지만 아무 검사·변환도 하지 않습니다. 네트워크에서 받은 JSON이 정말 Job 모양인지는 실행 중에 직접 확인해야 합니다.⑤ 예외
오류 코드를 반환하는 대신throw로 던지면 catch가 있는 곳까지 호출 스택을 거슬러 올라갑니다(setjmp/longjmp와 비슷하지만 안전). await을 건너서도, Lab B처럼 RPC를 건너 다른 DO에서 던진 오류도 그대로 전달됩니다.finally = 언어가 보장하는 goto cleanup.번외: main()이 없다
Worker 파일은 위에서 아래로 한 번 실행되며 함수를 정의·등록할 뿐입니다. 이후에는 런타임이 요청·메시지가 올 때마다 export default에 등록된 fetch / queue를 부릅니다. 리눅스 드라이버가 file_operations를 등록하고 커널이 부르기를 기다리는 구조와 같습니다.값 · 함수
| 문법 | 뜻 | C로 치면 | 이 코드에서 |
|---|---|---|---|
const x = … / let | 재대입 불가 / 가능 | int *const p처럼 변수만 고정. 가리키는 객체의 내용은 바꿀 수 있음 | audio-sandbox.ts 6 |
'…' `…${x}…` | 문자열, 값 끼워 넣기 | 불변 문자열. ${} ≈ snprintf. ===는 strcmp처럼 내용 비교 | index.ts 47 |
{ a: 1, b } | 객체 | 필드를 실행 중에 더할 수 있는 struct(해시맵에 가까움). b만 쓰면 .b = b | audio-sandbox.ts 63 |
[a, b] · .map() · .join() | 배열과 메서드 | 길이 가변 배열. 범위 밖 읽기는 crash가 아니라 undefined | audio-sandbox.ts 9 |
(x) => x + 1 | 화살표 함수 | 함수 포인터 + 캡처(②) | server.mjs 24 |
async · await | 비동기 함수 · 결과 기다리기 | C에 없음 (①) | index.ts 48 |
Promise.all([p1, p2]) | 동시에 시작, 모두 대기 | aio_read 두 개 + aio_suspend | audio-sandbox.ts 29 |
const [a, b] = … · const { size } = … | 구조 분해 | a = r[0]; b = r[1]; / size = r.size;를 한 줄에 | audio-sandbox.ts 58 |
{ ...obj, x: 1 } | 펼치기 | 얕은 memcpy 후 필드 추가 | index.ts 49 |
a ?? b | a가 없으면 b | a != NULL ? a : b (0이나 빈 문자열은 그대로 a) | index.ts(A) 7 |
null / undefined | 값 없음 | 둘 다 NULL 비슷. undefined = "아예 대입된 적 없음" | audio-sandbox.ts 30 |
try / catch / finally · throw | 예외 처리 | setjmp/longjmp + goto cleanup (⑤) | index.ts 44–55 |
for (const m of list) | 항목 순회 | for (i = 0; i < n; i++) { m = list[i]; … } | index.ts 42 |
/^[\w-]+$/.test(s) | 정규식 | regcomp + regexec를 문법으로 내장 | index.ts(A) 68 |
모듈 · 클래스
| 문법 | 뜻 | C로 치면 | 이 코드에서 |
|---|---|---|---|
import { A } from 'x' | 가져오기 | #include + 링크. './파일'=내 소스, '@cloudflare/…'=라이브러리, 'cloudflare:workers'·'node:http'=런타임 내장(libc 자리) | audio-sandbox.ts 1–2 |
export · export default | 내보내기 · 대표 하나 | static이 아닌 외부 심볼 · 런타임이 찾는 진입 심볼 | index.ts 11 |
class A extends B | 상속 | B를 첫 멤버로 품은 struct + vtable | audio-sandbox.ts 15 |
constructor · super() | 생성 시 실행 · 부모 먼저 | init() 함수 · 부모 init 먼저 호출 | audio-sandbox.ts 19 |
this.x | 이 인스턴스의 x | 숨겨진 첫 인자 self->x | audio-sandbox.ts 22 |
private readonly x: T | 클래스 전용, 한 번만 대입 | init에서만 채우는 const 멤버 (private은 컴파일 검사만) | audio-sandbox.ts 16 |
override m() · static x | 부모 메서드 교체 · 클래스 자체의 값 | vtable 슬롯 교체 · 전역 static 변수 | index.ts(A) 28–31 |
타입 표기 (컴파일 때만 존재)
| 문법 | 뜻 | C로 치면 | 이 코드에서 |
|---|---|---|---|
x: string | x는 문자열 | const char *x. 순서만 반대(이름 뒤에 타입) | index.ts 5 |
type Job = { a: string; b?: number } | 모양에 이름 붙이기 | typedef struct. ? = 없어도 되는 필드. 레이아웃·sizeof 없음 | audio-sandbox.ts 4 |
Promise<T> · MessageBatch<Job> | 제네릭 | C++ 템플릿. void * 대신 타입이 검사되는 컨테이너 | index.ts 41 |
Record<string, unknown> | 아무 키·값 객체 | char* 키 해시맵. unknown ≈ 쓰기 전에 확인해야 하는 void * | index.ts 5 |
값 as T | T로 간주 | 캐스트처럼 보이지만 비트도 안 바뀌고 검사도 없음 (④) | index.ts(A) 18 |
값! | null 아님 보장 | assert(p != NULL)인데 실행 시 검사 없음 | audio-sandbox.ts 61 |
obj satisfies T | 규칙에 맞는지 검사 | _Static_assert류 | index.ts 58 |
import { type Job } | 타입만 가져옴 | 헤더만 #include, 코드 생성 없음 | index.ts 1 |
: 타입 표기가 없습니다. 컨테이너 안 Node가 그대로 실행합니다.2. queue()는 누가 부르나
코드 어디에도 queue(...)를 부르는 줄이 없습니다. fetch()도 마찬가지입니다. 두 함수는 Cloudflare 런타임이 대신 불러 주는 함수(핸들러)입니다. queue()가 불리려면 아래 네 가지가 모두 있어야 합니다.
③ 배포: 소비자 등록
wrangler deploy가 이 Worker를 큐의 소비자로 등록. (그래서 Worker를 지우기 전에 queues consumer remove가 필요했음)signal(SIGUSR1, handler)로 핸들러를 등록해 두면 커널이 신호가 올 때 부르는 것과 같습니다. 등록은 ①·③이 하고, "신호"는 ④의 메시지입니다. 차이는 한 번에 여러 메시지를 묶어(batch) 준다는 점입니다.부르는 규칙
| 상황 | 런타임이 하는 일 |
|---|---|
| 메시지가 쌓임 | 3개가 모이거나(max_batch_size) 첫 메시지 후 2초가 지나면(max_batch_timeout) 그 묶음으로 queue(batch, env) 한 번 호출 |
m.ack() | 그 메시지는 끝. 다시 주지 않음 |
m.retry({ delaySeconds: 10 }) | 10초 뒤 같은 메시지를 다시 줌. m.attempts가 1 → 2 → 3 → 4로 늘어남 |
ack/retry 없이 queue()가 정상 종료 | 표시하지 않은 메시지도 모두 성공으로 처리 |
queue() 밖으로 오류가 빠져나감 | 표시하지 않은 메시지 전부 재시도 (그래서 실습 코드는 메시지마다 try/catch) |
시도가 max_retries(3)를 넘음 | 첫 시도 + 재시도 3 = 4번 실패 후 dead_letter_queue로 이동. 로그: Moving message … to dead letter queue … after 4 failed attempts |
Set에 담아 중복을 막고(server.mjs 80), Lab B는 작업마다 새 DO를 만들어 서로 섞이지 않게 합니다.queue()는 컨테이너에 일을 넘기고 202만 받으면 바로 ack() — ffmpeg는 그 뒤 백그라운드에서 돕니다. Lab B의 queue()는 await sb.stitch(job)로 ffmpeg가 끝날 때까지 기다린 뒤 ack합니다. 그래서 Lab B는 실패하면 큐가 자동으로 재시도해 주지만, 작업 시간은 queue() 한 번의 실행 시간 상한(문서 기준 최대 15분) 안에 끝나야 합니다.3. 호출 타임라인
세로 = 시간 순서, 가로 = 코드가 실행되는 자리. 화살표 하나가 "누가 누구를 부르는지"입니다. 다음 ▶을 누르거나 화살표를 클릭하면 설명이 나오고, 코드 보기로 그 줄로 이동합니다. 시간 표시는 실습 로그(로컬 wrangler dev)에서 실제로 찍힌 값입니다.
배치 시뮬레이터 — 메시지를 언제 보내면 몇 번 불리나
규칙(max_batch_size: 3, max_batch_timeout: 2)만 따른 단순 모델입니다. 실제로는 네트워크 지연 등으로 경계에서 묶음이 달라질 수 있습니다 — Lab 5에서 curl 3번을 빠르게 보냈을 때 3개 한 묶음으로 올 때도, 2개 + 1개로 나뉠 때도 있었던 이유입니다.
함수별 호출 요약
| 함수 | 누가 부르나 | 언제 | 코드 |
|---|---|---|---|
fetch(req, env) | 런타임 | HTTP 요청이 올 때마다 | B index.ts 12 · A index.ts 62 |
queue(batch, env) | 런타임 | 메시지가 3개 모이거나 2초 지났을 때, 재시도 때 | B index.ts 41 · A index.ts 99 |
DO constructor | 런타임 | 그 이름의 DO가 처음 메모리에 올라올 때 (stub 메서드를 처음 부를 때) | B audio-sandbox.ts 19 |
stitch(job) | 내 queue() (RPC) | 메시지 하나마다 | B audio-sandbox.ts 27 |
startAndWaitForPorts() | 내 코드 | 컨테이너가 필요할 때 (켜져 있으면 바로 통과) | A index.ts 53 |
onStart / onStop / onError | Container 부모 클래스 | 컨테이너 시작·종료·오류 | A index.ts 29–31 |
onActivityExpired() | Container 부모 클래스 | sleepAfter(1분) 동안 들어온 요청이 없을 때 | A index.ts 35 |
onCallback(req, env) | 런타임 (outboundByHost) | 컨테이너가 http://callback.internal/…로 요청할 때 | A index.ts 5 |
createServer의 함수 | Node (컨테이너 안) | 8080으로 요청이 들어올 때마다 | A server.mjs 62 |
runJob(job) | server.mjs의 /jobs | 작업 접수 직후, 기다리지 않고 백그라운드로 | A server.mjs 82 |
process.on('SIGTERM') | 플랫폼 → OS 신호 | stop()으로 컨테이너를 끌 때 | A server.mjs 92 |
4. Lab B 파일 해설 Sandbox 1.0
프로젝트 폴더 ~/Labs/container/audio-sandbox. 읽는 순서: 설정(wrangler.jsonc) → 이미지(Dockerfile) → DO(audio-sandbox.ts) → Worker(index.ts) → 개발 도구 파일.
audio-sandbox/wrangler.jsonc배포 설정 (wrangler가 읽음)$schema = 편집기(VS Code)가 자동 완성·오류 표시에 쓰는 규칙 파일 위치. 배포에는 영향 없음.AudioSandbox DO가 컨테이너를 씁니다.scheduling_policy: "durable_object" = DO 인스턴스 1개당 컨테이너 1대를 붙이는 방식. 작업마다 새 DO를 만들면 새 컨테이너가 생깁니다. (로컬 dev에서 쓰려면 wrangler 4.136 이상)containers + durable_objects + exports는 링커 스크립트처럼 "어떤 심볼(클래스)을 어떤 자원에 붙일지"를 적는 곳입니다.ffmpeg가 코드의 c.images.ffmpeg가 됩니다. 이미지를 여러 개 두고 골라 켤 수도 있습니다.env.AUDIO_SANDBOX로 AudioSandbox 인스턴스를 얻습니다.open()해서 넘겨준 fd. 코드는 경로·키로 직접 열 수 없고, 받은 핸들(env.X)만 씁니다.migrations를 대신하는 새 방식. "AudioSandbox는 SQLite 저장소를 가진 DO"라고 선언만 하면 되고, 태그 이력을 관리할 필요가 없습니다.env.AUDIO → 버킷 audio-lab. Lab B는 이 바인딩만으로 R2를 읽고 쓰므로 R2 API 키가 필요 없습니다.env.AUDIO도 미리 열린 fd와 같습니다. 그래서 R2 접속 키가 코드 어디에도 없습니다.env.JOBS.send() → audio-jobs 큐.queue()를 불러 줍니다.queue 콜백을 불러라"라는 등록표. signal(SIGUSR1, handler)를 설정 파일로 하는 셈입니다.audio-sandbox/container/Dockerfile컨테이너 이미지FROM = 출발점 루트 파일시스템(chroot 이미지). 이후 줄은 그 위에 레이어를 쌓습니다.COPY --from=이미지는 다른 이미지 안의 파일 하나만 가져오는 문법. 코드의 Files(writeFile/readFile/stat…)가 컨테이너 안에서 이 도구를 exec로 불러 파일을 다룹니다. 태그(1.0.3)는 설치한 @cloudflare/sandbox 버전과 같아야 합니다./opt/assets/./workspace를 미리 만들어 둠.sleep infinity = 아무것도 안 하고 켜져만 있음. 실제 일은 DO가 exec()로 명령을 넣어 시킵니다.int main(void){ for(;;) pause(); } — 아무 일도 안 하고 살아만 있는 PID 1.audio-sandbox/src/audio-sandbox.tsDurable Object (Cloudflare)@cloudflare/sandbox에서 Files 클래스만 가져옴.import ≈ #include + 링크. 단 헤더/구현 분리 없이 이름(심볼) 단위로 가져옵니다.cloudflare:workers = npm이 아니라 Workers 런타임이 기본으로 주는 모듈. 그 안의 DurableObject가 내 DO의 부모 클래스.<unistd.h>처럼 런타임(libc 자리)이 제공하는 모듈. npm으로 설치하지 않습니다.export(index.ts에서도 씀). type은 TS 검사용이라 실행 파일에서는 사라집니다.typedef struct { char *draftId; … } Job; 과 비슷하지만 메모리 레이아웃·sizeof가 없습니다. 큐로 보낼 때는 구조체 바이트가 아니라 JSON 비슷한 직렬화로 전달됩니다.const = 다시 대입하지 않는 값.·
silenceremove=1:0:-50dB — 앞쪽 무음 제거·
silenceremove=stop_periods=-1:… — 중간·끝의 1초 넘는 무음 제거·
loudnorm=I=-16… — 음량을 −16 LUFS(팟캐스트 표준 근처)로 맞춤·
[1]CLEAN[c]: 질문을 정리해 이름 c·
[3]CLEAN[r]: 답변 → r·
acrossfade=d=1: 앞뒤를 1초 겹쳐 잇기 → a1 → a2 → a3 → out`…${CLEAN}…`은 문자열 안에 변수 끼워 넣기, .join(';')는 배열을 ;로 이어 한 문자열로.`[1]${CLEAN}[c]` ≈ snprintf(buf, n, "[1]%s[c]", CLEAN). 메모리는 런타임이 알아서 잡습니다.extends DurableObject<Env> → this.env, this.ctx를 물려받음. <Env> 덕분에 this.env.AUDIO가 R2Bucket이라는 걸 TS가 압니다. wrangler.jsonc의 class_name과 이름이 같아야 합니다.struct AudioSandbox { struct DurableObject base; … }처럼 부모를 첫 멤버로 품고, 메서드는 vtable로 붙는다고 보면 됩니다.private = 클래스 밖에서 못 씀, readonly = 생성자에서 한 번만 대입, : Container = 타입.const 멤버를 생성자(init)에서만 채우는 것과 같습니다.new 하지 않음). super(ctx, env)로 부모에게 먼저 넘겨야 this를 쓸 수 있습니다.init()을 부르지 않습니다. 런타임이 객체를 만들 때 콜백처럼 부릅니다. super() = 부모의 init을 먼저 호출.ctx.container는 wrangler.jsonc의 containers에 이 클래스가 연결돼 있을 때만 있습니다. 없으면 설정 실수이므로 바로 알림.Files 도우미를 얹음.stitch = Worker가 RPC로 부르는 메서드. 다른 Worker 코드에서 stub.stitch(job)처럼 일반 함수처럼 부르지만, 실제로는 네트워크 너머 DO에서 실행됩니다. Promise<{ key: string; size: number }> = "나중에 key·size를 돌려줌".sb는 client stub, 이 메서드는 서버 쪽 구현. 인자 job과 반환값은 XDR처럼 직렬화돼 오갑니다.Promise.all). const [call, resp]는 배열을 두 변수로 나눠 받기. 키가 없으면 null이 오므로 컨테이너를 켜기 전에 실패 처리 → 비용 0.aio_read() 두 개를 걸고 둘 다 끝나길 기다리는 것과 같습니다. 없으면 NULL 반환 → 검사.c로 별칭.image: wrangler의 images.ffmpeg, instance: 'lite': 가장 작은 크기, enableInternet: false: 인터넷 차단(외부와 통신할 일이 없으므로). start()는 기다리지 않고 바로 돌아옵니다.fork()만 하고 wait()하지 않는 것처럼, 시작만 요청하고 바로 돌아옵니다.destroy()가 어떤 이유로 안 불려도 과금이 계속되지 않게). 10 * 60 * 1000 = 밀리초.alarm()으로 거는 워치독 타이머.try { … } finally { … } — 중간에 오류가 나도 65줄 destroy()는 반드시 실행.goto cleanup; 패턴을 언어가 보장해 주는 형태입니다. 중간에 예외(longjmp)가 나도 finally는 실행됩니다.recursive: 상위 폴더도 함께).call.body)을 그대로 컨테이너 파일로 씀. 파일 전체를 DO 메모리에 올리지 않고 흘려보냅니다(스트리밍).malloc해서 담지 않고, splice()처럼 스트림을 컨테이너 파일로 흘려보냅니다.·
timeout --kill-after=5 1800: 1800초(30분) 넘으면 종료, 5초 뒤에도 안 끝나면 강제 종료·
-i 5개 = 입력 [0]~[4], -filter_complex = 위 FILTER, -map [out] = 최종 결과만 저장·
{ cwd: JOB_DIR } = 이 폴더에서 실행 → call.input, episode.mp3처럼 짧은 경로 사용chdir(JOB_DIR); execvp("timeout", argv); 그대로입니다. system("…")이 아니라서 셸 인용·주입 문제가 없습니다.waitpid() + stdout/stderr 파이프를 끝까지 읽어 모으기.TextDecoder로 문자열로 바꿔 함께 던짐. timeout으로 끊기면 124(SIGTERM) 또는 137(SIGKILL).WEXITSTATUS. 137 = 128 + 9(SIGKILL). TextDecoder는 UTF-8 바이트 → 문자열 변환.const { size } = 돌려받은 객체에서 size만 꺼내기. size는 bigint(아주 큰 정수용 타입).bigint ≈ uint64_t. JS의 일반 숫자(number)는 double 하나뿐이라 253을 넘는 정수는 정확하지 않습니다.Response 형태로 읽음 → file.body가 스트림.FixedLengthStream(size)(Workers 내장)을 거쳐 "길이가 정해진 스트림"으로 만듭니다. file.body!의 ! = "null 아님을 내가 보장". contentType을 붙여 브라우저가 mp3로 인식하게.write() 루프를 도는 것. body! ≈ assert(body != NULL)인데, 실행 시 검사는 하지 않습니다.Number(size)로 바꾸는 이유: index.ts가 이 값을 JSON.stringify하는데, bigint는 JSON으로 바꿀 수 없어 오류가 나기 때문.(double)size 캐스트.kill() + 자원 정리. 컨테이너 자체를 없앱니다.audio-sandbox/src/index.tsWorker (Cloudflare)type Job의 type은 "타입만 가져옴" 표시.dlsym()처럼 이 이름으로 클래스를 찾습니다.status/<draftId>.json에 이벤트 + 시각을 저장하고 로그 출력. Record<string, unknown> = "아무 키·아무 값의 객체".Record<string, unknown> ≈ char* 키의 해시맵. ...event는 그 내용을 얕게 복사해 넣기.export default = 진입점. HTTP 요청마다 fetch(req, env)가 불립니다. env에는 wrangler.jsonc의 바인딩과 Secret이 들어 있음.struct file_operations { .read = …, .write = … }와 같은 구조. 함수 포인터 묶음을 런타임에 등록하면, 이벤트(HTTP 요청·큐 메시지)마다 런타임이 해당 함수를 부릅니다.GET /status/<id>: 토큰 없이 상태 조회. slice(8) = 앞의 /status/(8글자)를 잘라낸 나머지.slice(8) ≈ path + 8 (새 문자열을 만든다는 점만 다름).!==는 포인터 비교가 아니라 strcmp()처럼 내용 비교입니다.PUT /upload/<키> (테스트용): 요청 본문을 그대로 R2에 저장. curl -T 파일이 길이(Content-Length)를 붙여 보내므로 스트림 그대로 저장 가능.POST /jobs: 본문을 Job으로 읽음. req.json<Job>()의 <Job>은 결과 타입 지정.epoll 루프)가 메시지 이벤트를 받으면 이 함수를 부릅니다.m.body = send()로 넣었던 Job 객체 그대로.getByName이 그 이름의 DO 손잡이(stub)를 줌 → 처음 보는 이름이므로 새 DO, 새 컨테이너. (Lab A는 늘 'singleton' 1대)shm_open("/job-…")처럼 이름이 같으면 같은 객체, 다르면 새 객체.stitch()가 끝날 때까지 기다림. 그 안에서 던진 오류도 여기로 그대로 넘어와 catch로 갑니다.m.ack()로 완료 표시.m.retry({ delaySeconds: 10 }). 10초 뒤 같은 메시지로 queue()가 다시 불리고, 4번째도 실패하면 DLQ로.catch = setjmp 착지점. 다른 DO에서 던진 오류도 RPC를 건너 여기로 옵니다.satisfies ExportedHandler<Env, Job> = "이 객체가 Worker 진입점 규칙(fetch·queue의 인자 타입 등)에 맞는지 검사해 줘". 실행에는 영향 없음._Static_assert처럼 컴파일 시간에만 검사하고 코드를 만들지 않습니다.audio-sandbox/package.json내 Mac (npm)private: true = npm에 실수로 공개되지 않게.npm run 이름으로 부르는 단축 명령. npm run dev = wrangler dev, npm run cf-typegen = wrangler types.npm run dev ≈ make dev. scripts는 Makefile 타깃 모음.^4 = 4.x 중 최신 허용.node_modules/에 설치됩니다.@cloudflare/sandbox의 Files가 여기서 옴.audio-sandbox/tsconfig.json (주석 줄 생략)내 Mac (tsc 검사)npx tsc --noEmit이 이 파일을 읽습니다. (실제 변환·번들은 wrangler가 따로 하므로 여기 설정은 주로 "검사"용)CFLAGS 모음.target/lib: 어떤 JavaScript 버전 기능을 쓸 수 있다고 볼지 (ES2024).module: import/export 문법 사용. moduleResolution: "Bundler": 번들러(wrangler/esbuild)가 찾는 방식으로 import 경로 해석.noEmit: 검사만 하고 .js 파일을 만들지 않음.gcc -fsyntax-only와 같습니다.isolatedModules: 파일 하나씩 변환해도 안전한 코드만 허용(그래서 타입만 가져올 때 type 표시가 필요). 나머지는 import 호환·대소문자 검사.strict: true = 엄격 모드. null일 수 있는 값을 그냥 쓰면 오류 → 코드의 !, ??, if (!call) 같은 처리가 필요해진 이유.-Wall -Wextra -Werror에 가깝습니다. 특히 NULL 가능성을 엄격히 따집니다.include: 검사 대상 = 자동 생성 타입 파일 + src의 모든 .ts. test 폴더는 제외.audio-sandbox/worker-configuration.d.ts (앞 16줄)내 Mac (편집기·tsc)npx wrangler types가 wrangler.jsonc와 .dev.vars를 읽어 만듭니다. wrangler.jsonc를 바꾸면 다시 실행하세요.extern)만 있고 구현은 런타임에 있습니다.env의 정체. 바인딩 이름 → 타입:·
AUDIO: R2Bucket (r2_buckets)·
JOBS: Queue (queues.producers)·
JOB_TOKEN: string (.dev.vars의 Secret)·
AUDIO_SANDBOX: DurableObjectNamespace<AudioSandbox> (durable_objects)그래서 코드에서
env.을 치면 자동 완성이 되고, stub.stitch의 인자·반환 타입까지 검사됩니다.struct Env { R2Bucket *AUDIO; Queue *JOBS; const char *JOB_TOKEN; … }; 선언과 같습니다.Env를 위 목록과 같게 만듭니다. 이 아래로 1만 6천 줄은 Workers 런타임 API(Request, R2Bucket, FixedLengthStream …) 타입입니다.audio-sandbox/.dev.vars내 Mac (wrangler dev)wrangler dev용 Secret. 이름=값 한 줄에 하나. 코드에서 env.JOB_TOKEN..env.production도 같은 형식으로 운영용 값을 두고
wrangler deploy --secrets-file .env.production으로 올립니다. 두 파일 모두 .gitignore에 있어 Git에 올라가지 않습니다.Lab A는 같은 방식으로
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, JOB_TOKEN, CALLBACK_SECRET 4개를 둡니다.execve()의 envp로 들어가는 값과 같은 역할. 단 코드에서는 getenv()가 아니라 env.JOB_TOKEN으로 읽습니다.5. Lab A 파일 해설 Container 클래스
프로젝트 폴더 ~/Labs/container/kentcontainer. Lab B와 달리 컨테이너 안에 HTTP 서버(server.mjs)가 있고, Worker의 DO 클래스(JobContainer)가 index.ts 안에 같이 있습니다. package.json · tsconfig.json · worker-configuration.d.ts의 역할은 Lab B와 같습니다.
kentcontainer/wrangler.jsonc (보기 쉽게 줄 정리)배포 설정 (wrangler가 읽음)//)을 넣을 수 있는 JSON. 중괄호 { } 안에 "이름": 값 쌍을 쉼표로 나열합니다. 이 파일은 wrangler가 배포할 때 읽는 설계도이고, 코드 안에서는 env라는 모습으로 나타납니다.my-job-container.<서브도메인>.workers.dev의 앞부분이 됩니다.import를 따라가 하나로 묶어(번들) 올립니다.compatibility_date: 런타임 동작 기준 날짜(이 날짜의 동작을 계속 보장). nodejs_compat: node: 모듈 일부를 Worker에서 쓸 수 있게 하는 스위치.wrangler tail과 별개로 저장됩니다.env.R2_ENDPOINT, env.R2_BUCKET으로 읽습니다. 비밀이 아닌 값만 넣습니다. (R2 키·토큰 같은 Secret은 여기 넣지 않고 .dev.vars / --secrets-file로)vars ≈ envp에 들어가는 평문 값.·
class_name: 이 컨테이너를 켜고 끌 Durable Object 클래스 이름 (index.ts의 export class JobContainer와 같아야 함)·
image: 빌드할 Dockerfile 경로·
instance_type: 크기 (lite / basic / standard-1 …)·
max_instances: 동시에 켜질 수 있는 최대 대수env.JOB_CONTAINER라는 이름으로 JobContainer 클래스의 인스턴스를 얻을 수 있게 됩니다.env.JOB_CONTAINER로 DO 핸들을 얻습니다.v1에서 JobContainer를 SQLite 저장소를 쓰는 새 클래스로 등록. 클래스를 추가·이름 변경할 때마다 태그를 하나씩 늘려 기록합니다. (Lab B는 새 방식 exports를 사용)env.JOBS.send(...)를 부르면 my-jobs 큐에 메시지가 들어갑니다.queue() 함수를 대신 불러 줍니다.·
max_batch_size: 3 — 한 번에 최대 3개씩·
max_batch_timeout: 2 — 3개가 안 모여도 2초 지나면 보냄·
max_retries: 3 — 실패 시 3번 더 (총 4번)·
dead_letter_queue — 그래도 실패하면 옮겨 둘 큐(DLQ)queue 콜백 등록표. signal() 등록을 설정 파일로 하는 셈.queues 블록 끝.env.JOB_BUCKET.get/put으로 키 없이 바로 읽고 씁니다. (Worker 쪽 전용. 컨테이너 안에서는 이 바인딩을 못 쓰므로 Lab A는 S3 키를 따로 넘김)kentcontainer/container/Dockerfile컨테이너 이미지apt-get으로 ffmpeg와 HTTPS 인증서 묶음 설치 → 설치 목록 캐시 삭제(이미지 크기 줄이기). \는 "다음 줄에 이어짐", &&는 "앞이 성공하면 다음 실행"./app으로.--omit=dev: 개발용 제외). 코드만 바뀌면 이 단계는 캐시를 재사용해 빌드가 빨라집니다.defaultPort = 8080과 맞춥니다.setuid()로 root 권한을 버리고 실행하는 것과 같습니다.node server.mjs → HTTP 서버가 8080에서 대기.kentcontainer/container/package.json컨테이너 안 Node·
"type": "module" — import 문법 사용·
@aws-sdk/client-s3 — R2를 S3 방식으로 다루는 라이브러리 (컨테이너에는 R2 바인딩이 없어서 필요)kentcontainer/container/server.mjs컨테이너 안 (Node HTTP 서버)node:http(서버), child_process(ffmpeg 실행), util.promisify(콜백 함수를 await 가능하게), crypto.createHmac(서명), fs/promises(파일 읽기·쓰기·삭제).node: 모듈 = libc 헤더 자리. http는 소켓 서버, child_process는 fork/exec, fs는 파일 I/O.execFile은 원래 "끝나면 콜백 호출" 방식. promisify로 감싸 await run(...)처럼 쓸 수 있게 만듭니다.process.env = 컨테이너 환경 변수 (Worker가 startAndWaitForPorts의 envVars로 넣어 줌).·
region: "auto", endpoint: R2 전용 주소·
forcePathStyle: 주소를 /버킷/키 형태로·
...Checksum...: WHEN_REQUIRED: 최신 SDK의 기본 체크섬 헤더를 꺼서 R2와 호환·
?? '': 값이 없으면 빈 문자열static 객체.{ Bucket }처럼 같은 이름의 변수를 그대로 객체에 넣을 때 씁니다.Set = 중복 없는 모음. 지금 처리 중인 작업 id를 담아 같은 작업이 두 번 돌지 않게 하고, 개수(active.size)는 Worker의 onActivityExpired가 물어봅니다. draining은 종료 신호를 받았는지 표시.draining은 시그널 핸들러가 세우는 volatile sig_atomic_t 플래그와 같은 용도.data 이벤트마다 이어 붙이고, end에서 완성본을 돌려줍니다. new Promise((ok, ng) => …)는 "나중에 ok(값) 또는 ng(오류)로 끝나는 약속"을 직접 만드는 문법.recv() 루프로 버퍼에 이어 붙이다가 EOF에서 반환. 단 블로킹 없이 이벤트(data, end)로 옵니다.① 보낼 본문을 JSON 문자열로 (
...event: event의 속성을 펼쳐 넣기)② 현재 시각(ms)을 문자열로
③
"시각.본문"을 CALLBACK_SECRET으로 HMAC-SHA256 → 16진수 서명④
fetch로 POST, 시각·서명을 헤더에 담음받는 쪽(index.ts의
onCallback)이 같은 계산을 해서 맞는지 확인합니다. job.callbackUrl은 http://callback.internal/events — 실제 인터넷 주소가 아니라 Worker가 가로채는 가짜 호스트입니다.HMAC(EVP_sha256(), key, …) 후 hex 인코딩.delaySec초 일부러 대기. new Promise(r => setTimeout(r, ms))는 "ms 뒤에 끝나는 약속" = sleep.nanosleep()처럼 보이지만 스레드를 막지 않습니다. 그동안 다른 요청을 처리합니다./tmp에 저장.await.fork() + execvp("ffmpeg", argv) + waitpid().out/<id>.mp3로 업로드..catch(() => {})).finally = 성공·실패와 상관없이 마지막에 항상 실행. 임시 파일 삭제 (force: 없어도 오류 안 냄).unlink() 정리를 cleanup 레이블에서 하는 것과 같습니다.(req, res)로 불립니다. 바깥 try는 예상 못 한 오류를 500으로 돌려주기 위함(87줄).accept() 루프를 직접 쓰지 않습니다. 런타임이 epoll로 연결을 다중화하고, 요청마다 이 함수를 콜백으로 부릅니다./health: 살아 있는지 확인용. 토큰 없이 OK./version: ffmpeg -version의 첫 줄을 돌려줌 (이미지에 ffmpeg가 있는지 확인)./internal/status: 처리 중인 작업 수. Worker의 onActivityExpired()가 컨테이너를 끌지 말지 판단할 때 부릅니다.Authorization: Bearer <JOB_TOKEN>이 아니면 401./r2test (Lab 2): R2 쓰기 → 목록 읽기로 키가 제대로 동작하는지 확인. ?? []: 결과가 없으면 빈 배열, .map(o => o.Key): 각 항목에서 키만 뽑기.POST /jobs: 작업 접수. 종료 중이면 503 → Worker의 queue()가 실패로 보고 재시도합니다.runJob(job)을 await 없이 시작(백그라운드) → 끝나면 id 제거(.finally) → 곧바로 202 응답. 그래서 Worker는 ffmpeg를 기다리지 않습니다.pthread_create() 후 detach처럼 보이지만 스레드가 아닙니다. 같은 이벤트 루프에 작업을 걸어 두고 바로 돌아오며, ffmpeg를 기다리는 동안에도 이 스레드는 다른 요청을 받습니다.0.0.0.0:8080에서 대기. 127.0.0.1로 열면 컨테이너 밖(Worker)에서 접속할 수 없습니다.bind(fd, INADDR_ANY:8080) + listen().draining = true) → 처리 중 작업이 0이 될 때까지 0.5초마다 확인 → 정상 종료(exit(0)). 이 처리가 없으면 node(PID 1)는 SIGTERM을 무시해 15분 동안 켜져 있게 됩니다.sigaction(SIGTERM, …). 리눅스 커널은 PID 1에 핸들러가 없는 시그널을 전달하지 않으므로, 핸들러를 직접 달아야 SIGTERM으로 끝낼 수 있습니다.kentcontainer/src/index.tsWorker + Durable Object (Cloudflare)@cloudflare/containers 패키지에서 Container 클래스(컨테이너를 관리하는 DO의 부모)와 getContainer(DO 손잡이 얻기) 가져오기.ContainerProxy를 다시 내보냄. 아래 outboundByHost(컨테이너가 보내는 요청 가로채기)가 동작하려면 Worker가 이 클래스를 export하고 있어야 합니다.http://callback.internal/...로 보낸 요청이 인터넷으로 나가지 않고 이 함수로 들어옵니다. (req: Request, env: Env): Promise<Response> = "Request와 Env를 받아, 나중에 Response를 돌려주는 함수"라는 타입 표기.typedef Response (*handler_t)(Request, Env); 같은 함수 시그니처. 반환이 Promise라 비동기 함수입니다.?? ''로 빈 문자열.5 * 60_000: _는 숫자 구분용(=60000)./../g는 "두 글자씩 전부" 잘라내는 정규식, parseInt(h, 16)은 16진수 → 숫자.sscanf(sig + 2*i, "%2hhx", &bytes[i])를 정규식 한 줄로."시각.본문"으로 서명이 맞는지 검증. 틀리면 401.status/<id>.json에 저장 (...event에 시각 추가). as { id: string; type: string }은 "이 모양이라고 믿고 쓰겠다"는 TS 표기(실행 시 검사는 안 함).Container<Env>를 상속하면 시작·종료·포트 대기·요청 전달을 부모가 다 해 줍니다. wrangler.jsonc의 class_name: "JobContainer"와 이름이 같아야 연결됩니다.<Env>는 C++ 템플릿 인자처럼 this.env의 타입을 지정합니다.defaultPort: 요청을 전달할 컨테이너 포트. sleepAfter: 이 시간 동안 들어오는 요청이 없으면 onActivityExpired()를 부름.static = 인스턴스가 아니라 클래스에 붙는 값. "컨테이너가 callback.internal 호스트로 보내는 요청은 onCallback이 처리"라는 규칙표.static 테이블.override = "부모의 같은 이름 메서드를 바꿔 쓴다"는 표시.stop()인데, 백그라운드 작업이 남아 있을 수 있으므로 직접 판단합니다.containerFetch로 컨테이너의 /internal/status를 물어 처리 중 작업 수를 받음. 주소의 container는 아무 이름이나 됩니다(어차피 이 컨테이너로 감).renewActivityTimeout) 끝. 1분 뒤 또 불립니다.stop() → 컨테이너에 SIGTERM → 과금 종료. 이 호출을 빼먹으면 계속 켜져 있게 됩니다.type으로 정의. delaySec?의 ?는 "없어도 됨".getContainer(바인딩, 이름) = 이름에 해당하는 DO 손잡이(stub). 이름이 항상 'singleton'이므로 모든 요청이 같은 DO·같은 컨테이너 1대로 갑니다.shm_open("singleton")처럼 같은 이름이면 언제나 같은 객체.envVars로 넣어 줍니다 → 컨테이너 안 process.env.*.execve()의 envp로 넘기는 것과 같습니다. 그래서 컨테이너 안에서는 process.env.X(= getenv("X"))로 읽습니다.export default { … } = 이 Worker의 진입점 모음. 런타임이 HTTP 요청이 오면 fetch를, 큐 메시지가 오면 queue를 부릅니다.struct file_operations처럼 함수 포인터 묶음을 런타임에 등록.URL 객체로 만들어 경로(pathname)를 쉽게 꺼냄.GET /status/<id>: R2의 상태 파일을 그대로 돌려줌. id는 영문·숫자·_·-만 허용(정규식 /^[\w-]+$/) → 이상한 경로로 다른 키를 읽지 못하게. 조건 ? A : B는 "참이면 A, 아니면 B".POST /enqueue (Lab 5): 토큰 확인.inputKey, delaySec만 꺼내고(구조 분해), 8자리 작업 id를 만듦.my-jobs 큐로 가고, 이 요청은 더 기다리지 않습니다./jobs, /health 등)만 컨테이너를 켭니다. 위의 /status·/enqueue가 컨테이너를 깨우지 않도록 순서를 이렇게 둡니다.POST /jobs (Lab 3, 큐 없이 직접): id를 붙여 컨테이너에 그대로 전달하고, 컨테이너의 202 응답을 사용자에게 돌려줌. c.fetch는 DO를 거쳐 컨테이너 8080으로 갑니다./health, /version, /r2test).consumers 설정을 보고 Cloudflare 런타임이 메시지 묶음(batch)과 함께 불러 줍니다. MessageBatch<Job> = "Job 모양 메시지들의 묶음".for … of = 배열의 각 항목을 차례로./jobs로 POST. 본문 = 메시지 내용(...m.body) + 시도 횟수 + 콜백 주소.m.ack() = "이 메시지는 처리 완료, 다시 주지 마". (실제 ffmpeg는 컨테이너에서 계속 돌고 있음)m.retry({ delaySeconds: 10 }) = "10초 뒤 다시 줘". 시도 횟수(m.attempts)가 1씩 늘고, max_retries를 넘으면 DLQ로 이동.6. 용어 정리
| 용어 | 뜻 |
|---|---|
| 바인딩 (binding) | wrangler.jsonc에 적어 두면 코드의 env.이름으로 나타나는 연결. 키나 주소 없이 R2·Queue·DO를 바로 씁니다. |
env | 바인딩 + vars + Secret을 담은 객체. 런타임이 fetch·queue에 넘겨주고, DO에서는 this.env. |
| Durable Object (DO) | 이름 하나당 전 세계에 딱 하나 있는 작은 서버 객체. 컨테이너를 켜고 끄는 관리인 역할. |
| stub | DO를 부르기 위한 손잡이. getByName() / getContainer()가 돌려줌. 메서드를 부르면 실제로는 DO에서 실행. |
| RPC | 다른 곳에 있는 객체의 메서드를 내 함수처럼 부르는 방식 (sb.stitch(job)). |
| Promise · await | "나중에 끝나는 일"과 그것을 기다리는 문법. |
| 스트림 (ReadableStream) | 파일을 통째로 메모리에 올리지 않고 조각조각 흘려보내는 통로. obj.body, file.body. |
FixedLengthStream | Workers 내장. 전체 길이를 미리 알려 주는 스트림. R2에 스트림을 올릴 때 필요. |
| bigint | 아주 큰 정수용 숫자 타입 (123n). JSON으로 바꿀 수 없어 Number()로 변환. |
| sandbox-shim | 컨테이너 안에서 Files의 파일 작업을 대신 수행하는 작은 프로그램. |
| HMAC 서명 | 공유 Secret으로 만든 "변조 방지 도장". 받는 쪽이 같은 계산을 해 맞는지 확인. |
| 202 Accepted | "접수했음, 결과는 나중에" 응답. 200(끝났음)과 구분. |
| DLQ | Dead Letter Queue. 끝내 실패한 메시지를 보관하는 큐. 나중에 원인 확인·재처리용. |
| SIGTERM / SIGKILL | "정리하고 끝내라" 신호 / "즉시 강제 종료" 신호. |