
이번 단계는 “현재재고 페이지”를 프론트(React/TypeScript)에서 실제 API로 붙이고, 백엔드(Spring Boot/JPA)에서 페이징/정렬/필터/집계(가용재고/소요량/안전재고)를 한 화면에 내려주는 구조를 고정한 작업이다.
특히 재고는 ERP에서 가장 핵심 데이터라서, 단순 조회 화면이라도 “Inventory(스냅샷) + StockTx(원장)”의 역할 분리를 유지하면서도 프론트에서 바로 쓸 수 있는 RowResponse 형태로 정리하는 게 목표였다.
/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
);
}
}
현재재고 화면은 “창고-품목 단위”로 보이기 때문에 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
);
}
Service에서는 2단계로 처리했다.
@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());
}
}
기존에는 useMemo 더미 배열로 화면을 만들고 정렬/필터를 프론트에서 처리했는데, 이번 단계에서 백엔드 Page API로 완전히 전환했다.
핵심은 applied 상태가 바뀌면 page=0으로 리셋하고, useEffect로 reload를 호출해 데이터 갱신하는 패턴이다.
useEffect(() => {
void reload(page, size, applied);
}, [applied, page, size]);
백엔드 파라미터와 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();
};
백엔드가 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);
};
응답에 warehouseName이 있으니 테이블 표시 우선순위를 warehouseName으로 두고, 없을 때 locationLabel/warehouseCode로 폴백하도록 정리했다.
<td title={safeText(it.locationLabel || it.warehouseName || it.warehouseCode)}>
{safeText(it.warehouseName || it.locationLabel || it.warehouseCode)}
</td>