SANGMIN KANG
목록으로

MSA / Event-Driven

자동차 부품 유통 ERP

본사와 직영 지점의 재고·입출고를 다루는 MSA 기반 ERP입니다. 4인 팀에서 재고 흐름의 중심이 되는 Inventory 서비스를 맡아, 서비스 간 동기 호출을 이벤트 기반으로 전환하고 정합성 장치를 설계했습니다.

기간
2026.05 ~ 2026.06
유형
팀 프로젝트
역할
백엔드 · Inventory 도메인
Java 21Spring Boot 4Spring Data JPAPostgreSQL 16RabbitMQSpring Cloud GatewayKeycloakDockerJenkins

본사 1개소와 직영 지점 약 80개소, 연 약 12,000 SKU, 월 입출고 약 5만 건 규모를 가정한 자동차 부품 유통 ERP입니다. User·Item·Inventory·Procurement·Sales 다섯 개 도메인 서비스로 나누고, 그 앞에 게이트웨이를, 인증은 Keycloak에 맡겼습니다. 4인 팀에서 재고 서비스를 담당했습니다. 구매도 판매도 결국 재고 수량을 움직이는 일이라, 다른 도메인의 요청이 모여드는 자리였습니다. 서비스를 나눈 대가로 생긴 정합성 문제를 어떻게 감당했는지가 이 프로젝트의 중심입니다.

Architecture/

전체 구조

도메인마다 애플리케이션과 데이터베이스를 따로 두고, 게이트웨이가 인증과 라우팅을 앞에서 처리합니다.

Cloudflare 터널과 Nginx를 지나 Spring Cloud Gateway가 다섯 개 도메인 서비스로 라우팅하고, 서비스마다 PostgreSQL을 따로 두는 구조도
  • 서비스마다 저장소·빌드·Jenkins 파이프라인·컨테이너 이미지를 따로 두고, 서버에서는 Docker Compose로 함께 기동합니다.
  • 재고 서비스 내부는 controller·domain·infrastructure 3계층이며, 영속성과 외부 통신을 도메인이 정의한 인터페이스로 뒤집어(DIP) 도메인 모델에 프레임워크 의존을 남기지 않았습니다.
  • 인증은 Keycloak이 발급한 JWT를 각 서비스가 OAuth2 리소스 서버로 검증하고, 권한은 역할과 창고 단위 스코프로 나눴습니다.

Why Microservices/

단일 애플리케이션 대신 서비스를 나눈 이유

마이크로서비스는 실행과 배포 단위를 몇 개로 나눌 것인가의 문제입니다. 서비스마다 저장소를 따로 두는 멀티 레포 구성을 택하여 각자 맡은 도메인의 경계를 명확히 하고, 독립적으로 개발할 수 있었습니다.

코드 소유/

서비스 경계가 곧 담당 경계가 됩니다. 같은 파일을 여러 사람이 고치며 충돌하는 일 없이, 맡은 서비스 안에서 구조를 정하고 바꿀 수 있었습니다.

배포 단위/

서비스마다 파이프라인과 이미지가 따로 있습니다. 재고 로직만 고쳤는데 발주까지 함께 내려갔다 올라오는 일이 없습니다.

데이터 경계/

데이터베이스를 서비스별로 분리해 다른 도메인의 테이블을 직접 조인할 수 없게 했습니다. 남의 데이터가 필요하면 그 서비스가 열어 둔 내부 API나 이벤트를 거쳐야 합니다.

다만 이 규모라면 단일 애플리케이션으로도 충분했습니다. 학습이 선택의 출발점이었다는 점은 감추지 않겠습니다. 대신 나눠 봤기 때문에, 하나의 트랜잭션으로 묶이던 일이 두 서비스에 걸치는 순간 무엇이 깨지는지 직접 확인했습니다.

데이터베이스를 나눈 순간부터 판매와 재고는 같은 트랜잭션에 들어올 수 없습니다. 판매가 출고를 확정하고 재고가 차감되기까지, 중간에 무엇이 실패하든 두 서비스의 기록이 어긋나지 않아야 합니다. 이 문제를 동기 호출로 풀다가 이벤트 기반으로 옮긴 과정이 이 프로젝트에서 가장 오래 붙들었던 부분입니다.

