[프로젝트] 예약 구매 기능을 위한 여정

Godtaek·2024년 3월 1일
0

project

목록 보기
4/4

서론

취업 부트캠프에서 예약 구매 프로젝트라는 주제를 받았을 때는 그냥 MySQL에 주문 데이터 넣고 재고만 동시성 처리해주면 되는 거 아니야? 라고 생각했었는데....

동시성 처리 테스트

짜잔! 1만 개 요청을 안정적으로 효율적으로 처리할 수 있어야 했다. 그리고 다른 여러 문제들이 생겼는데, 일단 사용자 입장에서 예약 구매를 하기 위한 플로우를 보자

  1. 상품 화면 - 상태(초기 상태)
  2. 결제 화면 진입 - 행위
  3. 결제 화면 - 상태
  4. 결제 시도 - 행위
  5. 결제 중 - 상태
  6. 결제 완료 - 상태

일단, 1 → 2로 넘어가는 시점에서 주문 API가, 4 → 5로 넘어가는 시점에서 결제 API가 호출되도록 기획했다.

어? 주문 API를 하나만 쓰면 안되나? 라는 생각을 잠깐했지만, 결제 화면에 진입했지만 재고 부족으로 실패하는 상황에 대해 가장 민감하다는 조건이 존재했기 때문에, API를 2개로 나눴다.

첫번째 시도

주문 API에서 주문 정보를 저장하고, 결제 API에서 재고를 감소시킨다.

처음봤을 때, 이렇게 개발하는게 당연해보였지만 문제가 존재했다.

문제

  1. 주문 → 결제 과정에서 실패했을 경우, 주문 데이터를 어떻게 삭제할 건지?
  2. 1과 관련하여 클라이언트가 결제 화면에서 결제 시도를 한다는 것을 어떻게 확인할 건지?
  3. 1만 개 요청 기준 RDBMS는 속도가 느린데(요청 1개 약 평균 4ms) Redis(요청 1개 약 평균 2.5ms)로 재고를 감소시키면 안될까요?

해결 / 의사결정

  1. Redis에 간략한 주문 정보를 캐싱했다. opsForSet()을 통해서 주문 정보를 캐싱하고, 해당 정보를 결제 API를 호출할 때 불러와서 주문한 내역을 확인할 것이다.

    • 만료 시점을 걸까? 생각을 했지만, 결제 페이지에 존재하는 한 주문 정보를 삭제하면 안 된다고 생각했다. 이 고민은 2번 문제로 이어지게 만들었다.
    • 주문이라는 하나의 트랜잭션이 API 두 개가 진행되는동안 원자성을 보장해야 한다. 글로 쓰니 쉬워보인다
    • SAGA 패턴 등을 공부했다. 그러나 API가 두 개 진행되는 현재 프로젝트에는 맞지 않았다. 분산 환경이나, 여러 DB의 요청을 처리하는 경우 사용하는 패턴이었다.
  2. HeartBeat를 구현해 클라이언트에 결제 페이지에 머무르는 다는 정보를 받았다. Redis pub/sub 구조를 이용해서 사용자 주문 정보를 기반으로 채널을 만든 뒤, 결제 API 호출 전인데 반응이 없다면, 캐싱 데이터를 삭제했다.

    • AtomicBoolean을 사용해서 heartbeat를 보낼 때, 사용자 alive를 false로 세팅하고, 반응이 온다면 alive를 true로 바꾼다. heartbeat를 보낼 때, alive가 false라면 이탈한 것으로 간주하고 캐싱 데이터를 삭제하고 heartbeat를 멈춘다.
    • 클라이언트가 종료될 때 무언가 보내줄 수 있다고는 하는데 안정적이지 않아보였다.
    • 이 해결 방법은 또 다른 문제를 낳았는데....
  3. Redis는 in-memory DB로 RDMBS보단 확실히 빠르다. 그런데 치명적인 문제가 존재했다.

    • 나중에 언급할 이유로 Redis 컨테이너가 Off 되었는데, 재고 감소 데이터가 날라갔다..... 그리고, JPA @Transactional에서 Redis 롤백을 지원하지 않기 때문에 재고와 같은 중요한 데이터에 대해서 Redis는 어울리지 않는다고 생각했다.
    • Redis 자체는 동시성을 보장하지 않는다. 정확히는 동시성은 보장하지만, 병렬성을 보장하지 않아 데이터 정합성을 보장할 수 없다.
    • Redis 분산락, Watch, Multi, EXEC 패턴을 사용해봤는데, 비관적인락에 비해 효율적이지 않았다. 현재 프로젝트 구조에서는 분산락이 어울리지 않았다. Watch, Multi, Exec 패턴은 낙관적인 락과 비슷한 면이 존재했다.

