CoreERP 트러블슈팅 정리

최병현·2026년 3월 21일

coreerp project

목록 보기
37/44

CoreERP를 개발하면서 가장 크게 느낀 점은, ERP는 단순히 CRUD를 많이 만드는 프로젝트가 아니라는 점이었다. 실제로는 발주, 입고, 출고, 재고 스냅샷, 재고 원장, 인증, 권한, 검색, 집계처럼 서로 다른 흐름이 하나의 데이터 체인으로 연결되어 있었다. 그래서 화면이 보인다고 끝나는 것이 아니라, 각 단계의 데이터가 서로 모순 없이 이어지는지가 훨씬 중요했다.

이번 글은 CoreERP를 만들면서 실제로 겪었던 주요 트러블슈팅을 정리한 기록이다. 단순히 “에러가 나서 고쳤다” 수준이 아니라, 문제 상황, 원인 분석, 해결 코드, 그리고 그 수정이 왜 중요했는지까지 함께 정리했다. 포트폴리오 관점에서도 의미가 있도록, 단순 UI 버그보다 구조와 정합성에 영향을 주는 문제들을 중심으로 작성했다.


1. PurchaseOrder cancel 상태 검증 로직

이 문제는 발주 취소 기능을 구현하면서 발견했다. 처음에는 cancel API가 호출되면 발주를 취소 상태로 변경하도록 만들었는데, 테스트를 하다 보니 이미 일부 입고가 진행된 발주도 취소할 수 있는 상태였다.

예를 들어 발주 상태가 OPEN이 아니라 PARTIAL_RECEIVED인데도 취소가 가능하면, 이미 입고 데이터가 존재하는 문서를 나중에 취소한 것으로 바꾸게 된다. 이 경우 문서 흐름은 발주가 취소되었다고 말하는데, 실제 재고 흐름은 이미 입고가 반영되어 있는 모순 상태가 된다. 즉 이 문제는 단순한 버튼 제어 문제가 아니라, 발주 문서와 재고 데이터 사이의 데이터 무결성을 깨뜨리는 문제였다.

원인은 명확했다. 취소 기능을 구현할 때 API 호출 자체는 막지 않았고, 도메인 레벨에서 “어떤 상태의 발주만 취소 가능한가”라는 규칙이 들어가 있지 않았다. 즉 프론트에서 버튼을 숨기더라도 백엔드가 열려 있으면 언제든지 잘못된 상태 변경이 일어날 수 있는 구조였다.

그래서 해결은 UI 제어가 아니라 Domain Logic에서 처리해야 했다. 취소 API 내부에서 PurchaseOrderStatus가 OPEN인 경우에만 취소를 허용하도록 검증을 추가했다.

if (purchaseOrder.getStatus() != PurchaseOrderStatus.OPEN) {
    throw new IllegalStateException("취소할 수 없는 발주 상태입니다.");
}

이 수정 이후에는 OPEN 상태의 발주만 취소할 수 있게 되었고, 이미 입고가 진행된 문서를 취소하는 비정상 흐름을 막을 수 있었다. 이 문제를 통해 “상태 전이 규칙은 프론트가 아니라 백엔드 도메인에서 강제해야 한다”는 점을 확실히 체감했다.


2. PurchaseOrder 집계 데이터 오류

발주 이력 화면에서 발주수량, 입고수량, 발주잔량을 보여주도록 만들었는데, 처음에는 숫자가 이상하게 보이는 문제가 있었다. 어떤 문서는 입고수량이 실제보다 크게 나오고, 어떤 문서는 잔량이 맞지 않았다. 특히 PurchaseOrderLine과 InboundLine이 함께 엮이는 집계에서는 합계가 쉽게 꼬였다.

원인을 추적해 보니 JPQL join 구조에서 집계 기준이 불안정했다. PurchaseOrderLine과 InboundLine을 조인하는 과정에서 row가 중복 확장되면, 단순 SUM 집계가 실제 의도보다 크게 계산될 수 있다. 즉 화면 문제처럼 보였지만 실제로는 Query Logic 문제였다.

