정적 파일을 배포하는 방법은 다양하지만, 이번 프로젝트에서는 비용 효율적이면서도 클라우드 환경에 최적화된 방식을 선택했습니다.
KakaoCloud에서 제공하는 Object Storage와 CDN 조합을 활용하면, 별도의 서버 없이도 React/Vite 같은 프론트엔드 빌드 산출물을 안정적으로 서비스할 수 있습니다.
CDN(Content Delivery Network)은 전 세계 여러 지점에 분산된 서버 네트워크를 통해 웹 페이지, 이미지, 동영상 등 정적 콘텐츠를 빠르게 전달하는 기술입니다. 사용자가 특정 지역에서 웹사이트를 요청하면, 가장 가까운 CDN 서버가 콘텐츠를 제공하여 지연을 최소화합니다.
쉽게 말하면, 전 세계 캐싱 서버에 내 웹사이트를 저장해두는 것이라고 이해할 수 있습니다.
참고 자료
AWS - CDN이란 무엇인가요?
KakaoCloud - CDN 개요
먼저 KakaoCloud 콘솔에서 Object Storage를 생성합니다. 이 버킷은 정적 파일을 업로드하는 저장소로 사용됩니다.
생성 후, vite build 결과물(dist/
)의 모든 파일을 업로드합니다.
이제 Object Storage를 오리진으로 연결하는 CDN을 생성합니다.
정상적으로 설정이 완료되면, https://<카카오클라우드 서비스 도메인>/index.html
로 접속했을 때 vite로 빌드한 화면이 배포된 것을 확인할 수 있습니다 🎉
수동으로 dist/
파일을 업로드하는 대신, GitHub Actions CI/CD 파이프라인을 구성하면 main 브랜치에 코드를 푸시할 때마다 자동으로 KakaoCloud Object Storage에 정적 파일이 배포됩니다.
이 워크플로우의 주요 단계는 다음과 같습니다. 😃
dist/
폴더 생성dist/
)을 아티팩트로 업로드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 환경에서 구현하고 싶다면 아래 문서를 참고해도 좋을 것 같습니다 :)