CoreERP 재고 조정 구현 기록

최병현·2026년 2월 21일

coreerp project

목록 보기
7/44
post-thumbnail

1. 작성 목적

본 기록은 CoreERP 프로젝트에서 재고 시스템의 핵심인 “조정(Adjustment)” 경로를 구현하며, 재고 스냅샷(Inventory)과 원장(StockTx)을 함께 운영하는 구조를 완성한 과정을 정리하기 위한 문서이다.

단순 CRUD가 아닌 ERP 관점의 데이터 무결성과 트랜잭션 흐름을 우선 기준으로 두었으며, 재고 변동이 발생하는 모든 지점을 원장 기록으로 추적 가능하도록 설계하였다.


2. 현재 완료 범위

  • 마스터 (Item / Warehouse) 기반 구조 확정
  • 재고 구조 (Inventory 스냅샷 + UNIQUE(item_id, warehouse_id)) 확정
  • 재고 조정 API 구현
  • 원장(StockTx) 기록 구현 (memo 포함)
  • 전체 로직 트랜잭션 처리(@Transactional) 적용
  • 응답 DTO 구성으로 API 결과를 명확히 반환

3. 설계 핵심: 스냅샷(Inventory) + 원장(StockTx) 분리

CoreERP 재고 설계는 “현재 상태”와 “변동 이력”을 분리하는 구조를 기본 원칙으로 한다.

  • Inventory: 현재 재고 수량을 빠르게 조회하기 위한 스냅샷
  • StockTx: 재고 변동의 원인을 추적하기 위한 원장(Audit Log)

즉, 재고 수량은 Inventory에만 존재하지만, 그 수량이 왜/언제/무슨 근거로 변했는지는 StockTx에 남도록 설계하였다.


4. 데이터 모델

4-1. Inventory: (item_id, warehouse_id) UNIQUE 스냅샷

Inventory는 “품목 + 창고” 조합당 1행만 존재해야 한다. 그래야 현재 수량(currentQty)을 조회할 때 중복이 발생하지 않고, 재고 계산이 흔들리지 않는다.

package com.coreerp.domain;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(
        name = "inventory",
        uniqueConstraints = {
                @UniqueConstraint(name = "uk_inventory_item_warehouse", columnNames = {"item_id", "warehouse_id"})
        },
        indexes = {
                @Index(name = "idx_inventory_warehouse", columnList = "warehouse_id"),
                @Index(name = "idx_inventory_item", columnList = "item_id")
        }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Inventory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long inventoryId;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "item_id", nullable = false)
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "warehouse_id", nullable = false)
    private Warehouse warehouse;

    @Column(nullable = false)
    private Integer currentQty;

    @Column(nullable = false)
    private Integer reservedQty;

    private LocalDateTime lastInboundAt;
    private LocalDateTime lastOutboundAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        if (this.currentQty == null) this.currentQty = 0;
        if (this.reservedQty == null) this.reservedQty = 0;
        this.updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    public void applyDelta(int qtyDelta) {
        int next = this.currentQty + qtyDelta;
        if (next < 0) {
            throw new IllegalStateException("재고가 부족합니다.");
        }
        this.currentQty = next;
    }
}

4-2. StockTx: 원장(ledger) + memo 기록

StockTx는 “재고가 왜 변했는지”를 남기는 감사(Audit)용 기록이다. qtyDelta만으로는 업무 사유를 설명할 수 없으므로 memo를 포함했다.

package com.coreerp.domain;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(
        name = "stock_tx",
        indexes = {
                @Index(name = "idx_stocktx_wh_item_date", columnList = "warehouse_id, item_id, txDate"),
                @Index(name = "idx_stocktx_ref", columnList = "refType, refId")
        }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class StockTx {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long txId;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "item_id", nullable = false)
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "warehouse_id", nullable = false)
    private Warehouse warehouse;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 30)
    private TxType txType;

    @Column(nullable = false)
    private LocalDateTime txDate;

    @Column(nullable = false)
    private Integer qtyDelta;

    @Column(length = 30)
    private String refType;

    private Long refId;

    @Lob
    private String memo;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @PrePersist
    void onCreate() {
        LocalDateTime now = LocalDateTime.now();
        if (this.txDate == null) this.txDate = now;
        this.createdAt = now;
    }
}

