CI/CD는 GitHub에 코드가 올라오는 순간 시작합니다. 이 글에서는 실무에서 많이 쓰는 GitHub flow로 브랜치를 만들고, Pull Request를 어떤 방식으로 합칠지, main을 어떻게 보호할지, Jenkins가 변경을 알 수 있게 Webhook을 어떻게 설정하는지를 로컬 실습과 함께 정리합니다.
1. CI/CD에서 GitHub가 하는 일
[CI/CD] CI/CD와 GitOps 개념 정리에서 정리한 흐름의 첫 단계가 GitHub입니다. 개발자가 작업 브랜치를 push하고 Pull Request(PR)를 열면, CI가 빌드와 테스트를 돌려 결과를 PR에 남기고, 리뷰 승인까지 받으면 main에 합칩니다. main이 바뀌면 GitHub가 Webhook으로 CI 서버에 알려 배포용 빌드가 시작됩니다.
| 용어 | 뜻 |
| 커밋 SHA | 커밋마다 붙는 고유 값, 빌드와 배포의 기준이 되고 이미지 태그로도 씀 |
| 브랜치 | 특정 커밋을 가리키는 이름, 커밋할 때마다 가리키는 곳이 앞으로 이동 |
| Pull Request | 작업 브랜치(head)를 main(base)에 합쳐 달라는 요청, 리뷰와 CI 결과가 여기 모임 |
| 상태 체크 | CI가 PR 커밋에 남기는 성공, 실패 표시, status check |
| Webhook | push, PR 같은 이벤트가 생기면 GitHub가 지정한 URL로 보내는 HTTP 요청 |

