평소 MSA에 대해 관심이 많았는데 이번에 항해를 진행하며 MSA구조를 이해할 수 있는 좋은 기회가 생겼다.
기존 Monolithic 구조를 개선하고 서비스를 확장한다는 관점에서 MSA를 바라볼 수 있고, 특히 이렇게 좋은 기회가 생겼을때 조금이라도 공부하고 MSA에 대해 이해하고 넘어가는 것이 좋겠다는 생각이 들어 공부한 기록을 남기게 되었다.
Micro Service Architecture, MSA는 독립적인 단위로 생각할 수 있는 서비스들의 집합이라 할 수 있겠다.
여러 서비스를 결합하여 단일 프로젝트 단위로 배포하고 동작하는 기존 Monolithic 구조와 달리, 도메인 별로 분리한 서비스가 다른 서비스에 의존하지 않고 독립적으로 개발, 배포, 확장될 수 있다는 특징을 지니고 있다.
이때 의존한다의 개념은 Facade처럼 다양한 서비스를 의존하여 모든 동작이 완료되어야 트랜잭션이 가능하다는 점, 또한 이 중 하나의 서비스에 문제가 발생할 경우 다른 서비스까지 영향을 미치는 단일 장애점이라는 점을 내재한다.
따라서 서비스가 결합되어 단일 프로젝트로 동작하는 기존 Monolithic 구조에서는 장애가 발생하였을때 다른 서비스간의 영향도까지 고려하여 복합적으로 대응이 필요하지만, MSA는 도메인 바운더리(경계)를 명확하게 나누고 대응할 수 있기에 유지보수 측면에서도 유리한 점이 많다.
public void orderPay(OrderDTO orderDTO) throws Exception {
/*
* 학습목적으로 주문 메인로직을
* order, charge 서비스 호출로 분리
* */
orderWriterService.order(orderDTO);
pointWriterService.charge(orderMapper.toPointDomainFromOrderDomain(orderDTO));
/*
* 주문이 성공하였을때만 누적하도록 하며,
* 성공하지 못하였다면 Ranking 정보는 누적하지 않습니다.
* */
orderRedisTemplateProvider.setProductRanking(ProductEnum.HOT_SALE_PRODUCT.key() , orderDTO.getProductId(), orderDTO.getOrderQuantity());
위 로직은 주문 후 결제까지 하나의 트랜잭션으로 동작하며, 이후 주문 개수를 Redis Sorted Set에 Ranking값으로 저장하는 과정이다.
OrderFacadeWriterService 클래스 안에서 orderWriterService, pointWriterService 등 여러 서비스들을 의존받고 Redis 처리까지 이루어진다.
이 Facade 서비스는
따라서 Facade라는 명목으로 의존성을 여러 곳에서 주입받는 것을 당연하게 받아들이면 곤란하다.
위 Monolithic 구조에서 도메인을 분리하여 메인로직과 부가로직을 떼어놓고, 나아가 이를 이벤트 기반으로 처리하면 어떨까?
일단 지금의 Order Domain을 살펴보면 위와 같다.
Order 도메인, 즉 주문을 수행하기 위한 책임을 OrderFacade에서 전담하는 형태로 OrderService, PointService, RedisTemplate까지 총 3개의 서비스가 결합되어있는 형태이다.
이 상태에서 서비스가 확장되어 또 다른 서비스를 결합하게된다면 Order 도메인 내부에서 주문 트랜잭션을 수행할때 과다한 책임을 받으므로 단일 장애점이 발생한다.
개선 방안은 다음과 같다.
일단 기본적으로 Order 도메인에서 모든 트랜잭션을 처리하던 구조에서 부가로직를 떼어내어, 이를 Event 도메인에서 처리하도록 도메인을 분리하였다.
만약 유사한 사유로 다른 도메인의 부가로직 처리가 필요하다면, Event 도메인 내부에서 해당 도메인을 리스닝하는 구조로 확장하면 될 것이다.
세부적으로는 도메인을 세분화(Order Event)하여 어떠한 도메인에 관련된 이벤트인지 명확히 파악할 수 있도록 구성하였고, 세분화된 Order Event에서 최종적으로 주문 메인로직 이후의 부가로직을 진행할 수 있도록 하였다.
이 구조를 넓히면 위와 같다.
각각 분리한 도메인에서 이벤트 처리를 진행할 수 있도록 구현해주었다.
이에 따라 기존 Order 도메인의 Facade 서비스는 부가로직에 대한 책임을 Event에 위임할 수 있게되어 이전보다 로직을 비교적 간편하게 관리할 수 있게 되었다.
주문을 진행하는 OrderFacadeService의 로직은 메인로직만 남게되었고, 이후 부가로직을 진행하기위해 이벤트를 발행한다.
public void orderPay(OrderDTO orderDTO) throws Exception {
orderWriterService.order(orderDTO);
pointWriterService.charge(orderMapper.toPointDomainFromOrderDomain(orderDTO));
/*
* 후행 서비스는 OrderEventListener에서 동작하도록 구성
* 메인 로직은 이벤트를 발행하기만 하도록 구성합니다.
* 이벤트 발행 시 메인로직 정보가 담긴 이벤트 객체를 전달
* */
/*
* 주문이 성공하였을때만 누적하도록 하며,
* 성공하지 못하였다면 Ranking 정보는 누적하지 않습니다.
* */
//orderRedisTemplateProvider.setProductRanking(ProductEnum.HOT_SALE_PRODUCT.key() , orderDTO.getProductId(), orderDTO.getOrderQuantity());
applicationEventPublisher.publishEvent(new OrderCommitEvent(orderDTO.getOrderId(), orderDTO.getOrderQuantity()));
}
이후 부가로직을 살펴보기전에 이벤트 발행 정보를 전달하는 Event 객체를 먼저 살펴보아야 한다.
이벤트를 발행하는 과정은 Event 객체를 전달하면서 이루어진다.
이 Event 객체는 이벤트를 발행하면서 메인로직의 정보를 전달하는 단순 POJO객체의 의미를 넘어, 해당 이벤트를 구독하는 구독자들에게 "메인 로직이 종료되었다"라는 의미까지 내포한다.
참고로,
/*
* 주문까지 COMMIT 완료 시 * 이벤트를 전달하기 위한 POJO 객체
*
*/
public class OrderCommitEvent {
//order Id
private final Long productId;
//user Id
private final Long orderQuantity;
/*
* 생성자 주입
*
*/
public OrderCommitEvent(Long productId, Long orderQuantity) {
this.productId = productId;
this.orderQuantity = orderQuantity;
}
/*
* Event 객체에서
* 주문정보와 사용자정보를 전달받기 위함
* */
public Long getProductId() {
return this.productId;
}
public Long getOrderQuantity() {
return this.orderQuantity;
}
}
위처럼 Redis에 Ranking 정보를 전달해주기위한 상품아이디와 주문수를 Event 객체를 정의한다.
Event 객체의 도메인은 Order의 Object 도메인에서 정의하였고, 주문 이벤트를 구독하므로 이를 이벤트 객체명에 반영해주었다.
이제 주문 메인로직이 커밋된 후에 이벤트 정보를 가지고 리스너에 전달한다.
리스너는 해당 이벤트를 구독하는, 정확히 말하면 해당 이벤트 객체를 주는 것에 관심이 있는 컴포넌트이자 빈(bean)이다.
기본적으로 Spring에 빈(bean)으로 등록되어있어야 이벤트를 구독하여 이벤트 후속 처리가 가능해짐을 반드시 기억한다.
@Component
@Slf4j
public class OrderEventListener {
@Autowired
private OrderRedisTemplateProvider orderRedisTemplateProvider;
/*
* CASE 1 : AFTER COMMIT : 트랜잭션 커밋 이후
* 관심이벤트 : Order Commit Event
* */
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(OrderCommitEvent event) {
// Logic to execute after the transaction commits
/*
* 주문서비스 동작 이후 로그가 출력되는지 확인
* */
log.info("**************************");
log.info("주문 서비스를 구독하여 후행서비스를 진행하는 리스너");
log.info("**************************");
/*
* 주문이 성공하였을때만
* 랭킹 정보를 누적하는 orderRedisTemplateProvider를 동작하도록 구성합니다.
* */
orderRedisTemplateProvider.setProductRanking(ProductEnum.HOT_SALE_PRODUCT.key(), event.getOrderId(), event.getOrderQuantity());
}
/*
* CASE 2 : AFTER ROLLBACK : 트랜잭션 롤백 이후
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleAfterRollback(OrderCommitEvent event) {
// Logic to execute after the transaction rolls back
}
*/
/*
* CASE 3 : BEFORE COMMIT : 트랜잭션 커밋 이전
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleBeforeCommit(OrderCommitEvent event) {
// Logic to execute before the transaction commits
}
*/
/*
* CASE 4 : AFTER COMPLETION : 트랜잭션의 롤백 및 커밋 이후
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleAfterCompletion(OrderCommitEvent event) {
//Logic to execute after the transaction completes (commit or rollback)
}
*/
}
이벤트 리스너의 도메인은 Order의 EventListener도메인에서 정의하였고, Event 객체와 마찬가지로 특정 도메인 서비스를 구독하는 리스너이므로 이를 클래스명에 반영해주었다.
또한 리스너의 후속 동작의 phase에 따라 분류할 수 있고, 위의 경우에는 AFTER_COMMIT(커밋 이후에 이벤트 발행 및 후속 처리) phase에 이벤트를 걸어주었다.
주문결제서비스에서 재고 차감 서비스를 추가한다고 가정해보자.
기존 Facade Service에 재고 차감 서비스를 단순히 주입받아 추가한다고 하면 문제점이 몇가지 보인다.
또한 재고 차감 서비스를 주문 메인 로직에 대한 이벤트 리스너로 또 등록한다고 하더라도,
재고 차감은 어떻게 보면 주문의 메인로직이라 할 수 있겠지만, 이번엔 주문의 Facade Service의 서비스를 확장을 해야하지만 단순히 서비스 추가(의존성 증가)를 할 수는 없는 상황으로 보고 이를 도메인 분리를 통해 해결해보도록 하자.
세부 도메인은 분리하더라도 각각의 서비스들이 서로 양방향 통신을 할 수 있는 "체계"가 따로 필요하다.
일단 Facade Service와 Event domain 간의 일방향적인 상호작용을, Event 도메인 안에서 서로 구독/발행 등으로 양방향 통신을 할 수 있도록 응집한다.
단일 서비스 내에서 절차지향적으로 이루어지던 흐름을 양방향 상호작용 체계로 변경하여, 메인 로직으로 이벤트를 발생할 수 있고 재고 차감에 따라 메인 로직의 롤백을 유도할 수 있게 되었다.
내부적으로 설정한 세부 도메인에서 관심 이벤트 상관없이 본인의 책임대로만 처리하도록 한다.
도메인 간의 소통방식이 이제는 주된 고민거리가 되었고, 단일 프로젝트 전체로 영향을 미칠 수 있는(영향도가 큰) 걱정거리는 거의 없어지게 되었다.
무엇보다 기존 Monolithic에서 이미 구현하였던 Facade Service의 의존성 주입, 결합도 등을 고민하면서 프로젝트 도메인 세부 구성까지 소모가 많았던 반면, 도메인 분리와 양방향 통신으로 기존 존재하던 도메인 및 서비스의 수정을 최소화 할 수 있게 되었다.
지금까지 도메인을 분리하고 각각을 독립적인 서비스로 구성 및 서로의 양방향 통신이 가능하도록 Monolithic 구조의 개선방안을 생각해보았다.
개선방안의 핵심은 MSA 구조처럼 개별적인 도메인들을 두어 자신들의 책임에 대해서만 동작하게끔 구성하는 것이었다.
하지만 이 MSA도 한계점이 존재하는데, 트랜잭션 처리 관점에서 보았을때 아래와 같은 문제점이 있다.
단일 프로세스 상에서 단순히 다른 서비스를 호출하는 방식으로 이루어지는 Monolithic 구조와는 달리, MSA는 모든 서비스가 별도의 컴포넌트로 "독립적인 동작"을 하기에 이 상태에서 서로에게 통신(이벤트 전송 등)을 할 수 있는 방안이 필요하다.
통신 방식은 크게 두가지로 나눌 수 있는데,
따라서 각 통신 방법(IPC, Inter Process Communication)에 따라 적절한 사용 방안을 선택해야 한다.
메시지 브로커 여부에 따라 통신방법 두가지를 잠깐 살펴보자면 아래와 같다.
Akka
→ 액터 기반의 자바와 스칼라 언어를 모두 지원하는 오픈소스 툴킷.
→ 별도의 메시지 브로커 없이 메시징 자체로만 동작하기에, 관리 대상 컴포넌트가 추가되지 않는다는 장점이 있으나 유지보수성이 낮다.
Kafka
→ 메시지 브로커를 통한 비동기 메시징 방식 차용하는 프레임워크.
→ 단순 메시징 처리가 아닌 중개를 해주는 프로그램이므로, 관리 대상 컴포넌트가 추가된다.
→ Kafka의 메시지 브로커를 복제하여 고가용성을 의도할 경우, 원본의 장애를 그대로 영향받는 단일장애점(SPOF) 가능성이 존재한다.
→ 이를 방지하기 위한 클러스터링을 해야하여 복잡도는 증가하지만 가용성이 높고, MSA에서 많이 사용하는 통신 방법이다.
관리해야 하는 컴포넌트가 증가하고 구조가 복잡해지기에 Monolithic와 비교하였을때 개발 및 운영 비용이 더 커질 수 있다.
따라서 이에 대한 전략적인 선택이 필요할 것이다.
Monolithic 구조에서는 DB를 일원화(이원화하더라도 데이터 이관 정도로 관리하며 실제 처리는 원본에서만 작동)하기에 데이터 정합성 보장이 가능하다.
MSA 구조에서는 이러한 연결관계로 정의된 데이터들을 각각 마이크로서비스의 DB에 분산하여 관리하고, 이로 인해 데이터 정합성을 유지해야 하는 방안을 고민해야 한다.
단순히 동기식 통신을 통한 처리는 네트워크 장애 등의 변수를 생각해야 하므로 한계가 많으므로 비동기식 분산 트랜잭션을 활용한다.
따라서 비동기식 분산 트랜잭션의 동시성, 정합성 문제를 고려하여 데이터의 안전함을 보장할 수 있는 방안을 고민해야 할 것이다.
MSA 구조에 대해서는 더 깊게 공부하고 향후 기록할 예정이다.
배달의 민족에서 바라보는 MSA - MSA, 배달의 민족 마이크로서비스 여행기 정리
MSA가 좋지만은 않은 이유 - Sungho's Blog - https://sgc109.github.io/2021/10/22/about-msa/
MSA에서의 통신방식 - 마이크로서비스 간 통신 과정에서 주의해야할 점
Kafka - Kafka 기본 개념 및 용어 정리