CoreERP 재고/대시보드 트러블슈팅 기록

최병현·2026년 3월 8일

coreerp project

목록 보기
30/44

이번에 겪은 문제들은 단순 화면 버그가 아니라, ERP에서 가장 중요한 데이터 정합성과 집계 기준 문제들이 한 번에 섞여 있었던 케이스였다. 특히 출고 처리일시, 재고 원장(stock_tx), inventory의 마지막 출고일, 안전재고 부족 집계, 대시보드 요약 기준이 서로 다르면 화면이 얼핏 맞아 보여도 실제 분석 결과는 틀어질 수 있다.

아래는 이번 작업에서 실제로 수정한 내용을 기준으로, 각 이슈를 원인 코드와 해결 코드 중심으로 정리한 트러블슈팅 기록이다.


1. 출고 문서 날짜와 stock_tx 날짜가 서로 다르게 기록되던 문제

가장 먼저 확인된 문제는 출고 문서의 날짜와 재고 원장 기록의 날짜 기준이 어긋나 있었다는 점이다. 장기재고, 마지막 출고일, 사용량 집계는 결국 stock_tx.tx_date 를 많이 참조하게 되는데, 이 값이 실제 출고 기준일과 다르면 모든 분석이 틀어진다.

문제 증상

  • outbound 테이블의 outbound_date 는 과거 날짜처럼 보이는데
  • stock_tx 의 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();

2. 과거 잘못 저장된 stock_tx 날짜를 수동 보정해야 했던 문제

서비스 코드를 고친 뒤에도, 이미 이전에 잘못 들어간 데이터는 자동으로 바뀌지 않는다. 그래서 개발 DB 기준으로 과거 데이터를 보정해야 했다.

문제 증상

  • DB에서 outbound 날짜와 stock_tx 날짜가 안 맞았음
  • 장기재고 테스트가 정상 동작하지 않았음

확인 SQL

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;

해결 SQL

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';

이 작업 이후, 적어도 출고 문서와 재고 원장 기준일은 동일하게 정렬되었다.


3. inventory.last_outbound_at 가 여전히 잘못 남아 있던 문제

stock_tx 와 outbound 를 맞췄다고 끝이 아니었다. inventory는 스냅샷 테이블이기 때문에, 마지막 출고일이 별도 컬럼으로 저장된다. 이 컬럼이 예전 잘못된 시각을 그대로 가지고 있으면 장기재고 조회는 여전히 틀어진다.

문제 증상

  • stock_tx.tx_date 와 outbound.outbound_date 는 정상
  • 하지만 inventory.last_outbound_at 은 현재 시각 기준으로 남아 있음
  • 장기재고 조건을 만족해야 하는 품목이 최근 출고된 것처럼 보였음

원인 코드

기존 서비스 로직에서 inventory 역시 같은 잘못된 processedAt 기준으로 저장된 것이 원인이었다.

inventory.applyOutbound(line.qty(), processedAt);

해결 SQL

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

4. 장기재고 테스트가 안 되던 문제

코드 수정 후에도 장기재고가 바로 뜨지 않아서 로직 문제가 아닌가 의심했지만, 실제 원인은 테스트 데이터의 마지막 출고일 자체가 너무 최근이었다는 점이었다.

문제 증상

  • 오늘 날짜 기준 6개월 전보다 훨씬 최근 출고 이력이 존재
  • 그래서 장기재고 조건을 만족하지 못함

확인 SQL

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개 조합의 마지막 출고일을 과거 날짜로 보정했다. 중요한 점은 단순히 한 테이블만 바꾸는 것이 아니라 관련 컬럼을 함께 맞춰야 한다는 점이었다.

테스트용 보정 SQL

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;

5. 안전재고 상태와 부족수량이 서로 맞지 않던 문제

안전재고 페이지와 대시보드를 보다가 이상했던 점은, 어떤 품목은 부족수량이 0인데 상태가 위험으로 뜨거나, 안전재고가 0인데도 위험으로 분류되는 경우가 있었다는 점이다.

문제 증상

  • 안전재고가 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인 품목은 재고가 없어도 무조건 위험으로 보지 않고, 실제로 기준 재고가 존재하는 품목만 위험/주의 판정에 들어가게 되었다.


6. 대시보드 부족수량이 -7처럼 음수로 보이던 문제

