CoreERP 발주 등록 / 이력 API 프론트엔드 연동 기록

최병현·2026년 2월 22일

coreerp project

목록 보기
16/44
post-thumbnail

이번 단계는 CoreERP가 단순한 프론트 목업 상태를 벗어나, 실제 Spring Boot 백엔드 API와 연결된 “동작하는 발주(Purchase Order) 모듈”로 전환되는 구간이었다.

이전까지는 더미 데이터로 UI 시나리오(필터/정렬/상태 변경)를 검증하는 수준이었다면, 이번 작업은 발주 생성(Create)부터 이력 조회, 상세 조회, 취소까지 전 흐름을 서버 중심으로 확정했다는 점에서 의미가 크다.


1. 이번 단계의 핵심 목적

  • 더미 데이터 완전 제거
  • 발주 등록(Create) API 연동
  • 발주 이력 조회(Pageable + 검색 + 정렬) API 연동
  • 발주 취소(cancel) API 실제 호출 구현
  • 상태 변경 책임을 프론트 → 백엔드로 이동

즉, 화면이 기준이 되는 구조가 아니라 “서버가 기준이 되는 구조”로 바꾸는 단계였다.


2. 연동된 API 목록

이번 단계에서 프론트가 직접 호출하는 API는 다음과 같다.

  • POST /api/purchase-orders
  • GET /api/purchase-orders?page=0&size=20
  • GET /api/purchase-orders/{poId}
  • POST /api/purchase-orders/{poId}/cancel
  • GET /api/vendors/search?keyword=...&limit=8
  • GET /api/items/search?keyword=...&limit=8

초기에는 /api/vendors, /api/items 목록을 통째로 내려받아 필터링하는 방식도 가능하지만, 실무 기준으로는 검색 API로 “필요할 때만 좁혀서 가져오는 방식”이 더 자연스럽다.


3. PurchaseOrderCreate - 더미 제거 & 실 API 연동

발주 등록 화면은 “거래처 검색 → 품목 검색 → 수량/단가 입력 → 발주 등록” 흐름으로 동작한다. 더미 배열(useMemo) 기반 검색 대신, 입력 값 기반으로 API를 호출하여 결과를 dropdown으로 뿌린다.

3-1) 거래처 검색 (Vendor search)

useEffect(() => {
  const kw = vendorKw.trim();
  if (!vendorOpen || kw.length < 1) {
    setVendorResults([]);
    return;
  }

  const limit = 8;
  const controller = new AbortController();

  const t = window.setTimeout(async () => {
    setVendorLoading(true);
    try {
      const url = `/api/vendors/search?keyword=${encodeURIComponent(kw)}&limit=${encodeURIComponent(String(limit))}`;
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) {
        setVendorResults([]);
        return;
      }
      const list = (await res.json()) as Vendor[];
      setVendorResults(Array.isArray(list) ? list.slice(0, limit) : []);
    } catch {
      setVendorResults([]);
    } finally {
      setVendorLoading(false);
    }
  }, 200);

  return () => {
    window.clearTimeout(t);
    controller.abort();
  };
}, [vendorKw, vendorOpen]);

디바운스(200ms) + AbortController 조합으로 불필요한 요청을 줄이고, 입력 도중 빠르게 값이 바뀌어도 이전 요청을 취소하도록 구성했다.

3-2) 품목 검색 (Item search)

useEffect(() => {
  const kw = itemKw.trim();
  if (!itemOpen || kw.length < 1) {
    setItemResults([]);
    return;
  }

  const limit = 8;
  const controller = new AbortController();

  const t = window.setTimeout(async () => {
    setItemLoading(true);
    try {
      const url = `/api/items/search?keyword=${encodeURIComponent(kw)}&limit=${encodeURIComponent(String(limit))}`;
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) {
        setItemResults([]);
        return;
      }
      const list = (await res.json()) as Item[];
      setItemResults(Array.isArray(list) ? list.slice(0, limit) : []);
    } catch {
      setItemResults([]);
    } finally {
      setItemLoading(false);
    }
  }, 200);

  return () => {
    window.clearTimeout(t);
    controller.abort();
  };
}, [itemKw, itemOpen]);

