https://github.com/GroovyArea/MyChickenBreastShop/wiki/Version-1
프로젝트 초기 작성한 Wiki 문서에서 계획한 기능은 다 구현이 되었다.
게시판, 배송, 채팅 기능 같은 경우는 부수적이므로 다양한 기능을 얕게 구현하는 것보다 기능 하나를 구체적으로 고려하며 구현하는 것이 더 의미 있겠다는 판단하에 기능 구현은 여기서 종료하게 되었다.
이번 3일 간 리팩터링 하며 구현한 API는 총 두 가지인데 이메일 인증 번호 전송과, 주문 API이다. 두 개 모두 동시성과 성능을 고려하여 리팩터링을 진행했다.
내가 이번 프로젝트를 진행하면서 느낀 점은 단순히 기능을 구현하는 건 정말 쉽다.. 아무것도 아닌데.
기능이 적더라도 여러 가지 변수를 고려하며 확실하게 구현하는 게 더 의미가 있지 않나 싶다. 누군가는 로그인 기능을 프로젝트로 하는데 여러 문제들을 고려하며 만들자니 되게 어렵다는 얘기들을 한다. 그만큼 개발 공부에 남는 게 있을 것이고, 여러 가지 변수를 고려하며 시야가 넓어질 것이다.
앞으로는 다양한 시각으로 바라보는 연습을 해야겠다.
동시성 문제
1. 이메일 인증 번호 전송
=> 회원 가입 시 이메일로 인증 번호를 전송해준다. 하지만 본 서버에 장애가 생길 시 이메일 전송 서비스도 마비가 생길 수 있다. 그러면 어떻게 해야 할까?
MSA 환경을 고려해 보았다
내가 진행하는 방식은 모놀리식 아키텍쳐이다. msa로의 변환은 어렵지만 환경 자체를 충분히 고려할 순 있다.
찾아보니 멀티 모듈 이란 개념이 있었다. 그것을 적용시켜 보고자 했다.
이메일 전송 서비스를 다른 모듈로 이전할 경우 본 서버와 별개로 서비스를 제공할 수 있다.
=> 본 서버와 메일 서버를 각각 모듈로 분리했다
그럼 회원가입 요청을 어떻게 알고 이메일을 보내는 걸까?
=> OutBox 패턴에 대해서 알아보았다.
=> 참조 : https://daddyprogrammer.org/post/14068/database-migration-by-transactional-outbox/
쉽게 요약하자면 메시지 큐와 비슷한 개념이다.
어떤 요청 -> API 응답 -> 요청 데이터를 저장할 OutBox 테이블에 관련 데이터 저장 -> 테이블을 바라보는 모듈에서 요청 건 순차처리
=> 이렇게 해서 하나의 트랜잭션 단위로 묶으면 분산 데이터 처리 환경에서 데이터 정합성에 대한 부분이 해소가 된다.
=> 스케쥴러를 이용 시 ALO (At Least Once) 도 적용시킬 수 있다.
코드
/**
* 이메일을 받고 관련 정보를 DB에 저장 후 아웃박스 패턴을 위해 이벤트 발생
* @param emailRequestDTO 이메일 DTO
*/
@Transactional
public void saveEmailKey(UserEmailRequestDTO emailRequestDTO) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiredTime = now.plusMinutes(5);
EmailKey emailKey = toEmailKeyVO(expiredTime, emailRequestDTO.getUserEmail());
emailKeyMapper.insertEmailKey(emailKey);
EmailKey saved = emailKeyMapper.selectEmailKey(emailKey.getId());
applicationEventPublisher.publishEvent(
outBoxEventBuilder.createOutBoxEvent(EmailKeyCreated.builder()
.emailKeyId(saved.getId())
.emailKey(saved.getEmailKey())
.email(saved.getEmail())
.build())
);
}
이메일 인증 요청이 왔다. 매개변수로 DTO를 받는다.
이메일을 전송해야 하니 이벤트를 발생시킨다. (ApplicationEventPublisher 이용)
/**
* 이벤트 빌더 <br>
* 발생된 이벤트를 가공, payload를 json 형태로 변환 후 객체로 반환
*/
@Component
@Slf4j
public class EmailEventBuilder implements OutBoxEventBuilder<EmailKeyCreated> {
private static final String EVENT_ACTION = "이메일";
@Override
public OutBoxEvent createOutBoxEvent(EmailKeyCreated domainEvent) {
JsonNode jsonNode = ObjectMapperUtil.getMapper().convertValue(domainEvent, JsonNode.class);
return new OutBoxEvent.OutBoxEventBuilder()
.aggregateId(domainEvent.getEmailKeyId())
.aggregateType(EmailKey.class.getSimpleName())
.eventType(domainEvent.getClass().getSimpleName())
.eventAction(EVENT_ACTION)
.payload(jsonNode.toString())
.build();
}
}
=> 이메일 관련된 OutBoxEmail 객체를 만들어준다.
/**
* 아웃박스 객체 DB에 저장
*/
@Component
@RequiredArgsConstructor
public class OutBoxEventHandler {
private static final String ORDER = "주문";
private static final String EMAIL = "이메일";
private static final String CART = "장바구니 주문";
private final OutBoxMapper outBoxMapper;
@EventListener
public void doOutBoxEvent(OutBoxEvent outBoxEvent) {
switch (outBoxEvent.getEventAction()) {
case EMAIL:
outBoxMapper.insertEmailOutBox(OutBox.builder()
.aggregateId(outBoxEvent.getAggregateId())
.aggregateType(outBoxEvent.getAggregateType())
.eventType(outBoxEvent.getEventType())
.payload(outBoxEvent.getPayload())
.build());
break;
=> 만들어진 객체를 토대로 OutBox 테이블에 정보를 저장한다.
/**
* 이메일 스케쥴러 <br>
* 10초 간격으로 outbox 테이블 데이터 조회 후 메일 발송
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class EmailSendScheduler {
private static final long EXPIRE_DURATION = 60 * 5L;
private final RedisService redisService;
private final OutBoxEmailMapper outBoxMapper;
private final SendMailService sendMailService;
private final MailContentService mailContentService;
@Transactional
@Scheduled(cron = "0/10 * * * * ?")
public void schedulingValidNumberEmail() {
ObjectMapper objectMapper = new ObjectMapper();
log.info("이메일 전송 중...");
List<OutBox> outBoxList = outBoxMapper.selectAllOutBox();
if (!outBoxList.isEmpty()) {
List<Long> completedList = new LinkedList<>();
outBoxList.forEach(outBox -> {
String payload = outBox.getPayload();
try {
JsonNode jsonNode = objectMapper.readTree(payload);
String userEmail = jsonNode.get("email").asText();
String authKey = jsonNode.get("email_key").asText();
MailDTO content = mailContentService.createMailContent(payload);
sendMailService.sendEmail(content);
completedList.add(outBox.getId());
redisService.setDataExpire(userEmail, authKey, EXPIRE_DURATION);
} catch (MailException e) {
log.error("메일 발송 중 오류 발생 . . .");
} catch (FailedPayloadConvertException | JsonProcessingException e) {
log.error(e.getMessage());
}
});
if (!completedList.isEmpty()) {
outBoxMapper.deleteAllById(completedList);
}
}
}
=> 스케쥴러가 OutBox 테이블을 항시 바라보고 있다. 데이터가 있을 때 저장된 정보를 통해 이메일을 전송시킨다. 그 후 Redis에 인증번호를 저장한다. -> 인증을 위함
이렇게 하면 인증번호 데이터에 대한 데이터 정합성을 분산 처리 환경에서도 보장받을 수 있다. 하나의 트랜잭션으로 묶여있기 때문이다.
2. 주문 건에 대한 상품 재고 조회
=> 상품 주문 시 재고 파악을 먼저 해야 한다. 그리고 결제가 가능하게 해야 한다. 멀티 스레드 환경을 생각해보자. 동시에 접속한 유저들이 너도 나도 인기가 많은 상품을 주문할 수 있다. 하지만 이 상품은 재고가 한정적이다. 심지어 1개가 남아있을 때 2명이 동시에 주문을 할 수 있다. 두 개의 스레드가 동시에 조회를 하는 것이다.
내 DBMS : Mysql
=> 기본적으로 넥스트 키락을 지원하고 Repeatable Read 격리 수준이므로 동시성 문제를 일으키진 않지만,
기존에 레코드 락을 추가로 걸어서 해결했지만, 트래픽이 많이 몰릴 경우 성능 상 속도가 너무 느려질 수 있다.
그래서 역시 OubBox 패턴을 적용시켰다.
코드
List<OrderCreated> orderCartList =
getOrderCreatedList(productNoArr, productNameArr, productStockArr, totalAmount);
/* 재고 확인 이벤트 발생 */
applicationEventPublisher.publishEvent(
outBoxEventCartBuilder.createOutBoxEvent(orderCartList)
);
장바구니로 설명하겠다. 위와 동일
이벤트 발생
/**
* 장바구니 주문 아웃박스 이벤트 객체 빌더
*/
@Component
@Slf4j
public class CartOrderEventBuilder implements OutBoxEventBuilder<List<OrderCreated>> {
private final static String EVENT_ACTION = "장바구니 주문";
@Override
public OutBoxEvent createOutBoxEvent(List<OrderCreated> domainEvent) {
long firstId = domainEvent.get(0).getItemNumber();
return new OutBoxEvent.OutBoxEventBuilder()
.aggregateId(firstId)
.aggregateType(List.class.getSimpleName())
.eventType(domainEvent.getClass().getSimpleName())
.eventAction(EVENT_ACTION)
.cartList(domainEvent)
.build();
}
}
=> OutBox 이벤트 객체 생성 (List 정보 담아서) 및 반환
/**
* 주문 아웃 박스 조회 스케줄러 <br>
* 10초마다 주문 건에 대한 재고 파악을 한다.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class CheckStockScheduler {
private final OutBoxMapper outBoxMapper;
private final ProductMapper productMapper;
private final KakaoPayService kakaoPayService;
@Transactional
@Scheduled(cron = "0/10 * * * * ?")
public void schedulingCheckStock() {
ObjectMapper objectMapper = new ObjectMapper();
log.info("재고 확인 중 . . .");
List<OutBox> outBoxList = outBoxMapper.selectAllOrderOutBox();
if (!outBoxList.isEmpty()) {
List<Long> completedList = new LinkedList<>();
outBoxList.forEach(outBox -> {
String payload = outBox.getPayload();
try {
JsonNode jsonNode = objectMapper.readTree(payload);
String itemName = jsonNode.get("item_name").asText();
if (productMapper.selectStockOfProduct(itemName) < Integer.parseInt(jsonNode.get("quantity").asText())) {
kakaoPayService.changeStockFlag(false);
}
completedList.add(outBox.getId());
} catch (JsonProcessingException e) {
log.error(e.getMessage());
}
});
if (!completedList.isEmpty()) {
outBoxMapper.deleteAllById(completedList);
}
}
}
}
=> 스케쥴러를 통해 주기적으로 조회 및 재고 파악
재고 파악에 문제가 생길 시 flag 논리 변수를 false로 바꾼다.
public void changeStockFlag(boolean flag) {
this.exceptionFlag = flag;
}
=> 플래그 변경 메서드
/* 재고 품절 예외 발생 */
if (!this.exceptionFlag) {
throw new RunOutOfStockException();
}
트랜잭션 서비스 내의 조건문 (에러 발생 시 모두 롤백된다)
@ExceptionHandler(RunOutOfStockException.class)
public ResponseEntity<String> runOutOfException(RunOutOfStockException e) {
return ResponseEntity.ok().body(e.getMessage());
}
=> 예외를 던져 ExceptionHandler로 처리
결론
아웃 박스 패턴을 활용해서 데이터 분산 처리 환경에서 정합성 문제와 ALO를 적용시켜 리팩터링을 진행했다. 처음에야 어렵지 하다 보니 나름 괜찮게 이해한 것 같다.
확실히 다양한 환경을 고려하며 코드를 작성해야 될 것 같다. 더 현실에 가까운 코드를 짜려고 노력해보자!
앞으로의 리팩터링도 수준 높게 해보자아!!!!
깃헙 : https://github.com/GroovyArea/MyChickenBreastShop
'📕 Spring Framework > Spring Project' 카테고리의 다른 글
「테스트 코드 & Spring REST Docs」 (0) | 2022.06.20 |
---|---|
OutBox Pattern & Saga Pattern & Transaction (0) | 2022.06.13 |
2022.06.07 「프로젝트 중간 점검」 (0) | 2022.06.07 |
2022.06.02 「DB 동시성 문제」 (0) | 2022.06.02 |
2022.06.01 「결제 API - Ver.2」 (2) | 2022.06.01 |