| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- php
- electron
- playwright
- 백엔드
- 아키텍처
- JWT
- React
- Vite
- 크롤링
- 배포
- FastAPI
- next.js
- 프론트엔드
- 보안
- 메일자동화
- REST API
- javascript
- Kotlin
- github actions
- Android
- sqlite
- 소셜로그인
- typescript
- 상태관리
- contextBridge
- 도메인설계
- 마이그레이션
- 인증
- 온디바이스AI
- python
- Today
- Total
HM소프트
회계 연동 ERP 개발기 (1) — 매입·매출 전표와 정산 도메인의 정합성 설계 본문

매입·매출 전표와 정산 도메인의 정합성을 Spring Boot로 설계한 기록. 매출 상태 모델과 MST/DTL 트랜잭션 원자화, 선매출 차감의 별도 전표 분리, 취소의 역분개, 월마감 가드, 그리고 외부 회계 ERP로의 야간 배치 전송까지 '틀린 상태를 만들 수 없게' 막는 법을 다룬다.
시리즈를 시작하며 — "조용히 틀리면 안 되는" 도메인
회사 한 곳의 영업·매입 업무를 다루는 모듈을 만들었다. 매출 전표를 등록하고, 세금계산서를 발행하고, 외주·구매 정산을 전자결재로 올리고, 최종적으로 외부 회계 ERP의 장부에 전표를 꽂는 일까지가 한 흐름이다. 백엔드는 Java(Spring Boot), 프론트엔드는 TypeScript(React)다.
이 시스템을 만들면서 가장 많이 했던 생각은 "조용히 틀리면 안 된다"였다. 일반 웹 서비스라면 버그가 나도 사용자가 새로고침하면 그만이다. 그런데 회계는 다르다. 매출 한 건이 절반만 저장되거나, 세금계산서가 두 번 발행되거나, 외부 ERP로 같은 전표를 두 번 보내면 — 그건 화면 버그가 아니라 장부가 틀리는 사고다. 게다가 이런 사고는 한참 뒤 결산 때 발견된다. 그래서 이 도메인의 코드는 "에러를 안 내는 것"보다 "틀린 상태를 만들 수 없게 막는 것"에 무게를 둬야 했다.
세 편으로 나눠 기록한다.
- (이 글) 매입·매출 전표와 정산 도메인 — 상태 모델, MST/DTL 원자성, 선매출 차감, 취소의 역분개
- 외부 시스템 연동 — 전자결재 솔루션과 회계 ERP 전송의 신뢰성
- 운영 안정화 — 마이그레이션 충돌, 트랜잭션 매니저, 보안
특정 회사명·연동 제품명은 전부 일반화하고 구현만 다룬다. 회계 ERP 제품, 전자세금계산서 발행 서비스, 전자결재 솔루션은 모두 "외부 시스템"으로만 부른다.
시스템 한눈에 보기 — 모듈 구조와 데이터 흐름
먼저 전체 그림부터. 이 모듈은 자체 DB(MySQL 계열)를 "작업 공간"으로 쓰고, 회사의 기간계인 외부 회계 ERP(Oracle)는 "원장"으로 둔다. 주문 마스터·거래처 같은 마스터 데이터는 ERP에서 읽어오고, 이 모듈에서 만든 매출·정산은 다시 ERP로 흘려보낸다.

핵심 원칙은 "작업 DB와 원장을 분리한다"였다. 사용자가 매출을 등록하는 순간 ERP를 직접 건드리지 않는다. 일단 우리 DB에 안전하게 저장하고, ERP 전송은 별도 단계(배치·전자결재)로 떼어낸다. 외부 시스템이 느리거나 막혀도 업무 화면은 멀쩡해야 하기 때문이다. 이 분리 덕에 개발·로컬 환경에서는 ERP 연결 자체를 끈 채(oracle.enabled=false) 모든 업무 화면을 그대로 돌릴 수 있었다 — 이건 뒤에서 자주 등장하는 Optional<...>/ObjectProvider<...> 의존성 주입의 배경이기도 하다.
도메인 패키지는 업무 단위로 갈라 놓았다. order(주문), sales(매출), purchase(매입·정산), info(거래처·사업주·목표), integration(외부 연동), common(마감·파일·예외). 이 글은 그중 매출과 정산, 즉 "돈이 오가는 전표"를 집중적으로 다룬다.
매출 도메인 — 하나의 테이블, 여섯 가지 성격
매출은 sales_mst(마스터) + sales_dtl(품목 라인) 한 쌍으로 저장한다. 그런데 같은 "매출"이라도 성격이 제각각이다. 카드 매출, 계좌이체 매출, 전자세금계산서 매출, 선매출(미리 받은 돈), 선매출 차감분, 그리고 매출 취소(역분개). 이걸 테이블을 여러 개로 쪼개지 않고 sales_type + pay_type + status_cd 세 컬럼의 조합으로 표현했다.
처음엔 타입마다 테이블을 나눌까 고민했지만, 매출 목록·통계·세금계산서 발행 큐가 전부 "매출 전체"를 가로질러 조회하기 때문에 한 테이블이 압도적으로 단순했다. 대신 생성 시점에 상태 초기값을 정확히 박는 것이 중요해졌다. 매출 생성 로직의 분기가 그 핵심이다.

