배포 자동화 뜯어 고치기

2026. 10. 5. 00:16·Android

안녕하세요, 오늘은 반년정도 사용해오던 배포 CI를 뜯어고친 얘기를 해보려고 합니다.

저희 팀은 GitFlow에 가까운 브랜치 전략을 쓰고 있었어요. develop에서 기능을 개발하고, 배포할 때가 되면 release/x.x.x를 파서 버전 코드를 올리고 베이스라인 프로파일과 패치노트를 붙인 다음, main에 머지해서 Play Store로 내보내는 방식입니다. 그리고 마지막에 main을 develop으로 되돌려 반영하는 워크플로가 하나 있었어요.

그 마지막 워크플로가 계속 실패하고 있었고 배포과정 자체에 문제가 있는건 아니라서 흐린눈을 하고 있었습니다.

얼마나 실패했냐면

Sync Main to Develop 워크플로의 실행 이력을 뽑아봤어요. 2026년 1월 4일부터 8월 14일까지 21번 돌았는데요.

결과 횟수
failure 11
skipped 6
success 4

성공이 4건 있길래 "그래도 가끔은 됐네?" 싶었는데, 로그를 열어보니 가끔도 아니었어요.

git merge origin/main --no-ff -m "[CHORE] Sync main to develop [skip ci]"
git push origin develop
Already up to date.
Everything up-to-date

2026년 6월 30일 성공 건의 로그입니다. 반영할 게 없어서 성공한 거였어요.. 머지할 것도 없고 푸시할 것도 없으니 exit 0으로 끝난건데요. 나머지 성공 3건은 GitHub의 로그 보존 기간(90일)이 지나서 확인하지 못했어요.

정리하면 실제로 뭔가를 반영해야 했던 실행은 전부 실패했습니다.

1차 원인은 충돌이었어요

로그가 남아 있는 구간부터 봤어요.

CONFLICT (content): Merge conflict in app/src/release/generated/baselineProfiles/baseline-prof.txt
CONFLICT (content): Merge conflict in .../diarycard/DiaryCard.kt
CONFLICT (add/add): Merge conflict in .../diarycard/TtsController.kt
CONFLICT (add/add): Merge conflict in .../user/NicknameLocalValidation.kt

6월 4일 실행에서는 add/add 충돌만 9건, content 충돌 5건이 났어요.

여기서 add/add가 눈에 걸렸는데요. add/add 충돌은 양쪽에서 같은 경로에 파일을 각각 새로 추가했을 때 나는 충돌입니다. TtsController.kt는 제가 한 번 만든 파일인데, git이 보기엔 main과 develop이 서로 모르는 파일을 각자 만든 상태로 본거에요.

같은 작업이 양쪽에 따로 존재한다는 뜻이니 확인해봤습니다.

