코딩 복습용 블로그
안녕하세여! 코딩 복습용 블로그입니다.
전체 글 (419)
[TIL]폐쇄망 K8S 이미지 패치 정리-③ 이미지 교체·재배포 검증 및 Rollback
728x90

개요

앞선 글에서 아래 과정을 정리했습니다.

글 내용
정리-1 1. 보안 원격접속(SSH) 및 작업 노드 사전 점검 · 2. SFTP 기반 이미지 아티팩트 반입
정리-2 3. Deployment 매니페스트 백업 · 4. 이미지 적재·재태깅

이 글은 마지막 두 과정을 정리합니다.

  1. 이미지 교체 및 Rolling 재배포 검증
  2. 매니페스트 기반 Rollback

아래 이름은 모두 예시입니다. 실제 값은 승인된 작업 계획서에서 확인하세요.

항목 예시 값
Namespace cmp
Deployment sample-api, sample-web
새 이미지 (정리-2에서 push) registry.example.local:50000/myproject/<이미지>:<새 tag>
백업 파일 (정리-2에서 생성) sample-api-bak.yaml, sample-web-bak.yaml

5. 이미지 교체 및 Rolling 재배포 검증

개념

Deployment의 spec.template.spec.containers[].image 값을 바꾸면 그 자체로 Rolling Update가 시작됩니다. K8S가 새 이미지로 Pod를 하나씩 띄우고, 새 Pod가 Ready가 되면 기존 Pod를 하나씩 내립니다. 그래서 서비스를 멈추지 않고 이미지를 바꿀 수 있습니다.

kubectl rollout restart는 이미지 변경 없이 Pod만 새로 띄울 때 쓰는 명령입니다. 이미지를 바꾼 뒤에는 따로 실행하지 않아도 됩니다. 실행하면 재배포가 한 번 더 일어날 뿐 해는 없지만, Rollback 리비전이 하나 늘어 6번에서 헷갈릴 수 있습니다.

실제 작업 흐름

순서 행동 확인할 결과
1 Deployment의 image를 새 tag로 변경 edited 또는 image updated
2 롤아웃 완료 대기 successfully rolled out
3 Pod 상태·이미지 확인 새 Pod Running, READY 가득 참, image가 새 tag
4 애플리케이션 로그·서비스 확인 오류 로그 없음, 기능 정상
5 이상 시 6번 진행 -

목적: 대상 Deployment를 새 이미지로 교체하고 정상 기동을 확인합니다.
완료 기준: 롤아웃이 완료되고, 새 Pod가 모두 Ready이며 재시작이 늘지 않고, 서비스 확인까지 끝나면 패치 완료입니다.

5.1 이미지 교체

방법 A — kubectl edit

kubectl edit -n cmp deployment.apps/sample-api
kubectl edit -n cmp deployment.apps/sample-web

편집기에서 image: 줄의 태그만 <새 tag>로 바꾸고 저장·종료합니다. 다른 줄은 건드리지 않습니다.

방법 B — kubectl set image (오타 위험이 적음)

# 컨테이너 이름은 백업 파일의 containers[].name 값
kubectl set image -n cmp deployment/sample-api \
  <컨테이너명>=registry.example.local:50000/myproject/sample-api:<새 tag>
kubectl set image -n cmp deployment/sample-web \
  <컨테이너명>=registry.example.local:50000/myproject/sample-web:<새 tag>

(선택) 이미지 변경 없이 재기동만 필요할 때:

kubectl rollout restart -n cmp deployment.apps/sample-api
kubectl rollout restart -n cmp deployment.apps/sample-web

5.2 롤아웃 검증

kubectl rollout status -n cmp deployment/sample-api --timeout=300s
kubectl rollout status -n cmp deployment/sample-web --timeout=300s
kubectl get pod -n cmp -o wide -w          # Ctrl+C로 종료
kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image
kubectl logs -n cmp deploy/sample-api --tail=100
kubectl logs -n cmp deploy/sample-web --tail=100

watch -n 0.1 kubectl get pod ...처럼 0.1초 간격으로 조회하면 API 서버에 부담이 됩니다. -w(변경 시에만 출력)나 watch -n 2를 씁니다.

5.3 상태별 판단

Pod 상태 의미 조치
ContainerCreating → Running 정상 진행 대기
ImagePullBackOff / ErrImagePull 레지스트리 주소·태그 오류 또는 push 누락 kubectl describe pod의 Events 확인 → 정리-2의 4번 재확인
CrashLoopBackOff 새 이미지가 기동 직후 종료 kubectl logs --previous로 원인 확인 → 6번
Running인데 READY 0/1 readiness probe 실패 로그·설정 확인, 지속되면 6번
롤아웃이 --timeout 안에 안 끝남 새 Pod가 Ready가 되지 못함 위 상태 중 무엇인지 확인 → 6번

Rolling Update 중에는 기존 Pod가 남아 있으므로, 새 Pod가 실패해도 서비스는 기존 Pod로 계속 응답합니다. 서두르지 말고 원인을 확인한 뒤 되돌립니다.


6. 매니페스트 기반 Rollback

개념

새 이미지에 문제가 있으면 기존 이미지로 되돌립니다. 방법은 두 가지입니다.

방법 장점 주의
A. 백업 YAML 적용 정리-2의 3번에서 저장한 정의 그대로 복구 백업의 resourceVersion 때문에 충돌로 거부될 수 있음 → 6.1
B. kubectl rollout undo 명령 한 줄로 직전 ReplicaSet 복구 5번에서 edit와 restart를 둘 다 했다면 "직전"이 restart 시점일 수 있음 → rollout history로 리비전 지정

실제 작업 흐름

순서 행동 확인할 결과
1 Rollback 결정 (담당자 승인) -
2 백업 YAML 적용 또는 rollout undo configured / rolled back
3 롤아웃 완료 대기 successfully rolled out
4 Pod 상태·이미지 확인 image가 <기존 tag>, Pod 정상

목적: 패치 실패 시 정리-2에서 기록한 정상 상태로 되돌립니다.
완료 기준: Deployment 이미지가 기존 태그로 돌아오고, Pod 상태가 패치 전 기록과 같아지면 복구 완료입니다.

6.1 방법 A — 백업 YAML 적용

kubectl apply -n cmp -f sample-api-bak.yaml
kubectl apply -n cmp -f sample-web-bak.yaml

the object has been modified; please apply your changes to the latest version 오류가 나면 백업 파일의 resourceVersion 줄을 지우고 다시 적용합니다.

sed -i '/^  resourceVersion:/d' sample-api-bak.yaml sample-web-bak.yaml
kubectl apply -n cmp -f sample-api-bak.yaml
kubectl apply -n cmp -f sample-web-bak.yaml

6.2 방법 B — rollout undo

kubectl rollout history -n cmp deployment/sample-api
kubectl rollout undo -n cmp deployment/sample-api                       # 직전으로
kubectl rollout undo -n cmp deployment/sample-api --to-revision=<번호>   # 지정 리비전으로
# sample-web도 동일

rollout history --revision=<번호>로 각 리비전의 이미지를 확인한 뒤 고르면 안전합니다.

6.3 복구 확인

kubectl rollout status -n cmp deployment/sample-api --timeout=300s
kubectl rollout status -n cmp deployment/sample-web --timeout=300s
kubectl get pod -n cmp -o wide
kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image

image가 정리-2에서 기록한 <기존 tag>이고, Pod 수·상태가 패치 전과 같으면 복구 완료입니다.


전체 작업 체크리스트 (정리-1 ~ 3)

# 과정 확인
1 보안 원격접속(SSH) 및 작업 노드 사전 점검 SSH 접속, 점검 10항목 정상
2 SFTP 기반 이미지 아티팩트 반입 업로드, 서버 해시 = 원본 해시
3 Deployment 매니페스트 백업 상태 기록, 백업 YAML의 image = 기존 tag
4 이미지 적재·재태깅 load → tag → push → pull 오류 없음
5 이미지 교체 및 Rolling 재배포 검증 rollout 완료, Pod Ready, 로그·서비스 정상
6 (실패 시) 매니페스트 기반 Rollback image = 기존 tag, Pod 상태 원복

참고

728x90
  Comments,     Trackbacks
[TIL]폐쇄망 K8S 이미지 패치 정리-② 매니페스트 백업 및 이미지 적재·재태깅
728x90

개요

정리-1에서 아래 두 과정을 정리했습니다.

  1. 보안 원격접속(SSH) 및 작업 노드 사전 점검
  2. SFTP 기반 이미지 아티팩트 반입

이 글은 이어지는 두 과정을 정리합니다.

  1. Deployment 매니페스트 백업
  2. 이미지 적재·재태깅

아래 이름은 모두 예시입니다. 실제 값은 승인된 작업 계획서에서 확인하세요.

항목 예시 값
Namespace cmp
Deployment sample-api, sample-web
반입한 tar sample-api.tar, sample-web.tar
tar 안의 이미지 이름 registry.example.local:55000/myproject/<이미지>:<기존 tag>
클러스터가 pull하는 레지스트리 registry.example.local:50000/myproject/<이미지>:<새 tag>

3. Deployment 매니페스트 백업

개념

Deployment는 "이 이미지로 Pod를 N개 유지하라"는 선언입니다. 이미지를 바꾸면 K8S가 새 Pod를 하나씩 띄우고 기존 Pod를 하나씩 내리는 Rolling Update를 수행합니다.

이미지를 바꾸기 전에 두 가지를 남깁니다.

  • 현재 상태 기록: Pod 수, 상태, 재시작 횟수, 배치 노드. 패치 전부터 비정상이었다면 패치 탓과 구분할 수 없으므로 먼저 확인합니다.
  • 매니페스트 백업: 현재 Deployment 정의를 YAML 파일로 저장합니다. 새 이미지에 문제가 생겼을 때 되돌릴 원본입니다.

실제 작업 흐름

순서 행동 확인할 결과
1 Namespace 전체 Pod 상태 확인 대상 Pod 모두 Running, READY 가득 참
2 대상 Deployment·ReplicaSet·Pod 필터링 대상 리소스 확인
3 현재 이미지 태그 기록 기존 tag 확보 (4번에서 사용)
4 Deployment YAML 백업 *-bak.yaml 파일 생성
5 백업 파일 확인 image:에 기존 태그 표시

목적: 패치 전 정상 상태를 기록하고 원복할 수 있는 매니페스트를 확보합니다.
완료 기준: 대상 Pod가 정상이고, 백업 파일 두 개의 image:가 기존 태그와 같으면 4번으로 넘어갑니다.

3.1 현재 상태 확인

kubectl get pod -n cmp -o wide
kubectl get all -n cmp | grep -iE 'sample-api|sample-web'
  • STATUS가 Running, READY가 1/1처럼 가득 차 있는지 봅니다.
  • 이미 CrashLoopBackOff이거나 RESTARTS가 늘고 있다면 진행하지 말고 담당자에게 확인합니다.

3.2 현재 이미지 태그 기록

kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image

출력된 <기존 tag>와 레지스트리 주소를 작업 기록에 적어 둡니다. 4.2 재태깅에서 대상 주소를 맞출 때 씁니다.

3.3 매니페스트 백업

kubectl get -n cmp deployment.apps/sample-api -o yaml > sample-api-bak.yaml
kubectl get -n cmp deployment.apps/sample-web -o yaml > sample-web-bak.yaml

3.4 백업 확인

ls -l *-bak.yaml
grep 'image:' *-bak.yaml

