Yeji's Tech Notes
article thumbnail
반응형

요구사항

이번 글에서는 이벤트 쿠폰 발급 시 Redis를 이용해 동시성 처리를 해보겠습니다.

 

 

쿠폰 발급 서비스 동시성 처리하기 1 - (1/3) (feat. synchronized)

요구사항 멀티스레드 환경에서 쿠폰 발급 요청 시 동시성 처리하기 위한 방법으로 synchronized를 사용해보았습니다. Synchronized의 용도 멀티스레드 환경에서 여러 스레드가 하나의 공유 자원에 동

yejipro.tistory.com

 

쿠폰 발급 서비스 동시성 처리하기 2 - (2/3) (feat. Database Lock)

요구사항 지난 번 동시성 처리하기 방식으로 synchronized를 사용했습니다. 이번 글에서는 데이터베이스에서 제공하는 Lock을 이용해 동시성 처리를 해보겠습니다. 쿠폰 발급 서비스 동시성 처리하

yejipro.tistory.com

Redis를 이용한 동시성 처리

레디스를 이용한 동시성 처리방식은 lettuce와 Redisson이 있습니다.

Lettuce

lettuce의 경우 SpringDataRedis에 lettuce가 포함되어있기 때문에 별도의 설정을 하지 않고 사용할 수 있습니다. 하지만 lettuce로 분산락을 구현하기 위해서는 스핀락 형태로 구현해야된다는 단점이 있습니다. 스핀락 형태를 구현하게 되면 lock이 안걸려있을 경우에 획득해야하니 지속적으로 lock을 확인하게 되므로 서비스에 부하를 줄 수 있습니다. 


스핀락 (SpinLock)
락을 획득하기 위해 SETNX명령어를 사용해 value 값이 없을때만 값을 세팅하여 락을 획득하는 효과를 낼 수 있습니다.

SETNX
SET if Not eXist의 줄임말로, 특정 key 값이 존재하지 않을 경우에 set 하라는 명령어 입니다.

 

Redisson

Redisson을 사용하게 되면 앞서 말씀드렸던 서비스 부하와 타임아웃 문제를 신경쓰지 않아도 됩니다. Redisson은 Lettuce와 다르게 pub/sub구조를 이루고 있습니다. 

 

Lettuce을 이용한 동시성 처리

build.gradle 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

 

Redis Lock, unLock 

@RequiredArgsConstructor
@Component
public class CouponLettuceLockPortAdapter implements CouponRedisLockPort {

    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public Boolean lock(String key) {
        return redisTemplate.opsForValue()
                            .setIfAbsent(key, "lock", Duration.ofMillis(3_000));
    }

    @Override
    public Boolean unLock(String key) {
        return redisTemplate.delete(key);
    }
}

 

lock(String key) 메소드에 사용되는 key는 락 획득 유무를 판별할 기준 값입니다. 또한 "lock"으로 선언된 값은 락을 설정할 때 사용되는 값입니다. 이 값은 락 획득한 클라이언트나 스레드를 식별하는데 사용됩니다. Duration.ofMillis(3_000)은 만료되는 시간을 밀리초로 지정합니다. 락을 성공적으로 획득하며 true로 반환하고, 그렇지 않으면 false를 반환합니다.

 

unlock(String key) 메소드는 key를 제거하기 위한 메소드입니다. 락 해제를 위해서 사용합니다.

 

CouponEntity

@Entity
public class CouponEntity extends UpdatedEntity {

    public void issuanceCoupon() {
        if (this.numberOfCoupons < 0) {
            throw new IllegalArgumentException("쿠폰이 모두 소진되었습니다.");
        }
        this.numberOfCoupons--;
    }

}

쿠폰 엔티티 내부에 쿠폰을 감소시키기 위한 issuanceCoupon()메소드를 생성하였습니다. 메소드 호출시 쿠폰 갯수가 한개씩 감소하도록 하였습니다.

 

CreateCouponPortAdapter

@Component
@RequiredArgsConstructor
public class CreateCouponPortAdapter implements CreateCouponPort {

