CoreERP Dashboard 기능 구현 기록

최병현·2026년 3월 8일

coreerp project

목록 보기
29/44

이번 단계에서는 CoreERP 프로젝트에 Dashboard 기능을 추가했다. 기존에는 품목, 창고, 재고, 발주, 입고, 출고 기능이 각각 독립적으로 동작하는 구조였다면, 이번 작업부터는 이 여러 데이터를 한 화면에 모아 현재 시스템 상태를 한 눈에 확인할 수 있도록 만드는 것이 핵심 목적이었다.

특히 ERP에서 Dashboard는 단순한 메인 화면이 아니라, 운영자가 지금 어떤 품목이 부족한지, 어떤 창고에 재고 가치가 몰려 있는지, 최근 입고와 출고 흐름이 어떻게 움직이는지를 빠르게 확인할 수 있게 해주는 조회 중심 기능이다. 그래서 이번 구현은 CRUD를 하나 더 만드는 작업이 아니라, 여러 도메인의 데이터를 조합해서 가공하는 조회 전용 기능을 설계하고 연결하는 과정에 가까웠다.


1. 이번 단계의 핵심 목적

  • Dashboard 전용 Backend API 설계
  • 여러 도메인 데이터를 조합하는 조회 전용 구조 분리
  • 월별 입고 / 출고 흐름 차트 구성
  • 창고별 재고 가치 분포 계산
  • 최근 입고 / 출고 데이터 표시
  • 안전재고 부족 건수 및 긴급 발주 품목 TOP 5 표시
  • Frontend와 Backend를 실제 API로 연동

이번 작업을 통해 CoreERP는 단순한 관리 화면 집합이 아니라, 현재 상태를 분석하고 보여줄 수 있는 운영형 시스템으로 한 단계 올라가게 되었다.


2. Dashboard를 별도 도메인으로 분리한 이유

처음에는 기존 stock, inbound, outbound, purchase-order API를 조합해서 프론트에서 Dashboard를 만들 수도 있다고 생각했다. 하지만 실제로 화면이 요구하는 데이터는 단순 목록이 아니라 집계와 가공 결과였다.

예를 들어 Dashboard에서는 아래와 같은 정보가 필요했다.

  • 최근 6개월 입고 / 출고 수량
  • 창고별 재고 가치 합계
  • 안전재고 부족 품목 수
  • 최근 입고 / 출고 이력
  • 긴급 발주 필요 품목 TOP 5

이런 데이터는 특정 Entity 하나만 조회해서 만들 수 있는 정보가 아니라, 여러 도메인을 조합한 조회 결과에 가깝다. 그래서 Dashboard는 일반 CRUD 도메인과 다르게 조회 전용 Query Domain으로 분리하는 것이 맞다고 판단했다.

구조는 아래처럼 구성했다.

dashboard
 ├─ controller
 ├─ dto
 ├─ repository
 └─ service

이 구조의 장점은 다음과 같다.

  • Dashboard 전용 책임이 명확해진다.
  • 기존 item, stock, inbound, outbound 도메인을 오염시키지 않는다.
  • 프론트에서 필요한 응답 형태를 직접 만들 수 있다.
  • 집계 로직과 CRUD 로직이 섞이지 않는다.

3. Backend Dashboard API 설계

Dashboard는 여러 데이터를 한 번에 반환하는 구조로 만들었다. 프론트에서 API를 여러 번 호출하게 되면 네트워크 비용도 늘어나고, 데이터 기준 시점도 조금씩 달라질 수 있기 때문에 Dashboard 전용 단일 응답 구조를 사용하는 것이 더 적절했다.

Controller는 아래처럼 매우 단순하게 구성했다.

@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {

    private final DashboardService dashboardService;

    @GetMapping
    public DashboardResponse getDashboard() {
        return dashboardService.getDashboard();
    }
}

응답 DTO는 아래 구조를 사용했다.

public record DashboardResponse(
        DashboardSummaryResponse summary,
        List<DashboardFlowResponse> flow,
        List<DashboardWarehouseDistributionResponse> warehouseDistribution,
        List<DashboardRecentInboundResponse> recentInbounds,
        List<DashboardUrgentItemResponse> urgentItems,
        List<DashboardRecentOutboundResponse> recentOutbounds
) {
}

