
이번에 겪은 문제들은 단순 화면 버그가 아니라, ERP에서 가장 중요한 데이터 정합성과 집계 기준 문제들이 한 번에 섞여 있었던 케이스였다. 특히 출고 처리일시, 재고 원장(stock_tx), inventory의 마지막 출고일, 안전재고 부족 집계, 대시보드 요약 기준이 서로 다르면 화면이 얼핏 맞아 보여도 실제 분석 결과는 틀어질 수 있다.
아래는 이번 작업에서 실제로 수정한 내용을 기준으로, 각 이슈를 원인 코드와 해결 코드 중심으로 정리한 트러블슈팅 기록이다.
가장 먼저 확인된 문제는 출고 문서의 날짜와 재고 원장 기록의 날짜 기준이 어긋나 있었다는 점이다. 장기재고, 마지막 출고일, 사용량 집계는 결국 stock_tx.tx_date 를 많이 참조하게 되는데, 이 값이 실제 출고 기준일과 다르면 모든 분석이 틀어진다.
outbound_date 는 과거 날짜처럼 보이는데tx_date 는 현재 시각 기준으로 찍혀 있었음문제의 핵심은 출고 서비스에서 처리 시각을 만들 때, 사용자가 입력한 출고일에 현재 시간을 덧붙이고 있었다는 점이다.
LocalDate businessDate = parseDateOrToday(req.outboundDate());
LocalDateTime now = LocalDateTime.now();
LocalTime nowTime = now.toLocalTime();
LocalDateTime processedAt = businessDate.atTime(nowTime);
이 로직은 예를 들어 사용자가 2025-10-30 을 출고일로 선택해도 실제 저장값은 다음처럼 들어간다.
2025-10-30 20:28:43
즉 날짜는 과거인데 시간은 오늘 시각이라, 사람이 보기에도 이상하고 분석 기준으로도 혼란이 생긴다.
출고일을 날짜 기준 문서로 관리하기로 했기 때문에, 시간을 임의로 섞지 않고 시작 시각으로 고정했다.
LocalDate businessDate = parseDateOrToday(req.outboundDate());
LocalDateTime processedAt = businessDate.atStartOfDay();
이렇게 하면 출고 문서, 재고 원장, inventory 마지막 출고일이 모두 같은 기준으로 저장될 수 있다.
StockTx tx = StockTx.builder()
.item(item)
.warehouse(warehouse)
.txType(TxType.OUTBOUND)
.txDate(processedAt)
.qtyDelta(-line.qty())
.balanceAfter(inventory.getCurrentQty())
.createdBy(req.createdBy())
.refType("OUTBOUND")
.refId(outbound.getOutboundId())
.memo(memo)
.build();
서비스 코드를 고친 뒤에도, 이미 이전에 잘못 들어간 데이터는 자동으로 바뀌지 않는다. 그래서 개발 DB 기준으로 과거 데이터를 보정해야 했다.
SELECT
tx.tx_id,
tx.ref_id,
tx.tx_type,
tx.tx_date,
o.outbound_id,
o.outbound_date
FROM stock_tx tx
LEFT JOIN outbound o
ON tx.ref_id = o.outbound_id
WHERE tx.tx_type = 'OUTBOUND'
ORDER BY tx.tx_id DESC
LIMIT 30;
stock_tx 의 날짜를 outbound 문서 날짜에 맞추는 방식으로 보정했다.
UPDATE stock_tx tx
JOIN outbound o
ON tx.ref_id = o.outbound_id
SET tx.tx_date = o.outbound_date
WHERE tx.tx_type = 'OUTBOUND';
이 작업 이후, 적어도 출고 문서와 재고 원장 기준일은 동일하게 정렬되었다.
stock_tx 와 outbound 를 맞췄다고 끝이 아니었다. inventory는 스냅샷 테이블이기 때문에, 마지막 출고일이 별도 컬럼으로 저장된다. 이 컬럼이 예전 잘못된 시각을 그대로 가지고 있으면 장기재고 조회는 여전히 틀어진다.
stock_tx.tx_date 와 outbound.outbound_date 는 정상inventory.last_outbound_at 은 현재 시각 기준으로 남아 있음기존 서비스 로직에서 inventory 역시 같은 잘못된 processedAt 기준으로 저장된 것이 원인이었다.
inventory.applyOutbound(line.qty(), processedAt);
inventory의 마지막 출고일을 stock_tx 기준 마지막 출고시각으로 다시 맞췄다.
UPDATE inventory i
JOIN (
SELECT
item_id,
warehouse_id,
MAX(tx_date) AS last_outbound_at
FROM stock_tx
WHERE tx_type = 'OUTBOUND'
GROUP BY item_id, warehouse_id
) x
ON x.item_id = i.item_id
AND x.warehouse_id = i.warehouse_id
SET i.last_outbound_at = x.last_outbound_at;
이제 출고 관련 날짜는 다음 세 컬럼이 서로 일치하는 구조가 되었다.
outbound.outbound_date
stock_tx.tx_date
inventory.last_outbound_at
코드 수정 후에도 장기재고가 바로 뜨지 않아서 로직 문제가 아닌가 의심했지만, 실제 원인은 테스트 데이터의 마지막 출고일 자체가 너무 최근이었다는 점이었다.
SELECT
item_id,
warehouse_id,
MAX(tx_date) AS last_outbound_tx
FROM stock_tx
WHERE tx_type = 'OUTBOUND'
GROUP BY item_id, warehouse_id
ORDER BY last_outbound_tx DESC;
이 조회 결과 대부분이 2026-02월대처럼 최근이었다면, 2026-03-08 기준 장기재고로 잡히지 않는 것이 정상이다.
개발 DB에서 테스트용으로 10개 조합의 마지막 출고일을 과거 날짜로 보정했다. 중요한 점은 단순히 한 테이블만 바꾸는 것이 아니라 관련 컬럼을 함께 맞춰야 한다는 점이었다.
DROP TEMPORARY TABLE IF EXISTS tmp_longterm_targets;
CREATE TEMPORARY TABLE tmp_longterm_targets (
item_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
target_date DATETIME NOT NULL,
PRIMARY KEY (item_id, warehouse_id)
);
INSERT INTO tmp_longterm_targets (item_id, warehouse_id, target_date)
SELECT t.item_id,
t.warehouse_id,
'2025-08-01 00:00:00' AS target_date
FROM (
SELECT item_id, warehouse_id, MAX(tx_date) AS last_outbound_tx
FROM stock_tx
WHERE tx_type = 'OUTBOUND'
GROUP BY item_id, warehouse_id
ORDER BY last_outbound_tx DESC
LIMIT 10
) t;
UPDATE stock_tx tx
JOIN tmp_longterm_targets tt
ON tt.item_id = tx.item_id
AND tt.warehouse_id = tx.warehouse_id
SET tx.tx_date = tt.target_date
WHERE tx.tx_type = 'OUTBOUND';
UPDATE outbound o
JOIN stock_tx tx
ON tx.ref_id = o.outbound_id
JOIN tmp_longterm_targets tt
ON tt.item_id = tx.item_id
AND tt.warehouse_id = tx.warehouse_id
SET o.outbound_date = tx.tx_date
WHERE tx.tx_type = 'OUTBOUND';
UPDATE inventory i
JOIN (
SELECT tx.item_id, tx.warehouse_id, MAX(tx.tx_date) AS last_outbound_at
FROM stock_tx tx
WHERE tx.tx_type = 'OUTBOUND'
GROUP BY tx.item_id, tx.warehouse_id
) x
ON x.item_id = i.item_id
AND x.warehouse_id = i.warehouse_id
JOIN tmp_longterm_targets tt
ON tt.item_id = i.item_id
AND tt.warehouse_id = i.warehouse_id
SET i.last_outbound_at = x.last_outbound_at;
안전재고 페이지와 대시보드를 보다가 이상했던 점은, 어떤 품목은 부족수량이 0인데 상태가 위험으로 뜨거나, 안전재고가 0인데도 위험으로 분류되는 경우가 있었다는 점이다.
상태를 계산하는 메서드가 재고가 0이면 안전재고 기준과 상관없이 무조건 DANGER 로 처리하고 있었다.
private String calcSafetyStatus(int availableQty, int safetyQty, int monthlyUsage) {
if (availableQty <= 0) return "DANGER";
if (safetyQty > 0 && availableQty < safetyQty) return "WARNING";
if (monthlyUsage > 0 && availableQty > monthlyUsage) return "OVER";
return "OK";
}
이 로직이면 safetyQty = 0, availableQty = 0 인 경우도 DANGER가 된다.
private String calcSafetyStatus(int availableQty, int safetyQty, int monthlyUsage) {
if (safetyQty > 0 && availableQty <= 0) return "DANGER";
if (safetyQty > 0 && availableQty < safetyQty) return "WARNING";
if (monthlyUsage > 0 && availableQty > monthlyUsage) return "OVER";
return "OK";
}
이제 안전재고 기준이 0인 품목은 재고가 없어도 무조건 위험으로 보지 않고, 실제로 기준 재고가 존재하는 품목만 위험/주의 판정에 들어가게 되었다.
처음에는 백엔드 계산이 반대로 된 줄 알았지만, 실제로는 백엔드가 이미 양수 부족수량을 잘 내려주고 있었고 프론트에서 다시 - 를 붙여서 음수처럼 보이던 문제였다.
<td className="right">-{item.shortageQty}</td>
백엔드에서 이미 shortageQty = 7 을 보내는데, 프론트에서 문자열로 마이너스를 덧붙이고 있었다.
<td className="right">{item.shortageQty}</td>
이 수정 이후 대시보드와 안전재고 페이지의 부족수량 방향이 일치하게 되었다.
이 문제는 단순 렌더링 문제가 아니라 집계 기준이 서로 달랐기 때문에 발생했다. 같은 데이터를 본다고 생각했지만, 실제로는 상세 기준과 요약 기준을 서로 다른 화면이 사용하고 있었다.
대시보드가 처음에는 품목 합산 기준 메서드를 사용하고 있었다.
List<InventorySafetyRowResponse> safetyRows = inventoryQueryService.getSafetySummaryRows(
null,
null,
"itemName",
"asc"
);
반면 안전재고 페이지는 창고 + 품목 상세 기준인 getSafetyRows() 를 사용하고 있었다. 즉 같은 기준이 아니었다.
대시보드 운영 요약과 긴급 발주 품목도 안전재고 페이지와 동일한 상세 기준으로 맞췄다.
private DashboardSummaryResponse getSummaryFromSafety() {
List<InventorySafetyRowResponse> safetyRows = inventoryQueryService.getSafetyRows(
null,
null,
null,
"itemName",
"asc"
);
long dangerCount = safetyRows.stream()
.filter(row -> "DANGER".equalsIgnoreCase(row.stockStatus()))
.count();
long warningCount = safetyRows.stream()
.filter(row -> "WARNING".equalsIgnoreCase(row.stockStatus()))
.count();
long riskCount = dangerCount + warningCount;
Long openPurchaseOrderCount = dashboardQueryRepository.getOpenPurchaseOrderCount();
Long todayInboundCount = dashboardQueryRepository.getTodayInboundCount();
Long totalInventoryValue = dashboardQueryRepository.getTotalInventoryValue();
return new DashboardSummaryResponse(
dangerCount,
warningCount,
riskCount,
openPurchaseOrderCount,
todayInboundCount,
totalInventoryValue
);
}
private List<DashboardUrgentItemResponse> getUrgentItemsFromSafety() {
List<InventorySafetyRowResponse> safetyRows = inventoryQueryService.getSafetyRows(
null,
null,
"DANGER",
"shortageQty",
"desc"
);
return safetyRows.stream()
.filter(row -> "DANGER".equalsIgnoreCase(row.stockStatus()))
.sorted(Comparator.comparingInt(InventorySafetyRowResponse::shortageQty).reversed())
.limit(5)
.map(row -> new DashboardUrgentItemResponse(
row.itemCode(),
row.itemName(),
row.currentQty(),
row.safetyQty(),
row.shortageQty()
))
.toList();
}
이 수정으로 운영 요약과 안전재고 상세 화면의 숫자 기준이 통일되었다.
이 부분은 핵심 장애는 아니었지만, 날짜 정합성을 맞춰가는 과정에서 확인했던 포인트다. 최근 출고/입고 목록은 사람 입장에서 문서 생성 시각보다 실제 입출고 기준일을 보는 것이 더 자연스럽다.
SELECT o.outbound_no,
it.item_name,
w.warehouse_name,
ol.qty,
o.created_at,
'완료'
FROM outbound o
이렇게 되면 실제 출고일이 아니라 문서 생성 시각을 보여주게 된다.
SELECT o.outbound_no,
it.item_name,
w.warehouse_name,
ol.qty,
o.outbound_date,
'완료'
FROM outbound o
입고 역시 같은 방식으로 inbound_date 기준으로 통일하면 더 자연스럽다.
이번 작업은 단순히 코드를 몇 줄 고치는 문제가 아니라, 프론트와 백엔드, 그리고 DB가 서로 어떤 책임을 가져야 하는지 다시 확인하는 과정이었다.
부족수량처럼 이미 백엔드에서 의미를 계산해서 내려준 값은 프론트가 다시 부호를 붙이거나 재계산하면 안 된다.
<td className="right">{item.shortageQty}</td>
이처럼 받은 값을 그대로 표시하는 것이 가장 안전하다.
같은 데이터를 보여주는 화면이라면 같은 서비스 기준을 써야 한다. 요약 화면과 상세 화면이 서로 다른 집계 기준을 쓰면 사용자 입장에서는 둘 중 하나가 틀렸다고 느끼게 된다.
ERP에서는 아래 세 가지가 서로 맞아야 한다.
이 셋이 다르면 장기재고, 사용량, 회전율, 안전재고, 대시보드 요약이 모두 틀어질 수 있다.
이번 트러블슈팅은 한 번에 보면 복잡해 보이지만, 결국 흐름은 명확했다. 먼저 저장 시점의 날짜 기준을 고치고, 이미 틀어진 개발 데이터를 보정하고, 마지막으로 대시보드와 상세 페이지의 집계 기준을 통일하고, 프론트 표시 버그를 제거하는 순서였다.
정리하면 이번 수정의 핵심은 다음과 같다.
businessDate.atStartOfDay() 로 통일stock_tx.tx_date, outbound.outbound_date, inventory.last_outbound_at 정합성 복구safetyQty > 0 조건을 명확히 반영