처음에는 백엔드 계산이 반대로 된 줄 알았지만, 실제로는 백엔드가 이미 양수 부족수량을 잘 내려주고 있었고 프론트에서 다시 - 를 붙여서 음수처럼 보이던 문제였다.

문제 증상

  • 안전재고 페이지에서는 부족수량 7
  • 대시보드에서는 부족수량 -7

원인 코드

<td className="right">-{item.shortageQty}</td>

백엔드에서 이미 shortageQty = 7 을 보내는데, 프론트에서 문자열로 마이너스를 덧붙이고 있었다.

해결 코드

<td className="right">{item.shortageQty}</td>

이 수정 이후 대시보드와 안전재고 페이지의 부족수량 방향이 일치하게 되었다.


7. 대시보드 운영 요약 숫자와 안전재고 페이지 숫자가 다르게 나오던 문제

이 문제는 단순 렌더링 문제가 아니라 집계 기준이 서로 달랐기 때문에 발생했다. 같은 데이터를 본다고 생각했지만, 실제로는 상세 기준과 요약 기준을 서로 다른 화면이 사용하고 있었다.

문제 증상

  • 안전재고 페이지 실제 데이터: 위험 3건, 주의 39건
  • 대시보드 운영 요약: 위험 1건, 주의 19건처럼 다르게 보임

원인 코드

대시보드가 처음에는 품목 합산 기준 메서드를 사용하고 있었다.

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();
}

이 수정으로 운영 요약과 안전재고 상세 화면의 숫자 기준이 통일되었다.


8. 대시보드 최근 출고/입고의 기준일이 createdAt 이던 문제

이 부분은 핵심 장애는 아니었지만, 날짜 정합성을 맞춰가는 과정에서 확인했던 포인트다. 최근 출고/입고 목록은 사람 입장에서 문서 생성 시각보다 실제 입출고 기준일을 보는 것이 더 자연스럽다.

원인 코드

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 기준으로 통일하면 더 자연스럽다.


9. 이번 트러블슈팅에서 핵심적으로 배운 점

이번 작업은 단순히 코드를 몇 줄 고치는 문제가 아니라, 프론트와 백엔드, 그리고 DB가 서로 어떤 책임을 가져야 하는지 다시 확인하는 과정이었다.

1. Frontend는 표시만 해야 한다

부족수량처럼 이미 백엔드에서 의미를 계산해서 내려준 값은 프론트가 다시 부호를 붙이거나 재계산하면 안 된다.

<td className="right">{item.shortageQty}</td>

이처럼 받은 값을 그대로 표시하는 것이 가장 안전하다.

2. Backend는 집계 기준을 통일해야 한다

같은 데이터를 보여주는 화면이라면 같은 서비스 기준을 써야 한다. 요약 화면과 상세 화면이 서로 다른 집계 기준을 쓰면 사용자 입장에서는 둘 중 하나가 틀렸다고 느끼게 된다.

3. Database 날짜 정합성이 가장 중요하다

ERP에서는 아래 세 가지가 서로 맞아야 한다.

  • 문서 날짜
  • 재고 원장 날짜
  • inventory snapshot 날짜

이 셋이 다르면 장기재고, 사용량, 회전율, 안전재고, 대시보드 요약이 모두 틀어질 수 있다.


마무리

이번 트러블슈팅은 한 번에 보면 복잡해 보이지만, 결국 흐름은 명확했다. 먼저 저장 시점의 날짜 기준을 고치고, 이미 틀어진 개발 데이터를 보정하고, 마지막으로 대시보드와 상세 페이지의 집계 기준을 통일하고, 프론트 표시 버그를 제거하는 순서였다.

정리하면 이번 수정의 핵심은 다음과 같다.

  • 출고 처리 시각을 businessDate.atStartOfDay() 로 통일
  • stock_tx.tx_date, outbound.outbound_date, inventory.last_outbound_at 정합성 복구
  • 장기재고 테스트용 데이터는 관련 테이블을 함께 보정
  • 안전재고 상태 계산에서 safetyQty > 0 조건을 명확히 반영
  • 대시보드 부족수량 마이너스 표시 제거
  • 대시보드 운영 요약과 안전재고 상세의 집계 기준 통일
profile
Develop

0개의 댓글