About

각자 옳은 두 커밋이 만나면 리뷰로는 못 잡는 퇴행이 된다 — Rails 자동 연결이 3개월 뒤 조용히 죽은 이유

붙일 때와 찾을 때의 범위가 어긋났는데 어느 PR 의 diff 에도 안 보였다. ActiveRecord 로 재현하고, 왕복 스펙으로 막는 법까지

PR 을 두 개 다 읽어 봐도 버그가 안 보이는 퇴행이 있다. 각 PR 이 옳아서 그렇다. 다 읽으면 내 Rails 앱에서 같은 어긋남을 찾는 법과, 그걸 막는 스펙 한 개를 쓸 수 있다.

결론부터. 데이터를 넣을 때 쓰는 범위와 찾을 때 쓰는 범위가 같아야 하는데, 둘을 손으로 따로 쓰면 언젠가 한쪽만 좁아진다. 코드 리뷰는 이걸 못 잡는다. 넣고 다시 찾아보는 왕복 스펙이 잡는다.

「전부 후보 없음」 이라는 문의

회사 프로젝트(계약관리 SaaS)에 관련 계약 자동 연결 기능이 있다. 각서나 변경계약서 PDF 를 올리면 AI 가 본문에서 「어느 계약서의, 언제 체결된 건에 근거하는가」 를 읽고, 시스템이 원계약을 찾아 부모–자식으로 붙인다.

어느 날 트라이얼 고객에게 문의가 왔다. 하루에 올린 수십 건이 전부 「후보 없음」 이라는 것. 문의 받은 건을 열어 보니 체결일은 맞았고, 문서명 유사도도 자동 연결 기준을 넉넉히 넘었다. 그런데 후보 목록조차 비어 있었다.

유사도가 높은데 후보가 0건? 처음엔 말이 안 된다고 생각했다. 판정 순서를 보고 나서야 이해가 됐다.

1. 날짜     부모 체결일 · 개시일 일치          SQL
2. 회사명   거래처를 이름이 아니라 ID 로 조회    SQL   ID 가 0개면 return nil
3. 유사도   문서명 · 파일명 비교                Ruby  비싼 계산은 마지막에

싼 조건부터 거르는 흔한 순서다. 2번에서 끊기면 3번은 돌지도 않는다. 유사도가 아무리 높아도 소용없다.

어긋난 두 범위

거래처 마스터에 없는 이름이 나오면 OCR 결과로 「미확인」 거래처를 자동으로 만들어 계약에 붙인다. 정리해 보니 코드는 이런 모양이었다. (이름은 바꿨다.)

class Org < ApplicationRecord
  has_many :partners
  has_many :active_partners, -> { where(confirmed: true) }, class_name: 'Partner'
end

# 붙이는 쪽 — 미확인도 재사용해서 중복을 막는다
org.partners.find_or_create_by!(name: name)

# 찾는 쪽 — 미확인이 안 보인다
ids = org.active_partners.where(name: name).pluck(:id)
return nil if ids.empty?

거래처를 하나도 등록하지 않은 조직이라면 거래처가 전부 미확인이다. 붙이는 쪽은 그 행을 보는데 찾는 쪽은 못 본다. 그래서 ids 는 항상 빈 배열이고, 2번 게이트에서 끝난다.

One table, two scopes, an org with no confirmed partnersAttach sideorg.partnersAcme unconfirmedsees it, reuses it, contract is savedMatch sideorg.active_partners[]confirmed only, so the row is invisibleThe gates run cheapest first, and zero rows stops the chain1. DatematchesSQL2. Partner IDs[] return nilSQL3. Name similaritynever runsRubyOrgs with confirmed partners pass gate 2, so the overall match count barely moves.That is why no dashboard noticed.

더 헷갈렸던 건 거래처 이름은 다른 경로로 구해져서 정상으로 나왔다는 점이다. 이름은 나오는데 ID 만 안 나오니, 로그만 보면 멀쩡해 보인다.

왜 3개월이나 걸렸나

git log 를 시간순으로 놓으니 그제야 보였다.

6월 중순    미확인 거래처 도입. 목록 화면만 confirmed 로 거름
26분 뒤     거르는 로직을 active_partners 안으로 모음
            커밋 메시지: 「관련 계약 매칭은 영향 없음」
약 2주 뒤   다른 리팩터링에서 이름 문자열 fallback 삭제 ← 여기서 퇴행 완성
8월         프로덕션 릴리스
9월 초      트라이얼 고객 문의

「영향 없음」 은 그 시점엔 사실이었다. ID 가 없어도 이름 문자열로 맞춰 보는 fallback 이 살아 있었으니까. 2주 뒤 fallback 을 지운 쪽도 잘못이 없다. 그 PR 의 diff 에 active_partners 의 정의는 없다. 안 쓰는 길을 치운 것처럼 보일 뿐이다.

이 코드를 어느 PR 에서 리뷰했어도 못 잡았을 거라고 본다. 앞 커밋의 안전은 뒤에 지워진 코드에 기대고 있었는데, 그 기댐이 코드가 아니라 커밋 메시지에 적혀 있었다.

재현해 봤다

진짜 그런지 ActiveRecord 8.0.4 와 메모리 SQLite 로 최소 모델을 만들어 돌렸다. A 는 정식 거래처가 있는 조직, B 는 미확인만 있는 조직이다. 각각 계약 하나를 붙이고 다시 찾는다.

                       A(정식 있음)   B(미확인만)
fallback 있을 때        1              1
fallback 지운 뒤        1              0   ← 여기
찾는 범위를 고친 뒤     1              1

