Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

부품 유통 관리 ERP 개발기 (1) — 6개 모듈 프로토타입과 파일 하나로 끝낸 배포 본문

외주 개발일지

부품 유통 관리 ERP 개발기 (1) — 6개 모듈 프로토타입과 파일 하나로 끝낸 배포

HM소프트 2026. 6. 15. 00:07
반응형

부품 유통 관리 ERP 프로토타입을 React와 Express로 만든 기록. 회계·인사·영업·구매·재고·생산 6개 모듈 경계를 API prefix로 강제하고, 집계 전용 대시보드 엔드포인트, 인메모리 데이터 계층과 prefix ID, 다국가 통화 분리, 재고 알림 재계산과 작업지시 상태 머신, YAML 한 장 배포와 CORS·SPA rewrite까지 다룹니다.

프로토타입에 ERP를 통째로 욱여넣기

여러 나라에서 부품을 수입해 국내에 공급하는 유통 업무를 가정하고, 그 업무 흐름을 한 화면에서 다루는 웹 ERP 프로토타입을 만들었다. 회계·인사·영업·구매·재고·생산 — 보통 ERP라고 부르는 영역 6개를 모듈로 나눠 한 시스템에 담았다. 백엔드는 Express, 프론트엔드는 React로 짰고, 마지막에 클라우드 PaaS에 올려 실제 URL로 접근 가능하게 했다.

처음부터 분명히 해 둔 게 하나 있다. 목표는 "완성된 제품"이 아니라 업무 흐름이 눈에 보이는 프로토타입이라는 점이다. 의뢰 기업과 ERP의 범위를 논의하려면, 추상적인 기획서보다 "일단 만져 볼 수 있는 화면"이 훨씬 빠르게 합의를 끌어낸다. 회계 전표를 실제로 끊어 보고, 발주를 등록해 대시보드 숫자가 움직이는 걸 같이 보면, "우리한테 필요한 건 이거고 저건 빼자"는 대화가 그 자리에서 끝난다.

그래서 이 프로젝트의 모든 설계 결정은 단 하나의 기준을 따랐다. "빨리, 그러나 구조는 잃지 않게." 프로토타입이라고 막 짜면 나중에 진짜 제품으로 키울 때 통째로 버려야 하고, 너무 제대로 짜면 프로토타입의 미덕인 속도를 잃는다. 이 글은 그 균형을 어디서 어떻게 잡았는지에 대한 기록이다. 커밋은 단 두 개 — Initial commit으로 6개 모듈 전체를, 그다음 Add Render deployment configuration으로 배포까지. 두 번의 커밋 안에 ERP 한 채가 들어 있다.

한눈에 보는 구조 — 모듈 경계를 URL에 박다

ERP의 가장 큰 함정은 모든 게 모든 것과 얽힌다는 점이다. 회계는 영업의 매출을 알아야 하고, 생산은 재고를 깎아야 하고, 구매는 또 재고를 채운다. 이 의존을 방치하면 코드 한 곳을 고칠 때마다 다른 다섯 곳이 무너지는, 전형적인 ERP 스파게티가 된다.

프로토타입 단계에서 이걸 막는 가장 싼 방법은 경계를 물리적으로 강제하는 것이다. 그래서 모듈 경계를 추상적인 약속이 아니라 URL과 파일 구조에 그대로 새겼다.

규칙은 단순하다. 백엔드의 모든 엔드포인트는 /api/<모듈>/<리소스> 꼴을 따른다. /api/accounting/transactions, /api/hr/employees, /api/purchasing/suppliers 같은 식이다. 프론트엔드도 대칭을 맞춰서, 모듈마다 페이지 컴포넌트 하나(Accounting.js, HR.js, Inventory.js …)와 라우트 하나만 가진다. 사이드바 메뉴 7개가 곧 라우트 7개고, 그게 곧 API prefix 6개(+대시보드)와 일대일로 대응한다.

이렇게 해 두면 "구매 모듈을 손본다"가 곧 "Purchasing.js/api/purchasing/*만 본다"로 좁혀진다. 모듈끼리 직접 함수를 호출하는 일이 구조적으로 생기지 않는다. 서로를 모르기 때문이다.

대시보드만 예외 — 집계는 한곳에서

그런데 단 하나, 모든 모듈을 가로질러야 하는 화면이 있다. 대시보드다. 경영자는 회계·인사·영업·재고·생산·구매의 핵심 지표를 한 번에 보고 싶어 한다. 여기서 "모듈은 서로 모른다"는 원칙과 "모든 걸 한 화면에 모은다"는 요구가 정면으로 부딪힌다.

