Network & Server Factory

개인 공부 기록

DevOps/CI & CD

[GitHub] 브랜치, Pull Request, Webhook 정리

1nfra 2026. 9. 27. 14:55
CI/CD 파이프라인은 어떤 브랜치에 어떤 커밋이 올라왔는지에서 시작합니다. 브랜치 전략은 무엇을 언제 빌드할지 정하고, Pull Request와 브랜치 보호 규칙은 검증을 통과한 커밋만 main에 들어오게 하며, Webhook은 그 사건을 Jenkins 같은 CI 서버에 알립니다. 이 글은 세 가지의 동작 원리와 운영에서 자주 막히는 지점을 정리합니다.

1. CI/CD에서 저장소, 브랜치, 커밋이 하는 일

[CI/CD] CI/CD와 GitOps 개념 정리에서 파이프라인은 코드 변경을 main에 합치는 데서 시작한다고 정리했습니다. GitHub에서 변경은 커밋으로 기록되고, 브랜치는 특정 커밋을 가리키는 이름표일 뿐입니다. 그래서 CI가 실제로 빌드하는 대상은 브랜치가 아니라 그 순간 브랜치가 가리키던 커밋입니다. 같은 main이라도 5분 전과 지금은 다른 커밋일 수 있기 때문에, 저는 이 구분을 가장 중요하게 봅니다.

 

용어 CI/CD에서의 의미
커밋 빌드와 배포의 기준 단위, 40자리 SHA로 식별하며 이미지 태그에 자주 사용
브랜치 커밋을 가리키는 움직이는 이름, push할 때마다 가리키는 커밋이 바뀜
태그 한 커밋에 고정되는 이름, 릴리스 버전 표시에 사용
ref refs/heads/main, refs/tags/v1.0 처럼 브랜치와 태그의 전체 이름, push 이벤트 페이로드의 ref 값이 이 형태

 

"main을 배포했다"는 기록만으로는 어떤 코드가 나갔는지 알 수 없어서, 장애 때 되돌릴 기준을 찾기 어렵습니다. 그래서 저는 이미지 태그와 배포 기록에 커밋 SHA를 남깁니다.

2. 브랜치 전략 비교

브랜치 전략은 어떤 브랜치를 얼마나 오래 유지하고 어디로 합치는지에 대한 팀의 약속입니다.

 

전략 방식 잘 맞는 경우
GitHub flow main에서 짧은 브랜치를 만들고 PR로 병합 배포 대상이 하나인 서비스
Git flow main, develop을 유지하고 feature, release, hotfix 브랜치 운영 여러 버전을 동시에 지원하는 제품
트렁크 기반 main(트렁크)에 자주 통합, 브랜치는 아주 짧게 CI가 빠르고 테스트가 충분한 팀

 

Git flow를 제안한 Vincent Driessen은 2020년 원문에 덧붙인 글에서, 지속적으로 배포하는 웹 앱이라면 여러 버전을 지원할 필요가 없으니 GitHub flow 같은 단순한 흐름을 쓰라고 권합니다. Martin Fowler도 브랜치 패턴 글에서 하루치 이상의 작업을 통합하지 않은 채 두지 말라고 설명합니다. 브랜치가 오래 살수록 병합 충돌이 커지기 때문입니다.

 

저는 Jenkins와 ArgoCD로 서비스 하나를 배포한다면 GitHub flow를 권합니다. main 하나만 배포 기준으로 두면 "main에 들어온 커밋이 빌드되어 배포된다"는 규칙이 단순해지고, 브랜치를 짧게 유지하면 트렁크 기반에 가까워집니다. 개발, 운영 같은 환경 차이는 브랜치가 아니라 매니페스트 저장소의 디렉터리나 값 파일로 나눕니다. 환경 브랜치끼리 병합하면 어떤 변경이 어느 환경까지 갔는지 추적하기 어렵기 때문입니다. ArgoCD에서는 환경마다 Application을 두고 source.path로 해당 환경의 디렉터리를 가리키며, Application의 구조는 [ArgoCD] ArgoCD 동작 원리 정리에서 다룹니다.

