Cloudflare R2 presigned 멀티파트 업로드 — mock 160개가 못 잡은 버그 2개
최근 대용량 영상 업로드가 필요한 서비스를 만들면서, 그 첫 관문을 Cloudflare R2 presigned 멀티파트로 붙였습니다. 그 과정에서 제가 얕게 알고 있던 것 하나를 다시 배웠습니다. presigned 업로드는 서명을 발급하는 일이 아니라 신뢰 경계를 옮기는 일이라는 것입니다.
그리고 SDK를 mock한 테스트 160개가 전부 초록불인 상태에서, 실제 R2에 붙인 E2E 레인 하나가 버그 두 개를 잡아냈습니다. 둘 다 mock으로는 원리상 검출이 불가능한 종류였습니다. 그 이야기가 이 글의 핵심이지만, 왜 이런 구조를 택할 수밖에 없었는지부터 적겠습니다.
영상 바이트가 API 컨테이너를 통과할 수 없었습니다
원본 영상 상한은 500MB, 10분입니다. 그런데 Coolify에 올라가는 백엔드 API 컨테이너에 할당한 메모리는 384~512MB입니다. 이 두 숫자를 나란히 놓으면 결론이 하나밖에 없습니다. 영상 바이트가 API 컨테이너를 통과할 수 없다는 것입니다. 이 제약 하나가 아키텍처를 전부 결정했습니다. 기존에 쓰던 Cloudinary는 무료 플랜이 영상 100MB/파일이라 후보가 아니었고, 그래서 egress가 0인 Cloudflare R2를 택했습니다.
브라우저가 R2로 직접 PUT하기로 하면서 서버가 하는 일은 네 가지로 줄었습니다. 서명 발급, 쿼터 게이트, 완료 후 실측 검증, 메타데이터 소유. 일이 줄었으니 편해졌을 것 같지만 실제로는 반대였습니다. 바이트가 서버를 통과하지 않게 되는 순간, 서버는 세 가지를 잃습니다.
파일이 실제로 몇 바이트인지 — 이제 클라이언트 신고값만 남습니다.
업로드가 끝났는지 버려졌는지 — 브라우저가 그냥 닫히면 서버는 알 수 없습니다.
스토리지에 무엇이 남았는지 — orphan이 생깁니다.
이 글은 결국 그 셋을 각각 어떻게 되찾았는가에 대한 기록입니다.
인터페이스부터 갈라야 했습니다
모노레포에는 이미 IStorageService가 있었습니다. NestJS 백엔드가 이미지를 Cloudinary에 올릴 때 쓰던 인터페이스입니다. 처음에는 여기에 메서드 몇 개를 더하면 되겠다고 생각했는데, 곰곰이 들여다보니 그럴 수 없었습니다. upload(FileInput)이라는 시그니처 자체가 "서버가 바이트를 들고 있다"는 전제 위에 서 있었기 때문입니다. 대용량 영상은 그 전제가 성립하지 않습니다. 그래서 IPresignedMultipartStorage를 상속이 아니라 직교하는 별개 표면으로 신설했습니다. Cloudinary provider는 구현할 수 없으니 구현하지 않고, S3CompatibleProvider만 양쪽을 다 구현합니다. 그리고 불변식 하나를 못 박았습니다. create·complete·abort는 반드시 서버만 호출합니다. 브라우저에는 파트 PUT URL만 내려가고, R2 버킷 CORS도 그래서 PUT만 허용합니다.
코드를 짜기 전에 실물부터 찔러봤습니다
구현에 들어가기 전에 R2 버킷을 만들고 스크립트로 직접 찔러보는 단계를 뒀습니다. 여기서 나온 사실들이 이후 설계를 여러 번 구했습니다.
- Cloudflare의 100MB 프록시 업로드 제한은 R2의 S3 엔드포인트에 적용되지 않았습니다. 단일 presigned PUT으로 150MB를 올려 200 OK를 받았습니다. 그럼에도 파트 상한은 95MB로 clamp해 뒀습니다. 나중에 프록시를 타는 경로로 바뀌어도 깨지지 않도록 남겨둔 여유입니다.
- Object Read & Write 스코프 토큰은
ListBuckets가 AccessDenied입니다. 정상 동작입니다. 그러니 버킷 존재 확인을 ListBuckets로 하면 안 됩니다. - R2 API 토큰에 IP 필터를 걸어서도 안 됩니다. presigned URL은 최종 사용자의 브라우저 IP에서 호출되므로 전부 차단됩니다.
- R2에는 7일 지난 미완료 멀티파트를 자동 abort하는 규칙이 기본으로 켜져 있습니다. 다만 DB 행과 쿼터는 별개입니다.
- CORS에
ExposeHeaders: ETag가 없으면 브라우저가 파트 응답의 ETag를 읽지 못해 complete 자체가 불가능합니다.
mock 160개가 초록불인 채로 놓친 버그 두 개
SDK를 mock한 유닛 테스트가 전부 통과하는 상태에서, 실제 R2에 붙인 Playwright E2E 레인 하나가 버그 두 개를 잡아냈습니다. 부끄럽지만 둘 다 제가 코드를 아무리 다시 읽어도 찾아낼 수 없는 종류였습니다.
AWS SDK가 몰래 붙이는 체크섬이 서명을 깨뜨렸습니다
브라우저가 파트를 PUT하면 SignatureDoesNotMatch가 떨어졌습니다. 서명 로직을 몇 번을 들여다봐도 이상한 곳이 없었습니다. 원인은 제 코드가 아니라 SDK에 있었습니다.
aws-sdk v3.729부터 기본값이 된 requestChecksumCalculation: 'WHEN_SUPPORTED'는 PutObject와 UploadPart에 x-amz-sdk-checksum-algorithm과 CRC32 헤더를 자동으로 추가합니다. 그 상태로 presigned URL을 만들면 그 헤더가 서명에 포함됩니다. 그런데 브라우저는 그 헤더를 보내지 않습니다. 서명된 헤더와 실제 요청이 어긋나니 R2는 당연히 거절합니다. 정작 R2는 그 헤더를 요구하지도 않습니다. 순수하게 SDK가 스스로 만든 지뢰였습니다.
const client = new s3.S3Client({
region: this.config.region ?? 'auto',
endpoint: this.getEndpoint(),
forcePathStyle: this.config.forcePathStyle ?? true,
credentials: {
accessKeyId: this.config.accessKeyId,
secretAccessKey: this.config.secretAccessKey,
},
// 필수 (aws-sdk v3.729+): 기본값 WHEN_SUPPORTED 는 PutObject/UploadPart 에
// `x-amz-sdk-checksum-algorithm` + CRC32 헤더를 자동 추가한다. presigned URL 을 만들면
// 그 헤더가 서명에 포함되는데 브라우저는 보내지 않으므로 SignatureDoesNotMatch 로 실패한다
// (R2 는 애초에 이 헤더를 요구하지도 않는다). WHEN_REQUIRED 로 꺼야 presigned PUT 이 동작.
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
R2의 uploadId는 343자였습니다
두 번째는 더 단순하고 더 어이없었습니다. S3의 uploadId만 보고 스키마를 varchar(255)로 잡아뒀는데, 실제 R2가 돌려준 uploadId는 343자였습니다. INSERT가 Data too long으로 터지면서 500이 났습니다. varchar(1024)로 마이그레이션을 다시 만들어 해결했습니다.
mock은 'test-upload-id' 같은 짧은 가짜 값을 돌려줍니다. 그래서 유닛 테스트는 영원히 초록불입니다.
mock은 제가 상상한 계약을 재현할 뿐입니다
이 두 버그를 나란히 놓고 한참을 생각했습니다. 공통점이 분명했습니다. 외부 인프라와의 계약 — 문자열의 실제 길이, SDK가 몰래 붙이는 기본 헤더, 엣지가 강제하는 동작 — 은 mock이 정의상 재현하지 못합니다.
mock은 실제 계약을 재현하는 것이 아니라, 제가 상상한 계약을 재현합니다. 제 상상이 틀린 지점은 mock을 아무리 늘려도 드러나지 않습니다.
그래서 E2E 레인 하나는 반드시 실물에 붙어 있어야 한다는 결론에 이르렀습니다. 전부일 필요는 없습니다. 하나면 됩니다. 다만 그 하나는 진짜여야 합니다.
서명에는 강제할 수 있는 것만 넣습니다
"presigned는 그냥 URL을 발급하는 것"이라는 인식이 깨진 지점이 여기입니다. 파트 PUT 서명을 만들면서 저는 Content-Type은 빼고 Content-Length는 넣었습니다. 처음에는 이 비대칭이 이상해 보였습니다.
서명된 헤더는 브라우저가 정확히 동일하게 보내야 합니다. 그런데 fetch나 XHR은 Content-Type을 임의로 보정할 수 있습니다. 서명에 넣는 순간 SignatureDoesNotMatch의 씨앗이 됩니다. 게다가 오브젝트의 Content-Type은 createMultipartUpload 시점에 이미 확정됐습니다. 파트 PUT에 또 넣을 이유가 없습니다.
Content-Length는 정반대입니다. 브라우저는 Blob 크기로 Content-Length를 결정적으로 채웁니다. 어길 방법이 없으니 서명 불일치 위험이 0입니다. 그리고 서명에 박아두면 R2가 엣지에서 파트별 기대 바이트를 강제합니다. 클라이언트가 "100MB짜리예요"라고 신고해놓고 실제로는 400MB 바디를 밀어넣어 바이트 쿼터를 우회하는 경로가 여기서 닫힙니다.
// 마지막 파트는 partSize 미만일 수 있다 — 정확한 기대 바이트를 계산해 서명에 박는다.
const expectedBytes = Math.min(params.partSize, params.totalBytes - start);
const command = new s3.UploadPartCommand({
Bucket: this.config.bucket,
Key: params.key,
UploadId: params.uploadId,
PartNumber: partNumber,
ContentLength: expectedBytes, // ContentType 은 넣지 않는다.
});
return presigner.getSignedUrl(client, command, {
expiresIn,
signableHeaders: new Set(['content-length']), // 엣지에서 크기를 강제한다.
});
정리하면 Content-Type은 브라우저가 어길 수 있으니 빼고, Content-Length는 브라우저가 어길 수 없으니 넣습니다. 서명에 넣는다는 것은 곧 강제한다는 뜻이므로, 강제할 수 있는 것만 넣어야 한다는 원칙이 남습니다.
| 항목 | 값 | 이유 |
|---|---|---|
| 기본 파트 크기 | 64MB | 500MB 원본이 최대 8파트 — 서명 발급·조립 왕복이 작다 |
| clamp 범위 | 5MB ~ 95MB | 5MB는 S3 규약 최소, 95MB는 프록시 100MB 제한 대비 여유 |
| 파트 URL TTL | 6시간 | 대용량 업로드라 넉넉하게 |
| 다운로드 GET TTL | 10분 | 링크 유출 표면 축소 |
쿼터는 조건부 UPDATE 한 문장으로 원자화했습니다
플랜에는 기존 사내 코드의 패턴을 승계하라고 적어뒀었습니다. 다른 SaaS의 usage 서비스가 쓰던 "increment 하고, 초과면 decrement로 rollback" 방식입니다. 그런데 막상 옮겨 쓰려니 손이 멈췄습니다. 동시성 관점에서 다시 보니 구멍이 보였기 때문입니다.
그 패턴에는 카운터가 잠깐 부풀어 있는 창이 있습니다. 그 창 사이에 들어온 다른 요청은 아직 쓰지도 않은 용량 때문에 잘못 거절됩니다. 더 나쁜 건 rollback이 유실되는 경우입니다. 프로세스가 죽거나 네트워크가 끊겨 decrement가 실행되지 않으면 카운터는 영구히 오염됩니다. 복구 경로가 없습니다. 사용자는 쓰지도 않은 용량에 영영 막히게 됩니다.
그래서 검사와 증가를 한 UPDATE 안에서 끝내기로 했습니다. 쿼터 가드를 WHERE 절에 넣는 게 전부입니다.
UPDATE rc_usage
SET uploaded_bytes = uploaded_bytes + ?
WHERE user_id = ? AND month = ?
AND uploaded_bytes + ? <= ? -- 쿼터 가드가 WHERE 절에 있다
affectedRows = 0이면 그것이 곧 "쿼터 초과"입니다. 부풀림 창도 없고 rollback 유실도 없습니다. Drizzle로 옮기면 이렇게 됩니다.
const result = await db
.update(rcUsage)
.set({ uploadedBytes: sql`${rcUsage.uploadedBytes} + ${bytes}` })
.where(
and(
eq(rcUsage.userId, userId),
eq(rcUsage.month, targetMonth),
// 쿼터 가드가 WHERE 절에 있다 — 초과하면 행이 안 잡히고 affectedRows=0.
sql`${rcUsage.uploadedBytes} + ${bytes} <= ${limit}`
)
);
if (affectedRows(result) === 0) {
throw new ForbiddenException(
`이번 달 업로드 용량 한도(${formatGb(limit)})를 초과했습니다. 다음 달에 초기화됩니다.`
);
}
로컬 MySQL에 동시 20건을 던져 확인했습니다. 건당 10, 한도 100인 상황에서 정확히 10건만 성공했고 한도를 넘긴 건은 0건이었습니다.
실전 디테일도 두 가지 붙었습니다. release는 GREATEST(x - ?, 0)로 하한을 겁니다. 중복 release가 카운터를 음수로 오염시키지 않게 하기 위해서입니다. 그리고 월 경계 회계가 생각보다 까다로웠습니다. 예약은 "행이 만들어진 달"에 잡히므로 정산과 반환도 그 달에 해야 합니다. 월말 23시 5x분에 시작한 업로드가 자정을 넘겨 complete될 때 "현재 달"로 정산하면, 반환분은 새 달의 0 카운터에서 클램프되어 사라지고 추가분은 엉뚱한 달에 예약됩니다. 그래서 정산·반환 함수는 reservedMonth를 인자로 받습니다.
클라이언트 신고값은 믿지 않습니다
바이트가 서버를 통과하지 않으니 서버는 파일 크기를 모릅니다. 아는 것은 클라이언트가 신고한 숫자뿐인데, 그 숫자는 사용자가 마음대로 바꿀 수 있습니다. 그래서 세 겹으로 되찾았습니다.
- 서명에 Content-Length를 박습니다. R2 엣지가 파트 크기를 강제하니 신고한 것보다 크게 올릴 수 없습니다.
- complete 시 HeadObject로 실측합니다. 이것이 서버가 신뢰하는 유일한 ground truth입니다.
- 차액을 정산합니다. 실제가 신고보다 크면 쿼터 가드를 다시 통과해야 하고, 초과하면 403과 함께 객체를 폐기합니다. 축소 신고로 쿼터를 우회하는 경로가 여기서 닫힙니다.
필드 이름도 신뢰 수준이 드러나도록 갈랐습니다.
| 필드 | 출처 | 취급 |
|---|---|---|
declaredBytes |
클라이언트 신고값 | 신뢰 금지. 쿼터 예약 회계용으로만 존재 |
sourceBytes |
서버의 HeadObject 실측 | 사용자에게 노출하는 건 이것뿐 |
durationMs |
워커의 ffprobe 확정값 | 클라 신고값은 아예 저장하지 않음 |
R2 키에 사용자 파일명을 넣지 않는 것도 같은 맥락입니다.
u/{userId}/v/{videoId}/source.{ext}
path traversal과 인코딩 이슈, PII 유출 표면을 한 번에 제거합니다. 표시용 파일명은 DB에만 둡니다. 반대로 userId는 키에 박아둡니다. 오브젝트만 보고도 소유자를 역추적할 수 있어 사고 조사에 유리하기 때문입니다.
경계 너머의 쓰레기 — orphan과 보상, 그리고 원자적 선점
presigned를 쓰는 대가가 여기서 청구됩니다. "브라우저가 그냥 닫혔다"를 서버는 알 수 없습니다.
한 줄 순서 차이로 쿼터가 영구 소진됩니다
upload-intent가 중간에 실패하면 쿼터 예약분을 반환하고, 열린 R2 세션을 abort하고, DB 행을 지웁니다. 여기까지는 평범한 보상 트랜잭션입니다. 정작 오래 붙들었던 건 순서였습니다.
R2 관련 env가 누락되면 storage provider getter가 503을 던집니다. 이 getter 호출이 쿼터 예약 뒤에 있으면 503이 try 블록 밖에서 터져 보상이 돌지 않습니다. 사용자의 쿼터는 파일 한 바이트도 올리지 못한 채 영구 소진됩니다. 그래서 스토리지 가용성 체크를 쿼터 예약보다 먼저 하도록 옮겼습니다. 한 줄 순서로 갈리는 종류의 버그였습니다.
complete 성공 후의 실패는 abort로 지울 수 없습니다
completeMultipartUpload가 성공한 순간, 조립된 객체는 이미 R2에 존재합니다. 그 뒤에 HeadObject가 5xx를 내거나 정산에서 403이 나면 어떻게 될까요. abortMultipartUpload는 소용이 없습니다. 이미 complete된 세션이라 NoSuchUpload로 무력합니다. 결국 deleteObject로 객체 자체를 지워야 DB 어디서도 참조하지 않는 orphan이 남지 않습니다.
await src.completeMultipartUpload({ key: video.r2SourceKey, uploadId: video.r2UploadId, parts });
// completeMultipartUpload 가 성공한 순간 조립된 객체가 R2 에 존재한다. 이 지점 이후의
// 어떤 실패(HeadObject 5xx, 정산 초과)든 객체를 삭제하지 않으면 DB 어디에서도 참조하지
// 않는 orphan 이 남는다 — abort 는 이미 complete 된 세션이라 NoSuchUpload 로 무력하므로
// discardSource(= deleteObject) 로 객체 자체를 지운다.
let actual: number;
try {
// 서버 검증의 ground truth — 클라가 뭐라고 신고했든 R2 에 실제로 올라간 바이트를 본다.
const head = await src.headObject(video.r2SourceKey);
actual = head.size;
} catch (error: unknown) {
await this.discardSource(video, declared, 'HeadObject 실패');
throw error;
}
try {
// 신고값과 실측값의 차액 정산 — 축소 신고로 쿼터를 우회하는 경로를 여기서 막는다.
await this.usage.settleUploadBytes(userId, declared, actual, tier, reservedMonth);
} catch (error: unknown) {
await this.discardSource(video, declared, '쿼터 초과(실측)');
throw error;
}
조건부 DELETE로 행을 선점합니다
abort는 실제로 두 번 이상 불립니다. 프론트가 취소 핸들러와 XHR abort catch에서 각각 부르고, 24시간 sweep cron과도 같은 창을 공유합니다.
처음에 떠오른 순서는 SELECT → 상태 체크 → 쿼터 release → DELETE였습니다. 그런데 이렇게 짜면 두 요청이 나란히 SELECT를 통과해 쿼터를 이중 반환하는 race가 열립니다. 그래서 순서를 뒤집었습니다.
// 원자적 선점 (claim-then-act) — 조건부 DELETE 로 행을 먼저 잡는다.
const claimed = await db
.delete(rcVideos)
.where(
and(eq(rcVideos.id, videoId), eq(rcVideos.userId, userId), eq(rcVideos.status, 'uploading'))
);
if (affectedRows(claimed) === 0) {
// 다른 요청(또는 sweep)이 이미 선점해 처리했다 — idempotent no-op.
return;
}
await this.releaseUpload(video); // 선점한 요청만 R2 abort + 쿼터 예약분 반환.
경쟁하는 N개의 요청 중 정확히 하나만 affectedRows = 1을 받습니다. 그 하나만 R2 abort와 쿼터 반환을 실행하고, 나머지는 조용히 no-op으로 끝납니다. 상태를 읽고 판단하는 대신 먼저 선점하고 그다음에 행동하는 이 순서가, 경계가 흩어진 곳에서는 훨씬 단단하다는 생각이 들었습니다.
R2 abort 실패는 의도적으로 삼킵니다
선점에 성공한 요청이 실행하는 releaseUpload 안에서, R2 abort가 실패해도 throw하지 않습니다. 처음엔 이게 계속 찜찜했는데, 두 실패의 가격을 나란히 계산해보고 마음을 정했습니다.
private async releaseUpload(video: RcVideo): Promise<void> {
if (video.r2UploadId) {
// abort 실패를 삼킨다. 호출부가 claim-then-act 로 행을 이미 지운 뒤라 여기서 throw 하면
// 아래 쿼터 반환이 실행되지 않아 그 달 쿼터가 영구 누수된다 — 행이 없어 sweep 재시도로도
// 복구 불가. 미완료 MPU 는 R2 의 7일 Default Abort Rule 이 어차피 정리하므로 무해하다.
await this.storage.src
.abortMultipartUpload({ key: video.r2SourceKey, uploadId: video.r2UploadId })
.catch((error: unknown) =>
this.logger.error(`업로드 세션 abort 실패 (video=${video.id}): ${msg(error)}`)
);
// complete 는 성공했는데 그 뒤에서 실패해 이 경로로 온 경우, 조립된 객체가 남는다.
await this.storage.src.deleteObject(video.r2SourceKey).catch(() => undefined);
}
if (video.declaredBytes) {
const reservedMonth = this.usage.getCurrentMonth(video.createdAt);
await this.usage.releaseUploadBytes(video.userId, video.declaredBytes, reservedMonth);
}
}
이 시점엔 DB 행이 이미 지워졌습니다. 여기서 throw하면 그 아래 쿼터 반환이 실행되지 않고, 그러면 그 달 쿼터가 영구 누수됩니다. 행이 없으니 sweep 재시도로도 복구할 수 없습니다. 반면 미완료 멀티파트는 R2의 7일 자동 abort 규칙이 어차피 정리해 줍니다. abort 실패는 무해합니다.
무해한 실패를 삼켜서 치명적 실패를 막습니다. 어느 실패가 더 비싼지를 계산한 결과입니다.
남은 건 sweep입니다. R2가 파트를 7일 뒤에 알아서 지운다 해도 DB 행과 쿼터 예약분은 우리가 정리해야 합니다. 안 그러면 사용자는 쓰지도 않은 용량에 막힙니다. 그래서 24시간 넘게 uploading으로 남은 행을 정리하는 cron을 워커에 뒀고, 이 sweep도 조건부 DELETE로 선점한 뒤에만 부수효과를 실행합니다. 다만 영상을 삭제해도 월 사용량은 환급하지 않습니다. 이미 소비한 업로드 대역과 저장 비용이고, 환급하면 삭제와 재업로드를 반복해 쿼터를 무한히 재사용할 수 있기 때문입니다.
남의 영상에는 403이 아니라 404를 줍니다
조회·삭제·완료·중단, 전 경로가 ownerId 스코프입니다. 그리고 남의 영상에 접근하면 403이 아니라 404를 돌려줍니다. 403은 "그 id는 존재한다, 다만 네 것이 아니다"를 알려주는 응답입니다. 열거를 도와주는 셈입니다. 404는 아무것도 알려주지 않습니다.
프론트는 fetch가 아니라 XHR을 씁니다
업로드 오케스트레이션은 uploadVideo() 함수 하나가 전부 맡습니다. intent를 받고, 파트를 동시에 3개씩 PUT하고, complete를 부릅니다. 여기서 fetch가 아니라 XHR을 쓴 이유는 단 하나입니다. fetch는 업로드 진행률을 주지 않습니다. upload.onprogress가 필요해서 구식 API로 돌아갔습니다.
const xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
// fetch 는 업로드 진행률을 주지 않는다 — XHR 을 쓰는 유일한 이유.
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onSent(event.loaded);
};
xhr.onload = () => {
// R2 가 노출한 ETag (CORS ExposeHeaders). 없으면 complete 가 불가능하다.
const etag = xhr.getResponseHeader('ETag');
if (!etag) {
reject(new Error('파트 응답에 ETag 가 없습니다. 스토리지 CORS 설정을 확인해주세요.'));
return;
}
resolve(etag);
};
// setRequestHeader('Content-Type', ...) 금지 — 서명에 포함되지 않았다.
xhr.send(blob);
파트별로 최대 3회, 지수 백오프로 재시도합니다. 멀티파트의 존재 이유가 파트 단위 재전송이기 때문입니다. 모바일 회선에서 파트 하나가 실패했다고 500MB를 처음부터 다시 올리게 할 수는 없습니다. 반대로 한 파트가 재시도를 소진해 실패를 확정하면 형제 worker의 진행 중인 PUT까지 중단합니다. 진행률은 파트별 전송 바이트 배열의 합으로 계산하고, 재시도할 때는 해당 파트를 0으로 되감아 이중 계산을 막습니다.
업로드 전에 <video> 메타데이터로 길이를 미리 검사하기도 합니다. 500MB를 다 올린 뒤에 "10분 초과"로 거절당하는 낭비를 없애기 위해서입니다. 다만 이건 어디까지나 UX용입니다. 신뢰 경계는 언제나 서버입니다. 각 파트에서 모은 ETag도 서버가 받자마자 PartNumber로 정렬합니다. 병렬 PUT의 완료 순서를 신뢰하지 않기 때문입니다.
커스텀 도메인이 없어도 굴러가게 만들었습니다
가공 결과를 담을 버킷의 공개 도메인이 아직 없습니다. 제가 보유한 도메인들이 가비아 NS라 Cloudflare 존이 아니어서 R2 커스텀 도메인을 연결할 수 없습니다. 애석하게도 코드로 풀 수 있는 문제가 아닙니다. 그래서 외부 미결 사항이 구현 단계를 블로킹하지 않도록 폴백을 하나 심었습니다. publicBaseUrl이 비어 있으면 getPublicOrSignedUrl()이 presigned GET으로 자동 폴백합니다. 도메인 없이도 미리보기와 다운로드가 동작하고, 도메인이 생기면 env 한 줄로 전환됩니다. "외부 의존성이 안 풀렸으니 이 단계는 못 한다"를 "폴백 한 개 심고 진행한다"로 바꾼 결정이라 굳이 적어둡니다.
정리 — mock 160개와 실물 레인 하나
이번 작업에서 쌓인 테스트는 다음과 같습니다.
| 레이어 | 개수 | 실물 접점 |
|---|---|---|
| Vitest (core/storage) | 34 | SDK mock |
| Jest (백엔드) | 126 | fake drizzle + R2 mock |
| Playwright | 54 | 이 중 브라우저 → 실제 R2 → complete → 삭제 왕복 레인 포함 |
숫자 자체보다 중요한 건 그 배치입니다. mock 160개가 전부 초록불인 상태에서, 실물에 붙은 레인 하나가 버그 두 개를 잡았습니다. 이 대비가 이번 구현에서 제가 가장 오래 기억하게 될 부분입니다.
돌이켜 생각해보면, 이 글에 적은 결정들은 서로 다른 문제를 풀고 있는 것처럼 보이지만 결국 하나의 질문에서 갈라져 나왔습니다. 바이트가 내 서버를 지나가지 않을 때, 나는 무엇을 여전히 알 수 있는가.
서명에는 강제할 수 있는 것만 넣습니다. 크기는 실측으로만 확정합니다. 상태를 읽고 판단하는 대신 조건부 쓰기로 선점합니다. 무해한 실패는 삼켜서 치명적 실패를 막습니다. 그리고 mock은 제가 상상한 계약을 재현할 뿐이므로, 실물에 붙은 레인 하나는 반드시 남겨둡니다.
결국 presigned 업로드는 서명을 발급하는 일이 아니었습니다. 서버가 잃어버린 세 가지를 하나씩 다시 붙잡아 오는 일이었습니다.
관련 글
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다
서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.
Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.