`백엔드/서버` 개발자라면 반드시 `동시성` 문제를 만나게 된다.
특히, 요즘 `K8s` 환경을 자주 사용하는데 아무리 애플리케이션에서 `동시성` 발생 대비를 해도
다른 `Pod`에서 같은 `DB`에 접근하면 동시성 문제를 처리하는 것이 어렵다.
위의 그림과 같은 경우 `DB` 또는 `Redis`에서 제공하는 `Lock`을 활용하여 데이터의 동시성 접근을 제어해야 한다.
`DataBase Lock`을 이용할 경우 추가적인 인프라 구성요소 없이 `동시성`을 해결할 수 있다는 장점이 있으나
`Lock` 획득을 위해 `Waiting`되는 `DataBase Connection` 증가로 인해 부하가 발생할 수 있다.
`Redis`를 이용하여 `Distributed Lock`을 사용할 경우 `DataBase Connection` 증가는 방지할 수 있지만
`Redis`의 관리가 필요하다.
우리는 이전 포스팅에서 `DataBase Lock`인 `Optimistic Lock`과 `Pessimistic Lock`을 구현해보며 확인했다.
2024.09.27 - [Spring] - [Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3
그렇기에 이번 포스팅에서는 `Redis Distributed Lock`에 관해 알아본다.
Redis Distributed Lock
`Redis`는 문서에서 `Distributed Lock`를 위해 `RedLock`이라는 알고리즘을 제시하고 있으며
`Java`에서의 구현된 라이브러리는 `Redisson`이 존재한다.
왜 `spring-data-redis`의 기본 구현체인 `Lettuce`가 아닌 `Redisson`를 사용할까?
- Lettuce
- `spring-data-redis`의 기본 구현체
- 기본적으로 `Spin Lock`을 사용한다.
- 이는 Lock을 대기하는 상황에서, Lock을 획득할 수 있는지 계속 요청을 보낸다.
- 따라서, Lock을 획득하려는 스레드가 많을 경우 Redis에 부하가 집중된다.
- Lock에 관한 타임아웃이 없어, `Unlock(잠금 해제)`를 호출하지 못한 경우 `Dead Lock`을 유발할 수 있다.
- Redisson
- Pub/Sub 방식을 사용한다.
- Lock을 당장 획득할 수 없으면 대기한다.
- Lock이 획득 가능할 경우 Redis에서 클라이언트로 획득 가능함을 알린다.
- Lock의 lease time 설정이 가능하다.
- 즉, 설정된 `lease time`이 지난 경우 자동으로 Lock의 소유권을 회수하여 Dead Lock을 방지한다.
- Pub/Sub 방식을 사용한다.
Redisson Config
기본적으로 `Redis`와 통신하기 위한 `RedisClient`와 `RedisConnectionFactory`를 생성해야 한다.
`Redisson`에서는 `RedissonClient`와 `RedissonConnectionFactory`를 `Bean`으로 생성해야 한다.
아래의 예시는 `Redis` 서버를 하나 구성하였을 경우이며 `Redis` 구성에 설정은 다음 문서를 확인하자.
application.yml(내부에 spring-boot-starter-data-redis 의존성도 존재)
/* Redis 추가 */
implementation("org.redisson:redisson-spring-boot-starter:3.21.1")
RedisConfig
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379");
return Redisson.create(config);
}
@Bean
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redissonClient){
return new RedissonConnectionFactory(redissonClient);
}
}
Redisson Distributed Lock
`Redisson`를 활용한 `Distributed Lock` 사용 방식은 간단하며 다음 문서를 참고하면 된다.
- `RedissonClient`를 통해 해당 `Lock`의 `Key`를 설정하여 `RLock` 생성
- `RLock.lock` 또는 `RLock.tryLock`를 통해 `Lock` 획득이 가능하며 `waitTime`과 `leaseTime` 설정 가능
- `waitTime`: `Lock` 획득 대기 시간
- `leaseTime`: 'Lock` 획득 이후 소유 시간
- `RLock.unlock`를 통해 `Lock` 반납
@Service
@RequiredArgsConstructor
public class TicketV7Service {
private final RedissonClient redissonClient;
private final TicketRepository ticketRepository;
@Transactional
public Ticket purchaseRLock(Long ticketId, int amount) {
RLock lock = redissonClient.getLock("TEST");
try{
if(lock.tryLock(5000, 3000, TimeUnit.MILLISECONDS)){
Ticket ticket = ticketRepository.findById(ticketId).get();
ticket.setStock(ticket.getStock() - amount);
return ticket;
} else{
throw new RuntimeException();
}
} catch (InterruptedException e){
throw new RuntimeException(e);
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
}
문제 경우: 동시성이 적절히 처리되지 않았다.
왜 `Lock`을 처리했음에도 문제가 해결되지 않았을까?
그건 이전 포스팅에서도 살펴보았던 `@Transcational` 어노테이션의 동작 방식 때문이다.
문제 이유
즉, `Spring Transcational Commit`과 `Distributed Lock 반납` 시점차가 존재하기 때문이다.
위의 그림과 같이 `Distributed Lock 반납`이 `Spring Transaction Commit` 보다 먼저 실행되어
해당 시점에 다른 요청이 있는 경우 `Commit` 이전의 데이터를 조회하여 예상과 다른 결과가 나타나게 되는 것이다.
해결 방안1: 서비스 클래스 앞단에 Lock을 위한 프록시 클래스를 두는 것이다.
이전 포스팅에서 살펴본 `TicketFacade` 같은 형식
아래와 같이 설정하면 `@Transactional`이 동작하기 전에 `Lock`이 걸리므로 정상 동작할 것이다.
즉, `Service`를 타겟 클래스로 두고 해당 클래스를 접근하기 전에 `Lock`을 거는 것이다.
@Component
@RequiredArgsConstructor
public class TicketFacade {
private final TicketV7Service target;
private final RedissonClient redissonClient;
@Transactional
public Ticket invoke(Long ticketId, int amount){
RLock lock = redissonClient.getLock("TEST");
try{
if(lock.tryLock(5000, 3000, TimeUnit.MILLISECONDS)){
return target.purchase(ticketId, amount);
} else{
throw new RuntimeException();
}
} catch (InterruptedException e){
throw new RuntimeException(e);
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
}
TicketV7Service
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Ticket purchase(Long ticketId, int amount) {
Ticket ticket = ticketRepository.findById(ticketId).get();
ticket.setStock(ticket.getStock() - amount);
return ticket;
}
결과
위 방식의 흐름은 다음과 같다.
`Tx#1 시작` -> `Distributed Lock 획득` -> `Tx#2 시작` -> `로직` -> `Tx#2 종료` ->
`Distributed Lock 반납` -> `Tx#1 종료`
해결 방안2: @TransactionalEventListener
`@TransactionalEventListener`를 활용하여 `Distributed Lock 반납`을 `Spring Transactional` 이후에 실행할 수 있다.
즉, `Spring ApplicationEventPublisher`를 활용해서 이벤트를 `Publish`하고
`AFTER_COMMIT` 이벤트가 발생하면 그걸 `@TransactionalEventListener`가 읽어서 `Lock`을 해제한다.
@Service
@RequiredArgsConstructor
public class TicketV7Service {
private final ApplicationEventPublisher applicationEventPublisher;
private final RedissonClient redissonClient;
private final TicketRepository ticketRepository;
private final EventHandler eventHandler;
@Transactional
public Ticket purchaseRLock(Long ticketId, int amount) {
RLock lock = redissonClient.getLock("TEST");
try{
if(lock.tryLock(5000, 3000, TimeUnit.MILLISECONDS)){
Ticket ticket = ticketRepository.findById(ticketId).get();
ticket.setStock(ticket.getStock() - amount);
applicationEventPublisher.publishEvent(lock);
return ticket;
} else{
throw new RuntimeException();
}
} catch (InterruptedException e){
throw new RuntimeException(e);
} finally {
// if(lock.isLocked() && lock.isHeldByCurrentThread()){
// applicationEventPublisher.publishEvent(lock);
// }
}
}
@EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void unlockRLock(RLock rLock) {
if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
결과
위 방식의 흐름은 다음과 같다.
`Tx#1 시작` -> `Distributed Lock 획득` -> `로직` -> `Tx#1 종료` -> `Tx#2 시작`
-> `Distributed Lock 반납` -> `Tx#2 종료`
즉, 핵심은 `Tx 종료` 이후에 `Distributed Lock 반납`이 이루어져야 한다는 것이다.
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 1 (0) | 2024.11.19 |
---|---|
[Spring Data] Redis Lock을 적용해보자 - 2 (0) | 2024.10.17 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 2 (0) | 2024.09.25 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 1 (0) | 2024.09.22 |