Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

44장. 나중에 떼어내기 쉬운 구조 — 코드 · 데이터 · 트랜잭션 · API · 이벤트

8부의 마지막 장이다.

여기까지 오면 세 가지가 정리됐다.

계층, 코드 경계, 데이터 소유권.

남은 것은 둘이다.

그리고 그 둘이 진짜 어렵다.


다섯 경계

flowchart TB
    C[코드 경계<br/>40·41장] --> D[데이터 경계<br/>43장]
    D --> T[트랜잭션 경계]
    T --> A[API 경계]
    A --> E[이벤트 경계]

앞의 둘은 했다.
뒤의 셋을 이 장에서 다룬다.


트랜잭션 경계가 진짜 벽이다

@Transactional
fun cancel(orderId: Long) {
    orderService.cancel(orderId)        // orders 수정
    paymentService.cancel(orderId)      // payments 수정
    pointService.refund(orderId)        // point_histories 수정
}

같은 트랜잭션에서 세 도메인의 데이터가 바뀐다.

전부 성공하거나 전부 실패한다.
DB가 보장해준다.

⚠️ 서비스를 나누는 순간 이 보장이 사라진다.

분산 트랜잭션은 실무에서 답이 아니다.
그래서 다른 방식이 필요하다.

포인트 환급은 실패했는데 결제 취소는 됐다.
이 상태를 어떻게 처리할 것인가.

보상 로직을 미리 만든다

당장 나누지 않아도 이 준비는 할 수 있다.

주문 취소 트랜잭션의 각 단계에 대해
실패 시 되돌리는 방법을 정리해줘.

- 어떤 순서로 실행되는가
- 각 단계가 실패하면 앞 단계를 어떻게 되돌리는가
- 되돌릴 수 없는 단계가 있는가 (외부 호출 등)
- 되돌리는 대신 보정해야 하는 것은 무엇인가

세 번째 질문에서 대개 이런 답이 나온다.

PG 취소는 되돌릴 수 없습니다.
취소를 취소하는 API가 없습니다.

🔥 그래서 순서가 중요해진다.

되돌릴 수 없는 것을 마지막에 둔다.

1. 주문 상태 변경        (되돌리기 가능)
2. 포인트 환급           (되돌리기 가능)
3. PG 결제 취소          (되돌리기 불가) ← 마지막

이 순서 조정은 지금 당장 할 수 있고,
분리 여부와 무관하게 안전성이 올라간다.


트랜잭션을 좁힌다

두 번째 준비다.

// 후 — 도메인별 트랜잭션 + 이벤트
fun cancel(orderId: Long) {
    orderService.cancel(orderId)              // TX 1
    eventPublisher.publish(OrderCancelled(orderId))
}
// PaymentCancelListener  → TX 2
// PointRefundListener    → TX 3

⚠️ 31장의 문제가 전부 따라온다.

중복 수신, 순서 역전, 유실.
그래서 이 전환은 멱등성과 DLQ가 준비된 다음에 한다.

1장부터 따라온 이중 환급 버그가
바로 이 전환에서 생긴 종류의 문제였다.


API 경계 — 인터페이스 뒤로 숨긴다

43장에서 만든 pointQueryService 를 다듬는다.

// point 도메인이 공개하는 계약
interface PointApi {
    fun getBalance(userId: Long): PointBalance
    fun use(command: UsePointCommand): PointUseResult
    fun refund(command: RefundPointCommand): PointRefundResult
}

지금은 같은 프로세스의 구현체가 붙는다.

@Component
class LocalPointApi(...) : PointApi { ... }

나중에 이렇게 바뀔 수 있다.

@Component
class HttpPointApi(...) : PointApi { ... }

🔥 호출하는 쪽 코드는 한 줄도 바뀌지 않는다.

이 인터페이스를 설계할 때 지킬 것이 셋이다.

원칙이유
엔티티를 노출하지 않는다나중에 직렬화 불가
컬렉션 반환은 페이징네트워크 경계에서 위험
실패를 반환값으로 표현예외는 경계를 넘기 어렵다

세 번째가 특히 중요하다.

// ❌ 나중에 HTTP로 바꾸기 어렵다
fun use(...): PointBalance   // 실패 시 예외

// ✅
fun use(...): PointUseResult // Success | InsufficientBalance | ...