A 만 보면 전혀 안 깨졌다. 이게 3개월을 버틴 이유다. 거래처를 등록해 둔 조직은 그대로 동작하니 전체 연결 건수가 눈에 띄게 떨어지지 않았고, 지표에 안 잡혔다. 나중에 세어 보니 정식 거래처가 0건인 조직이 3분의 1 남짓이었다. 그 조직들은 릴리스 뒤로 자동 연결이 무조건 0건이었다는 뜻이다.

고친 건 한 줄이다. 찾는 쪽 범위를 붙이는 쪽에 맞췄다. 고치는 데 한 줄, 찾아내는 데 3개월.

범위가 두 개인 건 잘못이 아니었다

처음엔 「미확인을 왜 뺐어」 라고 생각했는데, 숫자를 보고 접었다. 거래처 테이블의 8할 가까이가 OCR 이 만든 미확인이었다. 자동완성이나 마스터 화면에 그대로 내보내면 사람이 등록한 수만 건이 AI 가 만든 10만 건대에 파묻힌다. 표시에서 빼는 게 맞다.

잘못은 표시용 범위에 active_partners 라는 이름을 붙인 데 있다고 본다. 「살아 있는 거래처」 로 읽히니까 조회에 쓰고 싶어진다. listable_partners 였다면 매칭 코드에 쓰기 전에 한 번은 멈칫했을 거다.

같은 모양이 벌써 쌓이고 있었다

같은 조직에 같은 이름의 거래처가 겹친 그룹이 수백 개 있었고, 대부분이 정식과 미확인이 섞인 경우였다. 「미확인을 못 보는 관계로 찾고, 없으면 새로 만든다」 는 등록 경로가 남긴 흔적이다. 이것도 재현된다.

org.partners.where(name: 'Acme').pluck(:id, :confirmed)
# => [[2, false]]

org.active_partners.find_or_create_by!(name: 'Acme')

org.partners.where(name: 'Acme').pluck(:id, :confirmed)
# => [[2, false], [3, true]]   같은 이름이 둘이 됐다

scope 가 붙은 관계로 create 하면 where 조건까지 값으로 들어간다. 그래서 안 보이는 미확인 옆에 정식 거래처가 새로 생긴다. 이름 하나에서 버그가 줄줄이 나온 셈이다.

이런 걸 뭐라고 부르나

가까운 말은 Martin Fowler 가 쓴 semantic conflict 다. 텍스트로는 문제없이 합쳐지는데 프로그램 동작이 달라지는 변경. 다만 Fowler 의 글은 병렬로 나간 브랜치 얘기고, 우리는 2주 간격으로 차례로 들어갔다. 그래도 답은 같다. 테스트가 잡는다. 리뷰어의 눈이 아니라.

막는 방법, 언제 무엇을

방법                    막아 주는 것                  비용        언제
왕복 스펙               이번 퇴행 전부                스펙 한 개  먼저 이것부터
찾는 범위를 한 곳에 정의  다음 사람이 손으로 또 쓰는 것  리팩터링    찾는 곳이 둘 이상이면
이름 바꾸기             잘못 고르는 것                전체 치환   scope 뜻이 좁으면
조직별 0건 지표          못 막지만 빨리 안다           대시보드    자동 처리 기능이면
코드 리뷰               이 유형은 못 잡는다           —           기대지 않는다

하나만 고르면 왕복 스펙이다. 붙이는 코드로 데이터를 넣고, 찾는 코드로 다시 찾는다. 범위를 직접 만든 픽스처로 시험하면 안 된다. 픽스처는 정식 거래처로 만들기 쉽고, 그러면 이번 버그가 그대로 통과한다.

it '미확인 거래처로 붙인 계약도 찾아낸다' do
  org = create(:org)                       # 정식 거래처 0건
  ContractAttacher.call(org, 'Acme', date) # 진짜 붙이는 코드

  expect(ParentFinder.call(org, 'Acme', date)).to be_present
end

앞의 재현 모델로 돌리면 fallback 을 지운 판에서 이 모양의 검사가 실패한다.

내 코드에서 할 것

# 조건이 붙은 관계 · scope 목록
grep -rnE "has_many .*-> *\{|scope :" app/models
# 빈 결과에서 조용히 빠져나가는 곳
grep -rnE "return( nil)? (if|unless) .*(empty\?|blank\?|none\?|any\?|present\?)" app

두 목록이 만나는 곳이 후보다. 표시용 범위가 매칭 · 중복 체크 · find_or_create_by 에 쓰이고 있으면 스펙을 하나 붙인다. 그리고 fallback 을 지우는 PR 을 볼 때는 「이 fallback 에 기대는 코드가 뭔가」 를 먼저 묻는다.

자동 연결처럼 조용히 0건이 되는 기능은 전체 합계로 보면 안 된다. 조직별로 쪼개야 보인다.

Rails 앱에서 이런 조용한 퇴행을 찾거나 스펙으로 막는 일을 같이 볼 사람이 필요하면 negabaro 에 물어봐도 된다.

핵심 포인트

1

넣는 코드와 찾는 코드가 같은 범위를 보는지 확인한다. 손으로 따로 썼다면 언젠가 어긋난다

2

스펙은 픽스처가 아니라 진짜 붙이는 코드로 넣고 진짜 찾는 코드로 찾는다

3

표시용 scope 에는 listable_ 처럼 용도가 보이는 이름을 붙인다

4

fallback 을 지우는 PR 에서는 거기 기대는 코드부터 찾는다

5

조용히 0건이 되는 기능은 조직별로 쪼개서 지표를 본다

사용 사례

scope · 관계가 여러 개인 Rails 모델에서 매칭 · 중복 체크 코드를 점검할 때
← Faraday open_timeout 은 안 쓰면 timeout 값이 된다