Free

테스트는 Claude에게, 당신은 review만—1,562줄의 분업 실전

TopicReview의 1,562줄 테스트는 거의 전부 Claude가 작성했다. 나는 review만—배포 후 2주가 넘도록 추가된 테스트는 있어도 고쳐 쓴 테스트는 없다. 이 글은 왜 테스트가 Claude에게 맡기기 가장 좋은 일인지, review에서 봐야 할 것과 보지 말아야 할 것(실제 spec에 숨어 있는 누락된 경계 포함), 그리고 이 분업의 기본 설정을 다룬다.


지난 글 말미에 TopicReview의 "119 spec 전부 녹색"이라고 했다. 독자가 진짜로 던질 다음 질문: 그 테스트 누가 썼는가?

답: Claude가 1,562줄 테스트 코드의 거의 전부를 썼다. 나는 review만 한다. 배포 2주가 넘도록 이 1,562줄의 유지보수는 새 테스트를 추가할 뿐, 기존 테스트를 고쳐 쓴 적은 없다.

이 글은 왜 테스트가 Claude에게 맡기기 가장 좋은 일인지, review에서 무엇을 보고 무엇을 보지 않아야 하는지, 그리고 이 분업이 실제로 어디까지 작동하는지에 관한 것이다.

먼저 숫자부터

TopicReview의 테스트는 7개 파일에 나뉘어 있다:

spec/services/topic_review_service_spec.rb   760줄 (88 테스트)
spec/requests/topic_reviews_spec.rb          281줄 (32 테스트)
spec/requests/review_appeals_spec.rb         152줄 (16 테스트)
spec/requests/review_votes_spec.rb           127줄
spec/policies/topic_review_policy_spec.rb    109줄
spec/jobs/close_topic_review_job_spec.rb      71줄 (7 테스트)
spec/models/topic_review_spec.rb              62줄
───────────────────────────────────────────
                                          1,562줄

네 가지 테스트 타입을 커버한다: service(비즈니스 로직), request(컨트롤러 + 통합), policy(Pundit 권한), job(스케줄 작업).

초기 commit d162f1eCo-Authored-By: Claude Sonnet 4.6가 찍혀 있고, 이 중 1,100줄 이상을 한 번에 썼다. 이후의 spec 변경은 전부 "Add test for..."—refactor나 rewrite는 한 건도 없다:

00393fc Add test for finalize! with zero votes (expired review)
3f53304 Add test for finalize! with legacy votes missing reasoning
3b185da Update specs to use PROVISIONAL_PENALTY constant

구멍 메우기지 되돌아가는 작업이 아니다. 이 디테일이 뒤에서 다시 중요해진다.

테스트가 Claude에게 맡기기 가장 좋은 이유

단단한 이유 네 가지:

1. 입력과 출력이 명확하다. 테스트의 본질은 "주어진 상태 → 기대 행위"다. 이것이 Claude가 가장 잘하는 번역이다—스펙을 assertion으로 바꾸는 일. 비즈니스 로직은 때로 저울질이 필요하지만, 테스트는 그럴 게 별로 없다.

2. 기계적 × 대량. 하나의 describe .open! 아래에 "eligible jurors 있음 / 없음 / topic 없음 / 이미 active review 있음"의 네 가지 context, 각 context에 2–5개의 it 블록. 사람은 세 번째 context쯤 되면 반사적으로 몇 케이스 빼먹고 싶어진다. Claude는 게으름 피우지 않는다—88번째 it를 첫 번째와 똑같은 성실함으로 쓴다.

3. 피드백 루프가 극단적으로 짧다. 테스트를 쓰고 rspec을 돌리면 통과/실패가 즉시 확인된다. 비즈니스 코드는 실제 사용한 지 며칠은 지나야 문제가 드러난다. 루프가 짧다는 건 Claude의 실수가 rspec에 바로 잡힌다는 뜻—지켜볼 필요가 없다.