3. Pull Request와 병합 방식

Pull Request(PR)는 head 브랜치(작업 브랜치)의 변경을 base 브랜치(보통 main)에 합쳐 달라는 요청입니다. CI는 빌드 결과를 상태 체크(status check)로 남기고, 리뷰어가 승인하면, 브랜치 보호 규칙이 조건을 확인한 뒤 병합을 허용합니다.

CI 결과와 리뷰 승인이 모두 Branch Protection 조건을 채워야 main에 병합할 수 있습니다

 

GitHub은 세 가지 병합 방식을 제공하고, 저장소 설정에서 허용할 방식을 고릅니다.

 

방식 결과 주의할 점
병합 커밋 Merge commit, PR의 커밋을 모두 남기고 병합 커밋 추가(--no-ff) 되돌릴 때 부모를 지정해야 함
스쿼시 Squash, PR의 커밋을 하나로 합쳐 추가 같은 브랜치로 다음 PR을 열면 합친 커밋이 다시 나타남
리베이스 Rebase, 병합 커밋 없이 커밋을 base 위에 다시 씀 GitHub은 항상 새 SHA를 만듦

 

로컬 저장소에서 같은 feature 브랜치(커밋 3개)를 두 방식으로 합쳐 보았습니다. 커밋 메시지는 GitHub 형식을 흉내 낸 예시입니다.

#!/usr/bin/env bash
# 같은 feature 브랜치를 merge commit과 squash 두 방식으로 합쳐 본다
set -e
export GIT_AUTHOR_NAME=dev GIT_AUTHOR_EMAIL=dev@example.com
export GIT_COMMITTER_NAME=dev GIT_COMMITTER_EMAIL=dev@example.com
export GIT_AUTHOR_DATE="2026-01-01T09:00:00" GIT_COMMITTER_DATE="2026-01-01T09:00:00"
rm -rf /srv/github-lab/app && mkdir -p /srv/github-lab/app && cd /srv/github-lab/app
git init -q -b main
echo "v1" > app.txt && git add . && git commit -qm "init"
git switch -qc feature/login
for m in "add login form" "fix typo" "apply review"; do
  echo "$m" >> app.txt && git commit -qam "$m"
done
git switch -q main
echo "readme" > README.md && git add . && git commit -qm "docs: readme"

# 1) Merge commit (GitHub 기본 Merge pull request와 같은 --no-ff)
git switch -qc merge-demo main
git merge -q --no-ff feature/login -m "Merge pull request #1 from feature/login"
echo "== merge commit =="; git log --graph --oneline merge-demo

