CoreERP 출고 등록 / 이력 모듈 정리 기록

최병현·2026년 2월 27일

coreerp project

목록 보기
19/44

1. 이전 단계 요약

구매(PurchaseOrder) → 입고(Inbound) 확정 흐름을 통해 재고 증가 로직과 원장(StockTx) 기록 구조를 먼저 완성했다. 이번 단계는 그 반대 축인 출고(Outbound)를 붙여 재고 사이클을 닫는 과정이다.


2. 이번 단계의 핵심 목적

출고는 단순히 outbound row를 저장하는 게 아니라,

  • 재고(Inventory) 감소
  • 원장(StockTx) OUTBOUND 기록
  • 출고 이력 조회(집계/검색/페이징)
  • 출고 상세 조회(라인 포함)

까지 하나의 흐름으로 “운영 가능한 모듈”로 완성하는 것이 목적이었다.


3. 작업 범위

  • 출고 확정 API: POST /api/outbounds/confirm
  • 출고 이력 검색 API: GET /api/outbounds (조건 검색 + Pageable)
  • 출고 상세 조회 API: GET /api/outbounds/{outboundId}
  • JPQL DTO projection + 집계(count/sum) + group by 구성
  • 트러블슈팅: 엔티티 필드명 mismatch, DTO 생성자 타입 mismatch

4. 설계 의도

이번 단계에서 의도적으로 신경 쓴 기준은 “조회는 Entity를 절대 반환하지 않는다”였다. ERP에서 이력 화면은 대부분 집계 값(라인 수, 총 수량 등)이 포함되기 때문에, Entity로 해결하려고 하면 N+1, 불필요한 로딩, 화면 전용 필드 증가 같은 문제가 바로 생긴다.

  • Command(등록/확정)와 Query(조회)를 분리해서 서비스 책임을 명확하게 가져간다.
  • List 화면은 DTO projection으로 필요한 필드만 가져오고, 집계를 포함한다.
  • Detail 화면은 outbound + lines 구조를 DTO로 반환한다.

5. Controller 구성

Outbound는 등록/확정 로직과 조회 로직이 성격이 달라서, OutboundService / OutboundQueryService로 분리했다. Controller는 API 계약만 담당하도록 유지했다.

@RestController
@RequestMapping("/api/outbounds")
@RequiredArgsConstructor
public class OutboundController {

    private final OutboundService outboundService;
    private final OutboundQueryService outboundQueryService;

    @PostMapping("/confirm")
    public OutboundConfirmResponse confirm(@RequestBody OutboundConfirmRequest req) {
        return outboundService.confirm(req);
    }

    @GetMapping
    public Page<OutboundRowResponse> search(
            @RequestParam(required = false) Long warehouseId,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
            @RequestParam(required = false) String outboundType,
            @RequestParam(required = false) String keyword,
            Pageable pageable
    ) {
        return outboundQueryService.search(warehouseId, fromDate, toDate, outboundType, keyword, pageable);
    }

    @GetMapping("/{outboundId}")
    public OutboundDetailResponse detail(@PathVariable Long outboundId) {
        return outboundQueryService.detail(outboundId);
    }
}

6. 출고 확정 로직 (OutboundService)

출고 확정은 다음 순서로 처리했다.

  • warehouseId, lines 유효성 검증
  • Outbound 생성(출고번호 생성 포함)
  • OutboundLine 반복 생성
  • Inventory 차감(applyOutbound())
  • StockTx OUTBOUND 기록(수량 delta는 음수)
@Service
@RequiredArgsConstructor
public class OutboundService {

    private final WarehouseRepository warehouseRepository;
    private final ItemRepository itemRepository;

    private final OutboundRepository outboundRepository;
    private final OutboundLineRepository outboundLineRepository;

    private final InventoryRepository inventoryRepository;
    private final StockTxRepository stockTxRepository;

    @Transactional
    public OutboundConfirmResponse confirm(OutboundConfirmRequest req) {

        if (req.warehouseId() == null) {
            throw new IllegalArgumentException("warehouseId는 필수입니다.");
        }
        if (req.lines() == null || req.lines().isEmpty()) {
            throw new IllegalArgumentException("lines는 최소 1개 이상 필요합니다.");
        }

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

        LocalDate outboundDate = parseDateOrToday(req.outboundDate());
        OutboundType type = parseTypeOrDefault(req.outboundType());

        String outboundNo = generateOutboundNo(outboundDate);

        Outbound outbound = outboundRepository.save(
                Outbound.builder()
                        .outboundNo(outboundNo)
                        .warehouse(warehouse)
                        .outboundDate(outboundDate)
                        .outboundType(type)
                        .customerName(req.customerName())
                        .manager(req.manager())
                        .memo(req.memo())
                        .build()
        );

        for (OutboundLineRequest line : req.lines()) {
            if (line.itemId() == null) {
                throw new IllegalArgumentException("line.itemId는 필수입니다.");
            }
            if (line.qty() == null || line.qty() <= 0) {
                throw new IllegalArgumentException("line.qty는 1 이상이어야 합니다.");
            }

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

            outboundLineRepository.save(
                    OutboundLine.builder()
                            .outbound(outbound)
                            .item(item)
                            .qty(line.qty())
                            .memo(line.memo())
                            .build()
            );

            Inventory inventory = inventoryRepository
                    .findByItem_ItemIdAndWarehouse_WarehouseId(item.getItemId(), warehouse.getWarehouseId())
                    .orElseThrow(() -> new IllegalStateException("재고가 존재하지 않습니다. 먼저 입고를 확정하세요."));

            inventory.applyOutbound(line.qty());
            inventoryRepository.save(inventory);

            StockTx tx = StockTx.builder()
                    .item(item)
                    .warehouse(warehouse)
                    .txType(TxType.OUTBOUND)
                    .txDate(LocalDateTime.now())
                    .qtyDelta(-line.qty())
                    .balanceAfter(inventory.getCurrentQty())
                    .createdBy(null)
                    .refType("OUTBOUND")
                    .refId(outbound.getOutboundId())
                    .memo(line.memo() != null && !line.memo().isBlank() ? line.memo() : req.memo())
                    .build();

            stockTxRepository.save(tx);
        }

        return new OutboundConfirmResponse(
                outbound.getOutboundId(),
                outbound.getOutboundNo(),
                req.lines().size()
        );
    }

