junyeokk
Blog
AWS·2025. 12. 10

S3 Key 저장 패턴

S3에 파일을 업로드하면 해당 파일에 접근할 수 있는 URL이 생긴다. 이 URL을 데이터베이스에 그대로 저장하고 싶은 유혹이 있다. https://my-bucket.s3.ap-northeast-2.amazonaws.com/uploads/photo-123.jpg 같은 전체 URL을 칼럼에 넣어두면 프론트엔드에서 바로 <img src={url}> 로 쓸 수 있으니 편해 보인다.

그런데 이 방식은 시간이 지나면 거의 반드시 문제를 일으킨다.


전체 URL 저장이 위험한 이유

CDN 도메인 변경

서비스가 성장하면 S3 직접 접근에서 CloudFront 같은 CDN으로 전환하게 된다. S3 URL은 https://bucket.s3.region.amazonaws.com/key 형태이고, CloudFront URL은 https://d1234abcdef.cloudfront.net/key 형태다. 여기에 커스텀 도메인을 붙이면 https://cdn.myservice.com/key가 된다.

DB에 전체 URL이 저장되어 있으면? 도메인이 바뀔 때마다 전체 레코드를 마이그레이션해야 한다. 수십만 행의 URL을 일괄 업데이트하는 건 위험하고 시간도 오래 걸린다.

sql
-- 이런 마이그레이션을 하고 싶지 않다
UPDATE assets 
SET url = REPLACE(url, 
  'https://my-bucket.s3.ap-northeast-2.amazonaws.com', 
  'https://cdn.myservice.com'
)
WHERE url LIKE 'https://my-bucket.s3%';

버킷 이름 변경

AWS에서 버킷 이름은 변경할 수 없다. 이름을 바꾸려면 새 버킷을 만들고 객체를 복사한 뒤 기존 버킷을 삭제해야 한다. URL을 저장했다면 버킷 이름이 URL에 포함되어 있으므로 역시 전체 마이그레이션이 필요하다.

리전 변경

서비스를 다른 리전으로 확장하거나 이전할 때도 마찬가지다. S3 URL에는 리전 정보(ap-northeast-2)가 포함되어 있어서 리전이 바뀌면 모든 URL이 무효화된다.

환경별 분리 불가

개발/스테이징/프로덕션 환경에서 각각 다른 버킷을 사용하는 건 기본이다. URL을 저장하면 환경마다 다른 값이 DB에 들어가므로, 프로덕션 DB를 스테이징으로 복제해서 테스트하는 것조차 복잡해진다.


Key만 저장하는 패턴

해결책은 간단하다. S3 객체의 key(경로)만 데이터베이스에 저장하고, 전체 URL은 런타임에 조합한다.

S3에서 key란 버킷 내 객체의 경로다. uploads/photos/2025/12/photo-abc123.jpg 같은 문자열이다. 버킷 이름, 리전, 프로토콜 등 인프라 정보를 일절 포함하지 않는다.

typescript
// Entity 정의 - key만 저장
@Entity()
export class Asset {
  @PrimaryKey({ type: 'uuid' })
  id: string = v4();

  @Property()
  s3Key!: string;  // "uploads/photos/2025/12/photo-abc123.jpg"

  @Property()
  type!: AssetType;
}

전체 URL은 서비스 레이어에서 환경 변수와 조합해서 만든다.

typescript
@Injectable()
export class AssetService {
  private readonly cdnUrl: string;

  constructor(private readonly config: ConfigService) {
    this.cdnUrl = this.config.get('ASSETS_CDN_URL');
    // 개발: http://localhost:4566/my-bucket
    // 스테이징: https://staging-cdn.myservice.com
    // 프로덕션: https://cdn.myservice.com
  }

  getPublicUrl(asset: Asset): string {
    return `${this.cdnUrl}/${asset.s3Key}`;
  }
}

이렇게 하면 인프라가 어떻게 바뀌든 환경 변수 하나만 수정하면 된다. DB 마이그레이션은 필요 없다.


Key 설계 전략

S3 key를 어떻게 구성하느냐도 중요하다. 단순히 파일 이름만 넣으면 충돌이 발생하고, 너무 복잡하면 관리가 어렵다.

UUID 기반 key

사용자가 업로드한 원본 파일명을 그대로 key로 쓰면 안 된다. 같은 이름의 파일이 덮어쓰기될 수 있고, 한글이나 특수문자가 포함되면 인코딩 문제가 생긴다.

typescript
function generateS3Key(originalFilename: string, prefix: string): string {
  const ext = path.extname(originalFilename).toLowerCase();
  const uuid = v4();
  return `${prefix}/${uuid}${ext}`;
  // "uploads/photos/550e8400-e29b-41d4-a716-446655440000.jpg"
}

UUID를 사용하면 충돌 가능성이 사실상 없고, 파일명에서 오는 인코딩 문제도 없다.

날짜 기반 파티셔닝

파일이 많아지면 날짜별로 디렉터리를 나누는 게 좋다. S3는 실제 디렉터리 구조가 아니라 key의 prefix로 가상 폴더를 구현하지만, 콘솔에서 탐색하거나 특정 기간의 파일을 일괄 처리할 때 훨씬 편하다.

