CoreERP 현재 재고 API 프론트 연동 기록

최병현·2026년 3월 1일

coreerp project

목록 보기
26/44

이번 단계는 “현재재고 페이지”를 프론트(React/TypeScript)에서 실제 API로 붙이고, 백엔드(Spring Boot/JPA)에서 페이징/정렬/필터/집계(가용재고/소요량/안전재고)를 한 화면에 내려주는 구조를 고정한 작업이다.

특히 재고는 ERP에서 가장 핵심 데이터라서, 단순 조회 화면이라도 “Inventory(스냅샷) + StockTx(원장)”의 역할 분리를 유지하면서도 프론트에서 바로 쓸 수 있는 RowResponse 형태로 정리하는 게 목표였다.


1. 이번 단계의 핵심 목적

  • 현재재고 페이지에서 더미 데이터를 제거하고 API 기반으로 실데이터를 조회
  • 페이징(Page), 정렬(sortKey/sortOrder), 검색(keyword), 기간(from/to), 옵션(optionMode), 재고상태(stockStatus)까지 화면에서 제어
  • 재고 수치의 기준을 명확히 고정
    • 현재고(currentQty): Inventory.currentQty
    • 가용재고(availableQty): currentQty - reservedQty (0 미만 방지)
    • 안전재고(safetyQty): Item.safetyStock
    • 주/월/년 소요량(usage): StockTx OUTBOUND/TRANSFER_OUT 집계 기반(최근 7/30/365일)

2. 프론트 ↔ 백엔드 데이터 흐름

  • Frontend(React)에서 필터/정렬/페이지 상태를 URL querystring으로 구성
  • Backend(Spring Boot)에서 Page<InventoryCurrentRowResponse> 형태로 응답
  • Frontend는 Spring Data Page 응답(content/totalElements/totalPages/number/size)을 그대로 이용해 테이블 + 페이지네이션을 갱신

3. 백엔드 구현 (Backend logic in Spring Boot)

3-1) Controller – 현재재고 조회 API

/api/stocks/current 엔드포인트를 기준으로, UI에서 필요한 필터/정렬/페이징 파라미터를 전부 받도록 고정했다.

@RestController
@RequestMapping("/api/stocks")
@RequiredArgsConstructor
public class InventoryQueryController {

    private final InventoryQueryService inventoryQueryService;

    @GetMapping("/current")
    public Page<InventoryCurrentRowResponse> current(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) Long warehouseId,
            @RequestParam(required = false) String stockStatus,
            @RequestParam(required = false) String optionMode,
            @RequestParam(required = false) String from,
            @RequestParam(required = false) String to,
            @RequestParam(defaultValue = "itemName") String sortKey,
            @RequestParam(defaultValue = "asc") String sortOrder,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size
    ) {
        return inventoryQueryService.currentPage(
                keyword, warehouseId, stockStatus, optionMode, from, to, sortKey, sortOrder, page, size
        );
    }
}

3-2) Repository – 조회 베이스 Row만 먼저 Page로 가져오기

현재재고 화면은 “창고-품목 단위”로 보이기 때문에 Inventory를 기준으로 Item/Warehouse를 join한 베이스 Row를 먼저 만들었다.

중요 포인트는, 화면에 바로 쓰기 위한 projection(InventoryBaseRow)을 만들어서 N+1을 줄이고, Page 기반으로 안정적으로 가져오는 것.

public interface InventoryQueryRepository extends JpaRepository<Inventory, Long> {

    @Query("""
        select new com.coreerp.stock.repository.projection.InventoryBaseRow(
            w.warehouseId,
            w.warehouseCode,
            w.warehouseName,
            it.itemId,
            it.itemCode,
            it.itemName,
            it.spec,
            it.unit,
            it.status,
            inv.currentQty,
            inv.reservedQty,
            inv.lastInboundAt,
            it.safetyStock
        )
        from Inventory inv
        join inv.warehouse w
        join inv.item it
        where (:warehouseId is null or w.warehouseId = :warehouseId)
          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 (:fromDt is null or inv.lastInboundAt >= :fromDt)
          and (:toDt is null or inv.lastInboundAt < :toDt)
    """)
    Page<InventoryBaseRow> searchBasePage(
            @Param("warehouseId") Long warehouseId,
            @Param("kw") String keyword,
            @Param("fromDt") java.time.LocalDateTime fromDt,
            @Param("toDt") java.time.LocalDateTime toDt,
            Pageable pageable
    );
}

