HeadVer 버저닝 기반 Vercel 자동 배포 구현기

 

개요

솔리드 커넥션의 웹 파트를 개발하며 HeadVer 버저닝을 기반으로 Github Actions와 Vercel을 사용해 CI/CD 자동화를 이루어낸 기록을 정리합니다.

솔리드 커넥션 웹, 그리고 배포 방식

솔리드 커넥션은 교환학생을 돕기 위한 서비스입니다. 웹 애플리케이션은 Next.js로 개발되었으며, 배포는 Vercel을 통해 이루어지고 있습니다.
 
Vercel은 정말 편리한 기능들을 많이 제공하고 있습니다. 그 중 하나는 Github과 잘 연계하여 빌드/배포를 매우 편리하게 해주는 것인데요, Github에 올라온 커밋들을 모두 자동으로 빌드해주고, 지정된 배포 브랜치에 올라온 내용을 자동으로 배포해줍니다.
 
서비스 CD를 위해서 배포용 release 브랜치를 만들고, 해당 브랜치에 PR을 올리는 것으로 배포를 하고 있었는데요, 문제는 배포에 버저닝을 적용하면서 부터 생겨납니다.
버저닝을 적용시킨 후, 배포 과정은 다음과 같은 절차를 거치게 되었는데요,
 
  1. main 브랜치의 최신 커밋에 버전 태그 추가(v1.2.3)
  1. 해당 태그에 대한 깃헙 릴리즈 생성
  1. release 브랜치로의 PR 생성, 병합
 
이는 그렇게 복잡한 절차는 아니였지만, 하루에 5번 이상의 배포를 진행하는 일이 생기자 굉장한 피로를 느끼게 되었습니다. 그래서 이를 개선하기로 했습니다.
매번 수동으로 버전을 지정하고 - 해당 버전을 복사하고 - 깃헙 릴리즈에 붙여넣고 - PR에도 동일하게 붙여넣는 작업은 단순 반복 작업이었기에 쉽게 자동화할 수 있을 것이라 생각했습니다.
그리하여 이를 개선하기 위해 원킬륵으로 배포 가능한 워크플로우를 만들어내기로 합니다.

TO-BE

제가 생각하는 이상은 다음과 같은 요건을 만족하는 시스템입니다.
  • 버저닝을 직접 지정해줄 필요가 없어야한다. 또는 최소한으로 인간의 손을 타야한다.
    • 버저닝을 직접 지어준다면 실수가 있을 수 있고, 버전을 고민하는데도 스트레스가 온다.
  • main 브랜치에 업데이트 된 내용은 바로 stage 환경에 배포되어야 한다.
    • main 브랜치에 들어가는 수정사항은 이미 PR로 검증받은 사항이기에 바로 qa 검증을 받기위한 stage환경으로 들어가도 무방하다고 보았습니다.
  • main 브랜치에서 자동 빌드되고 qa 테스트를 받은 버전중 하나를 짚어 원클릭으로 배포할 수 있어야 한다.

HeadVer

제가 도입하기로 결정한 것은 LINE에서 개발한 HeadVer 버저닝 시스템입니다.
일반적으로 많은 분들이 major.minor.patch 구조를 가진 SemVer(Semantic Versioning)에 익숙하실 텐데요. 하지만 웹/앱과 같은 사용자 대상 프로덕트에서는 SemVer보다 HeadVer가 더 적합하다고 판단했습니다.
 
왜 SemVer을 사용하지 않았을까요?
SemVer를 사용하면 Major 버전을 언제 올려야 할지가 모호해집니다. 보통 Major 버전은 하위 호환성을 깨는 변경이 있을 때 올리는 것이 일반적이지만, 라이브러리가 아닌 사용자 서비스에서는 ‘하위 호환성’의 개념이 명확하지 않습니다.
그렇다고 큰 업데이트를 기준으로 Major 버전을 올리려 해도, ‘큰 업데이트’의 기준 자체가 애매합니다.
예를 들어, 제가 좋아하는 게임인 Minecraft: Java Edition을 보면,
  • 2011년 1.0.0 버전 출시
  • 2025년 현재 1.21.4 버전이 최신