해결책은 모듈끼리 엮는 게 아니라, 집계 전용 엔드포인트를 따로 하나 두는 것이었다. 각 모듈의 데이터를 읽어 화면에 필요한 형태로만 추려 내보내는 /api/dashboard다.

핵심은 집계 로직을 모듈 안이 아니라 이 한 곳에 모았다는 점이다. 이렇게 하면 의존의 방향이 한쪽으로만 흐른다. 대시보드는 6개 모듈을 알지만, 6개 모듈은 대시보드를 모른다. 단방향 의존은 추적이 쉽다. "이 숫자 어디서 오지?"라는 질문의 답이 항상 이 함수 하나로 수렴한다.

프론트엔드 대시보드는 이 집계 엔드포인트와 수입 통계를 병렬로 한 번에 불러와 KPI 카드 6장과 차트들을 조립한다. 진행중 작업 건수나 입고완료가 아닌 발주 건수처럼, 화면에 필요한 "가공된 숫자"를 서버가 미리 계산해 주는 덕분에 프론트는 받은 값을 그대로 그리기만 하면 된다.

DB 없이 시작하기 — 인메모리 데이터 계층

프로토타입에서 가장 시간을 잡아먹는 게 DB 스키마 설계다. 테이블을 정규화하고, 마이그레이션을 쓰고, 커넥션을 관리하다 보면 정작 보여 주고 싶은 업무 흐름은 손도 못 댄다. 그래서 데이터 계층 전체를 자바스크립트 모듈 하나(data.js) 로 두기로 했다. 서버가 이걸 require로 메모리에 통째로 들고, 모든 CRUD가 이 메모리 객체의 배열을 직접 조작한다.

인메모리라 서버를 재시작하면 그동안의 변경분이 전부 날아간다. 보통은 단점이지만, 시연·검증 용도에서는 오히려 장점이 됐다. 누가 데모 중에 데이터를 엉망으로 만들어도 재시작 한 번이면 항상 같은 초기 상태로 돌아온다. "깨끗한 상태에서 다시 보여 주기"가 공짜다.

다만 한 가지는 진짜처럼 만들어 뒀다. ID 체계다. 나중에 이 인메모리 계층을 진짜 DB로 갈아탈 때, ID 발급 규칙이 엉성하면 데이터 마이그레이션이 지옥이 된다. 그래서 모듈마다 prefix를 달리한 일련번호를 발급하는 헬퍼를 하나 만들었다.

전표는 T, 직원은 E, 고객은 C, 주문은 O, 공급사는 S, 발주는 PO, 재고 품목은 I, 작업지시는 WO … prefix만 봐도 어느 모듈 데이터인지 즉시 알 수 있다. T001, E012, PO034 같은 ID가 화면 어디에 떠 있어도 출신이 명확하다. 디버깅할 때 "이 PO005가 뭐였더라"를 한참 뒤지는 일이 없다.

이 함수에는 작은 방어 장치도 들어 있다. parseIntisNaN을 걸러 내고, 배열이 비어 있으면 max를 0으로 둔다. 그래서 데이터가 하나도 없는 모듈에 첫 항목을 넣어도 T001부터 깔끔하게 시작한다. 사소해 보이지만, 빈 배열에서 Math.max(...[])-Infinity를 뱉어 ID가 망가지는 흔한 함정을 여기서 막는다.

다국가 수입 — 통화를 데이터로 분리하기

이 ERP의 도메인 특성은 여러 나라에서 부품을 들여온다는 점이다. 6개국에서 수입하는 시나리오라, 발주 금액이 나라마다 다른 통화로 매겨진다. 통화·환율을 발주 데이터에 일일이 박아 넣으면, 환율이 바뀔 때마다 모든 발주를 손봐야 한다. 그래서 국가·통화·환율을 공통 데이터(countries)로 한 번만 정의하고, 발주는 통화 코드만 들고 다니게 했다.

화면 쪽에서 빛을 발한 건 자동 채움이다. 발주 등록 시 공급사를 고르면, 그 공급사의 국가 코드로 countries에서 통화를 찾아 통화 필드를 자동으로 채운다. 사용자가 "이 공급사는 위안화였나 엔화였나"를 고민할 필요가 없다.

sup?.countrycountry?.currency의 옵셔널 체이닝이 작은 안전장치다. 공급사를 아직 안 골랐거나 매칭되는 국가가 없어도 빈 문자열로 떨어질 뿐, 화면이 죽지 않는다. 통화 필드 자체는 readOnly로 둬서 사용자가 임의로 못 바꾸게 했다 — 통화의 출처는 어디까지나 공급사의 국가여야 하기 때문이다. 이렇게 데이터의 출처를 하나로 묶는 작은 결정들이 모이면, 나중에 "발주마다 통화가 제각각"인 데이터 오염을 원천에서 막을 수 있다.