# 2) Squash (브랜치의 커밋 3개를 한 커밋으로)
git switch -qc squash-demo main
git merge -q --squash feature/login > /dev/null 2>&1
git commit -qm "Add login form (#1)"
echo "== squash =="; git log --graph --oneline squash-demo
== merge commit ==
*   d3dc258 Merge pull request #1 from feature/login
|\  
| * 447750f apply review
| * a06d525 fix typo
| * 7a16352 add login form
* | 7d56c19 docs: readme
|/  
* e536fdf init
== squash ==
* e0df839 Add login form (#1)
* 7d56c19 docs: readme
* e536fdf init

 

Merge commit은 모든 커밋과 갈라졌다 합쳐진 모양이 남고, Squash는 main에 커밋 하나만 추가됩니다. 되돌릴 때도 차이가 납니다.

#!/usr/bin/env bash
# 배포된 변경을 되돌릴 때 merge commit과 squash commit의 차이
set -e
export GIT_AUTHOR_NAME=dev GIT_AUTHOR_EMAIL=dev@example.com
export GIT_COMMITTER_NAME=dev GIT_COMMITTER_EMAIL=dev@example.com
export GIT_AUTHOR_DATE="2026-01-01T10:00:00" GIT_COMMITTER_DATE="2026-01-01T10:00:00"
cd /srv/github-lab/app

git switch -q merge-demo
echo "== merge commit을 부모 지정 없이 revert =="
git revert --no-edit HEAD 2>&1 | sed -E 's/([0-9a-f]{7})[0-9a-f]{33}/\1/'
echo "== -m 1로 첫 번째 부모(main) 기준 revert =="
git revert --no-edit -m 1 HEAD | head -1

git switch -q squash-demo
echo "== squash commit revert =="
git revert --no-edit HEAD | head -1
== merge commit을 부모 지정 없이 revert ==
error: commit d3dc258 is a merge but no -m option was given.
fatal: revert failed
== -m 1로 첫 번째 부모(main) 기준 revert ==
[merge-demo ca2f0f0] Revert "Merge pull request #1 from feature/login"
== squash commit revert ==
[squash-demo 81dd92a] Revert "Add login form (#1)"

 

병합 커밋은 부모가 둘이라 -m 1로 기준을 정해야 합니다. 또 Git 문서는 병합 커밋을 되돌리면 그 변경을 앞으로 원하지 않는다고 선언하는 셈이라, 같은 브랜치를 다시 병합해도 되돌린 변경은 돌아오지 않는다고 설명합니다. Squash 커밋은 평범한 커밋이라 그대로 되돌리면 됩니다.

 

저는 GitHub flow라면 Squash를 기본으로 둡니다. PR 하나가 main의 커밋 하나, 이미지 태그 하나가 되어 배포와 롤백 단위가 일치하기 때문입니다. 어떤 방식이든 main에 생기는 커밋은 PR에서 빌드한 커밋과 SHA가 다르므로, 배포용 이미지는 main에 push된 커밋으로 다시 빌드합니다.

4. 브랜치 보호 규칙과 Rulesets

브랜치를 보호하는 방법은 예전부터 있던 브랜치 보호 규칙(branch protection rule)과, 여러 규칙을 이름 붙여 묶는 Rulesets 두 가지입니다. 둘 다 Free 플랜은 공개 저장소에서만, Pro, Team, Enterprise 플랜은 비공개 저장소에서도 쓸 수 있습니다. CI/CD에서 주로 켜는 항목은 아래와 같습니다(이름은 Rulesets 기준).

 

항목 규칙 이름과 동작
PR 필수 Require a pull request before merging, 보호 브랜치에 직접 push 금지
승인 수 PR 필수 규칙의 추가 설정, 쓰기 권한이 있는 리뷰어의 승인 수
승인 무효화 Dismiss stale pull request approvals, 승인 뒤 diff가 바뀌면 승인 취소
필수 체크 Require status checks to pass before merging, 지정한 체크가 successful, skipped, neutral이어야 병합
최신 상태 Require branches to be up to date before merging, base의 최신 커밋 기준으로 다시 검증
선형 이력 Require linear history, 병합 커밋 금지(Squash나 Rebase만 허용)
강제 push Block force pushes, Rulesets에서 기본으로 켜져 있고 보호 규칙도 기본으로 막음

 

필수 체크는 이름(context)으로 지정합니다. Jenkins GitHub Branch Source 플러그인은 기본으로 continuous-integration/jenkins/pr-merge(base와 합친 결과 빌드), pr-head(PR 커밋 그대로 빌드), branch(일반 브랜치 빌드) 같은 이름으로 결과를 보고합니다. 지정한 이름의 체크가 보고되지 않으면 통과로 보지 않으므로, Jenkins에서 PR 빌드 방식을 바꾸면 모든 PR의 병합이 막힐 수 있습니다. 그래서 저는 필수 체크 이름을 Jenkins 설정과 함께 관리합니다. 쓰기 권한이 있으면 누구나 체크 값을 남길 수 있으므로, 가능하면 체크를 보고할 GitHub App도 출처로 지정합니다.

 

"최신 상태" 옵션(strict)은 GitHub 문서가 필수 체크의 기본 동작으로 설명합니다. 다른 PR이 먼저 병합되면 내 PR도 다시 빌드해야 해서 빌드가 늘지만, 끄면 각각 통과한 두 PR이 합쳐진 뒤 main이 깨질 수 있습니다. PR이 많은 저장소라면 merge queue(병합 대기열, 대기 중인 PR을 최신 base와 합친 상태로 필수 체크를 돌린 뒤 차례로 병합)를 검토할 만합니다. 이때 CI는 merge_group 이벤트에도 체크를 보고해야 합니다.

 

보호 규칙은 기본적으로 저장소 관리자에게 적용되지 않습니다(Do not allow bypassing the above settings로 변경). 또 한 브랜치에 하나만 적용되지만, Rulesets는 여러 개가 함께 적용되어 가장 엄격한 값을 따르고, 읽기 권한만 있어도 걸린 규칙을 볼 수 있습니다. 그래서 저는 새로 설정한다면 병합이 막힌 이유를 개발자가 직접 확인할 수 있는 Rulesets를 씁니다.

5. Webhook 이벤트와 페이로드

Webhook은 GitHub에서 이벤트가 생기면 지정한 URL로 HTTP POST를 보내는 기능입니다. CI 서버가 주기적으로 저장소를 확인하는 폴링과 달리 변경 즉시 알림을 받으므로, 빌드가 빨리 시작되고 API 호출 한도도 덜 씁니다.

 

이벤트 발생 시점과 CI에서의 쓰임
push 브랜치나 태그에 push할 때, 삭제할 때도 발생, main 빌드의 시작점
PR pull_request 이벤트, opened, synchronize(head 갱신), reopened, closed 등, PR 빌드의 시작점
ping Webhook을 새로 만들 때 설정 확인용으로 전송

 

push 페이로드에서 CI가 보는 값은 ref(예: refs/heads/main), before와 after(push 전후 SHA), forced(강제 push), deleted(삭제), commits(커밋 목록)입니다. pull_request의 closed는 병합과 단순 닫기를 모두 포함하므로 merged 값을 따로 봐야 합니다. 이 두 값을 보지 않으면 브랜치를 삭제한 push나 병합 없이 닫힌 PR에서도 빌드나 배포가 돌 수 있습니다.

 

GitHub 문서에 적힌 한계도 있습니다. 페이로드가 25MB를 넘으면 전송되지 않고, 한 번에 브랜치 5000개 초과 또는 태그 3개 초과를 push하면 push 이벤트가 만들어지지 않으며, commits는 최대 2048개까지만 담깁니다. 태그 여러 개를 한꺼번에 push하고 릴리스 빌드를 기대하면 빌드가 시작되지 않을 수 있습니다.

6. Webhook 전송과 서명 확인

Webhook URL은 외부에 열려 있어 누구나 가짜 요청을 보낼 수 있습니다. 그래서 Webhook에 secret을 설정하면 GitHub은 요청 본문을 secret으로 HMAC-SHA256(secret을 키로 쓰는 SHA-256 메시지 인증 코드) 계산한 값을 X-Hub-Signature-256 헤더에 담고, 받는 쪽은 같은 secret으로 다시 계산해 비교합니다. secret을 모르면 올바른 서명을 만들 수 없고, 본문이 한 글자만 바뀌어도 값이 달라집니다.

수신 서버는 서명을 먼저 확인하고, 작업은 큐에 넣은 뒤 바로 응답합니다

 

GitHub 문서는 구현 확인용 테스트 값(secret, 본문, 기대 서명)을 공개합니다. openssl로 계산하면 문서 값과 같습니다.

#!/usr/bin/env bash
# GitHub 문서의 테스트 값으로 X-Hub-Signature-256 계산
SECRET="It's a Secret to Everybody"
printf '%s' 'Hello, World!' \
  | openssl dgst -sha256 -hmac "$SECRET" -r \
  | awk '{print "sha256=" $1}'
sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

 

이번에는 push 페이로드 예시로 서명을 만들고, 본문이나 secret이 다를 때 실패하는지 확인했습니다. 비교는 문서 권고대로 == 대신 상수 시간 비교를 씁니다.

#!/usr/bin/env python3
# 받은 요청 본문(raw bytes)으로 서명을 다시 계산해 헤더 값과 비교
import hashlib, hmac, json

SECRET = b"example-webhook-secret"   # 실제로는 길고 무작위인 값


def sign(body: bytes, secret: bytes = SECRET) -> str:
    return "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()


def verify(body: bytes, header: str) -> bool:
    return hmac.compare_digest(sign(body), header)   # == 대신 상수 시간 비교


body = open("payload.json", "rb").read()
header = sign(body)                  # GitHub이 보냈다고 가정한 헤더 값
print("X-Hub-Signature-256:", header[:23] + "...")

cases = {
    "original body": body,
    "ref changed": body.replace(b"refs/heads/main", b"refs/heads/prod"),
    "re-serialized JSON": json.dumps(json.loads(body), indent=2).encode(),
}
for name, b in cases.items():
    print(f"{name:20} -> {'OK' if verify(b, header) else 'FAIL'}")
wrong = sign(body, b"old-secret")
print(f"{'wrong secret':20} -> {'OK' if verify(body, wrong) else 'FAIL'}")
X-Hub-Signature-256: sha256=9045643e5ea530e9...
original body        -> OK
ref changed          -> FAIL
re-serialized JSON   -> FAIL
wrong secret         -> FAIL

 

눈여겨볼 것은 re-serialized JSON입니다. 내용이 같아도 JSON을 파싱했다가 다시 만들면 공백이 달라져 서명이 맞지 않습니다. 그래서 서명은 프레임워크가 파싱하기 전, 받은 그대로의 바이트로 계산해야 합니다.

 

전송 규칙도 알아 둬야 합니다. GitHub.com은 10초 안에 2xx 응답이 없으면 실패로 기록하고, 실패한 전송을 자동으로 다시 보내지 않습니다. 지난 3일 안의 전송은 화면이나 REST API로 재전송할 수 있고, 이때 X-GitHub-Delivery 값은 처음과 같습니다. 그래서 수신 서버는 작업을 큐로 넘기고 바로 응답하며, 같은 GUID를 두 번 처리하지 않아야 합니다. 이 동작을 로컬 수신기로 재현했습니다.

#!/usr/bin/env python3
# 실습용 수신기: 서명 확인 -> 중복 확인 -> 빠르게 응답
import hashlib, hmac, json
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = b"example-webhook-secret"
seen = set()                                   # 처리한 X-GitHub-Delivery


class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"]))
        sig = self.headers.get("X-Hub-Signature-256", "")
        exp = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(exp, sig):
            return self.reply(401, "bad signature")
        guid = self.headers.get("X-GitHub-Delivery")
        if guid in seen:
            return self.reply(200, "duplicate, skipped")
        seen.add(guid)
        event = self.headers.get("X-GitHub-Event")
        ref = json.loads(body).get("ref")
        self.reply(202, f"queued {event} {ref}")  # 실제 작업은 큐에서

    def reply(self, code, msg):
        self.send_response(code); self.end_headers()
        self.wfile.write((msg + "\n").encode())

    def log_message(self, *a):
        pass


HTTPServer(("127.0.0.1", 8088), Hook).serve_forever()
#!/usr/bin/env bash
# GitHub이 보내는 것과 같은 헤더로 로컬 수신기에 POST
python3 receiver.py & PID=$!; sleep 1
HEX=$(openssl dgst -sha256 -hmac "example-webhook-secret" -r payload.json)
SIG="sha256=${HEX%% *}"
send() {  # $1=GUID $2=본문 파일
  curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:8088/github-webhook/ \
    -H "Content-Type: application/json" -H "X-GitHub-Event: push" \
    -H "X-GitHub-Delivery: $1" -H "X-Hub-Signature-256: $SIG" \
    --data-binary @"$2"
}
sed 's#refs/heads/main#refs/heads/prod#' payload.json > tampered.json
echo "== 1) 정상 전송";        send 1111-aaaa payload.json
echo "== 2) 같은 GUID 재전송"; send 1111-aaaa payload.json
echo "== 3) 본문 변조";        send 2222-bbbb tampered.json
kill $PID
== 1) 정상 전송
queued push refs/heads/main
HTTP 202
== 2) 같은 GUID 재전송
duplicate, skipped
HTTP 200
== 3) 본문 변조
bad signature
HTTP 401

 

