[쿠폰] 트러블 슈팅 - 동시성 처리

2024. 4. 15. 08:43프로젝트

 

대용량 트래픽이 발생했을때의 상황을 가정하여, 부하테스트를 진행하여 발생한 이슈와 트러블 슈팅에 대해 작성하고자 합니다.

 

 

서비스에서 중요한 부분은 트래픽이 단시간에 몰릴 때 오류가 없고 다운되지 않는 서버를 설계하는 것입니다.

고객 입장에서 서버가 느려지거나 다운된다면 이탈율이 높아지기 때문이겠죠...! 

 

저 같은 경우에도 앱이 먹통이 되거나 오류가 발생하는 일이 잦아지면 다른 대체 앱을 찾고는 합니다. 이것이 대용량 트래픽 경험이 중요한 이유 중 하나일 것입니다.

 

 

단기간에 대량의 트래픽이 몰리는 상황은 아래와 같은 경우들이 있습니다.

  • 뮤지컬 티켓팅
  • 대학교 수강신청
  • 배달어플 및 숙박 앱에서 선착순 할인 쿠폰 뿌리기

배달 어플에서 선착순으로 할인 쿠폰을 발급받았던 경험을 떠올리면서, 할인쿠폰 발급을 주제로 정했습니다.

 

개인 프로젝트 주소 : https://github.com/Jungsu-lilly/First-Come-First-Served-Coupon

 

상황

 

1000개의 할인 쿠폰이 있습니다. 정해진 기간동안 뿌리고, 선착순 1000명이 얻을 수 있습니다.

  • 초당 200명씩 유저가 생성되며, 동시에 쿠폰 발급 요청을 보냅니다.
  • 최대 2000명의 유저가 쿠폰 발급 요청을 보냅니다.

Locust 로 트래픽을 만들어 상황을 가정해, 테스트를 진행했습니다.

 

 

docker-compose.yml

 

컨테이너를 띄워 localhost:8089 에서 테스트를 진행합니다. 3개의 worker가 트래픽을 생성하도록 합니다.

 

실행

docker-compose up -d --scale worker=3

 

 

docker Desktop

 

 

 

 

 

 

 

 

 

coupon (쿠폰 정책) DDL