한 화면을 위해 네 번 부르기 — 병렬 로딩과 클라이언트 처리

구매 화면 하나를 그리려면 공급사·발주·수입 통계·국가 목록 네 가지 데이터가 모두 필요하다. 이걸 순차로 부르면 네 번의 왕복이 줄줄이 이어져 화면이 느려진다. 그래서 Promise.all네 요청을 동시에 쏘고, 다 모이면 한꺼번에 상태에 반영한다.

CRUD가 끝나면 이 fetchData()를 다시 호출하는 식으로, 화면 상태를 항상 서버 기준으로 새로 고친다. 낙관적 업데이트(optimistic update) 같은 정교한 기법은 일부러 안 썼다. 프로토타입에서는 "등록하면 곧바로 다시 불러와 그린다"가 가장 단순하고 버그가 없다. 데이터 양이 적으니 한 번 더 부르는 비용이 체감되지 않는다.

같은 철학이 검색과 페이지네이션에도 적용됐다. 서버에 쿼리 파라미터를 붙여 필터링·페이징을 시키는 대신, 전체를 받아 와 클라이언트에서 자른다.

데이터가 수십 건 규모인 프로토타입에서는 이게 정답이다. 서버 페이징 API를 설계하는 비용보다, 클라이언트에서 filterslice 하는 코드 몇 줄이 훨씬 싸다. 물론 수만 건이 되면 서버로 옮겨야 하지만, 그건 "그때 가서" 할 일이다. 프로토타입의 미덕은 미래의 문제를 미리 풀지 않는 데 있다.

재고 알림은 저장하지 않는다 — 파생 데이터 다루기

재고 모듈에서 한 가지 의식적으로 지킨 원칙이 있다. "계산할 수 있는 값은 저장하지 않는다." 재고 부족 알림이 대표적이다. 알림은 "현재 수량이 최소 수량 근처로 떨어진 품목"이라는, 품목 데이터에서 파생되는 정보다. 이걸 별도로 저장해 두면 품목 수량이 바뀔 때마다 알림도 같이 갱신해 줘야 하고, 둘이 어긋나는 순간 화면이 거짓말을 한다.

그래서 알림은 입고·출고·수정·삭제 등 재고에 변화가 생길 때마다 품목 배열에서 다시 계산한다.

function updateInventoryAlerts() {
  data.inventory.alerts = data.inventory.items
    .filter(item => item.qty <= item.minStock * 1.2)
    .map(item => ({
      itemId: item.id,
      type: '재고부족',
      message: `${item.name} 재고가 최소수량에 근접`,
      level: item.qty <= item.minStock ? 'critical' : 'warning'
    }));
}

minStock * 1.2라는 임계값에 의도가 담겨 있다. 최소 수량에 정확히 도달했을 때 알리면 이미 늦다. 리드타임을 고려하면 발주를 넣을 시간이 부족하다. 그래서 최소 수량의 1.2배, 즉 20% 여유가 남았을 때부터 warning을 띄우고, 실제로 최소 수량 이하로 떨어지면 critical로 등급을 올린다. 운영자가 "곧 부족" 단계에서 미리 움직일 수 있게 하는 안전 마진이다.

출고 처리에도 같은 결의 방어가 있다. 재고보다 많은 양을 출고하려 하면 막는다.

app.post('/api/inventory/items/:id/ship', (req, res) => {
  const item = data.inventory.items.find(i => i.id === req.params.id);
  if (!item) return res.status(404).json({ error: 'Not found' });
  if (item.qty < Number(req.body.qty)) {
    return res.status(400).json({ error: '재고 부족' });
  }
  item.qty -= Number(req.body.qty);
  updateInventoryAlerts();   // 출고 직후 알림 재계산
  res.json(item);
});

음수 재고는 현실에 존재할 수 없는 상태다. 그걸 데이터에 허용하는 순간 모든 집계가 거짓이 된다. 그래서 도메인적으로 불가능한 상태를 만드는 요청은 400으로 거절한다. 그리고 출고가 성공하면 그 자리에서 알림을 다시 계산한다 — "수량 변화 → 알림 재계산"이 항상 한 묶음으로 일어나게 묶어 둔 것이다.

숫자가 살아 움직이게 — 영업 실적 자동 연동

