이 글은 누구를 위한 것인가
- 릴리즈마다 버전 올리고 변경 로그 쓰는 게 번거로운 팀
- 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부터 팀에 도입하고, 나머지는 점진적으로 자동화하는 것이 현실적이다.