거래처 검색과 동일한 패턴으로 품목 검색도 구성했다.

3-3) 발주 등록(POST) payload

const payload = {
  vendorId: form.vendorId,
  expectedInboundDate: form.expectedInboundDate ? form.expectedInboundDate : null,
  manager: form.manager.trim(),
  memo: form.memo.trim() || null,
  lines: [
    {
      itemId: form.itemId,
      qtyOrdered: Number(form.qty),
      unitPrice: form.unitPrice.trim() ? Number(form.unitPrice) : null,
    },
  ],
};

const res = await fetch("/api/purchase-orders", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});

프론트는 vendorCode/itemCode 기반이 아니라, 백엔드 엔티티 관계가 유지되는 vendorId/itemId 기반으로 발주를 생성한다. 이 설계는 이후 입고(Inbound)에서 PO 라인을 직접 참조할 때도 안정적이다.

3-4) 내부 PK(poId) 노출 제거

등록 성공 시 poId를 사용자에게 노출하지 않도록 정리했다. ERP에서 내부 PK는 사용자에게 의미가 없고, 노출할수록 불필요한 공격 표면만 늘어난다.

await res.json(); 
alert("발주 등록 완료");

4. PurchaseOrderHistory - 이력 조회 API 전환

기존 PurchaseOrderHistory 페이지는 더미 데이터를 기반으로 필터/정렬/모달(상세, 메모, 취소)을 구성해두었고, 이번 단계에서 목록/상세/취소를 서버 API 호출 방식으로 전환했다.

4-1) 목록 조회 (Pageable)

GET /api/purchase-orders?page=0&size=20

서버 응답이 Page 구조로 내려오기 때문에, 프론트는 content 배열과 pagination 정보를 분리해서 렌더링해야 한다.

4-2) 상세 조회

GET /api/purchase-orders/{poId}

목록 row 클릭 → poId로 상세를 조회해 모달에 표시하는 구조로 정리했다. 더미 상태에서는 lines를 이미 들고 있었지만, 서버 기반으로 바뀌면서 “상세는 상세 API로 가져오는 구조”가 확정되었다.


5. 발주 취소(cancel) - 서버 기준 상태 통제

발주 취소는 UI가 마음대로 status를 바꾸는 기능이 아니라, 서버 도메인 규칙을 통과해야만 가능한 “상태 전이”다.

5-1) cancel API 호출

POST /api/purchase-orders/{poId}/cancel
const res = await fetch(`/api/purchase-orders/${cancelTarget.poId}/cancel`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    reason: cancelReason.trim(),
    cancelledBy: 1001,
  }),
});

cancelledBy는 현재 로그인/권한 시스템이 없기 때문에 임시로 고정 값(1001)로 두었다. 실무에서는 로그인 사용자 정보(세션/JWT)를 통해 서버가 cancelledBy를 결정하도록 바뀌는 지점이다.

5-2) 도메인 내부 상태 검증

PurchaseOrder 엔티티 내부 cancel() 메서드에서 상태 전이 규칙을 강제한다.

public void cancel(String reason, Long cancelledBy) {
    if (this.status == PurchaseOrderStatus.CANCELLED) return;

    if (this.status != PurchaseOrderStatus.OPEN) {
        throw new IllegalStateException("진행중(OPEN) 상태만 취소할 수 있습니다.");
    }

    if (reason == null || reason.isBlank()) {
        throw new IllegalArgumentException("취소 사유(reason)는 필수입니다.");
    }

    if (cancelledBy == null) {
        throw new IllegalArgumentException("취소자(cancelledBy)는 필수입니다.");
    }

    this.status = PurchaseOrderStatus.CANCELLED;
    this.cancelReason = reason.trim();
    this.cancelledAt = LocalDateTime.now();
    this.cancelledBy = cancelledBy;
}

