본문 바로가기
Amazon Web Services

S3 presigned URL로 브라우저에서 직접 업로드

고구마엔사이다·2026년 9월 13일·조회 0

파일 업로드 기능을 붙일 때마다 매번 같은 고민을 한다. 사용자가 올린 수백 MB짜리 영상이나 백업 파일을 애플리케이션 서버가 한 번 받아서 다시 S3로 넘기면, 서버 네트워크와 메모리, 임시 디스크가 그대로 부담을 진다. 트래픽이 몰리면 이 구간이 먼저 터진다. presigned URL은 이 중간 서버를 빼고 브라우저가 S3에 바로 쓰게 해주는 방법이라 오래전부터 써왔는데, CORS와 만료 처리에서 헷갈려하는 경우가 많아 한 번 정리한다.

결론부터 말하자면, 백엔드는 S3에 PutObject 권한을 가진 자격증명으로 짧게 만료되는 presigned URL만 발급하고, 실제 바이트 전송은 브라우저가 그 URL로 직접 PUT 요청을 보내 처리한다. 버킷에는 CORS 규칙을 걸어 브라우저 요청을 허용하고, 100MB를 넘는 파일은 multipart presigned URL로 조각내서 올린다. 서버는 파일 데이터를 만지지 않는다.

1. presigned URL이란

presigned URL은 특정 S3 작업(예: 객체 업로드) 하나를 정해진 시간 동안만 수행할 수 있도록 미리 서명해둔 URL이다. URL 자체에 서명이 들어 있어서, 이 URL을 받은 쪽은 AWS 자격증명이나 IAM 권한이 없어도 해당 작업을 실행할 수 있다. 서명을 만든 주체(IAM 사용자 또는 역할)의 권한 범위 안에서만 동작한다.

핵심 성질 두 가지만 기억하면 된다. 첫째, presigned URL은 사실상 소지자 토큰(bearer token)이다. URL을 가진 사람은 누구든 그 작업을 할 수 있으니 노출에 주의해야 한다. 둘째, 만료 시각은 URL을 만들 때 정하지만, 서명에 쓴 자격증명이 먼저 만료되면 URL도 같이 죽는다. 이 둘이 뒤에서 계속 문제가 된다.

업로드용 URL은 HTTP 메서드가 PUT이다. 브라우저가 이 URL로 파일 본문을 실어 PUT을 보내면 S3가 객체를 생성한다. 같은 키가 이미 있으면 덮어쓴다.

2. IAM 권한을 최소로 좁힌다

presigned URL은 서명한 자격증명의 권한을 그대로 물려받는다. 그러니 발급용 자격증명에는 딱 필요한 업로드 권한만 준다. 버킷 전체에 s3:*를 주면 안 된다.

업로드를 특정 프리픽스(uploads/)로 제한한 최소 정책은 다음과 같다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowPresignPutOnly",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/uploads/*"
    }
  ]
}

참고로 multipart 업로드에서 쓰는 CreateMultipartUpload, UploadPart, CompleteMultipartUpload도 IAM 권한상으로는 모두 s3:PutObject 하나로 커버된다. 그래서 이 정책 하나면 뒤에 나오는 multipart까지 다 된다.

3. 백엔드에서 PUT presigned URL 발급

Next.js(App Router)의 API 라우트에서 AWS SDK for JavaScript v3로 발급하는 예다. @aws-sdk/client-s3@aws-sdk/s3-request-presigner가 필요하다.

npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

발급 라우트는 이렇게 짠다. 키는 서버가 정하고, 클라이언트에는 URL과 키만 내려준다.

// app/api/s3/presign/route.js
import { randomUUID } from "node:crypto";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

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

export async function POST(req) {
  const { filename, contentType } = await req.json();
  const key = `uploads/${randomUUID()}/${filename}`;

  const cmd = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
  });

  // expiresIn: 초 단위, 최대 604800(7일)
  const url = await getSignedUrl(s3, cmd, { expiresIn: 300 });
  return Response.json({ url, key });
}

