서비스 구현 템플릿
주문·결제·레스토랑 서비스의 책임 경계와 포트/어댑터 구조를 템플릿화해 복제 가능한 MSA 구현 패턴 제시
Posted
By okorion
서비스 구현 템플릿
이 글의 결론: 세 서비스는 기능이 아니라 구조적 역할이 다르고, 그 차이가 재사용 가능한 템플릿을 만든다.
이 강의의 주문/결제/레스토랑 서비스는 “무엇을 한다”보다 어디까지 책임지고 어디서 멈추는가가 다르다. 이 차이를 코드 구조로 고정하면, 다른 도메인에서도 그대로 복제 가능해진다.
서비스 책임과 도메인 경계 요약 결론: 각 서비스는 “결정의 종류”가 다르다.
주문 서비스(Order)
- 책임: 주문 수명주기의 중앙 상태 관리자
- 도메인 경계: 주문 생성/상태 전이의 단일 소스
- 특징: SAGA 오케스트레이션의 중심이 되기 쉬움
결제 서비스(Payment)
- 책임: 금전적 판단(승인/거절)의 사실 판정자
- 도메인 경계: 결제 시도와 결과의 불변 기록
- 특징: 외부 시스템 의존이 많고, 결과를 이벤트로만 공유
레스토랑 서비스(Restaurant)
- 책임: 가게 정책에 따른 수락/거절 결정
- 도메인 경계: 재고/영업/조리 가능성 같은 로컬 규칙
- 특징: 주문의 “의미”는 모르고, 자기 정책만 본다
공통 모듈 구조 결론: 분리는 통일성을 위한 것이지, 복잡도를 늘리기 위한 게 아니다.
공통 구조
1
2
3
4
5
6
7
8
domain
application
└─ port (in / out)
adapter
├─ web
├─ messaging
└─ persistence
container (spring config)
왜 이렇게 나누는가
- domain: 비즈니스 규칙의 최종 위치
- application: 유스케이스 흐름과 트랜잭션
- adapter: 기술 교체 가능성 확보(JPA/Kafka/REST)
- container: 조립만 담당(의존성 방향 고정)
이 구조의 목적은 “서비스마다 같은 질문을 안 하게 만드는 것”이다.
Port 설계 규칙 결론: 포트는 기술이 아니라 의사결정의 입·출구다.
입력 포트(Input Port)
- UseCase 인터페이스
- “이 서비스가 외부에서 무엇을 할 수 있는가”
출력 포트(Output Port)
- Repository / Event Publisher
- “이 서비스가 외부에 무엇을 요구하는가”
DTO / Mapper 위치가 중요한 이유
- DTO는 어댑터 전용
- 도메인 ↔ DTO 직접 매핑 ❌
Mapper를 어댑터에 두는 이유:
- 도메인이 외부 표현 형식을 모르게 하기 위함
예외/에러 처리 결론: 에러는 종류가 다르고, 섞는 순간 구조가 무너진다.
컨트롤러 어드바이스
역할:
- 기술 예외 → HTTP 응답 변환
- 도메인 예외를 의미 있는 상태 코드로 매핑
도메인 예외 vs 기술 예외
도메인 예외
- 승인 불가, 정책 위반
- 복구 불가, 의미 있음
기술 예외
- DB 타임아웃, 메시지 역직렬화 실패
- 재시도/대체 가능
규칙: 도메인 계층에서 기술 예외를 던지지 않는다.
테스트 전략 결론: “유닛의 끝”과 “통합의 시작”을 명확히 그어야 한다.
도메인
- Entity / Domain Service
- 순수 단위 테스트
애플리케이션
- UseCase + Port Mock
- 흐름 검증
어댑터
- JPA/Kafka/Testcontainers
- 통합 테스트 최소화
최소 예시 1 결론: 주문 생성 API는 “도메인 규칙 → 이벤트 의도”까지 한 번에 묶는다.
흐름
1
2
3
4
5
Controller
→ CreateOrderUseCase
→ Order.create()
→ OrderRepository.save()
→ EventPublisher.publish(OrderCreated)
핵심 포인트
- Controller는 검증/변환만
- 상태 변경은 Order Aggregate
- 이벤트 발행은 출력 포트
최소 예시 2 결론: 결제 이벤트 소비는 “사실 판정”까지만 책임진다.
흐름
1
2
3
4
5
KafkaListener
→ ProcessPaymentUseCase
→ Payment.approve() / reject()
→ PaymentRepository.save()
→ EventPublisher.publish(PaymentApproved/Rejected)
핵심 포인트
- 이벤트 리스너는 어댑터
- 비즈니스 판단은 도메인
- 다음 흐름 제어는 SAGA/주문 서비스 몫
서비스별 비교표 결론: 이 표가 템플릿이다.
| 서비스 | 도메인 객체 | 상태 전이 | 입력 포트 | 출력 포트 | 발행 이벤트 | 소비 이벤트 |
|---|---|---|---|---|---|---|
| 주문 | Order | PENDING→APPROVED/REJECTED | Create/ChangeOrder | OrderRepo, EventPub | OrderCreated, OrderApproved | Payment, Restaurant |
| 결제 | Payment | INIT→APPROVED/REJECTED | ProcessPayment | PaymentRepo, EventPub | PaymentApproved/Rejected | OrderCreated |
| 레스토랑 | RestaurantOrder | NEW→ACCEPTED/REJECTED | DecideOrder | Repo, EventPub | RestaurantAccepted/Rejected | OrderCreated/PaymentApproved |
- 설계에 따라 달라질 수 있음
흔한 오해 / 실수 3가지
- “서비스마다 구조가 달라야 한다”는 오해
- 문제: 주문/결제/레스토랑을 각자 다른 패키지·레이어 규칙으로 구현
- 결과: 신규 서비스 추가 시 설계 재논의 반복, 유지보수 비용 증가
- 교정 기준: 책임은 다르지만 구조는 동일해야 한다. 차이는 도메인에만 둔다.
- Adapter에 비즈니스 판단을 두는 실수
- 문제: Controller/Listener에서 승인·거절 판단 수행
- 결과: 규칙 우회 경로 발생, 테스트 범위 확대
- 교정 기준: Adapter는 변환·전달만, 판단은 Domain/UseCase로 이동.
- DTO를 도메인과 동일하게 쓰는 관행
- 문제: 요청/이벤트 DTO가 그대로 도메인 객체로 사용
- 결과: 외부 포맷 변경이 도메인 변경으로 전파
- 교정 기준: DTO는 Adapter 전용, Mapper는 경계에만 둔다.
대표 실패 시나리오 3가지
- 주문 서비스가 모든 결정을 떠안는 경우
- 상황: 결제/레스토랑 정책까지 주문 서비스에서 판단
- 원인: 중앙 관리자 역할 오해
- 결과: 주문 서비스 비대화, 변경 시 전체 영향
- 대응: 주문은 상태 조율자, 판단은 각 도메인으로 환원
- 결제 이벤트 소비 로직이 중복 실행되는 경우
- 상황: Kafka 재전달로 결제 승인 로직 2회 실행
- 원인: 멱등 처리 부재
- 결과: 중복 결제/이벤트 폭주
- 대응: paymentAttemptId 기반 도메인 차단 + 이벤트 멱등
- 레스토랑 서비스가 주문 내부 상태를 가정하는 경우
- 상황: “이미 결제됐겠지”라는 전제 하 로직 작성
- 원인: 서비스 간 상태 공유 착각
- 결과: 순서 역전 시 오류
- 대응: 레스토랑은 자기 이벤트 입력만 신뢰
실무 체크리스트 (서비스 구조 복제 가능성 점검)
- 세 서비스가 동일한 패키지/레이어 규칙을 따르는가
- 각 서비스의 “결정 책임”을 한 문장으로 설명할 수 있는가
- 입력 포트가 기술 기준이 아니라 의사결정 기준인가
- 출력 포트가 실제 교체 가능성을 반영하는가
- Adapter에 비즈니스 if/else가 없는가
- DTO/Mapper가 Domain 밖에 고정돼 있는가
- 이벤트 리스너가 상태 변경을 직접 하지 않는가
- 중앙 상태 관리자가 명확히 한 곳인가
- 서비스 간 결합이 이벤트 계약으로만 이루어지는가
- 신규 서비스에 이 구조를 그대로 복제할 수 있는가
- 통합 테스트 범위가 Adapter에 한정돼 있는가
- “이 서비스는 무엇을 결정하지 않는가”를 말할 수 있는가
This post is licensed under CC BY 4.0 by the author.
