serverless-finch
백엔드를 Serverless Framework로 배포하고 있으면 자연스럽게 드는 생각이 있다. "프론트엔드도 같은 방식으로 배포할 수 없을까?" Lambda 함수야 serverless deploy로 끝나는데, React나 Vue로 빌드한 정적 파일들은 여전히 수동으로 S3에 올려야 한다. AWS 콘솔에서 버킷 만들고, 정적 웹사이트 호스팅 켜고, 퍼블릭 접근 정책 설정하고, 빌드 결과물 업로드하고... 이 과정을 매번 반복하는 건 분명히 자동화할 수 있는 영역이다.
serverless-finch는 이 문제를 해결하는 Serverless Framework 플러그인이다. serverless.yml에 몇 줄 설정을 추가하면 S3 버킷 생성, 정적 웹사이트 호스팅 설정, 파일 업로드를 하나의 명령어로 처리할 수 있다.
왜 필요한가
S3 정적 사이트 배포를 수동으로 하면 다음 작업이 매번 필요하다.
- S3 버킷 생성
- 정적 웹사이트 호스팅 활성화
- 퍼블릭 접근을 위한 버킷 정책 설정
- CORS 설정 (필요 시)
- 빌드된 파일 업로드
- 캐시 헤더 설정
이걸 스테이지별(dev, staging, prod)로 관리하면 복잡도가 배로 늘어난다. 버킷 이름도 다르고, 설정도 다를 수 있다. serverless-finch는 이 모든 것을 serverless.yml의 custom.client 블록 하나로 선언적으로 관리한다.
AWS CLI의 aws s3 sync로도 파일 업로드는 가능하지만, 버킷 생성이나 호스팅 설정까지는 커버하지 못한다. CloudFormation으로 S3 리소스를 정의하면 버킷은 만들 수 있지만, 빌드 결과물 업로드는 별도로 처리해야 한다. serverless-finch는 이 두 가지를 하나로 합친다.
설치와 기본 설정
npm install --save-dev serverless-finch
serverless.yml에 플러그인을 등록하고 custom.client에 설정을 추가한다.
plugins:
- serverless-finch
custom:
client:
bucketName: my-app-${self:provider.stage}
distributionFolder: dist
indexDocument: index.html
errorDocument: index.html
이게 최소 설정이다. 각 필드의 의미를 보자.
bucketName
S3 버킷 이름. 전 세계적으로 유일해야 한다. ${self:provider.stage}를 붙여서 스테이지별로 분리하는 것이 일반적이다. 예를 들어 my-app-dev, my-app-prod처럼 된다.
distributionFolder
업로드할 빌드 결과물이 위치한 디렉토리. serverless.yml 기준 상대 경로다. React의 경우 보통 build, Vite 기반이면 dist가 된다. 기본값은 client/dist인데, 대부분의 프로젝트에서는 명시적으로 지정해주는 게 좋다.
indexDocument / errorDocument
S3 정적 웹사이트 호스팅의 인덱스 문서와 에러 문서 설정이다. SPA에서는 둘 다 index.html로 설정하는 것이 핵심이다. 왜냐하면 SPA는 클라이언트 사이드 라우팅을 사용하기 때문에 /about 같은 경로로 직접 접근하면 S3 입장에서는 해당 파일이 없어서 404를 반환한다. errorDocument를 index.html로 설정하면 404 대신 index.html이 서빙되고, 클라이언트 라우터가 URL을 해석해서 올바른 페이지를 렌더링한다.
배포와 제거
# 배포
serverless client deploy
# 스테이지 지정 배포
serverless client deploy --stage prod
# 리전 지정
serverless client deploy --region ap-northeast-2
# 기존 파일 유지하면서 배포 (삭제 안 함)
serverless client deploy --no-delete-contents
# 버킷 정책 변경 안 함
serverless client deploy --no-policy-change
# 사이트 제거
serverless client remove
serverless client deploy를 실행하면 내부적으로 다음 순서로 동작한다.
- 버킷 존재 확인: 지정된 이름의 S3 버킷이 있는지 확인하고, 없으면 생성한다
- 기존 파일 삭제: 기본적으로 버킷의 기존 내용을 모두 삭제한다 (
--no-delete-contents로 방지 가능) - 정적 웹사이트 호스팅 설정: indexDocument, errorDocument 설정 적용
- 버킷 정책 설정: 퍼블릭 읽기 접근을 허용하는 정책 적용
- CORS 설정: 기본 CORS 규칙 또는 커스텀 규칙 적용
- 파일 업로드: distributionFolder의 모든 파일을 S3에 업로드
- 헤더 설정: objectHeaders에 정의된 HTTP 응답 헤더 적용
⚠️ 중요한 주의사항: 기본 동작이 기존 파일을 모두 삭제하고 새로 업로드하는 것이다. 이미 데이터가 있는 버킷 이름을 실수로 지정하면 데이터가 날아갈 수 있다.
캐시 헤더 전략
정적 사이트 배포에서 캐시 전략은 성능에 직접적인 영향을 미친다. serverless-finch의 objectHeaders 옵션으로 파일 유형별 캐시 정책을 세밀하게 제어할 수 있다.
custom:
client:
bucketName: my-app-${self:provider.stage}
distributionFolder: dist
indexDocument: index.html
errorDocument: index.html
objectHeaders:
ALL_OBJECTS:
- name: Cache-Control
value: max-age=31536000
'*.html':
- name: Cache-Control
value: max-age=0, no-cache, no-store, must-revalidate
'*.json':
- name: Cache-Control
value: max-age=0, no-cache, no-store, must-revalidate
이 설정의 로직을 풀어보면 이렇다.
모든 파일(ALL_OBJECTS): 1년(31536000초) 캐시. JS, CSS, 이미지 파일 등 해시가 포함된 정적 에셋은 내용이 바뀌면 파일명도 바뀌기 때문에 오래 캐시해도 안전하다. Vite나 Webpack 같은 번들러가 빌드 시 파일명에 해시를 넣어주므로 app.a1b2c3.js처럼 된다.
HTML 파일(*.html): 캐시 안 함. index.html은 앱의 진입점이고, 새로운 빌드를 배포하면 이 파일이 새 에셋 파일들을 참조해야 한다. 캐시되면 사용자가 이전 버전의 앱을 보게 된다.
JSON 파일(*.json): 캐시 안 함. manifest.json이나 설정 파일은 수시로 바뀔 수 있다.
objectHeaders는 더 구체적인 패턴이 우선한다. ALL_OBJECTS에서 1년 캐시를 설정하고 *.html에서 캐시를 끄면, HTML 파일은 캐시가 꺼진다. 이 우선순위 규칙 덕분에 "기본은 길게 캐시, 특정 파일만 예외"라는 전략을 깔끔하게 표현할 수 있다.
SPA 라우팅과 CloudFront 연동
serverless-finch만으로 S3 정적 웹사이트 호스팅은 완성되지만, 실제 프로덕션에서는 보통 CloudFront를 앞에 둔다. HTTPS, CDN 캐싱, 커스텀 도메인 등을 위해서다. 이 조합에서 SPA 라우팅 처리는 두 레이어에서 모두 설정해야 한다.
S3 레벨
serverless-finch의 errorDocument: index.html 설정으로 S3 자체의 404를 처리한다.
CloudFront 레벨
CloudFront에서도 CustomErrorResponses를 설정해서 403/404 에러를 index.html로 리다이렉트한다.
resources:
Resources:
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
CustomErrorResponses:
- ErrorCode: 404
ResponseCode: 200
ResponsePagePath: /index.html
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: /index.html
왜 403도 처리하는가? CloudFront + OAC(Origin Access Control)를 사용하면 S3 버킷에 직접 접근이 차단되고, 존재하지 않는 경로에 대해 S3가 404 대신 403(Access Denied)을 반환하기 때문이다. OAC 환경에서는 403이 사실상 "파일 없음"을 의미한다.
CloudFront와 serverless-finch의 역할 분리
serverless-finch는 파일 업로드와 S3 설정만 담당한다. CloudFront 배포, OAC, SSL 인증서 같은 인프라는 serverless.yml의 resources 섹션에서 CloudFormation으로 직접 정의한다. serverless-finch가 모든 것을 해주는 게 아니라, S3 업로드 자동화라는 하나의 역할에 집중하는 것이다.
배포 시에도 순서가 중요하다. 먼저 serverless deploy로 CloudFormation 스택(CloudFront, S3 버킷 등)을 배포하고, 그다음 serverless client deploy로 빌드 파일을 업로드한다. 이 두 명령어는 별개이며, CI/CD에서도 순서대로 실행해야 한다.
배포 무효화와 serverless-cloudfront-invalidate
serverless-finch로 새 파일을 업로드해도 CloudFront 엣지 캐시에 이전 버전이 남아 있으면 사용자에게 새 버전이 보이지 않는다. serverless-cloudfront-invalidate 플러그인을 함께 사용하면 배포 후 자동으로 캐시를 무효화할 수 있다.
plugins:
- serverless-finch
- serverless-cloudfront-invalidate
custom:
cloudfrontInvalidate:
- distributionIdKey: WebDistributionId
items:
- "/*"
distributionIdKey는 CloudFormation Outputs에 정의된 키를 참조한다. items: ["/*"]는 모든 경로의 캐시를 무효화한다. 특정 경로만 무효화할 수도 있지만, 프론트엔드 배포 시에는 보통 전체 무효화가 안전하다. AWS에서 월 1,000건까지는 무료이므로 비용 걱정도 크지 않다.
실전 설정 예시
모노레포에서 프론트엔드 앱을 배포하는 실전 설정을 보자. 백엔드는 별도의 serverless.yml로 관리하고, 프론트엔드 앱은 자체 serverless.yml에서 serverless-finch로 배포한다.
service: my-app-web
frameworkVersion: '3'
plugins:
- serverless-finch
- serverless-cloudfront-invalidate
provider:
name: aws
runtime: nodejs18.x
region: ${opt:region, 'ap-northeast-2'}
stage: ${opt:stage, 'dev'}
custom:
bucketName: my-app-web-${self:provider.stage}
client:
bucketName: ${self:custom.bucketName}
distributionFolder: dist
indexDocument: index.html
errorDocument: index.html
objectHeaders:
ALL_OBJECTS:
- name: Cache-Control
value: max-age=31536000
'*.html':
- name: Cache-Control
value: max-age=0, no-cache, no-store, must-revalidate
cloudfrontInvalidate:
- distributionIdKey: DistributionId
items:
- "/*"
resources:
Resources:
S3Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: ${self:custom.bucketName}
# ... 버킷 설정
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Origins:
- DomainName: !GetAtt S3Bucket.RegionalDomainName
Id: S3Origin
# ... OAC 설정
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
# ...
CustomErrorResponses:
- ErrorCode: 404
ResponseCode: 200
ResponsePagePath: /index.html
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: /index.html
Outputs:
DistributionId:
Value: !Ref CloudFrontDistribution
CI/CD에서는 이렇게 사용한다.
# 1. 프론트엔드 빌드
pnpm build
# 2. CloudFormation 스택 배포 (최초 또는 인프라 변경 시)
serverless deploy --stage dev
# 3. 정적 파일 업로드
serverless client deploy --stage dev --no-confirm
# 4. CloudFront 캐시 무효화 (serverless-cloudfront-invalidate가 자동 처리)
--no-confirm 플래그는 "기존 파일을 삭제하겠습니다"라는 확인 프롬프트를 스킵한다. CI 환경에서는 대화형 프롬프트를 받을 수 없으므로 필수다.
커스텀 버킷 정책과 CORS
기본적으로 serverless-finch는 퍼블릭 읽기 접근을 허용하는 버킷 정책을 자동으로 설정한다. 하지만 CloudFront OAC를 사용하는 경우처럼 커스텀 정책이 필요한 상황이 있다.
custom:
client:
bucketName: my-app-${self:provider.stage}
bucketPolicyFile: ./policy-${self:provider.stage}.json
corsFile: ./cors.json
bucketPolicyFile로 커스텀 정책 파일을 지정할 수 있다. 스테이지별로 다른 정책을 적용하려면 ${self:provider.stage}를 활용한다.
하지만 실무에서는 serverless-finch의 자동 정책 대신 resources 섹션에서 CloudFormation으로 버킷 정책을 직접 정의하는 경우가 많다. 특히 OAC를 사용할 때는 CloudFront만 접근 가능하도록 하는 정책이 필요한데, 이건 serverless-finch의 기본 정책과 충돌한다. 이런 경우 --no-policy-change 플래그로 serverless-finch가 정책을 건드리지 않게 한다.
serverless client deploy --no-policy-change --no-cors-change
태그 설정
AWS 리소스에 태그를 붙이는 것은 비용 추적과 리소스 관리의 기본이다.
custom:
client:
bucketName: my-app-${self:provider.stage}
tags:
project: my-app
environment: ${self:provider.stage}
team: frontend
태그는 S3 버킷에 적용되며, AWS Cost Explorer에서 프로젝트별/환경별 비용을 분리해서 볼 수 있게 해준다.
리다이렉트 설정
기존 도메인에서 새 도메인으로 트래픽을 보내야 할 때 S3의 리다이렉트 기능을 활용할 수 있다.
custom:
client:
bucketName: old-domain-redirect
redirectAllRequestsTo:
hostName: www.new-domain.com
protocol: https
이 설정은 해당 버킷으로 들어오는 모든 요청을 지정된 호스트로 리다이렉트한다. 도메인 마이그레이션이나 www ↔ non-www 리다이렉트에 유용하다.
더 세밀한 제어가 필요하면 routingRules를 사용한다.
custom:
client:
bucketName: my-app-${self:provider.stage}
routingRules:
- redirect:
hostName: new-path.example.com
replaceKeyPrefixWith: new-prefix/
protocol: https
condition:
keyPrefixEquals: old-prefix/
/old-prefix/page로 들어오면 https://new-path.example.com/new-prefix/page로 리다이렉트된다. URL 구조가 변경된 경우 SEO에 미치는 영향을 최소화할 수 있다.
대안 비교
aws s3 sync
aws s3 sync ./dist s3://my-bucket --delete
AWS CLI의 s3 sync는 파일 업로드만 처리한다. 버킷 생성, 호스팅 설정, 정책, 헤더 설정은 모두 별도로 해야 한다. 간단한 업로드에는 충분하지만, 반복적인 배포에는 부족하다.
CloudFormation 단독
S3 버킷과 CloudFront를 CloudFormation으로 정의하면 인프라는 코드로 관리된다. 하지만 CloudFormation은 파일 업로드 기능이 없다. 인프라 정의와 파일 배포가 분리되어 별도의 스크립트가 필요하다.
serverless-s3-sync
serverless-finch와 비슷한 역할을 하는 또 다른 플러그인이다. s3 sync를 래핑하며 여러 버킷에 동시 배포할 수 있다는 차이가 있다. serverless-finch는 하나의 버킷에 대한 배포에 특화되어 있고 정적 웹사이트 호스팅 설정까지 자동으로 해준다는 점이 다르다.
Terraform + aws_s3_object
Terraform으로 S3 리소스를 관리하면서 aws_s3_object로 파일도 업로드할 수 있다. 하지만 파일 수가 많으면 Terraform state가 비대해지고, 변경 감지도 느려진다. 인프라 관리 도구로 애플리케이션 배포까지 하는 것은 관심사가 섞이는 문제가 있다.
정리
serverless-finch는 "S3에 정적 파일 올리기"라는 단순하지만 반복적인 작업을 자동화한다. Serverless Framework 생태계 안에서 백엔드와 프론트엔드 배포를 동일한 도구 체인으로 관리할 수 있게 해주는 것이 핵심 가치다. 내부적으로 복잡한 일을 하는 것은 아니지만, S3 버킷 설정 → 파일 업로드 → 헤더 설정이라는 과정을 선언적으로 한 곳에서 관리한다는 점이 편리하다.
다만 CloudFront 배포나 OAC 설정 같은 부분은 serverless-finch의 범위가 아니므로 CloudFormation이나 다른 도구와 함께 사용해야 한다. 역할의 경계를 이해하고, serverless-finch는 "S3 업로드 자동화"라는 하나의 역할에 집중하는 도구로 보는 것이 맞다.