으로 14년 동안 Major 버전이 바뀌지 않았습니다. 그렇다면, 이 Major 버전은 도대체 무슨 의미가 있을까요? 🤔

HeadVer의 장점

HeadVer를 도입한 가장 큰 이유는 버전을 올리는 규칙이 명확하다는 것입니다. 버전 번호를 지정할 때 불필요한 고민이 줄어들고, 자동화된 빌드 시스템과 자연스럽게 연동되는 구조를 가지고 있습니다.
 
HeadVer에 대한 자세한 내용은 아래 링크에서 확인하실 수 있습니다.
 
이러한 이유로, 저는 SemVer가 아닌 HeadVer를 적용하는 것이 더 적절하다고 판단했고, 이를 도입하기로 결정했습니다. 🚀

HeadVer을 적용했을 때

솔리드 커넥션 웹에서는 각 head 넘버(버전)별로 기능을 계획해서 구현하고 각 head 넘버의 마지막 버전이 배포가 된 버전이 되게 했습니다.
즉 다음과 같은 깃 히스토리를 가지게 됩니다.
<추후 이미지 추가>
위 이미지에서 <추후 추가>, <추후 추가> 등이 배포가 이루어진 버전입니다.
그렇기에 해당 버전 다음 커밋부터 head 버전이 올라가 있는 것을 확인할 수 있습니다. 이번 head 버전에서는 모두 기능 개발이 끝나고 배포가 되었으니 뭔가 수정할 것이 있다면 다음 버전에서 진행해야 하는 것입니다.
또한 배포가된 <추후 추가>, <추후 추가> 태그가 있는 커밋은 2개의 버전이 지정되어 있는 것을 확인할 수 있습니다. 이는 stage 환경으로 빌드된 버전 하나, production 환경으로 빌드된 최종 버전 하나가 존재하기 떄문입니다.
이를 같은 버전으로 통합하지 않는 이유는 빌드 넘버는 빌드시마다 하나씩 올라가야 한다는 규칙 때문입니다. 그렇기에 한 커밋에 2개의 버전 태그가 달려 있는 것이 이상한 것은 아닙니다.

Vercel

Vercel은 브랜치에 커밋이 추가 되면 Github에 올라온 코드를 받아와 자동으로 설정해둔 환경변수와 통합하여 빌드를 진행합니다.
이런 자동화 프로세스를 사용하지 않고 직접 수동으로 빌드를 한다면 다음과 같은 과정을 거쳐야합니다.
  1. Vercel에 설정해둔 환경변수를 로컬로 받아옵니다
  1. 로컬에서 빌드를 진행합니다
  1. 빌드한 파일을 Vercel에 업로드해 배포합니다
 
name: Production Tag Deployment env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} on: push: # Pattern matched against refs/tags tags: - '*' # Push events to every tag not containing / jobs: Deploy-Production: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install Vercel CLI run: npm install --global vercel@latest - name: Pull Vercel Environment Information run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} - name: Build Project Artifacts run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
위의 예시를 참고해서, HeadVer을 자동으로 설정하고 자동으로 빌드해서 Vercel qa(stage) 환경에 배포, 원클릭으로 릴리즈를 생성해 production 환경에 배포할 수 있는 워크플로우를 제작했습니다.

워크플로우

1. headver 생성