여기서 한 번 걸리는 지점이 ContentType이다. 서명에 ContentType을 넣으면, 브라우저가 PUT을 보낼 때 정확히 같은 Content-Type 헤더를 실어야 한다. 값이 다르면 S3가 SignatureDoesNotMatch로 거절한다. 서명한 값과 실제 요청 헤더가 한 글자라도 어긋나면 서명 검증이 깨진다고 보면 된다.

키를 서버가 randomUUID()로 만드는 이유도 여기 있다. 클라이언트가 키를 마음대로 정하게 두면 남의 객체를 덮어쓰거나 원치 않는 경로에 쓰는 여지가 생긴다. 발급 시점에 키를 못박아 서명하면 그 키로만 올릴 수 있다.

4. 버킷 CORS 설정

브라우저가 다른 오리진(내 사이트)에서 S3 도메인으로 요청을 보내므로, 버킷에 CORS를 열어야 한다. 안 열면 브라우저가 preflight(OPTIONS) 단계에서 막고, 콘솔에 No 'Access-Control-Allow-Origin' header 에러가 뜬다. 요청이 S3까지 가보지도 못하고 브라우저가 차단한 것이다.

JSON CORS 규칙은 다음과 같다. ExposeHeadersETag를 넣는 게 중요하다. multipart에서 브라우저가 각 조각의 ETag 응답 헤더를 읽어야 하는데, 노출을 안 해두면 자바스크립트에서 그 값을 못 읽는다.

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedOrigins": ["https://sarc.io"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

적용은 CLI로 한다.

aws s3api put-bucket-cors \
  --bucket amzn-s3-demo-bucket \
  --cors-configuration file://cors.json

AllowedOrigins*로 열지 말고 실제 서비스 도메인만 적는다. AllowedMethods에 단순 업로드는 PUT, multipart도 PUT을 쓴다. MaxAgeSeconds는 preflight 응답을 브라우저가 캐시하는 시간이라, 넉넉히 주면 반복 업로드 때 OPTIONS 왕복이 줄어든다.

5. 브라우저에서 직접 업로드

클라이언트는 발급 라우트를 호출해 URL을 받고, 그 URL로 파일을 PUT 한다. 서버를 두 번째로 거치지 않는다.

async function uploadDirect(file) {
  // 1) 백엔드에서 presigned URL 받기
  const res = await fetch("/api/s3/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type || "application/octet-stream",
    }),
  });
  const { url, key } = await res.json();

  // 2) S3로 직접 PUT (서명한 Content-Type과 동일하게)
  const put = await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": file.type || "application/octet-stream" },
    body: file,
  });
  if (!put.ok) throw new Error(`upload failed: ${put.status}`);

  return key; // 이후 이 key를 DB에 저장
}

curl로 동작만 먼저 확인하고 싶으면 이렇게 검증한다.

curl -X PUT -T ./sample.bin \
  -H "Content-Type: application/octet-stream" \
  "https://amzn-s3-demo-bucket.s3.ap-northeast-2.amazonaws.com/uploads/..."

업로드가 끝나면 응답 본문은 비어 있고 상태 코드 200이 온다. 실패할 때 가장 흔한 원인은 앞서 말한 Content-Type 불일치와, 만료된 URL이다. 만료면 S3가 AccessDenied와 함께 Request has expired 메시지를 준다.

6. 대용량은 multipart presigned URL

단일 PUT으로 올릴 수 있는 객체는 최대 5GB다. 그리고 수백 MB를 한 번의 PUT으로 보내면 중간에 네트워크가 끊겼을 때 처음부터 다시 올려야 한다. multipart 업로드는 파일을 조각(part)으로 나눠 병렬로 올리고, 실패한 조각만 다시 보낸다.

