기술 포스트

AI로 만드는 ERP인데, 1단계에는 AI가 한 줄도 없다


Article

현업에서 쓰는 엑셀을 몇 번 열어 보고, 이런 게 있으면 편하겠다 싶어서 만들었다. 엑셀·레거시 DB → ERP 이관 어시스턴트다. 지저분한 현업 엑셀과 오래된 구 시스템 DB를 넣으면 AI가 스키마를 제안하고, 사람이 승인해야만 적재되는 도구다.

재고 관리를 엑셀로 굴리다 시스템으로 옮기는 일은 어디에나 있다. 그런데 그 이관 과정은 대개 누군가 손으로 밀어 넣고, 틀리면 어디서 틀렸는지 아무도 모른다. 그 부분을 도구로 만들면 어떨까 싶었다.

그런데 로드맵의 1단계(P0)에는 AI 호출이 한 줄도 없다. 재고 원장, 전표, 감사 로그, 그리고 동시성 검증. 그게 전부다.

순서를 뒤집자는 생각을 안 해본 건 아니다. AI 매핑 화면을 먼저 만들면 데모가 훨씬 빨리 그럴싸해진다. 그런데 그 순서로 만들면 결국 이런 물건이 나온다. AI가 뭔가 제안하고, 그걸 받아 적는 곳은 아무 방어가 없는 테이블인 도구.

이 시리즈의 전제는 한 문장이다.

판단은 AI가 하고, 책임은 사람이 진다.

책임을 사람이 지려면 사람이 되돌릴 수 있어야 하고, 되돌리려면 무슨 일이 있었는지 남아 있어야 한다. 그건 AI 기능이 아니라 데이터 구조와 DB 권한의 문제다. 그래서 1단계는 AI가 없다. 1편은 그 바닥을 까는 이야기다.

완료 기준을 문장이 아니라 실패로 적었다

P0의 완료 기준을 이렇게 적었다.

마지막 재고 1개를 두 명이 동시에 출고하면, 한쪽만 성공한다.

"동시성을 고려한다" 같은 문장이 아니라, 재현할 수 있는 장면으로 적었다. 이렇게 적어 두면 좋은 점이 하나 있다. 통과했는지 아닌지를 내가 판정하지 않아도 된다. 테스트가 판정한다.

그리고 이 기준 하나가 그 아래 결정을 거의 다 끌고 나왔다.

첫 결정 — 테스트 DB를 H2로 쓰지 않는다

보통 이 규모의 프로젝트에서 테스트 DB는 H2다. 빠르고, 설정이 없고, CI에서 잘 돈다. 나도 그러려고 했다.

그런데 위 완료 기준을 H2로 검증하면 어떻게 되는지 생각해봤다.

락 테스트를 어디서 돌릴 것인가

H2

빠르다

  • InnoDB 의 행 잠금 · 갭 락이 없다
  • innodb_lock_wait_timeout 이 없다
  • 락 테스트가 통과한다
  • 그런데 그 통과가 아무것도 증명하지 않는다

Testcontainers MySQL

느리다

  • 진짜 InnoDB 위에서 돈다
  • 갭 락 · 데드락 · 타임아웃이 실제로 일어난다
  • 테스트가 느려진다
  • 대신 테스트가 진실을 말한다
H2 에서 동시성 테스트를 통과시키면, "통과했다"는 사실 자체가 거짓말이 된다. 느려지는 대가로 진실을 산다.

Testcontainers를 골랐다. 매 테스트마다 도커로 MySQL 8을 띄우니 당연히 느리다. 이 결정이 값을 하는 데는 하루가 걸리지 않았다.

가장 값진 버그 — 내가 만든 데드락을 테스트가 잡았다

재고를 차감하려면 재고 행을 잠가야 한다. 그런데 재고 행이 아직 없는 경우가 있다. 새 창고에 처음 입고되는 순간이다. 없는 행은 잠글 수 없으니, 없으면 0으로 만들고 잠그기로 했다.

