CoreERP Vendor 마스터 API 작업 기록

최병현·2026년 2월 21일

coreerp project

목록 보기
9/44
post-thumbnail

이번 단계는 CoreERP에서 “업체(거래처, Vendor)”를 마스터 데이터로 확정하고, 프론트엔드(React)의 Vendor List 화면(모달 등록 방식)과 연결 가능한 백엔드 API 기준을 고정한 작업이다.

ERP에서 입고(Inbound), 발주(Purchase Order), 정산/거래 이력 등 대부분의 업무 데이터는 거래처(Vendor)를 참조하며, 결국 “거래처 마스터가 안정적으로 쌓이는 구조”가 선행되어야 업무 트랜잭션이 흔들리지 않는다.

특히 CoreERP 프론트는 “별도의 등록 페이지”가 아니라 “리스트 화면에서 모달로 등록”하는 UX를 채택했기 때문에, 백엔드는 화면 구조를 신경 쓰기보다 REST API가 일관되고 예측 가능하게 동작하도록 기준을 확정하는 것이 핵심이었다.


2. 작업 범위

  • Vendor 도메인(Entity) 생성
  • VendorRepository 구성 (vendorCode 중복 체크)
  • VendorService 구성 (필수값/중복 검증 + 기본값 처리)
  • VendorController 구성 (등록/목록/상세)
  • Postman으로 등록/조회 테스트

3. 핵심 목적

  • 입고/발주 등 트랜잭션 데이터가 참조할 “거래처 기준점”을 먼저 고정
  • 모달 기반 등록 UX에서도 흔들리지 않는 API 계약(Contract) 확립
  • 중복 코드, 필수값 누락 같은 “마스터 무결성”을 백엔드에서 방어

4. 설계 기준/의도

4-1. Vendor Code는 시스템 식별자이므로 UNIQUE

거래처명(vendorName)은 변경 가능하고 중복 가능하다. 반면 vendorCode는 시스템에서 거래처를 식별하는 기준 값이기 때문에 UNIQUE 제약을 둔다.

4-2. Biz No(사업자번호)는 UNIQUE를 강제하지 않고 인덱스만

사업자번호는 조직/지점/법인 구조에 따라 “중복 가능” 케이스가 존재할 수 있다. 따라서 CoreERP는 bizNo에 UNIQUE를 걸지 않고, 조회 성능을 위한 인덱스만 유지하는 정책으로 확정했다.

4-3. Status 기본값은 ACTIVE로 통일

Item과 동일하게 Vendor도 기본 상태값을 ACTIVE로 맞추어 “마스터 상태 관리” 기준을 일관되게 유지한다.


5. 구조 및 데이터 흐름

5-1. Frontend(React) → Backend(Spring Boot) → DB(MariaDB)

  • Frontend: VendorsList 화면에서 “업체 등록” 버튼 클릭
  • Modal 입력 후 저장 → POST /api/vendors 요청
  • Backend: 유효성 검증 + 저장
  • DB: vendor 테이블 insert
  • Frontend: 성공 시 GET /api/vendors로 목록 재조회

6. 핵심 코드 정리

6-1. Vendor Entity

