코드 한 줄이 운영에 닿기까지 — 이슈에서 운영 배포까지 6분 44초
코드는 AI 작업자가 짜고 사람은 결정만 하는 Sandcorp의 배포 흐름을, 실제 PR 두 개의 시각표로 따라갔습니다. 한 번에 통과하면 이슈에서 운영까지 6분 44초, PR 검사가 오류를 잡으면 19분 51초였습니다. 로컬은 바꾼 곳만 보고 전체 검사는 Jenkins PR 검사가 맡는 구조, 맥 우선·3분 폴백 빌드, 헬스 실패 시 자동 롤백, 지우는 migrate 막기까지 적었습니다.

코드 한 줄이 운영에 닿기까지
AI 작업자가 짠 코드가 사람의 결정 한 번을 거쳐 운영 서버에 닿는 길
왜 이렇게 만들었나
Sandcorp의 코드는 대부분 Claude Code 작업자가 짭니다. 사람(대표)은 문구, 가격, 권한처럼 제품을 정하는 결정만 합니다. 이 구조에서 느려지는 곳은 두 군데였습니다.
- 작업자가 막히면 사람에게 묻고, 사람이 답할 때까지 멈춥니다. 작업자가 여럿이면 질문도 여럿이 됩니다.
- 병합한 뒤 빌드·배포·확인에 사람 손이 가면, 코드를 짠 속도를 배포가 따라가지 못합니다.
그래서 두 가지를 나눴습니다. 결정은 한 창구로만 사람에게 가고, 병합한 뒤로는 사람 손을 거치지 않습니다. 서버를 역할별로 나눈 이야기는 앞 글에 있고, 이 글은 그 위를 흐르는 코드의 길을 따라갑니다.
따라갈 사례는 한 서비스에서 운영까지 간 실제 PR 두 개입니다.
- 한 번에 통과한 사례(2026-11-03 아침): 화면 오른쪽 아래 버튼 묶음을 세로에서 가로 한 줄로 바꾼 작은 화면 변경입니다. 조율자가 이슈를 연 순간부터 운영 배포 알림까지 6분 44초, 그중 병합에서 배포 알림까지는 1분 55초였습니다.
- CI가 오류를 잡은 사례(2026-11-02 밤): 운영 API에서 자동 생성 문서를 끄는 작은 보안 변경입니다. PR 검사가 작업자가 새로 쓴 시험의 실패를 병합 전에 잡았고, 시험을 고쳐 다시 올린 뒤 운영에 닿기까지 19분 51초가 걸렸습니다.
지휘 사슬과, 한 번에 통과한 PR의 시각표
CI가 오류를 잡은 사례
앞 사례처럼 매번 한 번에 통과하지는 않습니다. 2026-11-02 밤, 같은 서비스의 운영 API에서 자동 생성 문서를 끄는 작은 보안 변경은 이슈에서 운영 배포 알림까지 19분 51초가 걸렸습니다.
첫 PR 검사가 51초 만에 실패했습니다. 작업자가 새로 쓴 시험이 CI 환경에서 맞지 않았기 때문입니다. 작업자는 그 시험만 고쳐 다시 올렸고, 두 번째 검사(58초)를 통과한 뒤에야 병합했습니다. 로컬에서 바꾼 곳만 본 확인이 놓친 것을 PR 검사가 병합 전에 잡은 셈입니다.
두 사례의 시간을 나눠 보면, 느린 쪽을 가른 것은 구현이 아니라 PR을 연 뒤의 실패·수정·재검사 왕복이었습니다.
두 사례의 시간이 든 곳
| 구간 | 한 번에 통과 | CI가 오류를 잡음 |
|---|---|---|
| 이슈 → PR 열림 | 2분 34초 | 6분 24초 |
| PR 열림 → 병합 | 2분 15초 (검사 한 번 성공, 67초) | 10분 50초 (검사 실패 51초 → 시험 수정 → 검사 성공 58초) |
| 병합 → 배포 알림 | 1분 55초 | 2분 37초 |
| 이슈 → 배포 알림 | 6분 44초 | 19분 51초 |
- 모든 시간은 GitHub·Jenkins 기록에서 초 단위로 뽑았습니다.
- 이슈 → PR 열림에는 작업자를 띄우고 저장소 규칙을 읽고 작업 공간을 만드는 시간이 들어 있습니다. 한 번에 통과한 사례는 이미 떠 있던 작업자에게 맡겨 이 시간이 짧았습니다.
CI가 오류를 잡은 PR의 시각표
코드 한 줄이 지나가는 길
- 결정사람이 들어가는 유일한 곳
대장이 질문 패널로 대표에게 묻는다. 대표는 고르기만 한다
- 이슈이슈 하나 = 작업자 하나
대장이 저장소 조율자에게 넘기고, 조율자가 이슈와 지시서를 만든다
- 구현·시험전체 검사는 PR에서
작업자가 워크트리에서 시험을 먼저 쓰고 구현한 뒤, 바꾼 곳과 관련된 시험·lint·typecheck만 돌린다
- PR 검사·병합병합 = 배포 시작
PR을 열면 Jenkins가 병합 결과로 운영 빌드까지 전체 검사를 돌린다(1~2분). 통과를 확인한 뒤 작업자가 squash 병합한다
- 빌드운영 서버는 빌드하지 않는다
맥이 arm64 이미지를 구워 커밋 sha 태그로 ghcr에 올린다. 3분 안에 못 잡으면 집 서버. PR 검사와 다른 내용이면 같은 검사를 동시에 다시 돌린다
- 배포실패하면 직전 이미지로
운영 서버가 이미지를 받아 migrate → 교체 → 헬스 확인, 디스코드에 결과 한 줄
- 확인작업자는 운영에 쓰지 않는다
조율자가 빌드 결과와 운영 화면을 보고, 대장이 다시 확인해 대표에게 보고한다
단계마다 지키는 조건
| 단계 | 누가 | 통과 조건 | 실패하면 |
|---|---|---|---|
| 결정 | 대표 | 대장이 올린 질문에 답한다 | 답이 올 때까지 그 일은 열린 채 기다린다 |
| 구현·시험 | 작업자 | 시험 먼저, 바꾼 곳과 관련된 시험·lint·typecheck 통과 | 실패한 시험만 좁혀 다시 돌린다. 도구 호출이 120번을 넘으면 넘김 댓글을 남기고 새 작업자가 잇는다 |
| PR 검사 | Jenkins | 병합 결과로 파괴적 migrate 검사와 전체 검사(운영 빌드 포함, 백엔드 시험 전체 30초 안, 시험 하나 CPU 1초 안)를 통과 | 작업자가 병합하지 않는다. 실패한 시험만 로컬에서 고친다 |
| 빌드 | Jenkins → 맥 → 집 서버 | 맥이 3분 안에 작업을 잡는다. 배포할 내용이 PR 검사를 통과한 것과 다르면 재검사도 통과 | 재검사가 실패하면 배포하지 않는다. 맥을 못 잡으면 집 서버가 굽는다. 잡은 뒤 실패하면 재시도 없이 실패 |
| 배포 | 운영 서버 | DB 접속 → migrate → 웹 교체 후 healthy → 헬스 주소 200 | 직전 성공 이미지로 자동으로 되돌리고 헬스를 다시 확인, 디스코드에 알린다. migrate는 되돌리지 않는다 |
| 확인 | 조율자 → 대장 | 빌드 성공 + 운영 화면을 휴대폰·데스크톱 폭으로 확인 | 새 이슈로 돌아간다 |
- main이 아닌 브랜치의 push와, 직전 성공 빌드와 같은 커밋은 배포 빌드를 하지 않습니다.
- 모델은 티켓마다 고릅니다. 기본은 Sonnet이고, 최적화·보안·동시성·원인을 모르는 버그는 Opus입니다. 일을 찾는 잡무는 작업자가 Haiku 심부름꾼에게 맡깁니다.
부딪힌 문제와 해결
1. 맥을 기다리되, 영원히 기다리지는 않게
CI의 arm64 빌드를 맥에만 맡기자, 맥북을 들고 외출한 날(10/25) 빌드가 맥 실행기를 기다리며 멈췄습니다.
→ 두 갈래를 parallel(failFast: true)로 동시에 돌립니다. 한 갈래는 맥 노드를 잡으면 「잡았음」 표시를 하고 빌드합니다. 다른 갈래는 5초마다 그 표시를 보다가 180초가 지나도 없으면 오류를 냅니다. failFast가 맥 대기를 취소하고, catch에서 집 서버 노드로 다시 빌드합니다. 맥을 잡은 뒤 빌드가 실패하면 집 서버로 넘기지 않습니다. 그때는 기계보다 코드가 원인일 때가 많기 때문입니다. Jenkins 스크립트 승인을 피하려고 표준 단계만 썼습니다.
빌드 캐시는 레지스트리에 둡니다(mode=max). 두 노드가 같은 캐시를 쓰므로, 캐시가 없던 첫 빌드는 321초였지만 그 뒤 집 서버로 강제한 빌드는 65초였습니다.
2. 비공개 패키지의 키를 이미지에 남기지 않기
사이트들은 비공개 공용 패키지(인증 검증)를 씁니다. 10/29에 빌드 실행기에 저장소 키가 없어 패키지를 받지 못했습니다. 키를 이미지 안으로 복사하면 층에 남습니다.
→ BuildKit의 SSH 전달(--ssh default)로 빌드 단계에만 키를 빌려줍니다. docker history에 키가 남지 않는 것을 확인했습니다. ghcr 토큰도 빌드마다 만드는 임시 DOCKER_CONFIG에만 씁니다. 맥에서는 키체인 때문에 docker login이 실패해서이기도 합니다.
3. 저장소마다 복사한 Jenkinsfile을 하나로
처음에는 한 저장소에서 만든 Jenkinsfile을 다른 저장소가 옮겨 썼습니다. 3분 폴백 규칙이나 빌드 노드 이름을 바꾸려면 저장소마다 고쳐야 했습니다.
→ 폴백, 빌드 캐시, 임시 DOCKER_CONFIG, SSH 전달, 검사, 배포, 디스코드 알림을 Jenkins 공용 라이브러리(Shared Library) 하나로 모았습니다. 저장소에는 이미지 이름, 배포 디렉터리, 헬스 주소 같은 값만 남습니다. 빌드 노드 이름은 라이브러리의 파일 하나에만 있어서, 노드를 바꿀 때 한 줄만 고칩니다.
4. 전체 검사는 PR에서, 로컬에서는 바꾼 곳만
10/25에 로컬 시험을 모두 통과한 PR이 CI에서 실패했습니다. 프런트엔드의 CSS 축소 단계가 운영 빌드에서만 도는데, 그 단계에서만 오류가 났습니다. 병합이 곧 배포이므로, 병합한 뒤에야 안 셈입니다. 그렇다고 운영 빌드까지 넣은 전체 검사를 작업자가 로컬에서 돌리게 하면, 시험 하나가 실패할 때마다 작업자가 묶음 전체를 다시 돌립니다.
→ 전체 검사(make check: gen-check, lint, typecheck, 시험, 운영 빌드)는 PR 검사가 돌립니다. PR이 열리면 Jenkins가 main에 병합한 결과를 받아, 도커 안에 그 실행만 쓰는 MySQL을 띄우고 전체 검사를 돌린 뒤 결과를 커밋 상태로 올립니다. PR 검사는 집 서버의 Jenkins 노드를 먼저 잡고, 3분 안에 못 잡으면 맥으로 넘어갑니다. 잡 전체가 1~2분이고, 시험 한도(백엔드 시험 전체 30초 안, 시험 하나 CPU 1초 안)도 여기서 봅니다. 작업자는 로컬에서 바꾼 곳과 관련된 시험, lint, typecheck만 돌리고, 시험이 실패하면 그 시험만 좁혀 다시 돌립니다. PR 검사가 성공인 것을 확인한 뒤 병합하고, 실패하면 실패한 시험만 로컬에서 고칩니다. 위 「CI가 오류를 잡은 사례」에서도 첫 PR 검사가 작업자가 새로 쓴 시험의 실패를 병합 전에 잡았고, 작업자는 그 시험만 고쳐 다시 올렸습니다.
다만 조직이 GitHub 무료 요금제라, 비공개 저장소의 main에는 보호 규칙을 걸 수 없습니다. 검사가 실패해도 병합 버튼은 눌립니다. 그래서 배포 파이프라인이 배포 전에 같은 검사를 한 번 더 돌리고, 실패하면 운영에 반영하지 않습니다.
이 재검사가 배포를 47~60초에서 89~110초로 늘렸습니다. 그래서 배포할 병합 커밋의 파일 내용(git tree)이 PR 검사를 통과한 커밋과 똑같을 때만 재검사를 건너뜁니다. 병합 커밋을 만든 PR이 하나로 정해지고, 그 PR의 검사가 최신 성공이고, PR이 main보다 뒤처지지 않았어야 합니다. 하나라도 어긋나거나 GitHub 조회가 실패하면 재검사합니다. 재검사를 할 때도 이미지 굽기와 동시에 돌립니다. 위 두 사례는 모두 재검사를 건너뛰었습니다. 한 번에 통과한 사례의 배포 로그에는 「검사 생략: PR 검사 통과(tree 동일)」 한 줄이 찍혔고, 배포 잡은 86초였습니다(오류를 잡은 사례는 127초). 재검사를 건너뛰지 못하면 2~3분까지 걸립니다.
5. 같은 커밋이 두 번 빌드된다
병합 한 번에 같은 커밋으로 배포 빌드가 두 번 도는 일이 있었습니다. PR을 병합하면 GitHub가 Jenkins로 push 알림을 여러 번 보냅니다. main에 병합 커밋이 올라간 push와 작업 브랜치의 push·삭제가 몇 초 안에, 때로는 0.15초 간격으로 옵니다. 배포 잡은 알림을 받을 때마다 main 끝 커밋을 직전 빌드의 커밋과 비교합니다. 그런데 앞 빌드가 아직 코드를 받기 전이면 그보다 앞선 빌드와 비교해 「바뀌었다」고 보고, 같은 커밋으로 빌드를 하나 더 예약합니다. push 알림으로 시작한 빌드는 어느 브랜치의 push인지도 넘겨받지 못합니다.
→ 두 겹으로 막았습니다. 먼저 배포 파이프라인이 「main의 커밋이 이미 배포에 성공한 커밋이면 이미지 빌드와 배포를 건너뛰고 끝낸다(NOT_BUILT, 알림 없음)」로 막습니다. 두 번째 빌드가 돌더라도 약 10초 만에 끝나고, 같은 커밋이 두 번 배포되지는 않습니다. 작업 브랜치를 push하거나 지워도 배포가 일어나지 않고, 조율자는 성공한 빌드 하나만 봅니다. 다음으로 Jenkins가 push를 받은 뒤 빌드를 시작하기 전에 기다리는 시간(quiet period)을 5초에서 15초로 늘려, 거의 동시에 온 알림을 한 빌드로 합칩니다. 대신 모든 빌드가 약 10초 늦게 시작합니다.
15초로 바꾼 뒤 지나간 위 두 사례에서는 모두 병합 뒤 배포 빌드가 한 번만 돌았습니다. CI가 오류를 잡은 사례에서는 PR 브랜치에 push했을 때 배포 잡이 한 번 깨어났지만, main이 아니어서 약 10초 만에 NOT_BUILT로 끝났습니다.
6. 헬스가 실패하면 직전 이미지로
배포 뒤 헬스 확인이 실패했을 때 배포만 멈추고 알림을 보내면, 사람이 이전 커밋을 넣어 다시 돌릴 때까지 깨진 상태가 이어집니다.
→ 헬스 확인이 실패하거나 컨테이너가 unhealthy가 되면, 파이프라인이 직전에 성공한 이미지로 되돌리고 헬스를 다시 확인한 뒤 디스코드에 알립니다. migrate는 되돌리지 않습니다. 운영을 일부러 깨뜨리지는 않았고, docker를 흉내 낸 가짜 명령으로 헬스 실패, unhealthy, 롤백 실패, 되돌릴 이미지 없음 같은 경우를 시험했습니다.
7. 지우는 migrate는 PR에서 막기
롤백은 이미지만 되돌리고 스키마는 그대로 둡니다. 컬럼을 지우는 migrate가 함께 나갔다면, 되돌린 옛 코드가 없는 컬럼을 찾습니다. 무중단 배포 중에 새 스키마를 만나는 옛 백엔드도 같은 처지입니다.
→ 「코드에서 안 쓰기」와 「스키마에서 지우기」를 두 번의 배포로 나누고, 이를 PR 검사가 강제합니다. PR 검사가 그 PR에서 새로 생긴 migration을 읽어, 필드·모델 삭제, 이름 변경, null 불가로 바꾸기, DROP·RENAME SQL이 있으면 실패시킵니다. 기본값 없이 null 불가 칸을 더하는 것도 실패입니다. 「코드에서 안 쓰기」를 먼저 배포했다면, 정해진 통과 표시 주석을 달아 「스키마에서 지우기」를 통과시킵니다.
8. 두 대를 하나씩 바꿔 끊김 없이
웹 컨테이너를 다시 만드는 동안에는 요청이 실패합니다. 대부분 서비스는 이걸 받아들이지만, 위 두 사례의 서비스는 무중단으로 배포합니다.
→ 백엔드를 두 대 띄우고 앞에 Caddy를 두었습니다. Caddy는 두 대에 요청을 나누고, 한 대가 응답하지 않으면 10초 동안 다른 한 대로 다시 보냅니다(lb_try_duration). 배포는 migrate를 먼저 하고(옛 백엔드는 계속 요청을 받음), 백엔드를 한 대씩 교체해 healthy가 될 때까지 기다린 뒤 다음 대로 넘어갑니다. 위 두 사례에서는 한 대당 약 5초씩 걸렸습니다. 되돌릴 때는 migrate를 건너뛰고 같은 순서로 한 대씩 바꿉니다. Caddy 설정은 컨테이너를 다시 만들지 않고 reload로만 바꿉니다.
9. 빌드 실행기에 좀비 프로세스 208개
11/01에 작업 서버와 운영 서버의 Jenkins 실행기 컨테이너에 좀비 프로세스가 각각 208개, 94개 쌓여 있었습니다. 실행기(java)가 빌드마다 띄운 sh 자식 프로세스의 종료를 거두지 않았습니다.
→ compose에 init: true를 넣어 작은 init 프로세스가 종료된 자식을 거두게 했습니다.
10. 작업자의 질문이 사라졌다
작업 서버에서 개발 스택 세 개를 동시에 띄우다 메모리가 바닥났고, OOM으로 프로세스가 죽으면서 작업자가 보낸 질문 두 개가 배달되기 전에 사라졌습니다.
→ 지시서에 「시험에 필요한 컨테이너만 띄운다」를 넣었습니다. 지시서는 파일로 두고 작업자에게는 「읽고 수행하라」 한 줄만 보냅니다. 작업자를 다시 띄워도 같은 지시를 읽습니다.


