Background
PR #53의 hostdoc insight <code>는 로그 버킷의 .gz 객체를 나열 → 다운로드 → gunzip → W3C 파싱 → 집계하는 CLI-side 파이프라인이다. PR 본문도 "기본(--days 없음)은 ~30일 전체를 스캔한다"를 follow-up으로 명시했다.
이 이슈는 그 스캔 경로의 두 가지 비효율을 함께 다룬다 — 둘 다 runInsight의 같은 루프에 있어 한 PR로 처리하는 게 자연스럽다.
Problem
1. 서버측 prefix 필터를 안 쓴다
src/commands/insight.ts:
const allKeys = await listKeys(s3, logBucket, "");
const keys = filterLogKeys(allKeys, { since });
빈 prefix로 버킷 전체를 나열한 뒤 클라이언트에서 날짜를 거른다. 그런데 로그는 이미 파티션되어 있다 — infra/main.tf의 suffix_path = "{DistributionId}/{yyyy}/{MM}/{dd}/{HH}", 실제 키는 AWSLogs/<account-id>/CloudFront/<DistId>/<yyyy>/<MM>/<dd>/<HH>/... 형태다(파티셔닝 문서 — prefix 없는 버킷 ARN을 destination으로 주면 CloudFront가 AWSLogs/{account-id}/CloudFront/를 앞에 붙인다).
즉 --days 1이어도 30일치 전체 키를 페이징한 뒤 버린다. ListObjectsV2는 1000개씩 페이징하므로 트래픽이 많은 배포에서는 왕복이 그대로 늘어난다.
2. 파싱된 레코드를 전부 메모리에 쌓는다
const records: LogRecord[] = [];
for (const key of keys) {
records.push(...parseCloudFrontLog(gunzipSync(gz).toString("utf8")));
}
return formatInsight(aggregateInsight(records, args.code), keys.length);
모든 로그 파일의 모든 요청(다른 문서 대상, 봇, 에셋 포함 — 필터는 aggregateInsight 안에서야 적용된다)이 하나의 배열에 남는다. 메모리는 O(전체 요청 수)로 증가한다. 파일 단위로 집계하면 O(고유 IP 수)로 떨어진다.
Scope
- prefix 필터: 파티션 경로를 이용해
listKeys에 prefix를 넘긴다. 날짜 파티션이 {yyyy}/{MM}/{dd} 계층이라 단일 prefix로 임의 날짜 범위를 표현할 수 없으므로, 범위에 해당하는 일자별 prefix 목록을 만들어 나열하거나 최소한 AWSLogs/<acct>/CloudFront/<DistId>/ 까지는 좁힌다. 계정 ID를 모르므로 AWSLogs/ 수준부터 시작해도 유의미하다.
- 증분 집계:
aggregateInsight를 누적 가능한 형태로 바꾸거나(누적기 객체 주입), 파일별 부분 결과를 합치는 merge 함수를 추가. 배열 축적 제거.
- 기존 순수 함수 테스트(
test/insight.test.ts)를 유지하면서 누적 경로에 대한 테스트 추가.
Non-goals
- 병렬 다운로드·동시성 튜닝 — 별개 최적화이고 S3 요청 비용/스로틀과 얽힌다.
- Athena·S3 Select 등 서버측 쿼리로의 전환 — 아키텍처 변경 수준.
- 결과 캐싱.
Acceptance criteria
References
Background
PR #53의
hostdoc insight <code>는 로그 버킷의.gz객체를 나열 → 다운로드 → gunzip → W3C 파싱 → 집계하는 CLI-side 파이프라인이다. PR 본문도 "기본(--days없음)은 ~30일 전체를 스캔한다"를 follow-up으로 명시했다.이 이슈는 그 스캔 경로의 두 가지 비효율을 함께 다룬다 — 둘 다
runInsight의 같은 루프에 있어 한 PR로 처리하는 게 자연스럽다.Problem
1. 서버측 prefix 필터를 안 쓴다
src/commands/insight.ts:빈 prefix로 버킷 전체를 나열한 뒤 클라이언트에서 날짜를 거른다. 그런데 로그는 이미 파티션되어 있다 —
infra/main.tf의suffix_path = "{DistributionId}/{yyyy}/{MM}/{dd}/{HH}", 실제 키는AWSLogs/<account-id>/CloudFront/<DistId>/<yyyy>/<MM>/<dd>/<HH>/...형태다(파티셔닝 문서 — prefix 없는 버킷 ARN을 destination으로 주면 CloudFront가AWSLogs/{account-id}/CloudFront/를 앞에 붙인다).즉
--days 1이어도 30일치 전체 키를 페이징한 뒤 버린다.ListObjectsV2는 1000개씩 페이징하므로 트래픽이 많은 배포에서는 왕복이 그대로 늘어난다.2. 파싱된 레코드를 전부 메모리에 쌓는다
모든 로그 파일의 모든 요청(다른 문서 대상, 봇, 에셋 포함 — 필터는
aggregateInsight안에서야 적용된다)이 하나의 배열에 남는다. 메모리는 O(전체 요청 수)로 증가한다. 파일 단위로 집계하면 O(고유 IP 수)로 떨어진다.Scope
listKeys에 prefix를 넘긴다. 날짜 파티션이{yyyy}/{MM}/{dd}계층이라 단일 prefix로 임의 날짜 범위를 표현할 수 없으므로, 범위에 해당하는 일자별 prefix 목록을 만들어 나열하거나 최소한AWSLogs/<acct>/CloudFront/<DistId>/까지는 좁힌다. 계정 ID를 모르므로AWSLogs/수준부터 시작해도 유의미하다.aggregateInsight를 누적 가능한 형태로 바꾸거나(누적기 객체 주입), 파일별 부분 결과를 합치는 merge 함수를 추가. 배열 축적 제거.test/insight.test.ts)를 유지하면서 누적 경로에 대한 테스트 추가.Non-goals
Acceptance criteria
--days N이 지정되면 나열되는 S3 키가 해당 범위로 좁혀진다 (mock 기준ListObjectsV2호출의Prefix로 검증).hits/uniqueVisitors/range출력이 동일하게 유지된다 (회귀 테스트).References
src/commands/insight.ts(스캔 루프),src/lib/insight.ts(filterLogKeys,aggregateInsight),src/lib/aws.ts(listKeys)infra/main.tf(suffix_path파티션 정의)