    private LocalDate parseDateOrToday(String dateStr) {
        if (dateStr == null || dateStr.isBlank()) return LocalDate.now();
        try {
            return LocalDate.parse(dateStr);
        } catch (DateTimeParseException e) {
            throw new IllegalArgumentException("outboundDate 형식이 올바르지 않습니다. (YYYY-MM-DD)");
        }
    }

    private OutboundType parseTypeOrDefault(String typeStr) {
        if (typeStr == null || typeStr.isBlank()) return OutboundType.NORMAL;
        try {
            return OutboundType.valueOf(typeStr);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("outboundType 값이 올바르지 않습니다. (NORMAL|SALES|SAMPLE|LOSS)");
        }
    }

    private String generateOutboundNo(LocalDate outboundDate) {
        String base = outboundDate.toString().replace("-", "");
        String rand = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
        String no = "OB-" + base + "-" + rand;

        if (outboundRepository.existsByOutboundNo(no)) {
            rand = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
            no = "OB-" + base + "-" + rand;
        }
        return no;
    }
}

7. 출고 이력 조회 JPQL (집계 DTO projection)

List 화면에서 필요한 것은 다음이었다.

  • 출고번호, 창고, 출고일, 타입, 고객명/담당자
  • 라인 수(count)
  • 총 출고 수량(sum)

그래서 JPQL에서 DTO 생성자 프로젝션 + group by + count/sum을 사용했다.

@Query("""
    select new com.coreerp.outbound.dto.OutboundRowResponse(
        o.outboundId,
        o.outboundNo,
        w.warehouseId,
        w.warehouseName,
        o.outboundDate,
        cast(o.outboundType as string),
        o.customerName,
        o.manager,
        count(ol.outboundLineId),
        coalesce(sum(ol.qty), 0)
    )
    from Outbound o
    join o.warehouse w
    join OutboundLine ol on ol.outbound.outboundId = o.outboundId
    where (:warehouseId is null or w.warehouseId = :warehouseId)
      and (:fromDate is null or o.outboundDate >= :fromDate)
      and (:toDate is null or o.outboundDate <= :toDate)
      and (:type is null or cast(o.outboundType as string) = :type)
      and (
          :keyword is null or :keyword = '' or
          o.outboundNo like concat('%', :keyword, '%') or
          o.customerName like concat('%', :keyword, '%') or
          o.manager like concat('%', :keyword, '%')
      )
    group by o.outboundId, o.outboundNo, w.warehouseId, w.warehouseName,
             o.outboundDate, o.outboundType, o.customerName, o.manager
""")
Page<OutboundRowResponse> searchRows(...);

8. 트러블슈팅

8-1. 엔티티 필드명 mismatch

초기에 JPQL에서 w.name, l.getItem().getName() 같은 접근을 했는데, 실제 엔티티 필드는 warehouseName, itemName 구조였다. 이때 Hibernate에서 다음 오류가 발생했다.

  • Could not resolve attribute 'name'

해결은 단순하다. JPQL은 엔티티 필드명을 그대로 따라가야 한다.

8-2. DTO 생성자 매칭 실패 (count/sum 타입)

다음 오류가 발생했다.

  • Missing constructor for type 'OutboundRowResponse'

원인은 JPQL의 count()sum()이 반환하는 타입이 기본적으로 Long인데, DTO가 int로 받도록 설계되어 있었기 때문이다. Hibernate 6은 이 타입 불일치를 허용하지 않고 query validation 단계에서 바로 실패한다.

따라서 DTO는 다음처럼 집계 필드를 Long으로 맞춰야 한다.

public record OutboundRowResponse(
        Long outboundId,
        String outboundNo,
        Long warehouseId,
        String warehouseName,
        LocalDate outboundDate,
        String outboundType,
        String customerName,
        String manager,
        Long lineCount,
        Long totalQty
) {}

9. 이번 단계의 의미

이번 단계에서 출고(Outbound) 모듈은 단순 CRUD가 아니라,

  • 재고 감소(Inventory)
  • 원장 기록(StockTx)
  • 집계 기반 이력 조회

까지 포함한 “ERP다운 흐름”으로 완성되었다. 특히 조회를 DTO projection으로 고정하면서, 프론트 연동 시 화면 요구사항이 늘어도 도메인 엔티티가 더럽혀지지 않는 구조를 유지할 수 있게 됐다.


10. 다음 단계 계획

  • 출고 등록 프론트 폼 구성
  • 출고 이력 테이블 연동 (검색/페이징)
  • 출고 상세 모달(라인 표시)
  • 출고 타입/키워드/기간 필터 UI 정리

11. 마무리 회고

출고 자체는 “등록 + 재고 차감”으로 끝날 것 같았는데, 실제로는 이력 조회에서 집계 DTO projection을 설계하면서 Hibernate 6에서 타입 정합성이 얼마나 중요하게 작동하는지를 체감했다.

이런 디테일이 쌓여야 “실무에서 유지보수 가능한 백엔드”가 된다는 느낌이 들었고, CoreERP는 이제 구매/입고/출고 축이 모두 연결되는 단계로 들어왔다.

profile
Develop

0개의 댓글