그래서 단순히 프론트에서 값을 다시 계산하는 방식이 아니라, 조회 Projection 자체를 다시 정리했다. 발주수량, 입고수량, 잔량 계산 기준을 Query에서 명확하게 통일했다.

SUM(pol.qtyOrdered)
SUM(pol.qtyReceived)
(pol.qtyOrdered - pol.qtyReceived)

실제 구현에서는 RowView, RowResponse, JPQL 매핑 구조를 함께 손보면서 발주 리스트에서 ordered, received, remain 필드가 정확히 내려오도록 수정했다. 그 결과 발주 현황 테이블에서 숫자가 안정적으로 맞게 표시되었고, 프론트는 백엔드가 내려준 집계 결과를 그대로 신뢰할 수 있게 되었다.

이 경험을 통해 ERP에서 목록 조회 API는 단순 조회가 아니라, “업무 판단에 쓰이는 숫자를 계산하는 로직”이라는 점을 배웠다.


3. Inbound confirm 시 재고 증가 중복 문제

입고 confirm API를 구현할 때 가장 먼저 걱정한 부분 중 하나가 중복 호출이었다. 사용자가 실수로 버튼을 여러 번 누르거나, 네트워크 재시도로 같은 요청이 다시 들어오면 재고 증가와 StockTx 생성이 중복 실행될 수 있었다.

이 문제가 위험한 이유는 한 번의 입고 문서가 실제보다 두 번 반영될 수 있기 때문이다. 그러면 재고 수량은 부풀려지고, 원장 데이터도 중복 기록되어 전체 재고 흐름이 무너진다. ERP에서는 이런 종류의 중복 반영이 가장 치명적인 문제 중 하나다.

원인은 idempotency 관점의 검증이 없었던 것이다. 즉 “이 입고가 이미 처리된 적이 있는가”를 막는 조건이 서비스 로직 안에 충분히 반영되지 않았다.

그래서 각 발주 라인의 입고 누적 수량을 기준으로 검증을 넣었다. 이미 ordered quantity만큼 received quantity가 채워져 있다면, 같은 입고를 다시 처리하지 못하도록 막았다.

if (poLine.getQtyReceived() >= poLine.getQtyOrdered()) {
    throw new IllegalStateException("이미 입고 완료된 발주입니다.");
}

이후에는 동일 발주 라인에 대해 중복 confirm이 들어와도 재고 증가와 StockTx 생성이 다시 일어나지 않도록 방지할 수 있었다. 이 문제를 통해 “트랜잭션이 한 번 성공했다”는 사실만으로는 충분하지 않고, 같은 의도가 재실행되어도 결과가 깨지지 않도록 설계해야 한다는 점을 배웠다.


4. StockTx 재고 원장 기록 누락

재고 기능을 구현하면서 처음에는 Inventory snapshot 수량만 잘 변하면 되는 줄 알았다. 하지만 실제로는 snapshot만 바뀌고 재고 원장인 StockTx가 함께 남지 않으면, 나중에 왜 재고가 변했는지 추적할 수 없는 문제가 생긴다.

예를 들어 현재 수량이 100에서 120으로 바뀌었다고 해도, 그 변화가 입고 때문인지, 수동 조정 때문인지, 출고 때문인지 알 수 없으면 운영 데이터로서 의미가 약해진다. 즉 snapshot은 현재 상태를 보여주고, ledger는 변화 이력을 설명해야 한다.

문제는 재고 변경 로직이 분산되어 있었다는 점이다. 어떤 곳에서는 Inventory만 수정하고, 어떤 곳에서는 StockTx까지 같이 기록하는 식이면 쉽게 누락이 생긴다. 원인은 데이터 변경 책임이 한 서비스에 모이지 않았기 때문이다.

그래서 해결 방식은 단순히 누락된 insert 하나를 추가하는 것이 아니라, 재고 변경 책임을 하나의 서비스에 모으는 것이었다. Inventory update와 StockTx insert를 같은 흐름에서 항상 함께 실행하도록 정리했다.