4. 자연스럽게 병렬적. it 블록들은 서로 독립이고, 숨은 의존이 없으며, 대규모로 펼칠 수 있다. Claude가 독립 테스트 수십 개를 한 번에 생성하는 건 정확히 그의 강점이다.

Review에서 무엇을 보고 무엇을 보지 않는가

이 부분이 분업의 핵심이다.

보지 않는다:

  • RSpec 문법이 맞는지 → Claude는 거의 틀리지 않는다
  • mock이 잘 작성됐는지 → 명백한 over-mock이 아니면 보통 괜찮다
  • Factory가 우아한지 → 중요하지 않다, 돌아가면 된다
  • 테스트 스타일 통일성 → 문제 있으면 Claude에게 한 번에 고쳐달라 하면 끝

본다:

  • 엣지 케이스가 정말 다 커버되었는가
  • 테스트 이름이 실제 기대 행위를 제대로 묘사하는가
  • 있어야 하는데 없는 테스트가 있는가

마지막 항목이 review의 진짜 가치다. Claude는 "자신이 떠올릴 수 있는" 테스트는 커버하지만, 떠올리지 못하는 테스트는 자발적으로 보완하지 않는다. 이게 바로 human review의 자리다—비즈니스 규칙으로부터 거꾸로 커버리지 누락을 추적하는 것.

구체적 예: review가 실제로 잡아내는 것

spec/services/topic_review_service_spec.rb 앞부분의 describe ".open!"를 연다:

describe ".open!" do
  context "when there are eligible jurors" do
    # review 상태 정상 / post under_review / assignments 생성 / author 알림 / jurors 알림 / 이중 open 없음
  end
  context "when there are no eligible jurors" do
    # review는 생성되지만 assignments는 생성 안 됨
  end
  context "when post has no topic" do
    # nil 반환
  end
end

보기엔 포괄적이다. 하지만 model의 eligible_jurors 실제 규칙은 세 부류를 배제한다:

def eligible_jurors
  excluded_ids = [ post.user_id ] + post.reports.pluck(:user_id) + review_votes.where(stage: :initial).pluck(:user_id)
  User.jurors_and_judges.where.not(id: excluded_ids.uniq)
end

이제 테스트를 보자—"원작자는 배심원으로 선정되지 않는다"를 단언하는 테스트는 어디 있는가?

service_spec.rbmodel_spec.rb를 다 뒤져도 없다. model_spec.rbpending_vote_by 스코프의 몇 케이스만 다루고 eligible_jurors 자체는 다루지 않는다. service_spec.rb에는 주석 한 줄뿐: # Jurors must NOT be the post author—setup의 주석이지, 단언이 아니다.

이것이 review가 발견할 수 있는 누락이다: 세 가지 배제 규칙(원작자 / 신고자 / 초심 투표자), 어느 하나도 테스트로 보호되지 않는다. 나중에 누군가 eligible_jurors의 구현을 리팩터링하면서 실수로 post.user_id를 배제 리스트에서 빠뜨려도, 기존 테스트는 모두 통과한다—그리고 프로덕션은 조용히 원작자를 자기 배심원석에 앉힌다.

Claude는 틀리지 않았다. 요청된 것을 테스트했을 뿐이다. 다만 "이 세 규칙 각각에 테스트 보호가 필요한가"를 스스로 묻지 않았다. 이 질문—규칙에서 커버리지로의 역추적—이 review가 해야 할 일이다.

(솔직히 나도 첫 review 때 이걸 놓쳤다. 이 글을 쓰기 위한 두 번째 audit에서야 발견했다. 그래서 review도 한 방에 끝나지 않는다—하지만 안 하는 것보다 10배 낫다.)

후속 commit들이 이 분업의 현실성을 증명한다

"Claude가 쓰고 + human이 review"가 완벽한 분업이라면, 초기 commit 이후에는 새 테스트 commit이 없어야 한다. 실상은 더 흥미롭다—구멍 메우기는 있지만 다시 쓰기는 없다:

