Object Storage와 CDN으로 정적 웹사이트 배포하기

김유경·2025년 8월 17일

들어가며

정적 파일을 배포하는 방법은 다양하지만, 이번 프로젝트에서는 비용 효율적이면서도 클라우드 환경에 최적화된 방식을 선택했습니다.

KakaoCloud에서 제공하는 Object Storage와 CDN 조합을 활용하면, 별도의 서버 없이도 React/Vite 같은 프론트엔드 빌드 산출물을 안정적으로 서비스할 수 있습니다.

🔗 카카오클라우드 공식 튜토리얼 바로가기


CDN이란?

CDN(Content Delivery Network)은 전 세계 여러 지점에 분산된 서버 네트워크를 통해 웹 페이지, 이미지, 동영상 등 정적 콘텐츠를 빠르게 전달하는 기술입니다. 사용자가 특정 지역에서 웹사이트를 요청하면, 가장 가까운 CDN 서버가 콘텐츠를 제공하여 지연을 최소화합니다.

쉽게 말하면, 전 세계 캐싱 서버에 내 웹사이트를 저장해두는 것이라고 이해할 수 있습니다.

참고 자료

AWS - CDN이란 무엇인가요?
KakaoCloud - CDN 개요


1. Object Stroage 만들기

먼저 KakaoCloud 콘솔에서 Object Storage를 생성합니다. 이 버킷은 정적 파일을 업로드하는 저장소로 사용됩니다.

생성 후, vite build 결과물(dist/)의 모든 파일을 업로드합니다.

2. CDN 생성

이제 Object Storage를 오리진으로 연결하는 CDN을 생성합니다.

  • 오리진 서버: 방금 만든 Object Storage
  • 오리진 서버 경로: /dist
  • 나머지는 기본값으로 설정

3. 결과 확인

정상적으로 설정이 완료되면, https://<카카오클라우드 서비스 도메인>/index.html로 접속했을 때 vite로 빌드한 화면이 배포된 것을 확인할 수 있습니다 🎉


GitHub Actions로 자동 배포하기

수동으로 dist/ 파일을 업로드하는 대신, GitHub Actions CI/CD 파이프라인을 구성하면 main 브랜치에 코드를 푸시할 때마다 자동으로 KakaoCloud Object Storage에 정적 파일이 배포됩니다.

이 워크플로우의 주요 단계는 다음과 같습니다. 😃

  1. 빌드 단계
  • vite build 실행으로 dist/ 폴더 생성
  • 산출물(dist/)을 아티팩트로 업로드
  1. 배포 단계
  • 빌드된 산출물 다운로드
  • KakaoCloud IAM API로 인증 토큰 발급
  • Object Storage 버킷이 없으면 자동 생성
  • dist/ 파일 업로드

아래는 전체 스크립트입니다.

