Skip to content

Object Storage CDN으로 정적파일 서빙하기

Donghoon Kang edited this page Feb 8, 2026 · 2 revisions

개요

현재 우리 프로젝트는 다음과 같은 구조로 서비스를 제공하고 있다.

Public Subnet 안에 하나의 서버 인스턴스가 운영되고 있으며 내부적으로 총 5개의 컨테이너가 실행되고 있다. 현재 구조에서는 Nginx를 통해 정적 파일을 요청하면 웹 서버를 통해 서빙을, API 요청이 들어오면 웹 어플리케이션 서버로 요청을 전달하고 있다.

이런 구조에서 개선하고 싶었던 점은 프론트엔드와 백엔드를 분리하고 백엔드 서버를 Private Subnet에 위치시켜 보안을 높이는 것이었다.

BE 서버는 외부에 노출해야할 일이 없다보니 DB 서버처럼 Private Subnet에 위치시켜 보안성을 높여주고 FE를 서빙할 인스턴스를 생성하기 보다는 CDN 서비스를 이용하여 더 안정적으로 넓은 범위의 유저들에게 파일을 제공할 수 있도록 개선시키고자 하였다.

❓ CDN이 뭐에요?

Content Delivery Network(CDN)은 웹 콘텐츠를 세계 곳곳에 있는 여러 서버에 분산하여 저장하는 분산 서버 네트워크 시스템으로 사용자 위치에 맞게 정적인 웹 콘텐츠를 빠르게 전달할 수 있는 장점이 존재한다.

  • 미국에 있는 유저가 코문철 서비스를 사용한다면, 미국에서 한국의 NCP 서버까지 요청을 보내서 홈페이지를 로드하거나 필요한 동영상 및 이미지를 다운받아야 하는 문제가 발생한다!
  • 마찬가지로 한국에서 유명하지 않은 어떤 미국 웹사이트를 접속하게 된다면 어느 정도 시간이 흘러야 웹페이지가 로딩되는 현상이 발생한다.

이러한 문제처럼 거리가 길어질수록 이에 비례하여 요청 / 응답의 속도가 느려지기 때문에 이를 개선하고자 전 세계 곳곳에 여러 CDN 서버를 분산하여 빠르게 파일을 제공할 수 있는 시스템이다.

CDN 서버 종류

  • Origin Server: 사용자에게 제공할 원본 파일을 보관하는 서버로, CDN 서버가 요청 시 원본 파일을 가져갈 수 있는 기준점이다.
  • Edge Server: 전 세계에 분산된 서버로, 사용자 요청이 발생하면 Origin Server로부터 파일을 가져와 캐싱하고, 이후 동일한 요청에 대해 자신과 가까운 사용자에게 빠르게 파일을 제공한다.
  • PoP(Point of Presence): Edge Server를 관리하기 위한 지역 / 데이터 센터 단위

⇒ 전 세계에 분산된 PoP(Point of Presence) 를 통해 사용자는 자신에게 가장 가까운 Edge Server에 접근하여 캐시된 파일을 전달받을 수 있다.

어떻게 가장 가까운 Edge Server를 찾을까?

  1. 사용자가 서비스 웹사이트의 도메인으로 접속한다.
  2. 도메인의 DNS는 일반 서버의 IP가 아니라 CDN이 제공하는 Anycast IP 주소를 반환한다.
  3. 사용자의 요청은 Anycast IP로 넘어가서 **BGP(Border Gateway Protocol)**를 기준으로 가장 짧고 빠른 경로를 선택한다.
    1. 이 단계에서 사용자가에 가장 가까운 CDN PoP으로 요청이 전달된다.
  4. 해당 CDN PoP 내부 Edge Server에서 요청받은 파일이 존재하는지 확인하여 제공한다.
    1. 만약 Cache-Miss일 경우, Origin Server에서 파일을 가져온 후에 응답한다.

Anycast IP란?

Anycast는 들어오는 요청을 다양한 위치 또는 "노드"로 라우팅할 수 있는 네트워크 주소 지정 및 라우팅 방법이다.

네트워크 내 모든 노드가 각자의 고유한 IP를 갖는 것을 Unicast 라고 하는데, 이에 반면 Ancyast는 여러 노드가 공통된 IP를 갖게 된다.

CDN에서 제공하는 모든 PoP가 공통된 IP를 갖고 있으며 사용자는 요청을 Anycast IP로 보내면 BGP 프로토콜을 통해 최적의 경로를 도출하여 가장 가까운 엣지 서버에게로 패킷이 전달된다.

