← 목록

CloudFront Function을 활용한 멀티 SPA 프론트엔드 CI/CD 구축기

단일 SPA에서 멀티 앱 구조로 전환하면서 CloudFront Function 기반 경로별 SPA fallback과 앱별 선택 배포 파이프라인을 구축한 과정

2026-04-10AWSCloudFrontCI/CDDevOps

들어가며

세금계산서 앱을 추가하고 /tax/invoices/123을 열었더니 portal 화면이 떴다. 404도 아니고 에러도 아니라서 처음엔 라우팅 코드를 의심했는데, 원인은 앱이 아니라 그 앞단에 있었다.

기존 구조는 앱이 하나라는 전제 위에서 동작하고 있었고, 그 전제가 깨진 상태였다. 이 글은 전제가 바뀐 구조를 어떻게 다시 세웠는지에 대한 기록이다.


배경

프론트엔드는 기존에 단일 SPA(Single Page Application) 구조로 운영되고 있었다. S3 버킷 루트에 하나의 앱만 배포하고, CloudFront의 Custom Error Response(403 → /index.html)로 SPA 라우팅을 처리하는 방식이었다.

서비스가 확장되면서 portal 외에 invoice(세금계산서), retail-x 등 별도 프론트엔드 앱이 추가되었고, 이를 하나의 도메인에서 경로 기반으로 서빙해야 하는 요구사항이 생겼다.

기존 구조의 한계

사용자 → CloudFront → S3
                      ├── index.html    ← portal 앱 하나
                      └── assets/

SPA fallback: Custom Error Response (403 → /index.html)
배포: 전체 빌드 → S3 루트에 sync

기존 Custom Error Response 방식은 모든 404/403을 무조건 /index.html로 보내기 때문에, 멀티 앱 구조에서는 /tax/invoices/123 같은 요청도 portal의 index.html로 가버리는 문제가 있었다.

변경된 구조

사용자 → CloudFront → CF Function (SPA fallback) → S3
                                                    ├── index.html        ← portal
                                                    ├── assets/
                                                    ├── tax/
                                                    │   ├── index.html    ← invoice
                                                    │   └── assets/
                                                    └── retail-x/
                                                        ├── index.html    ← retail-x
                                                        └── assets/

하나의 S3 버킷, 하나의 CloudFront 배포 안에서 3개 앱이 경로별로 나뉘어 있는 구조다.

CloudFront Function 작성

CloudFront Function은 Viewer Request 단계에서 실행되어, S3로 요청이 가기 전에 URI를 rewrite한다.

function handler(event) {
    var request = event.request;
    var uri = request.uri;
 
    // 정적 파일 (js, css, png 등)은 그대로 통과
    if (uri.includes('.')) return request;
 
    // 앱별 SPA fallback
    if (uri.startsWith('/tax')) {
        request.uri = '/tax/index.html';
    } else if (uri.startsWith('/retail-x')) {
        request.uri = '/retail-x/index.html';
    } else {
        request.uri = '/index.html';
    }
 
    return request;
}

동작 예시

요청 URL판정rewrite 결과
/assets/main.js확장자 있음 → 통과/assets/main.js
/tax/invoices/123/tax prefix/tax/index.html
/tax/tax prefix/tax/index.html
/retail-x/products/retail-x prefix/retail-x/index.html
/mypage/profile해당 없음 → portal/index.html

핵심은 확장자가 없는 요청(SPA 라우팅 경로)만 해당 앱의 index.html로 rewrite하고, 정적 파일(.js, .css, .png 등)은 그대로 S3에서 가져오는 것이다.

함수를 짜면서 판단한 것들

이 열 줄짜리 함수에도 정해야 할 게 몇 가지 있었다.

정적 파일과 라우팅 경로를 무엇으로 구분할 것인가. 확장자 유무(uri.includes('.'))로 갈랐다. 단순하고 빠르지만 완벽한 규칙은 아니다. /users/john.doe 같은 경로가 생기면 정적 파일로 오인해서 S3로 그대로 가고 404가 난다. 지금 라우팅 규칙에는 그런 경로가 없어서 이 방식을 택했고, 대신 그 전제를 여기 적어둔다. 나중에 원인 모를 404가 나오면 이 줄부터 보면 된다.

어느 단계에서 실행할 것인가. Viewer Request 단계를 택했다. 요청이 도착하자마자 실행되기 때문에 캐시를 조회하기 전에 URI가 바뀐다. 즉 캐시는 rewrite된 경로 기준으로 동작한다. /tax/invoices/123과 /tax/orders/456이 같은 /tax/index.html 캐시를 공유하므로 적중률이 높다.