이렇게 짰다.

① findForUpdate(warehouse, item)   // 행이 없다
② insertZeroRowIfAbsent(...)       // 만든다
③ findForUpdate(warehouse, item)   // 이제 있다. 잠근다

읽어보면 아무 문제가 없다. 없으면 만들고, 만들었으니 잠근다. 완벽히 그럴싸한 코드다. 그리고 InnoDB에서 데드락이 난다.

왜 데드락이 나는가
  1. 01

    A 와 B 가 동시에 ① 을 실행한다

    존재하지 않는 행에 FOR UPDATE. InnoDB 는 REPEATABLE READ 에서 "행이 들어올 자리"에 갭 락(gap lock) 을 건다.

  2. 02

    여기까지는 둘 다 통과한다

    갭 락끼리는 서로 호환된다. 충돌하지 않는다. 이 지점에서는 아무 문제도 보이지 않는다.

  3. 03

    A 와 B 가 각각 ② 를 실행한다

    INSERT 에는 삽입 의도 락(insert intention lock) 이 필요하다. 그리고 삽입 의도 락은 상대방의 갭 락과 충돌한다.

  4. 04

    서로가 서로를 기다린다

    A 는 B 의 갭 락을, B 는 A 의 갭 락을 기다린다.

    Deadlock found when trying to get lock; try restarting transaction
① 이 만든 갭 락이 ② 를 막는다. 한 트랜잭션 안에서 앞 줄이 뒷 줄을 막고 있었다.

고친 방법은 순서를 뒤집는 것이다.

① insertZeroRowIfAbsent(...)       // 없으면 넣고, 있으면 아무것도 안 한다
② findForUpdate(warehouse, item)   // 행이 반드시 있으므로 레코드 락이 걸린다

먼저 넣어 버리면 갭 락을 만들 일이 없다. 행이 이미 존재하는 상태에서 FOR UPDATE를 걸면 갭 락이 아니라 레코드 락이 걸리고, 레코드 락은 이런 식으로 교착되지 않는다.

대가는 있다. 재고 행이 이미 있는 흔한 경우에도 아무 일도 안 하는 INSERT 문이 한 번 더 나간다. 값싼 대가다.

느린 테스트를 고른 값을 여기서 다 받았다.

왜 비관적 락인가 — 재시도가 무의미하기 때문이다

락을 걸기로 했으면 다음 질문은 낙관이냐 비관이냐다. JPA에서는 @Version 이냐 @Lock(PESSIMISTIC_WRITE) 냐의 문제다.

비관적 락을 골랐고, 근거는 성능이 아니라 결말이었다.

낙관적 락으로 마지막 재고 1개를 두 명이 동시에 출고하면, 뒤늦은 쪽은 "다른 사용자가 먼저 수정했습니다"를 받고 재시도한다. 그리고 재시도한 자리에서 재고가 0인 걸 보고 또 실패한다.

낙관적 락에서 B 가 겪는 일
  1. 1차 시도커밋 시점에 버전 충돌 → 실패
  2. 재시도프레임워크든 사람이든 다시 시도한다
  3. 2차 실패이번엔 재고가 0 이라 실패한다
  4. 결론재시도가 결말을 바꾸지 못했다. 두 번 실패했을 뿐이다
낙관적 락은 "충돌이 드물다"는 가정 위에 선다. 재고 차감은 그 가정이 성립하지 않는 대표적인 지점이다.

비관적 락은 B를 잠깐 대기시켰다가, A가 커밋한 뒤 재고 0을 보여준다. 같은 결말에 한 번에 도달한다.

근거가 하나 더 있다. 원장에 balance_after(이 거래 직후의 잔고) 컬럼을 두기로 했는데, 이 값은 순서에 의존한다. (품목, 창고) 단위로 트랜잭션이 직렬화되어야 값이 맞는다. 낙관적 락은 직렬화를 보장하지 않는다. 락을 빼면 CHECK 제약이 깨지기 전에 balance_after가 먼저 조용히 틀린다.

