[백엔드 입문 #6] 출고 상태 규칙을 도메인 모델에 두는 이유와 멱등성
지난 글에서 트랜잭션을 정리하면서 “다음에는 출고 도메인 모델을 열어 보겠다”고 했다. 처음에는 출고 상태가 몇 개인지, 어떤 순서로 바뀌는지를 외우는 식으로 공부했다. 그러다 문득 이런 생각이 들었다.
“이거 그냥 우리 회사 구현 내용 아닌가? 백엔드 공부랑 무슨 상관이지?”
다시 보니 출고는 예시일 뿐이었다. 진짜 주제는 백엔드에서 매일 하는 고민 하나였다. “이 상태에서는 이걸 하면 안 된다” 같은 규칙을 코드 어디에 둘까? 쇼핑몰이면 “배송 시작된 주문은 취소 불가”, 은행이면 “잔액보다 많이 출금 불가” 같은 규칙이다. 이번 글은 WMS 출고를 따라가면서 이 질문을 정리한다.
글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.
① 출고 하나가 창고를 지나가는 길
쇼핑몰에서 주문이 들어오면 WMS에 출고 한 건이 생기고, 이렇게 흘러간다.
WAIT ──→ PICK ──→ PACK ──→ SHIP ──→ DONE
대기 피킹 패킹 출하 완료
(선반에서 (박스에 (트럭/택배
꺼내기) 담기) 에 실음)
중간에 빠지는 길도 있다. 고객이 주문을 취소하면 CNCL(취소), 재고가 없거나 문제가 생기면 EXCP(예외) 로 간다.
@Enumerated(EnumType.STRING)
@Column(name = "outbound_step_cd")
private OutboundStepType outboundStepType; // WAIT, PICK, PACK, SHIP, DONE, CNCL, EXPA, EXCP
이 흐름에는 지켜야 할 규칙이 있다.
- 피킹을 안 했는데 패킹으로 넘어가면 안 된다. 빈 박스를 싸게 된다.
- 이미 트럭에 실린(SHIP) 출고는 취소할 수 없다. 물건이 이미 창고를 떠났다.
- 취소된 출고를 피킹하면 안 된다. 작업자가 헛걸음한다.
프론트에서 상태가 꼬이면 화면이 잠깐 이상해지는 정도다. 그런데 여기서 규칙이 깨지면 실제 창고에서 물건이 잘못 움직인다. 그래서 규칙을 어디에 두느냐가 중요하다.
② 방식 A: Service가 규칙을 들고 있기
레거시 코드에서 쇼핑몰 주문이 취소됐을 때 출고를 취소하는 부분이다.
if (ShopOrderStatsCd.CNL.name().equals(dto.getShopOrderStatsCd())) {
outboundVO.setCancelStepCd(dto.getOutboundStepCd()); // 어느 단계에서 취소됐는지
outboundVO.setOutboundStepCd(STEP_CD_CNCL); // 단계 = 취소
outboundVO.setStatsCd(OutboundStats.CNCL); // 상황 = 취소
outboundVO.setCancelDt(param.getBaseDt()); // 취소 시각
}
OutboundVO는 setter만 있는 데이터 상자다. “출고 취소란 필드 4개를 이렇게 바꾸는 것”이라는 규칙은 이 Service만 알고 있다.
그런데 출고를 취소하는 길은 하나가 아니다.
- 쇼핑몰 주문 동기화로 들어온 취소
- 관리자가 화면에서 직접 하는 취소
- 판매자가 API로 요청한 취소
- 재고 부족으로 처리할 수 없어서 하는 취소
길이 4개면 이 4줄도 4번 복사된다. 누군가 새 취소 기능을 만들면서 setCancelDt를 빠뜨리면? 에러는 안 난다. DB에 취소됐는데 취소 시각이 없는 출고가 조용히 쌓이고, 몇 달 뒤 “취소 통계가 안 맞아요”라는 문의가 와서야 알게 된다.
4곳 중 한 곳에서 “이미 SHIP이면 취소 불가” 검사를 빠뜨리면 더 심각하다. 트럭에 실려 나간 물건이 시스템에서는 취소로 바뀌고, 재고와 정산이 다 틀어진다.
프론트로 치면 컴포넌트마다 setOrder({ ...order, status: 'CANCEL' })를 직접 쓰고 있는 상황이다.
③ 방식 B: 출고가 자기 규칙을 지키기
새 코드의 Outbound 엔티티에는 단계를 바꾸는 setter가 없다. 대신 무엇을 하는지를 이름으로 가진 메서드가 있다.
public void startPickingForRobot(Long userId) {
if (this.outboundStepType.equals(OutboundStepType.WAIT)) { // 대기 중일 때만
this.outboundStepType = OutboundStepType.PICK;
this.pickingDt = LocalDateTime.now(); // 피킹 시작 시각도 같이
this.updatedId = userId;
}
}
public void endPicking(Long userId) {
if (this.outboundStepType.equals(OutboundStepType.PICK)
|| this.outboundStepType.equals(OutboundStepType.EXCP)) { // 피킹 중일 때만
this.outboundStepType = OutboundStepType.PACK;
this.updatedId = userId;
}
}
로봇이 피킹을 시작하든 작업자가 PDA로 스캔하든, 부르는 쪽은 메서드 한 줄만 쓴다.
- “WAIT일 때만 PICK으로 간다”는 규칙을 건너뛸 수 없다.
- “PICK으로 갈 때 시각도 기록한다”는 걸 빠뜨릴 수 없다.
- 규칙이 바뀌면 이 메서드 하나만 고치면 된다.
②의 취소도 이 방식으로 쓰면 이렇게 된다.
public void cancel(Long userId) {
if (this.outboundStepType == SHIP || this.outboundStepType == DONE) {
throw new AlreadyShippedOutboundException(this.id); // 이미 나간 건 취소 불가
}
this.cancelStepType = this.outboundStepType;
this.outboundStepType = CNCL;
this.statsCd = OutboundStatsType.CANCEL;
this.cancelDt = LocalDateTime.now();
this.updatedId = userId;
}
취소 길이 4개여도 모두 outbound.cancel(userId)를 부른다. 규칙은 한 곳에만 있다.
프론트로 치면 리듀서다. 컴포넌트는 dispatch({ type: 'CANCEL' })만 하고, 무엇을 어떻게 바꿀지는 리듀서가 정한다.
| 방식 A | 방식 B | |
|---|---|---|
| 이름 | 빈약한 도메인 모델 (Anemic) | 풍부한 도메인 모델 (Rich) |
| 출고 객체 | setter만 있는 데이터 상자 | 데이터 + 상태 규칙 |
| 규칙 위치 | 취소하는 Service마다 복사됨 | Outbound.cancel() 한 곳 |
| 위험 | 한 곳만 빠뜨려도 조용히 깨짐 | 규칙을 건너뛸 수 없음 |
우리 프로젝트가 레거시(MyBatis)에서 새 구조(JPA)로 옮기면서 바꾸려는 게 바로 이거였다. 팀 문서에 있던 “도메인 로직은 반드시 도메인 모델 안에” 라는 한 줄이 이제야 이해됐다.
setter를 막아서 규칙이 있는 메서드로만 상태를 바꾸게 하는 것을 캡슐화라고 한다. 다만 실제 코드에는 @Builder가 열려 있어서, 이걸로 객체를 만들면 메서드를 거치지 않고 아무 단계나 넣을 수 있었다. 레거시를 옮겨 오는 과도기라 교과서와 다른 부분도 있다.
④ 상태를 묻는 것도 출고에게
규칙은 상태를 바꿀 때만 있는 게 아니다. 물어볼 때도 있다.
public boolean isPackingInProgress() {
return OutboundStepType.PACK.equals(outboundStepType)
&& !OutboundStatsType.CANCEL.equals(statsCd)
&& !OutboundStatsType.CANCEL_WAIT.equals(statsCd);
}
statsCd는 단계와 별개인 상황이다. 고객이 주문을 취소했는데 창고에서는 이미 박스를 싸고 있으면, 단계는 PACK인데 상황은 CANCEL_WAIT(취소 대기)가 된다.
그래서 “패킹 진행 중”이란 PACK이면서 취소(대기)가 아닌 것이다. 이 조건을 Service마다 직접 쓰면 누군가는 CANCEL_WAIT를 빠뜨린다. 그러면 취소될 박스에 택배 라벨이 붙는다.
// 이렇게 쓰면 CANCEL_WAIT를 빠뜨린다
if (outbound.getOutboundStepType() == PACK && outbound.getStatsCd() != CANCEL) { ... }
// 이렇게 쓴다
if (outbound.isPackingInProgress()) { ... }
프론트의 selector(selectIsPackingInProgress(state))와 같은 역할이다.
⑤ 같은 스캔이 두 번 오면: 멱등성
③의 endPicking을 다시 보면 이상한 점이 있다. 조건이 안 맞으면 예외 없이 그냥 아무것도 안 한다. 피처 플래그는 규칙에 어긋나면 예외를 던졌는데, 이건 왜 무시할까?
창고 현장을 떠올리면 이유가 보인다.
작업자가 PDA로 피킹 완료 바코드를 찍었다. 서버는 PICK → PACK으로 바꾸고 응답을 보냈는데, 창고 구석이라 와이파이가 끊겨서 응답이 PDA에 도착하지 못했다. 작업자는 반응이 없으니 한 번 더 찍는다.
1번째 스캔: PICK → PACK ✅ (응답 유실)
2번째 스캔: 이미 PACK인데 또 endPicking
- 예외를 던지면: PDA에 빨간 에러가 뜬다. 실제로는 성공했는데. 작업자는 관리자를 부르고 작업이 멈춘다.
- 무시하면: 두 번째 스캔도 성공으로 끝난다. 결과는 똑같이 PACK이다.
같은 요청을 여러 번 보내도 한 번 보낸 것과 결과가 같은 성질을 멱등성(idempotency) 이라고 한다. 네트워크는 언제든 끊길 수 있으니 백엔드에서 정말 중요한 개념이다.
프론트에서도 이미 겪어 본 문제였다.
- 버튼 연타를 막으려고
disabled처리를 한다. 그건 프론트 쪽 방어다. 서버는 그런 방어 없이 요청이 두 번 와도 버텨야 한다. - React Query는 실패하면 자동으로 재시도한다. 서버가 멱등하지 않으면 재시도가 오히려 사고를 낸다.
그렇다고 무조건 무시하면
WAIT 상태(피킹을 시작도 안 함)에서 endPicking이 왔다면 그건 중복 스캔이 아니라 잘못된 요청이다. 다른 출고의 바코드를 찍었거나 버그일 수 있다. 무조건 무시하면 이런 문제도 같이 묻힌다.
그래서 이렇게 나누는 게 더 안전하다.
public void endPicking(Long userId) {
if (this.outboundStepType == PACK) return; // 이미 끝남 → 중복 스캔이니 무시
if (this.outboundStepType != PICK && this.outboundStepType != EXCP) {
throw new InvalidOutboundStepException(this.id, this.outboundStepType); // 잘못된 요청
}
this.outboundStepType = PACK;
this.updatedId = userId;
}
“이미 처리됨”과 “처리할 수 없음”을 구분하는 것이 핵심이다.
⑥ 출하 규칙은 배송 방법마다 다르다
PACK → SHIP으로 넘어갈 때의 규칙은 배송 방법마다 다르다.
- 차량(TRUCK): 패킹만 끝났으면 출하할 수 있다.
- 택배(CARRIER): 패킹이 끝났고 송장 번호도 있어야 한다.
이걸 if (CARRIER) {...} else if (TRUCK) {...}로 쓰지 않고, 배송 방법마다 검증 클래스를 따로 만들어 Map에 넣어 두었다.
// 택배용
if (!outbound.outboundStepType().equals(PACK)) throw new NotCompletePickingOutboundException(...);
if (outbound.trackingNumbers().isEmpty()) throw new NotIssuedTrackingNumberException(...);
// 차량용
if (!outbound.outboundStepType().equals(PACK)) throw new NotCompletePickingOutboundException(...);
// 프론트로 치면
const validators = { TRUCK: validateTruck, CARRIER: validateCarrier };
validators[transportType](outbound);
나중에 퀵 배송이 생기면 클래스 하나를 만들고 Map에 한 줄만 추가하면 된다. 이런 방식을 전략 패턴이라고 한다.
퀴즈 풀어 보기
문제 1: 취소 사유를 추가해 달라는 요청이 오면?
출고 취소 코드(setter 4줄)가 Service 4곳에 복사돼 있다. 기획에서 “취소할 때 취소 사유도 꼭 저장해 주세요”라는 요청이 왔다.
A는 복사된 곳을 전부 찾아 고쳐야 해서 한 곳이라도 빠뜨릴 위험이 있고, B는 cancel() 안에 한 줄만 추가하면 된다고 답했다. 여기까지는 맞았는데, B에는 장점이 하나 더 있었다. 사유를 받으려면 메서드 모양이 바뀐다.
public void cancel(Long userId, String reason) { ... this.cancelReason = reason; }
그러면 cancel(userId)를 부르던 4곳이 전부 컴파일 에러가 난다. 컴파일러가 고칠 곳을 빠짐없이 찾아 주는 셈이다. A는 setter 한 줄을 추가할 뿐이라 컴파일러가 아무것도 알려 주지 않는다. TS에서 함수에 필수 파라미터를 추가하면 호출하는 곳이 전부 빨간 줄이 되는 것과 같다.
문제 2: 출하 스캔이 두 번 오면?
작업자가 PDA로 출하(SHIP) 처리를 찍었는데, 와이파이가 끊겨서 한 번 더 찍었다. 출하할 때는 재고 차감도 같이 일어난다.
재고가 두 번 차감될 거라고 답했고, 맞았다. 출고 1건에 재고가 2번 빠진다. 선반에는 물건이 있는데 시스템에서는 없다고 나오고, 다음 주문이 “재고 부족”으로 막힌다. 막는 방법은 ⑤와 같다.
public void ship(Long userId) {
if (this.outboundStepType == SHIP) return; // 이미 출하됨 → 재고 차감까지 가지 않음
...
}
다만 이게 통하려면 지난 글의 트랜잭션처럼 재고 차감과 단계 변경이 같은 트랜잭션 안에 있어야 한다. 그래야 두 번째 요청이 SHIP을 보고 멈춘다.
그리고 두 요청이 정확히 동시에 오면 둘 다 아직 PACK인 상태를 읽고 둘 다 통과할 수 있다. 3편에서 본 race condition과 같은 문제다. 이건 DB 락(lock)으로 막는다고 하는데, 나중에 따로 공부해 보려고 한다.
문제 3: 이미 완료된 출고에 피킹 완료가 오면?
개선한 endPicking에서, 이미 DONE(완료)인 출고에 endPicking이 불리면?
코드대로라면 InvalidOutboundStepException을 던진다고 답했다. 코드만 보면 맞는 답인데, 창고 상황을 떠올려 보니 이 동작이 꼭 옳은 건 아니었다.
작업자 A가 피킹 완료를 찍었는데 응답이 늦어졌고, 그 사이에 작업자 B가 패킹하고 출하까지 끝냈다. 그 뒤에 A의 PDA가 재시도를 보내면 출고는 이미 DONE이다. 이 요청은 잘못된 요청이 아니라 늦게 도착한 중복 요청이다. 그런데 에러가 뜬다.
그래서 “이미 PACK이면 무시”보다 “이미 PACK 이후 단계면 무시” 가 더 정확하다.
if (this.outboundStepType.isAfterOrEqual(PACK)) return; // PACK, SHIP, DONE → 이미 지나감
반대로 CNCL(취소)인 출고에 endPicking이 오면 진짜 문제다. 취소된 물건을 들고 다니고 있다는 뜻이니 예외로 알려야 한다.
기준은 “이 요청이 원하는 상태가 이미 이뤄졌는가” 다. 이뤄졌으면 무시하고, 이뤄질 수 없는 상태면 예외를 던진다.
정리
| 개념 | WMS에서 | 프론트 비유 |
|---|---|---|
| 빈약한 도메인 모델 | 취소 Service마다 setter 4줄 복사 | 컴포넌트마다 setState |
| 풍부한 도메인 모델 | outbound.endPicking(), cancel() |
리듀서 action |
| 캡슐화 | setter를 막고 메서드로만 상태 변경 | 상태를 직접 못 바꾸게 막기 |
| 질문 메서드 | isPackingInProgress() |
selector |
| 멱등성 | 같은 PDA 스캔이 두 번 와도 결과가 같게 | 재시도해도 안전한 API |
| 전략 패턴 | 택배/차량별 출하 검증 클래스 | switch 대신 객체 조회 |
처음엔 “이건 그냥 회사 구현 내용 아닌가” 싶었는데, 출고라는 예시를 걷어 내고 보니 남는 질문은 어느 서비스에나 있는 것이었다. 규칙은 한 곳에 두고, 같은 요청이 두 번 와도 버티게 만든다. 프론트에서는 사용자 한 명의 화면만 지키면 됐지만, 서버는 네트워크가 끊기고 요청이 겹쳐 와도 데이터를 지켜야 한다는 걸 다시 느꼈다.
다음 글에서는 이번에 잔뜩 나온 throw new ...Exception이 어떻게 프론트에서 보던 에러 응답과 토스트 메시지로 바뀌는지 따라가 볼 예정이다.