즉 하나의 API 응답 안에 운영 요약, 월별 흐름, 창고 분포, 최근 입고, 긴급 발주 품목, 최근 출고까지 모두 담도록 설계했다.


4. 주요 응답 DTO 구성

Dashboard는 화면 구성이 명확했기 때문에 DTO도 화면 단위로 나누어 설계했다.

4-1. 운영 요약

public record DashboardSummaryResponse(
        Long dangerCount,
        Long openPurchaseOrderCount,
        Long todayInboundCount,
        Long totalInventoryValue
) {
}

여기서 표시하는 값은 다음과 같다.

  • 안전재고 부족 건수
  • 미입고 발주 건수
  • 오늘 입고 예정 건수
  • 총 자재 가치

4-2. 월별 입출고 흐름

public record DashboardFlowResponse(
        String month,
        Long inbound,
        Long outbound
) {
}

이 DTO는 Recharts AreaChart에 바로 연결하기 좋도록 month, inbound, outbound 구조로 단순하게 설계했다.

4-3. 창고별 재고 분포

public record DashboardWarehouseDistributionResponse(
        String warehouseName,
        Long amount,
        Integer ratio
) {
}

여기서는 창고명, 재고 가치 합계, 전체 대비 비율을 내려준다. 프론트에서는 이 데이터를 PieChart에 연결한다.

4-4. 긴급 발주 품목

public record DashboardUrgentItemResponse(
        String itemCode,
        String itemName,
        Integer currentStock,
        Integer safetyStock,
        Integer shortageQty
) {
}

이 DTO는 긴급 발주 필요 품목 TOP 5 테이블에 사용했다.


5. DashboardQueryRepository 구성

DashboardQueryRepository에서는 월별 흐름, 창고 분포, 최근 입고, 최근 출고, 운영 요약처럼 집계형 조회가 필요한 부분을 Native Query로 작성했다. 이유는 JPA Entity 조회보다 SQL 집계가 더 직관적이고, Dashboard 특성상 성능과 가독성을 동시에 챙기기 좋았기 때문이다.

5-1. 운영 요약 중 총 자재 가치

재고 가치는 inventory의 current_qty와 avg_unit_cost를 곱해서 계산했다. 이번 단계에서 avg_unit_cost를 inventory에 두도록 구조를 바꾸었기 때문에, 조회 시점에는 매우 단순한 계산으로 재고 가치를 구할 수 있게 되었다.

Long totalInventoryValue = getLong("""
    SELECT COALESCE(SUM(inv.current_qty * COALESCE(inv.avg_unit_cost, 0)), 0)
    FROM inventory inv
    """);

5-2. 월별 입고 흐름

List<Object[]> inboundRows = getRows("""
        SELECT DATE_FORMAT(i.inbound_date, '%Y-%m') AS ym,
               COALESCE(SUM(il.qty), 0) AS inbound_qty
        FROM inbound i
        JOIN inbound_line il ON i.inbound_id = il.inbound_id
        WHERE i.inbound_date >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 5 MONTH), '%Y-%m-01')
        GROUP BY DATE_FORMAT(i.inbound_date, '%Y-%m')
        ORDER BY ym ASC
        """);

5-3. 월별 출고 흐름

List<Object[]> outboundRows = getRows("""
        SELECT DATE_FORMAT(o.outbound_date, '%Y-%m') AS ym,
               COALESCE(SUM(ol.qty), 0) AS outbound_qty
        FROM outbound o
        JOIN outbound_line ol ON o.outbound_id = ol.outbound_id
        WHERE o.outbound_date >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 5 MONTH), '%Y-%m-01')
        GROUP BY DATE_FORMAT(o.outbound_date, '%Y-%m')
        ORDER BY ym ASC
        """);

5-4. 창고별 재고 분포