CloudFront Function으로 충분한가. 더 복잡한 처리가 필요하면 Lambda@Edge를 쓰지만, 그만큼 비용과 지연이 붙는다.

항목CloudFront FunctionLambda@Edge
실행 위치엣지 로케이션리전 엣지 캐시
실행 시간1ms 미만최대 5초 이상 가능
네트워크 호출불가가능
용도URI rewrite, 헤더 조작인증, 외부 조회 등

우리가 필요한 건 URI 변환뿐이라 가벼운 쪽으로 충분했다. 할 수 있는 게 적은 도구를 고르면 잘못 쓸 여지도 줄어든다.


왜 Custom Error Response 대신 CF Function인가?

항목Custom Error ResponseCloudFront Function
멀티 앱 분기불가 (하나의 경로만 지정)가능 (로직으로 분기)
실행 시점에러 발생 후요청 도착 즉시 (Viewer Request)
성능S3 404/403 발생 후 처리S3 요청 전에 URI 변환
확장성앱 추가 시 대응 불가else if 블록 추가만 하면 됨

적용 절차

1. CloudFront Function 생성 및 publish

# 함수 생성
aws cloudfront create-function \
  --name "spa-fallback-stg" \
  --function-config file://cf-function-config.json \
  --function-code fileb://spa-fallback.js
 
# publish (LIVE 상태로)
aws cloudfront publish-function \
  --name "spa-fallback-stg" \
  --if-match "[ETag]"

2. CloudFront 배포 설정 변경

Default Behavior에 CF Function을 Viewer Request로 연결하고, 기존 Custom Error Response(403 → /index.html)를 제거한다.

aws cloudfront update-distribution \
  --id [distribution-id] \
  --if-match "[ETag]" \
  --distribution-config file://cf-update-config.json

변경 포인트:

3. 배포 전파 확인

CloudFront 배포 업데이트는 전 세계 엣지 로케이션에 전파되는 데 3~10분 소요된다. Deployed 상태가 되면 적용 완료.

aws cloudfront get-distribution --id [distribution-id] \
  --query "Distribution.Status"

Jenkins CI/CD 변경

기존

변경

parameters {
    booleanParam(name: 'DEPLOY_PORTAL', defaultValue: false, description: 'portal 배포')
    booleanParam(name: 'DEPLOY_INVOICE', defaultValue: false, description: 'invoice 배포')
    booleanParam(name: 'DEPLOY_RETAIL_X', defaultValue: false, description: 'retail-x 배포')
}

앱별 매핑

파라미터turbo filterS3 sync 경로CF 무효화 경로
DEPLOY_PORTAL@portal/portals3://[bucket-name]/ (루트)/*
DEPLOY_INVOICE@portal/invoices3://[bucket-name]/tax//tax/*
DEPLOY_RETAIL_X@portal/retail-xs3://[bucket-name]/retail-x//retail-x/*

파이프라인 흐름: Checkout → Install → 선택된 앱마다 Build → S3 sync → CF 무효화

DEV 환경 (Dockerfile.dev)

로컬 개발과 동일하게 turbo MFE 프록시를 사용하되, Docker 컨테이너에서 외부 접근이 가능하도록 socat으로 포트 포워딩한다.

FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache socat

COPY package*.json ./
COPY --parents apps/*/package.json packages/*/package.json ./
RUN npm ci
COPY . .

EXPOSE 3000 3001 3002 3003

CMD sh -c "socat TCP-LISTEN:3000,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:3024 & npx turbo dev"

turbo MFE 프록시가 127.0.0.1에만 바인딩되기 때문에, socat이 0.0.0.0:3000에서 요청을 받아 127.0.0.1:3024로 전달하는 구조다.

S3 버킷 내부 구조

기존 버킷을 그대로 사용하되, 내부 구조가 멀티 앱으로 변경된다:

s3://[front-bucket]/
├── index.html          ← portal
├── assets/             ← portal
├── tax/
│   ├── index.html      ← invoice
│   └── assets/         ← invoice
└── retail-x/
    ├── index.html      ← retail-x
    └── assets/         ← retail-x

다운타임 없는 운영 적용

CF Function 연결은 CloudFront 배포 업데이트로 처리되며, rolling 방식으로 전파된다. 기존 설정으로 서빙하다가 점진적으로 새 설정으로 전환되기 때문에 다운타임이 발생하지 않는다.

