들어가며
현재 회사에서 리서치 콘텐츠를 관리하는 사내 CMS와 고객용 유료 리포트 다운로드 기능을 갖춘 글로벌 B2B 사이트를 운영하고 있습니다.
어느 날, 사이트를 사용하는 중국 지역 고객에게서 리포트 다운로드가 안 된다는 문의가 들어왔습니다. 이를 해결하다 보니, 파일 서빙 구성을 다시 점검하게 되었습니다. 이번 글에서는 S3와 CloudFront, presigned URL로 유료 파일을 서빙하는 구성을 정리하고, 이런 구성을 글로벌 환경에서 운영할 때 무엇을 고려해야 하는지 다뤄보려 합니다.
이 글의 버킷명·도메인·경로·Origin ID·배포 ID는 모두 일반화한 값이고, 구성과 코드도 실제 운영 환경이 아니라 논지에 필요한 부분만 남긴 간소화 버전입니다.
현재 파일 서빙 구조
우선 현재 파일을 어떻게 서빙하고 있는지를 정리해보려 합니다. 인프라 구성은 다음과 같습니다.
정적 파일을 안정적이고 빠르게 서빙하기 위해 Cloudflare를 사용하고 있습니다. CloudFront는 단순히 경로별로 origin을 분기하는 라우팅 계층으로만 사용하고 있고, 이미지와 같은 에셋은 이를 거쳐 S3로 접근할 수 있게 되어 있습니다.
유료 리포트는 특정 회원 그룹에만 제공하는 PDF·PPTX·XLSX 형태의 B2B 리서치 콘텐츠입니다. 이것도 이미지처럼 CloudFront가 그대로 중계하는 경로로 서빙해도 될까요?
❌ https://www.example.com/assets/files/report.pdf
백엔드에서 요청자의 권한을 검사해 위와 같은 다운로드 링크를 내려준다고 해도, CDN 단에서는 권한 검사를 하지 않기 때문에 URL을 아는 사람은 누구나 해당 파일을 받을 수 있습니다. 마찬가지로 이 URL은 사실상 영구 URL이 됩니다. 권한 검사는 링크를 내려주는 시점에 한 번뿐이라, 그 뒤에 구독이 끝나거나 권한이 회수돼도 링크는 그대로 살아 있습니다.
따라서 이미지와 같이 공개 가능한 경로만 S3로 라우팅되게 하고, 리서치 콘텐츠 등 비공개 콘텐츠는 명시적으로 접근이 불가능하도록 닫아 두었습니다.
Presigned URL로 다운로드 제공하기
그러면 막아 둔 콘텐츠를 권한 있는 사용자에게는 어떻게 내려주어야 할까요? 권한 검사를 통과한 시점에만 발급되고, 일정 시간이 지나면 만료되며, 위조·변조되면 거부되는 URL이 필요합니다.
S3 Presigned URL은 이미지 업로드를 구현하면서 많이 사용해 보셨을 겁니다. 브라우저가 큰 파일을 서버를 거치지 않고 올리도록, 서버가 PUT용 URL을 발급하고 브라우저는 그 URL로 S3에 직접 올리는 패턴입니다.
그런데 presigned URL은 다운로드에도 쓸 수 있습니다. S3 API 요청 하나에 미리 서명해 두는 것이라 메서드와 무관해서, PutObject에 서명하면 업로드용이 되고 GetObject에 서명하면 다운로드용이 됩니다.
AWS 문서는 presigned URL을 자격증명으로 시간 제한 권한을 부여하는 URL로 정의하고, 그 자격증명은 URL을 만든 주체의 것이라고 설명합니다. URL에 담기는 것은 권한이 아니라 자격증명 식별자와 서명값이라, 권한 검사는 URL을 만드는 시점이 아니라 요청이 도착한 시점에 이뤄집니다. 버킷이 비공개인데도 다운로드가 되는 것은 요청이 인증되기 때문입니다.
구현
async getAttachmentPresignedUrl(user: RequestUser, attachmentId: number, expiresIn = 300) {
// 리포트 단위 권한 검사. 통과하지 못하면 여기서 403으로 끝난다
await this.assertDownloadPermission(user, attachmentId);
const attachment = await this.attachmentRepository.findOneOrFail({ where: { id: attachmentId } });
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: attachment.key,
// 브라우저가 인라인 렌더링하지 않고 다운로드하도록
ResponseContentDisposition:
`attachment; filename="${encodeURIComponent(attachment.originalName)}"`,
});
const presignedUrl = await getSignedUrl(createS3Client(), command, { expiresIn });
return { presignedUrl, originalName: attachment.originalName };
}
getSignedUrl()은 네트워크 호출이 아닙니다. 로컬에서 HMAC을 계산하는 순수 연산이고 S3에 묻지 않습니다. 그래서 빠르고, S3 장애와 무관하게 동작합니다.
expiresIn은 길게 잡을수록 URL이 유출됐을 때 노출되는 시간이 늘어나고, 너무 짧으면 다운로드 재시도가 번거로워집니다. 회선이 불안정한 지역을 고려해 적정 시간을 정했습니다.
중국에서만 다운로드가 실패
위와 같은 구성으로 잘 운영하고 있던 중, 중국 본토에서 접속하는 고객사로부터 아래와 같은 문의가 들어왔습니다.
我购买了报告,但是下载不了! (대충 리포트 다운로드가 되지 않는다는 뜻)

