[무중단 배포 전환기] SST 프레임워크 걷어내기 (Next.js14)

D uuu·2025년 2월 19일
0

Next.js14 프로젝트

목록 보기
14/17
post-thumbnail

SST를 사용했을 때의 문제점

처음에는 SST 프레임워크를 사용해 AWS 환경에 Next.js 애플리케이션을 배포했다. Vercel처럼 간편하지는 않았지만, AWS 리소스를 직접 관리하면서 어떤 서비스를 사용하는지 파악할 수 있다는 점이 좋았다. 하지만 얼마 지나지 않아 예상치 못한 문제가 발생했다.

AWS 비용이 갑자기 $100 이상 청구된 것이다.. 원인을 찾다 보니, SST를 설정할 때 IAM 권한을 FullAccess로 주었는데, 이로 인해 AWS Support 서비스의 비즈니스 플랜(유료)에 자동 가입된 것이었다. 게다가 SST를 통해 배포된 AWS 서비스를 살펴보던 중 생각보다 다양한 서비스가 사용되고 있다는 사실을 알게 되었다. 어떤 서비스가 왜 사용되는지 명확히 알지 못하는 상황에서 이로 인해 점점 불안감이 커졌다.

과연 SST 구조를 제대로 이해하지 못한 상태에서 계속 사용해도 괜찮을까? 앞으로도 내가 컨트롤할 수 없는 문제가 또 생기지 않을까? 이런 고민 끝에 결국, SST에 대한 의존성을 줄이고 AWS 환경에 직접 배포하는 방식으로 전환하기로 했다.

무중단 배포

새로운 배포 방식으로 전환하면서 가장 중요했던 것은 무중단 배포 였다. 서비스가 운영 중인 상태에서 기존 환경을 중단하지 않고, 새로운 환경을 구축한 뒤 정상 작동을 확인하며 점진적으로 이전하는 방식이 필요했다.

이를 위해서 기존에 SST 로 배포한 환경을 그대로 두고 새로운 서비스 환경을 점진적으로 구축해 나가기로 했다. 예를 들어, 기존 환경을 변경하지 않으면서 S3, CloudFront, Lambda 등의 새로운 리소스를 추가로 생성하고, 환경을 설정한 후에 이들을 연결한다. 연결이 완료되면 테스트 도메인을 설정하여 정상적으로 작동하는지 확인한 다음 모든 테스트가 완료되면 기존 도메인을 새로운 서비스로 이전하는 방식으로 진행하는 것이다.

✅ 목표 설정

  • 기존 SST 로 설정된 서비스는 변경하지 않으며, 언제든지 롤백할 수 있도록 안전망을 마련
  • 정적 리소스는 S3에 업로드하고, CloudFront CDN을 활용해 빠르게 배포
  • 동적 리소스는 Lambda(Serverless)로 실행하여 비용 절감
  • API Gateway를 사용해 정적 및 동적 리소스를 분리
  • CodePipeline 을 이용해 push 이벤트 발생 시 자동으로 배포 진행하도록 CI/CD 구축

진행 순서

1️⃣ next.config.mjs 설정

Next.js를 서버리스 환경에 배포할 때 standalone 모드를 활성화하면 불필요한 파일을 제외하고 최소한의 파일만 포함된 버전을 생성할 수 있다. 이를 통해 배포 용량을 줄이고 속도를 최적화할 수 있다.

예를 들어, node_modules 폴더는 많은 파일을 포함하고 있어 무겁지만, 실행에 필요한 의존성만 복사하여 standalone 폴더에 넣으면 배포 속도를 개선할 수 있다.

사용법으로는 next.config.mjs 에 output : { 'standalone' } 을 설정하면 된다.

const nextConfig = {
    output: 'standalone',
};

2️⃣ run.sh & preapre-build.sh 스크립트 작성

서버리스 환경이나 컨테이너 환경에서 배포할 때 필요한 초기 설정 및 빌드 후 준비 작업을 수행하는 스크립트를 작성한다.
루트 디렉토리에 두개의 파일을 생성해 아래와 같이 작성해준다.

run.sh

Lambda 환경 또는 서버리스 환경에서 Next.js 서버를 실행하는 역할을 한다.


#!/bin/bash

[ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache

HOSTNAME=0.0.0.0 exec node server.js

preapre-build.sh

이 스크립트는 next build 후 standalone 폴더에 필요한 파일을 복사하는 역할을 한다.

#!/bin/bash
cp -r public/. .next/standalone/public
cp -r .next/static/. .next/standalone/.next/static
cp run.sh .next/standalone/run.sh

3️⃣ package.json 수정

Next.js 빌드 후 자동으로 prepare-build.sh 스크립트를 실행하도록 package.json 의 script 을 수정한다.

"build": "next build && sh ./prepare-build.sh",

이렇게 하면 빌드 후 .next/standalone/ 폴더 내부에 server.js 와 필요한 파일들이 생성되는 것을 확인할 수 있다.
그리고 아래 명령어를 통해 standalone 파일을 app.zip 으로 압축한다. 나중에 이 파일을 Lambda 에 업로드 할 것이다.

cd .next/standalone/ && zip -r app.zip .

4️⃣ AWS CLI 설정 및 S3 업로드

AWS CLI를 사용해 IAM 사용자 인증을 설정하고, 정적 파일을 S3에 업로드한다.
먼저 IAM 사용자 액세스 키를 적용해준다.

aws configure

IAM 에서 발급받은 액세스 키와 시크릿 키, 리전을 맞게 넣어준다.

AWS Access Key ID [None]: <새로운 액세스 키>
AWS Secret Access Key [None]: <새로운 시크릿 키>
Default region name [None]: ap-northeast-2 # 서울 리전을 사용한다면!
Default output format [None]: json

aws s3 sync 명령어를 통해 .next/static 폴더를 버킷에 업로드 한다.

aws s3 sync .next/static s3://<버킷명>/_next/static --acl private

5️⃣ CloudFront 생성

CloudFront 배포를 생성하고, 원본을 위에서 생성한 S3 버킷과 연결한다. 이후 생성된 배포 도메인(예: abcd1234.cloudfront.net)을 next.config.mjs 의 assetPrefix 부분에 넣어준다.

assetPrefix 는 Next.js 에서 정적 자원을 로드할때 사용하는 경로를 설정하는 옵션으로 배포 환경에서 정적 자원을 외부 CDN 에서 제공할 수 있도록 해준다.

const nextConfig = {
    output: 'standalone',
    assetPrefix: process.env.NODE_ENV === 'production' ? 'https://abcdsd314213.cloudfront.net' : '',
};

6️⃣ AWS Lambda 함수 생성

Lambda 함수를 생성하고 app.zip 을 lambda 에 배포하기 위한 명령어를 터미널에서 실행한다.

function-name 에는 함수명을 넣는다.
role 에는 본인의 aws account_id 를 넣어주고 Lambda 에 연결한 IAM 역할을 부여한다.

aws lambda create-function \
    --function-name "lambda 함수 이름" \
    --handler "run.sh" \
    --runtime "nodejs20.x" \
    --memory-size 256 \
    --timeout 10 \
    --zip-file fileb://app.zip \
    --role "arn:aws:iam::<account_id>:role/lambda-nextjs-role-test" \
    --architectures '["x86_64"]' \
    --layers '["arn:aws:lambda:ap-northeast-2:753240598075:layer:LambdaAdapterLayerX86:20"]' \
    --environment "Variables={PORT=8000, AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap, RUST_LOG=info}"

그 다음 위에서 생성한 함수의 ARN 을 가져온다.

aws lambda get-function --function-name "lambda 함수 이름" --query 'Configuration.FunctionArn' --output text

7️⃣ AWS API Gateway V2 생성

Lambda와 API Gateway를 연결하여 Next.js를 HTTP 엔드포인트로 실행할 수 있도록 한다.

aws apigatewayv2 create-api \
    --name "API Gateway 이름" \
    --protocol-type HTTP

그 다음 API Gateway ID 가져오기

aws apigatewayv2 get-apis --query "Items[?Name=='API Gateway 이름'].ApiId"

8️⃣ API Gateway 와 Lambda 연결하기

Lambda와 API Gateway를 연결하는 Integration 생성한다.

aws apigatewayv2 create-integration \
    --api-id <api_id> \
    --integration-type AWS_PROXY \
    --payload-format-version "2.0" \
    --integration-uri "arn:aws:lambda:ap-northeast-2:<account-id>:function:lambda 함수 이름"

그 다음 생성된 Intergration ID 가져온다.

aws apigatewayv2 get-integrations --api-id <api_id> --query "Items[?IntegrationType=='AWS_PROXY'].IntegrationId" --output text

9️⃣ API Gateway 라우트(Route) 설정

기본 라우트를 먼저 설정

aws apigatewayv2 create-route \
    --api-id "<api_id>" \
    --route-key "ANY /" \
    --target "integrations/<integration>"

다음으로 프록시 라우트 생성 (동적 라우트 지원)

aws apigatewayv2 create-route \
    --api-id <api_id> \
    --route-key "ANY /{proxy+}" \
    --target "integrations/<integration>"

🔟 Lambda가 API Gateway 요청을 받을 수 있도록 권한 추가

aws lambda add-permission \
    --function-name "lambda 함수 이름 넣어주기" \
    --statement-id "AllowExecutionFromAPIGateway" \
    --action "lambda:InvokeFunction" \
    --principal "apigateway.amazonaws.com" \
    --source-arn "arn:aws:execute-api:ap-northeast-2:<account-id>:<api-id>/*/*"

1️⃣1️⃣ API Gateway 배포

배포 ID 가져오기

aws apigatewayv2 create-deployment \
    --api-id "<api-id>" \
    --query 'DeploymentId' \
    --output text

API Gateway V2 Stage 생성

aws apigatewayv2 create-stage \
    --api-id "<api-id>" \
    --stage-name '$default' \
    --deployment-id "<deployment-id>"

✅ 완료 및 정리

모든 설정이 완료되면 API Gateway의 엔드포인트로 접속하여 애플리케이션이 정상적으로 동작하는지 확인한다.

마지막으로, 도메인 설정만 마무리하면 된다. API Gateway에서 사용자 지정 도메인 이름을 추가하고, ACM에서 발급받은 도메인과 동일하게 설정한다. 이후, Route53에서 해당 도메인의 A 레코드 별칭을 API Gateway 도메인 이름으로 연결한 뒤, 해당 도메인으로 접속하여 애플리케이션이 정상적으로 동작하는지 확인한다. 모든 과정이 정상적으로 완료되었다면, 기존에 사용하던 SST 코드와 AWS 환경에서 생성된 불필요한 리소스를 정리한다.

배포 자동화 적용하기

앞서 진행한 작업에서는 AWS 환경에서 S3, CloudFront, Lambda, API Gateway를 생성하고 상호 연결하여 Next.js 애플리케이션을 배포했다. 이후, 수동 배포를 통해 애플리케이션이 정상적으로 동작하는 것까지 확인했다.

이제 이 배포 과정을 자동화하기 위해 AWS CodePipeline을 활용하여 CI/CD 파이프라인을 구축했다.

배포 자동화 흐름

buildspec.yml 파일에 빌드 및 배포 과정에 필요한 명령어를 추가하여 코드가 변경될 때마다 자동으로 배포되도록 설정했다. 기존에 AWS CodePipeline을 사용하고 있었으므로, 동일한 방식을 유지하면서 배포 프로세스를 자동화했다.

💡 CodeBuild 환경에서 개발 중 사용했던 환경 변수를 반드시 설정해야 한다.

buildspec.yml 설정

아래 buildspec.yml 파일은 앞서 진행한 수동 배포 작업을 자동화하는 스크립트다. 코드 변경 사항을 push하면 CodePipeline이 트리거되어 다음 단계를 수행한다. 아래와 같이 package.json의 scripts 부분에 미리 명령어를 정의해두었다.

"upload": "aws s3 sync .next/static s3://<버킷명>/_next/static",
"cache-purge": "aws cloudfront create-invalidation --distribution-id <CloudFront명> --paths \"/*\""

version: 0.2

phases:
    install:
        runtime-versions:
            nodejs: 20
        commands:
            - echo node version check
            - node -v
            - npm -v
    pre_build:
        commands:
            - echo Installing source NPM dependencies...
            - npm install
    build:
        commands:
            - echo building app...
            - npm run build
            - cd .next/standalone
            - zip -r app.zip .
    post_build:
        commands:
            - echo S3 upload started on `date`
            - npm run upload
            - echo Lambda upload started on `date`
            - aws lambda update-function-code --function-name eat-fit-serverless --zip-file fileb://app.zip
            - echo Build completed on `date`
            - npm run cache-purge
            - echo Build Finished....
artifacts:
    files:
        - '**/*'

이제 코드 변경 후 push만 하면 자동으로 애플리케이션을 빌드하고 빌드 된 파일을 압축해 app.zip 을 생성한다. 이후 정적 리소스를 S3 에 업로드하고 압축된 파일은 Lambda 함수에 업데이트 한다. 배포가 완료되면 CloudFront 캐시를 삭제해 변경 사항이 즉시 반영되도록 한다.

standalone 배포 시간 최적화

이번 작업에서 Next.js의 standalone 배포 방식을 적용하면서 덕분에 배포 시간이 기존 8~10분에서 2분대로 단축되었다. 이는 약 75% 감소한 결과이자 엄청난 효율성을 가지고 왔다.

배포 속도가 빨라지면서 개발자는 더 빠른 피드백 루프를 경험할 수 있고, 배포 과정에서 발생하는 문제도 신속하게 해결할 수 있다. 이전에는 배포 도중 오류가 발생하면 수정 후 다시 배포할 때마다 6~8분을 기다려야 했지만, 이제는 2분 내로 배포가 완료되므로 시간 낭비가 줄어들었다. 또한, 변경 사항을 프로덕션에 반영하는 속도가 빨라지면서 사용자에게 새로운 기능을 더욱 신속하게 제공할 수 있게 되었다. 이는 사용자 경험 개선뿐만 아니라, 지속적인 서비스 업데이트에도 긍정적인 영향을 미친다.

변경 전변경 후

CI/CD 및 유저 진입 프로세스 다이어그램

배포를 하면서 가장 어려웠던 부분은 각 서비스를 하나의 인프라로 유기적으로 연결하는 것이었다. S3, CloudFront, Lambda, API Gateway 등 이들이 실제로 어떻게 조합되어 하나의 배포 프로세스를 구성하는지 머릿속에서 명확하게 그리는 것이 쉽지 않았다.

애플리케이션이 브라우저에서 정상적으로 동작하려면 여러 서비스가 복잡하게 얽혀 상호작용해야 한다. 하지만 처음에는 각 서비스가 어떤 역할을 하는지 따로따로 익히다 보니, 이를 하나의 흐름으로 연결하는 과정에서 혼란이 생겼다. "이 요청이 어디로 전달되지?", "이 데이터는 어떤 경로를 따라가야 하지?" 같은 질문들이 계속해서 떠올랐고, 전체적인 구조가 쉽게 잡히지 않았다.

요즘은 구글에 검색하면 수많은 배포 가이드가 나오고, 이를 그대로 따라 하면 배포 자체는 비교적 쉽게 할 수 있다. 하지만 단순히 "배포가 되었다"는 것과 "배포가 어떻게 동작하는지 이해하는 것"은 완전히 다르다. 실제 운영 환경에서는 예상치 못한 문제가 언제든 발생할 수 있는데, 단순히 튜토리얼을 따라 하기만 했다면 "어디에서 문제가 생긴 건지" 조차 파악하기 어렵다.

이런 혼란을 정리하기 위해 각 서비스의 역할과 연결 방식을 하나씩 따져가며 노트에 정리하기 시작했다. 서비스 간의 관계를 직접 글로 써보고, 흐름을 따라가며 시뮬레이션해 보면서 점점 이해도가 높아졌다. 그리고 최종적으로 이를 도식화하여 시각적으로 표현하자, 복잡하게 느껴졌던 배포 프로세스가 한눈에 들어오기 시작했다.

결국, 각 서비스의 역할을 제대로 이해하면, 어떤 클라우드 환경에서도 유연하게 적응할 수 있다. AWS이든, GCP이든, Azure이든 클라우드 서비스가 달라지더라도 핵심적인 원리는 변하지 않는다.

CI/CD 프로세스

ⓐ 코드 변경 및 GitHub Push 이벤트
개발자가 코드 변경 사항을 작업하고 이를 GitHub 저장소에 푸시하면 CodePipeline 이 이를 감지하고 파이프라인을 시작합니다.

ⓑ CodePipeline 트리거
CodePipeline 은 정의된 단계에 따라 GitHub 에서 소스 코드를 가져옵니다.

ⓒ CodeBuild 를 통한 빌드
CodeBuild 는 buildspec.yml 파일에 정의된 스크립트를 실행해 소스 코드를 실행 가능한 형태로 빌드합니다. 이 과정은 세 가지 주요 단계로 나타납니다.

  • pre-build : 빌드 전에 필요한 작업을 수행합니다. npm install 을 실행해 필요한 의존성을 설치해 일관된 환경을 보장합니다.
  • build : 소스 코드를 처리하여 실행 가능한 결과물로 생성하는 주요 빌드 단계입니다. Next.js 앱을 빌드하고 standalone 모드로 결과물을 생성한 후 이를 압축(zip) 하여 배포할 준비를 합니다.
  • post-build : 빌드 후 처리 작업을 수행하는 단계로 빌드된 결과물을 Lambda 와 S3 에 업로드할 준비를 합니다. 또한 캐시를 정리하는 등의 후속 작업도 이 단계에서 처리합니다.

ⓓ 빌드 된 파일을 S3에 업로드
CodeBuild 에서 생성된 빌드 결과물을 S3 버킷에 업로드합니다. 이 단계에서 기존 데이터를 새 빌드로 대체합니다.

ⓔ Lambda 에 업로드
CodeBuild 에서 생성된 Lambda 관련 파일(SSR 함수, API 라우트) 을 Lambda 에 업로드합니다.


유저 진입 프로세스

① 사용자가 웹사이트에 접속
사용자가 브라우저에서 도메인 주소를 입력해 웹사이트에 접속합니다.

② Route53 DNS 서비스
DNS 은 사용자가 입력한 도메인 주소를 컴퓨터가 이해할 수 있는 IP 주소로 변환해주는 시스템입니다. Route53은 DNS 서비스로 사용자가 입력한 도메인 주소를 연결된 API Gateway 의 IP주소로 매핑하여 요청을 전달합니다.

③ API Gateway 요청 전달(동적 요청)
API Gateway는 HTTP 요청을 처리하고 동적 요청이 들어오면 이를 Lambda 로 전달합니다. 예를들어 /api* 경로로 요청이 들어오면 API Gateway 는 이를 Lambda 함수에 전달하여 요청을 처리하게 합니다.

④ Lambda 함수 실행 (동적 요청 처리)
Lambda는 서버리스 컴퓨팅 엔진으로, API Gateway에서 전달받은 요청을 처리합니다. 예를 들어 데이터베이스 조회나 서버사이드 렌더링(SSR)을 통해 동적으로 콘텐츠를 생성하거나 데이터를 반환할 수 있습니다. Lambda에서 처리된 결과는 API Gateway를 통해 다시 클라이언트로 전달됩니다.

⑤ CloudFront CDN 서비스
CloudFront 는 CDN 역할을 하며 사용자와 가장 가까운 서버에서 콘텐츠를 제공하여 로딩 속도를 최적화 해주는 서비스입니다. 사용자가 요청한 데이터가 가까운 엣지 서버에 존재한다면 즉시 사용자에게 데이터를 전달합니다.

⑥ S3 에 원본 데이터 요청 (정적 요청)
S3 는 웹사이트의 정적 콘텐츠(HTML, JS, CSS, 이미지 등) 을 저장하는 저장소 역할을 합니다. 사용자가 요청한 콘텐츠가 CloudFront 의 엣지 서버에 없으면 S3 버킷에 원본 데이터를 요청합니다.

Next Step..

향후 테스트 코드를 추가할 예정이다. 원래 CI/CD에서 CI는 여러 개발자가 협업한 코드를 하나의 브랜치로 통합하기 전에 테스트 단계를 거쳐 문제가 없는지 확인하는 과정이다. 하지만 이번 프로젝트는 테스트 코드를 아직 도입하지 않았고, 또 혼자 개발을 진행했기 때문에 CI를 주로 자동 빌드 작업을 수행하는 용도로 활용했다.

앞으로는 테스트 코드를 추가하여, 코드 변경 시 테스트를 자동으로 실행하고 이를 통과했을 때만 배포가 진행되도록 개선할 계획이다. 이를 통해 더욱 안정적인 배포 환경을 구축할 수 있을 것으로 기대한다!!



Reference

https://medium.com/@danielangelesangelestoribio/aws-lambda-web-adapter-with-nextjs-ssr-daeb7fa9a4e0
https://blog.barnabycollins.com/web-dev/2023/12/22/how-to-host-nextjs-14.html

profile
배우고 느낀 걸 기록하는 공간

0개의 댓글

관련 채용 정보