name: Generate HeadVer Tag permissions: contents: write on: workflow_call: outputs: version: description: "Generated HeadVer version" value: ${{ jobs.generate_tag.outputs.version }} jobs: generate_tag: runs-on: ubuntu-latest outputs: version: ${{ steps.compute_version.outputs.version }} steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Compute HeadVer Tag id: compute_version run: | # headver.json에서 head 버전 가져오기 if [ ! -f headver.json ]; then echo "headver.json 파일이 없습니다. 기본 head=0을 사용합니다." HEAD=0 else HEAD=$(jq -r '.head' headver.json) fi echo "Head number: $HEAD" # 현재 연도와 주차(yyww) 가져오기 YYWW=$(date +"%y%V") echo "YearWeek: $YYWW" # 최신 태그 검색 LAST_BUILD=$(git tag --list "v*.*.*" | awk -F. '{print $3}' | sort -n | tail -n 1) if [ -z "$LAST_BUILD" ]; then BUILD=1 else BUILD=$((LAST_BUILD + 1)) fi echo "Build number: $BUILD" # 최종 태그 생성 VERSION="${HEAD}.${YYWW}.${BUILD}" echo "Computed version: $VERSION" # GitHub Actions 환경 변수로 설정 echo "version=$VERSION" >> $GITHUB_ENV echo "::set-output name=version::$VERSION" - name: Create Tag run: | TAG="v${{ steps.compute_version.outputs.version }}" echo "Creating tag: $TAG" git tag $TAG git push origin $TAG
.github/workflow/headver-tagging.yml
최신의 HeadVer을 자동으로 생성하는 워크플로우 입니다.
1. 우선 head 버전값을 가져오는데요, 이는 headver.json이라는 파일을 레포지토리에서 관리하는 방식으로 head 버전을 지정할 수 있게 했습니다. 이를 자동화할 수 있을거라는 생각을 했습니다. 그러나 HeadVer의 철학은 Head말고는 자동화 되는 버저닝 시스템, 다시 말해서 head 버전은 수동으로 지정해줘야 하는 방식이라고 생각했습니다.
그래서 이후에 불편한 점이 있다면 head 버전 지정 방식을 변경하되, 당장은 headver.json 파일에서 수동으로 지정해줄 수 있게 했습니다.
2. 그 다음은 워크플로우가 작동한 시간에서 YYWW 형식의 날짜를 추출합니다.
3. 그리고 레포지토리를 탐색해서 v1.2345.6 형식으로 되어 있는 버저닝 태그를 탐색하고, 이 중 최신의 태그(마지막 build 번호가 가장 높은 것)을 찾아 빌드 번호를 추출합니다. 최신의 버전은 이렇게 추출한 빌드 번호 + 1이 됩니다.
이렇게 추출한 버전을 기반으로 신규 버전을 만들어 태그를 생성합니다.
💡
이렇게 만들어진 워크플로우는 동시에 작동할 경우 빌드 버전이 중복되는 문제가 발생할 수 있습니다.
이를 막기 위해서는 Github Actions 동시 실행 제어를 사용할 수 있습니다.

2. 자동으로 stage 환경 빌드 후 qa 환경에 배포

프론트엔드 개발 시, 예상치 못한 버그들이 발생할 수 있습니다. 이는 특히 보안과 관련한 문제로 localhost 환경에서는 확인하기 어려운 OAuth 관련 사항을 다룰 때에 자주 일어납니다.
버그가 아니더라도 제작된 웹이 기획자/디자이너의 생각이나 디자인대로 제작되지 않을 수 있습니다. 이때 해당 사항들이 프로덕션 환경에 올라가기 전에 이에 대한 피드백을 제공할 수 있게 솔리드 커넥션 팀에서는 qa를 위한 stage 환경을 만들어 qa에 사용하고 있습니다.
name: Build and Vercel Preview Deployment on Main permissions: contents: write env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_ENV: preview on: push: branches: - main workflow_dispatch: jobs: generate_tag: uses: ./.github/workflows/headver-tagging.yml with: {} Deploy-Preview: runs-on: ubuntu-latest needs: generate_tag env: VERSION_TAG: ${{ needs.generate_tag.outputs.version }} steps: - uses: actions/checkout@v3 - name: Install Vercel CLI run: npm install --global vercel@latest - name: Pull Vercel Environment Information run: vercel pull --yes --environment=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Build Project Artifacts run: vercel build --yes --target=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --target=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Output Tag Version run: echo "Deployment completed for version $VERSION_TAG"
.github/workflow/build.yml
stage 환경은 main에 바로 연결되어, main으로 올라온 커밋은 모두 자동으로 stage 환경에 배포되게 만들어 두었습니다.
이는 개발 구조상 main에 들어오는 내용들은 모두 PR에서 검증/리뷰 되었기 때문에 굳이 stage 환경에 배포하기 위해 명시적으로 명령을 해주는 것 보다, 자동으로 main의 내용들을 stage 환경에 배포하는 것이 좋다고 생각했기 때문입니다.
즉 최대한 자주 자동으로 배포를 실시하여 빌드/배포되지 않은 코드라는 잠재적 부채를 줄이고 기능이 제대로 작동하는지 확인하고자 했습니다.