두번째 시도

주문 API에서 주문 정보를 캐싱한다.
Heartbeat를 통해서 결제 페이지에 있는지 확인한다.
결제 API에서 주문을 저장하고, 재고를 감소한다.

설계하면서 완벽하다고 생각했는데 역시나 문제가 존재했다.

문제

  1. Heartbeat가 아니라 Kafka, RabbitMQ로 이벤트 기반 처리하면 되지 않을까?
  2. Heartbeat 서비스가 천 개의 요청도 수행하지 못했다...
  3. 느린데? 비동기를 건드려볼까?
  4. 주문할 때 재고는 어떻게 확인할건데?

해결 / 의사결정

  1. Redis pub/sub을 사용한 이유는 다음과 같다.

    • 프로젝트 기한이 빠듯한 편이라 러닝 커브가 높은 다른 기술 스택을 사용하는 것이 부담스러웠다.
    • Redis pub/sub 또한 많은 클라이언트의 메세지를 효율적으로 처리할 수 있는 구조였다.
    • 휘발성이 Redis pub/sub의 단점인데, 단순 Heartbeat라면 휘발되어도 괜찮았다.
    • 서버로 사용하는 컴퓨터(ASUS ZenBook)의 성능문제도 존재했다. 모듈 7개와 2개의 DB(MySQL, Redis) 컨테이너를 운영하는 것만으로도 버거워했다.
  2. Redis pub/sub은 많은 메세지를 처리하는데 효과적이지만, 채널이 많아진다면 리소스 효율이 급감하는 문제가 존재했다.

    • 하나의 채널에서 메세지를 처리하는 로직을 통해 heartbeat를 추적하도록 바꿨다.
  3. Webflux? 안 된다. 정확히는 실패했다.

    • 일단, 비동기는 동기 프로그램에 비해서 비교적 많은 서버 성능을 요구한다. 앞서 말했듯, 서버 성능이 그렇게 좋지 않다.

    • WebFlux를 사용하려면 모든 과정이 비동기여야 효율적이라는 조언을 들었다. 모든 과정을 비동기로 만들기 위해선 로직 수정 + 다른 기술스택 공부가 수반되어야 했는데, 역시나 앞서 말했듯 프로젝트 일정이 빠듯하여 바꾸기 힘들었다.

      • AtomicBoolean은 하나의 스레드에서 동시성을 보장하는데, WebFlux는 하나의 스레드에서 여러 요청을 처리할 수 있기 때문에 로직을 수정해야 했다.
      • Feign Cleint를 활용하여 다른 모듈의 데이터를 주고 받았는데 이 역시, 동기 처리다. Reactive Feign을 통해 해결할 수 있다고 한다.
  4. 주문할 때 재고 확인하는 문제는 쉽게 해결했는데, 주문 캐시 데이터 양과 남은 재고양을 비교했다.

세번째 시도

주문 API에서 주문 정보를 캐싱한다.
Heartbeat를 통해서 결제 페이지에 있는지 확인한다.
결제 API에서 주문을 저장하고, 재고를 감소한다.
사실 디테일한 부분만 달라졌을 뿐, 크게 달라진 건 없다.

문제

  1. 여전히 느리다는 문제는 해결되지 않았다.
    • 주문 - 결제 API를 1만개 동시 요청한 결과 요청 한 개 평균 17ms가 걸렸다.

해결 방법

  1. 요청 한 개 평균 7.5ms로 낮췄다. 결제 API 반환 데이터를 없앴다.

    • 프론트엔드 입장에서 굳이 결제가 완료되었을 때, 데이터를 받을 필요 없다.
    • 사용자 입장에서도 결제 API에서 주문 데이터가 왔는지, 다른 API에서 주문 데이터가 왔는지 당연히 모른다.
    • 결제 API가 주문 생성, 재고 감소 등 많은 기능을 담당하기 때문에 필요하지 않은 기능은 빼야 했다.

마무리하며

사실.... 모든 문제가 해결된 것은 아니다.
여전히 시간을 더 줄일 수 있다고 생각하며, 비동기 처리가 답일 수 있다고 생각한다.

Webflux를 사용하기 위해서는 현재 로직이나 기술 스택만 아니라 아키텍처, 설계부터 고쳐야 하기 때문에 시간이 남을 때 해볼 생각이다.

1만 개가 조금 넘으면 feign client에서 retryable 에러가 나는 문제도 있다. 이걸 캐싱해서 해결할까 생각해봤는데, 사실 안 된다. order_service모듈에 대한 정리가 필요한 거 같다. 코드를 어떻게 작성해야하는지 고민했을 뿐, 아키텍처에 대해서도 고민이 필요할 것 같다.

profile
성장하는 개발자가 되겠습니다

0개의 댓글