typescript
function generateS3Key(originalFilename: string, type: string): string {
  const ext = path.extname(originalFilename).toLowerCase();
  const uuid = v4();
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  
  return `${type}/${year}/${month}/${day}/${uuid}${ext}`;
  // "photos/2025/12/14/550e8400-e29b-41d4-a716-446655440000.jpg"
}

리소스 유형별 prefix

자산의 종류에 따라 최상위 prefix를 나누면 관리가 수월하다.

text
photos/          → 촬영 원본 사진
frames/          → 프레임 이미지
composites/      → 합성 결과물
thumbnails/      → 썸네일
temp/            → 임시 파일 (lifecycle 정책으로 자동 삭제)

특히 temp/ prefix에 S3 Lifecycle 정책을 걸어두면 임시 파일이 자동으로 삭제되므로 별도의 정리 로직이 필요 없다.

json
{
  "Rules": [
    {
      "ID": "DeleteTempFiles",
      "Filter": { "Prefix": "temp/" },
      "Status": "Enabled",
      "Expiration": { "Days": 1 }
    }
  ]
}

DTO에서 URL 변환

key는 DB에만 저장하고, API 응답에서는 완전한 URL로 변환해서 내보내야 한다. 프론트엔드가 key를 받아서 URL을 직접 조합하게 하면 인프라 정보가 클라이언트에 노출되는 셈이다.

typescript
// 응답 DTO
class AssetResponseDto {
  id: string;
  url: string;  // 완전한 URL로 변환해서 내보냄
  type: AssetType;
}

// 서비스에서 변환
toResponseDto(asset: Asset): AssetResponseDto {
  return {
    id: asset.id,
    url: `${this.cdnUrl}/${asset.s3Key}`,
    type: asset.type,
  };
}

이 변환을 서비스 한 곳에서만 처리하면, CDN 도메인이 바뀌거나 URL에 서명을 추가하는 등의 변경이 생겨도 수정 지점이 하나뿐이다.

Private 파일과 Presigned URL 조합

모든 파일이 공개 접근 가능한 건 아니다. 인증된 사용자만 접근해야 하는 파일은 Presigned URL을 생성해서 응답한다. 이때도 DB에 key만 있으면 된다.

typescript
async getPrivateUrl(asset: Asset): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: this.bucket,
    Key: asset.s3Key,
  });
  
  return getSignedUrl(this.s3Client, command, {
    expiresIn: 3600, // 1시간
  });
}

공개 파일은 CDN URL, 비공개 파일은 Presigned URL. key만 저장해두면 접근 방식을 자유롭게 전환할 수 있다.


마이그레이션: URL에서 Key로

이미 전체 URL을 저장하고 있는 프로젝트라면, key 패턴으로 마이그레이션하는 방법이다.

typescript
// 1. 기존 URL에서 key 추출
function extractKeyFromUrl(url: string): string {
  const urlObj = new URL(url);
  // S3 URL: https://bucket.s3.region.amazonaws.com/key
  // CloudFront URL: https://dist.cloudfront.net/key
  return urlObj.pathname.startsWith('/') 
    ? urlObj.pathname.slice(1) 
    : urlObj.pathname;
}

// 2. 마이그레이션 스크립트
async function migrateUrlsToKeys(em: EntityManager) {
  const assets = await em.find(Asset, { s3Key: { $like: 'http%' } });
  
  for (const asset of assets) {
    asset.s3Key = extractKeyFromUrl(asset.s3Key);
  }
  
  await em.flush();
}

한 가지 주의할 점은 pathname에서 앞의 /를 제거해야 한다는 것이다. S3 key는 슬래시로 시작하지 않는다. uploads/photo.jpg이지 /uploads/photo.jpg가 아니다.


다중 스토리지 대응

key 패턴의 또 다른 장점은 스토리지 백엔드를 추상화할 수 있다는 것이다. 개발 환경에서는 로컬 파일시스템이나 MinIO를 쓰고, 프로덕션에서는 S3를 쓰는 구성이 가능하다.

typescript
interface StorageProvider {
  upload(key: string, body: Buffer, contentType: string): Promise<void>;
  getUrl(key: string): string;
  delete(key: string): Promise<void>;
}

class S3StorageProvider implements StorageProvider {
  getUrl(key: string): string {
    return `${this.cdnUrl}/${key}`;
  }
}

class LocalStorageProvider implements StorageProvider {
  getUrl(key: string): string {
    return `http://localhost:3000/static/${key}`;
  }
}

DB에는 동일한 key가 저장되고, 어떤 스토리지에서 서빙하느냐만 환경에 따라 달라진다. key가 스토리지에 종속되지 않기 때문에 이런 추상화가 자연스럽게 가능하다.


정리

저장 방식DB 값CDN 변경 시환경 분리
전체 URLhttps://bucket.s3...amazonaws.com/uploads/photo.jpg전체 마이그레이션환경마다 다른 URL
Key만uploads/photo.jpg환경 변수 수정동일한 key 유지

핵심은 인프라 정보와 데이터를 분리하는 것이다. key는 "이 파일이 무엇인지"를 나타내는 논리적 식별자이고, 도메인/버킷/리전은 "어디에 있는지"를 나타내는 물리적 위치다. 이 둘을 섞어서 저장하면 물리적 위치가 바뀔 때마다 데이터를 건드려야 한다. 분리해두면 설정만 바꾸면 된다.


관련 문서