PR은 CI 체크 성공과 리뷰 승인이 브랜치 보호 조건을 모두 채워야 main에 병합할 수 있습니다
2. 실습 환경
GitHub 계정 대신 같은 서버의 bare 저장소를 원격 저장소(origin)로 두고 Git 2.43으로 실습했습니다. Webhook은 GitHub가 보내는 것과 같은 헤더를 만들어 로컬 수신기로 보냈습니다. GitHub 화면에서 하는 설정은 실제 메뉴 이름으로 설명합니다.
3. 브랜치 만들고 push하기
GitHub flow는 규칙이 두 가지뿐입니다. main은 항상 배포할 수 있는 상태로 두고, 모든 작업은 main에서 만든 짧은 브랜치에서 한 뒤 PR로 합칩니다. 배포 대상이 하나인 서비스라면 이 방식이 가장 단순해서 저는 이것부터 권합니다.
#!/usr/bin/env bash
# GitHub flow: main에서 짧은 작업 브랜치를 만들어 push하고 PR로 합친다
set -e
export GIT_AUTHOR_NAME=dev-lee GIT_AUTHOR_EMAIL=dev@example.com
export GIT_COMMITTER_NAME=dev-lee GIT_COMMITTER_EMAIL=dev@example.com
rm -rf /srv/github-lab2/origin.git /srv/github-lab2/app
git init -q --bare /srv/github-lab2/origin.git # GitHub 저장소 역할
git clone -q /srv/github-lab2/origin.git /srv/github-lab2/app 2>/dev/null
cd /srv/github-lab2/app
echo "v1" > app.txt && git add . && git commit -qm "init" && git push -q origin main
# 1) 최신 main에서 작업 브랜치 만들기
git switch -c feature/login
# 2) 작은 단위로 커밋
echo "login form" >> app.txt && git commit -qam "add login form"
echo "validation" >> app.txt && git commit -qam "add input validation"
# 3) 원격에 push, GitHub라면 이 브랜치로 PR을 연다
git push -u origin feature/login
git branch -a
git log --oneline --graph --all
Switched to a new branch 'feature/login'
To /srv/github-lab2/origin.git
* [new branch] feature/login -> feature/login
branch 'feature/login' set up to track 'origin/feature/login'.
* feature/login
main
remotes/origin/feature/login
remotes/origin/main
* d6454d6 add input validation
* 5bdba78 add login form
* 5d6ee7a init
feature/login 브랜치가 원격에도 생겼고, main 위에 커밋 두 개가 쌓였습니다. 실제 GitHub라면 push 직후 저장소 화면에 Compare & pull request 버튼이 나오고, 이걸 눌러 base를 main, compare를 feature/login으로 PR을 엽니다. 브랜치 이름은 feature/, fix/처럼 목적을 앞에 붙이면 목록에서 찾기 쉽습니다.
4. PR 병합 방식 고르기
PR 화면의 Merge 버튼은 세 가지 방식을 고를 수 있고, 저장소 Settings의 General에서 허용할 방식을 정합니다.
| 방식 | main에 남는 것 |
| Create a merge commit | PR의 커밋 전부와 병합 커밋 하나 |
| Squash and merge | PR의 커밋을 하나로 합친 커밋 하나 |
| Rebase and merge | PR의 커밋을 main 위에 다시 쌓은 커밋들, 병합 커밋 없음 |
같은 feature 브랜치(커밋 3개)를 merge commit과 squash 두 방식으로 합쳐 보았습니다. 커밋 메시지는 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은 갈라졌다 합쳐진 모양과 작업 중 커밋(fix typo 같은)이 모두 남고, 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로 어느 쪽 기준으로 되돌릴지 정해야 하고, squash 커밋은 평범한 커밋이라 그대로 되돌려집니다. 그래서 저는 GitHub flow에서는 Squash and merge를 기본으로 둡니다. PR 하나가 main의 커밋 하나, 이미지 태그 하나가 되어 배포와 롤백 단위가 맞기 때문입니다.
5. main 보호하기
main에 아무나 직접 push하거나 CI가 실패한 PR을 합치면 GitHub flow의 전제가 깨집니다. 그래서 저장소 Settings의 Branches에서 main에 브랜치 보호 규칙을 겁니다(더 새로운 방식인 Settings의 Rules, Rulesets에서도 같은 규칙을 걸 수 있습니다). 처음에는 아래 네 가지만 켜도 충분합니다.
| 설정 | 효과 |
| Require a pull request before merging | main 직접 push 금지, Required approvals로 승인 수 지정 |
| Require status checks to pass before merging | 지정한 CI 체크가 성공해야 Merge 버튼이 열림 |
| Require branches to be up to date before merging | 최신 main 기준으로 다시 검증된 PR만 병합 |
| Block force pushes | main 이력을 강제로 덮어쓰지 못하게 함 |
필수 체크는 체크 이름으로 지정합니다. 그래서 CI를 먼저 연결해 PR에 체크가 한 번 찍힌 뒤, 그 이름을 그대로 골라 넣어야 합니다. 참고로 GitHub 문서에 따르면 Free 플랜에서는 공개 저장소에만 이 기능을 쓸 수 있습니다.
6. Webhook 설정
6.1 GitHub 설정
저장소 Settings의 Webhooks에서 Add webhook을 누르고 아래처럼 넣습니다. Jenkins GitHub 플러그인을 쓴다면 받는 주소는 Jenkins 주소 뒤에 /github-webhook/을 붙인 값입니다.
| 항목 | 넣는 값 |
| Payload URL | https://jenkins.example.com/github-webhook/ |
| Content type | application/json |
| Secret | 길고 무작위인 문자열, 받는 쪽에도 같은 값을 설정 |
| 이벤트 | Let me select individual events에서 Pushes와 Pull requests |
Secret을 넣으면 GitHub는 요청 본문을 이 값으로 HMAC-SHA256 계산해 X-Hub-Signature-256 헤더에 담아 보냅니다. 받는 쪽은 같은 계산을 해서 값이 맞을 때만 처리합니다. Webhook 주소는 외부에 열려 있어서 누구나 가짜 요청을 보낼 수 있기 때문입니다.

