트리거를 볼 권한이 없는 채로 레거시를 이관했다
Article
1편에서 재고 원장과 락을 깔았다. 이제 그 안에 데이터를 넣어야 한다.
이관 도구를 만들면서 가장 먼저 정한 건 데모 데이터를 일부러 더럽히는 것이었다. 깨끗한 엑셀로 시연하면 이 도구가 존재할 이유가 사라진다. 그래서 이렇게 만들었다.
- 상단 3행이 제목·작성자·출력일시라서 진짜 헤더는 4행부터 시작한다
- 헤더가 2행에 걸쳐 병합돼 있다 (
입고/일자·수량) - 날짜가 네 형식으로 섞여 있다:
2026-03-04·26.3.4·2026년 3월 4일·46085 - 수량도 세 형식:
"1,200"·1200·"1200개" - 거래처명 표기가 흔들린다:
(주)한빛·한빛상사·한빛 상사·한빛(주) - 의미를 알 수 없는 컬럼
비고2
그리고 구 시스템 DB는 별도 컨테이너로, 문자셋을 euckr로 띄웠다. 이 결정이 나중에 값을 한다.
헤더를 못 찾는다 — 데이터 행이 헤더로 뽑혔다
가장 먼저 막힌 건 헤더 탐지다. "비어 있지 않은 셀이 가장 많은 행"을 헤더로 잡으면 될 거라고 생각했다. 상식적이다. 그리고 데이터 행이 뽑혔다.
원인은 병합이었다. 입고가 두 칸에 걸쳐 병합돼 있으면 파서는 그걸 한 칸으로 센다. 헤더 행의 셀 수가 오히려 데이터 행보다 적게 나온다.
- 01
병합 값을 전파한다
병합된 칸을 같은 값으로 채운다. 그러면 상위 그룹 헤더는 같은 값이 반복된다.
입고 입고 품목 품목 품목 - 02
그 중복이 그룹 헤더의 지문이다
실제 헤더 행은 컬럼명이 서로 달라서 중복이 없다. 그룹 헤더 행은 중복이 있다.
- 03
후보를 좁힌다
셀이 가장 많은 행들 중에서 중복 값이 없는 첫 행을 헤더로 잡는다.
- 04
위아래를 합친다
상위
입고+ 하위일자→입고 일자. 매칭은 결합 헤더와 하위 헤더 둘 다 시도하고 이긴 쪽을 쓴다.
이 룰도 완벽하지 않다. 그래서 화면에 탐지된 헤더 행을 표시하고 사람이 바꿀 수 있게 했다. 파서의 추정은 제안이지 결정이 아니다. 이 프로젝트에서 반복되는 원칙이 여기서도 똑같이 적용된다.
46085 — "원본 그대로 저장한다"가 무너지는 한 칸
엑셀에서 2026-03-04로 보이는 셀의 실제 값이 46085인 경우가 있다. 엑셀 시리얼 넘버다. 셀 값은 숫자고, 서식이 살아 있을 때만 화면에 날짜로 보인다. 그리고 현업 파일은 복사·붙여넣기를 거치며 서식이 날아간다.
여기서 질문이 하나 생긴다. "원본 그대로 저장한다"고 할 때, 이 칸의 원본은 46085인가 2026-03-04인가?
둘 다 진심으로 원본이다. 저장하는 쪽이 하나를 고르면 다른 하나는 영원히 사라진다.
해석은 한다. 3,000행 중 701건을 날짜로 읽었다. 대신 해석했다는 사실을 숨기지 않는다. EXCEL_SERIAL_DATE 경고를 남기고, 원본 행을 펼치면 받은 값이 그대로 나온다.

화면 맨 위에 **"엑셀 시리얼 넘버를 날짜로 해석했습니다: 46101 → 2026-03-20"**이라고 적혀 있고, 아래 표에는 우리가 받은 원본이 그대로 있다. 46101 · NUMERIC · 46101. 서식이 이미 날아간 파일이라 화면 표시값마저 숫자다. 이 칸을 열어 보지 않으면 시스템에 들어간 날짜가 어디서 왔는지 알 수 없다.
그리고 시리얼로 인정하는 범위를 20000~60000으로 좁혔다. 안 그러면 사원번호 12345가 조용히 1933년으로 바뀐다. 범위를 좁힌 대신 범위 밖 숫자는 그냥 숫자로 둔다. 조용히 맞히는 것보다 시끄럽게 안 건드리는 쪽이 낫다.
3,000행을 적재해도 재고는 1g도 안 움직인다
이관 결과는 이랬다.
- 성공 2,864 · 보류 69 · 실패 136 · 전표 1,156건 생성 (전부 DRAFT)
- 원장 3행 → 3행
- 재고 합계 113.500 → 113.500 재고가 1g도 움직이지 않았다
- 롤백 → 전표 1,156건 삭제, 원본 3,069행은 그대로 보존
- 확정된 전표가 생긴 뒤의 롤백 → 거부됨

