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

김유경·2025년 8월 17일
0

들어가며

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

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개의 댓글