이 분기에는 업무 규칙이 압축돼 있다. 카드·계좌이체는 이미 돈이 들어왔으니 곧장 확정(CONFIRMED), 세금계산서로 받을 선매출은 입금 전이니 결제대기(PAYMENT_WAITING)다. 결제대기 건은 나중에 입금이 확인되면 confirmPayment()로 확정 전이하면서 그제야 매출일자를 당일로 박는다. "돈이 실제로 들어온 시점"과 "전표를 만든 시점"을 분리한 것이다.
여기서 세액 처리 규칙도 한 번 짚고 가야 한다. 사용자는 보통 공급가액을 입력한다고 가정하고, 세액(10%)과 합계는 서버가 계산한다. 이 "공급가 입력 → 세액·합계 역산" 규칙이 매출 등록·수정·세금계산서 변환 곳곳에서 반복되는데, 한 군데서 어긋나면 장부 금액이 1원 단위로 틀어진다. 그래서 부가세 계산은 가능한 한 한 패턴(Math.round(supply * 0.1))으로 통일하려 애썼다.
매출 상태 전이를 그림으로 그리면 이렇다.

MST/DTL 원자성 — "절반만 저장"을 구조적으로 막기
회계에서 가장 무서운 건 부분 저장이다. 매출 마스터(MST)는 저장됐는데 품목 라인(DTL)이 빠지면, 합계는 100만 원인데 라인 합은 0원인 유령 전표가 생긴다. 결산 때 "왜 안 맞지?" 하면서 이걸 추적하는 건 악몽이다.
이걸 막는 방법은 단순하다. 마스터와 디테일을 한 트랜잭션에서 같이 저장하거나 같이 실패하게 만드는 것. JPA의 cascade를 써서 마스터 하나만 save()하면 디테일이 함께 영속화되도록 매핑하고, 매출 생성 메서드 전체를 @Transactional로 묶었다.

여기서 한 가지 함정이 있었다. 원래 SalesDtl.recalculate()는 수량 × 단가 + 10% 세액으로 라인 금액을 자동 계산하는 메서드였는데, 주문에서 매출을 만들 때는 이미 확정된 결제금액(paymentAmt)을 그대로 써야 했다. 자동 재계산을 그대로 두면 주문에서 합의된 금액에 부가세가 한 번 더 붙어 매출 상세가 부풀려졌다. 그래서 이 경로에서는 재계산을 호출하지 않고 단가·공급가·세액을 직접 분배한다. "도메인 메서드가 늘 옳은 건 아니다 — 호출 맥락에 맞게 골라 써야 한다"는 걸 배운 지점이다.
선매출 차감 — 금액을 쪼개 별도 전표로 남기다
업무에서 까다로웠던 게 선매출 차감이다. 거래처가 미리 돈을 넣어둔(선매출) 상태에서 실제 매출이 발생하면, 그 선매출 잔액에서 일부를 차감한다. 예를 들어 카드로 300만 원이 결제됐는데 선매출 100만 원을 차감하기로 했다면, 실제로 새로 인식할 매출은 200만 원이다.
처음엔 매출 한 건에 "차감액" 컬럼만 두면 될 줄 알았다. 그런데 회계적으로는 "카드 매출 200만 원"과 "선매출 차감 100만 원"이 별개의 사건이다. 목록에서도 둘이 따로 보여야 하고, 선매출 잔액도 정확히 줄어야 한다. 그래서 차감을 만나면 메인 매출의 금액을 차감분만큼 줄이고, 차감액은 별도의 매출 전표(PRE_SALES_DEDUCT)로 분리 생성하는 방식을 택했다.