리포트에서 눈여겨볼 건 "등록 대상 아님"과 "실패"를 갈라 놓은 것이다. 소계 행·빈 행·중복 행은 애초에 등록할 값이 아니고(69건), 실패는 등록해야 하는데 못 한 행이다(136건). 둘을 뭉쳐 한 숫자로 만들면 그 숫자는 아무도 안 읽는다.
3,000행을 적재했는데 재고가 안 움직인다. 만들어지는 건 DRAFT 전표까지고, 확정은 사람이 따로 누른다.
이건 편의를 포기한 결정이다. 이관하면서 바로 확정해 버리면 시연이 한 단계 짧아진다. 그런데 1편에서 재고를 바꾸는 경로는 StockService 하나뿐이라고 못 박았다. 이관이 두 번째 경로가 되는 순간 "재고는 항상 원장의 합"이라는 불변식이 무너진다. 이관이 편해지자고 1편을 무너뜨리지는 않는다.
덤이 하나 붙는다. 롤백이 정확해진다. 재고가 안 움직였으니 되돌릴 재고가 없다.
통합 테스트의 심장이 이 한 줄이다.
importLoader.load(jobId, false, actor); // 3,000행 적재
assertThat(movementRepository.count()).isEqualTo(before); // 원장은 한 행도 안 변했다
롤백 가능성이 컬럼 하나에 걸려 있다
이관이 한빛상사라는 거래처를 새로 만들었다고 하자. 롤백하면 지워야 한다. 그런데 이관이 시드에 이미 있던 (주)한빛에 링크만 했다면, 롤백해도 그건 건드리면 안 된다. 남의 데이터다.
이 구분이 컬럼 하나에 들어 있다.
| 값 | 의미 | 롤백 시 |
|---|---|---|
| TRUE | 이 행이 엔티티를 새로 만들었다 | 지운다 |
| FALSE | 이미 있던 엔티티에 링크만 했다 | 건드리지 않는다 |
그래서 이관 적재는 마스터를 UPDATE하지 않는다. 있으면 링크, 없으면 생성. UPDATE를 허용하는 순간 롤백에 "이전 값 복원"이 필요해지고, 그건 훨씬 무거운 기능이다. 그건 다음 편(마스터데이터 병합)의 몫으로 미뤘다.
롤백이 거부되는 경우도 두 가지 뒀다. 확정된 전표가 하나라도 있으면 거부한다. 원장은 append-only라서, 되돌리려면 롤백이 아니라 전표 취소(반대 부호 원장)를 써야 한다. 그리고 이관이 만든 마스터를 다른 데서 쓰고 있어도 거부한다.
두 번째는 FK로 터뜨리지 않고 미리 묻고 거부한다. 예외가 나면 트랜잭션이 rollback-only로 오염돼서, 왜 롤백할 수 없는지 설명할 기회조차 사라지기 때문이다.
룰에도 신뢰도를 매겼다 — AI 자리를 미리 비워 뒀다
이 단계의 완료 기준은 "AI 없이도 이관이 굴러간다"였다. 그걸 이렇게 해석했다.
다음 단계에서 AI가 들어와도 승인 게이트는 한 줄도 안 바뀐다. 제안자만 룰에서 AI로 갈린다.
그래서 룰도 AI처럼 취급했다. 동의어 사전에 정확히 걸리면 1.000, 부분 일치면 0.900, 못 맞히면 0.000. 그리고 왜 그렇게 판단했는지 문장으로 남긴다.
- 거래처코드 → code 1.000 "헤더 '거래처코드'가 code의 동의어와 정확히 일치"
- 거래처명 → name 1.000
- 비고2 → (모름) 0.000 "어떤 필드와도 매칭되지 않았습니다. 사람이 지정해야 합니다."
비고2가 룰에서도 0.000이 나와 사람에게 넘어간다. AI 없이도 "기계가 모른다고 말하는" 장면이 성립한다.
덕분에 얻은 게 두 개 있다. 하나는 suggestion_source 컬럼(RULE 또는 AI) 하나만 추가하면 다음 단계가 들어온다는 것. 테이블도, 승인 로직도, 화면도 안 바뀐다. 다른 하나는 AI 제안을 채점할 정답지가 생겼다는 것. AI가 룰보다 낫다고 말로 주장하지 않고 숫자로 비교할 수 있다.
여기서부터가 진짜다 — 남의 DB
엑셀 다음은 구 시스템 DB다. 같은 MySQL의 다른 스키마가 아니라 **다른 서버(포트 3308)**이고, 서버 문자셋이 euckr다.
utf8mb4로 가짜 레거시를 만들었으면 훨씬 편했을 것이다. 안 그런 이유가 있다.
그리고 깨진 값은 적재하지 않는다. CHARSET_CORRUPTED로 실패시킨다. 깨진 3만 건은 복구되지 않지만, 실패한 3만 건은 다시 넣으면 된다.
레거시 이관은 엑셀과 완전히 같은 파이프라인을 탄다. ExcelParser 자리에 LegacyReader가 들어가 같은 형태의 결과를 만들고, 그 뒤의 원본 보존·룰 매핑·승인 게이트·적재·롤백은 한 줄도 새로 안 짰다. 파서와 파이프라인을 분리해 둔 값을 여기서 회수했다.
트리거 목록을 뽑을 수 없다
레거시 이관 가이드들은 하나같이 "첫 주에 트리거·프로시저 목록부터 뽑아라"고 한다. 구 시스템의 업무 규칙이 거기 숨어 있기 때문이다. 그런데 뽑을 수가 없었다.
MySQL에서 트리거 목록을 조회하려면 TRIGGER 권한이 필요하고, 그 권한은 DROP TRIGGER도 허용한다. 데이터를 안 바꿔도 구 시스템을 망가뜨릴 수 있다. 이 프로젝트의 불변 규칙 중 하나가 "어떤 경우에도 레거시에 손대지 않는다"였다.
그래서 권한을 안 받기로 했다. 여기서 진짜 함정이 나온다.
기대
권한이 없으면 거부
ERROR 1142 ... denied- 화면이 "권한 없음"을 표시한다
- 사람이 상황을 안다
실제
빈 결과를 준다
information_schema.TRIGGERS→ 0 행- "트리거가 없다"와 구분되지 않는다
- 숨은 로직을 없는 것으로 착각한다
"없다"와 "볼 수 없다"는 다르다. 이걸 뭉개는 순간, 실제로는 규칙이 숨어 있는 시스템을 규칙이 없는 시스템으로 취급하게 된다.
대사가 못 본 규칙을 숫자로 찾아냈다
그래서 구 시스템의 FLAG1 컬럼이 뭔지 모르는 채로 이관했다. 컬럼명이 FLAG1이면 사람도 모른다. '무시'로 결정했다. 당연한 선택이다.
그다음 대사(reconciliation)를 돌렸다. 구 시스템 재고와 신 시스템 재고를 나란히 놓고 비교하는 화면이다.