파일이 비어 있지 않고 image:에 3.2에서 기록한 태그가 보이면 백업 완료입니다.

3.5 백업 파일을 쓸 때 주의

나중에 kubectl apply -n cmp -f sample-api-bak.yaml로 되돌릴 때 the object has been modified 오류가 날 수 있습니다. kubectl get -o yaml로 받은 파일에는 저장 당시의 resourceVersion이 들어 있어, 그사이 Deployment가 바뀌면 충돌로 거부되기 때문입니다. 이때는 그 줄을 지우고 다시 적용합니다.

sed -i '/^  resourceVersion:/d' sample-api-bak.yaml sample-web-bak.yaml

4. 이미지 적재·재태깅

개념

폐쇄망 클러스터의 노드는 사내 레지스트리에서 이미지를 pull합니다. 작업 노드에 tar만 올려 두면 다른 노드는 그 이미지를 볼 수 없으므로, 적재 후 레지스트리에 올려야 합니다.

명령 하는 일
nerdctl load tar를 풀어 작업 노드의 로컬 이미지로 등록
nerdctl tag 같은 이미지에 클러스터가 pull할 주소·새 태그로 이름을 하나 더 붙임 (복사 아님)
nerdctl push 그 이름으로 레지스트리에 업로드
nerdctl pull 레지스트리에서 다시 받아 업로드가 정상인지 확인

왜 재태깅하는가: tar 안에는 빌드할 때 붙인 이름(예: :55000/...)이 그대로 들어 있습니다. 클러스터는 다른 주소(예: :50000/...)에서 이미지를 받으므로, 그 주소로 이름을 다시 붙여 올려야 합니다.

왜 새 태그인가: 기존 태그를 덮어쓰면 노드에 남은 캐시 때문에 일부 Pod가 옛 이미지로 뜰 수 있고, 되돌릴 때 어느 것이 옛 이미지인지 구분할 수 없습니다.

실제 작업 흐름

순서 행동 확인할 결과
1 tar 적재 Loaded image: ... 출력
2 적재된 이미지 이름·태그 확인 :55000/...:<기존 tag> 표시
3 레지스트리 주소·새 태그로 tag :50000/...:<새 tag> 추가 표시
4 대상 주소 대조 3.2에서 기록한 Deployment 주소와 일치
5 레지스트리에 push 오류 없이 종료
6 레지스트리에서 pull 오류 없이 종료

목적: 반입한 이미지를 클러스터 전 노드가 받을 수 있는 레지스트리에 새 태그로 올립니다.
완료 기준: push·pull이 오류 없이 끝나면 이미지 교체(재배포)를 진행할 준비가 된 것입니다.

4.1 이미지 적재

nerdctl load -i sample-api.tar
nerdctl load -i sample-web.tar
nerdctl images | grep -iE 'sample-api|sample-web'

Loaded image: 줄에 나온 이름이 다음 tag 명령의 원본 이름입니다. 작업 계획서와 다르면 출력된 이름을 씁니다.

4.2 재태깅

nerdctl tag registry.example.local:55000/myproject/sample-api:<기존 tag> \
            registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl tag registry.example.local:55000/myproject/sample-web:<기존 tag> \
            registry.example.local:50000/myproject/sample-web:<새 tag>
nerdctl images | grep -iE 'sample-api|sample-web'

⚠️ 대상 주소는 3.2에서 기록한 Deployment의 현재 image: 레지스트리 주소와 같아야 합니다. 다르면 이후 재배포 때 Pod가 ImagePullBackOff에 빠집니다.

4.3 레지스트리 업로드 및 확인

nerdctl push registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl push registry.example.local:50000/myproject/sample-web:<새 tag>

nerdctl pull registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl pull registry.example.local:50000/myproject/sample-web:<새 tag>

4.4 오류가 날 때

증상 확인할 것
server gave HTTP response to HTTPS client HTTP 레지스트리입니다. 운영 절차에 따라 --insecure-registry 사용 여부를 확인합니다.
x509: certificate signed by unknown authority 사내 CA 인증서가 노드에 등록되어 있는지 확인합니다. 임의로 검증을 끄지 않습니다.
unauthorized / denied nerdctl login 상태와 push 권한을 확인합니다.
no space left on device df -h로 확인합니다. 정리-1의 "tar 크기 2배" 기준을 다시 봅니다.

다음 글

적재·재태깅까지 끝나면 Deployment의 이미지를 새 태그로 바꾸고(kubectl edit / kubectl set image), kubectl rollout status로 재배포를 검증합니다. 실패하면 3번에서 만든 백업 YAML로 되돌립니다. 이 과정은 다음 글에서 정리합니다.

참고

728x90
  Comments,     Trackbacks
인프라 개념 정리: 보안과 자동화 정리
728x90

[TIL] 보안과 자동화 정리

인프라 보안과 IaC를 정리하며

개발하다 보면 서버가 느려지거나, 외부에서 비정상적인 요청이 들어오거나, 설정을 바꾼 뒤 서비스가 달라지는 상황을 마주한다. 이때 인프라 보안과 운영 자동화의 기본 개념을 알고 있으면 문제의 원인을 더 차분하게 좁힐 수 있다.

이번 글에서는 인프라에서 자주 만나는 보안 사고와 취약점 대응 방법을 먼저 정리하고, 이어서 인프라를 코드로 관리하는 IaC의 계층과 운영 원칙을 살펴본다.

1. 보안 사고를 보는 기본 관점

보안 사고는 한 가지 기술 결함만으로 발생하지 않는다. 취약한 소프트웨어, 잘못된 설정, 탈취된 계정, 감시 공백이 겹치면서 사고 가능성과 피해가 커진다.

위협, 취약점, 위험

  • 위협: 피해를 일으킬 수 있는 행위자나 사건
  • 취약점: 위협이 악용할 수 있는 약점
  • 위험: 취약점이 악용될 가능성과 예상 피해를 함께 고려한 판단

예를 들어 외부에 공개된 오래된 서비스는 취약점이 될 수 있다. 이를 노리는 공격은 위협이고, 서비스 중단이나 정보 유출이 일어날 가능성은 위험으로 본다.

사고가 반복되는 원인

  • 패치 누락, 공개 범위 오설정, 과도한 권한처럼 사람과 절차에서 생기는 실수
  • 이미 공개된 취약점을 방치하는 문제
  • 단일 장애 지점이나 과도한 신뢰 관계처럼 설계가 피해를 키우는 경우
  • 담당자의 기억과 수동 확인에만 의존해 운영의 일관성이 무너지는 경우

제로데이 취약점은 수정 패치가 제공되기 전에 알려졌거나 악용되는 취약점을 말한다. 패치 적용을 놓친 알려진 취약점과는 구분해야 한다. 제로데이 대응은 패치만 기다리는 데 그치지 않고, 노출 범위를 줄이고 접근을 제한하며 탐지를 강화하는 방식으로 위험을 낮춘다.

2. UDP와 서비스 거부 공격

서비스 거부(DoS) 공격은 네트워크나 시스템 자원을 소진시켜 서비스를 사용할 수 없게 만드는 공격이다. 여러 감염 기기가 동시에 공격하면 분산 서비스 거부(DDoS)라고 한다.

UDP는 연결 설정 절차가 없는 데이터그램 프로토콜이다. 이 특성은 출발지 주소를 위조한 요청과 서버 응답을 이용하는 반사 공격에 악용될 수 있다. UDP 자체가 공격을 뜻하는 것은 아니며, 서버의 역할과 설정에 따라 실제 동작은 달라진다.

반사와 증폭 공격 흐름

  1. 공격자는 요청의 출발지 주소를 피해자 주소로 위조해 여러 UDP 서버에 보낸다.
  2. 요청을 받은 서버가 응답하면 그 응답이 피해자에게 전달된다. 이를 반사 공격이라고 한다.
  3. 요청보다 응답 데이터가 더 큰 경우 피해자는 공격자가 보낸 양보다 많은 트래픽을 받는다. 이를 증폭 공격이라고 한다.

DNS, NTP, SSDP 등 UDP를 사용하는 서비스는 잘못 공개되거나 설정된 경우 반사·증폭 공격에 악용될 수 있다.

DNS 서버 역할 구분

  • 권한 DNS 서버: 특정 도메인 영역의 레코드를 권위 있게 제공한다.
  • 재귀 리졸버: 클라이언트를 대신해 다른 DNS 서버를 조회하고 결과를 캐시한다.
  • 오픈 리졸버: 인터넷의 임의 사용자에게 재귀 질의를 제공하도록 노출된 DNS 리졸버를 말한다.

권한 DNS 서버라고 해서 재귀 질의를 절대 하지 않는다고 단정할 수는 없다. 두 역할은 다르지만 실제 동작은 서버 설정을 확인해야 한다.

운영에서 적용할 방어

  • DNS와 UDP 기반 서비스는 필요한 기능과 대상에만 공개한다.
  • 재귀 DNS 질의의 접근 범위를 제한해 공개 리졸버가 되지 않도록 한다.
  • 출발지 주소 위조 트래픽을 줄이는 네트워크 필터링과 클라우드 사업자의 DDoS 완화 기능을 활용한다.
  • 평소 트래픽 기준선을 기록하고 요청량, 대역폭, 오류율의 급격한 변화를 감시한다.
  • 장애 시 차단·우회·사업자 연락 절차를 미리 정하고 점검한다.

3. 웹 공격과 표적형 공격

드라이브 바이 다운로드

드라이브 바이 다운로드는 사용자가 악성 파일을 직접 실행하지 않았더라도 취약한 브라우저나 플러그인, 잘못 구성된 웹 환경을 통해 악성 코드가 내려오거나 실행되는 공격을 가리킨다.

웹 서버와 브라우저를 최신 상태로 유지하고 불필요한 플러그인을 제거하는 것이 기본이다. 여기에 엔드포인트 보호, 웹 서버 변경 감시, 의심스러운 파일 격리를 더해 피해 가능성을 낮출 수 있다.

SQL 인젝션, 세션 공격, CSRF

SQL 인젝션은 외부 입력이 SQL 구문으로 해석되어 실행되는 취약점이다. 값과 질의 구조를 분리하는 매개변수화 질의를 사용하고, 데이터베이스 계정의 권한을 필요한 범위로 제한해야 한다.

세션 탈취와 인증 우회 위험을 낮추려면 쿠키 보호 설정, 안전한 세션 수명, 다중 요소 인증, 요청마다의 권한 확인을 적용한다.

CSRF(사이트 간 요청 위조)는 사용자가 로그인한 상태를 악용해 의도하지 않은 요청을 보내게 하는 공격이다. 상태를 변경하는 요청에서 CSRF 토큰을 확인하고, 쿠키의 SameSite 설정도 함께 검토한다.

스피어 피싱과 APT

  • 스피어 피싱: 특정 사람이나 조직에 맞춘 메시지로 링크 클릭이나 계정 정보 입력을 유도한다.
  • 소셜 엔지니어링: 사람의 신뢰나 업무 관행을 악용한다.
  • APT(Advanced Persistent Threat): 고도화되고 지속적인 위협을 뜻하며, 장기간 은밀하게 접근을 유지하는 공격 캠페인을 설명할 때 사용한다.

APT는 특정 악성코드나 도구 하나를 뜻하지 않는다. 메일·웹 필터링, 다중 요소 인증, 최소 권한, 계정과 단말 로그 수집, 백업 복구 점검을 함께 적용해야 한다. 계정 탈취가 의심되면 세션과 자격 증명을 폐기하고 영향 범위를 확인한다.