List<Object[]> rows = getRows("""
    SELECT w.warehouse_name,
           COALESCE(SUM(COALESCE(inv.current_qty, 0) * COALESCE(inv.avg_unit_cost, 0)), 0) AS amount
    FROM warehouse w
    LEFT JOIN inventory inv ON w.warehouse_id = inv.warehouse_id
    GROUP BY w.warehouse_id, w.warehouse_name
    ORDER BY amount DESC, w.warehouse_name ASC
    """);

여기서 중요한 점은 warehouse를 기준으로 LEFT JOIN을 썼다는 점이다. 처음에는 inventory 기준 JOIN으로 작성해서 재고가 없는 창고가 결과에서 빠지는 문제가 있었다. 이를 warehouse 기준 LEFT JOIN으로 수정하면서 전체 창고가 모두 표시되도록 만들었다.

5-5. 최근 입고 / 출고

최근 입고와 출고는 기본적으로 최근 날짜 기준 내림차순으로 정렬했다. 즉 최신 데이터가 항상 가장 먼저 오도록 구성했다.

ORDER BY i.inbound_date DESC, i.inbound_id DESC, il.inbound_line_id DESC
LIMIT 5

ORDER BY o.outbound_date DESC, o.outbound_id DESC, ol.outbound_line_id DESC
LIMIT 5

6. 재고 평가 구조 개선 - avg_unit_cost 도입

처음에는 재고 가치를 item.unit_cost 기준으로 계산하려고 했다. 하지만 실제 ERP 관점에서는 재고 가치를 품목 기본단가로만 보는 것은 한계가 있다. 입고 단가가 변할 수 있고, 실제 입고 가격이 반영되지 않으면 운영 데이터와 괴리가 생길 수 있기 때문이다.

그래서 inventory에 avg_unit_cost를 추가하고, 입고가 발생할 때 Moving Average 방식으로 평균단가를 갱신하도록 구조를 바꾸었다.

즉 inventory는 단순히 재고 수량만 갖는 테이블이 아니라, 현재 재고 수량과 현재 평가 단가를 함께 관리하는 스냅샷 역할을 하도록 설계했다.

6-1. inventory 컬럼 추가

ALTER TABLE inventory
ADD COLUMN avg_unit_cost DECIMAL(18,2) NOT NULL DEFAULT 0.00;

6-2. Inventory 엔티티에 avgUnitCost 추가

@Column(precision = 18, scale = 2, nullable = false)
private BigDecimal avgUnitCost;

6-3. 입고 시 Moving Average 반영

public void applyInbound(int qty, BigDecimal inboundUnitCost) {
    if (qty <= 0) {
        throw new IllegalArgumentException("입고 수량은 1 이상이어야 합니다.");
    }
    if (inboundUnitCost == null || inboundUnitCost.compareTo(BigDecimal.ZERO) < 0) {
        throw new IllegalArgumentException("입고 단가는 0 이상이어야 합니다.");
    }

    if (this.currentQty == null) this.currentQty = 0;
    if (this.avgUnitCost == null) this.avgUnitCost = BigDecimal.ZERO;

    BigDecimal currentQtyDecimal = BigDecimal.valueOf(this.currentQty);
    BigDecimal inboundQtyDecimal = BigDecimal.valueOf(qty);

    BigDecimal currentAmount = this.avgUnitCost.multiply(currentQtyDecimal);
    BigDecimal inboundAmount = inboundUnitCost.multiply(inboundQtyDecimal);

    int nextQty = this.currentQty + qty;
    BigDecimal nextQtyDecimal = BigDecimal.valueOf(nextQty);

    BigDecimal nextAvgUnitCost = nextQty == 0
            ? BigDecimal.ZERO
            : currentAmount.add(inboundAmount)
            .divide(nextQtyDecimal, 2, RoundingMode.HALF_UP);

    this.currentQty = nextQty;
    this.avgUnitCost = nextAvgUnitCost;
    this.lastInboundAt = LocalDateTime.now();
}

이 로직을 통해 입고가 누적될수록 평균단가가 자연스럽게 갱신되도록 만들었다.


7. InboundService 수정