Domain/

재고 서비스가 맡은 일

창고와 SKU의 조합마다 현재고와 안전재고를 관리하고, 모든 수량 변동을 되돌릴 수 없는 이력으로 남깁니다.

재고 조회 화면. 총 SKU·부족 재고·안전재고 충족률·최근 7일 이동 지표와 부품별 재고 목록
재고 조회 화면입니다. 상단 지표는 안전재고 미달 건수와 최근 7일 이동 건수를 집계한 것이고, 오른쪽 패널은 선택한 부품이 창고별로 얼마나 있는지를 안전재고와 나란히 보여 줍니다.
재고 이력 화면. 일자별로 묶인 입고·출고·조정 기록과 사유·원천 문서 번호
재고 이력은 추가만 가능하고 수정·삭제하지 않습니다. 각 행에는 변동 수량과 함께 원천 문서 번호가 남아, 이 수량이 어떤 판매 주문이나 조정에서 비롯됐는지 되짚을 수 있습니다.
  • 창고는 본사와 지점으로 나뉘고, 코드 유일성 검증과 활성·비활성 전환을 지원합니다.
  • 입고·출고는 다른 서비스가 보낸 이벤트로 처리되며, 조정은 사유 코드를 남기고 직접 수행합니다.
  • 부품명과 단위는 Item 서비스의 스냅샷 이벤트로 동기화하되, 이동 이력에는 변동 당시의 값을 그대로 박제해 둡니다.

Authorization/

본사와 지점, 관리자와 담당자

같은 재고 화면이라도 누가 보느냐에 따라 할 수 있는 일과 볼 수 있는 범위가 달라야 했습니다.

사용자는 두 축으로 나뉩니다. 하나는 소속이고, 다른 하나는 직급입니다. 본사 계정은 전체 창고를 다루지만 지점 계정은 자기 창고만 봅니다. 같은 소속 안에서도 관리자는 수량을 바꿀 수 있고 담당자는 조회와 일상 업무만 합니다. 두 축에 대해 네 역할을 두고, 전사 관리자를 더해 다섯 종류로 정리했습니다.

마스터 변경/

창고를 새로 만들거나 지점을 등록하는 일은 본사 관리자와 전사 관리자만 할 수 있습니다. 지점은 자기 창고의 존재 자체를 바꿀 수 없습니다.

수량 변경/

재고 조정과 안전재고 수정은 관리자만 가능합니다. 본사든 지점이든 담당자 계정은 여기서 막힙니다. 수량을 직접 움직이는 행위는 책임 있는 사람만 하도록 했습니다.

조회 범위/

KPI 집계는 모든 역할이 호출할 수 있지만, 집계 대상이 호출자의 소속으로 강제됩니다. 지점 계정에게는 자기 창고만 계산된 값이 나갑니다.

여기서 한 가지를 더 나눠야 했습니다. 역할만 검사하면 지점 관리자가 다른 지점의 창고를 조정하는 것을 막을 수 없습니다. 역할은 '무엇을 할 수 있는가'만 정할 뿐, '어디에 할 수 있는가'는 답해 주지 않기 때문입니다.

그래서 토큰에 소속 구분과 소속 코드를 함께 실어, 역할 검사를 통과한 뒤 대상 창고가 자기 소속인지 한 번 더 확인합니다. 조회 쿼리에도 같은 조건이 들어가 목록 자체가 자기 범위로 좁혀집니다. 권한을 통과했는데 남의 데이터가 보이는 상황은 화면이 아니라 조회 지점에서 막아야 한다고 봤습니다.

  • 역할 검사에 걸리면 403으로 돌려보내고, 어떤 역할이 필요한지는 API 문서에 함께 적었습니다.
  • 판매 발주 라인을 추가할 때 쓰는 단건 재고 조회는 지점 전용으로 열어, 본사 계정이 실수로 지점 흐름을 타지 않도록 했습니다.
  • 이동 이력에는 수행자의 사번과 이름을 그 시점 값으로 남겨, 누가 조정했는지 나중에도 확인할 수 있게 했습니다.

Evolution/