절차는 세 단계다. 서버가 업로드 세션을 열고(CreateMultipartUpload), 각 조각마다 presigned URL을 발급하면 브라우저가 조각을 올린다. 다 올라가면 서버가 조각들의 ETag를 모아 업로드를 완료한다(CompleteMultipartUpload).

먼저 세션을 열고 각 파트 URL을 발급하는 라우트다.

// app/api/s3/multipart/create/route.js
import { randomUUID } from "node:crypto";
import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

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

export async function POST(req) {
  const { filename, contentType, partCount } = await req.json();
  const key = `uploads/${randomUUID()}/${filename}`;

  const { UploadId } = await s3.send(
    new CreateMultipartUploadCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: contentType,
    })
  );

  const parts = [];
  for (let n = 1; n <= partCount; n++) {
    const url = await getSignedUrl(
      s3,
      new UploadPartCommand({
        Bucket: BUCKET,
        Key: key,
        UploadId,
        PartNumber: n,
      }),
      { expiresIn: 3600 }
    );
    parts.push({ partNumber: n, url });
  }

  return Response.json({ key, uploadId: UploadId, parts });
}

브라우저는 파일을 잘라 각 URL로 PUT 하고, 응답 헤더에서 ETag를 뽑는다. 여기서 조각 크기 규칙이 걸린다. 마지막 조각을 제외한 모든 조각은 최소 5MiB여야 하고, 조각은 최대 10,000개다. 5MiB보다 작게 자르면 완료 단계에서 EntityTooSmall 에러가 난다.

async function uploadMultipart(file) {
  const PART = 10 * 1024 * 1024; // 10MiB
  const partCount = Math.ceil(file.size / PART);

  const res = await fetch("/api/s3/multipart/create", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type || "application/octet-stream",
      partCount,
    }),
  });
  const { key, uploadId, parts } = await res.json();

  const done = [];
  for (const { partNumber, url } of parts) {
    const start = (partNumber - 1) * PART;
    const blob = file.slice(start, start + PART);
    const put = await fetch(url, { method: "PUT", body: blob });
    if (!put.ok) throw new Error(`part ${partNumber} failed`);
    // CORS ExposeHeaders에 ETag가 있어야 읽힌다
    done.push({ PartNumber: partNumber, ETag: put.headers.get("ETag") });
  }

  await fetch("/api/s3/multipart/complete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key, uploadId, parts: done }),
  });
  return key;
}

마지막으로 서버가 조각 목록을 넘겨 업로드를 마무리한다. PartsPartNumber 오름차순이어야 한다.

// app/api/s3/multipart/complete/route.js
import { S3Client, CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";

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

export async function POST(req) {
  const { key, uploadId, parts } = await req.json();
  parts.sort((a, b) => a.PartNumber - b.PartNumber);

  await s3.send(
    new CompleteMultipartUploadCommand({
      Bucket: BUCKET,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts },
    })
  );
  return Response.json({ ok: true, key });
}

주의사항 하나. 시작만 하고 완료도 취소도 안 한 multipart 업로드는 조각이 그대로 남아 스토리지 비용이 붙는다. 눈에 보이지도 않는다. 버킷에 AbortIncompleteMultipartUpload 수명주기 규칙을 걸어 일정 기간(예: 7일) 지난 미완료 업로드를 자동으로 지우는 편을 권한다.

7. 만료와 권한 최소화 주의사항

만료 시간은 SDK나 CLI로 발급하면 최대 7일(604800초)까지 지정할 수 있다. S3 콘솔로 만들면 1분에서 12시간 범위다. 업로드용이라면 굳이 길게 줄 이유가 없다. 사용자가 파일 선택 후 곧바로 올리는 동작이라 몇 분이면 충분하다. 짧을수록 URL이 새어나가도 위험 창이 좁다.