그리고 innodb_lock_wait_timeout은 기본값이 50초다. 그대로 두면 시연 중에 브라우저가 50초 멈춘다. 5초로 줄이고, 타임아웃을 "다른 사용자가 처리 중입니다"라는 명확한 에러로 매핑했다.

두 번째 삽질 — 화면에 틀린 설명을 써 놨다

동시 출고를 브라우저 두 창에서 시연하는 화면을 만들면서, 결과 패널에 이런 설명을 적어 뒀다.

"소요시간이 곧 락 대기의 증거다."

두 번째 창이 오래 걸렸다면 락 앞에서 기다린 것이다 — 그렇게 생각했다. 그리고 실제로 돌려봤다.

두 창에서 마지막 재고 1개를 동시에 출고한 결과. 창1은 확정 완료, 창2는 재고 부족.

이 글을 쓰면서 다시 한 번 돌린 결과다. 창1이 먼저 누르고 32ms 뒤에 창2가 눌렀다.

이번 실행
  • 창1 확정 완료 누른 시각 13:29:44.554 걸린 시간 55ms
  • 창2 재고 부족 누른 시각 13:29:44.586 걸린 시간 41ms
  • INSUFFICIENT_STOCK (HTTP 409) · 요청 1.000 / 현재 0.000
  • 재고 1 → 0 (−1 이 되지 않았다)

숫자를 이어 보면 이렇다. 창2는 32ms 뒤에 눌렀고, 창1의 트랜잭션은 55ms 걸렸다. 창2가 도착했을 때 창1은 아직 커밋 전이었다. 그래서 창2는 락 앞에서 20ms 남짓 기다렸다가, 창1이 커밋한 뒤 재고 0을 봤다.

문제는 항상 이렇지는 않다는 것이다. 처음 이 화면을 만들고 손으로 눌렀을 때는 이랬다.

그때 실행
  • 창1 확정 성공 누른 시각 16:56:35.612 걸린 시간 29ms
  • 창2 재고 부족 누른 시각 16:56:35.630 걸린 시간 19ms
  • 18ms 늦게 눌렀는데 19ms 만에 끝났다 → 기다린 적이 없다

이때 창2는 락 앞에서 기다린 적이 없다. 창1이 이미 커밋을 끝낸 뒤에 진입해서 그냥 재고 0을 본 것이다. 사람이 손으로 두 창을 누르면 대개 이렇게 된다. 밀리초 단위로 겹치는 일이 오히려 드물다.

두 실행은 내부에서 완전히 다른 일이 일어났다. 그런데 결과는 똑같다. 재고는 음수가 되지 않았다.

이 삽질이 앞의 데드락보다 오히려 더 오래 남았다. 그럴싸하고, 대체로 맞고, 가끔 틀린 문장은 코드에서만 나오는 게 아니었다. 내가 화면에 적어 둔 설명이 정확히 그거였고, 실제로 돌려보기 전까지는 틀린 줄도 몰랐다.

재고는 두 벌로 관리한다 — 원장과 스냅샷

재고 수량을 어디에 둘 것인가. 두 방식이 있고, 둘 다 하나씩 못 하는 게 있다.

원장만 · 스냅샷만 · 둘 다
방식재고 조회왜 이 값인지 역추적
원장(수불 이력)만매번 수십만 행 SUM — 느리다가능하다
스냅샷(현재 수량)만한 행 읽기 — 빠르다불가능하다
둘 다스냅샷을 읽는다원장으로 추적한다
"재고 수량이 왜 이 값인가" 에 답할 수 없는 ERP 는 재고 관리가 아니라 숫자 표시기다.

재고 현황 화면. 왼쪽은 현재고 스냅샷, 오른쪽은 그 숫자가 나온 입·출고 이력과 직후 잔고.