대시보드를 "살아 있게" 만드는 작은 장치도 같은 발상이다. 영업 주문을 등록하면 월 실적과 목표 달성률이 그 자리에서 갱신된다. 주문을 지우면 다시 빠진다. 데모에서 주문 하나를 등록했을 때 대시보드의 달성률 막대가 움직이는 걸 보여 주면, ERP가 "연결돼 있다"는 느낌을 단숨에 전달할 수 있다.

app.post('/api/sales/orders', (req, res) => {
  const { customerId, items, total, status } = req.body;
  const newOrder = {
    id: generateId('O', data.sales.orders),
    customerId,
    date: new Date().toISOString().split('T')[0],
    items: Number(items),
    total: Number(total),
    status: status || '대기'
  };
  data.sales.orders.push(newOrder);
  // 실적 즉시 반영
  data.sales.performance.monthlyActual += Number(total);
  data.sales.performance.achievementRate = Math.round(
    (data.sales.performance.monthlyActual /
     data.sales.performance.monthlyTarget) * 1000
  ) / 10;
  res.json(newOrder);
});

달성률 계산에 * 1000 / 10 트릭을 쓴 건 소수점 한 자리까지만 깔끔하게 반올림하기 위해서다. 84.6처럼 떨어진다. 삭제 엔드포인트에서는 정확히 반대로, monthlyActual에서 그 주문 금액을 빼고 달성률을 다시 계산한다. 등록과 삭제가 대칭을 이루도록 짜 둬서, 어느 쪽으로 조작해도 합계가 어긋나지 않는다. 이 대칭을 깨면 "등록은 되는데 삭제하면 숫자가 안 맞는" 류의 버그가 생긴다.

반제조 도메인 — BOM과 작업지시 상태 머신

이 회사는 단순 유통이 아니라 수입 부품을 반제품으로 조립하는 반제조 공정을 함께 가진다. 그래서 생산 모듈에는 BOM(자재 명세서)이 들어간다. 반제품 하나가 어떤 재고 품목들로 구성되는지를 품목 ID 배열로 들고 있다.

products: [
  { id: 'P001', name: '센서 모듈',
    type: '반제품', bom: ['I001', 'I003', 'I007'], processTime: 4 },
  { id: 'P002', name: '컨트롤러',
    type: '반제품', bom: ['I001', 'I002', 'I003', 'I004'], processTime: 6 },
  // ...
]

bom 배열의 각 원소가 재고 모듈의 품목 ID(I001 …)를 가리킨다. 여기서 앞서 정한 prefix ID 체계가 모듈 간 느슨한 연결의 역할을 한다. 생산 모듈은 재고 모듈을 직접 호출하지 않지만, ID 문자열을 매개로 "이 반제품은 저 품목들로 만든다"는 관계를 표현할 수 있다. 외래 키 없이도 데이터가 서로를 가리킨다.

작업지시(Work Order)에는 작은 상태 머신이 숨어 있다. 진행률(progress)을 올리다가 100%에 도달하면 상태가 자동으로 완료로 넘어간다.

app.put('/api/production/work-orders/:id', (req, res) => {
  const wo = data.production.workOrders.find(w => w.id === req.params.id);
  if (!wo) return res.status(404).json({ error: 'Not found' });
  Object.assign(wo, req.body);
  if (req.body.qty) wo.qty = Number(req.body.qty);
  if (req.body.progress !== undefined) wo.progress = Number(req.body.progress);
  if (wo.progress >= 100) wo.status = '완료';   // 진행률 → 상태 자동 동기화
  res.json(wo);
});

진행률과 상태가 따로 노는 걸 막는 장치다. 사람이 진행률을 100으로 올리고 상태는 깜빡 잊는 일이 없도록, "100%면 완료"라는 규칙을 코드가 강제한다. 앞의 재고 알림과 똑같은 철학이다 — 서로 종속된 두 값은 한쪽이 바뀔 때 다른 쪽도 같이 움직이게 묶어 둔다.

배포 — 파일 하나로 끝내기

두 번째 커밋이 통째로 배포였다. 사용한 PaaS는 YAML 한 장으로 여러 서비스를 한꺼번에 선언하는 Blueprint 방식을 지원한다. 그래서 백엔드(Node 웹 서비스)와 프론트엔드(정적 사이트)를 파일 하나에 적었다.