사이트 접속·로그인·리포트 목록 조회·이미지 표시는 모두 정상이고, 정확히 리포트 다운로드만 되지 않는다는 내용이었습니다. 하지만 재현되지 않았습니다. 미국, 유럽 등 다른 지역에서도 모두 정상이었고, 중국 지역에서만 실패했습니다.
원인은 GFW
우선 오류 화면에 찍힌 것은 ERR_EMPTY_RESPONSE, 그리고 데이터를 하나도 보내지 않았다(未发送任何数据)는 안내였습니다. 응답이 0바이트라는 뜻입니다. 회선이 느려서 실패한 것이라면 얼마간 받다가 끊기거나 타임아웃이 났을 텐데, 여기서는 전송이 시작조차 되지 않았습니다. 고객사에서는 VPN을 켜면 받아지고, 끄면 안 된다는 사실도 알려줬습니다.
이런 정황들을 보다 보니 중국의 GFW(Great Firewall, 만리방화벽)를 의심하게 됐습니다. GFW는 중국 공안부가 1998년부터 추진해 온 검열·감시 정책인 황금방패(金盾工程)의 일환입니다. 중국과 해외를 잇는 국제 회선에서 오가는 트래픽을 검사해, 검열 대상에 해당하는 연결을 차단합니다. 같은 도메인이라도 지정학적 상황이나 정책 변화에 따라 간헐적으로 차단되기도 합니다.
차단 방식은 한 가지가 아닙니다. IP 대역을 통째로 막기도 하고, DNS 질의에 위조된 응답을 주입하기도 하고, TLS handshake의 SNI(Server Name Indication) 필드를 검사하기도 합니다. SNI는 암호화가 시작되기 전에 오가기 때문에 HTTPS에서도 평문으로 노출됩니다. 브라우저는 접속할 도메인 이름을 DNS 질의와 SNI 두 곳에서 밖으로 내보내는데, 여기서 이름이 차단 목록에 걸리면 위조된 DNS 응답이 돌아오거나 연결에 RST가 주입됩니다.
저희는 이 중 SNI 차단이 유력하다고 판단했습니다. DNS가 위조됐다면 엉뚱한 IP로 붙어 타임아웃이나 인증서 오류가 났을 텐데, 화면은 연결이 맺어진 뒤 응답만 비어 있는 형태였습니다. VPN을 켜면 되는 것도 같은 맥락인데, 터널 안에서는 도메인 이름이 DNS 질의에도 SNI에도 드러나지 않기 때문입니다. 다만 VPN은 출구 IP와 경로, DNS 리졸버까지 한꺼번에 바꾸므로 이것만으로 SNI를 원인으로 확정할 수는 없습니다.
이미지는 되는데 다운로드만 막힌 이유
이미지는 앞에서 본 것처럼 CloudFront를 거쳐 우리 도메인으로 서빙하고 있었습니다. 브라우저가 보내는 SNI는 우리 도메인이고, S3는 CloudFront origin으로만 존재합니다. 차단 목록에 없는 도메인이라 그대로 통과합니다.
https://www.example.com/assets/images/thumb.png
~~~~~~~~~~~~~~~ 차단 목록에 없는 우리 도메인 → 통과
반면 presigned URL은 호스트명이 S3 자체입니다. 기본적으로 virtual-hosted style S3 엔드포인트, 즉 <bucket>.s3.<region>.amazonaws.com 형태로 발급되기 때문입니다.
https://my-assets-bucket.s3.us-east-1.amazonaws.com/assets/files/report.pdf
~~~~~~~~~~~~~~~~~~~~~~~~~ 여기가 문제
?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=...
중국 전용으로 AWS 중국 리전에 인프라를 따로 두는 방안도 있지만, 문의 몇 건 때문에 별개 파티션을 함께 운영할 일은 아니었습니다(중국 리전은 별도 파트너사가 운영하고 계정도 분리돼 있어 사실상 다른 클라우드를 하나 더 쓰는 셈).
우회 방법 찾기
차단 기준이 호스트명이니 호스트명을 바꾸면 될 것 같습니다. 마침 클라이언트가 붙는 엣지가 Cloudflare이니 여기서 처리를 해보겠습니다.
302 리다이렉트
가장 간단한 방법은 우리 도메인으로 요청을 받아 특정 경로에 대해서만 실제 S3 URL로 302 리다이렉트하는 것입니다. 브라우저가 원래의 S3 URL로 다시 요청하니 Host가 서명된 값 그대로이고, 추가 설정도 없습니다.
다만 이 케이스에서는 쓸 수 없습니다. 리다이렉트하면 브라우저가 amazonaws.com에 직접 붙게 되고, 그 순간 GFW에 막히기 때문입니다.
CNAME과 기본 프록시
그러면 static-files.example.com 같은 도메인을 하나 두고, CNAME으로 Cloudflare 프록시를 태워 그 뒤에 버킷을 두면 어떨까요? 얼핏 될 것 같지만, S3는 403 SignatureDoesNotMatch를 반환하며 요청을 거부합니다.
원인을 보려면 presigned URL이 어떻게 인증되는지부터 알아야 합니다. 실제로 발급되는 URL의 쿼리스트링을 분해해 봅니다.
https://my-assets-bucket.s3.us-east-1.amazonaws.com/assets/files/report.pdf
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=AKIA.../20260804/us-east-1/s3/aws4_request
&X-Amz-Date=20260804T063911Z
&X-Amz-Expires=60
&X-Amz-SignedHeaders=host
&X-Amz-Signature=8f3a...
쿼리스트링에 들어 있는 것은 서명자의 자격증명과 서명 시각, 유효기간, 서명 대상 헤더 목록, 그리고 서명값입니다. 여기서 host 헤더가 서명 대상에 포함되어 있습니다. SigV4 쿼리스트링 인증 방식에서 host는 제외할 수 없는 필수 서명 헤더입니다.
서명은 host = my-assets-bucket.s3.us-east-1.amazonaws.com를 기준으로 계산되었고, S3에 실제로 도착하는 요청의 Host 헤더가 이 값과 정확히 일치해야 검증이 통과합니다.
그런데 Cloudflare의 기본 프록시는 당연히 브라우저가 보낸 Host 헤더를 그대로 origin에 전달합니다. 브라우저가 static-files.example.com으로 요청하면 S3도 Host: static-files.example.com을 받게 되는데, 이 Host로는 어느 버킷인지 특정할 수 없고(virtual-hosted style이 성립하지 않습니다) 서명된 값과도 다르니 검증까지 실패합니다. 중국을 통과하려면 호스트명을 바꿔야 하는데, 서명이 유효하려면 바꿀 수 없는 상황입니다.
Cloudflare Worker로 해결하기
필요한 것은 Host를 원복해 주는 중간 계층입니다. 클라이언트(그리고 GFW)는 우리 도메인을 보고, S3는 서명된 원래 호스트를 받습니다. 302로 넘기지 않고 바이트를 중계하는 리버스 프록시여야 하고, Host는 명시적으로 재작성해야 합니다.
Origin Rules의 Host Header Override로도 가능하지만, 로직을 코드로 남길 수 있고 캐싱 정책을 직접 통제할 수 있다는 점에서 Worker를 선택했습니다.
애플리케이션 Side
먼저 백엔드에서 발급한 presigned URL의 호스트를 커스텀 도메인으로 치환합니다.
let presignedUrl = await getSignedUrl(createS3Client(), command, { expiresIn });
const customDomain = this.getCustomDomain();
if (customDomain) {
const url = new URL(presignedUrl);
const s3Host = url.host;
presignedUrl = presignedUrl.replace(`https://${s3Host}`, `https://${customDomain}`);
}
return { presignedUrl, /* ... */ };
private getCustomDomain(): string | null {
const stage = this.configService.get('STAGE');
if (stage === 'staging') return 'test-static-files.example.com';
if (stage === 'prod' || stage === 'production') return 'static-files.example.com';
return null; // 로컬은 원본 S3 그대로
}
호스트만 문자열로 치환하고, ?X-Amz-...와 같은 쿼리스트링은 그대로 유지합니다.
Cloudflare Worker Side
Worker는 이 도메인으로 들어온 요청을 받아 S3 엔드포인트로 전환해 전달합니다.
// 다운로드에 필요한 부분·조건부 요청 헤더만 통과시킨다.
const FORWARD_HEADERS = ['range', 'if-range', 'if-none-match', 'if-modified-since'];
function forwardHeaders(source) {
const headers = new Headers();
for (const name of FORWARD_HEADERS) {
const value = source.get(name);
if (value) headers.set(name, value);
}
return headers;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.hostname !== env.PUBLIC_HOST) return fetch(request);
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405 });
}
// fetch 대상 URL의 host를 S3 엔드포인트로 되돌린다.
// Workers 런타임이 이 URL의 hostname을 Host 헤더로 세팅하므로
// SigV4 서명(SignedHeaders=host)이 다시 맞아떨어진다.
// 쿼리스트링은 파싱하지 않고 원문을 그대로 이어붙인다.
const s3Url = `https://${env.S3_ENDPOINT}${url.pathname}${url.search}`;
const upstream = await fetch(s3Url, {
method: request.method,
headers: forwardHeaders(request.headers),
cf: { cacheEverything: false, cacheTtl: 0 },
});
const headers = new Headers(upstream.headers);
headers.set('Cache-Control', 'no-store, private');
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
},
};
${url.pathname}${url.search}는 문자열로 이어붙이고 URLSearchParams로 재조립하지 않습니다. SigV4는 쿼리스트링까지 서명 대상에 넣기 때문에, 재조립 과정에서 인코딩이 1바이트라도 달라지면 서명이 깨집니다.
Host는 fetch(s3Url)이 세팅합니다. Workers에서 fetch()에 새 URL을 주면 런타임이 그 URL의 hostname을 Host 헤더로 넣어주기 때문에, 앞에서 본 서명 불일치가 해소됩니다. 도메인과 S3 엔드포인트는 env로 주입받도록 했습니다. 요청 헤더는 허용목록으로만 전달합니다. Range가 빠지면 다운로드를 이어받을 수 없고, 반대로 Cookie나 Authorization을 그대로 넘기면 세션 정보가 S3로 흘러갑니다.
엣지 캐시는 끄도록 합니다. presigned URL은 발급마다 서명이 달라져 사실상 적중하지 않지만, 요청 쪽 cacheTtl: 0과 응답 쪽 no-store로 양쪽에서 명시적으로 막습니다.
더 근본적인 해법: CloudFront Signed URL
저희는 긴급하게 대응해야 했고 인프라 구조상 한계가 있어 진행하지 못했지만, 사실 더 근본적인 해결책이 있습니다.
지금까지는 S3가 Host를 서명한다는 제약에 의한 것입니다. CloudFront Signed URL을 사용하게 되면 이러한 제약을 근본적으로 해결할 수 있습니다.
CloudFront Signed URL은 Host를 서명하지 않습니다. 만료 시각과 서명값, 그리고 어떤 키로 서명했는지를 가리키는 Key-Pair-Id만으로 검증합니다. 호스트명이 서명 대상이 아니니 처음부터 우리 도메인으로 발급하면 됩니다.
먼저 RSA 키 페어를 만들어 공개키를 CloudFront에 등록하고, 그 공개키를 Key Group으로 묶습니다. distribution의 behavior에 이 Key Group을 Trusted Key Group으로 지정하면, 서명이 없거나 만료된 요청은 오리진에 닿기 전에 CloudFront가 403으로 차단합니다. 애플리케이션은 개인키를 들고 있다가 권한 검사를 통과한 요청에만 URL을 서명해 내려주고, S3는 OAC로 CloudFront만 접근 가능하게 합니다.
import { getSignedUrl } from '@aws-sdk/cloudfront-signer';
const url = getSignedUrl({
url: `https://${cdnDomain}/${attachment.key}`,
keyPairId: this.configService.get('CF_KEY_PAIR_ID'),
privateKey: this.configService.get('CF_PRIVATE_KEY'),
dateLessThan: new Date(Date.now() + expiresIn * 1000).toISOString(),
});
정리
유료 파일 다운로드를 구현할 때는 URL이 언제까지 유효한지, 중간 계층이 그 응답을 캐싱하지는 않는지까지 함께 확인해야 합니다. 권한 검사를 통과한 시점에만 발급되도록 만들어도, 만료가 길거나 엣지가 응답을 캐싱해 버리면 권한이 없는 사용자가 접근할 여지가 생깁니다.
중국 고객의 다운로드 실패는 GFW가 presigned URL의 S3 호스트명을 보고 연결을 끊은 것으로 판단했습니다. 그런데 SigV4는 그 호스트명까지 서명 대상에 넣기 때문에 도메인만 단순 치환할 수 없었고, Cloudflare Worker로 우리 도메인의 요청을 받아 Host만 원래 값으로 되돌려 S3에 전달하는 방식으로 해결했습니다.
이 외에도 중국을 대상으로 서비스할 때는 여러 제약들을 고려해야 합니다. reCAPTCHA나 Google Maps처럼 구글에 의존하는 기능, Facebook 같은 소셜 로그인, YouTube나 X 임베드가 정상적으로 동작하지 않을 수 있습니다.