package com.coreerp.domain;

public enum TxType {
    INBOUND,
    OUTBOUND,
    TRANSFER_IN,
    TRANSFER_OUT,
    ADJUST
}

5. 재고 조정 API의 역할

입고/출고 이전 단계에서도 실무에서는 재고 조정이 반드시 필요하다. 예를 들어 실사(재고 조사), 불량/폐기, 시스템 초기화, 오기입 정정 등이 대표적이다.

따라서 조정 API는 “재고를 바꾸는 CRUD”가 아니라, 재고 변동을 합법적으로 기록 가능한 관리 경로로 정의하였다.

5-1. Request/Response DTO

Request는 입력값을 받고, Response는 처리 결과를 프론트(React)가 즉시 반영할 수 있도록 반환한다.

package com.coreerp.dto;

public record AdjustStockRequest(
        Long itemId,
        Long warehouseId,
        Integer qtyDelta,
        String memo
) {}

package com.coreerp.dto;

public record AdjustStockResponse(
        Long itemId,
        Long warehouseId,
        Integer currentQty,
        Integer reservedQty,
        Long txId
) {}

6. 트랜잭션 처리 기준

재고 조정은 다음 작업이 하나의 작업 단위로 묶여야 한다.

  • Inventory 수량 변경(스냅샷)
  • StockTx 원장 기록(insert)
  • 응답 DTO 반환(결과 요약)

위 흐름 중 하나라도 실패하면 데이터 무결성이 깨지기 때문에, 서비스 로직을 @Transactional로 묶어 “성공하면 전부 반영, 실패하면 전부 롤백”이 되도록 구성하였다.

6-1. Repository

package com.coreerp.repository;

import com.coreerp.domain.Inventory;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface InventoryRepository extends JpaRepository<Inventory, Long> {
    Optional<Inventory> findByItem_ItemIdAndWarehouse_WarehouseId(Long itemId, Long warehouseId);
}

package com.coreerp.repository;

import com.coreerp.domain.StockTx;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StockTxRepository extends JpaRepository<StockTx, Long> {
}

6-2. Service: 재고 조정 핵심 로직


package com.coreerp.service;

import com.coreerp.domain.*;
import com.coreerp.dto.AdjustStockRequest;
import com.coreerp.dto.AdjustStockResponse;
import com.coreerp.repository.InventoryRepository;
import com.coreerp.repository.ItemRepository;
import com.coreerp.repository.StockTxRepository;
import com.coreerp.repository.WarehouseRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class StockService {

    private final ItemRepository itemRepository;
    private final WarehouseRepository warehouseRepository;
    private final InventoryRepository inventoryRepository;
    private final StockTxRepository stockTxRepository;

    @Transactional
    public AdjustStockResponse adjust(AdjustStockRequest req) {
        if (req.itemId() == null) {
            throw new IllegalArgumentException("품목 ID(itemId)는 필수입니다.");
        }
        if (req.warehouseId() == null) {
            throw new IllegalArgumentException("창고 ID(warehouseId)는 필수입니다.");
        }
        if (req.qtyDelta() == null || req.qtyDelta() == 0) {
            throw new IllegalArgumentException("수량 변화(qtyDelta)는 0이 될 수 없습니다.");
        }

        Item item = itemRepository.findById(req.itemId())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 품목입니다. itemId=" + req.itemId()));

        Warehouse warehouse = warehouseRepository.findById(req.warehouseId())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 창고입니다. warehouseId=" + req.warehouseId()));

        Inventory inventory = inventoryRepository
                .findByItem_ItemIdAndWarehouse_WarehouseId(req.itemId(), req.warehouseId())
                .orElseGet(() -> Inventory.builder()
                        .item(item)
                        .warehouse(warehouse)
                        .currentQty(0)
                        .reservedQty(0)
                        .build()
                );

        inventory.applyDelta(req.qtyDelta());
        Inventory saved = inventoryRepository.save(inventory);

        StockTx tx = StockTx.builder()
                .item(item)
                .warehouse(warehouse)
                .txType(TxType.ADJUST)
                .txDate(LocalDateTime.now())
                .qtyDelta(req.qtyDelta())
                .refType("ADJUST")
                .refId(saved.getInventoryId())
                .memo(req.memo())
                .build();

        StockTx savedTx = stockTxRepository.save(tx);

        return new AdjustStockResponse(
                saved.getItem().getItemId(),
                saved.getWarehouse().getWarehouseId(),
                saved.getCurrentQty(),
                saved.getReservedQty(),
                savedTx.getTxId()
        );
    }
}