3. 수동으로 production 환경 빌드후 실제 환경에 배포

name: Build and Vercel Production Deployment permissions: contents: write env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_ENV: production on: workflow_dispatch: jobs: generate_tag: name: Generate HeadVer Tag uses: ./.github/workflows/headver-tagging.yml with: {} create_release: name: Create GitHub Release runs-on: ubuntu-latest needs: generate_tag steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Create Release id: create_release uses: ncipollo/release-action@v1 with: tag: "v${{ needs.generate_tag.outputs.version }}" release_name: "Release v${{ needs.generate_tag.outputs.version }}" body: "Automated release created for build v${{ needs.generate_tag.outputs.version }}" token: ${{ secrets.GITHUB_TOKEN }} deploy_production: runs-on: ubuntu-latest needs: create_release env: VERSION_TAG: ${{ needs.generate_tag.outputs.version }} steps: - uses: actions/checkout@v3 - name: Install Vercel CLI run: npm install --global vercel@latest - name: Pull Vercel Environment Information run: vercel pull --yes --environment=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Build Project Artifacts run: vercel build --yes --target=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel run: vercel deploy --prebuilt --target=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} - name: Output Tag Version run: echo "Deployment completed for version $VERSION_TAG"
.github/workflow/release.yml
마지막으로 실제로 배포를 진행하는 워크플로우입니다.
프로덕션 환경변수를 가져와 빌드하고 실제로 배포하게 했습니다. workflow_dispatch를 사용해 버튼 하나로 배포를 진행할 수 있게 했는데요, 이 구조의 장점은 배포할 버전/브랜치를 직접 지정할 수도 있다는 것입니다.
notion image

문제

문제: Sensitive environment 불러오기 실패 문제

워크플로우를 제작중 문제가 발생했습니다.
vercel pull 을 통해 Vercel에서 환경변수를 불러올 때 sensitive environment로 설정한 환경변수를 공백값으로 불러와지는 문제가 있었습니다.
이는 sensitive environment는 접근할 수 없고, 빌드 시에만 자동으로 적용되는 값이기 때문입니다.
그렇기에 1. 환경변수를 불러오고, 2. 빌드를 진행하는 것이 아닌, 빌드를 진행하며 환경변수를 자동으로 불러 올 수 있는 vercel build --prod 명령을 통해 시도해보았으나, sensitive environment 값들은 적용이 안되는 것을 확인했습니다.
이런 문제가 발생한 것은 sensitive environment가 비교적 최근에 생긴 기능이기 때문이라고 생각이 드는데요, 해당 문제를 해결하기 위해 그냥 sensitive environment를 사용하지 않는 것으로 방향을 잡았습니다.
이유는 sensitive environment로 설정하지 않더라도 빌드시에 외부에 노출 되는 것이 아니고, sensitive environment는 내부 팀원들에게 환경변수를 보이지 않고 싶을 때 사용하는 설정이기 때문입니다. 환경변수는 어짜피 모두가 접근할 수 있었기에 굳이 sensitive environment를 적용할 필요가 없어 이를 제거했습니다.

Reference