$ git show -s --format="%h %ad %s" --date=short 3a57d531 58722059
3a57d531 2026-08-05 [REF/#904] 서잇푸 업데이트 관련 모달 정리 (#905)
58722059 2026-08-05 [REF/#904] 서잇푸 업데이트 관련 모달 정리 (#905)

제목도 날짜도 같은데 해시가 달라요. 내용을 확인해봐야 하는데 내용까지 같은지는 patch-id로 확인할 수 있어요.

$ git show 3a57d531 | git patch-id --stable
63659dbc095f914b6fc81cfcb575921c31ac7007 3a57d531...
$ git show 58722059 | git patch-id --stable
63659dbc095f914b6fc81cfcb575921c31ac7007 58722059...

63659dbc...로 완전히 같아요. 같은 변경이 서로 다른 커밋으로 두 번 존재하고 있다는 뜻입니다.

원인 찾기 시작

왜 이런 일이 생겼을까?🤔 생각했을때는 두 가지가 겹쳤어요.

일단 릴리즈 산출물이 main에만 먼저 들어가는데요. 버전 코드, 베이스라인 프로파일, 패치노트는 release/x.x.x에서 만들어서 main으로 보내고 develop에는 나중에 되돌려 반영하니까, 이 시점에 이미 두 브랜치가 각자의 커밋을 갖게 되는 셈이에요.

그리고 develop 브랜치 룰셋을 squash 머지만 허용하게 해놨었어요.

{
  "type": "pull_request",
  "parameters": {
    "required_approving_review_count": 1,
    "allowed_merge_methods": ["squash"]
  }
}

squash 머지는 브랜치의 커밋들을 하나로 뭉쳐 새 커밋을 만들어서 원본 커밋들은 develop의 조상이 되지 못해요. 그래서 develop에서 자란 기능이 release 브랜치를 타고 main으로 가면, main 쪽 히스토리와 develop 쪽 히스토리에 같은 변경이 다른 정체로 남게 됩니다.

git은 두 브랜치를 머지할 때 공통 조상을 기준으로 양쪽 변경을 비교하는데요. 같은 파일을 양쪽이 "내가 새로 만들었다"고 주장하니 add/add 충돌이 나는 겁니다.

이걸 매번 사람이 손으로 풀고 있었어요. 실제로 저희 main 히스토리에는 이런 커밋이 남아 있어요.

aa8ffadb Merge remote-tracking branch 'origin/main' into release/2.7.0

릴리즈 브랜치에 main을 다시 머지해서 충돌을 푼 흔적입니다.

여기까지 파악하고 "그럼 충돌만 잘 풀면 되겠네" 싶었지만 7월 2일 이후 실패 로그를 보니 내용이 달라져 있었어요.

remote: - Changes must be made through a pull request.
! [remote rejected] develop -> develop (push declined due to repository rule violations)
error: failed to push some refs to 'https://github.com/Hi-lingual/Hilingual-Android'

충돌이 아니라 푸시가 거부되고 있었어요. 7월 2일, 8월 5일, 8월 14일 실패가 전부 이 원인이었습니다. 실행 시간도 9초에서 19초로 짧았는데, 머지는 성공하고 푸시에서 죽었기 때문이었어요.

시간순으로 정리하면 이렇게 됩니다.

날짜 결과 원인
2026-05-24 failure 머지 충돌
2026-06-04 failure 머지 충돌 (add/add 9건)
2026-06-25 failure 머지 충돌
2026-06-30 success 반영할 것 없음
2026-07-02 failure 푸시 거부
2026-08-05 failure 푸시 거부
2026-08-14 failure 푸시 거부

앞의 벽이 뒤의 벽을 가리고 있었던 거예요. 충돌 때문에 머지 단계에서 죽으니 푸시까지 가본 적이 없었고, 그래서 푸시가 막혀 있다는 사실 자체를 몰랐다가 충돌이 우연히 없는 날이 오고 나서야 두 번째 문제가 발생한거에요.

GitHub Actions를 bypass에 넣으면 되지 않나요?

저도 그렇게 생각해서 API로 넣어봤어요.

$ gh api --method PUT repos/Hi-lingual/Hilingual-Android/rulesets/6438974 \
    --input ruleset.json
{
  "message": "Validation Failed",
  "errors": ["Actor GitHub Actions integration must be part of the ruleset source or owner organization"],
  "status": "422"
}

하지만 거부당했습니다. Actions는 마켓플레이스에서 설치하는 앱이 아니고 GitHub에 내장된 기능이다 보니 불가능한 방법이었어요.

남은 선택지는 PAT나 Deploy Key를 시크릿으로 등록하는 거였는데요. 배포 파이프라인에 만료되는 크리덴셜을 하나 더 얹는 게 맞나? 라는 고민이 들었습니다. 토큰은 언젠가 만료되고, 만료된 날은 하필 배포하는 날일 수도 있겠네? 하는 생각이 들었어요.

그래서 방향을 뒤집었어요

그리고 develop은 룰셋으로 보호되어 있는데 main은 보호 규칙이 하나도 없었어요.

$ gh api repos/Hi-lingual/Hilingual-Android/branches/main/protection
{"message":"Branch not protected","status":"404"}

봇이 push하지 못하는 건 develop이지 main이 아니네? 그러면 방향을 뒤집어보자 했습니다.

릴리즈를 develop에 먼저 머지하고, 배포가 성공하면 main을 그 커밋으로 fast-forward 하는 겁니다. 이렇게 하면 main은 develop 히스토리 위의 한 지점을 가리키는 포인터가 되겠죠. 자기만의 커밋을 갖지 않으니 갈라질 커밋이 없고, 갈라질 게 없으니 충돌할 대상도 없고, 되돌려 반영하는 단계 자체가 사라집니다.

이 구조가 마음에 들었던 건 불변식이 git 수준에서 강제된다는 점이었어요. fast-forward push는 대상이 조상이 아니면 애초에 실패하니까, 잘못된 상태로 배포가 나가는 게 물리적으로 불가능해지니까요.

 

배포가 끝난 뒤에 main을 옮깁니다

여기서 순서를 하나 더 손봤어요. main을 옮기는 시점을 Play Store 업로드가 성공한 뒤로 뒀습니다.

publish:
  name: Promote main & publish release
  needs:
    - guard
    - deploy

이렇게 하면 배포가 실패했을 때 main이 그대로 남으니까, main은 언제나 Play Store에 실제로 올라간 커밋을 가리키게 됩니다. 롤백 기준점이 필요할 때 main만 보면 되는거에요.

빌드 대상도 브랜치 대신 머지 커밋의 해시로 고정했습니다.

deploy:
  uses: ./.github/workflows/deploy-release.yml
  with:
    ref: ${{ needs.guard.outputs.sha }}
  secrets: inherit

배포가 도는 10분 사이에 누가 develop에 다른 PR을 머지해도 섞여 들어가지 않아요.

그리고 하나 더. GITHUB_TOKEN으로 push한 이벤트는 다른 워크플로를 트리거하지 않아요. main에 push해서 배포를 시작하는 기존 방식을 그대로 뒤집었다면 여기서 막혔을 텐데요. 재사용 워크플로(workflow_call)로 바꿔서 같은 실행 안에서 잡을 이어붙이니 트리거 문제가 아예 사라졌어요. 덤으로 needs로 순서가 보장되고 한 화면에서 전부 관할 수 있어요.

실제로 돌려봤어요

설계가 그럴듯해 보여도 돌려보기 전까지는 모르니 2.7.1 릴리즈로 검증했어요.

잡 결과 소요 시간
Preflight success 5초
Deploy (bundleRelease + Play Store 업로드) success 9분 49초
Publish (main ff + 태그 + Release) success 10초

그리고 결과를 확인했습니다.

$ git rev-parse origin/main origin/develop
4829e9fff567d54fce39e1aa092a1fd99c664d88
4829e9fff567d54fce39e1aa092a1fd99c664d88

main과 develop이 같은 커밋을 가리키는게 확인됐는데요, 설계 그대로 main이 자기 커밋 없이 포인터로만 동작한다는 뜻이에요. v2.7.1 태그와 GitHub Release도 패치노트를 본문으로 자동 생성됐습니다.

Preflight는 빌드를 시작하기 전에 네 가지를 확인해요.

검사 막는 상황
head 브랜치가 release/로 시작하는가 일반 PR이면 전체 스킵
main이 머지 커밋의 조상인가 히스토리가 갈라진 경우
v{버전} 태그가 이미 있는가 버전을 올리지 않고 배포하려는 경우
패치노트가 비어 있지 않은가 패치노트를 빠뜨린 경우

10분짜리 빌드를 시작하기 전에 5초 만에 걸러내자는 의도예요. 실제로 일반 기능 PR을 머지했을 때 세 잡이 전부 skipped로 넘어가는 것도 확인했어요.

덤으로 챙긴 것

QA 빌드는 debug APK인데 스토어에 올라가는 건 R8이 적용된 release AAB로 코드가 다른데요. 예전에 메타 광고 어댑터 버전 때문에 minifyReleaseWithR8이 실패한 적이 있었는데 이런 문제는 debug QA로는 잡히지 않습니다.

그래서 릴리즈 PR에서만 bundleRelease를 돌리는 잡을 추가했어요.

release_build:
  name: Release Build Check
  if: ${{ github.event_name == 'pull_request' && startsWith(github.head_ref, 'release/') }}

2.7.1에서 9분 1초 걸려 통과했습니다. 일반 기능 PR에는 영향이 없고 릴리즈 PR에서만 머지 전에 R8 문제를 잡아주는 구조입니다.

 

 

이번 작업에서 제일 크게 남은 건 "충돌을 잘 푸는 방법"을 찾다가 "충돌이 날 이유를 없애는 쪽"으로 방향을 튼 지점이었어요.

처음엔 동기화 단계를 어떻게든 성공시키려고 했는데요. 그런데 그 단계가 왜 필요한지를 되짚어보니 릴리즈 산출물을 main에 먼저 넣는다는 생각때문이었어요. 그래서 그냥 방향을 바꿔봤습니다.

비슷하게 main과 develop을 오가는 백머지 때문에 고생하시는 분들께 도움이 되면 좋겠습니다. 긴 글 읽어 주셔서 감사합니다 🙇🏻‍♂️

'Android' 카테고리의 다른 글

GMA Next Gen SDK 운영기, 정식 버전도 못믿겠는데..  (0) 2026.08.21
GMA Next Gen SDK 도입기 feat. 공식 문서 안믿기  (3) 2026.03.30
Android 17 BETA, 공부 많이 된다  (0) 2026.02.24
Google Mobile Ads Next-Gen SDK, 왜 만든건데?  (0) 2026.02.09
BaseViewModel을 쓰지 않는 이유  (4) 2026.01.30
'Android' 카테고리의 다른 글
  • GMA Next Gen SDK 운영기, 정식 버전도 못믿겠는데..
  • GMA Next Gen SDK 도입기 feat. 공식 문서 안믿기
  • Android 17 BETA, 공부 많이 된다
  • Google Mobile Ads Next-Gen SDK, 왜 만든건데?
아키001
아키001
Android 개발자가 되기까지.
  • 아키001
    미래 가젯 연구소
    아키001
  • 전체
    오늘
    어제
    • 분류 전체보기 (41)
      • Android (15)
        • Compose (12)
        • Jetpack (2)
      • Kotlin (2)
      • 우아한테크코스 (10)
        • 일상, 회고 (0)
        • 레벨1 (2)
        • 레벨0 (4)
        • 프리코스 (4)
  • 블로그 메뉴

    • 홈
    • 안드로이드
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    coroutine
    Gradle
    레벨0
    jetpack
    gma
    compose
    Android
    Kotlin
    우테코
    AdMob
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
아키001
배포 자동화 뜯어 고치기
상단으로

티스토리툴바