이 글을 쓰면서 컨테이너를 새로 띄우고 처음부터 다시 돌린 결과다. 차이가 7건 나왔다.
- L-001 | 서울창고 구 390 신 890 +500
- L-003 | 서울창고 구 35 신 135 +100
- L-004 | 서울창고 구 75 신 225 +150
- L-005 | 서울창고 구 450 신 750 +300
- L-007 | 서울창고 구 95 신 295 +200
- L-010 | 서울창고 구 500 신 900 +400
- SKU-001 | 서울창고 구 1 신 0 −1
앞의 6건은 전부 신 시스템 재고가 더 많다. 이관이 뭔가를 더 넣었다는 뜻이다. 원본 드릴다운으로 해당 품목이 들어간 전표를 찾아 구 시스템에서 열어 봤다. FLAG1='C'인 전표였다. 취소 전표다.
마지막 한 줄은 성격이 다르다. SKU-001이 구 시스템에는 1인데 신 시스템에는 0이다. 이건 1편에서 두 창이 동시에 출고한 그 마지막 재고다. 구 시스템은 그 출고를 모른다. 대조는 그것도 정직하게 차이로 잡아 준다.
구 시스템은 취소 전표를 재고에 반영하지 않고 있었고, 그 규칙은 트리거 안에만 있었다. 우리는 그 트리거를 끝내 못 봤다.
잘못 적재된 취소 전표를 전표 취소로 되돌리고 다시 대사를 돌리면 그 6건이 사라진다. 처음 이 기능을 만들었을 때는 정확히 그렇게 차이 0건까지 갔고, 그 시점이 컷오버가 가능해지는 순간이다.
- ① 트리거 조회권한이 없다. 확인 불가 로 기록하고 넘어간다
- ② 순진하게 이관
FLAG1이 뭔지 몰라 '무시' 로 결정했다 - ③ 대사차이 6건. 신 시스템이 더 많다
- ④ 원인 추적드릴다운 →
FLAG1='C'인 전표를 우리가 적재했다 - ⑤ 정정 후 재대사차이 0건 · 컷오버 가능
이 장면이 이 프로젝트에서 제일 하고 싶은 말이다. 레거시를 완벽히 이해하지 못해도 안전하다. 이해하지 못한 규칙은 대사 리포트에서 숫자 차이로 저절로 튀어나온다.
방어선은 걸리라고 있다 — 그리고 실제로 걸렸다
구 시스템을 건드리지 않기 위해 방어를 세 겹으로 세웠다.
| 겹 | 방법 |
|---|---|
| ① DB 권한 | legacy_ro 계정에 GRANT SELECT 만. INSERT · UPDATE · DELETE 가 애초에 불가능하다 |
| ② 커넥션 | setReadOnly(true) |
| ③ 코드 구조 | 레거시에는 JPA 엔티티도 Repository 도 save() 도 존재하지 않는다. 실수로 write 를 호출할 코드 자체가 없다 |
그리고 며칠 뒤, 첫 번째 방어선이 실제로 걸렸다.
java.sql.SQLException: Connection is read-only.
Queries leading to data modification are not allowed
엑셀 이관이 만드는 신 시스템 INSERT가 레거시 커넥션으로 날아갔다.
원인은 Spring Boot의 자동 설정이었다. JdbcTemplate 자동 설정은 @ConditionalOnMissingBean(JdbcOperations.class) 조건으로 걸려 있다. 내가 legacyJdbcTemplate 빈을 만드는 순간 자동 설정이 통째로 꺼졌고, 애플리케이션에 존재하는 유일한 JdbcTemplate이 "레거시(읽기 전용)" 하나가 됐다. 그리고 그게 신 시스템에 쓰는 쪽으로 주입됐다.
이게 이 단계에서 제일 값진 사고였다. 방어선을 세 겹으로 세우면서도 속으로는 "이건 형식적인 거고 실제로 걸릴 일은 없겠지" 싶었는데, 걸렸다.
조용히 사라지는 것이 실패보다 나쁘다
마지막으로 하나 더. 구 시스템에는 외래키가 없어서 존재하지 않는 거래처를 참조하는 전표가 실제로 있었다.
전표와 거래처를 INNER JOIN으로 읽으면 그 전표가 결과에서 조용히 사라진다. 이관은 성공하고, 리포트는 깨끗하고, 전표 두 건이 증발한다. 아무도 모른다.
LEFT JOIN으로 읽으면 거래처명이 NULL로 와서 "거래처명이 비어 있습니다"로 실패한다. 리포트에 남고, 사람이 본다.
이관에서 "조용히 사라지는 것"보다 나쁜 것은 없다. 실패는 보이지만 증발은 안 보인다.
같은 이유로 대사의 값 비교는 equals가 아니라 compareTo를 쓴다. BigDecimal의 equals는 소수 자릿수까지 보기 때문에 1.0과 1.00을 다르다고 판정한다. 거짓 MISMATCH는 진짜 MISMATCH를 묻어버린다. 허용 오차는 0이되, 표기 차이는 차이가 아니다.
그리고 레거시 커넥션 풀은 3개로 제한했다. 남의 DB다. 우리 이관 배치가 구 시스템 커넥션을 말리면 그쪽 업무가 멈춘다. 아직 구 시스템으로 회사가 굴러가고 있다.
여기까지 오면 룰이 무너진다
이 단계는 "AI 없이도 이관이 굴러간다"를 증명하려고 만들었고, 실제로 굴러갔다. 그런데 구 시스템 컬럼 앞에서 룰이 이렇게 된다.
- CUST_CD → (모름) 0.000
- CUST_NM → (모름) 0.000
- COL1 → (모름) 0.000
- FLAG1 → (모름) 0.000
- TEMP_YN → (모름) 0.000
- REG_DT → (모름) 0.000
- 6개 컬럼 전부 신뢰도 0.000 — 사람이 전부 지정한다
동의어 사전에 CUST_NM을 넣으면 해결된다. 안 넣었다. 그걸 넣는 건 **"이 특정 레거시 스키마를 미리 알고 있다"**는 뜻이고, 그 순간 이관 도구가 아니라 이 회사 전용 스크립트가 된다.
"AI 없이도 이관이 굴러간다"는 여전히 참이다. 다만 사람이 전부 지정한다. 정확히 그 지점이 AI가 필요한 이유고, 그 필요를 말이 아니라 화면으로 보여주기로 했다.
다음 편에서 AI를 켠다. 그런데 켜면서 세운 목표가 좀 이상하다. 잘 맞히게 만드는 게 아니라, 모를 때 모른다고 말하게 만드는 것이었다. AI가 모든 컬럼을 자신 있게 맞혀 버리면 승인 화면이 전부 초록불이 되고, 사람은 그냥 눌러 넘긴다. 그 순간 이 프로젝트의 승인 게이트는 껍데기가 된다.