CDN의 장점

1️⃣ 글로벌 서비스가 될 우리 프로젝트는 미국에 있는 Andrew에게도 빠르게 접속 가능한 환경을 제공한다.

CDN 도입 전 CDN 도입 이후

Singapore에서 제공하는 서비스를 New York에서 접근한다고 가정했을 때 다음과 같은 요소들로 인해 파일 제공 시간이 지연된다.

  1. 뉴욕 → 싱가폴로 요청 및 응답 시간 지연
  2. TCP / IP 연결을 위한 Handshake 비용
  3. 클라이언트가 요청하는 Assest의 수

하지만 만약 Singapore에서 제공하는 서비스가 Atlanta에 엣지 서버를 통해 파일 제공하고 있다면, 굳이 Singapore까지 요청을 보내지 않더라도 캐싱된 데이터에 대해서는 훨씬 빠르게 사용자에게 제공이 가능하다.

2️⃣ DDoS 문제로 부터 보호

하나의 Origin Server에서 직접 파일을 제공할 경우, 모든 요청을 단일 서버가 처리해야 하므로 부하와 장애에 취약하다.

반면 CDN을 사용하면 콘텐츠가 여러 지역의 Edge Server에 캐싱되어 사용자 요청이 분산되며, 그 결과 트래픽 집중을 완화하고 서비스의 안정성과 가용성을 향상시킬 수 있다.

3️⃣ 가용성 향성

여러 PoP에 파일을 캐싱해둔다면 가까운 PoP에 오류로 인해 서비스를 제공하지 못하더라도, Origin Server보다 더 가까운 다른 PoP의 엣지 서버에 요청이 가능하여 일관된 서비스 제공이 가능하다.

💾 Object Storage 에 FE 빌드 파일을 저장하자.

CDN에게 캐시할 파일을 제공해야 한다.

  • CDN은 단순히 가까운 유저에게 저장된 파일을 제공해주는 역할을 하기 때문에, CDN에 저장할 파일을 제공할 Origin Server가 존재해야 한다.
  • Origin Server는 캐시할 파일을 전달하거나 Cache-Miss가 발생했을 때, 해당 파일을 제공해줄 수 있어야 한다.

현재 우리 서비스에서는 도커를 통해 빌드된 FE 파일을 저장하여 Nginx를 통해 서빙하고 있다. 그렇다면 빌드된 FE 파일을 Nginx가 아닌 CDN에게 전달하여 애플리케이션 서버가 Origin Server 역할을 해줄 수 있지 않을까?

이러한 점에 대해 AI와 대화를 나누고 나니 다음과 같은 문제점이 예상되었다.

  1. 애플리케이션 서버는 서비스 로직을 처리하기 위해 존재한다.
  2. 만약 정적 파일 요청 혹은 Cache Miss로 인한 요청이 겹친다면 서버의 트래픽이 급증한다.
  3. 서버가 다운되어 파일을 제공할 수 없다면 CDN 또한 제대로 동작하기 어렵다.

이러한 이유로 인해, CDN에게 파일을 서빙할 Origin Server는 분리하기로 결정하였으며 Object Storage를 통해 파일을 제공하기로 하였다.

🔗 구현해보자

NCP에서 제공하는 Object Storage의 요금은 다음과 같다.

다음과 같이 대략적으로 예상되는 설계 흐름을 잡았다.

  1. CI/CD를 통해 프론트엔드 파일을 빌드하여 Object Storage에 저장한다.

  2. CDN에서 Object Storage에 저장된 파일을 캐싱하여 서빙한다.

  3. 정적 파일에서 API 요청은 백엔드 서버 도메인으로 설정하여 요청과 응답이 가능하도록 허용한다.

1️⃣ CI / CD에서 빌드된 파일을 Object Storage에 저장하자

NCP Object Storage 공식 문서에서는 CLI 형식으로 직접 접근하여 파일을 업로드가 가능하였다.

pip install awscli==1.15.85

1.15 버전의 awscli를 설치하여 버킷에 접근이 가능하다. 해당 버전은 파이썬 3.8 버전 이하에서만 설치가 가능하다.

Github Actions를 통해 CI/CD를 돌리고 있었는데, 기본적으로 내장된 파이썬의 버전이 지원 버전보다 높았기 때문에 수동으로 파이썬을 설치하여 awscli를 설치해주었다.