3-3) Service – 계산/집계 포함 RowResponse로 매핑

Service에서는 2단계로 처리했다.

  • 1단계: InventoryBaseRow(Page)를 받아서 current/available/safety/locationLabel 계산
  • 2단계: 같은 페이지에 있는 (warehouseId,itemId) 쌍에 대해 StockTx에서 usage 집계를 로드해서 weekly/monthly/yearly를 채움
@Service
@RequiredArgsConstructor
public class InventoryQueryService {

    private final InventoryQueryRepository inventoryQueryRepository;
    private final StockTxRepository stockTxRepository;

    @Transactional(readOnly = true)
    public Page<InventoryCurrentRowResponse> currentPage(
            String keyword,
            Long warehouseId,
            String stockStatus,
            String optionMode,
            String from,
            String to,
            String sortKey,
            String sortOrder,
            int page,
            int size
    ) {
        int p = Math.max(page, 0);
        int s = Math.min(Math.max(size, 1), 50);

        String kw = normalize(keyword);
        LocalDateTime fromDt = parseFrom(from);
        LocalDateTime toDt = parseToExclusive(to);

        Pageable pageable = PageRequest.of(p, s, buildSafeSort(sortKey, sortOrder));

        Page<InventoryBaseRow> basePage = inventoryQueryRepository.searchBasePage(
                warehouseId, kw, fromDt, toDt, pageable
        );

        List<InventoryBaseRow> baseRows = basePage.getContent();
        Map<String, Usage> usageMap = loadUsageAgg(baseRows);

        List<InventoryCurrentRowResponse> mapped = baseRows.stream()
                .map(r -> {
                    int current = nvl(r.getCurrentQty());
                    int reserved = nvl(r.getReservedQty());
                    int available = Math.max(current - reserved, 0);

                    int safety = nvl(r.getSafetyStock());

                    Usage u = usageMap.get(key(r.getWarehouseId(), r.getItemId()));
                    int weekly = u == null ? 0 : u.weekly;
                    int monthly = u == null ? 0 : u.monthly;
                    int yearly = u == null ? 0 : u.yearly;

                    String locationLabel = r.getWarehouseCode() + " / " + r.getWarehouseName();

                    return new InventoryCurrentRowResponse(
                            r.getWarehouseId(),
                            r.getWarehouseCode(),
                            r.getWarehouseName(),
                            r.getItemId(),
                            r.getItemCode(),
                            r.getItemName(),
                            r.getSpec(),
                            r.getUnit(),
                            r.getItemStatus(),
                            current,
                            available,
                            safety,
                            weekly,
                            monthly,
                            yearly,
                            locationLabel,
                            r.getLastInboundAt()
                    );
                })
                .toList();

        List<InventoryCurrentRowResponse> filtered = mapped.stream()
                .filter(r -> applyOptionMode(r, optionMode))
                .filter(r -> applyStockStatus(r, stockStatus))
                .toList();

        return new PageImpl<>(filtered, pageable, basePage.getTotalElements());
    }
}

3-4) 현재재고 컬럼 기준 정리

  • 현재고(currentQty): Inventory.currentQty
  • 가용재고(availableQty): max(currentQty - reservedQty, 0)
  • 안전재고(safetyQty): Item.safetyStock (없으면 0)
  • 주/월/년 소요량(weekly/monthly/yearly): StockTx에서 OUTBOUND/TRANSFER_OUT 집계

4. 프론트 구현 (Frontend UI + API Integration in React)

4-1) 더미 제거 + Page API 연동

기존에는 useMemo 더미 배열로 화면을 만들고 정렬/필터를 프론트에서 처리했는데, 이번 단계에서 백엔드 Page API로 완전히 전환했다.

핵심은 applied 상태가 바뀌면 page=0으로 리셋하고, useEffect로 reload를 호출해 데이터 갱신하는 패턴이다.

useEffect(() => {
  void reload(page, size, applied);
}, [applied, page, size]);

4-2) QueryString 구성

백엔드 파라미터와 1:1로 맞춰야 화면이 흔들리지 않는다. 특히 창고 필터는 warehouseCode가 아니라 warehouseId 기준으로 갈지 명확히 고정해야 한다.