6-3. Controller

package com.coreerp.controller;

import com.coreerp.dto.AdjustStockRequest;
import com.coreerp.dto.AdjustStockResponse;
import com.coreerp.service.StockService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/stock")
@RequiredArgsConstructor
public class StockController {

    private final StockService stockService;

    @PostMapping("/adjust")
    public AdjustStockResponse adjust(@RequestBody AdjustStockRequest req) {
        return stockService.adjust(req);
    }
}

7. 원장(StockTx) memo 포함의 의미

원장 기록에 memo를 포함한 이유는 “수량 변화”만으로는 실제 업무 원인을 설명할 수 없기 때문이다.

  • 재고 조정 사유(실사/폐기/오류정정 등)
  • 담당자 커뮤니케이션 흔적
  • 감사(Audit) 상황에서 근거 확보

memo는 단순 UI 편의가 아니라, ERP 시스템에서 데이터 변화의 근거를 남기기 위한 필수 요소로 판단하였다.


8. Postman 테스트

개발 단계에서 API 검증은 Postman으로 수행했고, “스냅샷 반영 + 원장 적재”가 동시에 되는지 확인했다.

8-1. 성공 케이스

{
  "itemId": 1,
  "warehouseId": 1,
  "qtyDelta": 5,
  "memo": "초기 재고 세팅 +5"
}

8-2. DB 확인

SELECT item_id, warehouse_id, current_qty, reserved_qty
FROM inventory;

SELECT tx_id, tx_type, qty_delta, ref_type, ref_id, memo, created_at
FROM stock_tx
ORDER BY tx_id DESC;

8-3. 실패 케이스(재고 부족)

currentQty보다 큰 음수 조정이 들어오면 예외를 발생시켜 무결성을 보장한다.

{
  "itemId": 1,
  "warehouseId": 1,
  "qtyDelta": -999,
  "memo": "재고 부족 테스트"
}

9. 현재 단계의 의미

이번 단계는 CoreERP가 단순 CRUD 프로젝트를 넘어, 트랜잭션 기반의 재고 무결성 구조를 갖춘 ERP 형태로 전환되는 지점이다.

특히 “재고가 변하는 모든 순간을 원장으로 남긴다”는 기준을 고정했다는 점에서, 향후 입고/출고/발주/이동 등 확장 기능을 붙이더라도 동일한 규칙으로 시스템을 확장할 수 있다.


10. 다음 단계

다음 단계에서는 조정 로직을 기반으로 실제 업무 트랜잭션을 연결한다.

  • 입고 확정(Inbound Confirm) → +qty, 원장 기록
  • 출고 확정(Outbound Confirm) → -qty, 원장 기록
  • 재고 이력 조회 API(StockTx History) 제공
  • 권한/담당자/사유 코드 체계(Reason Code) 확장

11. 정리

CoreERP 백엔드는 현재 “마스터 → 재고 스냅샷 → 원장 기록”의 핵심 구조를 완성했고, 재고 조정 API를 통해 재고 변동을 트랜잭션으로 안전하게 처리할 수 있는 기반을 확보했다.

이 구조를 기준으로 이후 입고/출고 등 실제 ERP 흐름을 확장해도 데이터 무결성과 추적 가능성을 유지할 수 있도록 설계 방향을 고정하였다.

profile
Develop

0개의 댓글