매일공부

[Git] dev/prod 배포 구조가 비대칭이었던 걸 온보딩 문서 쓰다가 발견한 이야기 본문

IT/작업기록

[Git] dev/prod 배포 구조가 비대칭이었던 걸 온보딩 문서 쓰다가 발견한 이야기

julian 2026. 8. 12. 14:31

배경

온보딩 문서를 정리하다가 "이 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)했을 때뿐이다

이 부분들은 다 맞았다. 다만 두 가지가 부정확했다.

  1. 커밋이 저장되는 곳을 "워크트리"라고 부르고 있었는데, 이건 착각이었다. 실제로는 그냥 일반 로컬 저장소(.git)에 커밋이 영구 저장되는 것뿐이었고, "워크트리"라는 이름이 붙는 곳은 따로 있었다 — CI 훅이 검사용으로 커밋을 잠깐 풀어놓는 임시 디렉터리(_ci_worktrees/<커밋해시>)가 그거였다.
  2. 운영서버로 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 훅이 동작해서:

  1. push된 커밋을 git archive로 뽑아서 임시 디렉터리(_ci_worktrees/<커밋해시>)에 풀어놓는다
  2. 그 안에서 Docker 컨테이너를 통해 pytest, mypy를 실행한다
  3. 둘 다 통과해야만 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에 자동으로 넘어가는 것도 아니다 — 순서대로:

  1. 삭제 포함해서 커밋 → git push dev master → CI 통과 확인
  2. 문제없으면 같은 커밋을 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만 구조가 다르지?"라며 헷갈릴 여지가 컸기 때문에, 다음 두 가지로 정리했다.

  1. 아무도 참조하지 않는 죽은 저장소는 삭제. 실제 워크플로우 어디에도 연결되어 있지 않다는 걸 코드베이스 전체 검색으로 확인한 뒤 제거했다.
  2. 온보딩 문서에 비대칭 구조 자체를 명시. 다음 한 줄이면 충분했다.
  3. dev는 prod와 달리 별도의 bare 중앙 저장소가 없다. git push dev는 라이브 워킹트리(.git)로 직접 들어가며, pre-receive 훅이 거기서 CI(pytest/mypy)를 통과해야 워킹트리가 갱신된다.

혼자 개발할 때는 "나만 알면 되는" 구조였는데, 온보딩 문서를 쓰려고 하니 "왜 이렇게 되어 있는지"를 말로 설명할 수 있어야 한다는 걸 새삼 느꼈다. 겉보기엔 대칭인 두 흐름이 실제로는 다른 원리로 동작하고 있다면, 그 차이를 문서에 명시적으로 남겨두는 게 나중의 혼란을 막는 가장 싼 방법이었다.

Comments