기능적 요구사항
- 사용자가 도서를 예약한다.
- 도서 예약 시 결제가 완료되어야 한다.
- 사용자가 예약 중인 도서를 대여한다.
- 사용자가 대여 중인 도서를 반납한다.
- 사용자가 예약을 취소할 수 있다.
- 도서 예약 취소 시에는 결제가 취소된다.
- 사용자가 예약/대여 상태를 확인할 수 있다.
비기능적 요구사항
- 트랜잭션
- 결제가 되지 않은 경우 예약할 수 없다 (Sync 호출)
- 장애격리
- 도서관리 기능이 수행되지 않더라도 대여/예약은 365일 24시간 받을 수 있어야 한다 Async (event-driven), Eventual Consistency
- 결제 시스템이 과중되면 사용자를 잠시동안 받지 않고 예약을 잠시후에 하도록 유도한다 Circuit breaker, fallback
- 성능
- 사용자는 MyPage에서 본인 예약 및 대여 도서의 목록과 상태를 확인할 수 있어야한다 CQRS
- Chris Richardson, MSA Patterns 참고하여 Inbound adaptor와 Outbound adaptor를 구분함
- 호출관계에서 PubSub 과 Req/Resp 를 구분함
- 서브 도메인과 바운디드 컨텍스트의 분리: 각 팀의 KPI 별로 아래와 같이 관심 구현 스토리를 나눠가짐
분석/설계 단계에서 도출된 헥사고날 아키텍처에 따라, 각 BC별로 대변되는 마이크로 서비스들을 스프링부트로 구현하였다. 구현한 각 서비스를 로컬에서 실행하는 방법은 아래와 같다 (각자의 포트넘버는 8081 ~ 808n 이다)
cd book
mvn spring-boot:run
cd mypage
mvn spring-boot:run
cd payment
mvn spring-boot:run
cd rental
mvn spring-boot:run
- 각 서비스내에 도출된 핵심 Aggregate Root 객체를 Entity 로 선언하였다: (예시는 book 마이크로 서비스). 이때 가능한 현업에서 사용하는 언어 (유비쿼터스 랭귀지)를 그대로 사용하려고 노력했다.
package library;
import javax.persistence.*;
import org.springframework.beans.BeanUtils;
import java.util.List;
@Entity
@Table(name="Book_table")
public class Book {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private Long bookId;
private String bookStatus;
private Long memberId;
private Long rendtalId;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getBookId() {
return bookId;
}
public void setBookId(Long bookId) {
this.bookId = bookId;
}
public String getBookStatus() {
return bookStatus;
}
public void setBookStatus(String bookStatus) {
this.bookStatus = bookStatus;
}
public Long getMemberId() {
return memberId;
}
public void setMemberId(Long memberId) {
this.memberId = memberId;
}
public Long getRendtalId() {
return rendtalId;
}
public void setRendtalId(Long rendtalId) {
this.rendtalId = rendtalId;
}
}
- Entity Pattern 과 Repository Pattern 을 적용하여 JPA 를 통하여 다양한 데이터소스 유형 (RDB or NoSQL) 에 대한 별도의 처리가 없도록 데이터 접근 어댑터를 자동 생성하기 위하여 Spring Data REST 의 RestRepository 를 적용하였다
package library;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface BookRepository extends PagingAndSortingRepository<Book, Long>{
}
- 적용 후 REST API 의 테스트 시나리오
- SAGA
- CQRS
- Correlation
# 사용자가 도서를 예약한다
http POST http://20.194.7.119:8080/rentals memberId=1 bookId=1
#Rental 내역 확인
http GET http://20.194.7.119:8080/rentals
# 사용자 예약 후 결제확인
http GET http://20.194.7.119:8080/payments
# 사용자 예약한 책 상태 확인
http GET http://20.194.7.119:8080/books
# 사용자 도서 예약취소
http PATCH http://20.194.7.119:8080/rentals/1 reqState="cancel"
# 결제취소 확인
http GET http://20.194.7.119:8080/rentals/1
# 사용자 예약 취소한 책 상태 확인
http GET http://20.194.7.119:8080/books
#마이페이지 확인
http GET http://20.194.7.119:8080/mypages/1
# 사용자 도서 예약
http POST http://20.194.7.119:8080/rentals memberId=1 bookId=1
# 사용자 도서 대여
http PATCH http://20.194.7.119:8080/rentals/2 reqState="rental"
# 사용자 대여한 책 상태 확인
http GET http://20.194.7.119:8080/books/
# 사용자 도서 반납
http PATCH http://20.194.7.119:8080/rentals/2 reqState="return"
# 사용자 반납한 책 상태 확인
http GET http://20.194.7.119:8080/books
#마이페이지 확인
http GET http://20.194.7.119:8080/mypages/2
- Request / Response
## 동기식 호출 과 Fallback 처리
분석단계에서의 조건 중 하나로 대여(rental)->결제(payment) 간의 호출은 동기식 일관성을 유지하는 트랜잭션으로 처리하기로 하였다. 호출 프로토콜은 이미 앞서 Rest Repository 에 의해 노출되어있는 REST 서비스를 FeignClient 를 이용하여 호출하도록 한다.
- 결제서비스를 호출하기 위하여 Stub과 (FeignClient) 를 이용하여 Service 대행 인터페이스 (Proxy) 를 구현
@FeignClient(name="payment", url="${api.payment.url}") public interface PaymentService {
@RequestMapping(method= RequestMethod.POST, path="/payments")//, fallback = PaymentServiceFallback.class)
public void payship(@RequestBody Payment payment);
}
- 예약 이후(@PostPersist) 결제를 요청하도록 처리
@PostPersist
public void onPostPersist(){
Reserved reserved = new Reserved();
BeanUtils.copyProperties(this, reserved);
reserved.publishAfterCommit();
//Following code causes dependency to external APIs
// it is NOT A GOOD PRACTICE. instead, Event-Policy mapping is recommended.
library.external.Payment payment = new library.external.Payment();
// mappings goes here
payment.setId(this.id);
payment.setMemberId(this.memberId);
payment.setBookId(this.bookId);
payment.setReqState("reserve");
RentalApplication.applicationContext.getBean(library.external.PaymentService.class)
.payship(payment);
}
- 동기식 호출에서는 호출 시간에 따른 타임 커플링이 발생하며, 결제 시스템이 장애가 나면 주문도 못받는다는 것을 확인:
# 결제 (payment) 서비스를 잠시 내려놓음
#주문처리
http http://localhost:8081/rentals memberId=1 bookId=1 #Fail
#결제서비스 재기동
cd payment
mvn spring-boot:run
#주문처리
http http://localhost:8081/rentals memberId=1 bookId=1 #Success
- 또한 과도한 요청시에 서비스 장애가 도미노 처럼 벌어질 수 있다. (서킷브레이커, 폴백 처리는 운영단계에서 설명한다.)
결제 이후 도서관리(book)시스템으로 결제 완료 여부를 알려주는 행위는 비 동기식으로 처리하여 도서관리 시스템의 처리로 인해 결제주문이 블로킹 되지 않도록 처리한다.
- 이를 위하여 결제이력에 기록을 남긴 후에 곧바로 결제승인(paid)이 되었다는 도메인 이벤트를 카프카로 송출한다(Publish)
# Payment.java
@Entity
@Table(name="Payment_table")
public class Payment {
...
@PostPersist
public void onPostPersist(){
Paid paid = new Paid();
BeanUtils.copyProperties(this, paid);
paid.publishAfterCommit();
...
}
- 도서관리 서비스는 결제완료 이벤트를 수신하여 자신의 정책을 처리하도록 PolicyHandler 를 구현한다:
# PolicyHandler.java (book)
...
@Service
public class PolicyHandler{
@StreamListener(KafkaProcessor.INPUT)
public void wheneverPaid_(@Payload Paid paid){
// 결제완료(예약)
if(paid.isMe()){
Book book = new Book();
book.setId(paid.getBookId());
book.setMemberId(paid.getMemberId());
book.setRendtalId(paid.getId());
book.setBookStatus("reserved");
bookRepository.save(book);
}
}
}
도서관리 시스템은 대여/결제와 완전히 분리되어있으며, 이벤트 수신에 따라 처리되기 때문에, 도서관리시스템이 유지보수로 인해 잠시 내려간 상태라도 주문을 받는데 문제가 없다:
# 도서관리 서비스 (book) 를 잠시 내려놓음
#주문처리
http http://localhost:8081/rentals memberId=1 bookId=1 #Success
#주문상태 확인
#상점 서비스 기동
cd book
mvn spring-boot:run
#주문상태 확인
http localhost:8080/rentals # 모든 주문의 상태가 "reserved"으로 확인
각 구현체들은 각자의 source repository 에 구성되었고, 사용한 CI/CD 플랫폼은 Azure를 사용하였으며, pipeline build script 는 각 프로젝트 폴더 이하에 cloudbuild.yml 에 포함되었다.
- Deploy / Pipeline
- Circuit Breaker
- 서킷 브레이킹 프레임워크의 선택: Spring FeignClient + Hystrix 옵션을 사용하여 구현함
시나리오는 대여(rental)-->결제(payment) 연결을 RESTful Request/Response 로 연동하여 구현이 되어있고, 결제 요청이 과도할 경우 CB 를 통하여 장애격리.
- Hystrix 를 설정: 요청처리 쓰레드에서 처리시간이 610 밀리가 넘어서기 시작하여 어느정도 유지되면 CB 회로가 닫히도록 (요청을 빠르게 실패처리, 차단) 설정
# application.yml
hystrix:
command:
# 전역설정
default:
execution.isolation.thread.timeoutInMilliseconds: 610
- 피호출 서비스(결제:payment) 의 임의 부하 처리 - 400 밀리에서 증감 220 밀리 정도 왔다갔다 하게
# Payment.java
@PostPersist
public void onPostPersist(){ //결제이력을 저장한 후 적당한 시간 끌기
...
try {
Thread.currentThread().sleep((long) (400 + Math.random() * 220));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
- 부하테스터 siege 툴을 통한 서킷 브레이커 동작 확인:
- 동시사용자 100명
- 60초 동안 실시
$ siege -c100 -t60S -v --content-type "application/json" 'http://rental:8080/rentals POST {"memberId":1, "bookId":1}'
- 운영시스템은 죽지 않고 지속적으로 CB 에 의하여 적절히 회로가 열림과 닫힘이 벌어지면서 자원을 보호하고 있음을 보여줌.
- 약 97%정도 정상적으로 처리되었음.
- Autoscale (HPA)
앞서 CB 는 시스템을 안정되게 운영할 수 있게 해줬지만 사용자의 요청을 100% 받아들여주지 못했기 때문에 이에 대한 보완책으로 자동화된 확장 기능을 적용하고자 한다.
- 결제서비스에 대한 replica 를 동적으로 늘려주도록 HPA 를 설정한다. 설정은 CPU 사용량이 15프로를 넘어서면 replica 를 10개까지 늘려준다:
kubectl autoscale deploy payment --min=1 --max=10 --cpu-percent=15
- CB 에서 했던 방식대로 워크로드를 2분 동안 걸어준다.
siege -c100 -t120S -r10 --content-type "application/json" 'http://localhost:8081/orders POST {"item": "chicken"}'
- 오토스케일이 어떻게 되고 있는지 모니터링을 걸어둔다:
kubectl get deploy pay -w
- 어느정도 시간이 흐른 후 스케일 아웃이 벌어지는 것을 확인할 수 있다:
- siege 의 로그를 보아도 전체적인 성공률이 높아진 것을 확인 할 수 있다.
- Zero-downtime deploy (readiness probe)
- 먼저 무정지 재배포가 100% 되는 것인지 확인하기 위해서 Autoscaler 이나 CB 설정을 제거함
- seige 로 배포작업 직전에 워크로드를 모니터링 함.
- 새버전으로의 배포 시작
kubectl set image ...
- readiness 설정
- seige 의 화면으로 넘어가서 Availability 가 100% 미만으로 떨어졌는지 확인
배포기간중 Availability 가 평소 100%에서 97% 대로 떨어지는 것을 확인. 원인은 쿠버네티스가 성급하게 새로 올려진 서비스를 READY 상태로 인식하여 서비스 유입을 진행한 것이기 때문. 이를 막기위해 Readiness Probe 를 설정함:
- readiness 설정 수정
kubectl apply -f kubernetes/deployment.yaml
- 동일한 시나리오로 재배포 한 후 Availability 확인:
배포기간 동안 Availability 가 변화없기 때문에 무정지 재배포가 성공한 것으로 확인됨.