ToyProject

[ToyProject] 상품 구매 서비스 개선 - 2 -

시무룩한개구리 2023. 7. 1. 21:11

[ToyProject] 상품 구매 서비스 개선 - 1 - (tistory.com)

 

[ToyProject] 상품 구매 서비스 개선 - 1 -

[ToyProject] 동시성 문제 해결에 대한 고민 (tistory.com) [ToyProject] 동시성 문제 해결에 대한 고민 현재 프로젝트에서는 동시성 문제를 해결하기 위해 해당 트랜잭션에 비관적 락을 걸어버린다. 비관

2tsumo-hitori.tistory.com

 

한 가지 문제점이 남아있다.

 

재고 감소에 대한 동시성 테스트의 속도는 낮추는 것에 성공했지만,

 

실제 api 요청이 왔을 때 상당히 오랜 시간이 걸리는 것이 문제였다.

 

사실 이만큼 걸릴만도한게, 

 

하나의 구매 요청에 대한 로그를 추적해보면 12개의 메서드를 거쳐서 하나의 트랜잭션이 수행된다.

 

구매 요청 하나에 벌어지는 일을 최대한 요약하자면..

 

1. 상품 이름 조회

2. PG 서버의 결제 검증

3. 주문 저장

 

이 세 가지 라고 볼 수 있겠다.

 

하나의 요청이 너무 많은 책임을 가지고 있다.

 

이렇게 되면 특정 영역에서 사이드이팩트가 생겼을 경우 해결하기가 어려워 질 수도 있다.

 

물론 그런일을 최대한 배제하기 위해서, 추상화를 지키면서 한 클래스에 하나의 책임만 가지게 하는 등의 작업 등 결합도를 낮추기 위해 노력했다.

 

하지만 좀 더, 근본적인 해결이 필요한 시점 같았다.

 

작금의 상황에 대한 해결 방법으로 우선 첫 번째로 든 생각은 요청을 두번으로 나뉘어서 처리하는 것이었다.

 

먼저, PG 서버의 결제 검증이 성공적으로 끝나게 되면 프론트 서버로 결과를 반환하고,

 

그 후에 프론트쪽에서 서버 api로 한번 더 요청을 보내서 주문을 저장하는 것이다.

 

이 방법에는 몇 가지 문제점이 있다.

 

1. 여러번의 요청과 응답 중 네트워크 문제로 인해 PG 서버의 결제 검증 결과를 잃을 수 있음.

 

- 이 상황은 최대한 피하고싶었다.  단순 회원가입이나 게시판 글 쓰기 같은 CRUD가 아니라, 구매 서비스는 실제 금액이 오가는 서비스이다.

 

물론 PG서버 쪽에서 누락된 데이터를 가져와서 정상 구매 처리가 가능하겠지만, 이는 방어 코드를 많이 짜놔야 하는 생산성의 낭비라고 생각했다.

 

2. 한두개 정도의 요청이야 괜찮겠지만, 만약 이 서비스가 엄청나게 성장해서 초당 1000건의 구매 요청이 들어오게 된다면?

 

- 이 상황 또한 피해야한다. 서버 쪽에서는 논리적으로는 하나의 요청이지만, 실제로는 여러 번의 요청을 받을 수 밖에 없기때문에 애플리케이션 서버에 많이 부담이 될 수 있다.

 

 

2번 상황에 대해 고민하다가 나온 결과가 비동기 처리 방식이었다.

 

WebClient나 RestTemplate를 사용해서 비동기로 처리해주면 어떨까?

 

검증 이후의 작업은 비동기로 처리되기 때문에 구매 요청 자체는 기존 로직보다 더 빨리 끝나게되고, 이후에 비동기 요청이 도착하면 주문 생성 작업을 진행해주면 된다!

 