@Transactional
public void increaseStock(Item item, Warehouse warehouse, int quantity) {
    Inventory inventory = inventoryRepository.findByItemAndWarehouse(item, warehouse)
            .orElseGet(() -> createInventory(item, warehouse));

    inventory.increase(quantity);

    StockTx stockTx = StockTx.createInboundTx(item, warehouse, quantity);
    stockTxRepository.save(stockTx);
}

이후부터는 재고가 변할 때마다 원장 기록도 함께 남게 되었고, 재고 수량의 변화 이력을 추적할 수 있게 되었다. 이 문제를 통해 snapshot과 ledger를 분리해서 설계한 이유를 더 분명히 이해하게 되었다.


5. Inventory Snapshot 생성 문제

ERP에서는 item과 warehouse 조합별로 inventory row가 존재해야 한다. 그런데 새로운 품목이 처음 특정 창고로 입고되는 시점에는 아직 inventory row 자체가 없는 경우가 있었다. 이 상태에서 바로 수량 증가 로직을 타면 조회 결과가 null이 되어 로직이 깨질 수 있다.

처음에는 “Inventory가 없는 것은 예외 상황”처럼 보였지만, 실제로는 정상적인 최초 진입 케이스였다. 즉 재고 row가 없는 것은 오류가 아니라, 아직 snapshot이 생성되지 않은 상태일 뿐이었다.

원인은 inventory row 존재를 항상 전제로 로직을 짠 것이었다. 하지만 ERP에서는 새로운 품목, 새로운 창고 조합이 계속 생길 수 있기 때문에, 이 전제는 현실과 맞지 않았다.

그래서 조회 시 없으면 자동 생성하는 방식으로 바꾸었다.

Inventory inventory = inventoryRepository.findByItemAndWarehouse(item, warehouse)
        .orElseGet(() -> createInventory(item, warehouse));

그리고 createInventory 내부에서 초기 수량 0의 Inventory snapshot을 생성한 뒤, 그 후 증가 또는 감소 로직을 이어가도록 만들었다.

private Inventory createInventory(Item item, Warehouse warehouse) {
    Inventory inventory = Inventory.builder()
            .item(item)
            .warehouse(warehouse)
            .quantity(0)
            .build();

    return inventoryRepository.save(inventory);
}

이렇게 수정한 뒤에는 새로운 item/warehouse 조합이 들어와도 로직이 끊기지 않고 자연스럽게 snapshot이 생성되도록 만들 수 있었다.


6. Vendor / Item 검색 성능 문제

발주 생성 화면에서는 Vendor와 Item을 검색해서 선택해야 했다. 처음 구현할 때는 input 값이 바뀔 때마다 즉시 API를 호출했다. 기능 자체는 동작했지만, 사용자가 한 글자씩 입력할 때마다 요청이 나가면서 서버 호출이 지나치게 많아졌다.

예를 들어 사용자가 “vendor”를 입력하면 v, ve, ven, vend, vendo, vendor까지 여러 번 요청이 발생한다. 검색 UX도 불안정하고, 서버와 네트워크 자원도 낭비된다.

원인은 검색어 변경과 API 호출이 1:1로 묶여 있었기 때문이다. 즉 사용자의 입력 의도가 어느 정도 완성되기 전에 너무 빨리 요청을 보내고 있었다.

그래서 프론트에서 debounce를 적용했다. 입력이 잠시 멈췄을 때만 API를 호출하도록 바꾸어 불필요한 요청 수를 줄였다.

const debouncedSearch = debounce((keyword) => {
  fetchVendor(keyword);
}, 300);

Item 검색도 같은 방식으로 적용했다. 그 결과 입력 중 불필요한 API 호출이 크게 줄었고, 검색 UX도 더 안정적으로 개선되었다. 이 문제는 프론트 최적화처럼 보이지만, 실제로는 Backend API를 어떻게 소비할 것인가에 대한 설계 문제이기도 했다.


7. Axios Content-Type 오류

프론트와 백엔드를 연결하는 과정에서 요청이 정상적으로 전송되지 않는 문제가 있었다. 원인을 확인해 보니 Axios header에 Content-Type이 잘못 작성되어 있었다.

기존에는 아래처럼 잘못 적혀 있었다.

