CoreERP 장기 재고 조회 API 프론트 연동 작업 기록

최병현·2026년 3월 1일

coreerp project

목록 보기
28/44

이번 단계는 “장기 재고(오래 출고/입고가 없거나, 기준 기간 이상 경과한 재고)”를 관리하기 위한 조회 화면을 만들고, 필터/정렬/페이지네이션까지 Frontend(React) ↔ Backend(Spring Boot) ↔ DB(MariaDB) 흐름을 끝까지 연결하는 작업이다.


1) 전체 흐름 (Frontend ↔ Backend ↔ DB)

  • Frontend(React)에서 필터 값(keyword, warehouseId, itemStatus, daysCut, sortKey/sortOrder, page/size)을 쿼리스트링으로 구성해 API 호출
  • Backend(Spring Boot)에서 inventory + item + warehouse 조인 기반으로 “장기 재고 조건(threshold)”을 적용해 조회
  • monthlyUsage(월 소요량)는 stock_tx(출고/이동출고) 집계 결과를 합쳐서 응답에 포함
  • 응답은 Page 형태(content/number/totalElements/totalPages 등)로 내려서 프론트에서 그대로 테이블 렌더링

여기서 포인트는 “조회 화면 UX(필터/정렬/페이지네이션)”를 먼저 안정화하고, 그 다음에 성능 최적화(집계/정렬 DB로 내리기, 인덱스 보강)를 진행할 수 있게 구조를 고정하는 것이다.


2) Backend 작업 (Spring Boot / JPA Query)

2-1. Response DTO

화면에 필요한 컬럼만 서버에서 확정해서 내려준다. (창고/품목 식별 + 재고 + 월 소요량 + 최종 입/출고일 + 경과일)


public record LongTermStockRowResponse(
        Long warehouseId,
        String warehouseCode,
        String warehouseName,
        Long itemId,
        String itemCode,
        String itemName,
        String spec,
        String unit,
        String itemStatus,
        Integer currentQty,
        Integer monthlyUsage,
        LocalDateTime lastInboundAt,
        LocalDateTime lastOutboundAt,
        Integer agingDays
) {}

2-2. Repository Query (장기재고 Base 조회)

Inventory 기준으로 warehouse/item join해서 base 컬럼을 가져오고, 장기재고 조건을 where에 적용한다.

  • inv.currentQty > 0 (재고 있는 것만)
  • inv.lastOutboundAt is null OR inv.lastOutboundAt <= threshold
  • warehouseId/keyword/itemStatus 필터 지원

코드는 길어지기 쉬워서 “핵심 조건만 남긴 형태”로 정리한다. (실제 구현은 projection/new DTO 포함)

@Query("""
select new com.coreerp.stock.repository.projection.LongTermBaseRow(
    w.warehouseId, w.warehouseCode, w.warehouseName,
    it.itemId, it.itemCode, it.itemName, it.spec, it.unit, it.status,
    inv.currentQty, inv.lastInboundAt, inv.lastOutboundAt
)
from Inventory inv
join inv.warehouse w
join inv.item it
where (:warehouseId is null or w.warehouseId = :warehouseId)
  and (:itemStatus is null or it.status = :itemStatus)
  and (
        :kw is null
        or lower(it.itemCode) like concat('%', lower(:kw), '%')
        or lower(it.itemName) like concat('%', lower(:kw), '%')
        or lower(it.spec) like concat('%', lower(:kw), '%')
  )
  and inv.currentQty > 0
  and (inv.lastOutboundAt is null or inv.lastOutboundAt <= :threshold)
""")
Page<LongTermBaseRow> searchLongTermBasePage(
    Long warehouseId, String kw, String itemStatus, LocalDateTime threshold, Pageable pageable
);

정확도를 위해 keyword는 normalize(공백 제거/빈 문자열 null 처리), itemStatus도 normalize 후 파라미터로 넣는 게 안정적이다.

2-3. Service (usage 집계 + agingDays 계산 + 정렬/페이지 슬라이싱)

