해당 글에 사용된 코드는 Github에 있습니다! GITHUB
열쇠
는 한곳에서 관리
되도록 하는것이다.Lock
을 관리하는 별도의 DB를 사용하는 전략을 사용해 보겠습니다.Lock
을 관리하는 별도의 DB를 사용하는 전략을 사용하게 되는데, 이를 분산 Lock 이라고 표현한다.//redis docker 이미지 pull
docker pull redis
//image 사용 container run
docker run --name myredis -d -p 6379:6379 redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
package BaekGwa.ConcurrencyIssue.global.redis.repository;
import java.time.Duration;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class RedisRepository {
private final RedisTemplate<String, String> redisTemplate;
/**
*
* @param key
* key는 트랜잭션 범위를 지정한다.
* 같은 트랜잭션은 같은 key를 사용해야한다.
* @return
*/
public Boolean lock(Long key) {
return redisTemplate.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
}
/**
*
* @param key
* key는 트랜잭션 범위를 지정한다.
* 같은 트랜잭션은 같은 key를 사용해야한다.
* @return
*/
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key){
return key.toString();
}
}
package BaekGwa.ConcurrencyIssue.domain.item.service;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import BaekGwa.ConcurrencyIssue.global.redis.repository.RedisRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* Facade 패턴 적용 ItemServiceImplV1 -> ItemServiceImplV6
*/
@Service
@RequiredArgsConstructor
public class ItemServiceImplV6 implements ItemService {
private final RedisRepository repository;
private final ItemServiceImplV1 itemService;
private final RedisRepository redisRepository;
//해당 key 는 분산환경에서 공유되어야함.
//트랜잭션의 범위를 설정하는 역할을 함.
//이렇게 사용하기 보다, 명확한 KEY 네이밍 규칙과 관리가 필요함.
private static final Long ITEM_SERVICE_LOCK_KEY = 1L;
@Override
public Boolean RegisterItem(NewItem newItem) {
getLock(ITEM_SERVICE_LOCK_KEY);
try {
return itemService.RegisterItem(newItem);
} finally {
unLock(ITEM_SERVICE_LOCK_KEY);
}
}
@Override
public Boolean buyItem(BuyItem buyItem) throws InterruptedException {
getLock(ITEM_SERVICE_LOCK_KEY);
try {
return itemService.buyItem(buyItem);
} finally {
unLock(ITEM_SERVICE_LOCK_KEY);
}
}
private void getLock(Long key) {
while (!redisRepository.lock(key)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
private void unLock(Long key) {
redisRepository.unlock(key);
}
}
package BaekGwa.ConcurrencyIssue.domain.item.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import BaekGwa.ConcurrencyIssue.domain.item.entity.Item;
import BaekGwa.ConcurrencyIssue.domain.item.repository.ItemRepository;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ItemServiceImplV6Test {
@Autowired
private ItemServiceImplV6 itemService;
@Autowired
private ItemRepository itemRepository;
@BeforeEach
public void init() {
NewItem newItem = new NewItem("상품A", 1000L, 100L);
itemService.RegisterItem(newItem);
}
@AfterEach
public void clear() {
itemRepository.deleteAll();
}
@Test
void 단일_구매_요청() throws InterruptedException {
BuyItem ItemA = new BuyItem("상품A", 1L);
Boolean isSuccess = itemService.buyItem(ItemA);
Item findItem = itemRepository.findAllByName("상품A");
assertEquals(isSuccess, true);
assertEquals(99, findItem.getStock());
}
@Test
void 다중_구매_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService es = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
es.submit(() -> {
BuyItem ItemA = new BuyItem("상품A", 1L);
Boolean isSuccess = null;
try {
isSuccess = itemService.buyItem(ItemA);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
assertEquals(isSuccess, true);
});
}
es.shutdown();
es.awaitTermination(10, TimeUnit.SECONDS);
Item findItem = itemRepository.findAllByName("상품A");
assertEquals(0, findItem.getStock());
}
}
ItemServiceImplV1
서비스 로직을 Facade 패턴을 사용하여 락을 획득해야지만 실행 되도록 하였습니다.Redis
에 저장하여 관리 하도록 하였습니다.getLock()
메서드는, key를 입력하면, 해당 키가 없으면 데이터를 생성하고, 있다면 무한 반복을 실행 하게 됩니다.Lock
을 재획득 하는 시간을 조금 미뤘습니다. 너무 빠른 시간에 재시도를 하면, 성능적 이슈가 발생할 수 있습니다.getLock()
을 성공적으로 통과한 다음, 핵심 임계 영역 코드
가 실행되는 메서드를 호출 할 수 있습니다.unlock()
을 실행하게 하여, Lock
을 반납 하도록 합니다.lock
을 성공/실패 여부 상관없이 반납 하도록 하여야 합니다.Lock
을 기다리게 됩니다.여담으로 AOP 기능을 활용하면, Annotation을 사용하여 별도의 Facade 객체를 생성하지 않아도 될 것 같습니다. Like @Transactional
Lock
을 획득해야 하는 Thread가, 지속/반복 적으로 Lock 획득 가능 여부를 확인하며 무한정으로 반복하는 형태.1L
이라는 key를 사용하였는데, 이는 해당 트랜잭션을 설명하기엔 너무 부족한 key
이다.key
는 중복되지 않도록 관리를 해야하는데, 이는 어떻게 관리를 하면 좋을까?-> 모든 의문점에 대한 해답은 아니지만, Redlock 이라는 알고리즘을 Redis 에서는 제공한다. 이를 java에서 사용할 수 있도록 Redisson
구현체가 제공된다.
의존성 추가
implementation("org.redisson:redisson-spring-boot-starter:3.23.2")
코드 작성
package BaekGwa.ConcurrencyIssue.domain.item.service;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
/**
* Facade 패턴 적용 ItemServiceImplV7 -> ItemServiceImplV1
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ItemServiceImplV7 implements ItemService {
private final ItemServiceImplV1 itemService;
private final RedissonClient redissonClient;
//해당 key 는 분산환경에서 공유되어야함.
//트랜잭션의 범위를 설정하는 역할을 함.
//이렇게 사용하기 보다, 명확한 KEY 네이밍 규칙과 관리가 필요함.
private static final String ITEM_SERVICE_LOCK_KEY = "1";
@Override
public Boolean RegisterItem(NewItem newItem) {
RLock lock = redissonClient.getLock(ITEM_SERVICE_LOCK_KEY);
try {
boolean available = lock.tryLock(10, 2, TimeUnit.SECONDS);
if (!available) {
log.info("{} : lock 획득 실패", Thread.currentThread().getName());
return false;
}
return itemService.RegisterItem(newItem);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
@Override
public Boolean buyItem(BuyItem buyItem) throws InterruptedException {
RLock lock = redissonClient.getLock(ITEM_SERVICE_LOCK_KEY);
try {
boolean available = lock.tryLock(10, 2, TimeUnit.SECONDS);
if (!available) {
log.info("{} : lock 획득 실패", Thread.currentThread().getName());
return false;
}
return itemService.buyItem(buyItem);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
test코드는, 받아오는 Bean만 다르고, TemServiceImplV6Test 와 동일 합니다.
항목 | Lettuce | Redisson |
---|---|---|
락 획득 및 대기 방식 | - lock() 호출로 락 시도- 락 실패 시 반복적으로 재시도 - Thread.sleep() 으로 Timed Waiting | - tryLock() 호출로 락 시도- 락 실패 시 Blocking 상태로 대기 |
락 관리 및 자동 해제 | - 수동으로 락 해제 코드 필요 - 잘못된 코드로 인해 데드락 위험 | - leaseTime 이후 락 자동 해제- 최대 대기 시간과 해제 시간 설정 가능, 편리한 관리 |
복잡성 및 코드 간결성 | - 추가 구현 필요 (재시도 로직, 락 해제 등) - 코드 복잡도 증가 | - 간결한 API, 자동 관리 - 빠르고 쉽게 락 구현 가능 |
다중 노드 사용 | - 불가능 | - 가능 |
Redlock 알고리즘
Redlock은 Redis의 다중 노드 환경에서 분산 락을 구현하기 위해 고안된 알고리즘입니다. 이 알고리즘은 다음과 같은 단계를 따릅니다:
다중 노드에 락 시도: 클라이언트는 다섯 개의 서로 다른 Redis 노드(일반적으로 홀수 개)에서 동시에 락을 시도합니다.
락 획득 조건: 클라이언트는 과반수 이상의 노드(예: 5개의 노드 중 3개)에서 락을 획득해야 합니다. 이는 특정 노드에서 장애가 발생하더라도 락이 안전하게 유지되도록 보장합니다.
락 유지 시간: 락을 획득한 후에는 설정된 leaseTime 동안만 유지됩니다. 이 시간이 지나면 락은 자동으로 해제됩니다.
락 해제: 클라이언트가 작업을 완료한 후, 락을 해제합니다. 모든 노드에서 락이 해제되면, 다른 클라이언트가 락을 획득할 수 있습니다.