이 방식은 “프론트가 상태를 바꾸는 구조”가 아니라, “도메인이 스스로 상태 전이를 판단하는 구조”라는 점에서 ERP 설계에 더 적합하다.


6. 409 Conflict 경험

OPEN이 아닌 상태에서 취소를 시도하면 409 Conflict가 발생한다.

이는 단순 에러가 아니라 “비즈니스 규칙 위반”에 대한 정상 응답으로 볼 수 있다.

  • 400 → 잘못된 요청(입력값 오류)
  • 409 → 비즈니스 상태 충돌
  • 500 → 서버 내부 오류

ERP 시스템에서는 상태 전이 규칙이 중요하기 때문에, 409를 명확히 반환하고 프론트가 이를 사용자에게 설명하는 흐름이 필요하다.


7. 트러블슈팅 기록

① 서버 미기동 (ECONNREFUSED)

프론트에서 500처럼 보였던 오류의 실제 원인은 Spring Boot 서버 미기동 상태였다.

Postman 호출 결과 connect ECONNREFUSED 127.0.0.1:8080 에러를 확인했고, 이를 통해 네트워크 레벨 오류와 애플리케이션 오류를 구분하는 감각을 얻었다.

② GlobalExceptionHandler 정리

IllegalArgumentException은 400, IllegalStateException은 409로 매핑하여 프론트가 “왜 실패했는지”를 상태 코드로 구분할 수 있도록 정리했다.

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleBadRequest(IllegalArgumentException e) {
    return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorResponse(400, e.getMessage(), LocalDateTime.now()));
}

@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<?> handleConflict(IllegalStateException e) {
    return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(new ErrorResponse(409, e.getMessage(), LocalDateTime.now()));
}

③ Projection + Pageable 정렬 검증

PurchaseOrderRowView Projection + group by + Pageable 조합에서 정렬 키가 예상치 못한 SQL을 생성할 수 있기 때문에 허용 가능한 정렬 필드를 제한하는 방식으로 안정화했다.


8. 설계 관점에서 이번 단계의 의미

  • Command(Service) / Query(QueryService) 분리 구조 유지
  • DTO Projection 기반 조회 최적화
  • 상태 Enum 기반 프론트-백엔드 매핑 확정
  • 도메인 내부 상태 전이 검증 확립
  • UI 중심 → 서버 중심 상태 통제 전환 완료

발주 모듈은 이제 단순 화면이 아니라 “데이터 흐름과 규칙을 가진 모듈”이 되었다.


9. 현재 발주 모듈 상태 정리

  • 발주 생성(Create) 완료
  • 발주 이력 조회(Page + 검색) 완료
  • 상세 조회 완료
  • 취소 로직 완료 (OPEN 상태만 허용)
  • 프론트 더미 데이터 완전 제거
  • 실 API 기반 전환 완료

발주 기능 단독 기준으로는 1차 완성 단계에 도달했다.


10. 다음 단계 계획

발주 모듈은 단독으로 끝나는 기능이 아니다. 다음 단계는 입고(Inbound)와 연결하여 “ERP 흐름”을 완성하는 것이다.

  • 입고 시 qtyReceived 증가
  • 전량 입고 시 상태 RECEIVED 변경
  • 부분 입고 시 PARTIAL_RECEIVED 처리
  • 재고 증가 로직 연결
  • 예정일 초과 시 지연(Overdue) 표시

expectedInboundDate는 “자동 입고 처리 기준”이 아니라 지연 경고/관리 기준이 된다. 실제 상태는 입고 등록(Receive) 이벤트에서만 확정된다.


11. 마무리

이번 작업은 기능 추가라기보다, CoreERP가 “진짜 시스템”으로 전환된 시점이었다.

UI 중심 사고에서 도메인 중심 설계로 넘어오며, 상태 관리의 책임을 프론트에서 서버로 이동시켰다.

profile
Develop

0개의 댓글