현재 단계에서는 UI 기능 완성과 데이터 흐름 고정을 위해 MAX_FETCH로 전체를 가져온 뒤, 서버 메모리에서 정렬/페이지 슬라이싱을 처리한다.

  • 장점: 프론트 요구사항(정렬 항목 추가/변경)에 즉시 대응 가능
  • 단점: 데이터가 많아지면 확장성 이슈 → 이후 DB 정렬/페이징으로 내려야 함
  • 안전장치: total > MAX_FETCH면 예외로 필터를 더 좁히게 유도
@Transactional(readOnly = true)
public Page<LongTermStockRowResponse> longTermPage(
        String keyword, Long warehouseId, String itemStatus,
        int daysCut, String sortKey, String sortOrder, int page, int size
) {
    int safeDays = Math.max(daysCut, 1);
    LocalDateTime threshold = LocalDateTime.now().minusDays(safeDays);

    Page<LongTermBaseRow> base = inventoryQueryRepository.searchLongTermBasePage(
            warehouseId, normalize(keyword), normalize(itemStatus), threshold,
            PageRequest.of(0, MAX_FETCH)
    );

    if (base.getTotalElements() > MAX_FETCH) {
        throw new IllegalStateException("Too many rows. Narrow your filters.");
    }

    Map<String, Usage> usageMap = loadUsageAggLongTerm(base.getContent());
    LocalDateTime now = LocalDateTime.now();

    List<LongTermStockRowResponse> mapped = base.getContent().stream()
            .map(r -> toResponse(r, usageMap, now))
            .toList();

    List<LongTermStockRowResponse> sorted = sortLongTermInMemory(mapped, sortKey, sortOrder);
    return sliceToPageLongTerm(sorted, page, size);
}

agingDays 계산 기준은 “최종 출고일(lastOutboundAt)”을 기본으로 잡았다. (출고 이력이 없는 재고는 agingDays를 null 처리)

  • 운영에서 “출고 이력 없는 재고”도 aging을 보고 싶다면 lastInboundAt 기준으로 fallback 하는 옵션을 추가할 수 있다.
  • monthlyUsage는 “최근 30일 출고 합계”처럼 정의를 고정하는 게 중요하다. (30일/한 달/캘린더 월 기준 중 무엇인지)

3) Frontend 작업 (React / API Integration)

3-1. 핵심 설계 포인트

  • 창고 select 옵션은 더미 제거 후 /api/warehouses에서 받아 구성
  • 장기 재고 조회는 /api/stocks/long-term 호출
  • draft/applied 분리: 입력 중에는 draft만 변경, “적용” 시 applied로 고정하고 page=0으로 리셋
  • daysCut 옵션은 180/365만 유지해서 화면 목적(장기 재고) 집중

3-2. Frontend 코드 (길이 축약 버전)

전체 컴포넌트가 길어지기 때문에 실제 동작에 영향이 큰 부분만 남긴다. (쿼리 구성 / 창고 로드 / 목록 로드 / Page 처리)

type FilterState = {
  keyword: string;
  daysCut: "180" | "365";
  warehouseId: "" | number;
  itemStatus: "ALL" | "SUSPENDED" | "DISCONTINUED";
  sortKey: "" | LongTermSortKey;
  sortOrder: "asc" | "desc";
};

const buildQuery = (f: FilterState, page: number, size: number) => {
  const qs = new URLSearchParams();
  qs.set("page", String(page));
  qs.set("size", String(size));
  qs.set("daysCut", String(Number(f.daysCut)));

  if (f.keyword.trim()) qs.set("keyword", f.keyword.trim());
  if (f.warehouseId !== "") qs.set("warehouseId", String(f.warehouseId));
  if (f.itemStatus !== "ALL") qs.set("itemStatus", f.itemStatus);

  if (f.sortKey) {
    qs.set("sortKey", f.sortKey);
    qs.set("sortOrder", f.sortOrder);
  }
  return qs.toString();
};

