오늘 교회 재정 시스템의 운영 DB 데이터를 검증하는 작업을 했다.
처음엔 단순했다. "수입이랑 지출이 맞는지 한번 확인해보자." 운영 DB를 덤프받아 개발 DB에 복원하고, 2026년 1~2월의 주차별 Excel 파일과 DB를 건별로 비교했다. 1월 4주차는 전부 일치. 좋은 신호였다.
그런데 2월부터 삐걱거리기 시작했다.
2월 8일 주차에서 나온 이름들
DB에는 있는데 Excel에는 없는 항목이 3건 잡혔다.
- 박영희, 30,000원, 생일감사
- 이순신, 20,000원, 장학
- 강감찬, 10,000원, 구제
박영희야 그렇다 치자. 그런데 이순신이라니. 임진왜란을 승리로 이끈 그 이순신? 거북선을 만든 그 사람이 교회 헌금을?
창시일을 확인했다. 셋 다 2026년 2월 18일 02:01. 새벽 두 시에 OCR로 일괄 입력된 항목들이었다. 명부를 확인했다. 이순신도, 강감찬도, 박영희도 명부에 없었다. 2월 15일 주차에서도 비슷한 패턴으로 추가된 항목들이 있었는데, 그쪽은 명부에 실제로 있는 이름(정형모, 이루리)이었다.
결론은 단순했다. 누군가 OCR 입력 테스트를 하면서 이순신, 강감찬을 이름으로 썼고, 그게 그대로 운영 DB에 들어갔다. 테스트 데이터가 운영에 올라간 클래식한 사고였다.
왜 아무도 몰랐을까
시스템은 이 항목들을 걸러내지 않았다. OCR로 이름을 추출하고 저장할 때 명부 교차 검증이 없었기 때문이다. 헌금 전표에 이순신이라고 적혀 있으면 그냥 이순신으로 저장했다.
생각해보면 당연한 설계다. 헌금 입력 시스템에 "이 사람이 교인인지 확인"하는 게이트는 원래 없었다. 신규 교인이 처음 헌금하는 경우도 있으니까. 그래서 이름 교정 시스템은 fuzzy matching으로 명부에서 가장 가까운 이름을 찾아주는 역할만 했다.
이순신은 fuzzy matching으로도 교정이 안 됐다. 명부에 비슷한 이름 자체가 없으니까.
SQL로 지우면 끝인 줄 알았다
soft delete로 6건을 처리했다. 간단했다. 그런데 UI의 "이력" 버튼을 눌렀더니 아무것도 나오지 않았다.
시스템의 이력 관리 구조를 확인했다. income_history 테이블이 따로 있었고, 이 테이블에 기록이 쌓이는 건 API를 통한 변경일 때뿐이었다. 우리가 psql로 직접 UPDATE income SET deleted_at = NOW()를 실행한 건 API를 거치지 않았으니 이력이 없는 게 당연했다.
API 삭제 → income_history에 action="delete" 기록 → UI에 표시
SQL 직접 → income_history 아무것도 없음 → UI에 안 보임
이력 테이블에 수동으로 레코드를 삽입해서 해결했지만, 애초에 API를 통해 삭제했다면 이 과정이 필요 없었다.
두 가지를 배웠다
첫째, 운영 DB 직접 수정은 사이드 이펙트가 많다. 이력 누락은 그나마 발견하기 쉬운 편이다. 마감 잠금 같은 비즈니스 로직도 우회된다. API를 통하면 이런 것들이 자동으로 적용된다.
둘째, 입력 시점의 검증이 없으면 나중에 훨씬 비싼 비용을 치른다. 이순신이 들어온 순간에 "명부에 없는 이름입니다. 계속하시겠습니까?"라는 경고가 있었다면 오늘 이 작업이 필요 없었을 것이다. 사후 검증이 아니라 입력 시점 검증이 맞다.
교회 재정 데이터베이스에서 이순신과 강감찬을 만날 줄은 몰랐다. 그런데 덕분에 시스템의 구멍 두 개를 찾았다. 나쁘지 않은 하루였다.