headers: {
  "Content-Type": "application-json"
}

정확한 값은 application/json인데, 하이픈 위치가 잘못되어 있었다. 이런 문제는 얼핏 보면 작은 오타 같지만, 실제로는 서버가 요청 body를 해석하는 방식에 영향을 줄 수 있기 때문에 꼭 정확해야 한다.

수정은 단순했지만, 프론트-백엔드 통신에서 header 하나도 정확해야 한다는 점을 다시 확인한 사례였다.

headers: {
  "Content-Type": "application/json"
}

8. Spring Security CORS 충돌

React Frontend와 Spring Boot Backend를 분리해서 개발하다 보니 CORS 문제가 발생했다. 처음에는 WebConfig에 addCorsMappings를 넣어두었는데도 일부 요청이 계속 차단되었다.

원인을 보니 SecurityConfig와 WebConfig의 설정이 서로 다른 레벨에서 동작하고 있었다. Spring Security를 사용하는 경우에는 단순한 WebMvcConfigurer 설정만으로는 충분하지 않고, Security chain 안에서도 CORS를 명시적으로 열어줘야 한다.

즉 문제의 핵심은 “CORS 설정이 없다”가 아니라, “Security layer에서 다시 차단하고 있었다”는 점이었다.

그래서 SecurityConfig에서 CORS를 활성화하도록 수정했다.

http.cors();

그리고 WebConfig와 SecurityConfig의 역할을 분리해서, 실제 인증이 필요한 API 호출까지 정상적으로 브라우저에서 통과되도록 맞추었다. 이 문제를 통해 Spring MVC 설정과 Spring Security 설정은 같은 CORS라도 동작 위치가 다르다는 점을 분명히 이해할 수 있었다.


9. JWT 토큰 저장 구조 문제

로그인 기능을 구현할 때 처음에는 token 저장 위치가 명확하지 않았다. 어떤 로직은 localStorage를 직접 읽고, 어떤 로직은 state에 저장된 값을 보고, 또 어떤 부분은 Context에서 사용자 정보를 관리하고 있었다. 이렇게 되면 인증 상태 초기화, 새로고침 시 복구, 로그아웃 처리 흐름이 쉽게 꼬이게 된다.

원인은 인증 정보의 책임이 한 곳에 모여 있지 않았기 때문이다. 즉 token 저장, token 조회, 사용자 상태 관리가 서로 분리되지 않고 섞여 있었다.

그래서 인증 구조를 정리했다. token 자체의 저장/조회 책임은 tokenStorage로 분리하고, 사용자 인증 상태와 화면 제어는 AuthContext에서 담당하도록 역할을 나누었다.

auth/
  tokenStorage.ts

예를 들면 tokenStorage는 순수하게 token을 다루고, AuthContext는 login, logout, me 조회, 초기 인증 복구 같은 흐름을 담당하는 구조로 분리했다.

export const getAccessToken = () => localStorage.getItem("accessToken");
export const setTokens = (accessToken, refreshToken) => {
  localStorage.setItem("accessToken", accessToken);
  localStorage.setItem("refreshToken", refreshToken);
};
export const clearTokens = () => {
  localStorage.removeItem("accessToken");
  localStorage.removeItem("refreshToken");
};

이렇게 바꾼 뒤에는 인증 상태 복구 흐름이 안정되었고, 토큰 저장 위치 혼재로 인한 버그를 줄일 수 있었다.


10. React Layout 권한 메뉴 문제

관리자 전용 메뉴를 추가한 뒤 일반 사용자 계정으로 로그인했을 때도 해당 메뉴가 화면에 노출되는 문제가 있었다. 실제로 접근은 막혀 있어도, 메뉴 자체가 보이면 UX 측면에서도 좋지 않고 보안상으로도 깔끔하지 않다.

원인은 Layout 렌더링 시 현재 로그인한 사용자의 role을 기준으로 분기하지 않았기 때문이다. 즉 백엔드 API 접근 제어는 되어 있어도, 프론트의 화면 제어가 빠져 있었다.

그래서 메뉴 렌더링 자체를 role 기반으로 바꾸었다.