수신 서버는 서명을 먼저 확인하고, 작업은 큐에 넣은 뒤 바로 응답합니다
6.2 수신 서버 동작 확인
실습용 수신기는 서명 확인, 같은 요청 중복 확인, 빠른 응답 세 가지만 합니다. GitHub.com은 10초 안에 2xx 응답이 없으면 실패로 기록하므로 실제 작업은 큐에 넘기고 먼저 응답합니다.
#!/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()
여기에 GitHub와 같은 헤더(X-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256)를 붙여 요청을 보냈습니다.
#!/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
정상 요청은 202로 받아 큐에 넣었고, 같은 X-GitHub-Delivery 값으로 다시 온 요청은 건너뛰었으며, 본문을 바꾼 요청은 서명이 맞지 않아 401로 거부했습니다. GitHub의 Webhook 화면에는 Recent Deliveries 탭이 있어서 요청과 응답을 확인하고 Redeliver로 다시 보낼 수 있습니다. 이렇게 재전송된 요청도 같은 GUID를 쓰므로 중복 확인을 해 두면 두 번 빌드되지 않습니다.
7. 자주 막히는 지점
Webhook 서명이 자꾸 틀린다면 대부분 본문을 다시 만들어서 계산한 경우입니다. 같은 내용이어도 JSON을 파싱했다가 다시 문자열로 만들면 공백이 달라져 서명이 맞지 않습니다.
#!/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이 FAIL인 것이 핵심입니다. 서명은 프레임워크가 파싱하기 전, 받은 그대로의 바이트로 계산해야 합니다.
| 증상 | 원인 | 확인과 해결 |
| Merge 버튼이 안 열림 | 필수 체크 이름과 CI가 남긴 체크 이름이 다름, 체크가 아직 안 옴 | PR의 체크 목록에서 이름 확인 후 보호 규칙에 같은 이름 지정 |
| Webhook 전송 실패 | 10초 안에 응답 없음, 주소 오타, 방화벽 | Recent Deliveries에서 응답 코드 확인, 작업은 큐로 넘기고 바로 응답 |
| 서명 불일치 | Secret 불일치, 파싱한 본문으로 계산 | 양쪽 Secret 확인, 원본 바이트로 계산 |
| 같은 커밋이 두 번 빌드됨 | 재전송이나 중복 이벤트 | X-GitHub-Delivery 값으로 중복 확인 |
| merge commit revert 실패 | 부모가 둘이라 기준이 없음 | git revert -m 1 사용, 또는 Squash 병합 사용 |
8. 정리
- main은 항상 배포 가능하게 두고, 작업은 짧은 브랜치에서 한 뒤 PR로 합치며, 병합은 배포와 롤백 단위가 맞는 Squash and merge를 기본으로 둡니다.
- main에는 PR 필수, 필수 체크, 최신 상태 확인, 강제 push 금지를 걸고, 필수 체크 이름은 CI가 실제로 남긴 이름과 똑같이 맞춥니다.
- Webhook은 Secret으로 서명을 확인하고 10초 안에 응답하며, 원본 바이트로 서명을 계산하고 X-GitHub-Delivery로 중복을 걸러 냅니다.
이 Webhook을 받아 빌드를 실행하는 쪽은 [Jenkins] Jenkins 설치와 사용법 정리에서 이어집니다.
참고
'DevOps > CI & CD' 카테고리의 다른 글
| [CI/CD] GitHub, Jenkins, ArgoCD로 dev/prd 배포 아키텍처 설계 (0) | 2026.09.27 |
|---|---|
| [ArgoCD] ArgoCD 설치와 사용법 정리 (0) | 2026.09.27 |
| [Jenkins] Jenkins 설치와 사용법 정리 (0) | 2026.09.27 |
| [CI/CD] CI/CD와 GitOps 개념 정리 (0) | 2026.09.27 |