모바일 앱 릴리즈 자동화: 시맨틱 버저닝, 변경 로그, CI/CD 통합

모바일

릴리즈 자동화시맨틱 버저닝Fastlane변경 로그CI/CD

이 글은 누구를 위한 것인가

  • 릴리즈마다 버전 올리고 변경 로그 쓰는 게 번거로운 팀
  • App Store/Play Store 배포를 자동화하고 싶은 모바일 개발자
  • Conventional Commits와 시맨틱 버저닝을 모바일에 적용하려는 팀 리더

들어가며

릴리즈 준비가 무섭다. 버전 번호를 올리고, CHANGELOG를 쓰고, 릴리즈 노트를 작성하고, 빌드하고, 서명하고, 스토어에 올리는 과정이 반복된다. 이 중 상당 부분은 자동화할 수 있다.

Conventional Commits + semantic-release + Fastlane 조합으로 커밋 메시지에서 버전이 자동으로 올라가고, 변경 로그가 자동 생성되며, 스토어 배포까지 자동으로 처리할 수 있다.

이 글은 bluefoxdev.kr의 모바일 CI/CD 자동화 가이드 를 참고하고, 릴리즈 자동화 관점에서 확장하여 작성했습니다.


1. 시맨틱 버저닝 전략

1.1 버전 구조

모바일 앱 버전 구조:
MAJOR.MINOR.PATCH (빌드 번호)

1.0.0 (1)      → 앱 출시
1.0.1 (2)      → 버그 수정
1.1.0 (3)      → 새 기능
2.0.0 (4)      → 주요 변경 (UI 개편 등)

AppStore/PlayStore:
- iOS: CFBundleShortVersionString (1.2.3) + CFBundleVersion (45)
- Android: versionName (1.2.3) + versionCode (45)

versionCode/CFBundleVersion은 단조증가 (절대 감소 불가)

1.2 Conventional Commits 규칙

커밋 메시지 형식:
<type>(<scope>): <description>

type:
  feat     → 새 기능 (MINOR 버전 올림)
  fix      → 버그 수정 (PATCH 버전 올림)
  docs     → 문서만 변경 (버전 변경 없음)
  refactor → 리팩토링 (버전 변경 없음)
  perf     → 성능 개선 (PATCH)
  test     → 테스트 추가/수정 (버전 변경 없음)
  chore    → 빌드/설정 변경 (버전 변경 없음)

BREAKING CHANGE: 풋노트에 추가 → MAJOR 버전 올림

예시:
feat(auth): 소셜 로그인 카카오 추가
fix(payment): 결제 완료 후 영수증 미발송 수정
feat(ui)!: 홈 화면 전면 개편

BREAKING CHANGE: 홈 화면 네비게이션 구조 변경

2. 자동 버전 관리 설정

2.1 iOS - xcconfig 기반

# Versioning.xcconfig
MARKETING_VERSION = 1.2.3
CURRENT_PROJECT_VERSION = 45

# 자동화 스크립트에서 이 파일만 수정
# Fastlane에서 버전 읽기/쓰기
lane :bump_version do |options|
  version = options[:version]
  build = options[:build]
  
  # Info.plist 직접 수정 대신 xcconfig 수정
  update_xcconfig_value(
    path: 'Versioning.xcconfig',
    name: 'MARKETING_VERSION',
    value: version
  )
  update_xcconfig_value(
    path: 'Versioning.xcconfig',
    name: 'CURRENT_PROJECT_VERSION',
    value: build.to_s
  )
end

2.2 Android - build.gradle.kts

// build.gradle.kts
import java.io.FileInputStream
import java.util.Properties

// version.properties 파일에서 읽기
val versionProps = Properties().apply {
    load(FileInputStream(rootProject.file("version.properties")))
}

android {
    defaultConfig {
        versionCode = versionProps["VERSION_CODE"].toString().toInt()
        versionName = versionProps["VERSION_NAME"].toString()
    }
}
# version.properties
VERSION_CODE=45
VERSION_NAME=1.2.3

3. Conventional Commits 기반 변경 로그 자동화

3.1 commitlint 설정

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'scope-enum': [2, 'always', [
      'auth', 'payment', 'home', 'profile', 'notification', 'core'
    ]],
    'subject-max-length': [2, 'always', 100],
  },
};
# .github/workflows/lint-commit.yml
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
      - run: npm install @commitlint/cli @commitlint/config-conventional
      - run: npx commitlint --from HEAD~1 --to HEAD

3.2 변경 로그 자동 생성