{user.role === "MASTER" && (
  <NavLink to="/admin">Admin</NavLink>
)}

이후에는 관리자 권한을 가진 사용자에게만 관련 메뉴가 보이도록 수정되었다. 이 문제는 단순한 화면 버그처럼 보이지만, 실제로는 권한 기반 UI 제어가 왜 필요한지를 잘 보여준 사례였다.


11. Hibernate 6 환경에서 CLOB 타입 컬럼에 lower() 함수 적용 불가

AuditLog 검색 기능을 구현하는 과정에서 Hibernate 6 관련 문제를 겪었다. details 같은 긴 문자열 컬럼에 대해 lower()를 적용한 LIKE 검색을 만들었는데, 실행 시 FunctionArgumentException이 발생했다.

처음에는 단순 문법 문제처럼 보였지만, 에러를 따라가 보니 Hibernate 6에서는 Query Function 타입 검증이 더 엄격해졌고, CLOB 타입 컬럼에 lower()를 그대로 적용하는 것이 허용되지 않는 구조였다. 즉 단순히 검색 조건이 틀린 것이 아니라, 엔티티 매핑 타입과 Query Function 제약이 충돌하고 있었다.

문제가 된 형태는 아래와 비슷했다.

@Query("""
SELECT a
FROM AuditLog a
WHERE lower(a.details) LIKE lower(concat('%', :keyword, '%'))
""")
Page<AuditLog> search(@Param("keyword") String keyword, Pageable pageable);

해결은 검색 기능을 빼는 것이 아니라, 매핑 구조를 재검토하는 방향으로 잡았다. details 컬럼을 실제 필요한 길이에 맞는 일반 문자열 타입으로 조정하여 Query Function과 충돌하지 않도록 바꾸었다.

@Column(length = 1000)
private String details;

그 결과 lower() 기반 검색이 정상 동작하도록 수정할 수 있었다. 이 경험은 ORM을 사용할 때도 결국 DB column type과 Query function의 조합까지 이해해야 한다는 점을 보여준 사례였다.


12. AuditLog 검색 필터 미적용 문제

검색어를 입력해도 결과가 달라지지 않는 문제가 있었다. 프론트에서는 keyword를 정상적으로 넘기고 있었지만, 서버 조회 결과는 전체 목록과 다를 바가 없었다.

원인을 보니 JPQL에서 optional parameter 처리가 빠져 있었다. 검색어가 없는 경우와 있는 경우를 분기하지 않고 무조건 LIKE 조건을 태우다 보니, null 또는 빈 문자열 처리에서 비정상적인 결과가 나왔다.

그래서 검색어가 있을 때만 조건이 의미 있게 적용되도록 Query를 수정했다.

WHERE (:keyword IS NULL OR lower(a.targetName) LIKE lower(concat('%', :keyword, '%')))

이후에는 검색어가 없으면 전체 조회, 검색어가 있으면 필터 적용이라는 의도대로 동작하도록 맞출 수 있었다.


13. 재고 음수 발생 가능성

출고 기능을 구현할 때 처음에는 요청 수량만큼 재고를 차감하면 된다고 생각하기 쉽다. 하지만 실제 운영에서는 현재 재고보다 더 큰 수량이 출고 요청으로 들어오는 경우를 반드시 막아야 한다.

검증 없이 차감하면 inventory quantity가 음수가 될 수 있고, 그 순간부터 모든 재고 데이터 신뢰도가 무너진다. 즉 이 문제는 예외 처리라기보다 재고 시스템의 기본 방어선에 가깝다.

원인은 출고 처리 시 “현재 재고 확인 → 가능 여부 검증 → 차감” 흐름이 완성되어 있지 않았던 것이다. 그래서 차감 전에 재고 수량 검증을 명시적으로 추가했다.

if (inventory.getQuantity() < request.getQuantity()) {
    throw new IllegalStateException("재고 부족");
}

이후에는 재고 부족 상태에서 출고가 진행되지 않도록 막을 수 있었고, 재고 음수 상태를 원천 차단할 수 있었다.


14. 출고 문서, 재고 원장, 스냅샷 간 시간 기준 불일치