	@Override
    public Coupon issuanceCoupon(String couponId) {
        CouponEntity couponEntity = couponRepository.findById(UUID.fromString(couponId))
                                                    .orElseThrow(() -> new IllegalArgumentException("쿠폰 값을 찾을 수 없습니다."));

        couponEntity.issuanceCoupon();

        return couponEntity.convertToCoupon();
    }
}

couponId값을 이용해 쿠폰이 존재할 경우 감소하는 메소드를 실행시키기 위해 해당 메소드를 생성하였습니다.

 

CouponRedisLockService

@RequiredArgsConstructor
@Component
public class CouponRedisLockService implements CreateCouponWithRedisUseCase {

    private final CouponRedisLockPort couponRedisLockPort;
    private final CreateCouponPort couponPort;

    @Transactional
    @Override
    public CouponResponse decrease(String couponId) throws InterruptedException {

        while (!couponRedisLockPort.lock(couponId)) {
            Thread.sleep(2000);
        }

        try {

            Coupon coupon = couponPort.issuanceCoupon(couponId);
            System.out.println("coupon : " + coupon.getNumberOfCoupons());
            return CouponResponse.from(coupon);

        } finally {
            couponRedisLockPort.unLock(couponId);
        }

    }
}

 

쿠폰 발급 시 동시성처리를 구현하기 위해 decrease(String couponId) 메소드는 생성하였습니다. CouponId를 기준으로 lock이 걸렸을 경우 Thread.sleep()을 이용해 일정시간 동안 대기시키도록 하였습니다. couponId가 lock이 해제되었을 경우에는 while문에서 벗어나 쿠폰을 발급하도록 하였습니다. 다른 곳에서 couponId를 기준으로 쿠폰 발급할 경우를 위해 finally에 unlock 메소드를 호출하도록 선언하였습니다.

 

쿠폰 발급 동시성 테스트 (Lettuce)