avgUnitCost 구조를 도입하면서 입고 서비스도 함께 수정해야 했다. 기존에는 단순히 재고 수량만 증가시키는 구조였다면, 이제는 입고 시점의 단가를 같이 전달해 평균단가 계산까지 수행해야 했다.

기존 코드는 아래와 같았다.

inventory.applyInbound(line.qty());

하지만 Inventory.applyInbound의 시그니처가 변경되면서 컴파일 에러가 발생했다. 그래서 InboundService에서 PurchaseOrderLine의 unitPrice를 조회해 함께 전달하도록 수정했다.

BigDecimal inboundUnitPrice = resolveInboundUnitPrice(item, poLine);

inventory.applyInbound(line.qty(), inboundUnitPrice);
inventoryRepository.save(inventory);

단가를 가져오는 기준은 아래처럼 설계했다.

  • poLine이 존재하면 poLine.unitPrice 사용
  • poLine이 없으면 item.unitCost를 fallback으로 사용
  • 둘 다 없으면 BigDecimal.ZERO 처리
private BigDecimal resolveInboundUnitPrice(Item item, PurchaseOrderLine poLine) {
    if (poLine != null && poLine.getUnitPrice() != null) {
        return poLine.getUnitPrice();
    }

    if (item.getUnitCost() != null) {
        return item.getUnitCost();
    }

    return BigDecimal.ZERO;
}

이렇게 수정하면서 입고 처리와 재고 평가 로직이 연결되었다.


8. Frontend Dashboard 연동

프론트는 React + TypeScript로 작성했고, Dashboard API를 받아 화면에 바로 매핑하도록 구성했다.

중요한 점은 백엔드 응답 DTO와 프론트 UI 모델을 그대로 동일하게 쓰지 않고, 필요한 경우 프론트에서 추가 필드를 붙여서 변환했다는 점이다. 예를 들어 warehouse distribution은 차트 색상 fill이 필요하므로 프론트에서 색상 배열을 붙였다.

8-1. API 호출