Jenkins에서는 이 로직을 직접 만들 필요가 없습니다. Jenkins GitHub 플러그인 소스를 보면 shared secret을 설정한 경우 /github-webhook/으로 들어온 요청의 서명을 SHA-256 기준으로 검증하고, secret이 없으면 서명을 무시합니다. 그래서 저는 Webhook을 만들 때 secret부터 넣습니다.

7. CI 도구의 GitHub 인증 방식

반대 방향으로 CI 서버가 코드를 clone하고, 상태 체크를 남기고, 매니페스트 저장소에 이미지 태그를 push하려면 GitHub에 인증해야 합니다.

 

방식 특징 운영상 주의
개인 토큰 Fine-grained PAT(personal access token), 사용자에 묶인 토큰, 저장소와 권한을 좁게 지정 만든 사람이 권한을 잃으면 파이프라인이 멈춤
GitHub App 사용자와 무관한 앱, 설치한 저장소에만 권한, 설치 토큰은 1시간 뒤 만료 앱 개인 키 보관 필요, 설정 단계가 많음
배포 키 Deploy key, 저장소 하나에 붙는 SSH 키, 기본은 읽기 전용 만료가 없고, 쓰기 권한을 주면 관리자 수준 동작 가능(GitHub 문서)

 

GitHub 문서는 오래 유지되는 연동에는 GitHub App을, PAT는 API 테스트나 짧은 스크립트에 권합니다. 저도 Jenkins처럼 계속 돌아가는 CI 서버에는 GitHub App을 권합니다. 사람 계정에 묶이지 않아 담당자가 바뀌어도 멈추지 않고, 토큰이 1시간이면 만료되어 유출 피해가 짧으며, 설치 토큰의 API 한도가 저장소 수와 조직 사용자 수에 따라 늘어나기 때문입니다. Jenkins GitHub Branch Source 플러그인 문서도 API 한도와 최소 권한을 이유로 GitHub App 인증을 안내합니다.

 