차감을 라인 단위로 한도(Math.min(original, remaining))를 두고 분배하는 게 포인트다. 차감액이 첫 라인 금액보다 크면 남은 만큼 다음 라인으로 넘어간다. 이렇게 하면 라인별 금액이 음수가 되는 일이 없다. 그리고 선매출 잔액 조회는 항상 "원 선매출 금액 − 이미 차감된 합계"로 계산하므로(sumPreSalesDeductAmtByPreSalesNo), 차감 전표가 생기는 순간 잔액이 정확히 줄어든다. 잔액이 0 이하인 선매출은 차감 후보에서 자동으로 빠진다.
이 설계의 부수 효과로, 이미 차감에 쓰인 선매출은 수정·삭제를 막아야 했다. 사용한 선매출을 함부로 고치면 이미 발행된 매출과 정합성이 깨지기 때문이다. 그래서 수정·삭제 시점에 sumPreSalesDeductAmtByPreSalesNo > 0이면 차단한다 — 도메인 불변식을 코드로 박아둔 것이다.
취소는 삭제가 아니다 — 역분개로 이력을 보존하다
매출 삭제 로직은 이 모듈에서 가장 신중하게 짠 부분이다. 단순히 행을 지우면 안 되는 경우가 있기 때문이다. 세금계산서가 이미 발행됐거나 확정된 매출은, 지우는 게 아니라 "취소 전표"를 새로 만들어 상쇄해야 한다. 발행된 세금계산서는 국세청에 이미 신고된 기록이라, 원본을 없애면 추적이 끊긴다.

음수 금액의 취소 전표를 쌓는 이 방식은 회계의 역분개와 같은 발상이다. 원본은 그대로 두고, 정확히 반대 금액의 전표를 더해 합계를 0으로 만든다. 그러면 매출 합계는 상쇄돼 맞으면서도, "이 매출은 언제 누가 왜 취소했다"는 이력이 남는다. 미발행·미확정 건은 위험이 없으니 그냥 지우고, 발행·확정 건만 역분개한다 — 이 분기가 "지워도 되는 것"과 "지우면 안 되는 것"을 가른다.
매출이 생기거나 취소되면 그에 묶인 주문의 상태도 다시 맞춰야 한다. 주문 한 건에 부분 매출이 여러 번 발생할 수 있어서, 매번 "이 주문의 순매출 합계"를 다시 계산해 상태를 재판정한다(recomputeOrderStatusAfterSalesChange). 순매출이 0 이하(취소로 전액 상쇄)면 출고 상태로 되돌리고, 주문 금액보다 적으면 부분매출, 같으면 매출등록 완료로 전이한다. 상태를 "현재 데이터로부터 항상 다시 계산"하게 만든 덕에, 중간에 어떤 순서로 매출이 오가도 주문 상태가 어긋나지 않는다.
월마감 — 닫힌 달의 장부는 못 건드린다
회계에는 월마감이 있다. 한 달 장부를 닫고 나면 그 달의 매출은 더 이상 손대면 안 된다. 이걸 코드로 강제하기 위해 closing_period 테이블과 마감 가드를 뒀다. 매출 등록·세금계산서 발행은 진입점에서 마감 여부를 검사하고, 닫힌 달이면 예외로 막는다.

마감을 "즉시"와 "예약" 두 가지로 만든 건 실무 요청이었다. 결산 담당자가 "이번 달은 25일 자정에 닫겠다"고 예약하면, 그 전까지는 입력이 열려 있다가 시각이 되면 자동으로 잠긴다. 잠금 판정 자체(isLocked)는 예약 시각만으로도 즉시 효력을 갖되, 화면 라벨과 감사 기록을 위해 도래한 예약은 스케줄러가 closedAt을 채워 "마감완료"로 명확히 전이시킨다. 매출 등록 코드는 이 가드를 호출만 하면 된다 — 마감 정책이 한 곳에 모여 있으니, 새 화면을 붙일 때도 같은 한 줄로 끝난다.
매출 전표를 ERP로 — 즉시 전송에서 야간 배치로
매출이 확정되면 외부 회계 ERP의 매출 장부(빌링)에 전표를 꽂아야 한다. 처음엔 매출 등록 시점에 즉시 전송했는데, 곧 야간 배치로 바꿨다. 이유는 세 가지였다.
- 외부 의존 분리: ERP가 느리거나 막히면 매출 등록 화면이 같이 느려진다. 사용자가 "저장" 버튼을 눌렀는데 외부 시스템 응답을 기다리느라 멈추는 건 최악이다.
- 부하 분산: 업무 시간에 전표마다 외부 호출을 하면 ERP에 부하가 몰린다. 새벽에 한 번에 보내면 된다.
- 정정 여지: 낮에 입력·수정한 전표를 밤에 한 번 정리해 보내면, 중간 수정이 반영된 최종본만 전송된다.
그래서 매출 등록 시점엔 ERP로 보내지 않고 전송 대기(PENDING)로 큐잉만 하고, 새벽 1시 배치가 전날 생성분을 모아 보낸다. 핵심은 한 건의 전송 실패가 다른 건을 막지 않게 하는 것 — 건별 독립 처리다.

