
CoreERP에서 Master Data는 모든 트랜잭션(발주/입고/출고/재고)의 출발점이다.
이번 단계는 단순히 “품목 등록 화면을 만든다”가 아니라, 실무형 기준으로 식별자 정책과 무결성 기준을 확정하고 프론트/백엔드를 동일한 규칙으로 묶는 작업이었다.
결론적으로 정책은 다음처럼 확정했다.
품번은 카테고리별 체계가 회사마다 다르고, 현재 프로젝트에서도 카테고리 정책이 확정되지 않은 상태였다.
이 상태에서 품번 자동생성을 고정하면 추후 기준이 바뀌었을 때 데이터 정리/마이그레이션 비용이 커진다.
따라서 이번 단계에서는 “수동 입력 + 백엔드 중복 검증”을 기준으로 두고, 카테고리 정책 확정 이후 자동 생성 도입 여부를 판단하기로 했다.
바코드는 사람이 직접 생성할 이유가 거의 없고, 유일성이 중요하다.
또한 스캐너/외부 연동 가능성까지 고려하면 “자동 생성 + 중복 방지”가 가장 안정적이다.
결론적으로 DocumentNoGenerator를 통해 바코드 자동 생성 정책을 확정했다.
@Transactional public ItemResponse create(CreateItemRequest req) {
if (req.itemCode() == null || req.itemCode().isBlank()) {
throw new IllegalArgumentException("품번은 필수입니다.");
}
if (req.itemName() == null || req.itemName().isBlank()) {
throw new IllegalArgumentException("품명은 필수입니다.");
}
if (req.manufacturer() == null || req.manufacturer().isBlank()) {
throw new IllegalArgumentException("제조사는 필수입니다.");
}
String itemCode = req.itemCode().trim();
if (itemRepository.existsByItemCode(itemCode)) {
throw new IllegalArgumentException("이미 존재하는 품번입니다.");
}
String barcode = documentNoGenerator.next(
"BC",
LocalDate.now(),
itemRepository::existsByBarcode
);
Item saved = itemRepository.save(
Item.builder()
.itemCode(itemCode)
.barcode(barcode)
.itemName(req.itemName().trim())
.itemType(normalizeExact(req.itemType()))
.category(normalizeExact(req.category()))
.spec(normalizeExact(req.spec()))
.unit(defaultIfBlank(normalizeExact(req.unit()), "EA"))
.manufacturer(req.manufacturer().trim())
.memo(normalizeExact(req.memo()))
.build()
);
return toResponse(saved);
@Component
public class DocumentNoGenerator {
public String next(String prefix, LocalDate date, Predicate<String> existsFn) {
return next(prefix, date, existsFn, 50);
}
public String next(String prefix, LocalDate date, Predicate<String> existsFn, int maxRetry) {
Objects.requireNonNull(prefix, "prefix");
Objects.requireNonNull(date, "date");
Objects.requireNonNull(existsFn, "existsFn");
if (maxRetry <= 0) {
throw new IllegalArgumentException("maxRetry must be positive");
}
String base = date.toString().replace("-", "");
for (int i = 0; i < maxRetry; i++) {
String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
String no = prefix + "-" + base + "-" + rand;
if (!existsFn.test(no)) return no;
}
throw new IllegalStateException(prefix + " 번호 생성에 실패했습니다. 다시 시도해주세요.");
}
}
<div className="field">
<div className="filter-label"> 바코드 (Barcode)
<span style={{ opacity: 0.7 }}>(auto)</span>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<div style={{ position: "relative", flex: 1 }}>
<input className="input" value="" placeholder="바코드는 등록 시 자동 생성됩니다."
autoComplete="off" disabled style={{ backgroundColor: "#f3f4f6", color: "#6b7280", cursor: "not-allowed" }} />
</div>
</div>
</div>
const res = await fetch("/api/items",
{ method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), });
if (!res.ok) {
const text = await res.text().catch(() => "");
alert(품목 등록 실패: ${res.status}\n${text});
return;
}
const data = (await res.json()) as Partial<ItemResponse>;
const createdCode = toText(data.itemCode) || payload.itemCode;
const createdBarcode = toText(data.barcode) || "-";
alert(품목 등록 완료\nItem Code: ${createdCode}\nBarcode(자동생성): ${createdBarcode});
원인: 저장 로직 문제가 아니라, 등록 시 memo를 입력하지 않아 DB에 null로 저장된 상태였다.
해결 결과: 응답 JSON을 직접 확인해 null 여부를 확인했고, 이후에는 입력 여부를 먼저 체크하는 습관으로 전환했다.
원인: 프론트에서는 manufacturer를 비워도 통과했고, 백엔드에서도 manufacturer 필수 검증이 없었다.
해결 결과: ItemService.create에서 manufacturer 필수 검증을 추가했고, 프론트 validate도 동일하게 맞춰 무결성 기준을 통일했다.
원인: 카테고리 정책이 확정되지 않은 상태에서 품번 자동 생성 도입 여부를 결정하지 못해 설계가 흔들렸다.
해결 결과: 이번 단계에서는 “품번 수동 입력”으로 고정하고, “바코드만 자동 생성”으로 분리해 안정적으로 마무리했다.
이번 작업은 단순 CRUD가 아니라, Master Data의 기준을 고정한 단계였다.
Item은 모든 재고 트랜잭션이 의존하는 엔티티이기 때문에, 여기서 정책이 흔들리면 이후 발주/입고/출고 흐름 전체가 흔들린다.
이번 단계에서 그 기반을 고정했다는 점이 가장 크다.