4. 클라우드 보안과 책임 범위

클라우드 보안은 제공자와 고객이 나누어 맡는다. 제공자는 데이터센터와 물리 장비, 가상화 기반처럼 클라우드 자체의 보안을 담당한다. 고객은 선택한 서비스에 따라 데이터, 계정과 권한, 네트워크 규칙, 애플리케이션과 설정을 관리한다.

가상 서버를 직접 운영할 때와 완전 관리형 서비스를 사용할 때 고객이 맡는 범위는 달라진다. 따라서 서비스별 책임 경계를 확인해야 한다.

관련 표준과 관리체계

  • ISO/IEC 27017: 클라우드 서비스의 정보보호 통제를 다루는 지침이다. 클라우드 제공자와 고객의 책임을 포함한다.
  • ISMS: 정보보호 관리체계(Information Security Management System)를 뜻한다. 위험을 식별하고 정책과 통제를 적용한 뒤 점검과 개선을 반복하는 관리 방식이다.

표준과 인증 기준은 개정될 수 있으므로 실제 계약이나 감사에 적용할 때는 해당 시점의 공식 기준을 확인한다.

클라우드 기본 점검

  • 계정과 역할에 최소 권한을 적용하고 관리자 접근에는 강한 인증을 사용한다.
  • 공개 저장소와 네트워크 규칙을 검토한다.
  • 암호화 키와 토큰 같은 비밀정보를 코드 저장소에 두지 않는다.
  • 로그와 백업의 보존 기간, 접근 권한, 복구 책임자를 정한다.
  • 서비스별 제공자 책임과 고객 책임을 문서화한다.

5. 운영 중 취약점과 이상 징후 확인

운영 보안은 자산을 파악하고, 이상을 탐지하고, 영향도를 판단한 뒤, 수정과 재확인을 반복하는 과정이다. 도구 이름만 외우기보다 어떤 질문에 답하기 위해 사용하는지 이해하는 것이 중요하다.

모니터링과 점검 도구

  • SNMP: 네트워크 장비와 서버의 상태 정보를 수집하는 관리 프로토콜이다.
  • Cacti, OpenNMS: 네트워크와 시스템 모니터링에 사용할 수 있는 도구의 예다.
  • syslog, Windows Event Log: 운영체제와 서비스에서 발생한 로그를 수집하는 데 사용한다.
  • ICMP ping: 호스트의 기본 연결성을 확인하지만 애플리케이션이 정상인지까지 보장하지는 않는다. 실제 서비스는 TCP 연결이나 HTTP 요청도 확인해야 한다.
  • Nmap: 네트워크 호스트와 포트를 확인하는 도구다. 소유하거나 명시적으로 허가받은 범위에서만 실행한다.
  • OWASP ZAP: 웹 애플리케이션의 동적 보안 시험에 활용할 수 있다. 테스트 환경과 실행 범위를 먼저 정한다.
  • Greenbone/OpenVAS 계열: 취약점 점검과 결과 관리를 지원한다. 발견 결과는 자산과 버전, 노출 경로를 대조해 확인한다.

취약점 대응 순서

  1. 자산과 외부 노출 범위를 확인하고 취약점 식별자, 영향을 받는 제품과 버전을 기록한다.
  2. CVSS 점수와 벡터를 참고해 기술적 심각도를 파악한다.
  3. 인터넷 노출 여부, 실제 악용 정황, 자산 중요도, 보완 통제와 패치 가능성을 함께 고려해 우선순위를 정한다.
  4. 패치나 설정 변경을 검증 환경에서 확인하고 변경 및 롤백 계획을 세운다.
  5. 적용 후 재검사와 로그 확인으로 해결 여부를 검증한다.
  6. 예외를 둘 경우 책임자, 만료 기한, 보완 통제를 기록한다.

CVSS는 취약점 특성에 따른 심각도 척도다. 조직의 실제 위험도나 조치 순서를 단독으로 결정하는 값은 아니다.

6. 코드로 관리하는 인프라

IaC(Infrastructure as Code)는 서버와 네트워크 같은 인프라의 원하는 상태를 코드나 설정으로 표현하고, 검토·배포·재현하는 방식이다. 변경 이력을 남기고 반복 가능한 절차를 만들면 수작업으로 생기는 누락과 환경 차이를 줄일 수 있다.

다만 잘못된 설정도 빠르게 복제될 수 있다. 코드 검토와 권한 통제를 함께 운영해야 한다.

부트스트래핑 계층

운영체제나 실행 기반을 준비하는 단계다. 데이터센터에서는 OS 설치 자동화가 해당하고, 클라우드에서는 이미지 선택이나 인스턴스 초기화가 이에 해당한다.

Kickstart와 Cobbler는 OS 설치 자동화의 사례다. 클라우드 이미지와 초기화 스크립트도 부트스트래핑의 일부를 맡는다.

구성 관리 계층

OS가 준비된 뒤 패키지, 미들웨어, 사용자, 파일, 서비스 설정을 원하는 상태로 맞춘다. Ansible, Puppet, Chef 등이 구성 관리 도구의 예다.

구성 관리 도구에서 자주 쓰는 개념은 다음과 같다.

  • 선언적: 원하는 최종 상태를 표현하고, 실행 절차 일부를 도구에 맡긴다.
  • 추상화: 개별 명령보다 서비스나 패키지 같은 관리 대상을 중심으로 기술한다.
  • 멱등성: 같은 설정을 여러 번 적용해도 결과가 의도한 상태로 유지되도록 한다.
  • 수렴성: 현재 상태와 원하는 상태의 차이를 줄여 목표 상태에 도달하도록 한다.

오케스트레이션 계층

여러 서버와 서비스의 배포와 연결, 확장을 조율한다. 애플리케이션 배포, 클러스터 구성원 변경, 로드밸런서 대상 등록, 모니터링 대상 등록 등이 여기에 포함된다.

서버를 직접 지정하는 방법은 변경 범위가 분명하지만 반복 작업이 생긴다. 서비스 디스커버리나 클러스터 관리 도구를 통한 자동 등록은 규모 확장에 유리하지만 자동화 규칙과 상태 감시가 필요하다.

Capistrano와 Fabric은 원격 작업이나 배포 자동화에 사용할 수 있다. Serf와 Consul은 클러스터 구성원 정보와 서비스 디스커버리 같은 분산 환경 기능의 사례다. 도구별 기능과 지원 상태는 바뀔 수 있으므로 도입 전 공식 문서를 확인한다.

IaC를 안전하게 운영하기

  • 코드를 버전 관리에 저장하고 동료 검토와 자동 검증을 거쳐 적용한다.
  • 비밀번호와 토큰, 개인 키를 코드나 상태 파일에 평문으로 저장하지 않는다.
  • 개발·검증·운영 환경의 차이를 명시적으로 관리한다.
  • 실제 인프라와 코드 사이의 설정 편차(drift)를 주기적으로 확인한다.
  • 변경 권한을 최소화하고 실패 시 되돌릴 방법을 준비한다.
  • 큰 변경은 일부 자원에 먼저 적용해 영향을 확인한다.

후기

이번 내용을 정리하면서 인프라 보안은 보안 도구를 설치하는 것만으로 끝나는 일이 아니라는 점을 알게 됐다. 안전한 설정과 최소 권한을 바탕으로 로그와 지표를 계속 확인하고, 취약점이 실제 서비스에 미치는 영향까지 살펴야 대응 우선순위를 제대로 정할 수 있다.

또 IaC를 사용하면 인프라 변경을 반복하고 재현하기 쉬워지지만, 잘못된 설정도 여러 환경에 빠르게 퍼질 수 있다는 점을 배웠다. 그래서 코드를 검토하고 비밀정보를 보호하며 실제 설정과 코드가 일치하는지 확인하는 과정이 자동화만큼 중요하다. 앞으로 인프라 문제를 만나면 도구부터 찾기보다 어떤 자산이 영향을 받는지, 책임 범위와 변경 이력이 무엇인지 먼저 확인해야겠다.

핵심만 다시 정리

  • 사고 원인은 기술 결함뿐 아니라 설정 실수, 계정 문제, 변경 절차, 감시 공백에서도 생긴다.
  • UDP 반사·증폭 공격은 주소 위조와 공개 서비스의 설정 문제를 악용할 수 있다.
  • 클라우드 책임 범위는 사용하는 서비스 형태에 따라 달라진다.
  • 취약점 점수와 스캐너 결과는 판단을 돕는 자료이며 실제 위험을 함께 따져야 한다.
  • IaC를 안전하게 쓰려면 변경 검토, 비밀정보 보호, 편차 확인이 필요하다.

참고 자료

728x90
  Comments,     Trackbacks
하네스 엔지니어링이란? - (end)
728x90

Claude Code 하네스로 디버깅 / RCA 팀 꾸리기 — "로컬에서는 잘 되는데요"

리뷰 팀은 있는 코드를 평가했고, 구현 팀은 없는 기능을 만들었고, 마이그레이션 팀은 같은 변환을 수천 번 반복했다. 이번엔 원인을 모르는 문제를 다룬다. 디버깅은 추측이 가장 쉽게 끼어드는 일이라, 이번 하네스의 목표는 하나다. 추측을 증거로 바꾸는 순서를 강제하는 것.
참고 저장소: https://github.com/revfactory/harness-engineering-with-cc (ex-14-10-application-guide)


1. 디버깅이 왜 하네스가 필요한 일인가

혼자 디버깅할 때를 떠올려 보면 이렇다. 버그 보고를 받고, 코드를 훑고, "아마 이거겠지" 하고 고치고, 테스트가 통과하면 끝. 재현도, 가설 검증도, 같은 실수가 다른 곳에 있는지 확인도 안 한다. 운 좋게 맞으면 다행이고, 틀리면 버그는 다른 모양으로 돌아온다.

Claude에게 "이 버그 고쳐 줘"라고 시켜도 똑같다. 오히려 더 빠르게 추측하고 더 자신 있게 고친다. 그래서 이번 하네스는 네 가지 장치로 그 지름길을 막는다.

장치 막는 것
단계별 담당 분리 재현·분석·가설·수정이 한 머릿속에서 뒤섞이는 것
결정론 게이트 재현도 안 하고 가설로 넘어가는 것
하이브리드 구조 탐색은 느려지고, 수정 루프는 끝없이 도는 것
피시본 분석 떠오르는 원인 하나에 꽂혀 나머지를 안 보는 것

2. 저장소에서 가져온 것, 직접 설계한 것

저장소의 디버깅 예제에는 응용 가이드 폴더에 프로덕션 장애용 reviewer.md 하나만 있었다. 핵심 4인 에이전트, 오케스트레이터, 샘플 버그는 없었다. 대신 reviewer.md가 검토하는 산출물 이름(reproduction.sh, hypotheses.md, fix.patch, completion-checklist.md)이 나와 있어서, 그 이름과 5단계 워크플로에 맞춰 나머지를 직접 설계했다.


3. 시나리오: 로컬에서는 통과, CI에서는 실패

실습용 버그는 타임존 버그로 만들었다.