이 문제는 개발하면서 가장 ERP스럽다고 느낀 문제 중 하나였다. 출고 문서, StockTx, Inventory snapshot이 모두 같은 작업 흐름 안에서 생성되는데, 저장된 시간이 조금씩 다르게 들어가고 있었다.

예를 들어 출고 문서 생성 시점과 StockTx 생성 시점, Inventory updatedAt이 각각 개별적으로 LocalDateTime.now()를 호출하면 미세하게 다른 시간이 들어간다. 표면상으로는 큰 문제처럼 안 보일 수 있지만, 나중에 데이터를 추적할 때 “어떤 이벤트가 먼저였는가”가 헷갈릴 수 있고, 운영 데이터 흐름도 일관되지 않게 된다.

그래서 서비스 계층에서 한 번만 now를 생성하고, 같은 트랜잭션에서 만들어지는 데이터에 동일한 기준 시간을 넣도록 정리했다.

LocalDateTime now = LocalDateTime.now();

outbound.setCreatedAt(now);
stockTx.setTxDate(now);
inventory.setUpdatedAt(now);

이후에는 하나의 출고 이벤트에 연결된 문서, 원장, 스냅샷이 같은 시간 기준으로 묶이게 되었고, 데이터 흐름을 추적하기 쉬워졌다. 이 문제를 통해 단순히 값만 맞는 것이 아니라, “같은 사건은 같은 시간 축에 놓여야 한다”는 관점도 중요하다는 것을 배웠다.


15. JWT Authorization Header 누락

로그인 자체는 성공했는데, 로그인 이후 일부 API 호출에서 401 또는 인증 실패가 발생하는 문제가 있었다. 이런 경우는 백엔드 로직이 틀렸다기보다, 프론트에서 인증 토큰을 헤더에 실어 보내지 못하고 있을 가능성이 높다.

실제로 확인해 보니 Axios 요청 중 일부에 Authorization header가 누락되어 있었다. 특히 API 모듈이 여러 개로 나뉘어 있을 때 공통 설정이 빠지면 이런 문제가 생기기 쉽다.

그래서 Axios config를 공통화하고, tokenStorage에서 token을 읽어 모든 인증 요청에 일관되게 넣도록 수정했다.

const getAxiosConfig = () => {
  const token = localStorage.getItem("jwt");

  return {
    headers: {
      Authorization: token,
      "Content-Type": "application/json",
    }
  };
};

이후에는 인증이 필요한 API 호출에서 토큰 누락 문제가 사라졌고, 프론트와 백엔드 인증 흐름이 안정화되었다.


16. Spring Security Bean 충돌

SecurityConfig를 정리하는 과정에서 PasswordEncoder Bean 관련 충돌 문제가 있었다. Spring Boot에서는 같은 타입의 Bean이 중복 등록되면 어떤 Bean을 주입해야 할지 모호해질 수 있다.

원인은 보안 설정을 수정하면서 PasswordEncoder Bean 정의가 분산되거나 중복되었기 때문이었다. 특히 Security 관련 클래스를 나누다 보면 같은 역할의 Bean이 중복 선언되기 쉽다.

해결은 간단하지만 중요했다. PasswordEncoder Bean을 한 곳에서만 명확하게 선언하도록 정리했다.

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

이후에는 인증 관련 의존성 주입이 안정적으로 동작했고, 보안 설정 구조도 더 단순하고 명확해졌다.


17. Spring Data REST 의존성 충돌

초기 구성 단계에서 Spring Data REST 의존성이 함께 들어가 있었는데, 기존의 Controller 기반 REST API와 충돌하는 느낌이 있었다. 프로젝트는 명확하게 직접 Controller, Service, DTO를 설계하는 방향으로 가고 있었기 때문에 자동 노출 기반의 REST 구조가 오히려 일관성을 해쳤다.

원인은 기술 선택의 방향 차이였다. Spring Data REST는 repository 중심 자동 노출에 가깝고, CoreERP는 도메인 규칙과 응답 DTO를 명시적으로 제어해야 하는 구조였다. 즉 기술 자체의 문제보다 현재 프로젝트와 맞지 않는 선택이었던 셈이다.