하지만 이 또한 근본적인 문제 해결은 되지 못한다. 왜냐하면.. 어차피 비동기 요청을 보내봤자 우리 서버로 보내게될텐데, 2번 상황을 전혀 해결 할 수가 없는 것이다.

 

타개책을 고민하던 도중 머릿속에 휙 하고 지나간 하나의 생각이 있었는데,

 

'서버 하나로 감당이 안되면 서버를 여러개를 올리면 되지 않을까?'

 

이런 생각이 들자마자 당장 기존 프로젝트를 멀티모듈로 변경하고, Spring Data Jpa, Spring-Web과 같은 공통 라이브러리를 사용하는 core 모듈, payment(결제)-api 서버, order(주문)-api 서버를 만들었다.

 

중복을 최대한 배제시키면서 payment 서버와 동일한 core모듈을 사용하는 모듈을 띄웠으며, 바로 비동기 요청으로 바꿀 준비를 했다.

 

분리한 모듈들은 다음과 같다. integration 모듈에는 외부 서비스에 의존하는 로직을 처리하기 위한 모듈이다.

여기서 또 머릿속을 휙 하고 지나간 생각이 있었는데, 서비스 개선 1편에서 사용했던 Redis의 pub/sub 방식의 분산 락 처리 방법이다.

 

그게 지금 상황이랑 뭔 상관이냐고 생각 할 수 있는데, 중요한건 분산 락 처리가 아니라 Redis에서 분산 락을 처리해주는 방법이다.

 

바로 레디스의 메세지 브로커 시스템이다..!

 

대표적인 메세지 브로커는 Redis, RabbitMQ, Apache Kafka 이 세가지가 있는 것으로 알고있다.

 

이중에서 redis는 메모리에 휘발성으로 저장되기 때문에, redis 서버가 터진다면 주문이 저장되지 않는 끔찍한 문제가 발생 할 것이다.

 

그래서 레퍼런스가 많고 성능이 우월한 Apache Kafka를 사용하기로 결정했다. 사용 방법 또한 비교적 간단해보였다.

 

메세지 브로커를 활용한 로직 구성은 다음과 같다.

 

1. 결제 API 요청이 들어옴

 

2. 결제 검증 처리

 

3. Kafka Producer가 order 토픽으로 메세지를 전송함

 

4. order 모듈에서 consumer가 구독한 채널의 메세지를 받아오고, 주문 저장

 

이후에는 코드로 설명하도록 하겠다.

 

@Component
@RequiredArgsConstructor
public class CreateOrderProducer {
    private final KafkaTemplate<String, OrderMessage> kafkaTemplate;

    public void create(Long userId, Long itemId) {
        kafkaTemplate.send("order_topic", new OrderMessage(userId, itemId));
    }
}

Producer가 메세지를 보내는 로직이다.

 

@Service
@RequiredArgsConstructor
@Logger
public class OrderService {

    private final MessageQueueService<OrderMessage> messageQueueService;

    public void orderCreate(OrderMessage listener) {
        messageQueueService.orderListener(listener);
    }
}

 

주문을 생성해주는 서비스(order-api 모듈)이다.

 

@Component
@RequiredArgsConstructor
public class KafkaConsumerService implements MessageQueueService<OrderMessage> {

    private final OrderRepository orderRepository;

    private final MemberRepository memberRepository;

    private final ItemRepository itemRepository;
    @Override
    @KafkaListener(topics = "order_topic", groupId = "order_group")
    public void orderListener(OrderMessage listener) {
        Item item = itemRepository.findById(listener.getItemId()).orElseThrow();
        Member member = memberRepository.findById(listener.getMemberId()).orElseThrow();

        orderRepository.save(Order
                .builder()
                .member(member)
                .item(item)
                .build());
    }
}

구독한 채널에서 메세지를 받고, 실제 주문이 생성되는 로직이다.

 

 

구매 요청의 처리가 85%나 높아졌다.

 

수치로 따지자면 7배나 빨라졌다.