- Oracle
- 오라클
- 인공지능기초다지기
- 코딩테스트 python
- 코딩테스트
- 네이버부스트캠프
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- SQL
- DB
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 파이썬
- python
- Django
- unsub
- 프로그래머스
- 기초다지기
- boostcourse
- 부스트코스
- 데이터베이스
- Ai
- boostcoures
- 정보처리기사
- 이기적
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- PY4E
- r
- 빅데이터분석기사
- AI 플랫폼을 활용한 데이터 분석
- 빅분기
- Today
- Total
매일공부
[Git] dev/prod 배포 구조가 비대칭이었던 걸 온보딩 문서 쓰다가 발견한 이야기 본문
배경
온보딩 문서를 정리하다가 "이 Git 배포 구조, 나는 아니까 괜찮은데 다른 개발자가 합류하거나 나중에 내가 잊어버리고 다시 보면 헷갈리겠다" 싶은 부분을 발견했다. dev와 prod의 배포 구조가 겉보기엔 대칭인 것 같았는데, 실제로는 비대칭이었던 것.
원래 이해하고 있던 구조 (용어 하나만 착각했던 부분)
로컬에서 두 개의 리모트로 push하는 흐름 자체는 원래 알고 있었다.
git remote -v
dev <nas-host>:/volume1/docker/django/<project>-dev/<project>
origin <nas-host>:/volume1/docker/git_repos/<project>-prod.git
이해하고 있던 내용:
- 커밋을 하면 개발 환경에 저장된다
- git push dev를 하면 dev 코드에만 push된다
- 실제 prod bare 저장소(<project>-prod.git)에 반영되는 건 운영서버로 push(git push origin)했을 때뿐이다
이 부분들은 다 맞았다. 다만 두 가지가 부정확했다.
- 커밋이 저장되는 곳을 "워크트리"라고 부르고 있었는데, 이건 착각이었다. 실제로는 그냥 일반 로컬 저장소(.git)에 커밋이 영구 저장되는 것뿐이었고, "워크트리"라는 이름이 붙는 곳은 따로 있었다 — CI 훅이 검사용으로 커밋을 잠깐 풀어놓는 임시 디렉터리(_ci_worktrees/<커밋해시>)가 그거였다.
- 운영서버로 push했을 때 훅이 "개발-운영 코드를 merge해준다"고 생각했는데, 실제로 훅 코드에 git merge --ff-only가 있는 걸 보면 "merge"라고 부른 게 틀린 건 아니었다. 다만 여기서 쓰는 건 두 브랜치를 진짜로 합치는 일반 merge가 아니라, --ff-only 옵션이 붙은 fast-forward 전용 merge였다 — 히스토리가 갈라져 있지 않을 때만 허용되고, 그냥 포인터를 앞으로 옮기는 것에 가깝다. 갈라져 있으면 병합을 시도하지 않고 그냥 실패한다. 그러니까 "개발-운영 병합"이라기보다는 "prod bare 저장소 → prod 라이브 디렉터리가, 갈라지지 않았다는 전제 하에 따라잡는 것"이었다.
실제 구조를 하나씩 확인해봤다.
실제 구조 확인
dev — 중앙 bare 저장소 없이, 라이브 워킹트리에 직접 push
<project>-dev/<project> 폴더 자체가 non-bare 저장소이자 동시에 dev 서버가 서빙하는 라이브 워킹트리다. 즉 "dev 전용 중앙 저장소"가 따로 있고 거기서 이 폴더로 다시 반영해주는 구조가 아니라, 이 폴더의 .git이 push를 받는 대상 그 자체다.
push를 받을 때 pre-receive 훅이 동작해서:
- push된 커밋을 git archive로 뽑아서 임시 디렉터리(_ci_worktrees/<커밋해시>)에 풀어놓는다
- 그 안에서 Docker 컨테이너를 통해 pytest, mypy를 실행한다
- 둘 다 통과해야만 push가 실제로 받아들여지고(ref 갱신), 실패하면 push 자체가 거부된다
#!/bin/sh
# scripts/pre-receive (요지만 발췌)
while read OLD_SHA NEW_SHA REF; do
git archive --format=tar -o "$WT_DIR.tar" "$NEW_SHA"
tar -xf "$WT_DIR.tar" -C "$WT_DIR"
docker exec -w "$CONTAINER_WT_DIR" "$CONTAINER" python -m pytest -q --tb=short || FAILED=1
docker exec -w "$CONTAINER_WT_DIR" "$CONTAINER" mypy apps/ --ignore-missing-imports || FAILED=1
done
[ "$FAILED" -ne 0 ] && exit 1
exit 0
git worktree add는 pre-receive 도중에 ref 갱신이 금지된 상태라 쓸 수 없어서(quarantine 상태), ref를 건드리지 않는 git archive로 트리 내용만 뽑아내는 방식을 쓴 것도 포인트였다.
prod — bare 중앙 저장소 + 별도 라이브 디렉터리 2단 구조
prod는 dev와 다르게 2단 구조다.
git_repos/<project>-prod.git ← bare 중앙 저장소. push는 여기로 들어감
│
│ post-receive 훅
▼
<project>-prod/<project> ← 실제 서비스 중인 라이브 디렉터리
이 구조가 왜 필요했는지는 실제로 겪어보고 나서 알았다. 로컬에서 git push origin master를 하면 bare 저장소(git_repos/<project>-prod.git)까지는 최신 커밋이 잘 들어갔는데, 정작 서비스 중인 라이브 디렉터리(<project>-prod/<project>)는 갱신되지 않고 예전 커밋에 멈춰 있었다. 확인해보니 그 라이브 디렉터리는 그 자체로 독립된 git 저장소였고, origin도 같은 bare 저장소를 가리키고 있었지만 자동으로 pull을 받아오는 훅이 없어서 반영이 안 되고 있었던 것. 예전에 NAS에 SSH로 직접 접속해서 그 폴더 안에서 작업하던 시절에는 이 단계가 사실상 생략(또는 같이) 됐는데, 로컬 개발로 전환하면서 이 "마지막 pull" 단계가 비어버린 상태로 남아있었다.
당장은 라이브 디렉터리에서 수동으로 git pull을 해서 맞출 수도 있었지만, "수동 단계를 깜빡하는" 문제가 정확히 방금 겪은 문제였고, dev에서 이미 pytest/mypy 검증을 통과한 코드를 prod로 올리는 것이라 코드 신뢰도는 이미 확보된 상태였다. 그래서 완전 수동 대신 자동화하되 범위는 좁게 가져가기로 했다:
- 자동화한 것: git pull로 코드를 작업 디렉터리에 동기화하는 것까지만
- 수동으로 남긴 것: python manage.py migrate, 서버(gunicorn) 재시작 — 코드가 바뀌는 순간 자동으로 서버가 재시작되거나 마이그레이션까지 타면, 예상 못 한 타이밍에 운영 서비스가 끊길 수 있기 때문
즉 push하면 코드는 바로 최신으로 맞춰지지만, "서비스에 실제 반영"(재시작·마이그레이션)은 여전히 사람이 판단해서 실행하는 방식이다. 완전 수동을 유지하면 같은 실수가 재발할 가능성이 높고, 완전 자동(재시작까지)은 그 단계에서는 과한 리스크라고 판단했다.
push하면 먼저 bare 저장소의 ref만 갱신되고, 그 직후 post-receive 훅이 별도의 라이브 디렉터리를 향해 fetch + merge --ff-only를 실행해서 코드를 동기화한다.
#!/bin/sh
# scripts/post-receive-prod (요지만 발췌)
LIVE_WORK_TREE="/volume1/docker/django/<project>-prod/<project>"
while read OLD_SHA NEW_SHA REF; do
case "$REF" in refs/heads/master) ;; *) continue ;; esac
# 라이브 디렉터리에 커밋 안 된 변경사항이 있으면 자동 동기화하지 않고 중단
DIRTY="$(git --git-dir="$LIVE_WORK_TREE/.git" --work-tree="$LIVE_WORK_TREE" status --porcelain)"
[ -n "$DIRTY" ] && continue
git --git-dir="$LIVE_WORK_TREE/.git" --work-tree="$LIVE_WORK_TREE" fetch origin master
# fast-forward만 허용 — 히스토리가 갈라져 있으면 자동 병합하지 않고 사람이 확인
git --git-dir="$LIVE_WORK_TREE/.git" --work-tree="$LIVE_WORK_TREE" merge --ff-only origin/master
done
여기서 "merge"라는 명령어 이름 때문에 dev 코드와 prod 코드를 합치는 걸로 오해하기 쉬운데, --ff-only 옵션이 핵심이다. 이 옵션이 붙으면 히스토리가 갈라져 있지 않을 때(한쪽이 다른 쪽의 조상일 때)만 허용되고, 그냥 포인터를 앞으로 옮기는 것에 가깝다. 두 히스토리가 갈라져 있으면 병합을 시도하지 않고 그대로 실패하며 훅이 중단된다. 즉 dev와 prod 두 코드 라인을 합치는 게 아니라, 이미 dev에서 검증된 동일한 히스토리를 그대로 prod 라이브 디렉터리에 복사해 넣는 개념이다.
정리하면 이런 그림
로컬 저장소 (일반 git 저장소, 커밋은 로컬 .git에 영구 저장)
│
├─ git push dev master
│ ▼
│ <project>-dev/<project> (non-bare, dev 라이브 서버 워킹트리 겸용)
│ → pre-receive: 임시 디렉터리에서 pytest/mypy 검사 → 통과 시만 push 수락
│
└─ git push origin master
▼
git_repos/<project>-prod.git (bare, 진짜 "운영 git 서버")
→ post-receive: <project>-prod/<project> 라이브 디렉터리로 fetch + ff-only merge
dev와 origin은 서로를 참조하는 체인이 아니라, 로컬 저장소가 각각에 독립적으로 push하는 평행한 두 개의 목적지다. "운영 서버가 개발 서버의 복제"라는 인상은 둘 다 같은 로컬 커밋을 받다 보니 결과적으로 코드 내용이 같아진다는 의미에서만 맞고, git 리모트 관계로는 서로 무관하다.
이 원리 때문에 나중에 실전에서 또 헷갈렸던 적이 있다. 개발 폴더를 정리(문서 파일 삭제 등)하고 나서 "dev에 push하면 되겠지?"라고 생각했는데, 로컬 워킹트리에서 파일을 지운 것과 실제로 dev/prod 원격 저장소에 반영되는 것은 별개였다. 커밋도 안 하고 push도 안 한 상태에서는 dev/master, origin/master(prod) 양쪽 다 여전히 그 파일이 존재하는 상태로 남아있다. 그리고 dev에 push한다고 prod에 자동으로 넘어가는 것도 아니다 — 순서대로:
- 삭제 포함해서 커밋 → git push dev master → CI 통과 확인
- 문제없으면 같은 커밋을 git push origin master로 prod에도 별도로 push
두 원격 다 각각 push를 해줘야 반영된다. "dev에는 이미 반영됐으니 prod도 자동으로 따라가겠지"라는 생각은 이 저장소 구조에서는 통하지 않는다 — dev와 origin이 평행한 별개의 목적지라는 원리를 실전에서 다시 확인한 사례였다.
헷갈렸던 진짜 이유 — 죽은 저장소 하나
구조를 확인하다가 git_repos/ 안에 <project>-dev.git이라는, prod와 대칭되는 이름의 bare 저장소가 하나 더 있는 걸 발견했다. 처음엔 "이것도 쓰이고 있나?" 싶었는데 확인해보니:
- 파일 타임스탬프가 오래전 어느 시점 이후로 전혀 갱신되지 않음
- 로컬 dev 리모트가 애초에 이 경로를 가리키지 않음 (dev 리모트는 라이브 워킹트리 경로를 직접 가리킴)
- 코드베이스 전체를 검색해도 훅/문서/스크립트 어디에서도 이 경로를 참조하지 않음
즉 예전에(아마도 NAS에 직접 SSH로 접속해서 개발하던 시절) prod와 대칭되는 구조로 만들어뒀다가, 워크플로우를 지금 방식으로 바꾸면서 실제로는 안 쓰게 된 잔재였다. dev는 원래부터 "중앙 bare 저장소 → 라이브 디렉터리"라는 prod식 2단 구조가 아니라, "라이브 워킹트리에 직접 push" 1단 구조로 운영되고 있었던 것.
결론 — 잔재는 지우고, 비대칭 구조는 문서에 명시
다른 개발자가 합류했을 때 "왜 dev만 구조가 다르지?"라며 헷갈릴 여지가 컸기 때문에, 다음 두 가지로 정리했다.
- 아무도 참조하지 않는 죽은 저장소는 삭제. 실제 워크플로우 어디에도 연결되어 있지 않다는 걸 코드베이스 전체 검색으로 확인한 뒤 제거했다.
- 온보딩 문서에 비대칭 구조 자체를 명시. 다음 한 줄이면 충분했다.
- dev는 prod와 달리 별도의 bare 중앙 저장소가 없다. git push dev는 라이브 워킹트리(.git)로 직접 들어가며, pre-receive 훅이 거기서 CI(pytest/mypy)를 통과해야 워킹트리가 갱신된다.
혼자 개발할 때는 "나만 알면 되는" 구조였는데, 온보딩 문서를 쓰려고 하니 "왜 이렇게 되어 있는지"를 말로 설명할 수 있어야 한다는 걸 새삼 느꼈다. 겉보기엔 대칭인 두 흐름이 실제로는 다른 원리로 동작하고 있다면, 그 차이를 문서에 명시적으로 남겨두는 게 나중의 혼란을 막는 가장 싼 방법이었다.
'IT > 작업기록' 카테고리의 다른 글
| [Git] 새 브랜치로 만든 기능, 테스트부터 dev 실반영까지 (0) | 2026.08.18 |
|---|---|
| [개발 회고록/Django] 판매지원 시스템, v1.0에서 v2.1까지 (0) | 2026.08.14 |
| [Git CI] "Permission denied"로 임시 디렉터리 정리가 안 되던 문제 — 그리고 그 뒤에 숨어있던 진짜 원인 (0) | 2026.08.07 |
| [작업 기록] POP3 메일 체크 스크립트, 계정별 복사 대신 config로 관리하기 (0) | 2026.08.06 |
| [Docker] WeasyPrint 시스템 의존성, Dockerfile 없는 구조에서 어떻게 설치하나 (0) | 2026.08.05 |