여기서 실무자들이 자주 헷갈리는 함정이 있다. EC2 인스턴스 역할이나 Lambda 실행 역할처럼 임시 자격증명으로 서명하면, expiresIn을 7일로 줘도 실제로는 그 자격증명이 만료될 때 URL이 같이 죽는다. EC2 인스턴스 메타데이터 자격증명은 대략 6시간 주기로 갱신되고, STS AssumeRole 세션은 기본 1시간이다. 그래서 "분명 만료를 하루로 줬는데 몇 시간 뒤부터 AccessDenied가 난다"는 상황이 생긴다. 원인은 자격증명 수명이다.

7일짜리 URL이 꼭 필요하면 임시 자격증명 대신 IAM 사용자의 장기 액세스 키로 서명해야 한다. 반대로 대부분의 업로드는 몇 분 만료로 충분하므로, 역할 기반 서명에서도 문제가 안 된다.

URL 유출 자체를 서버 쪽에서 한 번 더 조이고 싶으면, 버킷 정책에 s3:signatureAge 조건을 걸어 서명한 지 오래된 요청을 거부할 수 있다. 아래는 서명 후 10분(600000밀리초)이 지난 요청을 막는 예다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOldSignatures",
      "Effect": "Deny",
      "Principal": { "AWS": "*" },
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
      "Condition": {
        "NumericGreaterThan": { "s3:signatureAge": "600000" }
      }
    }
  ]
}

정리하면, 서버는 짧게 만료되는 서명만 발급하고 파일 바이트는 브라우저와 S3가 직접 주고받게 한다. 권한은 s3:PutObject 하나로 프리픽스까지 좁히고, CORS는 서비스 도메인과 ETag 노출만 열고, 대용량은 multipart로 조각내되 미완료 업로드는 수명주기 규칙으로 청소한다. 이렇게 하면 애플리케이션 서버는 업로드 트래픽에서 빠지고, S3가 대역폭을 감당한다.

자주 묻는 질문

presigned URL 하나로 여러 번 업로드할 수 있나?

만료 시각 전까지는 같은 URL을 여러 번 쓸 수 있다. 단 업로드용 PUT URL은 같은 키에 쓰므로, 다시 올리면 기존 객체를 덮어쓴다. 서로 다른 파일이라면 파일마다 별도 키로 URL을 발급해야 한다.

만료를 7일로 줬는데 몇 시간 만에 AccessDenied가 난다.

EC2 인스턴스 역할, Lambda 실행 역할, STS AssumeRole 같은 임시 자격증명으로 서명하면 URL 만료와 무관하게 자격증명이 먼저 만료될 때 URL도 죽는다. EC2 메타데이터 자격증명은 약 6시간, STS 세션은 기본 1시간이다. 진짜 7일짜리가 필요하면 IAM 사용자의 장기 액세스 키로 서명해야 한다.

브라우저 업로드에서 CORS 에러가 계속 난다.

버킷 CORS에 서비스 도메인을 AllowedOrigins로, PUT을 AllowedMethods로 넣었는지 확인한다. multipart에서 ETag를 자바스크립트로 읽어야 한다면 ExposeHeaders에 ETag를 넣어야 한다. preflight 응답이 브라우저에 캐시돼 있으면 MaxAgeSeconds가 지나야 새 설정이 반영될 수 있다.

SignatureDoesNotMatch 에러의 흔한 원인은?

서명할 때 ContentType을 지정했는데 브라우저 PUT 요청의 Content-Type 헤더가 다른 경우가 가장 많다. 두 값을 정확히 일치시킨다. 그 외 시스템 시계가 NTP와 어긋난 경우, 회사 프록시가 헤더나 쿼리스트링을 바꾸는 경우, 리전이 틀린 경우도 원인이 된다.

multipart에서 조각 크기 제한이 있나?

마지막 조각을 제외한 모든 조각은 최소 5MiB여야 하고, 한 업로드의 조각은 최대 10,000개다. 5MiB보다 작게 자르면 CompleteMultipartUpload 단계에서 EntityTooSmall 에러가 난다. 단일 PUT 업로드 객체 최대 크기는 5GB다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.