Propagation.NOT_SUPPORTED로 배치 전체를 하나의 큰 트랜잭션에 묶지 않은 게 중요하다. 만약 전부를 한 트랜잭션으로 묶으면, 100번째 전표에서 실패할 때 앞의 99건까지 롤백돼 버린다. 대신 건별로 저장하고 상태(SUCCESS/FAILED/SKIPPED)를 각각 남겨서, 실패한 전표만 다음 배치에서 재시도하게 했다. 이 전송 상태와 실패 사유를 컬럼으로 남긴 "관측성" 설계는 2편에서 자세히 다룬다.
매입(외주·구매 정산)은 매출과 달리 자동 전송하지 않는다. 매입은 전자결재 승인을 거쳐야 하고, 승인이 나야 ERP에 매입 전표가 꽂힌다. 이 비대칭(매출=자동 배치 / 매입=전자결재 후 생성)도 업무 성격을 그대로 코드에 반영한 결과다 — 매입은 "나가는 돈"이라 사람의 결재를 한 단계 더 거치는 게 맞다.
업무 화면은 화려함보다 흐름 — 2패널에서 리스트로 되돌리기
마지막은 코드보다 태도에 관한 이야기다. 거래처·사업주·영업담당자를 다루는 정보관리 화면을 처음엔 좌우 2패널(목록+상세) 구조로 만들었다. 보기엔 그럴듯했다. 그런데 실무자가 써보니 불편했다. 이들은 "하나 골라 천천히 본다"가 아니라 "여러 건을 빠르게 훑고 입력한다"는 흐름이었다. 그래서 리스트 중심 구조로 되돌렸다.
업무 시스템에서 화면 구조는 디자인 취향이 아니라 사용자의 동작 빈도가 정한다. 하루 수십 번 같은 화면을 쓰는 사람에게는, 예쁜 레이아웃보다 클릭 한 번을 줄이는 흐름이 훨씬 가치 있다. 보기 좋게 만든 걸 다시 단순하게 되돌리는 건 자존심 상하는 일이지만, "쓰는 사람이 빠른 쪽"이 늘 옳았다.
정리하며
- 작업 DB와 원장(외부 ERP)을 분리해, 외부 시스템 장애가 업무 화면을 멈추지 않게 했다. ERP 연결을 끈 채로도 전 기능이 돈다.
- 매출은 한 테이블에
sales_type/pay_type/status_cd조합으로 표현하되, 생성 시점에 상태 초기값을 정확히 박았다. 카드는 즉시 확정, 선매출+세금계산서는 결제대기. - MST/DTL을 한 트랜잭션·cascade로 원자화해 부분 저장을 구조적으로 막았다. 도메인 메서드(
recalculate)도 호출 맥락에 맞게 골라 썼다. - 선매출 차감은 별도 전표로 분리하고, 라인 단위 한도 분배로 음수 금액을 막았다. 사용된 선매출은 수정·삭제를 차단한다.
- 취소는 삭제가 아니라 음수 전표(역분개)로 — 발행·확정된 매출의 이력을 보존하면서 합계를 상쇄한다.
- 월마감 가드로 닫힌 달의 장부를 잠그고, 매출 전표는 즉시 전송 → 야간 배치로 옮겨 외부 의존·부하·정정 여지를 한 번에 해결했다.
다음 2편에서는 이 시스템의 가장 까다로운 부분, 외부 전자결재 솔루션과 회계 ERP 연동의 신뢰성(인바운드 웹훅, 아웃바운드 데이터 매핑, 관측성·폴백·트랜잭션)을 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 공간 대여 매칭 플랫폼 개발기 (1) — PostGIS 위치 검색과 모바일 MVP (0) | 2026.07.04 |
|---|---|
| 쇼핑몰 고객 상담 챗봇 개발기 (1) — 규칙 기반 대화 엔진과 CI 구축 (0) | 2026.07.04 |
| 취향 큐레이션 커머스 개발기 (2) — 49개 피처 테스트와 사흘간의 버그 사냥 (0) | 2026.07.04 |
| 취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기 (0) | 2026.06.23 |
| 전화 금융사기 탐지 앱 개발기 (1) — 온디바이스 Whisper·LLM 파이프라인 (0) | 2026.06.23 |