29장의 sealed 가 여기서 다시 쓰인다.


이벤트 경계

마지막이다.

도메인 간 통신을 이벤트로 바꾸면
결합이 가장 약해진다.

// order 도메인이 발행
data class OrderCancelled(
    val orderId: Long,
    val userId: Long,
    val usedPoint: Long,        // point 가 필요한 값을 포함
    val occurredAt: Instant,
    val eventId: String
)

⚠️ 이벤트 설계에서 자주 하는 실수가 있다.

// ❌ ID만 보낸다
data class OrderCancelled(val orderId: Long)

받는 쪽이 다시 조회해야 한다.
조회하려면 order 를 참조해야 하고, 결합이 남는다.

필요한 값을 이벤트에 담는 편이 낫다.

그리고 31장의 규칙이 그대로 적용된다.

- 모든 이벤트에 `eventId` 를 포함한다
- Consumer 는 `eventId` 로 중복 처리를 방어한다
- 이벤트는 발행 시점의 사실을 담는다 (나중에 조회하지 않아도 되게)

준비도 체크리스트

떼어낼 수 있는 상태인지 판단하는 기준이다.

## 포인트 도메인 분리 준비도

### 코드
- [x] 계층 분리 (40장)
- [x] 순환 의존 제거 (41장)
- [x] 의존성 테스트 (42장)

### 데이터
- [x] 경계 넘는 조인 제거 (43장)
- [x] 쓰기 주체 단일화 (43장)
- [ ] 외래키 제거
- [ ] 스키마 분리

### 트랜잭션
- [x] 보상 로직 정의
- [ ] 도메인별 트랜잭션 분리
- [ ] 정합성 배치

### API
- [x] 인터페이스 추출
- [x] 엔티티 비노출
- [ ] 실패의 반환값 표현

### 이벤트
- [ ] 이벤트 스키마 정의
- [x] Consumer 멱등성
- [ ] DLQ 및 재처리

이 체크리스트가 8부 전체의 요약이다.


떼어내지 않아도 이득이다

마지막으로 짚을 것이 있다.

이 책의 목적지는 마이크로서비스가 아니다.

체크리스트를 절반만 채워도 얻는 것이 있다.

얻는 것어디서
변경 영향 범위가 좁아진다코드·데이터 경계
실패가 전파되지 않는다트랜잭션 경계
새 팀원이 빨리 이해한다도메인 지도와 규칙
Agent가 더 정확해진다좁은 Context, 강한 검증

🔥 마지막 줄이 이 책의 논지다.

경계가 명확한 코드베이스는
사람에게도 Agent에게도 다루기 쉽다.

나누기 위해 정리하는 것이 아니라
정리했더니 나눌 수 있게 되는 것이다.

실제로 나눌지는 그때 가서 정한다.
나누지 않기로 해도 손해는 없다.


8부를 마치며

일곱 장에서 한 일을 되짚으면 이렇다.

38장  경계를 찾았다
39장  순서를 정했다
40장  계층을 정리했다
41장  코드를 옮겼다
42장  경계를 테스트로 고정했다
43장  데이터 소유권을 회복했다
44장  나머지 세 경계를 준비했다

이 과정 전체를 Agent와 함께했다.

그리고 매 단계에서 사람이 한 일은 같았다.

결정.


이 장의 핵심

  • 다섯 경계 중 트랜잭션·API·이벤트가 마지막 세 개다
  • 서비스를 나누는 순간 DB의 원자성 보장이 사라진다
  • 보상 로직 정의는 당장 나누지 않아도 할 수 있다
  • 되돌릴 수 없는 단계를 마지막에 둔다 — 순서 조정만으로 안전해진다
  • 트랜잭션을 도메인별로 좁히면 31장의 문제가 전부 따라온다
  • 도메인 API를 인터페이스로 두면 나중에 HTTP 구현으로 교체된다
  • 실패는 예외가 아니라 반환값으로 표현해야 경계를 넘을 수 있다
  • 이벤트에 ID만 담으면 받는 쪽이 다시 조회해야 하고 결합이 남는다
  • 준비도 체크리스트를 절반만 채워도 이득이 있다
  • 나누기 위해 정리하는 것이 아니라, 정리했더니 나눌 수 있게 되는 것이다