00393fc Add test for finalize! with zero votes (expired review)
3f53304 Add test for finalize! with legacy votes missing reasoning

첫 번째는 버그 발견 후의 regression test—e8cb2db Default to keep verdict when review expires with zero votes가 fix, 00393fc가 세트로 따라붙은 추가 테스트. 두 번째도 같은 패턴으로 abaa22e Fix CloseTopicReviewJob failing due to reasoning validation on old votes 뒤에 따라붙는다.

이 두 commit이 동시에 증명하는 것:

  • review가 100% 케이스를 잡지는 못했다—그래서 프로덕션이 두 버그를 드러냈다
  • 하지만 테스트 아키텍처는 충분히 안정적이어서, 재구조화 없이 계속 테스트를 추가할 수 있었다—그래서 commit이 "Add test for..."지 "Rewrite ... spec"이 아니다

"충분히 좋음 + 계속 보완 가능"—"완벽"보다 훨씬 현실적인 기준. 완벽한 review를 쫓다 보면 Claude에게 테스트를 맡기지 못하게 된다. "충분히 좋음"을 받아들여야 분업이 시작된다.

Claude에게 독립적으로 맡기면 안 되는 테스트

모든 테스트가 완전 위임에 적합하지는 않다:

  • E2E happy-path—프로덕트 시점이 필요하다. Claude가 쓸 수는 있지만 "기술적으로 완주 가능"만 커버하기 쉽고, "사용자가 실제로 막히는 곳"을 놓치기 쉽다
  • 보안 테스트—공격자 마인드가 필요. Claude는 보수적이어서 비표준 공격 면(SQL 키워드 주입, 초장문 문자열, 대체 유니코드 등)을 놓치기 쉽다
  • 성능 기준선—실제 배포 환경의 숫자가 필요. Claude는 임곗값을 대충 찍기 쉽다
  • 영향 범위가 큰 fixture / factory 개편—아키텍처급 변경이라 review가 아니라 plan mode로 돌아가야 할 사안

이런 경우 사람이 주도하고 Claude가 보조한다.

기본 구성

이 분업을 실행 가능한 default로 만들면:

  1. 기능 시작 전, 나는 비즈니스 규칙을 설명한다(RSpec 규칙이 아니라)
  2. Claude가 구현과 테스트를 쓴다
  3. 테스트를 돌린다. 통과 = 계속, 실패 = Claude가 스스로 고친다
  4. 나는 review:
    • 문법/mock/factory는 안 본다
    • 커버리지를 본다: 모든 비즈니스 규칙이 최소 하나의 테스트로 보호되는가
    • 엣지 케이스를 캐묻는다: "0건 / null / 동시성 / 권한 위반" 축을 하나씩 따라간다
    • 테스트 이름을 읽는다—이름으로 행위를 짐작 못 하면 Claude에게 이름 변경을 시킨다
  5. 프로덕션에서 발견된 버그는 regression test로 돌아온다—분업의 정상적 마모지 실패가 아니다

마무리

프로그래머가 테스트를 읽을 때의 심리 자원은 코드를 읽을 때보다 적다. 테스트는 반복적이고, 기계적이고, 지치게 하지만 필수적이다. 이 모든 특징이 Claude의 강점과 겹친다—지루해하지 않고, 지치지 않고, 50번째 it에서 대충하지 않는다.

당신이 할 일은 "테스트를 쓰는 것"이 아니라 "모든 비즈니스 규칙이 테스트로 덮이도록 보장하는 것"이다. 하나는 구현, 다른 하나는 판단—판단은 당신에게 남고, 구현은 Claude에게 간다.

119 spec / 1,562줄이 한 번의 배포로 올라가고 2주 넘도록 rework 없이 돌아가는 건 내가 테스트를 더 잘 써서가 아니라, 내가 전혀 쓰지 않기 때문이다. 나는 Claude보다 한 가지만 더 한다: 어떤 비즈니스 규칙이 보호받아야 하는지 결정하는 일.