# .github/workflows/release.yml
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup Node
        uses: actions/setup-node@v4

      - name: Install semantic-release
        run: |
          npm install -D semantic-release \
            @semantic-release/git \
            @semantic-release/changelog \
            @semantic-release/exec

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npx semantic-release
// .releaserc.json
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    [
      "@semantic-release/changelog",
      {
        "changelogFile": "CHANGELOG.md"
      }
    ],
    [
      "@semantic-release/exec",
      {
        "prepareCmd": "node scripts/update-version.js ${nextRelease.version}"
      }
    ],
    [
      "@semantic-release/git",
      {
        "assets": ["CHANGELOG.md", "version.properties", "Versioning.xcconfig"],
        "message": "chore(release): ${nextRelease.version} [skip ci]"
      }
    ]
  ]
}
// scripts/update-version.js
const version = process.argv[2];
const [major, minor, patch] = version.split('.');
const fs = require('fs');

// Android version.properties 업데이트
const versionProps = fs.readFileSync('version.properties', 'utf8');
const buildCode = parseInt(versionProps.match(/VERSION_CODE=(\d+)/)[1]) + 1;
fs.writeFileSync('version.properties', 
  `VERSION_CODE=${buildCode}\nVERSION_NAME=${version}`
);

// iOS Versioning.xcconfig 업데이트  
fs.writeFileSync('Versioning.xcconfig',
  `MARKETING_VERSION = ${version}\nCURRENT_PROJECT_VERSION = ${buildCode}`
);

console.log(`Version updated to ${version} (build ${buildCode})`);

4. Fastlane으로 스토어 배포 자동화

4.1 iOS Fastfile

# ios/fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "TestFlight 배포"
  lane :beta do
    # 인증서/프로비저닝 프로파일
    sync_code_signing(type: "appstore", readonly: true)
    
    # 버전 정보
    version = get_xcconfig_value(
      path: 'Versioning.xcconfig',
      name: 'MARKETING_VERSION'
    )
    build_number = get_xcconfig_value(
      path: 'Versioning.xcconfig', 
      name: 'CURRENT_PROJECT_VERSION'
    )
    
    # 빌드
    build_app(
      scheme: "MyApp",
      configuration: "Release",
      export_method: "app-store"
    )
    
    # TestFlight 업로드
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: read_changelog  # CHANGELOG.md에서 최신 내용 읽기
    )
    
    # Slack 알림
    slack(
      message: "iOS #{version} (#{build_number}) TestFlight 배포 완료",
      success: true
    )
  end
  
  desc "App Store 출시"
  lane :release do
    sync_code_signing(type: "appstore", readonly: true)
    build_app(scheme: "MyApp", configuration: "Release")
    
    upload_to_app_store(
      submit_for_review: true,
      automatic_release: false,  # 수동 승인 후 출시
      release_notes: { 
        'ko' => read_changelog,
        'en-US' => read_changelog_en
      }
    )
  end
end

4.2 Android Fastfile

# android/fastlane/Fastfile
platform :android do
  desc "Play Store 내부 테스트 배포"
  lane :internal do
    gradle(
      task: "bundle",
      build_type: "Release",
      properties: {
        "android.injected.signing.store.file" => ENV["KEYSTORE_FILE"],
        "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
        "android.injected.signing.key.password" => ENV["KEY_PASSWORD"],
      }
    )
    
    upload_to_play_store(
      track: "internal",
      release_status: "completed",
      changelogs: {
        "ko-KR" => read_changelog,
        "en-US" => read_changelog_en
      }
    )
  end
end

5. 릴리즈 노트 자동화

// scripts/generate-release-notes.js
// CHANGELOG.md에서 최신 버전 릴리즈 노트 추출

const fs = require('fs');

function extractLatestChangelog(lang = 'ko') {
  const content = fs.readFileSync('CHANGELOG.md', 'utf8');
  
  // 최신 버전 섹션 추출
  const match = content.match(/## \[[\d.]+\].*?\n([\s\S]*?)(?=## \[|$)/);
  if (!match) return '버그 수정 및 성능 개선';
  
  const changes = match[1]
    .split('\n')
    .filter(line => line.startsWith('* ') || line.startsWith('- '))
    .map(line => line.replace(/^[*-] /, '• '))
    .join('\n');
  
  // 앱스토어 최대 길이 (4000자)
  return changes.slice(0, 4000);
}

마무리: 릴리즈 자동화 성숙도 모델

Level 1 (수동):
  - 버전을 수동으로 올림
  - 변경 로그 수동 작성
  - 스토어 수동 업로드

Level 2 (반자동):
  - Conventional Commits 도입
  - 변경 로그 자동 생성
  - 스토어 업로드 Fastlane 자동화

Level 3 (완전 자동):
  - main 머지 시 자동 버전 결정
  - 변경 로그 + 릴리즈 노트 자동 생성
  - 스토어 배포까지 CI/CD 연동
  - Slack 릴리즈 알림

처음부터 Level 3을 목표로 하면 부담이 크다. Conventional Commits부터 팀에 도입하고, 나머지는 점진적으로 자동화하는 것이 현실적이다.