@Entity
@Table(
        name = "vendor",
        uniqueConstraints = {
                @UniqueConstraint(name = "uk_vendor_code", columnNames = "vendor_code")
        },
        indexes = {
                @Index(name = "idx_vendor_biz_no", columnList = "biz_no")
        }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Vendor {

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

    @Column(nullable = false, length = 50)
    private String vendorCode;

    @Column(length = 30)
    private String bizNo;

    @Column(nullable = false, length = 200)
    private String vendorName;

    private String ceoName;
    private String phone;
    private String email;
    private String address;

    @Column(nullable = false, length = 30)
    private String status;

    @Lob
    private String memo;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        LocalDateTime now = LocalDateTime.now();
        this.createdAt = now;
        this.updatedAt = now;

        if (this.status == null || this.status.isBlank()) {
            this.status = "ACTIVE";
        }
    }

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

6-2. Repository (중복 체크)

public interface VendorRepository extends JpaRepository<Vendor, Long> {
    boolean existsByVendorCode(String vendorCode);
}

6-3. Service (필수값 + 중복 검증)

    @Transactional
    public VendorResponse create(CreateVendorRequest req) {
        if (req.vendorCode() == null || req.vendorCode().isBlank()) {
            throw new IllegalArgumentException("vendorCode는 필수입니다.");
        }
        if (req.vendorName() == null || req.vendorName().isBlank()) {
            throw new IllegalArgumentException("vendorName은 필수입니다.");
        }
        if (vendorRepository.existsByVendorCode(req.vendorCode())) {
            throw new IllegalArgumentException("vendorCode가 이미 존재합니다.");
        }

        Vendor saved = vendorRepository.save(
                Vendor.builder()
                        .vendorCode(req.vendorCode())
                        .bizNo(req.bizNo())
                        .vendorName(req.vendorName())
                        .ceoName(req.ceoName())
                        .phone(req.phone())
                        .email(req.email())
                        .address(req.address())
                        .status((req.status() == null || req.status().isBlank()) ? "ACTIVE" : req.status())
                        .memo(req.memo())
                        .build()
        );

        return toResponse(saved);
    }

6-4. Controller (등록/목록/상세)

@RestController
@RequestMapping("/api/vendors")
@RequiredArgsConstructor
public class VendorController {

    private final VendorService vendorService;

    @PostMapping
    public VendorResponse create(@RequestBody CreateVendorRequest req) {
        return vendorService.create(req);
    }

    @GetMapping
    public List<VendorResponse> list() {
        return vendorService.list();
    }

    @GetMapping("/{id}")
    public VendorResponse get(@PathVariable Long id) {
        return vendorService.get(id);
    }
}

7. Postman 테스트

7-1. Vendor 등록 (POST /api/vendors)

{ "vendorCode": "VND-SEO-001", 
 "vendorName": "서울전자상사", 
 "ceoName": "홍길동", 
 "phone": "02-123-4567", 
 "email": "sales@seoul-elec.co.kr", 
 "bizNo": "123-45-67890", 
 "address": "서울시 강남구 테헤란로 123", 
 "status": "ACTIVE", 
 "memo": "초기 거래처" } 

7-2. Vendor 목록 조회 (GET /api/vendors)

프론트의 Vendor List 화면은 이 목록 API를 기반으로 테이블을 렌더링하고, 모달 저장 성공 시 재조회로 최신 데이터를 즉시 반영할 수 있다.


8. 트러블슈팅 및 기준 보정 경험

  • Vendor 마스터가 없으면 Inbound/PO 등 업무 트랜잭션 단계에서 테스트가 계속 막히므로, Day2를 API 기준으로 먼저 완성하는 것이 효율적이었다.
  • 모달 UX는 프론트의 구현 방식일 뿐이며, 백엔드는 “일관된 Contract”를 제공하는 것이 핵심이라는 기준을 다시 확인했다.
  • bizNo는 UNIQUE로 고정하지 않고 인덱스만 둬서, 현실적인 데이터 케이스(지점/법인) 확장성을 확보했다.

9. 이번 단계의 의미 정리

이번 단계는 CoreERP의 “거래처 마스터”를 확정함으로써, 향후 입고/발주/정산 흐름이 참조할 기준점을 고정한 작업이다.

또한 프론트(모달 등록 방식)와 백엔드(REST API)의 역할을 분리하여, UI가 바뀌더라도 백엔드 계약이 흔들리지 않는 구조를 유지할 수 있게 되었다.


10. 다음 단계 계획

  • Inbound “확정” 트랜잭션 구현 (Inbound/InboundLine 저장 + inventory 증가 + stock_tx 기록)
  • 입고 확정이 “재고 증가의 유일한 경로”가 되도록 기준 고정
  • StockTx 이력 조회 API 제공 (감사/추적 가능성 강화)

11. 마무리

CoreERP는 현재 마스터(Item/Warehouse/Vendor)를 API 기준으로 안정화했고, 이제부터는 실제 업무 트랜잭션(입고/출고/발주/이동)을 연결하는 단계로 넘어간다.

다음 단계에서는 Inbound 확정을 통해 “재고 증가의 표준 흐름”을 완성하고, Inventory(스냅샷) + StockTx(원장) 기반의 ERP 재고 엔진을 본격적으로 확장할 예정이다.

profile
Develop

0개의 댓글