// src/settlement.js — 정산 마감일 계산
export function businessDate(isoString) {
  const d = new Date(isoString);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

getDate()는 코드를 실행하는 컴퓨터의 시간대 기준이다. 한국 시간 11월 1일 새벽 0시 30분은 UTC로는 10월 31일 15시 30분. 그래서 한국 PC에서는 2026-11-01, UTC로 도는 CI 서버에서는 2026-10-31이 나온다. 코드만 봐서는 잘못이 안 보이고, 개발자 PC에서는 테스트가 전부 통과한다. "로컬에서는 잘 되는데요"의 교과서 같은 사례다.

재료는 세 가지를 뒀다.

  • 버그 보고서 reports/BUG-001.md: 증상, "로컬에서는 잘 되는데요"라는 개발자 코멘트, 발생·정상 환경
  • CI 로그 reports/ci.log: 실패 메시지와 함께 Node 버전, LANG=C.UTF-8, TZ=(빈 값). 진짜 단서와 헷갈리게 하는 단서가 섞여 있다
  • 같은 패턴의 두 번째 파일 src/invoice.js: 청구서 발행일도 같은 방식으로 날짜를 뽑는다. 마지막 단계의 "유사 패턴 검색"이 이걸 찾아내는지 보기 위한 함정이다

하네스를 돌리기 전에 정답부터 손으로 확인해 뒀다.

npm test                 # 통과 (로컬 한국 시간)
TZ=UTC npm test          # 실패 (CI와 같은 조건)

4. 팀 구성: 4명

에이전트 맡은 일 model 핵심 권한
reproducer 재현 스크립트, 3회 연속 재현 게이트, 수정 후 재검증 sonnet Bash, Write / Edit 없음
log-analyzer 정상·실패 환경 비교, 타임라인 sonnet Bash, Write / Edit 없음
hypothesis-analyst 피시본 분석, 가설 5개, 반증 실험 설계 opus Write / Bash 없음
fixer 최소 수정, 회귀 테스트, fix.patch sonnet Edit, Bash

지난 실습들에서 배운 대로 역할 경계는 tools 한 줄로 정했다.

코드를 고칠 수 있는 건 fixer뿐이다. 재현 담당이 재현하다가 "이거 고치면 되겠네" 하고 손대는 순간, 재현이 증거에서 추측으로 바뀐다.

별도 verifier가 없다. 수정 후 검증은 reproducer가 맡는다. 처음에 버그를 잡아낸 그 재현 스크립트로 "이제 안 일어난다"를 보여 준다. 같은 자로 재야 전후 비교가 된다.

가설 담당은 실험을 못 한다. Bash가 없어서 반증 실험을 설계만 하고 실행은 reproducer가 한다. 가설을 세운 사람이 자기 가설을 검증하면 확증 편향이 끼어든다. 구현 팀에서 "만든 사람이 검증하면 자기 확증"이라고 정리했던 원리가 여기서는 "가설 세운 사람이 실험하면 자기 확증"으로 나타난다.

opus는 가설 담당 하나다. 여러 증거를 동시에 놓고 범주별로 원인을 따지는, 판단이 가장 무거운 자리라서다.


5. 워크플로: 5단계와 두 개의 게이트

단계 하는 일 모드 다음으로 가는 문
1. 준비 보고서 파싱, _workspace/bug-001/ 생성 메인 세션 report.md 정리
2. 관찰 재현 + 로그 분석 병렬 팀 게이트 1: 재현 3회 연속
3. 가설 피시본 → 가설 5개 → 우선순위 → 반증 실험 서브 1순위 가설이 실험으로 지지됨
4. 수정-검증 수정 → 재현 스크립트 확인, 최대 3회 서브 게이트 2: 수정 후 3회 연속 통과
5. 정리 포스트모템 + 유사 패턴 검색 메인 세션 완료 체크리스트

6. 결정론 게이트: "3회 연속"의 의미

reproducer의 정의 파일에서 가장 중요한 줄은 이것이다.

3회 중 결과가 갈리면 "비결정적"으로 보고하고 게이트를 통과시키지 않는다.

한 번 재현은 우연일 수 있다. 두 번 실패, 한 번 성공이면 그건 재현이 아니라 운이다. 그 상태로 가설 단계에 가면 가설을 확인할 방법이 없다. 실험 결과가 바뀌어도 그게 가설 때문인지 운 때문인지 모르니까.

그리고 재현 스크립트에는 대조군이 같이 들어간다. "UTC에서 실패"만으로는 부족하고, 같은 스크립트 안에서 "한국 시간에서는 통과"까지 보여야 한다. 그래야 시간대가 원인의 경계선이라는 게 증명된다.

reproduction.sh 3회 실행
  1회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
  2회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
  3회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
→ 게이트 1 통과

같은 스크립트가 수정 후에는 반대 방향의 게이트가 된다. 3회 모두 양쪽 다 통과해야 수정이 인정된다. 게이트를 사람의 느낌이 아니라 반복 실행 결과로 정한다는 것, 이게 이번 실습에서 배운 첫 번째 지식이다.


7. 하이브리드 구조: 왜 관찰은 팀, 수정은 서브에이전트인가

지금까지 세 팀은 모드가 하나였다. 리뷰 팀과 마이그레이션 팀은 서브에이전트, 구현 팀은 팀 모드. 디버깅 팀은 한 워크플로 안에서 둘을 섞는다.

관찰은 탐색이다. log-analyzer가 CI 로그에서 TZ=가 비어 있는 걸 발견하는 순간, reproducer가 바로 TZ=UTC로 돌려 봐야 빠르다. 메인 세션을 거쳐 전달하면 느리고 맥락이 끊긴다. 그래서 둘을 한 팀으로 묶어 직접 대화하게 둔다.

수정-검증은 통제다. 고치고, 재고, 실패하면 다시. 이 루프의 횟수를 메인 세션이 정확히 세고 있어야 3회 상한이 지켜진다. fixer와 reproducer를 한 팀으로 묶어 직접 대화하게 두면 저희끼리 몇 번이고 주고받으며 상한이 무너진다. 그래서 메인 세션이 한 명씩 부른다.

탐색 구간은 팀으로, 통제 구간은 서브에이전트로.

모드를 "이 하네스 전체에 하나"로 고르는 게 아니라 구간마다 일의 성격을 보고 고른다. 구현 팀에서 패턴을 Phase마다 조합했던 것처럼, 이번엔 실행 모드를 단계마다 조합한 셈이다.

관찰 단계에 팀 모드가 들어가니 이번엔 환경 변수가 필요하다.

export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
claude

8. 피시본: 가설을 떠올리지 말고 펼친다

가설 담당에게는 원인 범주 여섯 개를 줬다.

코드 / 환경 / 의존성 / 데이터 / 테스트 / 프로세스

규칙은 범주마다 최소 하나씩 요인을 적고, 해당 없으면 "해당 없음 + 이유"를 적는 것. 이렇게 하면 처음 눈에 띈 원인에 꽂혀 나머지를 건너뛰는 일이 막힌다. 이번 버그로 펼치면 대략 이런 모양이 나와야 한다.

범주 요인
코드 실행 환경 시간대에 의존하는 날짜 getter
환경 CI의 TZ 미설정(UTC), 로컬은 한국 시간
의존성 Node 버전 차이 (로컬 vs CI v20)
데이터 자정 직후 타임스탬프 (시간대 경계)
테스트 로컬 시간대에서만 실행, 시간대 고정 없음
프로세스 CI에 시간대 매트릭스 없음

여기서 가설 5개를 고르고, 가설마다 반증 실험을 붙인다.

  • 시간대 가설이 맞다면 → TZ=Asia/Seoul로 돌리면 통과해야 한다
  • Node 버전 가설이 맞다면 → 시간대는 그대로 두고 Node 버전만 바꿔도 결과가 바뀌어야 한다

반증 실험은 reproducer가 돌린다. 결과가 나오면 가설끼리 말싸움할 필요가 없다. 틀린 가설은 실험으로 지워진다. 이게 "체계적으로 좁힌다"의 실제 모습이었다.

한 가지 더. 가설 담당에게 "단일 원인으로 환원하지 않는다, 직접 원인과 기여 요인을 구분한다"를 줬다. 이 버그의 직접 원인은 코드지만, 기여 요인은 테스트(한국 시간에서만 돎)와 프로세스(CI에 시간대 고정 없음)다. 코드만 고치면 다음 시간대 버그도 또 "로컬에서는 잘 되는" 채로 들어온다.


9. 범위를 지키는 두 줄

이번 하네스에서 의외로 중요했던 건 "하지 않는 일"을 정한 두 줄이다.

fixer: "기여 요인은 고치지 않고 포스트모템 권고로 넘긴다."
fixer가 신나서 CI 설정까지 바꾸기 시작하면 수정 범위가 커지고, 검증이 실패했을 때 무엇 때문인지 가려낼 수 없다. 코드 수정은 최소로, 구조 개선은 포스트모템으로 분리한다.

5단계: "유사 패턴은 목록화만 하고 고치지 않는다."
invoice.js에도 같은 버그가 있다. 하지만 여기서 같이 고치면 이번 수정의 검증 범위가 흐려진다. 유사 패턴 목록은 다음 사고 후보 목록이고, 고치는 건 별도 버그로 다룬다.


10. 하네스 구조

debug_rca/
├── CLAUDE.md                          원칙: 재현 없이 가설 없음, 3회 게이트, blameless
├── reports/
│   ├── BUG-001.md                     버그 보고서
│   └── ci.log                         CI 실패 로그
├── src/settlement.js, invoice.js      버그가 숨은 코드
├── tests/settlement.test.js           로컬에서만 통과하는 테스트
├── .claude/
│   ├── agents/
│   │   ├── reproducer.md
│   │   ├── log-analyzer.md
│   │   ├── hypothesis-analyst.md
│   │   └── fixer.md
│   └── skills/debug-rca/SKILL.md      5단계 오케스트레이터
└── _workspace/bug-001/                실행 시 생성
    ├── report.md
    ├── observations/repro.md, logs.md
    ├── reproduction.sh
    ├── hypotheses.md
    ├── fix.patch
    ├── verification.md
    ├── postmortem.md
    ├── similar-patterns.md
    └── completion-checklist.md

실행은 한 줄이다.

BUG-001 디버깅해 줘

11. 응용: 같은 뼈대, 바꾸는 곳만 다르다

재현 → 가설 → 수정-검증 → 정리라는 뼈대는 그대로 두고, 상황에 따라 게이트의 의미와 담당자의 초점만 바꾼다.

응용 게이트 1 (재현) 바뀌는 담당 게이트 2 (수정 확인)
프로덕션 장애 프로덕션 말고 스테이징·로컬에 재현 환경 구축 log-analyzer → 알람·배포 타임라인 중심. 저장소의 reviewer 추가 핫픽스는 사람이 결정, 하네스는 근본 수정만
플래키 테스트 3회 연속 대신 N회(예: 50회) 실패율 측정 피시본 범주 교체: 실행 순서, 타이밍, 공유 상태, 난수, 네트워크 같은 N회에서 실패 0회
성능 저하 3회 측정 모두 기준선 대비 임계값 이상 저하 + 편차 작음 log-analyzer → 프로파일러 역할 3회 측정 모두 기준선 ± 허용 오차
1인 개발자 그대로 3회 팀 모드 없이 reproducer + fixer 둘만 남김. 로그·가설은 메인 세션이 겸함 그대로 3회, 루프 3회 상한 유지

몇 가지는 짚어 둘 만하다.

프로덕션 장애의 reviewer는 저장소에 있던 유일한 파일인데, 도구가 Read 하나뿐이다. 포스트모템 검토자는 아무것도 실행하거나 고치지 못하고 읽기만 한다. 그리고 "누구 잘못인가" 대신 "시스템이 왜 그걸 허용했나"를 묻고, 권고마다 자동화된 가드(린터, 테스트, 알람 임계값)를 붙이게 되어 있다. 장애 회고가 사람 탓으로 흐르는 걸 구조로 막는다. 이 blameless 원칙은 기본 하네스의 CLAUDE.md에도 헌법으로 넣어 뒀다.

플래키 테스트는 reproducer의 "3회 중 결과가 갈리면 통과시키지 않는다"에서 갈라지는 경로다. 결정론 게이트를 통과하지 못하는 버그는 버리는 게 아니라 통계 게이트로 갈아탄다.

1인 개발자 버전에서 아무리 줄여도 reproducer와 fixer는 반드시 분리한다. 둘이 합쳐지면 "고친 사람이 고쳐졌다고 판정"하게 되어 하네스의 존재 이유가 사라진다. 비용을 줄일 때 지켜야 할 최소 단위가 이 둘이다.


12. 네 팀을 이어 보면 (최종 정리)

리뷰 팀 구현 팀 마이그레이션 팀 디버깅 팀
다루는 일 있는 코드 평가 없는 기능 만들기 같은 변환 반복 모르는 원인 찾기
주 패턴 팬아웃 Phase별 조합 감독자 게이트 + 하이브리드
넘어가는 기준 없음 Phase 산출물 검토 배치 검증 재현 3회 연속
실행 모드 서브 팀 서브 팀 + 서브 혼합
검증 기준 정의 파일 기준 설계 문서 기준선 테스트 재현 스크립트
마지막 산출물 리뷰 보고서 통합 보고서 상태 파일 포스트모템

디버깅 팀에서 새로 얻은 건 두 가지다.

  1. "넘어가도 되는가"를 사람의 느낌이 아니라 반복 실행 결과로 정하는 게이트. 그리고 그 게이트가 통과되지 않을 때 갈아탈 경로(플래키, 성능)까지 미리 설계해 두는 것.
  2. 한 워크플로 안에서도 탐색 구간은 팀으로, 통제 구간은 서브에이전트로 섞는 설계. 패턴을 조합했듯 모드도 조합한다.
728x90

'인공지능' 카테고리의 다른 글

하네스 엔지니어링이란? - (3)  (0) 2026.11.03
하네스 엔지니어링이란? - (2)  (0) 2026.11.02
하네스 엔지니어링이란? - (1)  (0) 2026.11.01
  Comments,     Trackbacks
하네스 엔지니어링이란? - (3)
728x90

레거시 파일 수천 개를 옮기는 하네스 직접 쌓아 보기 — 감독자 패턴 실습기

리뷰 팀은 "동시에 보고 끝내는" 팀이었고, 구현 팀은 "Phase마다 팀을 갈아끼우는" 팀이었다. 세 번째로 만든 건 같은 작은 일이 수천 번 반복되는 일을 맡는 팀이다. 레거시 .jsx 파일들을 TypeScript로 옮기는 마이그레이션. 이번엔 감독자 패턴을 처음으로 직접 짜 봤다.

항목 내용
실습일 2026-11-01
실습 위치 ~/Desktop/side/harness/legacy_migration
만든 것 에이전트 4명 + 감독자 스킬 1개 + CLAUDE.md + 상태 파일 구조
직접 설계한 것 baseline-tester, migration-worker, migration-verifier, legacy-migration 스킬
가져다 쓴 것 dependency-mapper 정의, 샘플 레거시 코드 17개
진행 상황 하네스 구축·검사 완료, 본 실행 결과는 다음 글

1. 시작하며: 이번엔 일의 모양이 다르다

앞의 두 팀을 만들면서 "일의 모양이 패턴을 정한다"는 걸 배웠다. 그래서 이번에도 먼저 일의 모양부터 따져 봤다. 레거시 3,500개 파일을 바꾼다고 치면 성질이 네 가지다.

  1. 반복: 파일 하나 바꾸는 일 자체는 작고 비슷하다. 그게 수천 번.
  2. 순서: UserCard → useAuth → api → http. 아래부터 바꿔야 위가 안 깨진다.
  3. 불균형: 같은 개수여도 어떤 묶음은 쉽고 어떤 묶음은 어렵다.
  4. 중단: 이 정도 양이면 중간에 반드시 끊긴다.

1-1. 처음 떠올린 두 방법이 왜 안 되는가

Claude 하나에게 다 시키기. 제일 먼저 떠오르지만 바로 접었다.

  • 맥락이 넘쳐서 100번째와 3,000번째 파일의 변환 방식이 달라진다.
  • 어디까지 했는지가 대화 속에만 있어서 끊기면 처음부터다.
  • 한 파일이 깨져도 다음으로 넘어가 버린다.

리뷰 팀처럼 처음에 똑같이 나누기(고정 팬아웃). 리뷰 팀에서 잘 썼으니 이번에도 될까 싶었는데 아니었다.

  • 워커 A가 UserCard를 바꾸는 동안 그게 쓰는 http는 워커 C가 아직 손도 안 댔을 수 있다. 순서가 찢어진다.
  • 쉬운 묶음 받은 워커는 놀고, 어려운 묶음 받은 워커는 한참 걸린다.
  • 이미 다 나눠 준 뒤라 실패한 일을 다시 나눌 수가 없다.

1-2. 그래서 감독자

정리하고 보니 필요한 건 대기열을 쥔 사람이었다. 주방장이 주문서를 들고 있다가, 요리사 손이 비면 지금 바로 만들 수 있는 주문 하나를 건넨다. 소스가 아직 안 끓었으면 그 소스가 들어가는 요리는 내주지 않는다. 일을 미는 게 아니라 워커가 가져가는 구조다.

  혼자 다 하기 처음에 나누기 감독자
분배 시점 없음 처음 한 번 워커가 빌 때마다
의존 순서 머릿속 찢어짐 대기열이 지킴
일의 불균형 — 노는 워커 자동 흡수
실패 처리 묻힘 못 옮김 재배정, 상한 넘으면 사람
중단 후 재개 처음부터 어려움 상태 파일에서 이어서

2. 팀 설계: 4단계, 4명 + 감독자

예제 저장소를 열어 보니 이번 폴더엔 dependency-mapper 에이전트 하나와 샘플 코드 17개뿐이었다. README에 나오는 mock-mapper.sh, verify.sh도 없었다. 리뷰 팀 때 "저장소는 단계별 스냅샷"이라는 걸 한 번 겪어서 이번엔 덜 당황했다. 나머지 팀원은 직접 설계하기로 했다.

먼저 워크플로를 4단계로 잡았다.

단계 담당 하는 일 패턴
1. 의존 매핑 dependency-mapper import 그래프 → 배치와 순서 —
2. 기준선 테스트 baseline-tester 변환 전 동작을 테스트로 박제 —
3. 점진 변환 감독자 + migration-worker 여럿 시작 가능한 배치를 하나씩 가져감 감독자
4. 검증 migration-verifier 배치마다 PASS/FAIL 판정 생성-검증

구현 팀에서 배운 "패턴의 층"이 여기서도 그대로 보였다. 바깥 뼈대는 파이프라인(1→2→3·4), 안쪽 엔진은 감독자와 생성-검증. 다만 이번엔 3단계와 4단계가 배치 단위로 맞물려 계속 돈다.

[1. 의존 매핑] ──▶ [2. 기준선 테스트] ──▶ ┌──────── 3·4단계 루프 (배치 단위) ────────┐
 import 그래프       변환 전 동작 박제       │ 감독자(메인 세션) ── 대기열(batches.json) │
 → 배치·순서         전부 통과 못 하면 정지  │   │ 시작 가능 배치 출고 (최대 3 동시)      │
                                          │   ▼                                    │
                                          │ migration-worker ×N ──▶ migration-verifier│
                                          │        ▲   FAIL(사유)       │ PASS        │
                                          │        └── pending 복귀 ◀───┘ → verified  │
                                          │  3회째 FAIL → 되돌림 + failed + 위층 차단  │
                                          └──────────────────────────────────────────┘

감독자는 에이전트로 만들지 않았다. 구현 팀의 PM처럼 스킬을 읽은 메인 세션이 감독자다. 워커끼리 대화할 일도 없어서 에이전트 팀 모드도 안 켰다. 배치 사이의 정보는 "이미 변환된 파일의 타입을 읽는다"로 파일을 통해 오간다. 일반 서브에이전트로 충분하다.

변환 대상은 샘플이 .jsx라서 JavaScript → TypeScript로 정했다.

2-1. 권한 설계: tools 칸이 곧 직무 분리

담당 모델 tools 일부러 뺀 것
dependency-mapper (원본 유지) Read, Grep, Bash Write — 저장은 감독자가
baseline-tester sonnet Read, Grep, Write, Bash Edit — src는 읽기만
migration-worker sonnet Read, Grep, Write, Edit, Bash (본문에서 tests/·남의 배치·commit 금지)
migration-verifier sonnet Read, Grep, Bash Edit, Write — 판정만

표를 채우면서 의도한 건 세 가지다.

  • 테스트를 쓰는 사람은 코드를 못 고친다 → 테스트 통과시키려고 코드를 바꾸는 일이 불가능.
  • 판정하는 사람은 아무것도 못 고친다 → 보다가 편한 쪽을 슬쩍 고쳐 버리는 일이 불가능. 구현 팀 boundary-verifier에서 배운 그대로다.
  • 아무도 커밋하지 않는다 → 커밋은 내가 한다.

2-2. 모델 설계: 이번엔 전원 sonnet

구현 팀 때 boundary-verifier는 opus였다. 두 파일을 동시에 놓고 "구현 탓이냐 설계 탓이냐"를 추론해야 했으니까. 이번 verifier는 다르다. tsc, vitest, git diff가 내놓는 결과로 판정이 거의 다 정해지고, 판정도 PASS/FAIL 둘뿐이다. 판단이 도구 출력에 있으면 모델은 한 단계 낮춰도 된다고 봤다. 워커도 규칙이 명확하고 배치가 작으니 sonnet. 반복 횟수가 많아서 비용 면에서도 이게 맞다.


3. 판 깔기

mkdir -p ~/Desktop/side/harness/legacy_migration
cd ~/Desktop/side/harness/legacy_migration

git clone --depth 1 https://github.com/revfactory/harness-engineering-with-cc.git /tmp/hewcc
cp -r /tmp/hewcc/ex-13-01-dependency-mapper/sample-src ./src
find src -type f | sort

샘플 17개를 훑어보다가 일부러 심어 둔 함정 두 개를 발견했다.

파일 함정 하네스가 해야 할 일
cyclic-a.jsx, cyclic-b.jsx 서로를 import하는 순환 의존 한 배치로 묶고 cyclic 경고
truncated.jsx 중간에 잘린 파일 (파싱 실패) 배치에서 빼고 manual-queue로

나머지 15개로 의존 층을 직접 그려 봤다. 실행했을 때 맞는지 비교할 정답지다.

1층 (의존 없음)   utils/http, utils/format, components/Footer, standalone
2층              utils/api
3층              hooks/useAuth, hooks/useFetch, components/Header
4층              context/SessionContext, components/UserCard
5층              components/Profile, components/UserList
6층              pages/Dashboard, pages/Settings
따로             cyclic-a + cyclic-b (한 배치, cyclic 경고)
제외             truncated (manual-queue)

되돌리기를 git으로 할 거라 레거시 상태를 먼저 커밋해 두고, TypeScript와 vitest를 깔았다.

git init
git add src
git -c user.name=practice -c user.email=practice@local commit -m "legacy baseline"

npm init -y
npm install -D typescript vitest
npm pkg set scripts.test="vitest run" scripts.typecheck="tsc --noEmit"

cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "preserve",
    "strict": true,
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
EOF

allowJs: true가 중요했다. 마이그레이션 도중엔 .tsx와 .jsx가 섞여 있다. 이 상태에서도 타입 체크가 돌아야 배치마다 검증할 수 있다.


4. CLAUDE.md: 원칙 네 줄

cat > CLAUDE.md << 'EOF'
# legacy_migration — 레거시 마이그레이션 하네스

## 개요
- 진입점: legacy-migration 스킬 ("src를 TypeScript로 마이그레이션해 줘")
- 메인 세션이 감독자. 워커는 서브에이전트
- 상태 파일: _workspace/migration/batches.json (진행 상태의 유일한 기준)

## 워크플로
1. 의존 매핑 → 2. 기준선 테스트 → 3. 점진 변환 → 4. 배치별 검증

## 원칙
- 마이그레이션은 동작을 바꾸지 않는다. 버그도 그대로 옮긴다. 고치는 건 별도 작업
- 기준선 테스트는 변환 중 절대 수정하지 않는다
- 배치 실패가 상한을 넘으면 그 배치 파일을 git으로 되돌리고 manual-queue에 기록
- 누구도 git commit 하지 않는다. 커밋은 사람이 한다
EOF

"버그도 그대로 옮긴다"를 넣을지 한참 고민했다. 바꾸는 김에 고치면 좋지 않나? 그런데 그러면 테스트가 깨졌을 때 변환이 잘못된 건지 일부러 고친 건지 구분이 안 된다. 기준이 흔들리면 검증 자체가 의미가 없어진다. 그래서 고치는 건 별도 작업으로 뺐다.


5. 팀원 명부

5-1. dependency-mapper: 가져와서 배치 크기만 줄였다

mkdir -p .claude/agents
cp /tmp/hewcc/ex-13-01-dependency-mapper/.claude/agents/dependency-mapper.md .claude/agents/
sed -i 's/10~50/2~4/' .claude/agents/dependency-mapper.md

정의를 열어 보니 tools: Read, Grep, Bash로 Write가 없고, 복잡도 공식과 배치 필드 8개가 들어 있었다.

필드 의미
id 배치 식별자
files 배치에 속한 파일
depends_on 먼저 verified 돼야 하는 배치
complexity 복잡도 점수 (감독자의 우선순위)
pattern 변환 유형
status pending → in_progress → converted → verified | failed
attempts / max_attempts 시도 횟수 / 상한

배치 크기는 원래 10

50이다. 그런데 파일이 17개뿐이라 그대로 두면 배치가 한두 개밖에 안 나오고, 감독자가 일을 나눠 주는 모습을 볼 수가 없다. **분배가 눈에 보이도록 2

4로 줄였다.**

5-2. baseline-tester: "올바른 동작"이 아니라 "지금의 동작"

cat > .claude/agents/baseline-tester.md << 'EOF'
---
name: baseline-tester
description: 마이그레이션 전에 레거시 코드의 현재 동작을 테스트로 박제한다. 변환 전 코드에서 전부 통과해야 한다. 기준선, 박제, 회귀 테스트 키워드에서 사용.
model: sonnet
tools: Read, Grep, Write, Bash
---

## 역할
- src의 export마다 현재 동작을 그대로 기록하는 테스트를 쓴다.
- 기대값은 "올바른 동작"이 아니라 "지금의 동작"이다. 버그도 그대로 박제한다.

## 입력
- src/ 전체
- batches.json (manual-queue에 들어간 파일은 제외)

## 절차
1. 파일마다 export 목록을 뽑는다.
2. 외부 호출(fetch 등)은 vi.fn으로 막고, 무엇이 어떤 인자로 호출되는지까지 기록한다.
3. tests/baseline/에 테스트 작성. import 경로에 확장자를 쓰지 않는다 (.jsx → .tsx 후에도 그대로 동작하도록).
4. npm test 실행. 레거시 코드에서 전부 통과해야 한다.

## 출력
- tests/baseline/*.test.js
- 통과 결과 요약 (파일별 테스트 수)

## 에러 핸들링
- src 파일은 읽기만 한다. Edit이 없다.
- 실행하면 문제가 생기는 동작(예: 무한 재귀)은 "그 문제가 일어난다"를 기대값으로 박제한다.
- 레거시에서 통과하지 않는 테스트는 기준선이 아니다. 통과할 때까지 테스트를 고친다.
EOF

이 에이전트를 쓰면서 마이그레이션 검증의 핵심이 뭔지 정리됐다. "동작이 안 바뀌었다"를 확인하려면, 바꾸기 전 동작을 먼저 사진 찍어 둬야 한다. 순환 의존 파일이 스택을 터뜨리면 "스택이 터진다"가 정답이다.

작은 요령 하나: import 경로에 확장자를 안 쓰게 했다. 그래야 .jsx가 .tsx로 바뀌어도 테스트를 한 글자도 안 고쳐도 된다.

5-3. migration-worker: 유일하게 손을 가진 팀원

cat > .claude/agents/migration-worker.md << 'EOF'
---
name: migration-worker
description: 감독자에게 배치 하나를 받아 그 배치의 파일만 JavaScript에서 TypeScript로 변환한다. 여러 명이 동시에 일한다. 변환, 마이그레이션 워커 키워드에서 사용.
model: sonnet
tools: Read, Grep, Write, Edit, Bash
---

## 역할
- 받은 배치의 파일만 변환한다. 다른 배치 파일은 건드리지 않는다.
- 동작은 바꾸지 않는다. 버그도 그대로 옮긴다.

## 입력
- 배치 하나 (id, files, pattern)
- 재시도라면 이전 검증 실패 사유

## 변환 규칙
1. .jsx → .tsx (JSX가 없으면 .ts). git mv로 이름을 바꿔 이력을 남긴다.
2. 함수 인자와 반환값에 타입을 붙인다. 모르면 unknown으로 두고 any는 쓰지 않는다.
3. import 경로는 확장자 없이 유지한다.
4. 로직, 문자열, 호출 순서는 그대로 둔다.

## 절차
1. 배치 파일과, 그 파일이 import하는 이미 변환된 파일의 타입을 읽는다.
2. 변환 규칙대로 바꾼다.
3. npx tsc --noEmit으로 이 배치 파일에 오류가 없는지 확인한다.
4. 감독자에게 변환한 파일 목록과 특이사항을 돌려준다.

## 에러 핸들링
- tests/ 아래 파일은 절대 수정하지 않는다.
- 동작을 바꿔야만 타입이 맞는 경우 변환을 멈추고 감독자에게 사유를 보고한다.
- git commit 금지.
EOF

리뷰 팀 refactorer 때처럼, 손을 가진 팀원일수록 제약을 많이 걸었다. 특히 마지막 탈출구 — "동작을 바꿔야만 타입이 맞으면 멈추고 보고" — 를 꼭 넣고 싶었다. 억지로 타입을 맞추다 로직을 건드리는 게 마이그레이션에서 제일 조용히 틀리는 방식이라서다.

5-4. migration-verifier: 판정 순서를 고정했다

cat > .claude/agents/migration-verifier.md << 'EOF'
---
name: migration-verifier
description: 변환이 끝난 배치 하나를 검증해 PASS 또는 FAIL만 판정한다. 직접 고치지 않는다. 검증, 배치 검증 키워드에서 사용.
model: sonnet
tools: Read, Grep, Bash
---

## 역할
- 배치 하나가 변환 규칙을 지켰고 동작이 그대로인지 판정한다.

## 판정 순서 (반드시 이 순서로)
1. npm run typecheck — 이 배치 파일에서 타입 오류가 있으면 FAIL
2. npm test — 기준선 테스트 중 하나라도 실패하면 FAIL (동작이 바뀜)
3. git diff로 이 배치 외 파일이나 tests/가 바뀌었으면 FAIL (경계 침범)
4. any 사용, 로직 변경 흔적이 있으면 FAIL
5. 모두 통과하면 PASS

## 출력
- verdict: PASS | FAIL
- 실패 단계 번호, 파일:라인, 근거 (도구 출력 인용)

## 에러 핸들링
- Edit, Write가 없다. 직접 고치지 않는다.
- 도구 출력 없이 판정하지 않는다.
EOF

boundary-verifier를 고치면서 얻은 교훈 — 판정 에이전트에는 판정의 뜻이 아니라 순서를 준다 — 를 처음부터 적용했다. 그리고 boundary-verifier와 비교해 보니 차이가 분명했다.

  boundary-verifier (구현 팀) migration-verifier (이번)
판정 PASS / FIX / REDO PASS / FAIL
되돌아갈 곳 구현 또는 설계 워커뿐 (설계 문서가 없음)
기준 설계 문서 두 개 기준선 테스트 하나
모델 opus sonnet

REDO가 없는 이유는 간단하다. 되돌아갈 설계가 없다. 기준이 기준선 테스트 하나라 PASS/FAIL이면 충분하다. "도구 출력 없이 판정하지 않는다"는 리뷰 팀에서 haiku 담당이 근거 없이 지적하던 걸 보고 넣은 줄이다.


6. 감독자 스킬: 이번 실습의 핵심

mkdir -p .claude/skills/legacy-migration
cat > .claude/skills/legacy-migration/SKILL.md << 'EOF'
---
name: legacy-migration
description: 레거시 코드 대규모 마이그레이션을 감독자 패턴으로 진행한다. "마이그레이션해 줘", "TypeScript로 바꿔 줘" 요청에서 사용.
---

# 레거시 마이그레이션 감독자

메인 세션이 감독자다. 직접 변환하지 않는다. 대기열을 관리하고 일을 나눠 주고 판정을 받는다.

## 상태 파일
- _workspace/migration/batches.json — 진행 상태의 유일한 기준. 상태가 바뀔 때마다 즉시 저장한다.
- _workspace/migration/manual-queue.md — 사람이 봐야 할 파일과 사유
- 배치 status: pending → in_progress → converted → verified | failed

## 1단계: 의존 매핑
- dependency-mapper에게 target_glob "src/**/*.jsx"를 넘긴다.
- 돌려받은 batches.json 초안을 저장한다. max_attempts가 비어 있으면 3.
- 파싱 실패 파일은 manual-queue.md에 기록한다.
- cyclic 경고가 붙은 배치를 사용자에게 알린다.

## 2단계: 기준선 테스트
- baseline-tester를 부른다.
- npm test가 레거시 코드에서 전부 통과해야 3단계로 간다. 하나라도 실패하면 멈춘다.

## 3단계: 점진 변환 (감독자 루프)
반복한다, pending 배치가 없어질 때까지:
1. 시작 가능 배치 = status가 pending이고 depends_on의 모든 배치가 verified인 것
2. 비어 있는 워커 자리(최대 3)만큼 시작 가능 배치를 꺼낸다. complexity가 높은 것부터.
3. 꺼낸 배치를 in_progress로 바꾸고 저장한 뒤 migration-worker에게 하나씩 넘긴다.
4. 워커가 돌아오면 converted로 바꾸고 4단계 검증으로 보낸다.
5. 워커 자리가 비면 바로 1로 돌아가 다음 배치를 꺼낸다. 다른 워커를 기다리지 않는다.

## 4단계: 배치별 검증 (생성-검증 루프)
- migration-verifier에게 배치를 넘긴다.
- PASS → verified로 저장. 이 배치를 기다리던 배치들이 시작 가능해진다.
- FAIL → attempts + 1
  - attempts < max_attempts: 실패 사유를 붙여 pending으로 되돌린다 (다시 대기열로)
  - attempts = max_attempts: 그 배치 파일을 git checkout으로 되돌리고 failed로 저장, manual-queue.md에 기록.
    이 배치에 의존하는 배치들도 failed로 막고 사유를 남긴다.

## 끝
- verified / failed / manual-queue 건수를 사용자에게 보고한다.
- 커밋은 하지 않는다.
EOF

쓰고 나서 다시 읽어 보니 감독자 패턴을 결정하는 건 결국 세 줄이었다.

  1. "다른 워커를 기다리지 않는다." 이 한 줄이 팬아웃과 감독자를 가른다. 팬아웃은 셋이 다 끝나야 다음 라운드, 감독자는 끝난 자리부터 바로 채운다. 이 줄이 빠지면 그냥 라운드제 팬아웃이 된다.
  2. 시작 조건은 converted가 아니라 verified. 처음엔 converted로 썼다가 고쳤다. 검수 안 된 기초 위에 2층을 올리면, 아래가 나중에 FAIL 났을 때 위까지 다 다시 해야 한다.
  3. FAIL은 재배정, 상한이면 되돌리고 위층까지 막는다. 반쯤 바뀐 파일을 남기지 않고, 깨진 기초 위에 쌓지 않는다.

complexity 높은 것부터 꺼내는 것도 의도가 있다. 오래 걸리는 일을 먼저 출발시켜야 마지막에 한 워커만 혼자 붙잡고 있는 꼬리가 짧아진다.

6-1. 상태 파일 하나로 끝까지

구현 팀 때까지는 진행 상태가 Phase 산출물에 흩어져 있었다. 이번엔 수천 번 반복이라 그러면 안 된다. 모든 진행 상태를 batches.json 하나에 두고, 바뀔 때마다 즉시 저장하게 했다. 세션이 끊기면 새 세션이 이 파일만 읽고 이어 가면 되고, 나도 이 파일 하나만 열면 진행률을 안다.

pending ──▶ in_progress ──▶ converted ──▶ verified
   ▲                                          
   └──── FAIL (3회 미만, 실패 사유 첨부) ───────┘
                    FAIL 3회째 ──▶ failed (되돌림 + manual-queue + 위층 차단)

7. 결재함 만들고 검사하기

mkdir -p _workspace/migration tests/baseline
echo "_workspace/" >> .gitignore
echo "node_modules/" >> .gitignore
find CLAUDE.md .claude tsconfig.json -type f | sort
ls package.json tsconfig.json
claude plugin validate .claude/agents

여기서 하나 당황한 게 있었다. 에이전트가 제대로 등록됐는지 보려고 /agents를 쳤는데 안 먹었다. 이 버전에선 /agents 마법사가 빠졌다. /list-agents는 에이전트가 아니라 세션 목록이었다. 결국 이렇게 확인했다.

  • 세션에서 @mig, @dep, @base를 치면 자동완성에 (agent) 표시가 붙은 항목 4개가 뜨는지
  • 파일 형식은 claude plugin validate .claude/agents로

그리고 리뷰 팀 때 데인 기억이 있어서 이번엔 처음부터 지켰다. 정의 파일이 있는 legacy_migration 폴더에서 새 세션을 연다. 세션 중에 폴더를 옮겼다가 에이전트가 정식 로드되지 않아 tools 제한이 무시됐던 사건 — verifier의 "Edit 없음"이 그냥 부탁이 돼 버리면 이 하네스는 의미가 없다.

cd ~/Desktop/side/harness/legacy_migration
claude

처음 돌릴 땐 shift+tab으로 auto mode를 꺼서 단계마다 눈으로 확인하고, 진행 중엔 /tasks로 워커와 모델을 볼 생각이다.

src를 TypeScript로 마이그레이션해 줘

8. 돌리기 전에 짚어 본 것들

설계를 마치고 다시 훑어보니 걸리는 데가 몇 군데 있었다. 실행하면서 실제로 터지는지 같이 보려고 적어 둔다.

# 걸리는 점 왜 대응 후보
1 병렬 워커 간 오판정 워커 3명이 같은 작업 트리에서 일한다. verifier의 git diff에 다른 워커의 변경이 섞이면 "경계 침범" FAIL이 잘못 나올 수 있다. npm test도 남의 반쯤 바뀐 파일 때문에 깨질 수 있다 diff를 git diff -- <배치 파일>로 한정, 또는 검증만 직렬화
2 truncated.jsx와 타입 체크 allowJs: true라 tsc가 잘린 JS도 파싱한다. 워커가 돌리는 npx tsc --noEmit이 매번 구문 오류를 볼 수 있다 manual-queue 파일을 tsconfig.exclude에 추가
3 git mv 뒤 되돌리기 이름을 바꾼 뒤 git checkout만으로는 원래 .jsx가 안 돌아오고 .tsx가 남을 수 있다 원래 경로 restore + 새 파일 삭제를 절차로 명시
4 컴포넌트 테스트 의존성 깐 건 typescript, vitest뿐. 컴포넌트를 렌더링하는 기준선이라면 react 등이 더 필요할 수 있다 샘플 import 보고 사전 설치
5 차단 범위 "의존 배치도 failed"가 바로 위층만인지 그 위 전부인지 애매하다 그래프 상위 전체를 막는다고 명시

9. 실행하면 확인할 것

단계 확인 항목 결과
1. 의존 매핑 내가 그린 층 구조와 맞는가 / 순환 두 파일이 한 배치 + 경고 / truncated → manual-queue ☐
2. 기준선 레거시에서 전부 통과 / 순환 파일의 "스택 터짐"이 박제되는가 ☐
3. 점진 변환 1층부터 나가는가 / 한 워커가 끝나자마자 다음 배치가 나가는가 / 남의 배치 파일을 안 건드리는가 ☐
4. 검증 FAIL이 사유와 함께 대기열로 돌아가는가 / 3회째면 되돌려지고 위층이 막히는가 ☐
전체 batches.json만 보고 진행 상황을 알 수 있는가 ☐
8장 걸리는 점 1~5 중 실제로 터진 것 ☐

10. 세 팀을 나란히 놓고 보니

  리뷰 팀 구현 팀 마이그레이션 팀
일의 모양 한 번 보고 끝 단계마다 다른 일 같은 일 수천 번
주 패턴 팬아웃 & 팬인 Phase별 조합 감독자 + 생성-검증
분배 처음에 한 번 Phase마다 팀 교체 워커가 빌 때마다
진행 상태 보고서 Phase 산출물 상태 파일 하나
검증 기준 정의 파일의 기준 설계 문서 기준선 테스트
워커 간 대화 없음 필요 (팀 모드) 없음 (서브에이전트)
verifier 모델 — opus sonnet

리뷰 팀이 한 장면을 잘 찍는 법, 구현 팀이 장면을 이어 붙여 영화를 만드는 법이었다면, 이번 팀은 같은 장면을 수천 번 찍어도 흔들리지 않게 촬영장을 운영하는 법이었다.

11. 정리

  1. 일의 모양이 패턴을 정한다. 반복·순서·불균형·중단, 이 네 가지가 감독자를 불렀다.
  2. 감독자의 본질은 대기열 + 당겨 가기 + 상태 파일이다. 처음에 나누지 않고, 빌 때마다 시작 가능한 일을 준다. "다른 워커를 기다리지 않는다" 한 줄이 팬아웃과의 차이다.
  3. 합격 기준을 기계가 판정할 수 있게 만든다. 지금의 동작을 버그까지 기준선 테스트로 박제하니 verifier가 sonnet + PASS/FAIL로 충분해졌다.
  4. 검수 끝난 것 위에만 쌓는다. 시작 조건은 verified.
  5. 실패는 멈추고 기록하고 사람에게. 상한 → 되돌림 → 위층 차단 → manual-queue.
  6. 앞에서 배운 건 그대로 재사용된다. 같은 뼈대의 정의 파일, tools로 권한 분리, 판정 순서 명시, 커밋은 사람, 정의 파일 위치에서 새 세션.

다음엔 실제로 돌려서 batches.json과 manual-queue.md를 들고 9장 표를 채우고, 8장에서 걸렸던 점들이 실제로 터지는지 확인할 생각이다. 일부러 FAIL을 유도해서 3회 차단까지 가는지도 볼 예정이다.


참고

728x90

'인공지능' 카테고리의 다른 글

하네스 엔지니어링이란? - (end)  (0) 2026.11.04
하네스 엔지니어링이란? - (2)  (0) 2026.11.02
하네스 엔지니어링이란? - (1)  (0) 2026.11.01
  Comments,     Trackbacks
하네스 엔지니어링이란? - (2)
728x90

리뷰 팀 다음에 구현 팀을 만들며 알게 된 것: 에이전트 설계에서 워크플로 설계로

리뷰 팀을 만들 때는 "에이전트 한 명을 어떻게 잘 정의하는가"를 배웠다. 구현 팀을 쌓아 보니 질문이 바뀌었다. "여러 팀을 시간 순서대로 어떻게 굴리는가." 이번 실습에서 얻은 하네스 엔지니어링 지식은 세 가지다.

  1. 패턴 4개를 Phase별로 조합하는 법
  2. 경계면만 보는 boundary-verifier가 왜 따로 있어야 하고, 어떻게 판정해야 하는가
  3. feature-pm이 Phase마다 역할을 바꾸고 팀을 갈아끼우는 타이밍


1. 패턴은 고르는 게 아니라 Phase마다 조합한다

리뷰 팀은 패턴 하나로 충분했다. 네 명이 동시에 읽고(팬아웃) 보고서를 모으면(팬인) 끝. 리뷰라는 일이 처음부터 끝까지 같은 모양이었기 때문이다.

구현은 다르다. 요구사항을 쪼개는 일, 설계하는 일, 코드를 짜는 일, 채점하는 일은 모양이 전부 다르다. 그래서 Phase마다 "지금 이 일은 어떤 모양인가"를 묻고 거기 맞는 패턴을 꺼낸다.

이 일에 대한 질문 답이 "예"면 이번 팀에서 쓰인 곳
일 전체를 누가 쪼개고 책임지나? 계층적 위임 PM이 처음부터 끝까지 꼭대기
앞 단계 결과가 있어야 다음을 할 수 있나? 파이프라인 Phase 0 → 1 → 2 → 3 → 4
여러 사람이 서로 기다리지 않고 동시에 할 수 있나? 팬아웃 & 팬인 Phase 1 설계 3인
만든 걸 바로바로 따로 검증해야 하나? 생성-검증 Phase 2·3 구현 ↔ verifier
독립된 판정 하나만 필요한가? 단독 서브에이전트 Phase 4 test-suite

여기서 얻은 핵심은 패턴에 층이 있다는 것이다.

  • 바깥 층(뼈대): 계층적 위임과 파이프라인. 기능 하나가 시작해서 끝날 때까지 전체를 감싼다.
  • 안쪽 층(엔진): 팬아웃과 생성-검증. 특정 Phase 안에서만 돈다.

Phase 경계가 곧 안쪽 엔진이 갈아끼워지는 지점이다. Phase 1에서 팬아웃이 돌다가, 설계가 확정되는 순간 Phase 2로 넘어가며 생성-검증으로 바뀐다. 리뷰 팀 때는 패턴을 "하나 고르는 것"이라 생각했는데, 실제로는 뼈대 위에 Phase별 엔진을 끼우는 설계였다.

조합이 교과서처럼 깔끔하지만도 않다. Phase 1은 팬아웃이지만 ui-designer와 db-migrator는 서로 직접 말하지 않고 api-designer를 거친다. API가 화면과 DB 사이의 계약이라서다. 패턴은 이름표가 아니라 일의 의존 관계를 그대로 옮긴 모양이다.


2. boundary-verifier: 왜 따로 있어야 하고, 어떻게 판정해야 하나

2-1. 리뷰 실습에서 이미 본 장면

지난 리뷰 실습에서 design-reviewer가 잡은 가장 좋은 지적은 두 파일 사이에 있었다. 같은 failed라는 이름이 실행 엔진, 결과 집계, 화면에서 서로 다른 뜻으로 쓰여 한 화면에 "0 실패"와 "1개 실패"가 동시에 뜰 수 있다는 것. 파일 하나만 보면 절대 안 보이는 버그였다.

구현 팀은 이 관점을 사후 리뷰가 아니라 만드는 도중에 넣는다. 그럼 왜 backend-impl이나 frontend-impl이 스스로 확인하지 않고 한 명을 더 둘까.

2-2. 별도 에이전트여야 하는 이유

① 경계면은 주인이 없는 땅이다.
backend-impl은 자기 API가 설계대로면 됐다고 믿고, frontend-impl은 자기 훅이 설계대로면 됐다고 믿는다. 둘이 맞물리는지는 누구의 일도 아니다. 주인 없는 곳에는 전담자를 둔다.

② 만든 사람이 검증하면 자기 확증이 된다.
생성-검증은 주체가 둘이어야 성립한다. 같은 머리가 만들고 검사하면 "내가 의도한 대로 됐다"를 확인할 뿐이다.

③ 검증자에게 손이 있으면 판정이 흐려진다.
verifier에게 Edit이 있으면 어긋남을 보자마자 편한 쪽을 고쳐 버린다. 그러면 "누가 설계를 안 따랐는지", "설계 자체가 모순인지"가 기록되지 않고 묻힌다. 손 없이 판정만 하게 해야 고치는 사람과 기준이 분명해진다.

이 제약은 정식 에이전트여야만 걸린다. 리뷰 실습에서 정의 파일을 일반 에이전트에게 글로 읽혀 흉내 내게 했더니 Write 금지가 무시됐다. tools 제한은 에이전트 단위로만 걸린다. 구현자 안에 "검증 모드"를 넣으면 Edit을 뺄 방법이 없다.

④ 필요한 두뇌가 다르다.
구현자는 확정된 설계로 한 파일씩 짜니 sonnet이면 된다. verifier는 두 파일을 동시에 놓고 추론해야 해서 opus다. 모델 배정 단위도 에이전트다.

⑤ 판정이 한 곳에 모여야 루프가 끝난다.
같은 경계면 REDO가 2회면 사람을 부른다. 이 카운트가 의미 있으려면 판정자가 한 명이어야 한다.

2-3. 정의를 직접 고치며 배운 것: FIX가 기본, REDO는 예외

처음 정의 파일에는 PASS / FIX / REDO의 뜻만 나열되어 있었다. 그런데 읽다 보니 걸리는 게 있었다. 구현이 안 맞으면 구현이 잘못된 건데, 왜 설계로 되돌리는 REDO가 있지?

따져 보니 판정의 순서가 빠져 있었다. 어긋남을 본 verifier가 "설계가 이상하네" 하고 곧장 REDO를 내면, 구현자가 설계를 안 따른 것까지 설계 탓이 된다. 그래서 boundary-verifier.md에 판정 순서 칸을 새로 만들었다.

  1. 양쪽 구현이 맞물리는가? → 맞물리면 PASS
  2. 안 맞물리면, 각 구현을 자기 설계 문서와 대조한다. 설계와 다른 쪽이 있으면 FIX → 그 구현자에게. 기본은 FIX다.
  3. 양쪽 다 자기 설계대로인데도 안 맞을 때만 REDO → api-designer에게.
상황 원인 판정
API 설계는 { user, accessToken }인데 백엔드가 { data: { user } }로 짰다 구현이 설계를 안 따름 FIX → backend-impl
API 설계는 객체를 주는데 UI 설계의 훅은 배열을 기대한다. 양쪽 다 자기 설계대로 짰다 설계 둘이 서로 모순 REDO → api-designer
설계에 에러 응답 형식이 없어 양쪽이 각자 다르게 짰다 설계 누락 REDO → api-designer

REDO가 필요한 이유는 아래 두 경우 때문이다. 설계끼리 모순이거나 설계에 내용이 없으면, 어느 쪽 구현을 고쳐도 설계 문서는 틀린 채 남는다. 그런데 Phase 4의 test-suite는 그 설계와 요구사항을 보고 E2E를 짠다. 코드만 맞춰 두면 테스트가 엉뚱한 기준으로 실패한다. 그래서 기준(설계)부터 바로잡고, 고쳐진 설계 기준으로 다시 FIX가 나가게 했다. REDO도 결국 구현을 맞추는 걸로 끝난다. 한 단계를 더 거칠 뿐이다.

함께 넣은 장치가 두 개 더 있다.

  • 판정 메시지에 근거 칸: "어느 설계 문서의 어느 부분과 어떻게 다른지"를 반드시 적는다. 받는 구현자가 무엇에 맞출지 바로 알고, 나중에 판정이 옳았는지 추적할 수 있다.
  • 금지 한 줄: "설계 문서와 대조하지 않고 REDO를 내지 않는다." 대조를 건너뛰고 재설계로 도망가는 걸 막는다.

2-4. 여기서 얻은 지식

리뷰 팀 때는 에이전트를 관점(보안, 설계, 정적 분석)으로 나눴다. 구현 팀에서 배운 건 두 가지다.

권한이 다르거나, 필요한 모델이 다르거나, 책임의 단위가 다르면 별도 에이전트로 뺀다.

판정을 맡기는 에이전트에는 판정의 뜻만이 아니라 판정의 순서를 줘야 한다.

뜻만 주면 에이전트가 그때그때 편한 판정을 고른다. 순서를 주면 같은 어긋남에 언제나 같은 판정이 나온다. 리뷰 팀 security-auditor에 심각도 기준을 줬던 것과 같은 원리인데, 이번엔 기준이 아니라 결정 순서였다.


3. feature-pm은 Phase마다 다른 사람이 된다

3-1. 리더의 역할이 고정이 아니다

리뷰 팀의 오케스트레이터는 한 가지 모드였다. 부르고, 기다리고, 모으기만 했다. feature-pm은 Phase마다 하는 일이 바뀐다.

Phase PM의 모드 하는 일
0 작성자 요구사항 json을 직접 쓴다. 이 기능의 채점표
1 검토자 / 문지기 설계 문서 3종에 [NOTE.]·[BLOCKER.]·[Q.] 주석, 최대 3회, 통과해야 다음으로
2·3 감독자 워커끼리 직접 대화하게 두고 중계하지 않는다. 결과와 사람 호출 신호만 받는다
4 호출자 / 통합자 test-suite를 직접 불러 채점시키고, 통합 보고서를 쓰고, 팀을 해체한다

PM이 처음부터 끝까지 능동적이면 두 가지가 터진다. 병목: 구현자 둘과 verifier의 모든 대화가 PM을 거치면 PM이 막힌다. 맥락 오염: "5회 실패 후 60초 잠금" 같은 구현 세부가 PM 머릿속에 쌓여 정작 Phase 게이트 판단이 흐려진다.

그래서 PM만 할 수 있는 일(채점표 작성, 게이트 판정, 최종 채점)은 직접 하고, 워커끼리 풀 수 있는 일에서는 물러난다. 리더는 늘 같은 강도로 개입하지 않고, 단계에 따라 개입의 깊이를 조절한다.

3-2. 팀은 언제 갈아끼우나

전환 팀 교체? 이유
Phase 0 → 1 설계 팀 생성 일이 "요구사항 작성"에서 "설계"로 바뀜
Phase 1 → 2 교체 산출물이 문서에서 코드로 바뀜. 설계자가 남아 있으면 구현 중에도 설계를 계속 바꾸려 들고, 동시 4명 상한도 넘는다
Phase 2 → 3 유지 일의 종류가 같음. 남은 FIX·REDO 정리라 쌓인 맥락을 이어 가는 게 이득
Phase 3 → 4 해체 후 단독 채점은 만든 사람들과 섞이면 안 된다
REDO 발생 팀 교체 없이 api-designer만 재호출 설계 전체가 아니라 모순된 계약 부분만 부분 복귀

일의 종류가 바뀌면 팀을 바꾸고, 같은 일이 이어지면 팀을 유지한다. 교체 신호는 Phase 게이트다.

Phase 2와 3의 구성이 같은데 굳이 나눈 이유도 여기서 이해됐다. 팀은 같아도 목표가 다르다. Phase 2는 만들면서 검증, Phase 3은 남은 결함을 0으로. 종료 조건이 다르니 단계를 나눈다.

REDO가 팀 교체 없이 api-designer 한 명만 다시 부르는 것도 2-3절과 이어진다. REDO는 "설계 전체가 틀렸다"가 아니라 "이 경계면의 계약이 모순이다"라서, 그 부분만 고치고 바로 FIX로 돌아온다.


4. 리뷰 팀과 나란히 놓고 보면

리뷰 팀에서 배운 것 구현 팀에서 새로 배운 것
설계 대상 에이전트 한 명 여러 팀이 시간 순서로 도는 워크플로
패턴 하나를 고른다 뼈대 위에 Phase별 엔진을 끼운다
에이전트 분리 기준 관점 관점 + 권한 + 모델 + 책임 단위
판정 설계 심각도 기준을 준다 판정 순서를 준다 (FIX 기본, REDO 예외)
검증 시점 다 만든 뒤 만드는 도중
리더 한 가지 모드 Phase마다 모드 전환
팀 한 번 꾸리고 끝 일의 종류가 바뀔 때 교체
실패 처리 정직한 표기 상한을 넘으면 멈추고 사람을 부름

리뷰 팀이 한 장면을 잘 찍는 법이었다면, 구현 팀은 장면을 이어 붙여 영화를 만드는 법이다. 에이전트 하나하나의 뼈대는 그대로고, 그 위에 언제 깨우고, 언제 해산시키고, 누가 어떤 순서로 판정하고, 어디서 멈추는지를 설계하는 층이 하나 더 생겼다.


5. 실제로 돌리며 확인할 것

  • 패턴 조합: Phase 1에서 설계 3인이 동시에 뜨고, Phase 2로 넘어가며 생성-검증으로 바뀌는지
  • verifier 판정 순서: 어긋남마다 설계 대조를 먼저 하는지, FIX와 REDO의 비율이 어떻게 나오는지, 근거 칸이 채워지는지
  • verifier 권한: 정말 한 줄도 고치지 않는지
  • PM 모드 전환: Phase 2·3에서 PM이 대화에 끼어들지 않고 결과만 받는지
  • 팀 교체 타이밍: 설계 팀이 Phase 2 시작 시점에 해산되고, Phase 2→3에서는 유지되는지
728x90

'인공지능' 카테고리의 다른 글

하네스 엔지니어링이란? - (end)  (0) 2026.11.04
하네스 엔지니어링이란? - (3)  (0) 2026.11.03
하네스 엔지니어링이란? - (1)  (0) 2026.11.01
  Comments,     Trackbacks