v1 동기 호출에서 v2 이벤트 기반으로

먼저 동작하는 것을 만들고, 그것이 왜 부족한지 확인한 뒤에 옮겼습니다.

1차 버전에서는 서비스 간 통신을 동기 HTTP 호출로 처리했습니다. 판매 주문이 승인되면 판매 서비스가 재고 서비스의 내부 API를 직접 호출해 재고를 차감하는 방식입니다. 가장 빠르게 동작하는 형태를 먼저 만들자는 판단이었습니다.

판매 주문 승인이 재고 차감 내부 API를 동기로 호출하는 v1 흐름도
판매 주문 승인이 재고 차감 내부 API를 직접 호출하고, 그 응답을 기다린 뒤에야 승인 처리가 끝납니다. 두 서비스가 호출과 응답으로 묶여 있습니다.

개발이 묶임/

재고 로직 하나를 검증하려면 상대 서비스를 로컬에 띄우고, 상대가 내부 API를 먼저 구현해 둬야 했습니다. 각자 도메인에 집중하려고 나눴는데 정작 개발이 서로를 기다렸습니다.

장애가 전파됨/

재고 서비스가 내려가 있으면 판매 주문 승인 자체가 실패합니다. 나눠서 얻으려던 장애 격리가 동기 호출 한 줄로 사라집니다.

정합성이 위태로움/

재고 차감은 성공했는데 판매 쪽 커밋이 실패하면 두 서비스의 기록이 어긋납니다. 두 저장소에 각각 쓰는 이상 한 트랜잭션으로 묶을 수 없습니다.

2차 버전에서는 RabbitMQ를 도입해 같은 흐름을 이벤트로 바꿨습니다. 판매 서비스는 재고 서비스를 호출하는 대신 출고 요청 이벤트를 발행하고 자기 트랜잭션을 끝냅니다. 재고 서비스는 그 이벤트를 받아 차감한 뒤, 결과를 다시 이벤트로 알립니다.

판매 주문 승인이 명령 이벤트를 발행하고, 재고 서비스가 처리 후 결과 이벤트를 돌려주는 v2 흐름도
요청은 erp.commands로, 결과는 erp.events로 흐릅니다. 판매 서비스는 재고 서비스가 지금 살아 있는지 알 필요가 없고, 결과는 나중에 결과 이벤트로 돌려받습니다.
  • 상대 서비스가 떠 있지 않아도 이벤트는 큐에 남습니다. 복구된 뒤 처리되므로 장애가 요청 실패로 번지지 않습니다.
  • 동기 호출은 두 서비스가 같은 순간에 함께 떠 있어야 흐름이 완성되지만, 이벤트는 그렇지 않습니다. 재고 쪽 흐름을 확인하려면 약속한 형식의 메시지를 큐에 하나 넣으면 되므로, 상대 서비스가 아직 구현되지 않아도 개발을 이어 갈 수 있었습니다.
  • 요청이 몰려도 큐가 완충 역할을 합니다. 재고 서비스는 자기 처리 속도대로 한 건씩 소비합니다.

대신 즉시 응답이 사라집니다. 승인 요청에 대한 재고 차감 결과가 그 자리에서 오지 않으므로, 화면은 결과 이벤트가 반영될 때까지 폴링해 상태를 갱신합니다.

전환 과정에서 동기 경로를 지우지 않고 설정 값 하나로 열고 닫게 만들었습니다. 이 값이 꺼져 있으면 메시지 리스너와 릴레이가 아예 기동하지 않아, 브로커 없이도 애플리케이션을 띄우고 테스트할 수 있습니다. 새 구조가 자리 잡는 동안 구 버전으로 테스트를 용이하게 하기 위함입니다

Consistency/

이벤트를 잃지도, 두 번 처리하지도 않게

이벤트로 옮기면 새 문제가 생깁니다. 발행에 실패하면 사라지고, 재시도하면 두 번 처리됩니다. 아웃박스와 인박스로 양쪽을 막았습니다.

가장 먼저 막아야 할 것은 이중 쓰기입니다. 재고를 차감한 뒤 이벤트를 발행하는 순서라면, 차감은 됐는데 발행 직전에 프로세스가 죽는 경우 결과를 아무도 모릅니다. 반대로 발행부터 하면 차감이 실패했는데 성공을 알리게 됩니다.