@Test
@DisplayName("redis 동시성 테스트")
void test_case_1() throws Exception {
    // given
    List<Coupon> coupons = createCouponPortAdapter.getCoupons();
    if (coupons.isEmpty()) {
        throw new IllegalStateException();
    }
    int threadCount = 100;
    UUID couponId = coupons.get(0)
                           .getCouponId();
    // when
    ExecutorService executor = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            try {
                couponRedisLockService.decrease(String.valueOf(couponId));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();
    // then
    Optional<Coupon> optionalCoupon = createCouponPortAdapter.findById(couponId.toString());
    if (optionalCoupon.isEmpty()) {
        throw new IllegalStateException();
    }

    Coupon coupon = optionalCoupon.get();
    assertEquals(900, coupon.getNumberOfCoupons());
}

 

쿠폰 발급이 정상적으로 이루어지는 것을 확인할 수 있었습니다.

 

Thread.sleep() 사용의 문제

lock 획득 여부를 지속적으로 확인하기 위해 Thread.sleep을 사용하였습니다. 하지만 Thead.sleep()에는 몇가지 문제점이 존재합니다.

1. CPU 리소스 낭비

'Thread.sleep()'은 현재 스레드를 정지시키므로 해당 스레드는 대기 상태가 됩니다. 이는 CPU리소스는 점유하고 있지만 실제로는 아무런 작업을 수행하지 않는 상태입니다.

2. 정확성 문제

'Thread.sleep()'은 정확한 시간을 지정하더라도 그 시간이 완전히 정확하지는 않습니다. 특히 다른 스레드가 CPU를 점유하거나 운영체제가 스케쥴링을 변경할 수 있기 때문에 정확한 시간은 보장할 수 없습니다.

동시성 문제도 존재하지만 CountDownLatch를 사용해 스레드의 동기화와 대기를 관리하였기 때문에 동시성 문제를 제외하더라도 2가지 문제점이 있습니다. 

 

Thread.sleep()의 대안책

Thread.sleep()의 대안책으로 스프링에서는 다양한 기능을 제공해주고 있습니다. 해당 글은 동시성처리에 대한 글이므로 이번 글에서는 우선 생략하고 다음 글에서 재시도 패턴에 대해서 정리해보겠습니다.

 

Redisson을 이용한 동시성 처리

build.gradle 설정

dependencies {
	// https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter
	implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.26.0'
 }

CouponRedissonLockService 구현

@RequiredArgsConstructor
@Component
public class CouponRedissonLockService implements CreateCouponWithRedisUseCase {

    private final RedissonClient redissonClient;

    private final RedisLockProperties redisLockProperties;

    private final CreateCouponPort couponPort;

    @Override
    public CouponResponse decrease(String couponId) throws InterruptedException {
        RLock lock = redissonClient.getLock(couponId);

        try {
            boolean isLocked = lock.tryLock(redisLockProperties.getWaitTime(), redisLockProperties.getLeaseTime(), TimeUnit.SECONDS);

            if (!isLocked) {
                System.out.println("락 획득 실패");
                throw new IllegalThreadStateException("락 획득에 실패하였습니다.");
            }

            Coupon coupon = couponPort.issuanceCoupon(couponId);
            return CouponResponse.from(coupon);

        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }

    }
}

 

redissonClient.getLock()을 사용해 key값의 락을 가져옵니다. tryLock() 메서드를 사용해 대기 시간을 설정 후 대기 시간 동안에도 락을 획득하지 못하는 경우 false를 반환합니다. 그렇지 않은 경우 true를 반환하고 락을 획득한 것으로 간주합니다.

 

tryLock()메서드를 통해 락을 즉시 획득할 수 있는지 확인하고 일정 시간 동안 대기하지 않고 즉시 반환하여 확인 할 수 있습니다.

 

RedissonLock 테스트

@SpringBootTest
class CouponRedissonLockServiceTest {

	@Test
    @DisplayName("redis 동시성 테스트")
    void test_case_1() throws Exception {
        // given
        List<Coupon> coupons = createCouponPortAdapter.getCoupons();
        if (coupons.isEmpty()) {
            throw new IllegalStateException();
        }
        int threadCount = 100;
        UUID couponId = coupons.get(0)
                               .getCouponId();
        // when
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    couponRedisLockService.decrease(String.valueOf(couponId));
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        // then
        Optional<Coupon> optionalCoupon = createCouponPortAdapter.findById(couponId.toString());
        if (optionalCoupon.isEmpty()) {
            throw new IllegalStateException();
        }

        Coupon coupon = optionalCoupon.get();
        assertEquals(900, coupon.getNumberOfCoupons());
    }

}

 

정리

1. Redisson

- redisson은 분산 락, 분산 세마포어 등 동시성 제어를 위한 자료구조를 제공합니다. 이를 통해 여러 프로세스 또는 스레드 간의 동시성 문제를 해결할 수 있습니다.

- redisson은 다양한 동시성 문제에 대한 솔루션을 제공하며, 이를 쉽게 활용할 수 있습니다.

2. Lettuce

- Lettuce는 비동기 및 동기 모드에서 사용할 수 있는 redis 클라이언트 입니다. 

- 높은 성능을 제공하여 대규모 또는 고성능의 시스템에서 사용됩니다. Lettuce의 비동기 특성을 활용해 네트워크 I/O 대기 시간을 최소화 할 수 있습니다.

- Lettuce는 동시성을 위한 별도의 자료구조를 제공하지 않지만, Reactive 프로그래밍 모델을 통해 동시성을 다룰 수 있습니다.

 

Redisson은 분산 시스템에서의 동시성 처리를 위한 다양한 자료구조와 기능을 제공하여 동시성 문제를 쉽게 해결할 수 있도록 도와줍니다. 반면 Lettuce는 락 획득과 해제를 직접 처리해줘야되며 락 획득을 위해 재시도 로직을 구현해야되며 부하가 발생할 수 있습니다. 

 

코드

https://github.com/cyeji/coupon-service

 

GitHub - cyeji/coupon-service

Contribute to cyeji/coupon-service development by creating an account on GitHub.

github.com

 

반응형
profile

Yeji's Tech Notes

@Jop

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!