- name: Setup Python
  uses: actions/setup-python@v5
  with:
    python-version: "3.8"

그리고 awscli에서 제공하는 s3 명령어를 통해 원하는 파일을 Object Storage에 저장이 가능하다.

(더 높은 버전의 awscli를 통해서도 업로드가 가능한 것으로 아는데, 이럴 경우에는 awscli에서 파일을 업로드할 때 새로 추가된 checksum 알고리즘을 비활성화해야한다고 한다.)

파일을 업로드할 때 추가적인 옵션을 통해서 해당 파일의 캐싱을 설정할 수 있다.

  aws s3 sync frontend/dist/ s3://${{ secrets.NCP_BUCKET }}/${{ secrets.NCP_BUCKET_ROOT }} \
    --endpoint-url ${{ secrets.NCP_ENDPOINT }} \
    --exclude "*" \
    --include "index.html" \
    --acl public-read \
    --cache-control "no-cache, no-store, must-revalidate"

파일을 빌드하면 index.html 는 진입점이기 때문에 항상 동일한 파일명을 갖는다.

CDN 입장에서는 파일명이 동일하기 때문에 파일 내용의 변경 여부를 파악하기 어려우며 최신 파일이 아닌 과거에 캐싱된 파일을 계속 서빙될 수 있는 문제가 존재한다.

그렇기 때문에 캐싱하지 않는다는 옵션을 추가하여 파일을 저장한다.

  • no-cache: 캐시된 복사본을 사용자에게 보여주기 이전에, 재검증을 위한 요청을 원 서버로 보내도록 강제합니다.
  • no-store:캐시는 클라이언트 요청 혹은 서버 응답에 관해서 어떤 것도 저장해서는 안됩니다.
  • must-revalidate :캐시는 사용하기 이전에 기존 리소스의 상태를 반드시 확인해야 하며 만료된 리소스는 사용되어서는 안됩니다.
  aws s3 sync frontend/dist/ s3://${{ secrets.NCP_BUCKET }}/${{ secrets.NCP_BUCKET_ROOT }} \
    --endpoint-url ${{ secrets.NCP_ENDPOINT }} \
    --exclude "index.html" \
    --delete \
    --acl public-read \
    --cache-control "public max-age=31536000, immutable"

반면, assets , script와 같은 파일은 오랜 기간동안 변화하지 않거나 변화된 파일에 대해서는 파일명이 해싱되어 저장된다. 그렇기 때문에, 오랜 기간 변화하지 않는 파일은 그대로 캐싱을, 변화된 파일은 감지하여 원본으로 요청을 바로 보낼 수 있다.

  • public :응답이 어떤 캐시에 의해서든 캐시된다는 것을 나타냅니다
  • max-age : 리소스가 최신 상태라고 판단할 최대 시간을 지정합니다. Expires에 반해, 이 디렉티브는 요청 시간과 관련이 있습니다.
  • immutable: 해당 리소스는 변하지 않는다고 명시하며 새로고침 시에도 기존의 데이터를 사용한다.

다만 Chrome은 immutable을 지원하지 않으며 Firefox나 Safari에서는 지원한다.

2️⃣ CDN을 생성하여 빌드 파일을 서빙한다.

Object Storage에 경로를 /index.html에 저장하고 CDN에서 원본 경로를 비워둔다음 {cdn_domain.com}/ 으로 접근해도 ACCESS_DENIED 에러가 발생하였다.

이에 대해 문의를 하였을 때 다음과 같은 답변을 받았다.

최상위 경로를 통해 접근할 수 없도록 최근에 수정되었다고 확인하였고 이에 맞춰 추가적으로 수정해주었다. 하지만 여전히 문제가 해결되진 않았다.

  • Object Storage 경로를 /cmc/index.html에 저장하고 CDN 원본 경로를 /cmc로 설정하여 {cdn_domain.com}/ 으로 접근해도 ACCESS_DENIED 에러 발생
  • Object Storage 경로를 /cmc/index.html 에 저장하고 CDN 원본 경로를 비워둔 후, {cdn_domain.com}/cmc 으로 접근하면 해당 파일을 찾을 수 없다고 뜸
  • Object Storage 경로를 /cmc/index.html 에 저장하고 CDN 원본 경로 /cmc/index.html 로 설정한 후, {cdn_domain.com} 으로 접근하면 ACCESS_DENIED 에러 발생