그래서 이벤트를 브로커로 바로 보내지 않고, 같은 데이터베이스의 아웃박스 테이블에 업무 변경과 함께 한 트랜잭션으로 기록합니다. 둘은 같이 커밋되거나 같이 롤백됩니다. 실제 발행은 별도의 릴레이가 아웃박스를 1초 간격으로 폴링하며 처리합니다.

판매 서비스에서 RabbitMQ를 거쳐 재고 서비스로, 다시 결과가 돌아오는 아웃박스·인박스 9단계 흐름도
발행자와 구독자 양쪽 모두 아웃박스를 씁니다. 재고 서비스는 처리 결과 역시 아웃박스에 적재한 뒤 릴레이가 결과 큐로 보내므로, 요청과 응답 어느 방향에서도 이중 쓰기가 생기지 않습니다.

릴레이는 발행 후 브로커가 수신을 확인해 주는 응답(publisher confirm)을 받은 뒤에만 발행 완료로 표시합니다. 확인을 못 받은 행은 그대로 남아 다음 폴링에서 다시 발행됩니다. 유실은 막히지만 대신 같은 이벤트가 두 번 갈 수 있으므로, 중복은 받는 쪽에서 흡수합니다.

멱등 장치/

메시지의 eventId, 아웃박스 유니크 키, 이동 이력의 유니크 제약을 겹쳐 중복 처리를 막습니다.

릴레이 폴링/

발행 대기 상태의 아웃박스 행을 주기적으로 모아 한 번에 내보냅니다.

기술 실패 재시도/

간격을 늘려 가며 다시 시도하고, 횟수를 소진하면 DLQ에 남깁니다.

받는 쪽은 이벤트를 처리하기 전에 인박스에서 같은 eventId가 이미 있는지 확인합니다. 있으면 아무 일도 하지 않고 정상 응답만 보냅니다. 원래 결과는 이미 아웃박스에 있어 릴레이가 다시 전달해 주기 때문입니다. 여기에 더해 이동 이력 테이블에 원천 문서와 창고를 묶은 유니크 제약을 걸어, 애플리케이션 계층을 뚫고 들어와도 데이터베이스가 마지막으로 막게 했습니다.

이 멱등 키를 어디에 걸지에서 한 번 걸렸습니다. 결과 이벤트를 발행할 때도 발주 번호를 그대로 멱등 키로 썼는데, 재고가 부족해 거절된 발주를 재고를 채운 뒤 다시 승인하자 결과 이벤트가 나가지 않았습니다. 거절 역시 정상적인 결과라 같은 키로 실패 기록이 이미 남아 있었고, 새 결과가 중복으로 막힌 것입니다. 화면은 성공도 실패도 받지 못한 채 대기 상태에 머물렀습니다.

발주 단위와 시도 단위를 구분하지 않은 것이 원인이었습니다. '이 발주를 이미 처리했는가'와 '이 요청을 이미 처리했는가'는 다른 질문입니다. 결과 발행의 멱등 키를 처리한 명령의 이벤트 식별자로 바꾸자, 같은 발주라도 다시 요청하면 새 결과가 나가게 됐습니다. 같은 요청이 중복으로 들어오는 경우는 인박스가 이미 막고 있어 이 변경으로 중복이 늘지는 않습니다.

정합성 장치 중 가장 오래 고민한 것은 실패를 어떻게 나눌 것인가였습니다. 모든 실패를 재시도하면 될 리 없는 요청이 큐를 계속 돌고, 모두 버리면 일시적인 장애까지 잃습니다. 그래서 네 가지로 나눴습니다.

형식 오류/

페이로드가 계약을 어긴 경우입니다. 몇 번을 다시 시도해도 같은 결과이므로 재시도 없이 DLQ로 보냅니다.

업무 거절/

재고 부족처럼 정상적인 거절입니다. 재고 변경은 롤백하고, 거절 결과만 별도 트랜잭션으로 남겨 상대에게 알립니다.

기술 실패/