name: Deploy to Kakao Object Storage

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - name: Install dependencies
        run: npm ci --no-audit

      - name: Build app (vite only, no tsc)
        run: npx vite build --mode development
        env:
          DISABLE_ESLINT_PLUGIN: true
          GENERATE_SOURCEMAP: false

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist-files
          path: dist/
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      KAKAO_REGION: ${{ secrets.KAKAO_REGION }}
      KAKAO_PROJECT_ID: ${{ secrets.KAKAO_PROJECT_ID }}
      KAKAO_BUCKET: ${{ secrets.KAKAO_BUCKET }}
      KAKAO_IAM_ACCESS_KEY_ID: ${{ secrets.KAKAO_IAM_ACCESS_KEY_ID }}
      KAKAO_IAM_SECRET_KEY: ${{ secrets.KAKAO_IAM_SECRET_KEY }}
      OBJECT_PREFIX: dist
       
    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: dist-files
          path: dist/

      - name: Install tools
        run: |
          sudo apt-get update -y
          sudo apt-get install -y jq file

      - name: Issue API token
        id: auth
        shell: bash
        run: |
          set -e
          if [ -z "${KAKAO_IAM_ACCESS_KEY_ID}" ] || [ -z "${KAKAO_IAM_SECRET_KEY}" ]; then
            echo "KAKAO_IAM_ACCESS_KEY_ID or KAKAO_IAM_SECRET_KEY is missing"
            exit 1
          fi
          AUTH_PAYLOAD=$(cat <<JSON
          {
            "auth": {
              "identity": {
                "methods": ["application_credential"],
                "application_credential": {
                  "id": "${KAKAO_IAM_ACCESS_KEY_ID}",
                  "secret": "${KAKAO_IAM_SECRET_KEY}"
                }
              }
            }
          }
          JSON
          )
          RESP_FILE=$(mktemp)
          HEADERS_FILE=$(mktemp)
          HTTP_CODE=$(curl -sS -o "$RESP_FILE" -D "$HEADERS_FILE" -w "%{http_code}" \
            -X POST "https://iam.kakaocloud.com/identity/v3/auth/tokens" \
            -H "Content-Type: application/json" \
            -H "Accept: application/json" \
            -d "$AUTH_PAYLOAD")
          TOKEN=$(grep -i '^x-subject-token:' "$HEADERS_FILE" | awk -F': ' '{print $2}' | tr -d '\r')
          if [ -z "$TOKEN" ]; then
            echo "Failed to get X-Subject-Token (HTTP $HTTP_CODE)"
            echo "---- response headers ----"
            cat "$HEADERS_FILE" || true
            echo "---- response body ----"
            cat "$RESP_FILE" || true
            exit 1
          fi
          echo "token=$TOKEN" >> $GITHUB_OUTPUT

      - name: Ensure bucket exists
        run: |
          set -e
          BASE_URL="https://objectstorage.${KAKAO_REGION}.kakaocloud.com/v1/${KAKAO_PROJECT_ID}/${KAKAO_BUCKET}"
          # Create container if not exists (Swift: PUT on container path)
          curl -s -o /dev/null -w "%{http_code}" -X PUT "$BASE_URL" \
            -H "X-Auth-Token: ${{ steps.auth.outputs.token }}" \
            || true

      - name: Upload dist to bucket (overwrite)
        run: |
          set -e
          BASE_URL="https://objectstorage.${KAKAO_REGION}.kakaocloud.com/v1/${KAKAO_PROJECT_ID}/${KAKAO_BUCKET}"
          if [ -n "${OBJECT_PREFIX}" ]; then
            BASE_PATH="${BASE_URL}/${OBJECT_PREFIX}"
          else
            BASE_PATH="${BASE_URL}"
          fi
          
          # Function to get Content-Type by file extension
          get_content_type() {
            local file_path="$1"
            case "${file_path##*.}" in
              css) echo "text/css" ;;
              js|mjs) echo "application/javascript" ;;
              json) echo "application/json" ;;
              html|htm) echo "text/html" ;;
              svg) echo "image/svg+xml" ;;
              png) echo "image/png" ;;
              jpg|jpeg) echo "image/jpeg" ;;
              gif) echo "image/gif" ;;
              webp) echo "image/webp" ;;
              ico) echo "image/x-icon" ;;
              woff) echo "font/woff" ;;
              woff2) echo "font/woff2" ;;
              ttf) echo "font/ttf" ;;
              otf) echo "font/otf" ;;
              eot) echo "application/vnd.ms-fontobject" ;;
              map) echo "application/json" ;;
              txt) echo "text/plain" ;;
              xml) echo "application/xml" ;;
              pdf) echo "application/pdf" ;;
              zip) echo "application/zip" ;;
              *) echo "application/octet-stream" ;;
            esac
          }
          
          find dist -type f ! -name 'index.html' -print0 | while IFS= read -r -d '' file; do
            REL_PATH="${file#dist/}"
            MIME_TYPE=$(get_content_type "$REL_PATH")
            if [[ "$REL_PATH" =~ \.[0-9a-fA-F]{8,}\.(js|css|svg|png|jpg|jpeg|gif|webp|ico|map)$ ]]; then
              CACHE_CONTROL="public, max-age=31536000, immutable"
            else
              CACHE_CONTROL="no-cache, no-store, must-revalidate"
            fi
            echo "Uploading ${OBJECT_PREFIX:+$OBJECT_PREFIX/}$REL_PATH (type: $MIME_TYPE, cache: $CACHE_CONTROL)"
            curl --fail -s -X PUT "$BASE_PATH/$REL_PATH" \
              -H "X-Auth-Token: ${{ steps.auth.outputs.token }}" \
              -H "Content-Type: $MIME_TYPE" \
              -H "Cache-Control: $CACHE_CONTROL" \
              --data-binary @"$file" \
              -o /dev/null || { echo "Failed to upload $REL_PATH"; exit 1; }
          done

          if [ -f dist/index.html ]; then
            MIME_TYPE="text/html"
            CACHE_CONTROL="no-cache, no-store, must-revalidate"
            echo "Uploading ${OBJECT_PREFIX:+$OBJECT_PREFIX/}index.html (type: $MIME_TYPE, cache: $CACHE_CONTROL)"
            curl --fail -s -X PUT "$BASE_PATH/index.html" \
              -H "X-Auth-Token: ${{ steps.auth.outputs.token }}" \
              -H "Content-Type: $MIME_TYPE" \
              -H "Cache-Control: $CACHE_CONTROL" \
              --data-binary @"dist/index.html" \
              -o /dev/null || { echo "Failed to upload index.html"; exit 1; }
          fi

      - name: Done
        run: echo "배포 완료"

마지막으로

비슷한 구성을 AWS 환경에서 구현하고 싶다면 아래 문서를 참고해도 좋을 것 같습니다 :)

Amazon S3 + CloudFront + Route53 정적 웹사이트 배포

0개의 댓글