useEffect(() => {
  const fetchDashboard = async () => {
    try {
      setLoading(true);
      setError("");

      const response = await fetch("http://localhost:8080/api/dashboard", {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data: DashboardResponse = await response.json();
      setDashboard(data);
    } catch (err) {
      console.error("Dashboard fetch error:", err);
      setError("대시보드 데이터를 불러오지 못했습니다.");
    } finally {
      setLoading(false);
    }
  };

  fetchDashboard();
}, []);

8-2. 창고 분포 UI 모델 변환

function buildWarehouseDistribution(
  rows: DashboardWarehouseDistributionResponse[]
): WarehouseDistributionRow[] {
  return rows.map((row, index) => ({
    warehouseName: row.warehouseName,
    ratio: row.ratio,
    amount: row.amount,
    fill: CHART_COLORS[index % CHART_COLORS.length],
  }));
}

8-3. 운영 요약 카드 변환

function buildSummaryItems(summary: DashboardSummaryResponse): SummaryRow[] {
  return [
    {
      label: "안전재고 부족",
      value: `${summary.dangerCount.toLocaleString("ko-KR")}건`,
      sub: "즉시 발주 또는 창고 이동 검토",
      valueClass: "danger",
    },
    {
      label: "미입고 발주",
      value: `${summary.openPurchaseOrderCount.toLocaleString("ko-KR")}건`,
      sub: "OPEN / PARTIAL_RECEIVED 상태 확인",
      valueClass: "warn",
    },
    {
      label: "오늘 입고 예정",
      value: `${summary.todayInboundCount.toLocaleString("ko-KR")}건`,
      sub: "검수 후 입고 등록 필요",
      valueClass: "info",
    },
    {
      label: "총 자재 가치",
      value: formatCurrency(summary.totalInventoryValue ?? 0),
      sub: "현재고 × 평균단가 기준 합계",
      valueClass: "neutral",
    },
  ];
}

9. 안전재고 기준을 하나로 통일한 과정

이번 단계에서 가장 중요했던 부분 중 하나는 안전재고 기준이 화면마다 다르게 계산되지 않도록 정리하는 것이었다.

처음에는 Dashboard에서 getUrgentItems()를 별도로 만들어 SQL만으로 계산하고 있었는데, 이렇게 하면 왼쪽 안전재고 관리 화면과 Dashboard의 긴급 발주 품목 값이 서로 다를 가능성이 생긴다.

그래서 결국 기준은 InventoryQueryService의 safetyPage 로직을 중심으로 통일했다. 즉 안전재고 계산은 stock 쪽 서비스가 책임지고, Dashboard는 그 결과를 재사용하는 방식으로 변경했다.

9-1. 긴급 발주 품목 TOP 5

긴급 발주 품목은 의미상 DANGER만 가져오는 것이 맞다고 판단했다.

@Transactional(readOnly = true)
public List<InventorySafetyRowResponse> findDangerTop5ForDashboard() {
    return buildAllSafetyRowsForDashboard().stream()
            .filter(row -> "DANGER".equalsIgnoreCase(row.stockStatus()))
            .sorted(Comparator.comparingInt(InventorySafetyRowResponse::shortageQty).reversed()
                    .thenComparing(InventorySafetyRowResponse::itemCode, String.CASE_INSENSITIVE_ORDER))
            .limit(5)
            .toList();
}

9-2. 운영 요약의 안전재고 부족 건수

운영 요약의 "안전재고 부족"은 DANGER만 쓰는 것이 아니라, 안전재고 미만 전체를 의미하는 것이 맞다고 판단했다. 따라서 DANGER + WARNING을 합쳐서 계산하도록 수정했다.

@Transactional(readOnly = true)
public long countSafetyShortageRowsForDashboard() {
    return buildAllSafetyRowsForDashboard().stream()
            .filter(row ->
                    "DANGER".equalsIgnoreCase(row.stockStatus())
                            || "WARNING".equalsIgnoreCase(row.stockStatus())
            )
            .count();
}

이렇게 하면서 다음 기준이 정리되었다.

  • 운영 요약의 안전재고 부족 = DANGER + WARNING
  • 긴급 발주 품목 TOP 5 = DANGER

이 기준은 화면의 의미와도 잘 맞는다. 부족 전체를 요약에서는 보여주고, 정말 긴급한 품목만 별도 TOP 5 테이블로 강조하는 구조이기 때문이다.


10. 트러블슈팅

10-1. DashboardResponse / DTO 생성자 오류

처음 Dashboard DTO를 만들고 Service에서 응답을 조합할 때, record 생성자와 실제 DTO 정의가 맞지 않아 compile error가 발생했다.

원인은 DTO 클래스에 필드가 없거나, record 선언이 실제 사용 방식과 맞지 않았기 때문이다.

해결은 간단하지만 중요했다. 모든 Dashboard DTO를 응답 구조에 맞는 record로 다시 정의했다.

public record DashboardSummaryResponse(
        Long dangerCount,
        Long openPurchaseOrderCount,
        Long todayInboundCount,
        Long totalInventoryValue
) {
}

이 수정 이후 Service에서 new DashboardSummaryResponse(...) 같은 생성이 정상 작동하게 되었다.

10-2. Native Query 결과 매핑 경고

EntityManager.createNativeQuery(...).getResultList()를 바로 List<Object[]>로 받을 때 unchecked warning이 발생했다.

원인은 JPA의 Native Query가 제네릭 타입 안정성을 제공하지 않기 때문이다.

해결은 공통 메서드로 캐스팅을 묶는 방식으로 정리했다.

@SuppressWarnings("unchecked")
private List<Object[]> getRows(String sql) {
    return (List<Object[]>) em.createNativeQuery(sql).getResultList();
}

이렇게 하니 repository 코드가 훨씬 깔끔해졌다.

10-3. 창고는 6개인데 Dashboard 차트에는 4개만 표시되던 문제

처음 쿼리는 inventory 기준 join으로 작성되어 있었다. 그러다 보니 inventory row가 존재하는 창고만 결과에 포함되었고, 재고가 없는 창고는 아예 빠져버렸다.

원인은 기준 테이블이 inventory였기 때문이다.

해결은 warehouse를 기준으로 LEFT JOIN을 거는 방식으로 바꾼 것이다.

SELECT w.warehouse_name,
       COALESCE(SUM(COALESCE(inv.current_qty, 0) * COALESCE(inv.avg_unit_cost, 0)), 0) AS amount
FROM warehouse w
LEFT JOIN inventory inv ON w.warehouse_id = inv.warehouse_id
GROUP BY w.warehouse_id, w.warehouse_name

이후 모든 창고가 차트 데이터에 포함되도록 수정할 수 있었다.

10-4. 총 자재 가치와 창고별 금액이 모두 0으로 보이던 문제

처음에는 item.unit_cost 기준으로 계산하고 있었고, 실제 테스트 데이터에서 unit_cost가 null이 많았다. 그래서 재고 수량은 존재하지만 금액 계산 결과는 전부 0이 되는 문제가 발생했다.

원인은 평가 단가가 비어 있는 상태에서 item.unit_cost에만 의존했기 때문이다.

해결은 두 단계로 진행했다.

  • inventory.avg_unit_cost 컬럼 추가
  • 입고 시 평균단가를 반영하는 구조로 변경

이후 Dashboard 금액 계산은 inventory.avg_unit_cost 기준으로 변경되었고, 조회 시점 계산이 훨씬 단순하고 안정적으로 바뀌었다.

10-5. Inventory.applyInbound 시그니처 변경 후 InboundService compile error

Inventory.applyInbound를 qty만 받던 메서드에서 qty와 inboundUnitCost를 함께 받는 형태로 바꾸면서 compile error가 발생했다.

기존 코드는 아래와 같았다.

inventory.applyInbound(line.qty());

하지만 변경된 시그니처는 아래였다.

applyInbound(int qty, BigDecimal inboundUnitCost)

원인은 단가 인자를 전달하지 않았기 때문이다.

해결은 PurchaseOrderLine.unitPrice를 읽어 함께 전달하도록 InboundService를 수정한 것이다.

BigDecimal inboundUnitPrice = resolveInboundUnitPrice(item, poLine);
inventory.applyInbound(line.qty(), inboundUnitPrice);

10-6. 안전재고 관리 화면과 Dashboard TOP 5 값이 서로 달랐던 문제

처음에는 DashboardQueryRepository에서 별도 SQL로 긴급 발주 품목을 계산하고 있었다. 하지만 왼쪽 안전재고 화면은 InventoryQueryService의 safetyPage 로직을 사용하고 있었기 때문에, 두 화면의 기준이 달라질 수 있었다.

원인은 안전재고 계산 로직이 두 군데에 중복되어 있었기 때문이다.

해결은 Dashboard에서 InventoryQueryService의 safety 로직을 재사용하도록 구조를 바꾼 것이다.

이후 Dashboard의 긴급 품목과 안전재고 화면의 기준이 일치하게 되었다.


11. 이번 단계에서 얻은 의미

이번 Dashboard 작업은 단순히 차트를 하나 추가한 수준이 아니었다. CoreERP 전체 구조 안에서 다음 개념을 명확히 잡는 과정이었다.

  • 조회 전용 도메인 분리
  • 운영 지표 계산 로직의 단일화
  • 재고평가 방식의 구조적 개선
  • 프론트와 백엔드 API 계약 구조 정리

특히 이번 단계에서 중요한 것은 Dashboard의 숫자가 단순히 "예쁘게 보이는 값"이 아니라, 실제 stock 서비스의 계산 기준과 일치해야 한다는 점이었다. 이 기준을 맞춘 덕분에 이후에 화면이 늘어나더라도 기준이 흔들리지 않는 구조를 만들 수 있었다.


12. 다음 단계

다음 단계에서는 이번 Dashboard를 기반으로 재고 분석 기능을 더 확장할 계획이다.

  • 장기재고 분석 고도화
  • 재고 회전율 지표 추가
  • 창고별 KPI 비교
  • Dashboard 카드 클릭 시 상세 화면 이동

이번 단계가 단순한 화면 추가가 아니라, CoreERP를 실제 운영형 시스템처럼 보이게 만들어주는 기반 작업이었다는 점에서 의미가 컸다.

profile
Develop

0개의 댓글