const buildQuery = (f: InventoryFilter, p: number, s: number) => {
  const qs = new URLSearchParams();
  qs.set("page", String(p));
  qs.set("size", String(s));

  if (f.keyword && f.keyword.trim()) qs.set("keyword", f.keyword.trim());
  if (f.lastInboundFrom) qs.set("from", f.lastInboundFrom);
  if (f.lastInboundTo) qs.set("to", f.lastInboundTo);

  if (f.sortKey) qs.set("sortKey", f.sortKey);
  if (f.sortKey) qs.set("sortOrder", f.sortOrder);

  if (f.optionMode && f.optionMode !== "DEFAULT") qs.set("optionMode", f.optionMode);
  if (f.stockStatus) qs.set("stockStatus", f.stockStatus);

  return qs.toString();
};

4-3) 최종 입고일 “분까지” 출력

백엔드가 LocalDateTime을 ISO 문자열로 내려주면, 프론트에서 “YYYY-MM-DD HH:mm”까지만 잘라서 보여주면 된다.

const formatDateTime = (iso: string | null): string => {
  if (!iso) return "-";
  const base = iso.replace("T", " ");
  return base.slice(0, 16);
};

4-4) 창고 위치 표시가 코드로 나오던 문제 대응

응답에 warehouseName이 있으니 테이블 표시 우선순위를 warehouseName으로 두고, 없을 때 locationLabel/warehouseCode로 폴백하도록 정리했다.

<td title={safeText(it.locationLabel || it.warehouseName || it.warehouseCode)}>
  {safeText(it.warehouseName || it.locationLabel || it.warehouseCode)}
</td>

5. 트러블슈팅 (원인 / 결과)

5-1) 창고 위치가 “창고명”이 아니라 “창고코드”로 보임

  • 원인: 프론트에서 locationLabel 또는 warehouseCode만 우선 출력하거나, normalize 단계에서 warehouseName 폴백 우선순위가 낮았음
  • 결과: UI 표시 우선순위를 warehouseName > locationLabel > warehouseCode로 고정해서 해결

5-2) 최종 입고일이 날짜만 보이거나 초/나노초까지 길게 보임

  • 원인: LocalDateTime이 ISO 문자열로 내려오면서 “2026-02-24T20:47:48.838632” 형태로 표시됨
  • 결과: replace + slice로 “YYYY-MM-DD HH:mm”까지만 출력하도록 formatDateTime을 도입해 해결

5-3) 주/월/년 소요량이 전부 0으로 보임

  • 원인: 집계는 “오늘 기준 최근 7/30/365일”의 OUTBOUND/TRANSFER_OUT 데이터가 있어야 값이 나오는데, 아직 데이터가 누적되지 않았거나 해당 기간에 출고/이동이 없었음
  • 결과: 로직 문제는 아니고 데이터 누적 전 단계의 정상 동작. 이후 출고/이동 데이터가 쌓이면 자동으로 값이 채워짐

5-4) optionMode/stockStatus를 서비스에서 후처리 필터링할 때 페이징 왜곡 가능

  • 원인: Page로 먼저 가져온 뒤에 자바 스트림으로 필터를 적용하면, totalElements와 content 개수가 논리적으로 어긋날 수 있음
  • 결과: 빠른 완성 버전으로는 허용하고, 안정화 단계에서 Query 조건으로 이동(WHERE 절 반영)하는 계획을 확정

6. 이번 단계의 의미

  • 현재재고 페이지는 “스냅샷(inventory) + 원장(stock_tx)”의 기준을 유지하면서도 화면 요구사항(정렬/필터/집계)을 만족하는 형태로 고정했다.
  • 이 구조가 고정되면 안전재고/장기재고 페이지는 같은 RowResponse를 재사용하면서 조건만 바꿔 빠르게 확장할 수 있다.

7. 다음 단계 계획

  • 안전재고 페이지 작업
    • availableQty < safetyQty 조건 기반
    • 부족 상태 강조(UI badge/컬러)
  • 장기재고(aging) 페이지 작업
    • lastInboundAt 기준 threshold(days) 적용
    • 재고가 남아있는 품목만 추출

8. 핵심 요약

  • 현재재고 페이지 더미 제거 → API 연동 완료
  • 현재고/가용재고/안전재고/소요량 기준을 백엔드에서 고정
  • 최종 입고일 표시, 창고명 표시 등 UI 실사용 이슈 해결
  • 안전재고/장기재고는 동일 구조 재사용으로 확장 준비 완료
profile
Develop

0개의 댓글