Skip to main content

Storage Browser for S3와 CloudFront 정액제로 수백 GB 유전체 데이터 배포 포털 만들기

유전체 분석 서비스는 분석이 끝나면 결과 파일을 고객에게 넘겨야 합니다. 문제는 크기입니다. BAM 하나가 100~150 GB, FASTQ 묶음이 100 GB를 넘고, 같은 파일을 해외 연구그룹과 내부 인력이 여러 장소에서 반복해서 내려받습니다. 지금까지는 S3 presigned URL을 메일로 보내는 방식이었는데, 월 100 TB 규모에서 S3 인터넷 전송 요금이 그대로 비용이 되고, 브라우저의 기본 다운로드는 1시간짜리 URL이 만료되면 이어받기가 되지 않아 해외에서는 실패가 잦았습니다.

이 글은 그 문제를 Storage Browser for Amazon S3 + Amazon Cognito + S3 Access Grants + Amazon CloudFront 정액제 플랜으로 풀어낸 참조 구현을 설명합니다. 스펙 작성부터 dev 계정 배포, 통합·e2e 테스트, 53 GiB 파일의 CloudFront 캐시 실측까지 한 번에 끝낸 과정에서 확인한 사실과 함정을 그대로 적었습니다. 코드는 GitHub hmkim/omics-data-delivery-portal에 MIT-0 라이선스로 공개했습니다. 내용은 크게 다섯 가지입니다.

  1. 로그인·폴더 탐색은 Storage Browser에 맡기고, 권한은 S3 Access Grants로 위임한다.
  2. "같은 파일의 두 번째 다운로드부터 CloudFront"라는 규칙을 DynamoDB 원자 카운터 하나로 구현한다.
  3. 50 GB를 넘는 파일은 8 GiB Range 파트로 나눠 CloudFront를 통과시키고, 브라우저 안에서 이어받기·병합·CRC32 검증까지 한다.
  4. CloudFront 정액제 플랜(Pro)을 CloudFormation으로 연결하되, 플랜이 허용하는 기능 안에서만 설계한다.
  5. 표준 로그 v2 → Athena로 캐시 히트율과 허용량을 매일 계산하고, 임계값에 알람을 건다.

1. 요구사항을 어떻게 정리했나

시작점은 두 개의 공식 문서였습니다. Storage Browser for S3 컴포넌트와 AWS Transfer Family 웹 앱입니다. Transfer Family 웹 앱은 코드 없이 같은 UX를 주지만 IAM Identity Center 전용이라 외부 고객 계정을 Cognito로 발급하는 요구와 맞지 않았습니다. 그래서 그 웹 앱의 기반 컴포넌트인 Storage Browser를 직접 호스팅하고, 로그인·브랜딩·폴더 탐색 UX를 동등하게 재현하는 쪽으로 정했습니다.

요구사항은 다음과 같이 확정됐습니다(스펙의 R1R7, 결정 D1D7).

영역 결정
인증 Cognito User Pool. 관리자가 사용자를 조직(Org)에 배정. 고객 사용자는 읽기 전용(list/get)
데이터 범위 조직 → S3 prefix 매핑을 런타임에 변경(새 고객사 = 코드 수정 없이 관리자 API)
다운로드 경로 객체별 다운로드 횟수를 세어 첫 번째는 S3 직접, 두 번째부터 CloudFront 서명 URL. 임계값은 설정값(0이면 전량 CloudFront)
캐시 사용자마다 서명이 달라도 캐시가 맞도록 서명 파라미터는 캐시 키에서 제외
대용량 1 GiB 초과는 브라우저 분할 다운로드(8 GiB × 병렬 4, Range), 일시정지·재개·새로고침 후 이어받기, URL 자동 갱신, full-object CRC32 검증. Safari/Firefox는 aria2c/curl 명령 폴백
URL 수명 1시간. 갱신 요청은 횟수로 세지 않고 같은 경로 유지
세션 60분 무조작 로그아웃. 단, 다운로드 진행 중에는 무조작으로 세지 않음. 리프레시 토큰 24시간
정액제 CloudFront Pro 플랜의 제약(WAF 필수, OAC, 실시간 로그 미사용, 관리형 정책만) 준수
운영 표준 로그 → Athena 히트율·국가별 지표, 허용량 사용률 50/80/100/200 % 알람

