업로드는 S3 Pre-signed URL, 다운로드는 CloudFront - URL을 분리해야 하는 이유

English (N/A)

파일 업로드 기능을 구현하다 보면 한번쯤 이런 생각이 든다. "CloudFront 도메인 하나로 업로드, 다운로드를 다 처리하면 안 되나?" 아키텍처가 단순해질 것 같고, 도메인도 하나로 통일되니 깔끔해 보인다.

결론부터 말하면 기술적으로는 가능하지만, 실제로는 거의 아무도 그렇게 하지 않는다. 이유가 있다.

CloudFront는 업로드용으로 설계된 서비스가 아니다

CloudFront는 CDN(Content Delivery Network)이다. 전 세계 edge location에 콘텐츠를 캐싱해서 빠르게 배포하는 것이 핵심 역할이다. 파일을 저장하는 기능은 없다. 업로드를 받더라도 결국 origin 서버(S3)로 요청을 프록시해줄 뿐이다.

CloudFront를 통해 S3에 PUT 요청을 보내는 것 자체는 된다. CloudFront 배포 설정에서 PUT/POST 메서드를 허용하면 된다. 하지만 여기서 치명적인 문제가 생긴다.

CloudFront URL을 업로드에 열어두면, 그 URL을 아는 사람은 언제든지 파일을 업로드할 수 있게 된다. CloudFront는 CDN 레이어이기 때문에 파일 크기 제한, Content-Type 검증, 업로드 권한 만료 같은 세밀한 제어를 하기가 매우 어렵다. 사실상 S3 버킷에 누구나 파일을 쑤셔 넣을 수 있는 구멍을 만드는 셈이다.

S3 Pre-signed URL이 업계 표준인 이유

실제로 대부분의 서비스는 이렇게 동작한다.

[업로드 흐름]
Client → Backend (Pre-signed URL 요청)
       → S3 (Pre-signed URL 생성)
       → Client (URL 수신)
       → S3 (URL로 직접 PUT)

[다운로드 흐름]
Client → CloudFront → S3 (파일 서빙)

업로드와 다운로드의 경로가 완전히 분리되어 있다.

S3 Pre-signed URL은 IAM 자격증명을 사용해서 서버 측에서 생성하는 임시 URL이다. 이 URL에는 만료 시간, 허용되는 파일 크기, Content-Type 같은 조건이 서명에 포함된다. URL이 외부에 노출되더라도 만료 후에는 더 이상 쓸 수 없고, 조건을 벗어나는 업로드는 S3가 자동으로 거부한다.

구현도 간단하다. AWS SDK를 쓰면 몇 줄로 생성할 수 있다.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-2" });

async function generateUploadUrl(key: string, contentType: string) {
  const command = new PutObjectCommand({
    Bucket: "my-bucket",
    Key: key,
    ContentType: contentType,
    // 파일 크기 제한 (10MB)
    ContentLengthRange: [0, 10 * 1024 * 1024],
  });

  // 15분 후 만료
  const url = await getSignedUrl(s3, command, { expiresIn: 900 });
  return url;
}

클라이언트는 이 URL을 받아서 S3에 직접 PUT 요청을 보낸다. 서버는 업로드 트래픽을 전혀 처리하지 않아도 된다.

업로드 완료 후 CloudFront URL을 만들어 저장한다

여기서 핵심 패턴이 나온다.

파일 업로드가 완료된 뒤, 서버는 해당 파일의 CloudFront URL 을 만들어서 데이터베이스에 저장한다. 이후 클라이언트가 파일을 조회할 때는 S3 URL이 아니라 CloudFront URL을 돌려준다.

// 업로드 완료 후 DB에 저장하는 URL
const cloudfrontUrl = `https://d1234567890.cloudfront.net/${key}`;

await db.file.create({
  key,
  url: cloudfrontUrl, // CloudFront URL을 저장
  uploadedAt: new Date(),
});

이렇게 하면 몇 가지 이점이 생긴다.

S3 버킷을 퍼블릭으로 열 필요가 없다. CloudFront가 S3 앞단에서 접근을 중개하기 때문에, S3는 CloudFront에서만 접근 가능하도록 닫아둘 수 있다(OAC, Origin Access Control). 파일을 직접 S3 URL로 접근하는 것 자체를 막을 수 있다.

전 세계 어디서든 빠르게 다운로드된다. CDN의 본래 역할이다. 이미지, 동영상 같은 정적 파일을 edge location에서 서빙하니 latency가 줄어든다.

S3 요청 비용이 줄어든다. CloudFront가 캐싱하기 때문에 S3에 직접 요청이 들어오는 횟수가 줄어든다.

CloudFront Signed URL은 언제 쓰나

CloudFront에도 Signed URL이 있다. 하지만 이건 업로드가 아니라 다운로드 접근 제어 에 쓰는 도구다. 유료 콘텐츠처럼 특정 사용자만 특정 시간 동안 파일에 접근할 수 있어야 할 때 사용한다.

S3 Pre-signed URL과 비교하면 구현이 복잡하다. RSA 키페어를 별도로 관리해야 하고, 개인키로 직접 서명을 해야 한다. 만료 시간과 IP 제한 정도의 제어만 가능하다. 반면 S3 Pre-signed URL은 IAM 자격증명만으로 생성할 수 있고, 파일 크기나 Content-Type까지 조건으로 걸 수 있다.

업로드에는 S3 Pre-signed URL을, 다운로드 접근 제어가 필요하다면 CloudFront Signed URL을 쓰는 것이 각각의 도구를 제 용도에 맞게 쓰는 것이다.

글로벌 업로드 속도를 높이고 싶다면

한 가지 오해가 있을 수 있다. "CloudFront의 edge network를 업로드에도 활용하면 전 세계 사용자의 업로드 속도가 빨라지지 않나?" 라는 생각이다.

이 문제를 해결하는 방법이 따로 있다. S3 Transfer Acceleration 이다. CloudFront와 같은 AWS 글로벌 edge network를 활용하면서, S3에 직접 업로드하는 방식이다. bucket.s3-accelerate.amazonaws.com 엔드포인트로 업로드하면 된다. S3 Pre-signed URL과 함께 사용할 수 있어서 구현도 간단하다.

더 복잡한 요구사항이라면 멀티 리전 S3 버킷과 Route 53의 지연시간 기반 라우팅을 조합하거나, 대용량 파일은 Multipart Upload를 병행하는 방식을 쓴다. CloudFront를 업로드 경로에 끼워 넣는 것보다 훨씬 실용적인 선택지들이다.

정리

결국 이 패턴의 핵심은 역할 분리 다.

  • 업로드: S3 Pre-signed URL (임시, 만료, 조건 제한)
  • 다운로드: CloudFront URL (캐싱, 빠른 배포, S3 보호)

CloudFront에 PUT을 허용하는 순간, "CDN 도메인으로 언제든 파일 업로드 가능"이라는 보안 구멍이 생긴다. Pre-signed URL은 이 구멍을 원천 차단하면서, 업로드 후에는 읽기 전용 CloudFront URL만 노출한다.

도구를 설계 의도에 맞게 쓰는 것. 클라우드 아키텍처에서도 결국 같은 원칙이 적용된다.

참고