일시적인 연결 문제 등입니다. 간격을 늘려 가며 세 번 재시도하고, 그래도 안 되면 DLQ에 보관합니다.

이 분류를 코드에서는 예외 한 줄로 구분합니다. 모든 업무 거절이 공통 예외를 상속하므로, 그것만 잡아 거절 처리로 보내고 나머지는 그대로 올려보내면 컨테이너가 재시도와 DLQ를 맡습니다. 판단 기준을 타입에 담아 두면 분기가 늘어나도 흩어지지 않습니다.

발행자·익스체인지·라우팅 키·큐·DLQ·구독자로 이어지는 RabbitMQ 토폴로지 표
명령은 erp.commands로, 사실을 알리는 이벤트는 erp.events로 나눴습니다. 큐마다 DLQ를 따로 두어 처리에 실패한 메시지가 어느 흐름에서 나온 것인지 구분할 수 있게 했습니다.

Concurrency/

같은 재고에 동시에 손대는 경우

출고 요청이 동시에 들어와도 재고가 음수로 내려가서는 안 됩니다. 세 겹으로 막았습니다.

가용 재고를 확인하고 차감하기까지 사이에 다른 요청이 끼어들면, 둘 다 검증을 통과하고 둘 다 차감해 음수가 됩니다. 그래서 출고 대상 재고 행을 비관적 쓰기 락으로 잠가 같은 조합에 대한 동시 출고를 직렬화했습니다. 락 대기에는 3초 상한을 두어, 오래 기다리는 대신 명확한 오류로 돌려보냅니다.

  • 입·출고 시점의 재고 행은 비관적 쓰기 락으로 잠급니다. 대기가 3초를 넘으면 409로 응답합니다.
  • 재고·창고 정보 수정에는 버전 기반 낙관적 락을 걸어, 동시에 고친 경우 충돌을 알립니다. 수정 권한이 있는 유저만 접근 가능하고, 자주 수정되지 않기 때문에 낙관적 락을 선택했습니다.
  • 이동 이력 테이블에는 변화량이 0이 아니고 처리 후 재고가 음수가 아니어야 한다는 제약을 걸어, 도메인 규칙을 데이터베이스가 마지막으로 검증하게 했습니다.

락과 제약을 함께 둔 이유는 서로 막는 층이 다르기 때문입니다. 락은 동시에 들어온 요청의 순서를 정하고, 제약은 어떤 경로로 들어왔든 잘못된 상태 자체를 거부합니다. 애플리케이션 코드가 늘어나도 마지막 방어선은 그대로 남습니다.

Testing/

테스트로 확인한 범위

멱등 처리와 실패 분류는 눈으로 확인하기 어려운 로직이라, 테스트로 고정해 두는 편이 확실했습니다.

도메인이 정의한 인터페이스를 인메모리 구현으로 갈아 끼워 서비스 계층을 브로커나 데이터베이스 없이 검증했습니다. 앞서 비동기 경로를 설정으로 게이팅해 둔 덕분에, 테스트에서는 메시지 계층을 끄고 순수한 도메인 동작만 다룰 수 있었습니다.

서비스별 JaCoCo 커버리지 리포트와 Allure E2E 테스트 결과
왼쪽은 서비스별 JaCoCo 커버리지 리포트, 오른쪽은 Playwright로 돌린 E2E 테스트의 Allure 리포트입니다. 재고 서비스는 명령어 1,413개 중 18개, 분기 111개 중 1개만 검증되지 않았습니다.

98%

Inventory 명령어 커버리지

전체 서비스 평균 95.2%

99%

Inventory 분기 커버리지

전체 서비스 평균 88.2%

63건

E2E 시나리오

브라우저 기준 통과율 87.27%

같은 이벤트를 두 번 보냈을 때 재고가 한 번만 줄어드는지, 재고가 부족할 때 거절 이벤트가 나가는지, 확인 응답을 받지 못한 아웃박스 행이 다음 폴링에서 다시 발행되는지는 실제로 재현해 두지 않으면 확신할 수 없는 동작이었습니다. 이 경로들을 하나씩 테스트로 만들었기 때문에 분기 커버리지가 높게 나올 수 있었습니다.