한 가지 설계 논쟁이 있었습니다. presigned URL 1시간이 대용량 다운로드에 제약인가? 답은 "요청 시점에만 만료를 검사하므로 이미 시작된 전송에는 영향이 없다"입니다. 문제는 만료 뒤에 새 요청이 생기는 경우, 즉 연결이 끊긴 뒤의 이어받기와 분할 다운로드의 다음 파트 요청입니다. 그래서 (1) 분할 엔진이 만료 5분 전에 URL을 갱신하고, (2) 1 GiB만 넘으면 분할 엔진을 쓰도록 임계값을 낮춰 "브라우저 기본 다운로드가 만료 후 이어받기를 못 하는" 구간을 없앴습니다.

2. 아키텍처

브라우저(React SPA, Storage Browser)
   │  로그인(Cognito Hosted 없이 Authenticator) · /api/* 는 같은 CloudFront 뒤
   ▼
CloudFront (웹) ──/api/*──▶ API Gateway HTTP API (JWT authorizer) ──▶ Lambda
   │                                                     │  ├ listLocations / getLocationCredentials
   │ 정적 SPA                                            │  ├ POST /downloads · renew · status
   ▼                                                     │  └ admin: org · user · location(=grant)
S3 (web)                                                 ▼
                                    S3 Access Grants ◀── GetDataAccess(READ, Minimal, 1~12 h)
                                                         │
CloudFront (데이터, OAC + Key Group 서명 URL) ──────▶ S3 (data, KMS)
   │  표준 로그 v2 (parquet)                               ▲
   ▼                                                      │
S3 (logs) ──▶ Glue/Athena ──▶ opsDaily Lambda ──▶ CloudWatch 지표·알람 ──▶ SNS
DynamoDB: orgs · object-downloads(카운터) · audit-events(400일 TTL → Glacier IR 아카이브)

CDK 스택은 8개(auth → waf(us-east-1) → cdn → cdn-logs(us-east-1) → data → api → web → ops)이고 리소스 이름에는 genome-portal- 접두어만 씁니다. 스택이 8개가 된 이유는 §7에서 다룹니다.

3. 인증과 권한: Storage Browser + S3 Access Grants

Storage Browser는 세 가지 인증 모드를 지원합니다. Amplify Auth, IAM Identity Center, 그리고 우리가 listLocations/getLocationCredentials 두 함수를 구현하는 customer-managed auth입니다. Amplify Auth는 defineStorage에 정적 규칙을 적기 때문에 조직별 공유 prefix를 런타임에 바꾸는 요구를 채우지 못합니다. 그래서 customer-managed로 가되, 자격 증명 발급의 실질을 S3 Access Grants에 맡겼습니다.

배포 위치(prefix) 하나가 Access Grants의 grant 하나입니다. 관리자가 위치를 조직에 배정하면 Lambda가 grant를 만들고, 마지막 조직이 해제되면 grant를 지웁니다. 사용자가 브라우저에서 위치를 열면 Lambda는 조직 소속만 확인하고 나머지는 S3에 넘깁니다.

// infra/lambda/shared/accessGrants.ts (발췌)
const out = await s3Control.send(
  new GetDataAccessCommand({
    AccountId: accountId,
    Target: target,            // s3://<bucket>/projects/ORG-0001/* 또는 객체 1개
    TargetType: targetType,    // 'Object' — presigned URL 서명용은 객체 1개로 한정
    Permission: 'READ',
    Privilege: 'Minimal',      // 요청한 Target 범위로 자격 증명을 축소
    DurationSeconds: ttl,      // 900 ~ 43,200
    AuditContext: auditContext // user=<sub>;org=<orgId>;dl=<downloadId> → CloudTrail에 남음
  }),
);

이 선택이 준 것 세 가지입니다.

  • 범위 계산을 S3가 한다. 세션 정책 JSON을 우리가 만들지 않으니 단위 테스트할 "권한 문자열"이 없습니다. 위치 IAM 역할은 읽기 액션만 가지며 s3:AccessGrantsInstanceArn 조건으로 Access Grants 경유 외 사용을 막습니다.
  • Lambda에는 S3 권한이 없다. HeadObject(체크섬 조회)조차 사용자용 Access Grants 자격 증명으로 수행합니다. 그래서 CloudFront 경로를 포함한 모든 다운로드가 GetDataAccess CloudTrail 이벤트를 남기고, AuditContext에 사용자·조직·다운로드 ID가 기록됩니다(실측 가시 지연 약 2분).
  • URL 만료 = 자격 증명 만료. S3 presigned URL의 실제 유효기간은 min(요청한 만료, 서명한 자격 증명의 잔여 수명)입니다. Lambda 실행 역할로 서명하면 잔여 수명을 알 수 없어 "1시간"을 보장하지 못합니다. 객체 1개 전용 자격 증명을 DurationSeconds = URL TTL로 받아 서명하면 둘이 정확히 같아집니다.

한 가지 확인해야 했던 전제는 Lambda에서 호출해도 12시간 자격 증명이 나오는가였습니다. AssumeRole은 역할 체이닝이면 1시간 상한이 걸리는데, GetDataAccess는 그렇지 않았습니다. 통합 테스트에서 DurationSeconds=43200 요청에 expiresAt − now = 43,200.2 s가 나왔고, 그 덕에 URL TTL 상한을 설정값(기본 1시간, 최대 12시간)으로 둘 수 있었습니다.

정직하게 적어 둘 한계도 있습니다. 브라우저로 넘어간 1시간짜리 READ 자격 증명은 CLI로도 쓸 수 있으므로, 포털을 우회한 직접 GET(횟수 미집계)이 가능합니다. Storage Browser 구조상 불가피하고, 탐지는 S3 데이터 이벤트로 합니다.

4. "두 번째부터 CloudFront": 카운터 하나로 끝내기

기본 download 액션은 임시 자격 증명으로 브라우저가 직접 presign을 만들기 때문에 횟수 기록을 우회합니다. 그래서 다운로드 핸들러를 교체해 POST /downloads를 경유시켰습니다. 서버는 객체별 카운터를 원자적으로 올리고, 이전 값으로 경로를 판정합니다.

// infra/lambda/handlers/downloads.ts (발췌)
const out = await ddb.send(new UpdateCommand({
  TableName: DOWNLOADS_TABLE,
  Key: { pk: `OBJ#${bucket}/${key}` },
  UpdateExpression: 'ADD #c :one SET lastAt = :now, sizeBytes = :size, checksumCrc32 = :crc',
  ExpressionAttributeNames: { '#c': 'count' },
  ExpressionAttributeValues: { ':one': 1, ... },
  ReturnValues: 'ALL_OLD',
}));
const prevCount = Number(out.Attributes?.count ?? 0);
const path = prevCount >= cfThreshold ? 'CF' : 'S3';   // cfThreshold: SSM, 기본 1 (0 = 전량 CloudFront)

동시 요청이 몇 개 오더라도 prevCount = 0을 받는 요청은 정확히 하나라서 "첫 번째만 S3"가 보장됩니다(통합 테스트에서 동시 2건 → S3 1건 확인). 임계값을 SSM 파라미터로 둔 것은 "전량 CloudFront가 더 싸다"는 판단이 몇 달 뒤 바뀔 수 있어서입니다. 코드 배포 없이 값만 바꾸면 됩니다(60초 캐시).

CloudFront 경로는 Key Group 서명 URL입니다. 사용자마다 서명 쿼리가 달라도 캐시가 맞아야 하므로 캐시 정책은 쿼리 문자열을 캐시 키에 넣지 않는 관리형 CachingOptimized를 씁니다. 오리진은 OAC로 잠긴 S3 버킷이고, 버킷·KMS 키 정책은 이 distribution만 허용합니다. 여기서 CDK 순환 참조가 생깁니다. 버킷 정책은 distribution ARN이 필요하고 distribution은 버킷이 필요하니까요. 해법은 cdn 스택이 버킷을 이름 문자열로만 참조하고 data 스택만 cdn 출력을 가져오는 단방향 의존입니다. CloudFront는 아직 없는 버킷을 오리진으로 받아 줍니다.

함정: 캐시 키에 Origin이 없다

배포 포털에서 처음 분할 다운로드를 돌렸을 때 모든 Range fetch가 Failed to fetch로 실패했습니다. 원인은 이렇습니다. S3는 버킷 CORS 규칙에 맞춰 요청한 Origin을 그대로 Access-Control-Allow-Origin에 돌려주는데, CachingOptimized는 Origin 헤더를 캐시 키에 넣지 않습니다. 전날 로컬 개발 서버(localhost:5173)에서 받은 응답이 캐시에 남아, 배포 포털의 요청에도 Access-Control-Allow-Origin: http://localhost:5173이 나갔던 것입니다. 관리형 CORS-With-Preflight 응답 헤더 정책은 오리진이 보낸 CORS 헤더를 덮어쓰지 않습니다.

첫 수정은 오리진 헤더를 덮어쓰는 커스텀 응답 헤더 정책이었는데, 이건 Pro 플랜에서 쓸 수 없는 기능(Business 이상)이었습니다(§7). 최종 수정은 더 단순합니다. 버킷 CORS를 AllowedOrigins: *로 두면 S3가 어느 오리진에도 같은 답을 하므로 Origin 없는 캐시 키로 캐시돼도 문제가 없습니다. 접근 제어는 서명 URL이 하므로 *가 새로 노출하는 것은 없습니다. 교훈: 캐시 키에 들어가지 않는 요청 헤더에 따라 오리진 응답이 달라지면 안 된다.

5. 50 GB를 넘는 파일: 브라우저 안의 분할 다운로드 엔진

CloudFront의 단일 객체 한도는 50 GB입니다. 150 GB BAM을 CloudFront로 보내려면 클라이언트가 Range 요청으로 잘라 받아야 합니다. 참조 구현의 분할 엔진은 다음처럼 동작합니다.

  • 계획: 8 GiB 파트, 동시 4개. 150 GB면 파트 19개.
  • 파트마다 서비스 워커가 아닌 전용 Web Worker가 fetch(Range)로 받아 디스크에 씁니다.
  • URL 만료 5분 전 또는 403을 받으면 POST /downloads/{id}/renew로 갱신. 갱신은 횟수에 세지 않고 같은 경로(S3/CF)를 유지합니다.
  • 완료 후 S3의 full-object CRC32(x-amz-checksum-crc32)와 로컬 계산값을 대조합니다.
  • Firefox/Safari에는 File System Access API가 없어, 50 GB 초과 파일은 aria2c -x 4 -s 4 -k 8G ... / curl -L -C - ... 명령을 서명 URL과 함께 보여 줍니다.

함정 1: aws s3 cp --checksum-algorithm CRC32는 검증에 못 쓴다

멀티파트 업로드에 aws s3 cp --checksum-algorithm CRC32를 쓰면 S3에는 COMPOSITE(파트별 체크섬의 체크섬)가 기록됩니다. 클라이언트가 계산하는 전체 파일 CRC32와 비교할 수 없습니다. 시드 업로드 도구를 SDK lib-storage로 바꾸고 ChecksumType: FULL_OBJECT를 선언한 뒤 로컬 CRC32와 대조하도록 했습니다. 데이터 파이프라인이 파일을 올리는 쪽에서도 같은 조건이 필요하므로 운영 문서에 명시했습니다.

함정 2: 브라우저는 저장 버튼을 누를 때만 파일에 쓴다

처음 설계는 FileSystemWritableFileStream.write({ position })으로 사용자가 고른 최종 파일에 직접 쓰는 것이었습니다. 인수 테스트의 "새로고침 후 이어받기" 시나리오에서 CRC32 불일치가 났습니다. 이유는 File System Access API의 동작 방식입니다. createWritable()은 임시 사본(swap file) 에 쓰고, close()가 성공한 순간에만 진짜 파일로 바꿔칩니다. 엔진은 진행률을 IndexedDB에 청크마다 적었지만 close()는 일시정지·완료 때만 불렀으니, 새로고침(=close() 없는 종료) 뒤에는 기록이 17 %인데 파일에는 마지막 일시정지 시점(5 %)까지만 있었습니다. 재개는 17 %부터 이어받아 중간에 구멍이 난 파일이 완성됐습니다.

덤으로 발견한 비용도 있습니다. 재개 때마다 부르는 createWritable({ keepExistingData: true })를 Chromium은 확정된 파일 전체를 새 사본으로 복사하는 방식으로 구현합니다. 150 GB를 90 %에서 재개하면 첫 바이트를 받기 전에 135 GB를 로컬 복사합니다.

세 가지 안을 비교했습니다.

기준 A. OPFS 파트 파일 + 마지막 1회 병합 B. 파트마다 close()/재개방 C. 확정 바이트만 진행률로 인정
비정상 종료 시 유실 청크 1개 최대 8 GiB 마지막 일시정지 이후 전부
추가 로컬 I/O (150 GB) 병합 300 GB, 선형 ≈ 1.4 TB 복사(제곱 증가) 없음
디스크 2×, 그중 1×는 시스템 드라이브 2× 1~2×
시크릿 모드 불가 → C 폴백 가능 가능

A를 택하고 C를 폴백으로 넣었습니다. 파트는 브라우저 전용 저장소(OPFS)에 두고, 워커가 FileSystemSyncAccessHandle로 제자리에 즉시 씁니다. 사본이 없고 flush()로 디스크에 확정되며, 진행률은 메모가 아니라 파트 파일의 실제 크기에서 읽습니다. 모든 파트가 끝나면 사용자가 고른 위치로 한 번 병합합니다.

// web/src/download/splitWorker.ts (발췌)
const dir = await splits.getDirectoryHandle(spec.downloadId, { create: true });
const fileHandle = await dir.getFileHandle(partFileName(partIdx), { create: true });
const handle = await withLockRetry(() => fileHandle.createSyncAccessHandle());
const size = handle.getSize();                 // = 이 파트의 내구성 있는 진행률
if (size > offset) handle.truncate(offset);    // 마지막 청크는 부분 쓰기 대비로 다시 받음
// ... 청크마다 handle.write(data, { at }) , 64 MiB마다 handle.flush()

두 가지가 더 필요했습니다.

  • 용량 판정은 estimate()가 아니라 예약 프로브로. Chromium의 navigator.storage.estimate()는 할당량을 10 GiB로 캡해서 보고했지만 실제로는 160 GiB 이상 예약이 가능했습니다. 그래서 OPFS 스크래치 파일에 truncate(파일 크기)를 시도해(I/O 없음, 할당량만 검사) 성공하면 A, 실패하면 C로 갑니다. 시크릿 창은 OPFS가 RAM에 있어 1 GiB 이상 쓰기가 실패하므로 역시 C입니다.
  • 새로고침 직후의 잠금 경합. 이전 페이지의 워커가 잡은 sync access handle 잠금이 비동기로 풀려, 재개가 1~2초 안에 시작되면 NoModificationAllowedError가 납니다. 약 6초 백오프 재시도로 해결했습니다.

결과: 12 GiB 파일을 5 %에서 일시정지 → 재개 → 17 %에서 새로고침 → 재개했을 때 기록된 2,302,019,106 B가 그대로 보존되고 CRC32가 일치했습니다. 이전 설계에서 같은 흐름은 CHECKSUM_MISMATCH였습니다.

실측: 53 GiB(56.9 GB)도 CloudFront를 통과한다

"50 GB 초과 객체도 8 GiB Range 파트로 나누면 CloudFront를 통과하고 캐시된다"는 요구사항의 전제를 실제 배포에서 확인했습니다.

패스 CloudFront 결과 요청 전송 소요(수신 + 병합 + CRC32)
1회차 Miss 7/7 (파트마다 오리진 인출) 7 56.96 GB 1,052 s
2회차 Hit 7/7 7 56.92 GB 1,095 s

두 패스 모두 병합 후 CRC32가 시드 값과 일치했습니다. 흥미로운 점은 캐시 히트 패스가 더 빠르지 않다는 것입니다. 네트워크 인출은 두 패스 모두 약 4분이고 나머지 13~14분은 로컬 작업(OPFS 기록 → 53 GiB 병합 → CRC32)이었습니다. 데스크톱급 회선에서는 병목이 디스크이고, 캐시의 이득은 대역폭이 좁은 해외 사용자에게서 나타납니다.

6. 세션과 URL 수명을 다운로드 시간에 맞추기

150 GB를 50 Mbps로 받으면 6.7시간입니다. 60분 무조작 로그아웃과 1시간 URL을 그대로 두면 한 시간마다 멈춥니다. 그래서

  • 분할·zip 다운로드가 진행 중이면 무조작으로 세지 않고, 끝난 시점부터 60분을 다시 셉니다.
  • API 클라이언트가 매 호출 전 fetchAuthSession()으로 토큰을 갱신합니다. 리프레시 토큰은 24시간(브라우저에 남는 토큰의 노출 창과 6.7시간 다운로드 사이의 타협).
  • 갱신 실패(관리자 비활성화, 24시간 도달)는 다운로드를 실패가 아닌 일시정지로 보존합니다. e2e에서 AdminUserGlobalSignOut → paused/AUTH_EXPIRED → 재로그인 → 108 s 뒤 재개 → CRC32 일치를 확인했습니다.

7. 정액제 플랜을 IaC로 연결하기

CloudFront 정액제 플랜은 CloudFormation 리소스 AWS::PricingPlanManager::Subscription으로 만들 수 있습니다. CDK에서는 설정값 하나로 켭니다.

// infra/lib/cdn-stack.ts (발췌) — cfg.flatRatePlan = { tier: 'PRO' } 일 때만
this.flatRatePlan = new pricingplanmanager.CfnSubscription(this, 'FlatRatePlan', {
  planFamily: 'CloudFront',
  planTier: this.cfg.flatRatePlan.tier,                 // PRO | BUSINESS | PREMIUM
  usageLevel: this.cfg.flatRatePlan.usageLevel ?? 'DEFAULT',
  resourceArns: [this.distribution.distributionArn, props.webAclArn],
});

알아 둘 점이 두 가지입니다.

  • 생성 직후 상태는 PENDING_APPROVAL이고 사람이 승인해야 플랜이 적용됩니다(콘솔 또는 approve-paid-subscription). 승인 즉시 과금이 시작되고 청구 주기 말까지 해지가 안 되므로, dev 환경에서는 승인하지 않고 두었습니다. 승인된 구독이 있으면 distribution 삭제(teardown)도 월말까지 막힙니다.
  • Pro 플랜은 관리형 정책만 허용합니다. 커스텀 캐시 정책, 커스텀 응답 헤더 정책, 실시간 로그, OAI(OAC는 가능)가 있으면 CreateSubscription이 거부됩니다. §4의 CORS 수정을 커스텀 응답 헤더 정책으로 했다면 여기서 막혔을 것입니다. 정액제를 쓸 계획이면 설계 단계에서 티어 기능표를 제약 조건으로 넣어야 합니다.

8. 운영: 로그 → Athena → 알람

  • CloudFront 표준 로그 v2(CloudWatch Logs 전송 → S3 parquet)는 distribution 리전과 무관하게 us-east-1에서만 전송 리소스를 만들 수 있습니다. 그래서 cdn-logs 스택을 us-east-1에 따로 두었습니다(8번째 스택). 로그 버킷은 워크로드 리전에 있어도 됩니다.
  • parquet의 모든 필드는 문자열입니다. Glue 테이블에서 sc_bytes를 bigint로 선언하면 Athena가 BINARY vs bigint로 실패합니다. 전부 string으로 두고 집계 쿼리에서 try_cast합니다.
  • opsDaily Lambda가 매일 캐시 히트율·국내/해외 바이트·정액제 허용량 사용률·grant↔DynamoDB 정합성(GrantDrift)을 계산해 CloudWatch 지표로 올리고, 감사 이벤트를 Glacier IR로 아카이브합니다. 알람 10개(허용량 50/80/100/200 %, 5xx, GrantDrift, 미서명 403 급증 등)가 SNS로 나갑니다.
  • 알람이 조용히 실패하던 결함도 있었습니다. SNS 토픽에 enforceSSL 정책을 붙이면서 CloudWatch가 발행할 수 있는 기본 문이 사라져 알람 10개가 전부 Failed to execute action이었습니다. 알람은 만든 뒤 set-alarm-state로 한 번 울려서 실제 수신을 확인해야 합니다.

9. 검증

단계 내용 결과
단위 contract 29 · infra/Lambda 256 · web 102 · tools 56 통과, Lambda 커버리지 99 %
통합(dev) 12 h 자격 증명, 조직 격리(타 조직 prefix 403, grant 없는 배정 NO_GRANT), 경로 규칙(동시 2건 → S3 1건), URL 수명(TTL 900 s 만료 15 s 뒤 403), grant 수명주기, CloudTrail auditContext, /downloads p95 298 ms 23/23
e2e(Playwright) Chromium: 분할·새로고침 이어받기·65분 무조작 중 토큰 갱신·인증 만료 후 재개·캐시 2패스·zip·zip 취소; Firefox: 안내·명령 폴백 14 시나리오 통과
인수 시나리오 11개(3시간 방치 다운로드, Safari 12 GB, 진행 중 계정 비활성화 포함) 11/11
보안 검토 16항목 증거, cdk-nag 억제 14종 재검토, IAM Access Analyzer finding 0, 수용 위험 4건 완료

인수 과정에서 잡은 제품 결함은 위에 적은 것들(CORS 캐시 키, swap 파일, 알람 SNS 정책, COMPOSITE 체크섬, zip 다운로드가 서비스 워커를 우회해 index.html을 저장하던 문제 등)이고 모두 수정·재검증했습니다.

자동화가 전부 녹색인 뒤, 사람이 5분 만에 찾은 결함 2건

위 표가 모두 통과한 상태에서 첫 사용자 테스트를 받았습니다. 로그인하고 목록을 보고 zip을 한 번 눌러 본 것뿐인데 결함이 두 개 나왔습니다. 둘 다 "정상 경로"만 검증한 테스트의 빈틈이었습니다.

1. 헤더 로고가 깨져 보였다. config.json의 brand.logoUrl은 /brand/logo.svg였지만 정적 버킷에 그 키가 한 번도 배포된 적이 없었습니다. 설계 문서에는 "로고는 배포 시 /brand/에 업로드"라고 적혀 있었고, 그 한 줄을 구현한 코드는 없었습니다. S3는 없는 객체에 403을 돌려주고 브라우저는 깨진 이미지 아이콘과 alt 텍스트를 그렸습니다. 단위 테스트는 config.json에 brand 이름이 들어가는지만 확인했고, e2e는 로고가 로드되는지 묻지 않았습니다.

고친 방법은 세 겹입니다. SPA가 중립적인 기본 로고(web/public/brand/logo.svg)를 함께 배포해 기본 URL이 항상 동작하게 했고, 배포 설정에 brand.logoFile을 추가해 로컬 이미지를 logoUrl 키로 함께 업로드하게 했습니다(고객 로고는 저장소 밖에 둡니다). 그리고 설정 로더가 아무것도 서빙하지 않는 상대 경로를 synth 단계에서 거부합니다. 헤더 컴포넌트는 onError에서 이미지를 숨깁니다. e2e에는 img.complete && img.naturalWidth > 0 한 줄이 추가됐습니다.

2. zip 다운로드를 취소하면 화면이 "zip 생성 중"에 머물렀다. 1 GiB 파일 하나를 zip으로 받다가 취소를 누르면 진행 숫자만 멈추고 취소 버튼이 그대로 남았습니다. 원인은 두 겹이었고, 두 번째는 첫 번째를 고친 뒤 배포 환경에서 반복 실행해야만 드러났습니다.

  • 취소가 끝나지 않는다. 취소 버튼은 AbortController를 abort합니다. fetch body가 에러로 끝나고, 서비스 워커에 넘긴 zip 스트림도 에러로 끝나 브라우저 다운로드는 "실패"가 됩니다. 그런데 runZip은 엔트리 제너레이터의 finally에서만 풀리는 promise를 기다리고 있었고, client-zip은 그 제너레이터를 return()이 없는 이터레이터로 감싸 소비합니다. for await 루프가 예외로 빠져나갈 때 return()이 없으면 제너레이터는 yield에 멈춘 채로 남고 finally는 영원히 실행되지 않습니다. 취소 신호 자체를 함께 기다리고 제너레이터를 직접 닫는 것으로 해결했습니다.

    // web/src/download/zip.ts (발췌) — 취소는 반드시 settle되어야 한다
    const aborted = new Promise<void>((r) => signal?.addEventListener('abort', () => r(), { once: true }));
    await Promise.race([finishedPromise, aborted]);
    if (signal?.aborted && !finished) {
      void entryIterator.return(undefined).catch(() => undefined); // client-zip은 return()을 부르지 않는다
      return { status: 'cancelled' };
    }
    
  • 취소 뒤에 도착한 진행률이 결과를 덮어쓴다. 위 수정으로 runZip은 cancelled를 돌려주는데, 배포 포털에서 다섯 번 중 두세 번은 여전히 화면이 바뀌지 않았습니다. 트레이스를 넣어 보니 순서는 늘 같았습니다. 취소 → runZip 종료 → 뷰가 done 상태를 설정 → 카운팅 스트림에 이미 들어와 있던 청크가 진행률을 한 번 더 보고 → 뷰가 running 상태를 설정. React는 두 상태 업데이트를 한 번에 처리하고 마지막 쓰기가 이깁니다. 로컬 개발 서버에서는 타이밍이 달라 재현되지 않았습니다. runZip은 abort 뒤에 보고를 멈추고, 뷰는 진행률을 함수형 업데이터로 running일 때만 반영하도록 바꿨습니다.

    onProgress: (progress) =>
      setPhase((prev) => (prev.kind === 'running' ? { kind: 'running', progress } : prev)),
    

    단위 테스트는 "취소 결과 뒤에 늦은 진행률이 와도 cancelled가 유지된다"를 고정하고(옛 코드에서는 실패), e2e zip 취소 시나리오는 배포 포털에서 5/5 통과합니다(수정 전 빌드 2/5).

같은 점검에서 위치 목록의 "버킷" 열도 숨겼습니다. 사용자마다 버킷은 하나라 정보가 없고, 버킷 이름에는 계정 ID가 들어 있었습니다.

교훈은 단순합니다. 다운로드 포털의 테스트는 완료 경로에 집중하기 쉽지만, 사용자가 가장 먼저 누르는 버튼은 취소이고 가장 먼저 보는 것은 로고입니다. 두 결함 모두 테스트 한 줄씩이면 잡혔습니다.

10. 비용 관찰

  • S3 직접 전송(서울, 첫 10 TB) 약 $0.126/GB 대 CloudFront Pro 정액제 $15/월(50 TB 허용량). 월 100 TB급이면 "두 번째부터 CloudFront"만으로도 반복 다운로드분이 정액제 안으로 들어갑니다. 유일 다운로드 비중이 크면 임계값을 0(전량 CloudFront)으로 바꾸는 편이 유리할 수 있어, 2~3개월 운영 뒤 재판단하도록 runbook에 넣었습니다.
  • S3 Access Grants는 요청 건수 과금(서울 $0.03/1,000건)이고 보유 비용이 없습니다. 브라우징·다운로드 규모에서 월 수 달러 이하입니다.
  • 브라우저 쪽 비용도 있습니다. A안은 파일 크기만큼의 시스템 드라이브 여유(OPFS)와 저장 위치의 여유를 동시에 요구합니다. UI가 시작 전에 두 값을 안내합니다.

11. 정리

  • Storage Browser는 "표시"에, S3 Access Grants는 "권한"에, Lambda는 "규칙"에만 관여하게 나누면 각 층의 테스트가 단순해집니다.
  • 경로 규칙은 DynamoDB ADD + ReturnValues: ALL_OLD 한 줄이면 충분하고, 임계값은 코드가 아니라 설정값으로 둡니다.
  • 캐시 키에 없는 요청 헤더(Origin)에 따라 오리진 응답이 달라지면 안 됩니다. 버킷 CORS *가 정답이었습니다.
  • 브라우저 파일 쓰기는 close() 시점에만 확정됩니다. 이어받기가 필요하면 OPFS + FileSystemSyncAccessHandle에 쓰고 마지막에 병합하세요. 용량은 estimate()가 아니라 truncate() 예약으로 판정합니다.
  • 정액제 플랜은 IaC로 연결할 수 있지만 승인은 사람이 하고, 티어의 기능 제약은 설계 단계의 입력입니다.
  • 표준 로그 v2 전송 리소스는 us-east-1, parquet 필드는 전부 문자열, 알람은 만든 뒤 한 번 울려 보기.

참조 구현(CDK 8스택, Lambda, React SPA, admin CLI, 통합·e2e 테스트, runbook, 요구사항·설계 문서)은 GitHub hmkim/omics-data-delivery-portal에 MIT-0 라이선스로 공개했습니다. 공개 저장소에서는 계정 ID·도메인·이메일을 자리표시자로 바꿨고, 측정값과 날짜는 dev 실측 그대로입니다. 질문과 개선 제안은 저장소 이슈로 남겨 주세요.