Object Storage에 저장하는 모든 파일을 '공개'로 설정하고 암호화도 하지 않았는데 아직도 원인이 무엇인지 파악하지 못했다.. (알면 댓글로 알려주세요..)

CDN 서비스는 생성할 때나 설정을 수정하여 다시 서비스를 배포할 때 30분 내외로 시간이 소요되기 때문에 AI를 통해 계속해서 문제의 원인에 대해서 이야기를 해보다가 Cache Rule을 설정할 수 있는 Global Edge를 한 번 사용해보라는 조언을 받았다.

Global CDN의 경우에는 한국 및 중국에서는 서비스 품질을 보장하지 않기 때문에 한국 내에서 서비스를 제공하는 경우에는 Global Edge 서비스를 이용하라고 적혀있다. Global Edge 서비스에서는 룰 빌더를 제공하는데, 이를 통해 콘텐츠 유형, 디렉토리에 따라 상세 캐시 룰 제어, 헤더 재정의, URL 리디렉션 및 리라이트가 가능하다.

3️⃣ Cache Rule 부여

Global Edge 서비스를 신청하여 Global CDN과 동일하게 원본 서버를 Object Storage에 연결하였더니 성공적으로 정적파일을 서빙받을 수 있었다.

하지만 기본적으로 Object Storage에 있는 파일을 그대로 CDN을 통해 제공하기 때문에 / 으로 접속했을 때 메인 페이지가 제공되는 것이 아닌, /index.html을 통해 메인 페이지에 접근이 가능하다.

예를 들어, https://naver.com 이 아닌 https://naver.com/index.html로 접근해야 한다는 것이다.

이에 대해 룰 빌더를 설정해주어 / 로 접근할 경우, /index.html로 URL Rewrite가 되도록 설정해주었다.

debugging 1

하지만 현재 우리가 제공하고 싶은 서비스는 SPA 어플리케이션이기 때문에 index.html 내부에 라우팅과 관련된 로직이 모두 포함되어있다. 문제는 URL 경로가 라우팅인지, 정적파일 서빙인지 CDN은 제대로 파악하기 어렵다는 것이다.

예를 들어, //index.html을 잘 보여주는데, /path 를 요청하면 이게 /path 페이지를 원하는 지? Object Storage에 있는 path 디렉토리를 요청하는지 알기 어렵다는 것이다.

