문제: 같은 투입량이 공정마다 전액 계상됐다
두 CTE의 GROUP BY 축이 달랐다.
-- 산출 쪽: 공정별
GROUP BY lup.id, match_key
-- 투입 쪽: LCA 전역 (match_key 당 1행)
GROUP BY lup.lca_id, match_key
이 둘을 LEFT JOIN으로 붙이면 어떻게 되나. 투입 1행이 같은 match_key를 가진 모든 산출 공정에 각각 붙는다. 그리고 각 공정이 그 투입량을 전액 가져간다.
실측으로 재현했다.
- UP1이 반제품A 100kg 산출, UP2도 100kg 산출 (총 200kg)
- UP3가 반제품A를 100kg만 투입
| UP1 ratio | UP2 ratio | 귀속 실효 물량 | |
|---|---|---|---|
| 수정 전 | 1.000000 | 1.000000 | 100×1.0 + 100×1.0 = 200kg |
| 실제 소비 | — | — | 100kg |
2배 과대귀속. 그리고 이건 상한이 아니다. 같은 반제품을 N개 공정이 균등 산출하면 최대 N배가 된다.
배출량 산정 서비스에서 이건 단순 버그가 아니다. 결과 숫자가 틀리는데 에러가 없다.
팀은 이미 위험을 알고 있었다
코드를 뒤지다가 이걸 발견했다.
팀도 이 위험을 인지하고 있었다 —
SemiProductScalingService의ambiguousSource플래그가 “산출이 2개 이상 공정에서 나오면 경고만 남기고 매칭 로직은 변경하지 않는다” 고 주석에 적어두었다.
즉 “산출 공정이 여러 개면 위험하다”는 인식은 있었고, 경고 플래그까지 만들어뒀다. 다만 그 경고가 가리키는 계산 자체는 고치지 않았다.
이런 상태가 왜 유지되나 생각해봤다.
- 경고를 만든 시점에는 “매칭이 애매해질 수 있다” 정도로 인식했을 것이다
- 실제로는 “금액이 N배가 된다” 였다
- 경고는 화면에 뜨지만, 그게 얼마나 틀린 건지는 아무도 계산해보지 않았다
“알고 있다”와 “얼마나 나쁜지 안다”는 다르다. 위험 플래그를 남길 때 그 위험이 실현되면 결과가 어떻게 되는지까지 적어야 후속 조치로 이어진다.
수정: 안분(pro-rata)
투입 쪽을 공정별로 바꾸는 게 자연스러워 보인다. 그런데 그 안은 택하지 않았다.
sp_input을 단위공정별로 바꾸는 안은 택하지 않았다 — “A공정 산출을 B공정이 투입”이 정상 시나리오인데 소비 공정 기준이 되어 매칭이 전부 깨진다.
반제품은 원래 한 공정에서 만들어 다른 공정에서 쓴다. 투입을 소비 공정 기준으로 묶으면 산출 공정과 연결할 방법이 사라진다. 축을 맞추는 게 답이 아니었다.
대신 비례 배분을 택했다.
-
match_key별 LCA 전역 산출 합(grand_out)을 구한다 - 소비 총량을 각 산출 공정에 산출량에 비례해 나눈다
검산은 이렇게 된다.
UP1 = 100 × (100/200) / 100 = 0.5
UP2 = 100 × (100/200) / 100 = 0.5
총 귀속 = 100×0.5 + 100×0.5 = 100kg ← 실제 소비량과 일치
수정 범위를 좁게 유지한 것도 의도적이다.
-
ratio의 단위 — 여전히source_unit_process_id기준 → 사용처 조인 키 변경 불필요 -
sp_output은 손대지 않음 → 이를 참조하는 다른 CTE들도 무영향
계산식만 바꾸고 인터페이스는 그대로 뒀다. 그래서 파급이 없다.
함정: 같은 계산이 두 곳에 있었다
이 도메인에는 구현이 두 개다.
- 총량 — DB 뷰
- 브레이크다운(상세) — Java의 native SQL
둘은 독립 구현이다. 같은 규칙을 각자 SQL로 적어놨다.
한쪽만 고치면 026 이 방금 해결한 종류의 화면 간 불일치가 재발한다.
바로 직전에 같은 종류의 사고를 겪었다는 뜻이다. 총량 화면과 상세 화면의 숫자가 안 맞는 것이다. 그래서 코드에 이런 주석이 이미 있었다.
LciaScaledBreakdownServiceJavadoc 도 “할당 규칙 변경 시 반드시 양쪽을 함께 수정할 것” 이라고 명시한다.
이번엔 그 주석 덕에 양쪽을 함께 고쳤다. 다만 근본적으로는 같은 규칙이 두 곳에 손으로 적혀 있는 구조 자체가 부채다. 주석은 사람이 읽어야 작동하는 안전장치라, 못 읽으면 그대로 뚫린다.
왜 이 버그가 살아남았나
가장 중요한 발견은 여기였다.
LciaScaledBreakdownService전용 테스트는 그동안 0건이었다 — native SQL 이라 컴파일·런타임 모두 검증되지 않아 D7 이 살아남은 이유
native SQL의 성질이다.
- 문자열이라 컴파일러가 안 본다 — 컬럼명을 틀려도 빌드는 통과한다
- 실행하기 전엔 런타임도 모른다
- 결과가 틀려도 행은 정상적으로 반환된다 — 숫자만 틀리다
JPA나 QueryDSL이었다면 최소한 타입 오류는 잡혔을 것이다. native SQL은 그 안전망이 전혀 없는데, 하필 가장 복잡한 계산이 거기 들어가 있었다. 복잡할수록 문자열로 쓰게 되고, 문자열로 쓸수록 검증이 사라진다.
검증: 여러 겹으로
수정 후 검증을 촘촘히 했다. 계산 결과가 바뀌는 변경이라 조심할 필요가 있었다.
| 검증 | 결과 |
|---|---|
| scratch 스키마 실측 | 1.0/1.0 → 0.5/0.5 |
| 정상 케이스 회귀 | 산출 공정 1개일 때 수정 전후 모두 0.8 — 변화 없음 |
| 실 데이터 적용 | 616행 / 총량 baseline과 동일 |
| SQL diff 확인 | 변경은 신규 CTE 1개 + 수식뿐, 나머지 블록 동일 |
| 신규 통합 테스트 | 수정을 되돌리면 실제로 실패함을 확인 후 복구 |
두 가지가 특히 중요했다.
정상 케이스가 안 변하는 것을 확인 — 산출 공정이 1개면 grand_out = out_total이라 배수가 1이 되어 수식이 기존과 완전히 동일해진다. 수학적으로 회귀가 없음을 보인 것이라, 케이스 몇 개 돌려본 것보다 강하다.
실 데이터에서 변화가 없는 것도 정상 — 이 데이터에는 다중 산출 공정 케이스가 없었다. 그러니 총량이 그대로인 게 맞다. “바뀌어야 정상”인지 “안 바뀌어야 정상”인지를 먼저 정하고 확인해야 한다. 안 그러면 “숫자가 그대로네, 수정이 안 먹었나?”로 헤맨다.
배포에 딸린 조건
조회 경로가 물질화 테이블이라 SQL만 적용해선 화면에 안 나타난다.
- SQL 적용 후 ADMIN 전체 재동기화 실행 필요
- local/dev/stg — 자동 적용
- prod — 사람이 수동 적용
계산 로직을 고쳤는데 캐시 성격의 테이블이 중간에 있으면, 고친 로직이 반영되는 시점이 배포 시점과 다르다. 이걸 커밋에 안 적으면 “배포했는데 숫자가 그대로”가 된다.
남는 교훈
이 버그의 성질을 정리하면 이렇다.
- 에러가 없다 — SQL은 성공하고 행도 정상 반환된다
- 틀린 게 숫자다 — 그것도 배출량이라는, 이 서비스의 존재 이유인 숫자
- 테스트가 0건이었다 — native SQL이라 어떤 자동 검증도 안 걸렸다
- 위험 인식은 있었다 — 경고 플래그까지 있었는데 계산은 안 고쳤다
여기서 얻은 규칙 세 가지다.
① GROUP BY 축이 다른 CTE를 JOIN할 때는 카디널리티를 손으로 계산한다. “1행 대 N행”이 되는 순간 값이 N배가 된다. SQL은 이걸 경고해주지 않는다.
② native SQL을 쓰기로 했다면 통합 테스트는 선택이 아니다. 컴파일러도 ORM도 안 봐주는 유일한 코드라, 사람이 만든 테스트가 유일한 검증 수단이다.
③ 위험을 발견해 주석으로 남길 때는 “실현되면 결과가 어떻게 되는지”까지 적는다. “매칭이 애매해진다”와 “금액이 N배가 된다”는 후속 조치의 우선순위가 완전히 다르다.