화면도 이 구조를 그대로 드러낸다. 왼쪽 표가 스냅샷이고, 행을 누르면 오른쪽에 그 숫자가 어떻게 나왔는지가 원장으로 펼쳐진다. 맨 위 행이 조금 전 동시 출고에서 재고를 1에서 0으로 만든 그 출고다. 상단의 "재고 정상"은 입·출고 이력을 처음부터 다시 더한 값이 현재고와 같은지 검사한 결과다.

둘 다 두기로 했다. 대신 둘은 어긋날 수 있다. 그래서 어긋났는지 확인하는 검증을 붙였다. 매 테스트 끝에 SUM(원장) == 스냅샷을 확인하고, 시드 데이터에도 원장과 스냅샷을 함께 넣는다. 스냅샷만 넣고 원장을 빼먹으면 첫 실행부터 검증이 MISMATCH를 뱉는다. 불변식은 코드에만 적용되는 규칙이 아니라 데이터가 존재하는 모든 순간에 성립해야 한다.

이 "캐시는 어긋날 수 있다 → 그래서 검증한다"는 사고방식은 나중에 그대로 재활용된다. 다음 편에 나올 대사(reconciliation) 가 정확히 같은 구조다. 구 시스템 재고와 신 시스템 재고를 맞춰보는 그 작업은, 여기서 만든 검증 스크립트의 확대판이다.

그리고 DB에 CHECK (quantity >= 0)을 걸어 뒀다. 이건 락의 대체재가 아니다.

잘못될 수 있는 경로를 아예 코드에서 없앤다

재고를 바꾸는 경로는 StockService 하나뿐이다. 원장과 스냅샷은 여기서만, 항상 함께 쓰인다.

말로만 그런 게 아니라 그렇게 되도록 강제했다.

규칙을 문서가 아니라 타입으로 강제한다
장치무엇을 불가능하게 만드는가
Stock 에 public setter 없음package-private apply() 만 둔다. 원장 없이 스냅샷만 바뀌는 코드 경로가 존재하지 않는다
StockMovement 에 setter 없음생성자로만 만들어지고 그 뒤로는 읽기 전용. 원장은 append-only 다
Propagation.MANDATORY쓰기 메서드를 트랜잭션 밖에서 부르면 호출 시점에 터진다. 조용히 틀리는 것보다 시끄럽게 실패하는 게 낫다
락 획득 순서 전역 고정전표 → 재고행들을 (창고, 품목) 오름차순으로. 데드락 예방의 거의 전부다
"이렇게 쓰면 안 된다" 를 문서에 적는 대신, 그렇게 쓸 수 없게 만든다.

감사 로그도 같은 기준으로 정했다. AOP나 Hibernate Envers로 전 엔티티 변경을 자동 기록하는 방법이 있고, 편하다. 안 썼다.

전부 자동으로 남기면 "왜 바꿨나"가 사라진다. "누가 발주 수량을 30에서 300으로 고쳤나"에 답하려면 의도가 기록되어야 하는데, 의도는 자동으로 추출되지 않는다. 감사 로그는 의도적으로 명시적이어야 한다.

커밋 이후에 비동기로 남기는 방법(@TransactionalEventListener(AFTER_COMMIT))도 안 썼다. 업무 트랜잭션이 커밋된 직후 장애가 나면 감사 로그만 유실된다. 상태는 바뀌었는데 기록이 없는 상태 — 감사 로그가 가장 하면 안 되는 일이다.

그래서 감사 로그는 업무 트랜잭션과 같은 트랜잭션 안에서 명시적으로 쓴다. 여기엔 딸려오는 성질이 하나 있다. 재고 부족으로 실패한 출고는 감사 로그에 안 남는다. 롤백될 때 같이 사라지기 때문이다.

