[이제와서 시작하는 GitHub 마스터하기 - 심화편 #3] 고급 브랜치 전략과 릴리스 관리: 대규모 팀을 위한 워크플로우
들어가며
GitHub 마스터하기 심화편 세 번째 시간입니다. 이번에는 대규모 팀과 복잡한 프로젝트를 위한 고급 브랜치 전략과 릴리스 관리 방법을 깊이 있게 다룹니다. Git Flow, GitHub Flow, GitLab Flow부터 최신 Trunk-Based Development까지, 각 전략의 장단점과 실제 적용 방법을 알아보겠습니다.
1. 브랜치 전략 비교 분석
준비와 읽는 순서: 브랜치 생성·병합, PR 리뷰, 태그의 역할을 알고 있다는 전제로 진행합니다. 비교표를 읽기 전에 팀의 배포 주기, 동시에 유지할 버전 수, main에 병합해도 되는 조건을 적어 보세요. 전략 이름보다 이 세 조건이 선택 기준이 됩니다.
모든 자동화 코드를 한 번에 도입할 필요는 없습니다. 1절에서 후보를 고르고, 2·3·4절 중 그 전략의 흐름을 읽은 뒤 5절의 릴리스와 6절의 전환 계획을 살펴보세요. 아래 스크립트에는 브랜치 병합·태그 생성·설정 변경이 포함되므로, 실행을 검토할 때는 별도의 연습 저장소를 기준으로 합니다.
주요 브랜치 전략 개요
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Git Flow:
브랜치:
- main/master: 프로덕션
- develop: 개발 통합
- feature/*: 기능 개발
- release/*: 릴리스 준비
- hotfix/*: 긴급 수정
장점:
- 명확한 릴리스 주기
- 병렬 개발 지원
- 버전 관리 용이
단점:
- 복잡한 워크플로우
- 많은 브랜치 관리
- CI/CD와 충돌 가능
GitHub Flow:
브랜치:
- main: 항상 배포 가능
- feature branches: 모든 작업
장점:
- 단순함
- 지속적 배포 적합
- 빠른 피드백
단점:
- 릴리스 관리 어려움
- 환경별 배포 복잡
GitLab Flow:
브랜치:
- main: 개발
- pre-production: 스테이징
- production: 프로덕션
- feature/*: 기능 개발
장점:
- 환경별 브랜치
- 유연한 배포
- 이슈 트래킹 통합
단점:
- 환경 관리 복잡
- 추가 인프라 필요
Trunk-Based Development:
브랜치:
- main/trunk: 메인 개발
- short-lived branches: 1-2일
장점:
- 지속적 통합
- 빠른 피드백
- 충돌 최소화
단점:
- 높은 팀 숙련도 필요
- Feature flags 필수
- 엄격한 테스트 필요
전략 선택 의사결정 트리
이 선택기는 프로젝트 조건에 점수를 주어 후보 전략을 비교합니다. 점수가 높다는 것은 코드에 넣은 가정에 잘 맞는다는 뜻이며, 팀의 배포 장애나 리뷰 대기 시간을 실제로 측정한 결과는 아닙니다. 예제 입력에서 배포 빈도나 기능 플래그 사용 여부를 바꿨을 때 추천이 어떻게 달라지는지 먼저 읽어 보세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# branching_strategy_selector.py
class BranchingStrategySelector:
def __init__(self):
self.criteria = {}
def analyze_project(self, project_info):
"""프로젝트 특성 분석"""
self.criteria = {
'team_size': project_info.get('team_size', 0),
'release_frequency': project_info.get('release_frequency', 'monthly'),
'environments': project_info.get('environments', ['production']),
'team_experience': project_info.get('team_experience', 'medium'),
'project_type': project_info.get('project_type', 'web'),
'compliance_required': project_info.get('compliance_required', False),
'feature_toggles': project_info.get('feature_toggles', False)
}
def recommend_strategy(self):
"""최적 전략 추천"""
score = {
'git_flow': 0,
'github_flow': 0,
'gitlab_flow': 0,
'trunk_based': 0
}
# 팀 규모
if self.criteria['team_size'] > 20:
score['git_flow'] += 3
score['gitlab_flow'] += 2
elif self.criteria['team_size'] < 5:
score['github_flow'] += 3
score['trunk_based'] += 2
# 릴리스 빈도
if self.criteria['release_frequency'] == 'daily':
score['trunk_based'] += 3
score['github_flow'] += 2
elif self.criteria['release_frequency'] == 'monthly':
score['git_flow'] += 3
score['gitlab_flow'] += 2
# 환경 복잡도
if len(self.criteria['environments']) > 3:
score['gitlab_flow'] += 3
score['git_flow'] += 1
# 규정 준수
if self.criteria['compliance_required']:
score['git_flow'] += 2
score['gitlab_flow'] += 2
# Feature toggles
if self.criteria['feature_toggles']:
score['trunk_based'] += 3
# 최고 점수 전략 반환
return max(score, key=score.get), score
# 사용 예제
selector = BranchingStrategySelector()
selector.analyze_project({
'team_size': 15,
'release_frequency': 'weekly',
'environments': ['dev', 'staging', 'production'],
'team_experience': 'high',
'project_type': 'saas',
'compliance_required': True,
'feature_toggles': True
})
strategy, scores = selector.recommend_strategy()
print(f"Recommended strategy: {strategy}")
print(f"Scores: {scores}")
2. Git Flow 고급 구현
자동화된 Git Flow 워크플로우
Git Flow의 핵심은 기능 개발의 통합 지점인 develop과 출시 상태를 나타내는 main을 구분하는 것입니다. feature 완료는 개발 브랜치 통합이고, release 완료는 출시 브랜치 병합과 태그 생성입니다. 긴 스크립트에서는 finish_feature, finish_release처럼 각 완료 경로를 비교하며 이 차이를 따라가세요.
확인할 상태: 연습 저장소에서 시작 브랜치, 병합 대상, 생성할 태그를 먼저 적고 명령 흐름과 대조합니다. 테스트 실행에 성공했다는 사실과 원하는 브랜치에 병합됐다는 사실은 각각 확인해야 합니다.
Git Flow의 feature·release·hotfix 명령을 묶은 전체 스크립트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
#!/bin/bash
# git-flow-advanced.sh
# 설정
DEVELOP_BRANCH="develop"
MAIN_BRANCH="main"
VERSION_FILE="version.txt"
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# 현재 버전 읽기
get_current_version() {
if [ -f "$VERSION_FILE" ]; then
cat "$VERSION_FILE"
else
echo "0.0.0"
fi
}
# 버전 증가
increment_version() {
local version=$1
local type=$2
IFS='.' read -ra PARTS <<< "$version"
local major="${PARTS[0]}"
local minor="${PARTS[1]}"
local patch="${PARTS[2]}"
case $type in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
esac
echo "$major.$minor.$patch"
}
# Feature 브랜치 시작
start_feature() {
local feature_name=$1
if [ -z "$feature_name" ]; then
echo -e "${RED}Error: Feature name required${NC}"
exit 1
fi
echo -e "${GREEN}Starting feature: $feature_name${NC}"
# develop에서 브랜치 생성
git checkout "$DEVELOP_BRANCH"
git pull origin "$DEVELOP_BRANCH"
git checkout -b "feature/$feature_name"
echo -e "${GREEN}Feature branch created: feature/$feature_name${NC}"
}
# Feature 완료
finish_feature() {
local current_branch=$(git rev-parse --abbrev-ref HEAD)
if [[ ! "$current_branch" =~ ^feature/ ]]; then
echo -e "${RED}Error: Not on a feature branch${NC}"
exit 1
fi
local feature_name=${current_branch#feature/}
echo -e "${GREEN}Finishing feature: $feature_name${NC}"
# 테스트 실행
if ! run_tests; then
echo -e "${RED}Tests failed. Aborting.${NC}"
exit 1
fi
# develop에 병합
git checkout "$DEVELOP_BRANCH"
git pull origin "$DEVELOP_BRANCH"
if git merge --no-ff "feature/$feature_name" -m "Merge feature '$feature_name' into develop"; then
echo -e "${GREEN}Feature merged successfully${NC}"
# 브랜치 삭제
git branch -d "feature/$feature_name"
git push origin --delete "feature/$feature_name"
# develop 푸시
git push origin "$DEVELOP_BRANCH"
else
echo -e "${RED}Merge failed. Please resolve conflicts.${NC}"
exit 1
fi
}
# Release 시작
start_release() {
local version_type=${1:-patch}
local current_version=$(get_current_version)
local new_version=$(increment_version "$current_version" "$version_type")
echo -e "${GREEN}Starting release: $new_version${NC}"
# develop에서 브랜치 생성
git checkout "$DEVELOP_BRANCH"
git pull origin "$DEVELOP_BRANCH"
git checkout -b "release/$new_version"
# 버전 파일 업데이트
echo "$new_version" > "$VERSION_FILE"
git add "$VERSION_FILE"
git commit -m "Bump version to $new_version"
echo -e "${GREEN}Release branch created: release/$new_version${NC}"
echo -e "${YELLOW}Please perform final testing and fixes on this branch${NC}"
}
# Release 완료
finish_release() {
local current_branch=$(git rev-parse --abbrev-ref HEAD)
if [[ ! "$current_branch" =~ ^release/ ]]; then
echo -e "${RED}Error: Not on a release branch${NC}"
exit 1
fi
local version=${current_branch#release/}
echo -e "${GREEN}Finishing release: $version${NC}"
# 최종 테스트
if ! run_tests; then
echo -e "${RED}Tests failed. Aborting.${NC}"
exit 1
fi
# main에 병합
git checkout "$MAIN_BRANCH"
git pull origin "$MAIN_BRANCH"
git merge --no-ff "$current_branch" -m "Merge release '$version'"
# 태그 생성
git tag -a "v$version" -m "Release version $version"
# develop에 병합
git checkout "$DEVELOP_BRANCH"
git pull origin "$DEVELOP_BRANCH"
git merge --no-ff "$current_branch" -m "Merge release '$version' into develop"
# 브랜치 삭제
git branch -d "$current_branch"
# 푸시
git push origin "$MAIN_BRANCH"
git push origin "$DEVELOP_BRANCH"
git push origin "v$version"
git push origin --delete "$current_branch"
echo -e "${GREEN}Release $version completed!${NC}"
}
# Hotfix 시작
start_hotfix() {
local current_version=$(get_current_version)
local hotfix_version=$(increment_version "$current_version" "patch")
echo -e "${GREEN}Starting hotfix: $hotfix_version${NC}"
# main에서 브랜치 생성
git checkout "$MAIN_BRANCH"
git pull origin "$MAIN_BRANCH"
git checkout -b "hotfix/$hotfix_version"
echo -e "${GREEN}Hotfix branch created: hotfix/$hotfix_version${NC}"
}
# 테스트 실행
run_tests() {
echo -e "${YELLOW}Running tests...${NC}"
# 프로젝트에 맞는 테스트 명령어
if [ -f "package.json" ]; then
npm test
elif [ -f "Gemfile" ]; then
bundle exec rspec
elif [ -f "pom.xml" ]; then
mvn test
else
echo -e "${YELLOW}No test framework detected${NC}"
return 0
fi
}
# 메인 명령어 처리
case "$1" in
feature)
case "$2" in
start)
start_feature "$3"
;;
finish)
finish_feature
;;
*)
echo "Usage: $0 feature {start|finish} [name]"
exit 1
;;
esac
;;
release)
case "$2" in
start)
start_release "$3"
;;
finish)
finish_release
;;
*)
echo "Usage: $0 release {start|finish} [major|minor|patch]"
exit 1
;;
esac
;;
hotfix)
case "$2" in
start)
start_hotfix
;;
finish)
finish_release # hotfix는 release와 동일한 프로세스
;;
*)
echo "Usage: $0 hotfix {start|finish}"
exit 1
;;
esac
;;
*)
echo "Usage: $0 {feature|release|hotfix} {start|finish} [options]"
exit 1
;;
esac
Git Flow 상태 모니터링
전략을 정한 뒤에는 오래된 기능 브랜치와 미병합 변경이 얼마나 쌓이는지 관찰해야 합니다. get_all_branches로 상태를 모으고 analyze_flow_health가 이를 해석하는 순서로 읽으세요. 텍스트 보고서와 Prometheus 출력은 같은 점검 결과를 사람과 모니터링 도구에 각각 전달하는 방법입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/usr/bin/env python3
# git_flow_monitor.py
import subprocess
import json
from datetime import datetime, timedelta
from collections import defaultdict
class GitFlowMonitor:
def __init__(self, repo_path='.'):
self.repo_path = repo_path
self.branches = self.get_all_branches()
def run_git_command(self, cmd):
"""Git 명령 실행"""
result = subprocess.run(
f"git {cmd}",
shell=True,
cwd=self.repo_path,
capture_output=True,
text=True
)
return result.stdout.strip()
def get_all_branches(self):
"""모든 브랜치 정보 수집"""
branches = {}
# 로컬 및 원격 브랜치
output = self.run_git_command("branch -a --format='%(refname:short)|%(committerdate:iso)|%(committername)'")
for line in output.split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 3:
branch_name = parts[0].replace('origin/', '')
last_commit = datetime.fromisoformat(parts[1].replace(' +0000', '+00:00'))
author = parts[2]
branches[branch_name] = {
'last_commit': last_commit,
'author': author,
'age_days': (datetime.now(last_commit.tzinfo) - last_commit).days
}
return branches
def analyze_flow_health(self):
"""Git Flow 건강 상태 분석"""
health_report = {
'status': 'healthy',
'issues': [],
'warnings': [],
'metrics': {}
}
# 브랜치 분류
features = [b for b in self.branches if b.startswith('feature/')]
releases = [b for b in self.branches if b.startswith('release/')]
hotfixes = [b for b in self.branches if b.startswith('hotfix/')]
# 메트릭 계산
health_report['metrics'] = {
'active_features': len(features),
'active_releases': len(releases),
'active_hotfixes': len(hotfixes),
'total_branches': len(self.branches)
}
# 오래된 feature 브랜치 확인
old_features = [b for b in features if self.branches[b]['age_days'] > 30]
if old_features:
health_report['warnings'].append({
'type': 'stale_features',
'message': f"{len(old_features)} feature branches older than 30 days",
'branches': old_features
})
# 다중 release 브랜치 확인
if len(releases) > 1:
health_report['issues'].append({
'type': 'multiple_releases',
'message': f"{len(releases)} release branches active simultaneously",
'branches': releases
})
health_report['status'] = 'unhealthy'
# develop 브랜치 확인
if 'develop' not in self.branches:
health_report['issues'].append({
'type': 'missing_develop',
'message': "Develop branch not found"
})
health_report['status'] = 'unhealthy'
# 병합되지 않은 브랜치 확인
unmerged = self.find_unmerged_branches()
if len(unmerged) > 10:
health_report['warnings'].append({
'type': 'many_unmerged',
'message': f"{len(unmerged)} unmerged branches",
'count': len(unmerged)
})
return health_report
def find_unmerged_branches(self):
"""병합되지 않은 브랜치 찾기"""
merged = self.run_git_command("branch -a --merged develop")
all_branches = self.run_git_command("branch -a")
merged_set = set(merged.split('\n'))
all_set = set(all_branches.split('\n'))
return list(all_set - merged_set)
def generate_report(self):
"""상세 리포트 생성"""
health = self.analyze_flow_health()
report = f"""
Git Flow Health Report
======================
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
Status: {health['status'].upper()}
Metrics:
--------
Active Features: {health['metrics']['active_features']}
Active Releases: {health['metrics']['active_releases']}
Active Hotfixes: {health['metrics']['active_hotfixes']}
Total Branches: {health['metrics']['total_branches']}
"""
if health['issues']:
report += "ISSUES:\n-------\n"
for issue in health['issues']:
report += f"❌ {issue['message']}\n"
if health['warnings']:
report += "\nWARNINGS:\n---------\n"
for warning in health['warnings']:
report += f"⚠️ {warning['message']}\n"
# 브랜치 상세 정보
report += "\nBranch Details:\n---------------\n"
for branch_type in ['feature/', 'release/', 'hotfix/']:
branches = [b for b in self.branches if b.startswith(branch_type)]
if branches:
report += f"\n{branch_type.upper()}:\n"
for branch in sorted(branches, key=lambda b: self.branches[b]['age_days'], reverse=True):
info = self.branches[branch]
report += f" - {branch}: {info['age_days']} days old (by {info['author']})\n"
return report
def export_metrics(self, format='json'):
"""메트릭 내보내기"""
data = {
'timestamp': datetime.now().isoformat(),
'health': self.analyze_flow_health(),
'branches': {
branch: {
'last_commit': info['last_commit'].isoformat(),
'author': info['author'],
'age_days': info['age_days']
}
for branch, info in self.branches.items()
}
}
if format == 'json':
return json.dumps(data, indent=2)
elif format == 'prometheus':
metrics = []
health = data['health']
metrics.append(f"git_flow_active_features {health['metrics']['active_features']}")
metrics.append(f"git_flow_active_releases {health['metrics']['active_releases']}")
metrics.append(f"git_flow_active_hotfixes {health['metrics']['active_hotfixes']}")
metrics.append(f"git_flow_health_status {1 if health['status'] == 'healthy' else 0}")
return '\n'.join(metrics)
# 사용 예제
monitor = GitFlowMonitor()
print(monitor.generate_report())
# Prometheus 메트릭 출력
print("\nPrometheus Metrics:")
print(monitor.export_metrics('prometheus'))
3. GitHub Flow 최적화
GitHub Flow 자동화 도구
GitHub Flow에서는 짧게 유지한 작업 브랜치를 PR로 검토하고 main으로 돌려보냅니다. 이 예제의 읽기 경로는 createFeatureBranch → createPullRequest → monitorPR → autoMerge입니다. 자동 병합 전에 어떤 체크와 리뷰 조건을 보는지 먼저 살펴보세요.
자동화가 보조하는 것은 반복 작업입니다. 저장소에서 요구하는 승인·체크와 코드 안의 판단이 일치하는지, 머지된 코드가 언제 배포되는지는 팀 정책으로 별도 확인해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// github-flow-automation.js
const { Octokit } = require('@octokit/rest');
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);
class GitHubFlowAutomation {
constructor(token, owner, repo) {
this.octokit = new Octokit({ auth: token });
this.owner = owner;
this.repo = repo;
}
async createFeatureBranch(branchName, description) {
// 1. main 브랜치 최신 커밋 가져오기
const { data: mainBranch } = await this.octokit.repos.getBranch({
owner: this.owner,
repo: this.repo,
branch: 'main'
});
const mainSha = mainBranch.commit.sha;
// 2. 새 브랜치 생성
await this.octokit.git.createRef({
owner: this.owner,
repo: this.repo,
ref: `refs/heads/${branchName}`,
sha: mainSha
});
// 3. 브랜치 보호 규칙 설정 (선택적)
if (branchName.startsWith('feature/')) {
await this.setupBranchProtection(branchName);
}
// 4. 이슈 생성 및 연결
const { data: issue } = await this.octokit.issues.create({
owner: this.owner,
repo: this.repo,
title: `Feature: ${description}`,
body: `## Feature Branch: ${branchName}\n\n${description}`,
labels: ['feature', 'in-progress']
});
console.log(`Created branch: ${branchName}`);
console.log(`Created issue: #${issue.number}`);
return { branch: branchName, issue: issue.number };
}
async createPullRequest(branchName, issueNumber) {
// 1. 브랜치 차이 확인
const { data: comparison } = await this.octokit.repos.compareCommitsWithBasehead({
owner: this.owner,
repo: this.repo,
basehead: `main...${branchName}`
});
if (comparison.total_commits === 0) {
throw new Error('No commits to create PR');
}
// 2. PR 템플릿 생성
const prBody = `
## Description
Closes #${issueNumber}
## Changes
${comparison.commits.map(c => `- ${c.commit.message}`).join('\n')}
## Checklist
- [ ] Tests pass
- [ ] Documentation updated
- [ ] Code review requested
- [ ] No breaking changes
## Screenshots (if applicable)
N/A
`;
// 3. PR 생성
const { data: pr } = await this.octokit.pulls.create({
owner: this.owner,
repo: this.repo,
title: `Feature: ${branchName.replace('feature/', '')}`,
head: branchName,
base: 'main',
body: prBody,
draft: false
});
// 4. 리뷰어 자동 할당
await this.assignReviewers(pr.number);
// 5. 라벨 추가
await this.octokit.issues.addLabels({
owner: this.owner,
repo: this.repo,
issue_number: pr.number,
labels: ['needs-review', 'feature']
});
return pr;
}
async assignReviewers(prNumber) {
// CODEOWNERS 파일 기반 리뷰어 할당
try {
const { data: codeowners } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path: '.github/CODEOWNERS'
});
const content = Buffer.from(codeowners.content, 'base64').toString();
const reviewers = this.parseCodeOwners(content);
await this.octokit.pulls.requestReviewers({
owner: this.owner,
repo: this.repo,
pull_number: prNumber,
reviewers: reviewers.slice(0, 2) // 최대 2명
});
} catch (error) {
console.log('CODEOWNERS not found, skipping auto-assignment');
}
}
async monitorPR(prNumber) {
const checkInterval = 60000; // 1분
const monitor = async () => {
const { data: pr } = await this.octokit.pulls.get({
owner: this.owner,
repo: this.repo,
pull_number: prNumber
});
// 체크 상태 확인
const { data: checks } = await this.octokit.checks.listForRef({
owner: this.owner,
repo: this.repo,
ref: pr.head.sha
});
const allChecksPassed = checks.check_runs.every(
check => check.status === 'completed' && check.conclusion === 'success'
);
// 리뷰 상태 확인
const { data: reviews } = await this.octokit.pulls.listReviews({
owner: this.owner,
repo: this.repo,
pull_number: prNumber
});
const approved = reviews.some(r => r.state === 'APPROVED');
console.log(`PR #${prNumber} Status:`);
console.log(`- Checks: ${allChecksPassed ? '✅' : '⏳'}`);
console.log(`- Reviews: ${approved ? '✅' : '⏳'}`);
console.log(`- Mergeable: ${pr.mergeable ? '✅' : '❌'}`);
// 자동 병합 조건 확인
if (allChecksPassed && approved && pr.mergeable) {
await this.autoMerge(prNumber);
return true; // 모니터링 종료
}
return false;
};
// 주기적 모니터링
const intervalId = setInterval(async () => {
const completed = await monitor();
if (completed) {
clearInterval(intervalId);
}
}, checkInterval);
}
async autoMerge(prNumber) {
try {
// Squash merge 실행
const { data: mergeResult } = await this.octokit.pulls.merge({
owner: this.owner,
repo: this.repo,
pull_number: prNumber,
merge_method: 'squash'
});
console.log(`✅ PR #${prNumber} merged successfully`);
// 브랜치 삭제
const { data: pr } = await this.octokit.pulls.get({
owner: this.owner,
repo: this.repo,
pull_number: prNumber
});
await this.octokit.git.deleteRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${pr.head.ref}`
});
console.log(`🗑️ Branch ${pr.head.ref} deleted`);
} catch (error) {
console.error(`Failed to auto-merge PR #${prNumber}:`, error.message);
}
}
parseCodeOwners(content) {
// 간단한 CODEOWNERS 파서
const lines = content.split('\n');
const owners = new Set();
lines.forEach(line => {
if (line.trim() && !line.startsWith('#')) {
const parts = line.split(/\s+/);
parts.slice(1).forEach(owner => {
if (owner.startsWith('@')) {
owners.add(owner.substring(1));
}
});
}
});
return Array.from(owners);
}
}
// 사용 예제
async function runGitHubFlow() {
const automation = new GitHubFlowAutomation(
process.env.GITHUB_TOKEN,
'myorg',
'myrepo'
);
// 1. Feature 브랜치 생성
const { branch, issue } = await automation.createFeatureBranch(
'feature/user-authentication',
'Implement user authentication with OAuth2'
);
// 2. 개발 작업 (로컬에서)
await execAsync(`git fetch origin`);
await execAsync(`git checkout ${branch}`);
// ... 코드 작성 ...
await execAsync(`git add .`);
await execAsync(`git commit -m "feat: implement OAuth2 authentication"`);
await execAsync(`git push origin ${branch}`);
// 3. PR 생성
const pr = await automation.createPullRequest(branch, issue);
console.log(`Created PR: #${pr.number}`);
// 4. PR 모니터링 및 자동 병합
await automation.monitorPR(pr.number);
}
// 실행
runGitHubFlow().catch(console.error);
4. Trunk-Based Development 구현
Feature Flags를 활용한 TBD
Trunk-Based Development에서는 작은 변경을 자주 합치되, 미완성 기능을 사용자에게 노출할지는 별도로 제어합니다. 아래 예제의 FeatureFlag.is_enabled_for_user가 “코드가 존재하는가”와 “이 사용자가 새 경로를 쓰는가”를 나눕니다. 사용자 ID에 따른 비율 계산과 조건 판정을 먼저 보세요.
관찰할 결과: 같은 사용자에게 같은 플래그 조건을 적용했을 때 선택이 일관적인지, 비활성화했을 때 기존 경로로 돌아오는지를 비교합니다. 플래그를 끄는 것은 이미 발생한 데이터 변경까지 되돌리는 작업은 아닙니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# trunk_based_development.py
import json
import hashlib
import requests
from datetime import datetime
from abc import ABC, abstractmethod
class FeatureFlag:
"""Feature Flag 구현"""
def __init__(self, name, enabled=False, rollout_percentage=0, conditions=None):
self.name = name
self.enabled = enabled
self.rollout_percentage = rollout_percentage
self.conditions = conditions or {}
def is_enabled_for_user(self, user_id, attributes=None):
"""사용자별 활성화 여부 확인"""
if not self.enabled:
return False
# 조건 확인
if self.conditions:
if not self._check_conditions(attributes):
return False
# 점진적 롤아웃
if 0 < self.rollout_percentage < 100:
# 일관된 해싱으로 사용자 그룹 결정
hash_input = f"{self.name}:{user_id}"
hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
user_percentage = hash_value % 100
return user_percentage < self.rollout_percentage
return True
def _check_conditions(self, attributes):
"""조건 확인"""
if not attributes:
return False
for key, expected_value in self.conditions.items():
if key not in attributes:
return False
if isinstance(expected_value, list):
if attributes[key] not in expected_value:
return False
else:
if attributes[key] != expected_value:
return False
return True
class FeatureFlagProvider(ABC):
"""Feature Flag Provider 인터페이스"""
@abstractmethod
def get_flag(self, flag_name):
pass
@abstractmethod
def update_flag(self, flag_name, config):
pass
class LocalFeatureFlagProvider(FeatureFlagProvider):
"""로컬 JSON 기반 Provider"""
def __init__(self, config_file='feature_flags.json'):
self.config_file = config_file
self.flags = self._load_flags()
def _load_flags(self):
try:
with open(self.config_file, 'r') as f:
data = json.load(f)
return {
name: FeatureFlag(name, **config)
for name, config in data.items()
}
except FileNotFoundError:
return {}
def get_flag(self, flag_name):
return self.flags.get(flag_name)
def update_flag(self, flag_name, config):
self.flags[flag_name] = FeatureFlag(flag_name, **config)
self._save_flags()
def _save_flags(self):
data = {
name: {
'enabled': flag.enabled,
'rollout_percentage': flag.rollout_percentage,
'conditions': flag.conditions
}
for name, flag in self.flags.items()
}
with open(self.config_file, 'w') as f:
json.dump(data, f, indent=2)
class TrunkBasedWorkflow:
"""Trunk-Based Development 워크플로우"""
def __init__(self, feature_flag_provider):
self.feature_flags = feature_flag_provider
self.deployment_pipeline = []
def add_feature(self, feature_name, implementation):
"""Feature flag로 보호된 기능 추가"""
flag = FeatureFlag(feature_name, enabled=False)
self.feature_flags.update_flag(feature_name, {
'enabled': False,
'rollout_percentage': 0,
'conditions': {}
})
def wrapped_implementation(*args, **kwargs):
user_id = kwargs.get('user_id', 'anonymous')
user_attributes = kwargs.get('user_attributes', {})
flag = self.feature_flags.get_flag(feature_name)
if flag and flag.is_enabled_for_user(user_id, user_attributes):
return implementation(*args, **kwargs)
else:
# 기존 동작 또는 대체 동작
return None
return wrapped_implementation
def deploy_feature(self, feature_name, strategy='canary'):
"""점진적 배포"""
stages = []
if strategy == 'canary':
stages = [
{'percentage': 1, 'duration': 3600}, # 1% for 1 hour
{'percentage': 5, 'duration': 7200}, # 5% for 2 hours
{'percentage': 25, 'duration': 14400}, # 25% for 4 hours
{'percentage': 50, 'duration': 28800}, # 50% for 8 hours
{'percentage': 100, 'duration': 0} # 100% (완료)
]
elif strategy == 'blue_green':
stages = [
{'percentage': 0, 'duration': 0},
{'percentage': 100, 'duration': 0}
]
return DeploymentPipeline(
feature_name,
stages,
self.feature_flags
)
class DeploymentPipeline:
"""배포 파이프라인"""
def __init__(self, feature_name, stages, feature_flags):
self.feature_name = feature_name
self.stages = stages
self.feature_flags = feature_flags
self.current_stage = 0
self.start_time = None
def start(self):
"""배포 시작"""
self.start_time = datetime.now()
self._apply_stage(0)
def _apply_stage(self, stage_index):
"""스테이지 적용"""
if stage_index >= len(self.stages):
print(f"✅ Deployment of {self.feature_name} completed!")
return
stage = self.stages[stage_index]
self.current_stage = stage_index
# Feature flag 업데이트
self.feature_flags.update_flag(self.feature_name, {
'enabled': True,
'rollout_percentage': stage['percentage'],
'conditions': {}
})
print(f"📊 Stage {stage_index + 1}: {stage['percentage']}% rollout")
# 다음 스테이지 스케줄링 (실제로는 별도 스케줄러 사용)
if stage['duration'] > 0:
print(f"⏱️ Next stage in {stage['duration']} seconds")
# 실제 구현에서는 스케줄러 사용
def rollback(self):
"""롤백"""
print(f"🔄 Rolling back {self.feature_name}")
self.feature_flags.update_flag(self.feature_name, {
'enabled': False,
'rollout_percentage': 0,
'conditions': {}
})
def get_metrics(self):
"""배포 메트릭"""
# 실제로는 모니터링 시스템과 통합
return {
'feature': self.feature_name,
'current_stage': self.current_stage,
'rollout_percentage': self.stages[self.current_stage]['percentage'],
'start_time': self.start_time,
'duration': (datetime.now() - self.start_time).total_seconds()
}
# 사용 예제
if __name__ == "__main__":
# Feature flag provider 초기화
provider = LocalFeatureFlagProvider()
# TBD 워크플로우 생성
workflow = TrunkBasedWorkflow(provider)
# 새 기능 추가
@workflow.add_feature('new_checkout_flow')
def new_checkout(user_id, cart_items, user_attributes=None):
print(f"New checkout flow for user {user_id}")
# 새로운 체크아웃 로직
return {'status': 'success', 'flow': 'new'}
# 기능 테스트
result = new_checkout(user_id='user123', cart_items=[])
print(f"Result: {result}") # None (비활성화 상태)
# 카나리 배포 시작
deployment = workflow.deploy_feature('new_checkout_flow', 'canary')
deployment.start()
# 다시 테스트
result = new_checkout(user_id='user123', cart_items=[])
print(f"Result after deployment: {result}")
Branch by Abstraction 패턴
장기간의 코드 교체는 새 구현을 별도 브랜치에 계속 쌓는 대신 공통 인터페이스 뒤에서 진행할 수 있습니다. 이 예제에서는 PaymentProcessor가 경계를 만들고, 호출부가 구·신 결제 구현 중 하나를 선택합니다. 인터페이스 → 구현 선택 → 단계별 전환 → 롤백 순서로 읽으면 클래스 수가 많아도 목적을 잃지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# branch_by_abstraction.py
from abc import ABC, abstractmethod
from typing import Dict, Any
import logging
class PaymentProcessor(ABC):
"""결제 처리 추상화"""
@abstractmethod
def process_payment(self, amount: float, payment_info: Dict[str, Any]) -> Dict[str, Any]:
pass
@abstractmethod
def refund_payment(self, transaction_id: str, amount: float) -> Dict[str, Any]:
pass
class LegacyPaymentProcessor(PaymentProcessor):
"""기존 결제 시스템"""
def process_payment(self, amount: float, payment_info: Dict[str, Any]) -> Dict[str, Any]:
logging.info(f"Processing payment with legacy system: ${amount}")
# 기존 결제 로직
return {
'status': 'success',
'transaction_id': f'legacy_{payment_info.get("order_id")}',
'processor': 'legacy'
}
def refund_payment(self, transaction_id: str, amount: float) -> Dict[str, Any]:
logging.info(f"Processing refund with legacy system: ${amount}")
return {
'status': 'success',
'refund_id': f'refund_{transaction_id}',
'processor': 'legacy'
}
class NewPaymentProcessor(PaymentProcessor):
"""새로운 결제 시스템"""
def process_payment(self, amount: float, payment_info: Dict[str, Any]) -> Dict[str, Any]:
logging.info(f"Processing payment with new system: ${amount}")
# 새로운 결제 로직
return {
'status': 'success',
'transaction_id': f'new_{payment_info.get("order_id")}',
'processor': 'new',
'additional_info': {
'fraud_score': 0.1,
'processing_time': 1.2
}
}
def refund_payment(self, transaction_id: str, amount: float) -> Dict[str, Any]:
logging.info(f"Processing refund with new system: ${amount}")
return {
'status': 'success',
'refund_id': f'refund_{transaction_id}',
'processor': 'new',
'instant_refund': True
}
class PaymentProcessorFactory:
"""결제 프로세서 팩토리"""
def __init__(self, feature_flags):
self.feature_flags = feature_flags
self.legacy_processor = LegacyPaymentProcessor()
self.new_processor = NewPaymentProcessor()
def get_processor(self, user_id: str = None, user_attributes: Dict = None) -> PaymentProcessor:
"""사용자별 적절한 프로세서 반환"""
flag = self.feature_flags.get_flag('new_payment_system')
if flag and flag.is_enabled_for_user(user_id, user_attributes):
return self.new_processor
else:
return self.legacy_processor
class PaymentService:
"""결제 서비스 (추상화 사용)"""
def __init__(self, processor_factory: PaymentProcessorFactory):
self.processor_factory = processor_factory
def process_order_payment(self, order_id: str, amount: float,
user_id: str, payment_info: Dict[str, Any]) -> Dict[str, Any]:
"""주문 결제 처리"""
# 적절한 프로세서 선택
processor = self.processor_factory.get_processor(
user_id=user_id,
user_attributes={'subscription_tier': payment_info.get('tier', 'free')}
)
# 결제 처리
result = processor.process_payment(amount, payment_info)
# 공통 후처리
self._log_payment_metrics(result)
self._send_notification(user_id, result)
return result
def _log_payment_metrics(self, result: Dict[str, Any]):
"""메트릭 로깅"""
processor_type = result.get('processor', 'unknown')
logging.info(f"Payment processed by {processor_type} system")
def _send_notification(self, user_id: str, result: Dict[str, Any]):
"""알림 전송"""
if result['status'] == 'success':
logging.info(f"Sending payment success notification to user {user_id}")
# 마이그레이션 전략
class PaymentSystemMigration:
"""결제 시스템 마이그레이션"""
def __init__(self, feature_flags, payment_service):
self.feature_flags = feature_flags
self.payment_service = payment_service
self.migration_stages = [
{
'name': 'testing',
'percentage': 0,
'conditions': {'subscription_tier': ['internal_test']},
'duration_days': 7
},
{
'name': 'beta_users',
'percentage': 1,
'conditions': {'subscription_tier': ['beta']},
'duration_days': 14
},
{
'name': 'premium_users',
'percentage': 10,
'conditions': {'subscription_tier': ['premium']},
'duration_days': 7
},
{
'name': 'gradual_rollout',
'percentage': 50,
'conditions': {},
'duration_days': 14
},
{
'name': 'full_rollout',
'percentage': 100,
'conditions': {},
'duration_days': 0
}
]
def execute_migration(self):
"""단계별 마이그레이션 실행"""
for stage in self.migration_stages:
print(f"\n🚀 Starting migration stage: {stage['name']}")
# Feature flag 업데이트
self.feature_flags.update_flag('new_payment_system', {
'enabled': True,
'rollout_percentage': stage['percentage'],
'conditions': stage['conditions']
})
# 모니터링 및 검증
if not self._validate_stage(stage):
print(f"❌ Validation failed for stage {stage['name']}")
self._rollback()
return False
print(f"✅ Stage {stage['name']} completed successfully")
print("\n🎉 Migration completed successfully!")
return True
def _validate_stage(self, stage):
"""스테이지 검증"""
# 실제로는 메트릭 확인, 에러율 체크 등
print(f"Validating stage {stage['name']}...")
return True
def _rollback(self):
"""롤백"""
print("🔄 Rolling back to legacy system")
self.feature_flags.update_flag('new_payment_system', {
'enabled': False,
'rollout_percentage': 0,
'conditions': {}
})
5. 릴리스 관리 자동화
Semantic Versioning 자동화
릴리스 자동화는 커밋 메시지 해석과 실제 배포를 구분해서 읽어야 합니다. ConventionalCommitParser가 변경 종류를 분류하고, determine_version_bump가 버전 증가를 선택한 뒤 create_release가 파일·태그·릴리스를 처리합니다.
먼저 --dry-run 경로에서 다음 버전과 변경 로그가 어떻게 계산되는지 살펴보세요. 예를 들어 fix:와 feat:를 각각 넣었을 때 패치·마이너 선택이 달라지는지, 호환성을 깨는 변경을 팀의 커밋 규칙대로 인식하는지가 확인 대상입니다. 파서가 모든 커밋 표기를 처리한다고 가정하지 마세요.
버전 계산·변경 로그·릴리스 생성을 연결한 Python 전체 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
#!/usr/bin/env python3
# semantic_release.py
import re
import subprocess
import json
from datetime import datetime
from typing import List, Tuple, Dict
class SemanticVersioning:
"""Semantic Versioning 2.0.0 구현"""
VERSION_PATTERN = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9\-\.]+))?(?:\+([a-zA-Z0-9\-\.]+))?$')
def __init__(self, current_version: str):
match = self.VERSION_PATTERN.match(current_version)
if not match:
raise ValueError(f"Invalid version format: {current_version}")
self.major = int(match.group(1))
self.minor = int(match.group(2))
self.patch = int(match.group(3))
self.prerelease = match.group(4)
self.metadata = match.group(5)
def bump(self, bump_type: str) -> str:
"""버전 증가"""
if bump_type == 'major':
self.major += 1
self.minor = 0
self.patch = 0
elif bump_type == 'minor':
self.minor += 1
self.patch = 0
elif bump_type == 'patch':
self.patch += 1
elif bump_type == 'prerelease':
if self.prerelease:
# 기존 prerelease 버전 증가
parts = self.prerelease.split('.')
if parts[-1].isdigit():
parts[-1] = str(int(parts[-1]) + 1)
else:
parts.append('1')
self.prerelease = '.'.join(parts)
else:
self.prerelease = 'rc.1'
else:
raise ValueError(f"Unknown bump type: {bump_type}")
return str(self)
def __str__(self):
version = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
version += f"-{self.prerelease}"
if self.metadata:
version += f"+{self.metadata}"
return version
class ConventionalCommitParser:
"""Conventional Commits 파서"""
COMMIT_PATTERN = re.compile(
r'^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?: (?P<description>.+?)(?:\n\n(?P<body>[\s\S]*?))?(?:\n\n(?P<footer>[\s\S]+))?$'
)
BREAKING_CHANGE_PATTERN = re.compile(r'BREAKING[\s-]CHANGE:', re.IGNORECASE)
TYPE_MAPPING = {
'feat': 'minor',
'fix': 'patch',
'docs': None,
'style': None,
'refactor': None,
'perf': 'patch',
'test': None,
'build': None,
'ci': None,
'chore': None,
'revert': 'patch'
}
def parse(self, commit_message: str) -> Dict:
"""커밋 메시지 파싱"""
match = self.COMMIT_PATTERN.match(commit_message.strip())
if not match:
return None
result = match.groupdict()
# Breaking change 확인
result['breaking'] = bool(
self.BREAKING_CHANGE_PATTERN.search(commit_message) or
commit_message.startswith('!:')
)
# 버전 영향도 결정
if result['breaking']:
result['version_bump'] = 'major'
else:
result['version_bump'] = self.TYPE_MAPPING.get(result['type'], None)
return result
class ReleaseAutomation:
"""릴리스 자동화"""
def __init__(self, repo_path: str = '.'):
self.repo_path = repo_path
self.commit_parser = ConventionalCommitParser()
def get_commits_since_last_tag(self) -> List[str]:
"""마지막 태그 이후 커밋 가져오기"""
try:
# 마지막 태그 찾기
last_tag = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0'],
cwd=self.repo_path,
text=True
).strip()
except subprocess.CalledProcessError:
# 태그가 없으면 첫 커밋부터
last_tag = ''
# 커밋 로그 가져오기
if last_tag:
cmd = ['git', 'log', f'{last_tag}..HEAD', '--pretty=format:%H|%s|%b|%aN|%aE']
else:
cmd = ['git', 'log', '--pretty=format:%H|%s|%b|%aN|%aE']
output = subprocess.check_output(cmd, cwd=self.repo_path, text=True)
commits = []
for line in output.strip().split('\n'):
if line:
parts = line.split('|', 4)
commits.append({
'hash': parts[0],
'subject': parts[1],
'body': parts[2] if len(parts) > 2 else '',
'author': parts[3] if len(parts) > 3 else '',
'email': parts[4] if len(parts) > 4 else ''
})
return commits
def determine_version_bump(self, commits: List[Dict]) -> str:
"""커밋 기반 버전 범프 타입 결정"""
bump_priority = {'major': 3, 'minor': 2, 'patch': 1}
max_bump = None
for commit in commits:
full_message = f"{commit['subject']}\n\n{commit['body']}"
parsed = self.commit_parser.parse(full_message)
if parsed and parsed['version_bump']:
if not max_bump or bump_priority.get(parsed['version_bump'], 0) > bump_priority.get(max_bump, 0):
max_bump = parsed['version_bump']
return max_bump or 'patch'
def generate_changelog(self, commits: List[Dict], version: str) -> str:
"""변경 로그 생성"""
changelog = f"# Release {version} ({datetime.now().strftime('%Y-%m-%d')})\n\n"
# 커밋 분류
categories = {
'Features': [],
'Bug Fixes': [],
'Performance Improvements': [],
'Breaking Changes': [],
'Other Changes': []
}
for commit in commits:
full_message = f"{commit['subject']}\n\n{commit['body']}"
parsed = self.commit_parser.parse(full_message)
if not parsed:
categories['Other Changes'].append(commit)
continue
if parsed['breaking']:
categories['Breaking Changes'].append(commit)
elif parsed['type'] == 'feat':
categories['Features'].append(commit)
elif parsed['type'] == 'fix':
categories['Bug Fixes'].append(commit)
elif parsed['type'] == 'perf':
categories['Performance Improvements'].append(commit)
else:
categories['Other Changes'].append(commit)
# 변경 로그 작성
for category, items in categories.items():
if items:
changelog += f"## {category}\n\n"
for item in items:
scope = ''
if parsed := self.commit_parser.parse(f"{item['subject']}\n\n{item['body']}"):
if parsed.get('scope'):
scope = f"**{parsed['scope']}:** "
changelog += f"- {scope}{item['subject']} ([{item['hash'][:7]}]({item['hash']}))\n"
changelog += "\n"
# Contributors
contributors = set((commit['author'], commit['email']) for commit in commits)
if contributors:
changelog += "## Contributors\n\n"
for name, email in sorted(contributors):
changelog += f"- {name} <{email}>\n"
return changelog
def create_release(self, dry_run: bool = False):
"""릴리스 생성"""
# 현재 버전 가져오기
try:
current_version = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0'],
cwd=self.repo_path,
text=True
).strip()
except subprocess.CalledProcessError:
current_version = 'v0.0.0'
# 커밋 분석
commits = self.get_commits_since_last_tag()
if not commits:
print("No commits since last release")
return
# 버전 결정
bump_type = self.determine_version_bump(commits)
version = SemanticVersioning(current_version)
new_version = version.bump(bump_type)
# 변경 로그 생성
changelog = self.generate_changelog(commits, new_version)
print(f"Current version: {current_version}")
print(f"New version: v{new_version}")
print(f"Bump type: {bump_type}")
print("\nChangelog:")
print(changelog)
if not dry_run:
# 태그 생성
subprocess.run(
['git', 'tag', '-a', f'v{new_version}', '-m', f'Release v{new_version}\n\n{changelog}'],
cwd=self.repo_path,
check=True
)
# CHANGELOG.md 업데이트
self._update_changelog_file(new_version, changelog)
print(f"\n✅ Release v{new_version} created!")
print("Run 'git push --tags' to publish")
else:
print("\n🔍 Dry run completed (no changes made)")
def _update_changelog_file(self, version: str, changelog: str):
"""CHANGELOG.md 파일 업데이트"""
changelog_file = os.path.join(self.repo_path, 'CHANGELOG.md')
if os.path.exists(changelog_file):
with open(changelog_file, 'r') as f:
existing_content = f.read()
else:
existing_content = "# Changelog\n\n"
# 새 내용 추가
new_content = existing_content.replace(
"# Changelog\n\n",
f"# Changelog\n\n{changelog}\n\n"
)
with open(changelog_file, 'w') as f:
f.write(new_content)
# Git에 추가
subprocess.run(
['git', 'add', 'CHANGELOG.md'],
cwd=self.repo_path,
check=True
)
subprocess.run(
['git', 'commit', '-m', f'docs: update changelog for v{version}'],
cwd=self.repo_path,
check=True
)
# CLI 인터페이스
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Automated semantic release')
parser.add_argument('--dry-run', action='store_true', help='Perform dry run')
parser.add_argument('--path', default='.', help='Repository path')
args = parser.parse_args()
automation = ReleaseAutomation(args.path)
automation.create_release(dry_run=args.dry_run)
6. 브랜치 전략 마이그레이션
전략 변경 가이드
전략 전환에서 먼저 정할 것은 삭제할 브랜치가 아니라, 진행 중인 변경을 어디로 통합하고 어떤 검증을 거칠지입니다. 아래 코드는 현재 구조 분석 → 전환 계획 → 설정·가이드 생성 순서를 보여줍니다. dry_run=True가 출력하는 계획과 실행 경로를 비교하며 읽으세요.
전환 전 확인: 열린 PR의 새 병합 대상, 유지할 릴리스 브랜치, 기존 태그와 배포 이력, 실패 시 돌아갈 기준을 정합니다. 이름이 바뀌어도 리뷰·테스트·배포의 책임자가 사라지지 않도록 합의하는 것이 코드 실행보다 먼저입니다.
전환 검토 참고: 브랜치 분석과 TBD 마이그레이션 전체 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# branch_strategy_migration.py
import os
import subprocess
from typing import List, Dict
from datetime import datetime
class BranchStrategyMigration:
"""브랜치 전략 마이그레이션 도구"""
def __init__(self, repo_path: str = '.'):
self.repo_path = repo_path
def analyze_current_structure(self) -> Dict:
"""현재 브랜치 구조 분석"""
# 모든 브랜치 가져오기
branches = subprocess.check_output(
['git', 'branch', '-a'],
cwd=self.repo_path,
text=True
).strip().split('\n')
structure = {
'total_branches': len(branches),
'patterns': {
'git_flow': 0,
'github_flow': 0,
'custom': 0
},
'branches': []
}
# 패턴 분석
for branch in branches:
branch = branch.strip().replace('* ', '').replace('remotes/origin/', '')
if branch in ['main', 'master', 'develop']:
structure['patterns']['git_flow'] += 1
elif branch.startswith(('feature/', 'release/', 'hotfix/')):
structure['patterns']['git_flow'] += 1
elif '/' not in branch and branch not in ['main', 'master']:
structure['patterns']['github_flow'] += 1
else:
structure['patterns']['custom'] += 1
structure['branches'].append(branch)
# 현재 전략 추정
if structure['patterns']['git_flow'] > structure['patterns']['github_flow']:
structure['current_strategy'] = 'git_flow'
elif structure['patterns']['github_flow'] > structure['patterns']['git_flow']:
structure['current_strategy'] = 'github_flow'
else:
structure['current_strategy'] = 'unknown'
return structure
def migrate_to_trunk_based(self, dry_run: bool = True):
"""Trunk-Based Development로 마이그레이션"""
print("🚀 Migrating to Trunk-Based Development")
migration_plan = []
# 1. 현재 구조 분석
current = self.analyze_current_structure()
print(f"Current strategy: {current['current_strategy']}")
print(f"Total branches: {current['total_branches']}")
# 2. 활성 feature 브랜치 확인
active_features = [b for b in current['branches'] if b.startswith('feature/')]
if active_features:
print(f"\n⚠️ Found {len(active_features)} active feature branches")
migration_plan.append({
'action': 'merge_features',
'branches': active_features,
'description': 'Merge all active feature branches'
})
# 3. develop 브랜치 처리
if 'develop' in current['branches']:
migration_plan.append({
'action': 'merge_develop',
'description': 'Merge develop into main and remove'
})
# 4. 브랜치 정리 계획
cleanup_branches = [
b for b in current['branches']
if b not in ['main', 'master'] and
not b.startswith('feature/') and
not b.endswith('/HEAD')
]
if cleanup_branches:
migration_plan.append({
'action': 'cleanup_branches',
'branches': cleanup_branches,
'description': 'Remove unnecessary branches'
})
# 5. 설정 변경
migration_plan.append({
'action': 'update_settings',
'description': 'Update repository settings for TBD'
})
# 계획 출력
print("\n📋 Migration Plan:")
for i, step in enumerate(migration_plan, 1):
print(f"\n{i}. {step['description']}")
if 'branches' in step:
for branch in step['branches'][:5]: # 처음 5개만 표시
print(f" - {branch}")
if len(step['branches']) > 5:
print(f" ... and {len(step['branches']) - 5} more")
if not dry_run:
print("\n⚡ Executing migration...")
self._execute_migration_plan(migration_plan)
else:
print("\n🔍 Dry run completed (no changes made)")
# 마이그레이션 가이드 생성
self._generate_migration_guide()
def _execute_migration_plan(self, plan: List[Dict]):
"""마이그레이션 계획 실행"""
for step in plan:
if step['action'] == 'merge_features':
for branch in step['branches']:
print(f"Merging {branch}...")
# 실제 병합 로직
elif step['action'] == 'merge_develop':
print("Merging develop into main...")
# develop 병합 로직
elif step['action'] == 'cleanup_branches':
for branch in step['branches']:
print(f"Removing {branch}...")
# 브랜치 삭제 로직
elif step['action'] == 'update_settings':
print("Updating repository settings...")
self._update_repo_settings_for_tbd()
def _update_repo_settings_for_tbd(self):
"""TBD를 위한 저장소 설정 업데이트"""
# .github/settings.yml 생성
settings = """
# Trunk-Based Development Settings
branches:
- name: main
protection:
required_status_checks:
strict: true
contexts:
- continuous-integration
- tests
enforce_admins: false
required_pull_request_reviews:
required_approving_review_count: 1
dismiss_stale_reviews: true
require_code_owner_reviews: true
restrictions: null
allow_force_pushes: false
allow_deletions: false
labels:
- name: feature-flag
color: "1d76db"
description: "Feature flag required"
- name: ready-to-merge
color: "0e8a16"
description: "PR is ready for merge"
- name: do-not-merge
color: "d93f0b"
description: "PR should not be merged yet"
# Auto-delete head branches
delete_branch_on_merge: true
"""
os.makedirs('.github', exist_ok=True)
with open('.github/settings.yml', 'w') as f:
f.write(settings)
def _generate_migration_guide(self):
"""마이그레이션 가이드 생성"""
guide = """
# Trunk-Based Development Migration Guide
## New Workflow
1. **Creating Changes**
```bash
git checkout main
git pull origin main
git checkout -b short-lived-branch
# Make changes (small, incremental)
git commit -m "feat: add new feature"
git push origin short-lived-branch
```
2. **Feature Flags**
- All new features must be behind feature flags
- Use the feature flag service for rollout control
- Remove flags only after full rollout
3. **Branch Lifetime**
- Branches should live < 24 hours
- Large features: use Branch by Abstraction
- No long-lived feature branches
4. **Code Review**
- PRs must be small (< 400 lines)
- Reviews should happen within 2 hours
- Use pair programming for complex changes
5. **Deployment**
- Main branch is always deployable
- Automated deployment on merge
- Rollback via feature flags, not reverts
## Tools and Scripts
- `./scripts/feature-flag.sh` - Feature flag management
- `./scripts/quick-pr.sh` - Quick PR creation
- `./scripts/health-check.sh` - TBD health metrics
## Resources
- [Trunk-Based Development](https://trunkbaseddevelopment.com/)
- [Feature Flags Best Practices](internal-wiki-link)
- [Branch by Abstraction](https://martinfowler.com/bliki/BranchByAbstraction.html)
"""
with open('TBD_MIGRATION_GUIDE.md', 'w') as f:
f.write(guide)
print("\n📚 Migration guide saved to TBD_MIGRATION_GUIDE.md")
# 사용 예제
if __name__ == "__main__":
migration = BranchStrategyMigration()
# 현재 구조 분석
current = migration.analyze_current_structure()
print(f"Current branch strategy: {current['current_strategy']}")
# TBD로 마이그레이션 (dry run)
migration.migrate_to_trunk_based(dry_run=True)
마무리
팀의 상황에 적용해 보기
- 매일 배포하지만 두 개의 구버전도 유지해야 한다면, 어떤 브랜치가 왜 필요한가요?
- 기능 플래그를 끄는 것과 배포를 되돌리는 것은 어떤 상태를 각각 바꾸나요?
- 릴리스 자동화의 dry run에서 확인할 버전·변경 로그·병합 기준을 설명할 수 있나요?
한 전략의 이름을 답하는 것보다, 팀이 감당할 통합 주기와 복구 절차를 설명할 수 있는지가 이 글의 도달점입니다.
고급 브랜치 전략과 릴리스 관리는 팀의 규모와 프로젝트의 특성에 따라 선택해야 합니다. 각 전략은 장단점이 있으며, 성공적인 구현을 위해서는 자동화와 팀의 규율이 필수적입니다.
핵심 포인트:
- 프로젝트 특성에 맞는 전략 선택
- 자동화를 통한 일관성 확보
- Feature Flags로 위험 관리
- 지속적인 모니터링과 개선
- 팀 교육과 문화 변화
올바른 브랜치 전략은 개발 속도를 높이고, 품질을 보장하며, 팀의 협업을 원활하게 만듭니다.
다음 심화편에서는 GitHub GraphQL API 마스터하기에 대해 다루겠습니다.
📚 GitHub 마스터하기 시리즈
🌱 기초편 (입문자)
💼 실전편 (중급자)
🚀 고급편 (전문가)
- GitHub Actions 입문
- Actions 고급 활용
- Webhooks와 API
- GitHub Apps 개발
- 보안 기능
- GitHub Packages
- Codespaces
- GitHub CLI
- 통계와 인사이트
!["[이제와서 시작하는 GitHub 마스터하기 - 심화편 #3] 고급 브랜치 전략과 릴리스 관리: 대규모 팀을 위한 워크플로우"](/assets/img/posts/github/github-expert-03.png)