그래서 해당 의존성을 제거하고, Controller 기반 API 구조를 유지하는 방향으로 정리했다. 이후에는 API 스펙이 더 예측 가능해졌고, 응답 구조도 프론트와 협업하기 쉬운 형태로 통일할 수 있었다.


18. Git LF / CRLF 경고

문서나 이미지 관련 파일을 커밋할 때 LF will be replaced by CRLF 경고가 나오는 경우가 있었다. 기능에 직접적인 오류를 일으키는 것은 아니지만, 팀 협업이나 저장소 일관성 관점에서는 정리해둘 필요가 있었다.

이 문제는 Windows 환경과 Git line ending 설정 차이에서 자주 발생한다. 프로젝트 자체의 비즈니스 로직과는 관계없지만, 작업 환경이 다르면 계속 같은 경고를 보게 되어 관리상 피로도가 커질 수 있다.

그래서 Git 설정을 점검하고 line ending 전략을 정리했다. 이런 환경 설정 문제도 프로젝트를 안정적으로 운영하려면 무시할 수 없다는 점을 느꼈다.


19. 전체적으로 느낀 점

CoreERP를 개발하면서 느낀 가장 큰 포인트는, ERP는 눈에 보이는 화면보다 보이지 않는 데이터 흐름이 더 중요하다는 점이었다. 발주가 생성되고, 입고가 누적되고, 재고가 변하고, 원장이 남고, 권한에 따라 메뉴가 갈리고, 토큰에 따라 API 접근이 제어되는 모든 과정이 결국 하나의 시스템 경험으로 이어진다.

이번 트러블슈팅들을 정리해 보니 단순히 “에러를 고쳤다”라기보다 다음과 같은 역량을 실제로 다뤘다는 점이 더 중요하게 느껴졌다.

  • 상태 전이 규칙을 도메인 레벨에서 강제하는 방법
  • 발주 / 입고 / 재고 / 원장 간 데이터 정합성을 맞추는 방법
  • 중복 처리와 재고 음수 같은 위험 흐름을 방어하는 방법
  • JPA / Hibernate의 Query 제약과 엔티티 매핑 관계를 분석하는 방법
  • React와 Spring Boot 사이 인증, 검색, 권한 흐름을 안정화하는 방법

결국 CoreERP는 단순한 CRUD 프로젝트가 아니라, 실제 업무 흐름을 코드로 옮기면서 데이터 무결성과 시스템 일관성을 맞추는 프로젝트였다. 이 과정에서 겪었던 문제들은 모두 단순 버그 수정 이상의 의미가 있었고, 포트폴리오에서도 가장 강하게 보여줄 수 있는 경험이라고 생각한다.


핵심 요약

  • 발주 취소는 OPEN 상태에서만 가능하도록 도메인 검증을 추가했다.
  • 발주 집계 조회는 Query Projection을 수정하여 ordered, received, remain 값을 안정적으로 계산했다.
  • 입고 confirm 중복 실행을 막아 재고 증가와 StockTx 중복 생성을 방지했다.
  • Inventory snapshot과 StockTx ledger를 함께 관리하도록 재고 변경 책임을 서비스에 모았다.
  • item / warehouse 신규 조합은 inventory row를 자동 생성하도록 설계했다.
  • 검색 API에는 debounce를 적용해 프론트 입력과 백엔드 호출 사이의 균형을 맞췄다.
  • JWT, CORS, role 기반 메뉴 제어를 정리해 인증/권한 흐름을 안정화했다.
  • Hibernate 6 Query Function 제약과 시간 정합성 문제까지 구조적으로 해결했다.

이 글은 CoreERP 개발 과정에서 겪었던 핵심 트러블슈팅을 정리한 기록이며, 앞으로도 기능을 추가할 때 단순히 “동작하는가”를 넘어서 “데이터 흐름이 일관적인가”, “도메인 규칙이 백엔드에서 강제되는가”, “운영 관점에서 추적 가능한가”를 더 중요하게 보면서 개발해 나갈 생각이다.

profile
Develop

0개의 댓글