적용 순서:

  1. STG 환경에 먼저 적용 + 테스트
  2. 문제 없으면 PROD에 동일하게 적용
  3. 배포 전파 완료 후 portal 기존 동작 확인

배포 후에 옛 화면이 보이는 문제

멀티 앱으로 나누면서 같이 정리한 게 캐시 정책이다. SPA 배포에서 흔히 겪는 증상이 있다. 배포를 했는데 일부 사용자에게는 옛 화면이 계속 보이고, 새로고침을 해도 그대로다.

원인은 두 종류의 파일이 서로 다른 성질을 갖기 때문이다.

파일이름적절한 캐시
index.html배포해도 이름이 같다짧게. 매번 새로 확인해야 한다
assets/main.[hash].js내용이 바뀌면 이름도 바뀐다길게. 같은 이름이면 같은 내용이다

index.html이 오래 캐시되면, 브라우저는 옛 HTML을 들고 있으면서 그 안에 적힌 옛 해시의 JS를 요청한다. 배포 때 옛 파일을 지웠다면 그 요청은 404가 되고, 화면이 깨진다. 그래서 배포 시 S3 sync는 기존 파일을 남겨두고, 무효화는 앱별 경로로 한정했다.

앱별 배포에는 이 점에서도 이득이 있다. 예전에는 무엇을 고쳐도 /* 전체를 무효화해야 했는데, 이제는 /tax/*만 지우면 된다. 무효화 범위가 좁아지면 다른 앱 사용자는 캐시를 그대로 쓰기 때문에, 배포가 다른 앱의 체감 성능을 건드리지 않는다.


앱 추가 시

새로운 앱이 추가되면 이렇게 한다.

  1. CF Function에 else if 블록 하나 추가
  2. Jenkins 파라미터에 체크박스 하나 추가
  3. S3에 해당 경로로 sync

인프라 변경 없이 코드 레벨에서만 확장 가능한 구조다. 다만 else if가 늘어나는 방식이라 앱이 열 개쯤 되면 함수가 지저분해진다. 그때는 경로와 앱의 매핑을 배열로 빼거나, 아예 경로 규칙을 정해서(모든 앱은 /{app}/ 아래) 분기 없이 처리하는 쪽이 맞다. 지금 규모에서는 분기 세 개가 읽기 쉬워서 그대로 뒀다.

정리

항목기존변경
앱 수1개 (portal)3개 (portal, invoice, retail-x)
SPA fallbackCustom Error ResponseCloudFront Function
배포 단위전체앱별 선택
S3 구조루트에 단일 앱경로별 멀티 앱
확장성앱 추가 어려움else if + 체크박스 추가

배운 점

하나를 서빙하는 구조와 여럿을 서빙하는 구조는 설정 하나 차이가 아니었다. Custom Error Response 방식은 앱이 하나일 때만 성립한다. 403을 전부 같은 index.html로 보내기 때문에, 앱이 늘어나면 어느 앱의 경로인지 구분할 방법이 없다. 기존 방식이 틀렸다기보다 전제가 바뀐 경우였다.

그래서 판단 기준은 "지금 구조가 성립하던 전제가 아직 유효한가"가 됐다. 요구사항이 늘었을 때 설정을 덧붙일지 방식을 바꿀지 고민하게 되는데, 전제가 깨졌다면 덧붙이는 쪽은 임시방편이 된다. 이번에는 전제가 깨진 게 분명해서 방식을 바꿨다.

전제를 코드 옆에 적어두는 습관이 생겼다. 확장자 유무로 정적 파일을 판단하는 규칙은 지금 라우팅에서는 맞지만 영원히 맞지는 않는다. 이런 건 나중에 원인 모를 404로 돌아오기 때문에, 판단 근거를 함수 옆에 남겨두는 것까지가 작업이라고 생각하게 됐다.

배포 단위를 나누면 무효화 범위도 같이 나뉜다. 앱별 배포는 빌드 시간만 줄이는 게 아니라, 한 앱의 배포가 다른 앱 사용자의 캐시를 건드리지 않게 해준다. 하나를 고치려고 전체를 배포하는 구조는 배포 자체를 무섭게 만든다.

이 변경을 운영 중에 무중단으로 적용할 수 있었던 이유는 CloudFront 배포 업데이트는 정말 다운타임 없이 가능할까?에 따로 정리했다.


이어 읽기