ArgoCD가 매니페스트 저장소 하나를 읽기만 한다면 읽기 전용 Deploy key로 충분합니다. 권한이 저장소 하나의 읽기로 좁기 때문입니다. 반면 CI가 매니페스트 저장소에 이미지 태그 변경을 push해야 한다면, 만료가 없고 권한이 넓은 쓰기 Deploy key보다 Contents 쓰기 권한만 준 GitHub App을 권합니다. Jenkins에서 인증 정보를 Credentials로 다루는 방식은 [Jenkins] Jenkins 구조와 파이프라인 정리에서 이어집니다.

8. 정리

  • CI/CD가 빌드하는 대상은 브랜치가 아니라 그 순간의 커밋이므로 배포 기록과 이미지 태그에 커밋 SHA를 남기고, 배포 대상이 하나인 서비스는 짧은 브랜치의 GitHub flow에 Squash 병합으로 배포와 롤백 단위를 맞춥니다.
  • 필수 체크는 이름으로 매칭되므로 Jenkins의 체크 이름과 보호 규칙을 함께 관리하고, 새로 설정한다면 여러 규칙이 함께 적용되고 읽기 권한으로도 확인할 수 있는 Rulesets를 씁니다.
  • Webhook은 secret과 X-Hub-Signature-256으로 원본 바이트를 검증하고 10초 안에 응답하며, 오래 유지되는 CI 연동은 GitHub App, 매니페스트 읽기 전용은 Deploy key로 인증합니다.

 

다음 글인 [Jenkins] Jenkins 구조와 파이프라인 정리에서는 이 Webhook을 받아 빌드를 실행하는 Jenkins의 구조를 정리합니다. 브랜치 보호 규칙, Webhook, GitHub App을 실제 저장소에 설정하는 과정은 이후 활용 글에서, Webhook으로 Jenkins 빌드를 시작하는 연결은 연동 글에서 다룹니다.

참고

728x90
서울
--:--:--
-전체 글
-카테고리
오늘 방문