개발이군고구마

[Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 1.멀티 스레드의 동시성을 고려하지 못한 기존 구조 본문

SERVER/Kafka

[Redis/Kafka 이용한 대용량 트래픽 예약 및 쿠폰 발급 서비스] 1.멀티 스레드의 동시성을 고려하지 못한 기존 구조

김구황 2025. 3. 2. 20:13
728x90

1. 기존 구조

1️⃣시퀀스 다이어그램

예약 요청과 그에 따른 부수적인 비즈니스 로직이 한 트랜잭션으로 묶여 있는 구조
  • 회원에 한해서 예약이 가능
  • 예약 요청 인원을 재고 요청 인원과 비교하여, 요청 인원<재고인지 확인
  • 재고 인원이 확보되는 경우 예약 테이블에 저장
  • 쿠폰을 발급 받지 않은 회원인지 확인
  • 쿠폰을 발급 받지 않은 회원에게 쿠폰 발급 (=쿠폰 이력 테이블에 저장)

2️⃣ 클래스 다이어그램

예약 서비스 클래스 내에 쿠폰 서비스 로직이 혼재 되어 있음
  • 예약 처리 클래스 안에서 쿠폰 repository를 의존성 주입 받음
  • 비즈니스 로직의 흐름에 따라 절차적으로 스레드가 실행되는 구조

 

2. 예약 요청

👩🏻‍🌾 HOST 세팅

  • 총 10명의 인원을 수용할 수 있는 HOST 를 생성함
  • 요청(=스레드)을 처리할 수 있는 스레드풀 사이즈 5개로 고정함
  • 2명의 예약 신청 7개가 동시에 들어옴

👥 기대 결과

  • 요청 시도 7개 중 최대 수용 가능 인원 10명에 맞추어 5개 요청 성공
  • 5개 요청이 성공하면 HOST의 수용 가능 인원 0명이 되며, 이후 요청은 재고 부족으로 예약 거절
  • 예약 성공 요청에 대해서는 쿠폰 발급

💥통합 테스트 실제 결과

executor 사용

final int numberOfOrders = 7; // 스레드 7개 요청됨
ExecutorService es = Executors.newFixedThreadPool(5);
CountDownLatch latch = new CountDownLatch(numberOfOrders); 

for (int i = 0; i < numberOfOrders; i++) {
    String taskName = "thread" + i;
    es.execute(() -> {
        try {
            System.out.println("현재 스레드정보 :[" + taskName+"]");
            resrvService.saveRequest(mockPrincipal, histRequestDto);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            latch.countDown(); // 주문 처리 후 countDown
        }
    });
}
latch.await(); // 모든 스레드가 끝날 때까지 대기
es.shutdown(); // 스레드 풀 종료
  • 지정한 풀 사이즈에 따라 5개의 요청이 스레드에 배정됨
  • 5개의 요청이 ‘동시에’ 재고를 확인함
  • 스레드 요청 ‘순서에 상관없이’ 재고를 확인 및 처리
  • 예약 완료 후 재고 차감을 할 때, 모든 요청 스레드가 HOST의 수용 가능 인원을 ‘공유’ 하기 때문에
  • 5개의 요청이 바뀌기 전 값에서 요청 인원을 뺀 값을 저장함
기존의 10개의 수용 인원에서 각 요청별 2를 뺀 8을 모든 스레드가 저장
  • 7개의 요청 중 나머지 2개의 추가 요청이 처리되고, 마찬가지로 2개의 요청이 동시에 처리됨

🤦🏻‍♀️ 7개의 요청이 들어왔음에도 HOST의 수용 가능 인원이 6이 되는 결과

org.opentest4j.AssertionFailedError: 
Expecting:
 <6>
to be equal to:
 <0>
but was not.
Expected :0
Actual   :6

 

3. 무엇이 문제였을까

1️⃣ 우선 스레드에 대해서 알아보자

  • 프로그램: 단순한 파일
  • 프로세스 : 메모리에 실행 중인 프로그램의 인스턴스
  • 프로그램 실행 : 프로세스가 생성 후 코드가 한 줄씩 실행되는 되는 것
  • 스레드 : 코드를 한 줄씩 실행하는 주체 (= 프로세스 내에서 실행되는 작업의 단위)

✔️ 한 프로세스 내에 여러 개의 스레드가 존재할 수 있으며(=멀티스레드)

✔️ 스레드는 프로세스의 있는 동일한 메모리 공간을 공유

스레드가 한 줄씩 코드를 읽어 CPU를 통해 실행함

2️⃣ 스레드는 어떻게 작동할까

  • 스케줄링 : 운영체제가 CPU에 어떤 프로그램이 얼만큼 실행될지 결정하는 방법
  • 컨텍스트 스위칭 : 여러 개의 스레드가 하나의 코어를 공유하며 스레드를 번갈아 실행하는 멀티 태스킹을 진행할 때, 스레드 변경되는 시점 CPU의 값을 메모리에 저장하고 불러오는 과정

3️⃣ 멀티 스레드와 동시성

  • 멀티 스레드 : 한 프로세스에서 여러 개의 스레드가 실행되는 방식. 스레드는 자신이 속한 프로세스의 메모리를 ‘공유
  • 동시성 : 하나의 코어에서 멀티 스레드가 ‘번갈아’ 실행하는 성질
SpringBoot 프로그램이 실행 -> JVM 프로세스가 생성 -> main 스레드 실행됨

4️⃣ 그래서 무엇이 문제였을까

  • 우선, 운영체제의 스케줄링을 예측하여 CPU의 스레드 처리 ‘순서’ 도 보장할 수 없을 뿐더러
  • 먼저 실행된 스레드가 데이터를 변경 커밋하기 이전에 다른 스레드들이 메모리 공유 자원에 접근하여 데이터를 확인함

5️⃣ 추가 문제의 소지 : 한 트랜잭션에 수 많은 디스크 I/O 존재

int curReqPpl = histRequestDto.getReqPpl();
int curIvtPplByHost = hostRepository.countIvtPpl(host.get().getHnum());

if (curReqPpl > curIvtPplByHost) {
    throw new Exception("재고인원 부족");

} else{
    host.get().updateIvtPpl(histRequestDto.getReqPpl());
    hostRepository.save(host.get());

    // 예약 완료
    histRequestDto.setUserid(userId);
    resrvHisRepository.save(histRequestDto.toEntity()); 


    // 쿠폰 발급 로직 
    // 선착순 20명 CPN00001
    Optional<Cpn> cpn = cpnRepository.findById("CPN00001");
    // ** 회원이 쿠폰 가지고 있는지
    int couponCntByUser = cpnRepository.searchCpnByUserId("CPN00001", userId);
    log.info("사용자 쿠폰 확인 조회 : {}", couponCntByUser);

    if (cpn.isPresent() && cpn.get().getIvtCnt() > 0 && couponCntByUser <= 0) {
        // 재고 존재 + 사용자 발급받은 적 없음

        // 쿠폰 재고 차감
        cpn.get().updateIvtCnt();
        cpnRepository.save(cpn.get());
        log.info("쿠폰 재고 차감 후 현재 재고: {}", cpn.get().getIvtCnt());

        // 회원에 따른 쿠폰 발급
        cpnIssuRepository.save(CpnIssu.builder()
                                .cpnNum(cpn.get().getCpnNum())
                                .userid(user.get().getId())
                                .build()
                               );
        log.info("사용자 쿠폰 발급 성공 : {}", cpn.get().getCpnNum());
    } else {
        log.info("쿠폰 발급 실패 재고 부족");
    } 

💥재고 확인

💥쿠폰 발급 확인 및 쿠폰 발급

  • 대량의 트래픽 상황에서 스레드 풀의 모든 스레드가 요청을 처리하고 있는 경우 서버 과부화로 인한 나머지 예약 취소 현상
  • 또는 한 스레드에서 지연이 발생하는 경우 알림 없이 무한 대기 현상
  • 비즈니스 로직이 계속해서 증가하는 경우라면 ? (예, 포인트 적립, ERP연동 등) 계속해서 지연

 

4. 어떻게 해결할 수 있을까

1️⃣ 공유 자원 접근 막기

2️⃣ 로직 분리

  • 예약과 관련되지 않은 부분은 추후에 처리해도 무방함
  • 비동기 처리하여 확장성에 대비

3️⃣ 디스크 I/O 최소화

  • 수 많은 재고 확인을 매번 데이터를 조회하는 것은 비효율
  • 캐시 서버 사용 방법 고려

✔️ 캐시 서버와 DB 데이터를 어떻게 일치 시켜 줄 것인지 고민

 

 


참고 문헌

서울우먼잇츠 스터디 1주차 스터디 발표 자료 

원본 : https://medium.com/@rlawlgh3245/redis-kafka-이용한-대용량-트래픽-예약-및-쿠폰-발급-서비스-1-동시성과-순서를-고려하지-못한-기존-구조-a8eac77fd33f