본문 바로가기
📕 Spring Framework/Spring Project

「OutBox Pattern」 활용

by GroovyArea 2022. 6. 10.

https://github.com/GroovyArea/MyChickenBreastShop/wiki/Version-1

 

GitHub - GroovyArea/MyChickenBreastShop: ChikenBreastShop API with Spring boot

ChikenBreastShop API with Spring boot. Contribute to GroovyArea/MyChickenBreastShop development by creating an account on GitHub.

github.com

프로젝트 초기 작성한 Wiki 문서에서 계획한 기능은 다 구현이 되었다. 
게시판, 배송, 채팅 기능 같은 경우는 부수적이므로 다양한 기능을 얕게 구현하는 것보다 기능 하나를 구체적으로 고려하며 구현하는 것이 더 의미 있겠다는 판단하에 기능 구현은 여기서 종료하게 되었다.

이번 3일 간 리팩터링 하며 구현한 API는 총 두 가지인데 이메일 인증 번호 전송과, 주문 API이다. 두 개 모두 동시성과 성능을 고려하여 리팩터링을 진행했다. 

내가 이번 프로젝트를 진행하면서 느낀 점은 단순히 기능을 구현하는 건 정말 쉽다.. 아무것도 아닌데.
기능이 적더라도 여러 가지 변수를 고려하며 확실하게 구현하는 게 더 의미가 있지 않나 싶다. 누군가는 로그인 기능을 프로젝트로 하는데 여러 문제들을 고려하며 만들자니 되게 어렵다는 얘기들을 한다. 그만큼 개발 공부에 남는 게 있을 것이고, 여러 가지 변수를 고려하며 시야가 넓어질 것이다. 
앞으로는 다양한 시각으로 바라보는 연습을 해야겠다.

 

동시성 문제

1. 이메일 인증 번호 전송 

=> 회원 가입 시 이메일로 인증 번호를 전송해준다. 하지만 본 서버에 장애가 생길 시 이메일 전송 서비스도 마비가 생길 수 있다. 그러면 어떻게 해야 할까?

 

MSA 환경을 고려해 보았다

https://sweeeetgoguma.tistory.com/entry/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B6%84%EC%82%B0-%EC%B2%98%EB%A6% AC-Micro-Service-Architecture

 

데이터 분산 처리 [Micro Service Architecture]

프로젝트를 진행 중이다. 프로젝트의 규모가 커질 수록 계층 간 DTO 객체를 이용하는 일이 많아졌다. 불변 객체를 적절히 설계해야 할 필요를 느끼며 최대한 클래스 설계를 잘했다. 리뷰를 받던

sweeeetgoguma.tistory.com

 

내가 진행하는 방식은 모놀리식 아키텍쳐이다. msa로의 변환은 어렵지만 환경 자체를 충분히 고려할 순 있다.

찾아보니 멀티 모듈 이란 개념이 있었다. 그것을 적용시켜 보고자 했다.

이메일 전송 서비스를 다른 모듈로 이전할 경우 본 서버와 별개로 서비스를 제공할 수 있다. 

 

=> 본 서버와 메일 서버를 각각 모듈로 분리했다

 

그럼 회원가입 요청을 어떻게 알고 이메일을 보내는 걸까?

=> OutBox 패턴에 대해서 알아보았다. 

=> 참조 : https://daddyprogrammer.org/post/14068/database-migration-by-transactional-outbox/

 

Database Migration by Transactional Outbox Pattern

이전 실습까지는 데이터 마이그레이션을 위해 Database에서 제공하는 binlog나 DynamoDB/MongoDB에서 제공하는 Change Stream을 통해 변경 데이터를 처리할 수 있었습니다. 하지만 이렇게 시스템적으로 지원

daddyprogrammer.org

 

쉽게 요약하자면 메시지 큐와 비슷한 개념이다.

어떤 요청 -> 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명이 동시에 주문을 할 수 있다. 두 개의 스레드가 동시에 조회를 하는 것이다. 

 

참조 : https://sweeeetgoguma.tistory.com/entry/20220602-%E3%80%8CDB-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%E3%80%8D

 

2022.06.02 「DB 동시성 문제」

자바 기반 웹 프로그램은 기본적으로 멀티스레딩을 기반으로 하기 때문에, 동시성 관련 문제를 잘 해결해야 한다고 들었다. 이번에 내가 하는 쇼핑몰 프로젝트에서도 그 이슈가 딱 터졌다. 예를

sweeeetgoguma.tistory.com

 

내 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

 

반응형