온보딩 과제를 진행하던 도중, 동시성에 관한 문제를 직면하고 싶었다.
auto increase를 사용하여 추가하는 no와 달리, 마지막 순서를 찾아 해당 순서에서 +1을 진행하는 로직에서는 동시성 문제가 발생하지 않을까?라는 생각에 도달했고, 스레드를 만들어 테스트를 한 결과.
Pk였던 no는 1, 2, 3, ... 순서에 맞춰 생성되고 있었지만, orderId는 1, 1, 1, 2, 2, 2, 2, 2,... 순서가 겹쳐지기 시작했다.
접근 목록
- 트랜잭션 격리 수준 격상
- MySQL 락 사용
- Synchronized 사용
- Redis Lettuce Lock 사용
- Redis Redisson Lock 사용
트랜잭션 격리 수준 격상
트랜잭션의 격리 수준을 최상으로 올렸다.
SERIALIZABLE을 사용하여 테스트를 한 결과, orderId는 순서대로 출력되고 있었으나 pk 쪽에서 insert가 씹혔는지 1, 5, 13, 4, 5,... 불규칙적으로 db에 입력되어 있었다.
또한 100번의 시도를 하였으나 저장된 정보는 10개 내외로 약 90%에 가까운 입력들이 무시당해버렸다.
한 단계, 낮은 REPETABLE READ를 사용해 봤지만 사용한 것과 사용하지 않은 것에 대해 큰 차이가 없었다.
MySQL 락 사용
Pessimistic Lock을 구현하여 적용해 보았다.
변경하는 로직이 아닌, 생성 로직이기 때문에 Lock을 걸기 위해 찾는 해당 엔티티의 정보를 찾아내기 어려웠다.
orderId를 넣어서 락을 생성해 보았지만 락 관련 select가 진행되지 않았다.
로직을 추가하여 orderId를 통해 최근 입력된 값의 정보를 찾고 해당 PK를 뽑아내서 락을 걸어봤지만 락에 관련한 select 쿼리는 날아갔으나 이후 insert쿼리가 날아가지 않았다.
Synchronized 사용
구현하는데 시간을 너무 많이 쏟아버렸기 때문에 가장 간단한 방법인 Synchronized를 사용하여 구현하게 되었다.
public synchronized ToDo createToDo(CreateRequest request) {
Long lastOrderId = toDoCustomRepository.findLastOrderId();
if (lastOrderId == null) {
lastOrderId = 0L;
}
ToDo response = CreateRequest.of(request, lastOrderId);
return toDoRepository.save(response);
}
일단 db에는 모든 것이 이상 없게 insert 되고 있었으나, 단일 서버가 아닌 여러 서버를 사용하게 되는 경우 해당 코드에서 문제가 일어날 수 있으므로 다른 방법을 고려해야 했다.
물론 서버가 1대라면 사실 이대로 끝내도 될 것 같지만, 온보딩이니 최대한 많은 상황에 대해 고려해보고 싶었다.
또한 동료 개발자분께서 계속 리뷰로 할 수 있는 방법에 대해 알려주시는데 참을 수 없었다.
할 수 있을까 라는 생각도 있었지만, 한 번 해보자 라는 마음이 더 컸던 것 같다.
Redis 사용
맥환경으로 넘어오면서 도커와 레디스 사용이 매우 편해졌다..
도커를 사용하여 레디스를 설치하고, 관련 의존성을 그래들에 추가하였다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
먼저 구현하기 쉽다는 Lettuce Lock 방식을 구현해 봤다.
Lettuce Lock
Service
@Transactional
public ToDo createToDo(CreateRequest request) {
Long lastOrderId = Optional.ofNullable(toDoCustomRepository.findLastOrderId()).orElse(0L);
ToDo response = CreateRequest.toEntity(request, lastOrderId);
return toDoRepository.save(response);
Facade
@Component
@RequiredArgsConstructor
public class LockToDoFacade {
private final RedisLockRepository redisLockRepository;
private final ToDoService toDoService;
public void createToDo(Long key, CreateRequest request) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100);
}
try {
toDoService.createToDo(request);
} finally {
redisLockRepository.unlock(key);
}
}
}
Redis Repository
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
위와 같이 처리하면 이제 Redis의 Lettuce방식의 락 구현이 끝이 났다.
키를 계속 요청하고, 확인하고 키를 받아야만 insert 할 수 있기 때문에 동시성이 해결되었다.
하지만 한 가지 문제점이 더 발생한다. 해당 방식은 스핀락 방식으로 계속해서 Redis에 요청을 보내게 된다.
계속 요청을 보낸다는 것은 부하가 생길 수 있고, 비효율적인 상황이 발생할 수 있다는 것에 접근할 수 있다.
이왕이면 조금 더 효율적이면 좋으니까..?
그래서 Redisson 방식까지 써보기로 했다.
Redisson 방식
일단 그래들에 의존성을 하나 더 추가해야 한다.
implementation 'org.redisson:redisson-spring-boot-starter:3.20.0'
Facade는 다음과 같이 수정한다.
@Component
public class LockToDoFacade {
private RedissonClient redissonClient;
private ToDoService toDoService;
public LockToDoFacade(RedissonClient redissonClient, ToDoService toDoService) {
this.redissonClient = redissonClient;
this.toDoService = toDoService;
}
public void createToDo(Long key, CreateRequest request) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean isLocked = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!isLocked) {
System.out.println("lock 획득 실패");
return;
}
toDoService.createToDo(request);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
해당 방식은 pub sub방식이므로 계속된 요청이 아닌, 해당 락의 소유권이 끝이 날 때 알려주는 방식이기 때문에 Lettuce에 비해 부하가 엄청 적어진다는 것을 알 수 있습니다.
최종적으로는 Redisson방식으로 구현을 해서 push를 했지만, 또 다른 방법이 있는지 개선할 방법이 있는지 알아봐야 할 것 같습니다.
그리고 해당 방식에 대해 일단 구현을 해놓은 상태이므로, 이해하는 시간 또한 필요할 것 같네요
'Server > Java' 카테고리의 다른 글
[Java] Javadoc Export 명령어 (0) | 2023.05.16 |
---|---|
[Java] ResponseEntity<> 사용 이유? (1) | 2023.05.15 |
[Java] Builder 패턴이란? @Builder (0) | 2023.05.03 |
[Spring] Lombok @AllArgsConstructor, @NoArgsConstructor, @RequiredArgsConstructor (0) | 2023.04.26 |
[JDBC] 데이터베이스 연결 (0) | 2022.11.08 |