ON DELETE CASCADE의 함정: 전송한 세금계산서가 출하 되돌리기에 딸려 지워지는 경로
제조업 ERP 데모에 전자세금계산서 모의 발행을 붙인 날 저녁, 스키마를 다시 읽었습니다. 세금계산서 모델의 전표 연결 한 줄에 이런 주석이 붙어 있었습니다.
/// 어느 매출 전표에서 났나. 전표가 지워지면 계산서도 지워진다(되돌리기 흐름).
voucherId String
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)주석까지 달아 둔 의도된 설계였습니다. 그리고 이 한 줄 때문에 국세청 전송까지 끝난 세금계산서가 출하 되돌리기 한 번에 흔적 없이 사라질 수 있었습니다.
되돌리기를 편하게 하려던 Cascade
출하를 등록하면 매출 전표가 생깁니다. 잘못 등록한 출하를 되돌리면 재고 이동을 지우고 매출 전표도 지웁니다. 주석의 「되돌리기 흐름」이 이 선택의 이유였습니다. 전표가 지워질 때 계산서가 남아 있으면 외래 키 오류가 나니, 같이 지워지게 두면 되돌리기가 손대지 않고 그대로 동작합니다.
PostgreSQL 문서의 설명 그대로입니다.
CASCADE specifies that when a referenced row is deleted, row(s) referencing it should be automatically deleted as well.
이 선택은 기본값이 아니었습니다. Prisma 문서에 따르면 필수 관계에서 onDelete 를 적지 않으면 Restrict 입니다. 기본값이었다면 막혔을 삭제를, 되돌리기를 살리려고 일부러 풀어 준 셈입니다.
출하 되돌리기가 보던 것과 보지 않던 것
출하 되돌리기에는 이미 막는 조건이 둘 있었습니다.
if (voucher?.settled) back(backTo, { e: "수금된 출하는 되돌릴 수 없습니다. 수금을 먼저 취소하세요." });
if (voucher?.closingId) back(backTo, { e: "마감된 달의 출하는 되돌릴 수 없습니다." });수금이 끝났거나 마감된 달이면 되돌리지 못합니다. 둘 다 「이미 밖으로 나간 것은 되돌리지 않는다」는 규칙입니다. 세금계산서는 그 규칙에 빠져 있었습니다. 수금 전이고 마감 전인 출하라면, 계산서가 발행됐든 국세청에 전송됐든 되돌리기는 통과하고 전표 삭제가 계산서를 끌고 갔습니다.
앱 코드의 조건과 스키마의 Cascade 가 따로 보면 둘 다 멀쩡하다는 것이 문제였습니다. 되돌리기 코드는 계산서를 지우는 줄이 없으니 계산서를 건드리지 않는 것처럼 읽힙니다. 스키마는 되돌리기 흐름을 위해 Cascade 를 걸었다고 적혀 있습니다. 둘을 겹쳐 읽어야 전송 완료 계산서가 지워지는 경로가 보입니다.
전송한 세금계산서는 지울 수 있는 기록이 아닌 이유
데모의 전송은 모의 승인번호를 붙이는 데서 끝나지만, 실제 제도라면 이 경로는 성립할 수 없습니다. 전송이 끝난 전자세금계산서는 이미 국세청에 신고된 문서입니다. 우리 데이터베이스에서 행을 지운다고 신고가 사라지지 않습니다. 남는 것은 국세청에는 있고 우리 장부에는 없는 계산서입니다.
바로잡는 길은 삭제가 아니라 수정 발행입니다. 원래 계산서를 남겨 두고, 그것을 가리키는 수정 계산서를 새로 발행합니다. 그래서 이 데모의 계산서 모델에도 수정 발행이 원본을 가리키는 칸이 따로 있었습니다. 수정 발행을 만들어 놓고 원본은 Cascade 로 지워지게 둔 것이 제 누락이었습니다.
Restrict 와 되돌리기의 막는 조건 두 겹
고친 것은 두 곳입니다.
스키마의 삭제 규칙을 Restrict 로 바꿨습니다. 계산서가 있는 한 전표는 지워지지 않습니다.
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Restrict)이것만으로는 사용자가 되돌리기를 눌렀을 때 외래 키 오류가 납니다. 그래서 되돌리기 코드에도 조건을 넣고, 계산서의 상태에 따라 다른 안내를 냅니다.
if (ti) back(backTo, { e: ti.status === "ISSUED"
? `세금계산서 ${ti.no} 를 먼저 취소하세요.`
: `전송한 세금계산서 ${ti.no} 가 있어 되돌릴 수 없습니다. 수정 발행으로 바로잡으세요.` });발행만 하고 전송 전이면 계산서를 취소하고 다시 되돌리면 됩니다. 전송까지 했으면 되돌리기 자체가 틀린 길이라 수정 발행으로 보냅니다.
두 겹으로 둔 이유가 있습니다. 앱의 조건은 사용자에게 무엇을 하라고 말해 주지만, 다른 경로에서 전표를 지우는 코드가 생기면 그 조건을 거치지 않습니다. 스키마의 Restrict 는 말은 못 하지만 어느 경로로 오든 막습니다. 앱 조건이 빠진 곳이 생기면 사용자는 친절한 안내 대신 오류를 보겠지만, 계산서는 남습니다.
Restrict 가 드러낸 다른 삭제 경로
Restrict 로 바꾸자 곧바로 걸린 곳이 하나 있었습니다. 테스트 스크립트가 만든 수주를 치우는 정리용 API 입니다. 이 경로는 수주에 딸린 전표를 한꺼번에 지웠고, 계산서가 붙어 있으면 이제 실패합니다.
여기는 되돌리기와 성격이 다릅니다. 스크립트가 만들고 스크립트가 치우는 데이터라 계산서까지 지우는 것이 맞습니다. 그래서 계산서를 먼저 명시적으로 지우도록 한 줄을 더했습니다. Cascade 가 알아서 하던 일을 코드에 적은 것입니다. 이제 계산서를 지우는 곳은 코드를 검색하면 전부 나옵니다.
스모크 테스트도 이 흐름에 맞췄습니다. 발행하고, 되돌리기가 막히는지 보고, 취소한 뒤 되돌립니다. 이미 전송된 시드 계산서로는 수정 발행과 그 취소를 확인합니다. 스모크에서 전송은 하지 않습니다. 전송은 되돌릴 수 없는 동작이라, 테스트가 돌 때마다 되돌릴 수 없는 기록이 하나씩 쌓이기 때문입니다.
Cascade 를 걸기 전에 물을 질문
이번 일로 저는 Cascade 를 걸 때 질문 하나를 더 합니다. 지워지는 쪽이 밖으로 나간 적이 있는 기록인가.
같은 스키마에서 전표 줄은 전표에 Cascade 로 걸려 있고, 그게 맞습니다. 전표 줄은 전표의 일부일 뿐 따로 밖에 나가지 않습니다. 세금계산서는 전표에서 태어나지만 국세청과 거래처에게 따로 전달됩니다. 부모에게서 만들어졌다는 사실만으로 부모와 운명을 같이하지는 않습니다.
수금, 마감, 전송처럼 되돌리기를 막는 조건이 앱 코드에 늘어선다면, 그 목록은 사실 「밖으로 나간 기록」의 목록입니다. 그 목록에 새 항목이 생길 때마다 스키마의 삭제 규칙도 같이 봐야 한다는 것이 이번에 배운 것입니다.
함께 읽기
- ERP 공통 코드와 enum: 드롭다운 선택지를 어디에 둘지 정하는 기준생산 실적 화면에 불량 사유를 적는 칸이 있다고 해 보겠습니다. 한 달이 지나 불량 사유별로 집계를 내면 이런 줄이 나옵니다.
- 전자세금계산서 연동: 업무 시스템이 국세청 대신 발급대행 API에 붙는 이유업무 시스템 이야기에서 「세금계산서도 되나요?」라는 질문은 빠지기 어렵습니다. 이 질문에는 흔히 「시스템이 국세청에 연결되나요?」라는 기대가 섞여 있습니다. 전표를 저장하는 순간 국세청에 신고까지 끝나는 그림입니다.
- ERP 메뉴 스타일 정리: 화면을 찾아가는 여섯 가지 방식과 두 줄 툴바입사 둘째 날인 영업 담당자가 수주를 넣으려고 ERP를 엽니다. 왼쪽 메뉴에는 영업관리, 구매관리, 재고관리, 생산관리가 있고, 영업관리를 펼치니 수주등록, 수주현황, 수주마감, 수주변경이력이 또 나옵니다. 옆자리 선임은 메뉴를 쓰지 않습니다. 검색창에 「수주」를 치고 엔터를 누릅니다.
- AI 도입 전에 물어야 할 것: ERP 를 바꿀 일인가, 앞뒤를 붙일 일인가AI 기능이 들어갔다는 업무 시스템으로 갈아탔습니다. 반년이 지났는데 현장에서는 여전히 거래처 카톡을 읽어 엑셀에 옮겨 적고 있습니다.
- 거래처 관리와 CRM의 차이: 거래가 생기기 전의 기록ERP의 거래처 등록 화면을 열면 필수 칸이 이렇습니다. 사업자등록번호, 상호, 대표자, 세금계산서 받을 이메일, 결제 조건.