CREATE TABLE `coupon`
(
    `id`                bigint NOT NULL AUTO_INCREMENT,
    `name`              varchar(50) NOT NULL,
    `type`              varchar(50) NOT NULL COMMENT,
    `total_quantity`    int NULL COMMENT,
    `issued_quantity`   int NOT NULL COMMENT,
    `discount_price`    int NOT NULL COMMENT '할인 금액',
    `minimum_price`     int NOT NULL COMMENT '최소 사용 금액',
    `issue_start_date`  datetime(6) NOT NULL COMMENT '발급 시작 일시',
    `issue_end_date`    datetime(6) NOT NULL COMMENT '발급 종료 일시',
    `created_at`        datetime(6) DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시',
    `updated_at`        datetime(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '수정 일시',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

 

total_quantity = 1000 으로, issued_quantity = 1000 이 되면 발급을 멈춥니다.

쿠폰을 발급하기 전 total_quantity >= issued_quantity 인지 검사하고, 쿠폰을 발급합니다.

 

 

coupon_issuance(쿠폰 발급) DDL

CREATE TABLE `coupon_issuance`
(
    `id`                bigint NOT NULL AUTO_INCREMENT,
    `coupon_id`         bigint NOT NULL COMMENT '쿠폰 ID',
    `user_id`           bigint NOT NULL COMMENT '유저 ID',
    `date_issued`       datetime(6) NOT NULL COMMENT '발급된 일시',
    `date_used`         datetime(6) COMMENT '사용된 일시',
    `created_at`        datetime(6) DEFAULT CURRENT_TIMESTAMP(6) COMMENT '생성 일시',
    `updated_at`        datetime(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '수정 일시',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

어떤 유저가 어떤 쿠폰을 발급했는지 나타내는 테이블입니다.

쿠폰이 실제 발급될 때마다 행이 1개씩 증가합니다.

 

 

쿠폰 발급 로직은 다음과 같습니다.

  • 발급된 수량 확인 : issued_quantity < total_quantity 인경우, 쿠폰을 발급한다.
  • issued_quantity += 1;
  • insert coupon_issuance(coupon_id, user_id)

 

 

테스트 시작

초당 100명의 유저를 새로 생성하고 최대 2000명으로 설정했습니다.

1분간 서버에 부하를 주었습니다.

 

 

실행 결과

 

초반에는 평균 ResponseTime 이 200~300 ms 이었다가, 나중에는 2600 ~ 3000 ms 까지 느려졌습니다.

유저 수는 조금씩 증가하다가 2000명까지 늘어난 것을 확인할 수 있습니다.

 

 

결과 분석

앞서 쿠폰 개수를 1000개로 제한해 선착순으로 1000 명에게 나누어주었습니다.

쿠폰을 발급하기 전 coupon 테이블의 issue_quantity(발급 갯수)를 확인하여  1씩 증가시키므로, coupon_issuance 1000개의 튜플이 생성되는게 맞습니다.

 

coupon 테이블

  • issued_quantity = 1000
  • 1000개 모두 발급되었음을 나타냅니다.

 

coupon_issuance 테이블

 

총 9451 개의 행이 나왔습니다.

 

쿠폰 테이블에서는 1000개로 발급되었다고 적혀있는데, 실제 발급 수량이 9.45배 많은 9451개가 발급된 것입니다.

실제 상황이었다면 예상보다 예산이 9.45배나 많이 들었을 것입니다.

 

 

오류가 발생한 이유

동시성 때문입니다.

쿠폰 발급 요청이 아래와 같이 순차적으로 발생했다면, 문제가 되지 않았을 것입니다.

발급 요청과 처리가 수차적으로 이뤄진 경우

 

두 명의 유저가 2번의 쿠폰 발급 요청을 하고 쿠폰 2개가 발급되었습니다.

 

다음은 쿠폰 발급 요청이 동시에 발생한 경우를 살펴보겠습니다.

동시성 처리를 해주지 않으면 위와 같은 상황이 발생할 수 있습니다.

발급된 수량 확인을 확인하고, 쿠폰을 발급하기 직전 다른 트랜잭션이 들어와 실행될 수 있습니다. 그 결과 실제로 2개의 쿠폰 발급이 이뤄졌지만, issuedQuantity = 1 로 기록됩니다.

 

따라서 coupon_issunance 튜플은 2개가 생성되었지만 coupon 테이블의 issued_quantity = 1 로 기록되어 있습니다.

요청이 동시에 여러 개가 들어오는데 처리가 안되어 있어서 1000개만 발급되었어야 할 쿠폰이 9450개가 발급된 것입니다.

 

 

 

해결 방법

Lock 사용

발급된 수량 확인, 쿠폰 발급 행위를 하나의 락으로 묶는 것입니다.

User1 이 락은 획득해 트랜잭션을 실행하는 동안, User2 에서는 락을 발급을 시도합니다. 하지만 락이 이미 획득된 상태이므로, LOCK 을 얻기위해 대기하게 됩니다. User1이 작업을 마치면, 락이 풀려 User2 에서 획득하게 되면 작업을 실행합니다.

 

LOCK을 사용하면 동시성 문제를 해결할 수 있습니다.

공유 자원을 동시에 접근하는 부분 (critical section)을 순차 처리하게 만드는 것인데, 락은 처리량에 병목을 발생시킬 수 있습니다.

 

  • (a) -> (b) -> (c) 순차처리 하는 것 보다는
  • (a) <-> (b) <-> (c) 병렬처리 하는 것이 더 빠르다.

정확히 수량을 제어할 순 있겠지만, 처리량에는 병목이 생길 수 있습니다.

 

Lock 사용 전

@RequiredArgsConstructor
@Service
public class CouponIssueService {

    private final CouponJpaRepository couponJpaRepository;
    private final CouponIssueJpaRepository couponIssueJpaRepository;
    private final CouponIssueRepository couponIssueRepository;

    @Transactional
    public void issue(long couponId, long userId) {
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);
    }

 

Lock 사용 후 - synchronized

    @Transactional
    public void issue(long couponId, long userId) {
        synchronized (this) {
            Coupon coupon = findCoupon(couponId);
            coupon.issue();
            saveCouponIssue(couponId, userId);
        }
    }

 

synchronized 블럭을 만나면 스레드가 락을 획득하기 전까지는 로직을 실행할 수 없습니다.

한 순간에 하나의 스레드에서만 위 로직을 실행할 수 있고, 순차적으로 실행할 수 있습니다.

 

앞선 상황과 동일하게 테스트 수행

 

수행 결과

 

1000개가 아니라 2098개가 생성되었습니다. 락을 걸었는데 왜 1000개가 생성되지 않았을까요...?

/*
    트랜잭션 시작

    lock 획득
    Coupon coupon = findCoupon(couponId);
    coupon.issue();
    saveCouponIssue(couponId, userId);
    lock 반남

    트랜잭션 커밋
*/

 

위 코드에서 synchronized를 @Transactional 밑에 걸어주었습니다.

User1 이 lock 을 반납해도, 트랜잭션을 커밋된 것은 아닙니다.

따라서 lock 반납 ~ 트랜잭션 커밋 단계 사이에 User2가 들어오게 된다면 

트랜잭션 커밋 전이므로, 데이터베이스에 반영 전에 조회한 쿠폰은 쿠폰 발급처리가 되지 않은 데이터를 조회합니다.

 

그냥 순서를 바꿔주면 됩니다.

/*
    lock 획득
    
    트랜잭션 시작
    Coupon coupon = findCoupon(couponId);
    coupon.issue();
    saveCouponIssue(couponId, userId);
    트랜잭션 커밋
    
    lock 반납
*/

 

 

수정 - 락을 트랜잭션 바깥쪽에서 걸어준다.

@RequiredArgsConstructor
@Service
public class CouponIssueRequestService {

    private final CouponIssueService couponIssueService;
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void issueCoupon(CouponIssueRequestDto requestDto) {
        synchronized (this) {
            couponIssueService.issue(requestDto.couponId(), requestDto.userId());
            log.info("쿠폰 발급 완료. couponId: %s, userId: %s".formatted(requestDto.couponId(), requestDto.userId()));
        }
    }
@RequiredArgsConstructor
@Service
public class CouponIssueService {

    private final CouponJpaRepository couponJpaRepository;
    private final CouponIssueJpaRepository couponIssueJpaRepository;
    private final CouponIssueRepository couponIssueRepository;

    @Transactional
    public void issue(long couponId, long userId) {
        Coupon coupon = findCoupon(couponId);
        coupon.issue();
        saveCouponIssue(couponId, userId);
    }
    
	// 생략 ...
}

 

락 획득 - 트랜잭션 시작 - 트랜잭션 커밋 - 락 반납 순서로 작동하게 바꿨습니다.

 

 

테스트 결과

  • Locust 차트를 보면 락을 걸었을 때 RPS가 현저히 낮아진 것을 볼 수 있습니다.

 

coupon_issuance 테이블에 드디어 정확히 1000개의 튜플이 생성되었습니다.

 

 

synchronized 를 사용해 동시성 이슈를 해결했습니다.

하지만 이 키워드는 는 자바 어플리케이션에 종속되므로, 여러 서버로 확장되면 락 구현이 제대로 되지 않는다는 단점이 있습니다.

따라서 현재 사용중인 기술스택 내에서 분산 락을 구현하려고 합니다.

 

 

사용 중인 기술 스택은 다음과 같습니다.

  • Mysql
  • Redis

 

Redis를 사용한 락

build.gradle 에 redisson 의존성을 추가해줍니다.

dependencies {
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.redisson:redisson-spring-boot-starter:3.16.4")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

 

Redisson은 Redis 기반의 Java in-memory 데이터 프레임워크로, 다양한 자바 객체를 제공합니다.

이 Redisson을 통해 분산 락을 구현할 것입니다.

 

RedissonClient를 사용할 것이기에 Configuration 클래스를 하나 만들어줍니다.

@Configuration
public class RedisConfiguration {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    RedissonClient redissonClient() {
        Config config = new Config();
        String address = "redis://" + host + ":" + port;
        config.useSingleServer().setAddress(address);
        return Redisson.create(config);
    }
}

 

분산 락 구현체를 하나 만들어 줍니다.

@RequiredArgsConstructor
@Component
public class DistributeLockExecutor {

    private final RedissonClient redissonClient;
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void execute(String lockName, long waitMilliSecond, long releaseMilliSecond, Runnable logic) {
        RLock lock = redissonClient.getLock(lockName);
        try {
            boolean result = lock.tryLock(waitMilliSecond, releaseMilliSecond, TimeUnit.MILLISECONDS);
            if (!result) {
                throw new BaseException(400, "["+lockName+"] lock 획득 실패");
            }
            logic.run();
        } catch (InterruptedException e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

redissionClient를 통해 락을 얻어와 획득 및 release 를 구현했습니다. Runnable 인터페이스의 구현체를 얻어와, 해당 로직을 추가해주는 방식입니다.

 

 

위 클래스를 Service 단에 적용시켜줍니다.

@RequiredArgsConstructor
@Service
public class CouponIssueRequestService {

    private final CouponIssueService couponIssueService;
    private final DistributeLockExecutor distributeLockExecutor;
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void issueCouponV1(CouponIssueRequestDto requestDto) {
        distributeLockExecutor.execute("lock_" + requestDto.couponId(), 10000, 10000, () -> {
            couponIssueService.issue(requestDto.couponId(), requestDto.userId());
        });
        log.info("쿠폰 발급 완료. couponId: %s, userId: %s".formatted(requestDto.couponId(), requestDto.userId()));
    }
}

 

이를 통해 synchronized 가 아닌, Redis를 이용한 분산 락을 적용시켜 주었습니다.

 

 

테스트

Locust 로 부하를 발생시켜 1000 개의 선착순 쿠폰이 알맞은 개수로 발급되는지 확인 (전과 동일합니다.)

 

쿠폰 테이블

 

쿠폰 발급 테이블

 

동시성 이슈 없이 정확히 1000개가 발급되었습니다. => 성공

 

synchronized 키워드는 JVM 안에서만 동작하기 때문에 자바에 종속적입니다. 따라서 Redis를 통한 락은 분산 시스템에서도 사용 가능하다는 장점이 있습니다. 기본적으로 레디스는 싱글 스레드로 동작하기 때문에, 단일 레디스 노드를 구축해 사용해도 동시성 문제가 발생하지 않는다고 합니다.