남기려면 별도 트랜잭션을 열어야 하는데, 그 순간 "감사 로그 = 확정된 상태 변경의 원장"이라는 성질이 깨진다. 일어나지 않은 일이 섞인다. 그래서 감사 로그는 성공한 상태 변경만 담고, 실패 시도는 애플리케이션 로그로 남긴다. 이 성질 덕분에 감사 로그를 재생하면 재고가 정확히 복원된다.

감사 로그 화면. 왼쪽은 누가·언제·무엇을 했는지, 오른쪽은 바뀌기 전과 바뀐 후.

기록 한 줄을 누르면 무엇이 무엇으로 바뀌었는지가 나온다. 위 화면의 전표는 상태가 작성 중 → 확정됨으로 바뀌었고, 품목과 수량은 그대로다. 자동으로 전 필드를 덤프했다면 이 화면은 바뀌지 않은 필드까지 잔뜩 나열됐을 것이다.

곁다리로 밟은 것 하나

리포지토리 인터페이스를 클래스 안에 중첩해서 묶어 놨다가 기동이 깨졌다.

No qualifying bean of type 'MasterRepositories$ItemRepository'

Spring Data JPA는 클래스 안에 중첩된 인터페이스를 리포지토리로 스캔하지 않는다. 파일이 늘어나는 게 싫어서 홀더 클래스로 묶은 건데, 그러면 안 된다. 리포지토리는 최상위 인터페이스여야 한다.

별거 아닌 삽질인데 적어 둔다. 이런 건 원인을 알기 전까지 인가 설정이나 컴포넌트 스캔 범위를 의심하며 한참 헤매게 된다.

1단계를 끝내고 남은 것

AI는 아직 한 줄도 없다. 화면도 목록과 폼 몇 개뿐이다. 그런데 이제 이런 것들이 성립한다.

P0 이 끝난 시점에 증명된 것
  1. 동시 출고마지막 1개를 둘이 동시에 출고 → 한쪽만 성공, 재고는 음수가 안 된다
  2. 부분 반영 없음실패하면 원장 · 스냅샷 · 전표 · 감사 로그가 전부 롤백된다
  3. 역추적재고가 왜 이 값인지 원장으로 되짚을 수 있다
  4. 권한읽기 전용 계정으로 DELETE 하면 ERROR 1142 로 거부된다
마지막 항목이 이 시리즈의 복선이다. 4편에서 AI 에게 SQL 을 쥐여줄 때, "AI 가 DELETE 를 만들면 어떻게 되는가" 의 답이 코드가 아니라 DB 권한 이 된다.

읽기 전용 계정은 P0에서는 쓸 데가 없다. 쓸 데가 생기는 건 한참 뒤(4편의 자연어 조회)다. 그런데도 지금 만들어 뒀다. "호스팅이 계정 분리를 허용하지 않는다"를 배포 시점에 발견하면, 이 프로젝트의 핵심 주장 하나가 통째로 무너지기 때문이다. 로컬에서 먼저 성립시켜 두고 시작한다.

AI로 만든다면서 AI 없는 단계를 1편에 놓는 게 이상해 보일 수 있다. 그런데 이 프로젝트에서 AI가 하는 일은 제안이고, 제안은 받아주는 쪽이 단단할 때만 안전하다. 승인 게이트, 롤백 가능한 원장, 권한으로 막힌 계정 — 이게 없으면 AI 기능은 데모에서만 그럴싸한 장난감이 된다.

다음 편은 이제 진짜 지저분한 데이터를 만나는 이야기다. 상단 3행이 제목과 출력일시라 진짜 헤더가 4행부터 시작하는 엑셀, 날짜가 네 가지 형식으로 섞여 있고 그중 하나는 46085 같은 숫자인 엑셀. 그리고 문자셋이 euckr인 구 시스템 DB. 거기서 트리거를 볼 권한조차 없는 채로 이관을 끝내야 했던 이야기를 쓴다.

AI로 만드는 ERP인데, 1단계에는 AI가 한 줄도 없다 — 디코드랩(DCODELAB)