const loadWarehouses = async () => {
  const res = await fetch("/api/warehouses");
  const data = await res.json().catch(() => null);

  const content = Array.isArray(data?.content) ? data.content : Array.isArray(data) ? data : [];
  return content.map((w: any) => ({
    warehouseId: Number(w.warehouseId),
    warehouseName: String(w.warehouseName ?? ""),
  }));
};

const loadRows = async (query: string) => {
  const res = await fetch(`/api/stocks/long-term?${query}`);
  if (!res.ok) throw new Error(await res.text().catch(() => "request failed"));
  return (await res.json()) as { content: any[]; number: number; totalElements: number; totalPages: number };
};

UI에서는 Page 응답을 그대로 받아 table 렌더링하고, 페이지 이동 시 page만 변경해 reload한다. draft/applied 분리 덕분에 입력 중 네트워크가 터지지 않는다.


4) 트러블슈팅 (원인 / 해결)

4-1. “창고 셀렉트가 비어있음”

  • 현상: Backend는 정상인데 Frontend select에 옵션이 렌더링되지 않음
  • 원인: /api/warehouses 응답이 Page({content:[...]})인데 프론트에서 배열([])로 가정하고 map 처리
  • 해결: 응답이 Page인지 Array인지 둘 다 흡수하도록 content 추출 로직 추가
const content = Array.isArray(data?.content) ? data.content : Array.isArray(data) ? data : [];

4-2. “장기재고가 0건이면 오류인가?”

  • 현상: /api/stocks/long-term 결과가 0건 → 화면이 비어서 불안함
  • 원인: 조건이 “기준일(daysCut) 이전 출고(lastOutboundAt <= threshold) 또는 출고 이력 없음(null) + currentQty > 0”이기 때문에, 테스트 데이터가 threshold를 만족하지 않으면 0건이 정상
  • 해결: seed에서 lastOutboundAt을 과거로 넣거나, daysCut을 줄여 검증. 운영에서는 시간이 지나면서 자연스럽게 데이터가 누적된다.

4-3. “3개월 옵션 제거 (90일)”

  • 현상: 90일 기준은 노이즈가 많고, 실무에서 ‘장기 재고 관리’ 목적에 덜 맞음
  • 해결: daysCut을 180/365만 남겨 화면의 의미를 명확히 고정

4-4. “정렬/페이지네이션이 꼬이는 느낌”

  • 현상: 필터 변경 후 페이지가 유지되면 빈 페이지로 떨어질 수 있음
  • 원인: 필터 적용 시 page=0으로 리셋하지 않으면, 기존 page가 새 totalPages 범위를 벗어남
  • 해결: onApply에서 setApplied 후 setPage(0) 강제

5) 보강 포인트

  • DB로 정렬/페이지네이션 내리기: 현재는 MAX_FETCH + in-memory 정렬이므로 데이터 증가 시 한계가 명확하다
  • monthlyUsage 집계 정의 고정: “최근 30일 출고합”인지 “캘린더 월”인지 서버/화면 공통 규칙을 문서화
  • 인덱스 점검:
    • inventory: (warehouse_id, item_id), (current_qty), (last_outbound_at)
    • item: (status), (item_code), (item_name)
    • stock_tx: (warehouse_id, item_id, tx_type, tx_at)
  • agingDays 기준 확장: lastOutboundAt이 null인 재고를 lastInboundAt 기준으로 계산하는 옵션 추가 가능

6) 정리

  • 장기 재고 화면은 “데이터가 없는 상태”가 오류가 아니라, 조건을 만족하는 이벤트가 아직 누적되지 않은 정상 상태일 수 있다
  • warehouse select는 더미 제거 후 실제 API로 연동했고, Page/Array 응답 포맷 차이를 프론트에서 흡수하도록 보강했다
  • draft/applied 분리를 통해 입력 UX를 안정화했고, 필터 적용 시 page 리셋으로 페이지네이션 꼬임을 막았다
  • 현 단계의 목표는 기능 완성 + 데이터 흐름 고정이며, 다음 단계에서 DB 정렬/페이징/인덱스 최적화로 확장 가능하다
profile
Develop

0개의 댓글