services:
  - type: web            # API 서버
    runtime: node
    rootDir: backend
    startCommand: node server.js
    envVars:
      - key: NODE_ENV
        value: production
      - key: FRONTEND_URL
        value: https://<프론트-도메인>

  - type: web            # 정적 빌드 결과물
    runtime: static
    rootDir: frontend
    buildCommand: npm install && npm run build
    staticPublishPath: build
    envVars:
      - key: REACT_APP_API_URL
        value: https://<백엔드-도메인>/api
    routes:
      - type: rewrite    # SPA 라우팅 — 모든 경로를 index.html로
        source: /*
        destination: /index.html

이 한 장을 작동시키려고 코드 쪽에서 챙긴 게 정확히 세 가지였다.

첫째, CORS의 환경 분리. 로컬에서는 프론트가 백엔드와 다른 포트에서 돌기 때문에 localhost 오리진을 열어 둬야 하고, 프로덕션에서는 아무 데서나 API를 부르지 못하게 허용 오리진을 환경변수로 못 박아야 한다. 이 둘을 NODE_ENV로 분기했다.

const corsOptions = {
  origin: process.env.NODE_ENV === 'production'
    ? [process.env.FRONTEND_URL]            // 프로덕션: 지정 도메인만
    : ['http://localhost:7000', 'http://localhost:3000'],  // 로컬: 개발 포트
  credentials: true
};
app.use(cors(corsOptions));

둘째, API 주소의 환경 분리. 프론트 코드에 백엔드 주소를 하드코딩하면 로컬·프로덕션 사이를 오갈 때마다 코드를 고쳐야 한다. 그래서 주소를 환경변수에서 읽되, 없으면 /api로 폴백하는 설정 모듈을 한 줄로 뒀다.

const config = {
  API_BASE: process.env.REACT_APP_API_URL || '/api'
};
export default config;

|| 한 글자가 의외로 일을 많이 한다. 빌드 시점에 환경변수가 주입되면 그 절대 URL을 쓰고, 없으면(예: 같은 도메인에서 백엔드가 프론트를 함께 서빙하는 구성) 상대 경로 /api로 떨어진다. 덕분에 어느 환경에서도 코드 수정 없이 동작한다. 모든 페이지가 이 config.API_BASE 하나만 바라보게 통일해 둔 게 핵심이다 — 주소를 바꿀 일이 생겨도 손댈 곳이 한 군데뿐이다.

셋째, SPA 새로고침 404 방지. React 라우터로 만든 SPA는 /purchasing 같은 경로를 클라이언트가 그린다. 그런데 사용자가 그 URL에서 새로고침하면, 서버는 /purchasing이라는 정적 파일을 찾다가 없어서 404를 낸다. 그래서 모든 경로를 index.html로 rewrite하는 규칙을 넣었다. 이 한 줄을 빼먹으면 대시보드 외의 URL로 직접 진입할 때마다 화면이 깨진다 — SPA 배포에서 가장 흔히 밟는 지뢰다.

정리하며

  • ERP는 본질적으로 얽히기 쉽다. 모듈 경계를 API prefix와 페이지 단위로 물리적으로 강제해, 한 모듈을 고칠 때 다른 모듈을 건드리지 않게 했다.
  • 대시보드처럼 전 모듈을 가로지르는 요구는, 모듈을 엮는 대신 집계 전용 엔드포인트 하나로 단방향 의존을 만들어 풀었다.
  • 프로토타입에서는 DB 대신 인메모리 데이터 계층이 속도를 줬고, prefix 일련번호 ID가 가독성과 미래의 DB 이전 가능성을 동시에 챙겼다.
  • 다국가 수입은 국가·통화·환율을 공통 데이터로 분리하고, 공급사 선택 시 통화를 자동 채워 데이터 오염을 원천 차단했다.
  • "계산할 수 있는 값은 저장하지 않는다" — 재고 알림은 매번 재계산, 영업 실적은 주문 등록/삭제와 대칭으로 연동, 작업지시는 진행률 100%에서 상태 자동 전이. 종속된 값은 항상 한 묶음으로 움직이게 묶었다.
  • 배포는 YAML 한 장으로 백엔드·프론트를 선언하고, CORS 환경 분기 · API 주소 환경변수 폴백 · SPA rewrite 세 가지만 챙기면 끝났다.

다음에 이 프로젝트를 진짜 제품으로 키운다면 첫 작업은 인메모리 계층을 실제 DB로 교체하는 일이 될 것이다. 데이터 접근이 data.js 한 곳에 모여 있고 ID 체계가 이미 DB 친화적이라, 교체 비용은 생각보다 크지 않으리라 본다. 프로토타입이 "버릴 코드"가 아니라 "키울 수 있는 코드"가 되도록, 빠르게 짜면서도 구조만은 지키려 한 이유가 바로 거기에 있다.

반응형