미디어용 콘텐츠 해시 키, 불변성 및 중복 제거
이미지를 파일명이 아닌 바이트의 sha256 값으로 저장하세요. 불변성을 거저 얻고, 중복이 자동으로 제거되며, env var 하나로 이동할 수 있는 스토리지를 확보하게 됩니다.
객체 스토리지에 이미지를 업로드할 때, 가장 명확한 키는 파일 이름입니다: img/hero.png. 읽기 좋고 이해하기 쉽지만, 조용히 캐시 무효화, 중복 객체, 데이터베이스에 고정된 URL 문제를 겪게 만듭니다. 이 세 가지를 모두 피할 수 있는 다른 키가 있습니다. 파일을 따라 객체 이름을 짓는 대신, 바이트를 따라 이름을 짓는 것입니다. 파일 콘텐츠의 sha256을 계산하고 img/{sha256}.{ext}에 저장합니다. 이름이 아닌 콘텐츠가 키를 결정합니다. 이 게시물은 그 선택에 대한 전체적인 논거, 이를 구현하는 Go 코드, 그리고 여전히 파일 이름 키가 올바른 선택인 한 가지 경우에 대해 다룹니다.
콘텐츠 해시 키의 작동 방식
규칙은 한 줄입니다. 스토리지 키는 파일 콘텐츠의 해시입니다. 바이트를 읽고, 그 위에 sha256을 실행하고, 다이제스트를 헥스 인코딩한 다음, 객체를 img/{digest}.{ext}에 둡니다. 콘텐츠 해시가 9f86d0...인 파일은 img/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08.png에 위치하게 됩니다.
여기서 즉시 두 가지 속성이 파생되며, 이 게시물의 다른 모든 내용은 이 속성들의 결과입니다.
- 동일한 바이트는 항상 동일한 키를 생성합니다. 두 개의 다른 소스에서, 두 개의 다른 원본 이름으로 동일한 파일을 두 번 업로드해도, 둘 다 정확히 동일한 객체에 위치하게 됩니다.
- 다른 바이트는 다른 키를 생성합니다. 단일 픽셀만 변경해도 다이제스트가 완전히 변경되므로, 편집된 파일은 새 키의 새 객체가 되고 이전 키는 여전히 이전 바이트를 가리킵니다.
이것이 콘텐츠 주소 지정(content addressing)입니다. 데이터의 주소는 데이터 자체에서 파생됩니다. Git도 같은 이유로 blob에 대해 동일한 작업을 수행합니다.
불변성: 영원히 캐시하고, 무료로 무효화하기
키는 오직 하나의 정확한 바이트 시퀀스만 참조할 수 있기 때문에, 해당 키의 객체는 절대 변경되지 않습니다. 바로 그 사실 하나가 웹이 가진 가장 강력한 캐싱 헤더를 사용할 수 있게 합니다.
Cache-Control: public, immutable, max-age=31536000
max-age=31536000은 1년입니다. immutable은 사용자가 새로고침할 때 브라우저가 If-None-Match로 재검증할 필요조차 없다고 알려줍니다. 이들이 인코딩하는 약속이 실제로 사실이기 때문에 여기서는 둘 다 안전합니다. 즉, 이 URL 뒤의 바이트는 객체가 존재하는 한 변경되지 않습니다. R2 또는 S3 앞의 CDN은 이를 한 번 캐시하고 제거될 때까지 엣지에서 제공하며, 오리진(origin)은 해당 파일에 대한 반복 트래픽을 거의 보지 못합니다.
이제 무효화 스토리를 비교해 보겠습니다. 파일 이름 키를 사용하면 hero.png를 업데이트하는 것은 객체를 덮어쓰는 것을 의미하며, 이전 버전을 이미 보유하고 있는 모든 캐시는 이제 잘못된 상태가 됩니다. CDN을 퍼지(purge)해야 하고, 어떤 브라우저도 동일한 URL로 오래된 복사본을 가지고 있지 않기를 바라야 합니다. 콘텐츠-해시 키를 사용하면 아무것도 덮어쓰지 않습니다. 편집된 이미지는 새로운 키이므로 새로운 URL이며, 이 새로운 URL은 잠시 전까지 존재하지 않았기 때문에 캐시된 적이 없습니다. 이전 URL은 여전히 이를 참조하는 모든 사람에게 이전 바이트를 계속 제공합니다. 캐시 무효화는 더 이상 여러분이 수행하는 작업이 아니게 됩니다. URL의 의미가 절대 변하지 않기 때문에 그것은 일어날 수 없는 일이 됩니다.
중복 제거: HeadObject 후 PutObject
동일한 바이트가 항상 동일한 키에 매핑된다면 동일한 파일을 두 번 저장하는 것은 한 번 저장하는 것과 같습니다. 업로드 경로는 객체를 쓰기 전에 확인합니다.
- 콘텐츠 해시 키를 계산합니다.
- 해당 키에 대해
HeadObject를 실행합니다. - 객체가 존재하면 아무것도 하지 않습니다. 바이트가 이미 있기 때문입니다.
- 객체가 존재하지 않으면
PutObject를 실행합니다.
이렇게 하면 업로드가 멱등성을 갖게 됩니다. 다시 실행하는 것은 저렴하고 안전하며, 이는 수집 작업이 재시도되거나 동일한 자산이 세 개의 다른 게시물에 나타날 때 중요합니다. 40개의 기사에서 참조되는 공유 로고는 40개가 아닌 하나의 객체입니다. HeadObject에 대한 비용을 지불하게 되는데, 이는 메타데이터 호출이며 전체 본문을 중복으로 업로드하는 것보다 훨씬 저렴합니다.
// putImmutable stores b under a content-hash key, skipping the upload
// if an object with that key already exists.
func putImmutable(ctx context.Context, cli *s3.Client, bucket string, b []byte, ext string) (string, error) {
sum := sha256.Sum256(b)
key := fmt.Sprintf("img/%s.%s", hex.EncodeToString(sum[:]), ext)
_, err := cli.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &key,
})
if err == nil {
return key, nil // already present, dedup hit
}
var nf *types.NotFound
if !errors.As(err, &nf) {
return "", fmt.Errorf("head %s: %w", key, err)
}
_, err = cli.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: bytes.NewReader(b),
ContentType: aws.String(contentType(ext)),
CacheControl: aws.String("public, immutable, max-age=31536000"),
})
if err != nil {
return "", fmt.Errorf("put %s: %w", key, err)
}
return key, nil
}
HeadObject는 누락된 키에 대해 형식화된 NotFound 오류를 반환하며, 그 외의 모든 것은 무시해서는 안 되는 실제 실패라는 점에 유의하십시오. 모든 오류를 부재로 처리하면 일시적인 네트워크 오류가 중복 업로드로 이어져 본래의 목적을 해치게 됩니다.
데이터베이스에서 스토리지 분리하기
세 번째 이점은 캐싱과 아무 관련이 없습니다. 이는 URL이 어디에 있는지에 관한 것입니다.
흔한 실수는 전체 미디어 URL(https://media.example.com/img/9f86d0....png)을 게시물 옆의 열에 저장하는 것입니다. 이 방법은 호스트를 변경하거나, 버킷 이름을 바꾸거나, 도메인을 이전하는 날까지는 작동합니다. 그러면 그 모든 절대 URL이 잘못되고, 이를 수정하려면 콘텐츠에 대한 마이그레이션을 해야 합니다.
콘텐츠-해시 키는 깔끔한 분리를 제공합니다. 데이터베이스는 img/9f86d0....png와 같은 키만 저장하고 호스트는 절대 저장하지 않습니다. 절대 URL은 렌더링 시 환경 변수로부터 조립됩니다.
func mediaURL(base, key string) string {
return strings.TrimRight(base, "/") + "/" + key
}
// base comes from MEDIA_BASE_URL, e.g. https://media.example.com
url := mediaURL(cfg.MediaBaseURL, post.HeaderKey)
R2에서 다른 공급자로 이전하거나 새로운 CDN을 앞에 두는 것은 이제 행이나 게시물 본문을 건드리지 않는 2단계 작업입니다. 객체를 새 위치로 복사한 다음 MEDIA_BASE_URL을 변경합니다. 키는 콘텐츠에서 파생되므로 양쪽에서 동일하며, 따라서 동일한 키는 어디에서 호스팅되든 동일한 바이트로 해석됩니다. 데이터베이스는 오늘 어느 버킷이 파일을 제공하는지 알지도 못하고 신경 쓰지도 않습니다.
업로드 파이프라인, 엔드투엔드
다음은 마크다운 게시물을 처리하는 수집 단계에서 각 부분이 어떻게 맞춰지는지에 대한 설명입니다. 작성자는 와 같은 상대 참조를 작성하고, 파이프라인은 업로드하면서 이를 콘텐츠-해시 키로 재작성합니다.
- 게시물을 파싱하고 모든 로컬 이미지 참조를 수집합니다.
- 참조된 각 파일에 대해 바이트를 읽고 콘텐츠-해시 키를 계산합니다.
HeadObject를 실행한 다음, miss 시에만 불변 캐시 헤더와 함께PutObject를 실행합니다.- 본문의 상대 참조를 저장된 키로 교체합니다.
- 게시물을 영속화합니다. 이제 본문과 데이터베이스는 절대 URL이 아닌 키를 가집니다.
재작성 단계 덕분에 작성자 경험이 단순하게 유지됩니다. 작성자는 리포지토리에서 의미가 통하는 상대 경로를 다루며, 해싱과 버킷에 대해 아는 곳은 파이프라인뿐입니다.
func processBody(ctx context.Context, up *Uploader, body string, refs []ImageRef) (string, error) {
for _, ref := range refs {
b, err := os.ReadFile(ref.LocalPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", ref.LocalPath, err)
}
key, err := up.PutImmutable(ctx, b, ref.Ext)
if err != nil {
return "", err
}
body = strings.ReplaceAll(body, ref.Original, key)
}
return body, nil
}
트레이드오프: 고아 객체와 인플레이스 업데이트 불가
콘텐츠 주소 지정은 공짜가 아니며, 이를 좋게 만드는 바로 그 속성이 한 가지 특정한 면에서 어색하게 만듭니다. 바이트가 조금이라도 변경되면 새로운 키가 생성되기 때문에 파일을 인플레이스(in-place)로 업데이트할 수 없습니다. 덮어쓰기라는 개념은 존재하지 않습니다. 이미지를 수정하면 새로운 키에 새로운 객체가 생성됩니다. 이전 객체는 원래 있던 자리에 그대로 남아 있습니다.
더 이상 아무것도 이전 키를 참조하지 않으면 해당 객체는 고아 객체가 됩니다. 저장 공간을 차지하지만 아무도 읽지 않습니다. 시간이 지남에 따라 수정되고 교체된 애셋은 데드 웨이트(dead weight)로 축적됩니다. 다른 게시물이나 오래된 캐시된 페이지가 여전히 해당 키를 가리키고 있는지 스토리지가 알 수 없으므로 자동 정리는 없습니다. 가비지 컬렉터가 필요합니다. 주기적으로 버킷의 객체 목록을 가져오고, 라이브 콘텐츠 전체에서 실제로 참조되는 키 목록을 가져온 다음, 첫 번째 집합에는 나타나지만 두 번째 집합에는 나타나지 않는 객체를 삭제해야 합니다. 스케줄에 따라 실행하고, 고아 객체에 삭제 전 유예 기간을 주어 진행 중인 호출자가 404 오류를 받지 않도록 해야 합니다.
아래 표는 두 모델을 나란히 비교하여 보여줍니다.
| 고려 사항 | 파일명 키 (img/hero.png) |
콘텐츠 해시 키 (img/{sha256}.png) |
|---|---|---|
| Cache-Control | 재검증 또는 짧은 TTL 필요 | immutable, max-age=31536000 |
| 이미지 업데이트 | 동일한 키에 덮어쓰고 CDN 퍼지 | 새로운 키, 새로운 URL, 퍼지 불필요 |
| 중복 업로드 | 별개의 객체 | 하나의 객체, 중복 제거됨 |
| 오래된 캐시 위험 | 실제로 존재, 무효화 필요 | 없음, URL의 의미가 절대 변하지 않음 |
| DB 저장 내용 | 주로 절대 URL | 키만 저장, 호스트는 환경 변수에서 가져옴 |
| 정리 부담 | 덮어쓰기로 공간 회수 | 고아 객체 축적, GC 필요 |
| 인플레이스 업데이트 | 자연스러움 | 설계상 불가능 |
마지막 두 행이 비용입니다. 그 위의 모든 것이 이점입니다. 이 트레이드오프가 가치가 있는지는 파일이 실제로 얼마나 자주 변경되는지, 그리고 그 대가로 얻는 캐싱과 중복 제거(dedup)를 얼마나 중요하게 생각하는지에 달려 있습니다.
파일 이름 키가 여전히 더 나은 선택일 때
콘텐츠 주소 지정은 한 번 쓰고 여러 번 읽는 미디어에 유리하며, 이는 콘텐츠 사이트의 거의 모든 이미지, 비디오, 글꼴, 정적 다운로드에 해당합니다. 바이트의 정체성보다 슬롯의 정체성이 더 중요할 때는 잘못된 도구입니다.
다음과 같은 경우 안정적인 파일 이름 키를 사용하세요.
- 항상 최신 버전을 제공하는 단일 표준 URL을 원하고, 새 URL을 만드는 것보다 CDN을 퍼지하는 것을 선호하는 경우. 호출자가 참조를 업데이트해서는 안 되는 상황에서 마케팅팀이
logo/current.svg를 제자리에서 덮어쓰는 것은 파일 이름 작업입니다. - 경로 자체가 시스템이 의존하는 의미를 지니는 경우, 예를 들어
users/{id}/avatar.png처럼 위치가 조회 키이고 슬롯당 정확히 하나의 라이브 객체가 있는 경우입니다. 콘텐츠 주소 지정은 각 아바타 편집을 새로운 키들로 흩어지게 만들고, 결국 어떤 것이 최신인지 추적해야 하는 부담을 남깁니다. - 객체가 크고, 변경 가능하며, 지속적으로 편집되어, 합리적인 GC가 정당화할 수 있는 것보다 고아(orphan) 더미가 더 빨리 커지는 경우.
이러한 경우에는 무효화 작업을 감수하고 이름을 유지하세요. 사실상 한 번만 쓰는 모든 것에 대해서는 콘텐츠가 키를 선택하게 하세요. 거의 필요하지 않았던 제자리 업데이트를 불변성, 중복 제거, 그리고 단 하나의 행도 변경하지 않고 스토리지 이동에서 살아남는 데이터베이스와 맞바꾸는 것입니다.