동시성 문제(Concurrency Issue)와 분산락(Distributed Lock)
동일한 자원(data)에 대해 여러 스레드가 동시에 접근하면서 발생하는 '동시성 문제'는 상품의 재고 관리 등, 프로세스의 여러 곳에서 발생할 수 있는데요.
이러한 동시성 문제를 해결하는 방법 중 하나로 '분산락'이 있으며, 아래 내용은 'Redisson을 통한 분산락을 사용하는 방법'에 대한 정리입니다.
* 동시성 문제(concurrency issue) - 하나의 스레드가 데이터를 수정 중인 상황에서 다른 스레드에서 수정 전의 데이터를 조회하여 수정함으로써 데이터의 정합성(consistency)이 깨지는 문제를 말합니다.
* 분산락(distributed lock) - 경쟁 상황(race condition)에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic)을 보장하는 기법입니다.
Redisson이란?
Redis에서는 분산락을 구현하기 위해 다양한 구현체를 제공하는데요. 'Redisson'은 Java에서 사용되는 Redis 클라이언트입니다.
Lettuce가 아닌 Redisson을 사용하는 이유(락 획득 방식의 차이)
spring boot 2.0부터는 Netty(비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Lettuce가 Redis의 기본 클라이언트로 사용되고 있는데요. (spring-boot-starter-data-redis)
Lettuce는 공식적으로 분산락 기능을 제공하지 않기 때문에 필요한 경우 직접 구현해서 사용해야 합니다.
Lettuce의 락 획득 방식은 락을 획득하지 못한 경우 락을 획득하기 위해 redis에 계속해서 요청을 보내는 '스핀락(spin lock)'으로 구성되어 있으며, 이러한 방식(redis에 계속 요청을 보내는 방식)으로 인해 redis에 부하가 생길 수 있다는 특징이 있습니다.
(부하를 낮추기 위해 락 획득을 재시도하는 시간을 길게 설정하게 되면, 락을 획득할 수 있음에도 불구하고 무조건 설정된 시간만큼 기다려야 하는 비효율적인 경우가 발생할 수 있습니다.)
반면 redisson의 경우 락 획득 시, 스핀락(spin lock) 방식이 아닌 '발행/구독(pub/sub)' 방식이 사용되는데요.
pub/sub 방식은 락이 해제될 때마다 subscribe 중인 클라이언트들에게 '락 획득을 시도해도 된다.'는 알림을 보내기 때문에 클라이언트 측에서는 락 획득에 실패했을 때, redis에 계속 락 획득 요청을 하는 과정이 사라지게 되며, 계속된 요청으로 인한 부하가 발생하지 않게 됩니다.
Redisson 분산락 사용 방법
1. 의존성 추가
// https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.17.0'
(gradle 기준 의존성)
redisson-spring-data-3x가 spring boot 3 이상부터 호환되기 때문에 spring boot 2를 사용하고 있는 경우 org.redisson:redisson-spring-boot-starter 3.17 이하 버전을 사용해야 합니다.
(자세한 내용은 포스팅 맨 하단 redisson github를 통해 참고할 수 있습니다.)
2. RedissonConfig
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
RedissonClient 설정에 사용되는 Config 객체는 useSingleServer() 메서드 외에 다른 메서드를 통해 다양한 형식의 redis 구성에 대한 RedissonClient를 설정할 수 있습니다.
* useMasterSlaveServers(), useSentinelServers(), useClusterServers(), useReplicatedServers()
spring:
redis:
host: 127.0.0.1
port: 6379
(application.yml 파일 중 일부)
3. RedissonLock 사용 코드 예시
public void acquireLock1() {
...
RLock lock = redissonClient.getLock("{key}");
try {
//락 획득 요청
boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
if (!isLocked) {
//락 획득 실패 시 예외 처리
throw new Exception( ... );
}
} catch (InterruptedException e) {
//쓰레드가 인터럽트 될 경우 예외 처리
} finally {
//락 해제
lock.unlock();
}
}
(락 획득 실패 시 exception을 발생시키며, 락 획득 시에는 획득 후 사용이 끝나면 unlock() 메서드를 통해 락을 해제합니다.)
public boolean acquireLock2() {
...
RLock lock = redissonClient.getLock("{key}");
//락 여부 확인
boolean isLocked = lock.isLocked();
if (!isLocked) {
//락이 걸려있지 않은 경우 락 획득
lock.lock(3, TimeUnit.SECONDS);
}
return isLocked;
}
(락 획득 실패 시 return false를 반환하며, 락 획득 시 따로 return true를 반환하는데 unlock을 하지 않고 leaseTime 만큼 잠금을 획득하는 방식입니다.)
//lock method
void lock(long leaseTime, TimeUnit unit);
//tryLock method
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
Redisson을 통한 락에는 RLock 인터페이스가 사용되는데요.
'lock()' 메서드의 경우 leaseTime 만큼 잠금을 획득하며, 잠금 획득 후 leaseTime이 지나면 자동으로 잠금이 해제됩니다.
'tryLock()' 메서드는 return 타입이 boolean으로 잠금 획득에 성공하는 경우 true를, 실패하는 경우 false를 반환하는데요.
lock() 메서드와 마찬가지로 leaseTime 만큼 잠금을 획득하며, 잠금을 획득하지 못한 경우 return false를 반환하기 전까지 최대 waitTime 동안 잠금 획득을 시도합니다.
잠금 획득 후 leaseTime이 지나면 자동으로 잠금이 해제됩니다.
* redisson의 경우 leaseTime 설정을 통해 해당 시간이 지나면 락이 자동으로 만료되도록 구현되어 있는데요. 때문에 애플리케이션에서 락을 해제해주지 않더라도 leaseTime이 지나면 다른 스레드 혹은 애플리케이션에서 락을 획득할 수 있게 됩니다.
(특수한 상황에 의해 락 획득 후 해제가 되지 않아 다른 스레드 혹은 프로세스에서 해당 락을 획득하기 위해 무한정 대기해야 하는 상황이 발생하지 않게 됩니다.)
Redisson Distributed Lock을 통한 동시성 문제 해결 예시
public void useCouponWithLock(final String key) {
final String lockName = key + ":lock";
final RLock lock = redissonClient.getLock(lockName);
final String threadName = Thread.currentThread().getName();
try {
if (!lock.tryLock(1, 3, TimeUnit.SECONDS)) {
return;
}
final int quantity = usableCoupon(key);
if (quantity <= EMPTY) {
log.info("threadName : {} / 사용 가능 쿠폰 모두 소진", threadName);
return;
}
log.info("threadName : {} / 사용 가능 쿠폰 수량 : {}개", threadName, quantity);
setUsableCoupon(key, quantity - 1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock != null && lock.isLocked()) {
lock.unlock();
}
}
}
(CouponService Class -> useCouponWithLock() method)
@Test
@Order(1)
void 락사용_회원_50명이_쿠폰_사용() throws InterruptedException{
final int numberOfMember = 50;
final CountDownLatch countDownLatch = new CountDownLatch(numberOfMember);
List<Thread> threadList = Stream
.generate(() -> new Thread(new UsingLockMember(this.couponKey, countDownLatch)))
.limit(numberOfMember)
.collect(Collectors.toList());
threadList.forEach(Thread::start);
countDownLatch.await();
}
(CouponServiceTest Class -> 락사용_회원_50명이_쿠폰_사용 test method)
사용 가능한 쿠폰 수량을 30개로 설정하고, 50명의 회원이 해당 쿠폰을 사용하도록 테스트하였는데요.
30명의 회원은 정상적으로 쿠폰을 사용하였고, 나머지 20명의 회원에게는 사용 가능한 쿠폰이 모두 소진되었다는 로그가 정상적으로 출력되는 것을 확인할 수 있었습니다.
(자세한 코드는 아래 github 링크를 통해 참고 부탁드리겠습니다.)
여기까지 redisson 분산락을 사용하는 이유와 기본적인 사용 방법에 대해서 살펴봤는데요. 잘못된 부분이나 궁금하신 부분은 댓글 남겨주시면 확인하여 답변드리도록 하겠습니다. 감사합니다.
< 참고 자료 >
https://velog.io/@hgs-study/redisson-distributed-lock
https://way-be-developer.tistory.com/274
< redisson github >
https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter
< github >
'Programming > Spring Boot' 카테고리의 다른 글
@QueryDelegate 어노테이션으로 QueryDSL where절 간결하게 사용하기 (0) | 2023.07.07 |
---|---|
JPA 연관 관계 매핑 - 조인 테이블(@JoinTable) 개념과 적용 방법 (0) | 2023.06.21 |
Spring Boot + PayPal 결제 구현해 보기 (sandbox 테스트 환경) (0) | 2023.06.06 |
RedisHash 사용 시 @Indexed 필드 TTL(timeToLive) 적용 안되는 문제 (0) | 2023.05.26 |
양방향 매핑 순환참조 문제 Cannot call sendError() after the response has been committed (0) | 2023.04.28 |