이에 대해서 /* 로 접근하면 모두다 index.html을 제공하도록 한 번 더 캐시 룰을 설정해주었다.

모든 URL 접근에 대해서 Object Storage에서 파일을 찾는 것이 아닌, index.html을 통해 라우팅을 하라는 것을 명시해주었다.

debugging 2

다음과 같이 설정을 하게되니 잘 제공받던 메인 페이지에 다음과 같은 에러가 발생하였다.

Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.

한 가지 잊고 있었던 것은 CDN으로부터 제공받는 파일이 index.html이 아닌 .js, .css 등 다양한 Assets들과 Script 파일들이 존재한다는 것이다. 이에 대한 요청또한 모두 다 index.html을 제공하려고 하다보니 요청한 파일과 응답받은 파일의 형식이 달라 에러가 발생한 것이다.

이를 해결하기 위해서 요청받은 파일의 특정 확장자들은 index.html이 아닌 그대로 제공하도록 캐시룰을 추가해주었다.

그리고 추가로 이와 관련해서 더 안정적으로 캐시 룰을 설정할 수 있는 방법(정규 표현식이나 더 상세한 규칙들)이 있을까 궁금하여 문의를 드렸더니 다음과 같은 답변을 받았다.

제공하는 모든 경로에 대해서도 index.html로 URL Rewrite을 적용할 수 있다고 말씀해주셔서 이에 대해서도 캐싱 룰을 추가해주었다. 하지만 아무래도 페이지가 추가될 때마다 룰을 항상 추가해서 재배포를 진행해야 하니 페이지 유동이 많다면 따로 해당 룰을 설정하지 않아도 될 듯 싶다.

마지막으로 지정되지 않은 페이지에 접근할 경우도 고려해주었다. 예를 들어, 라우팅으로 설정한 페이지(/login, /nickname) 같은 경우에는 정상적으로 서빙이 되겠지만 (/abc, /strange) 와 같은 URL로 접근한다면 에러 페이지를 보여줘야 할 것이다.

하지만 이 에러 페이지 또한 index.html에서 제공되는 우리가 직접 만든 에러 페이지를 보여주는 것이 더 UX적으로 좋지, Object Storage를 통해 파일을 찾지 못했다는 에러를 보여주면 안된다고 생각하였기에 모든 요청에 대해 index.html로 제공하라는 마지막 룰을 추가해주었다.

이렇게 설정한 끝에 우선순위에 따라 최종적으로 다음과 같이 동작된다.

  1. /로 접근하면 index.html을 제공하여 메인 페이지를 보여줘라
  2. 특정 파일 확장자 요청에 대해서는 Object Storage에서 해당 경로의 파일을 서빙해라
  3. 특정 페이지를 요청하면 index.html을 제공하여 라우팅을 시켜라
  4. 그 외의 모든 요청에 대해 index.html을 제공하여 라우팅 혹은 에러 페이지를 보여줘라

4️⃣ API 요청 경로 변경

이제 정상적으로 SPA 서비스를 CDN을 통해 정적으로 잘 제공하고 있다. 마지막으로 해줘야 할 것은 API 요청에 대한 경로를 변경해주는 것이다.

우리 서비스에서는 이제 두 가지 도메인이 생겼다. 하나는 Edge 도메인, 하나는 백엔드 도메이다.

기존에는 애플리케이션 도메인에서 Nginx를 통해 / 요청으로 들어오면 정적 파일 서빙을, /api로 요청이 들어오면 백엔드 서버로 요청을 보내주었다. 하지만 이제 정적 파일 서빙은 CDN을 통해 보내주기 때문에, Nginx에서는 요청을 받아 백엔드 서버로 요청을 보내주기만 하면 된다.

Frontend 파일에서 fetch('api/request1')  과 같이 요청을 보냈다면 현재는 API 경로를 추가하여 fetch(${import.meta.env.VITE_API_URL}/api/request) 과 같이 수정하여 로컬과 배포 환경에서 적절한 위치로 요청을 보내게 수정해주었다.

debugging 1

배포를 진행한 후, 테스트를 진행해보니 정상적으로 FE와 BE가 요청을 주고 받는 것을 확인하였다. 하지만 로그인이 정상적으로 이루어지지 않는다는 것을 확인하였고 디버깅을 하던 도중, 쿠키가 저장되지 않는다는 문제를 마주하였다.

기존의 쿠키는 다음과 같은 옵션으로 저장하고 있었다.

secure: true
sameSite: lax

하지만 lax의 경우, 사용자가 직접 의도적으로 이동하는 경우에만 Cross Site 를 허용하고 그 외에는 쿠키를 허용하지 않는다. Global Edge에서 제공해준 도메인과 현재 운영 중인 백엔드 서버의 도메인은 Origin이 다르기 때문에 Cross Site에 해당하였고 lax 설정으로 인해 자동적으로 쿠키가 저장되지 않는 문제였다.

이에 대해 도메인이 다른 두 경로에서 쿠키를 주고 받기 위해 sameSite=None 으로 설정하여 쿠키를 수신하고자 하였다.

debugging 2

한 가지 더 재밌는 점을 발견하였는데, sameSite=None의 경우 보안적인 위험이 존재하기 때문에 항상 secure=true인 옵션과 함께 사용되어야 한다. 즉, Https 통신이 보장되어야 cross site에서도 쿠키 저장을 허용해주며 http 환경에서는 도메인이 다르면 쿠키를 저장할 수 없다는 것이다.

이에 대해, 배포 환경에서는 간단히 두 옵션을 허용해주면 되지만 로컬에서는 https 환경을 만들지 않았기 때문에 쿠키 설정의 분리가 필요하여 다음과 같이 수정하였다.

const isSecure = this.config.get<string>('NODE_ENV') === 'production'

res.clearCookie('access_token', {
  httpOnly: true,
  secure: isSecure,
  sameSite: isSecure ? 'none' : 'lax',
  path: '/',
})

Ref

https://docs.tosspayments.com/resources/glossary/cdn

https://www.cloudflare.com/ko-kr/learning/cdn/performance/

https://www.youtube.com/watch?v=RI9np1LWzqw

https://developers.google.com/search/blog/2020/01/get-ready-for-new-samesitenone-secure

https://guide.ncloud-docs.com/docs/ko/globaledge-overview

🧩 팀 문화

🎮 코문철 소개

🧾 스프린트 기록

1주차 회의록
2주차 회의록
3주차 회의록
4주차 회의록
5주차 회의록
6주차 회의록
7주차 회의록

🔊 유저 테스트

📖 팀 위키

Clone this wiki locally