저번 포스팅에서, 함께 결제시 모든 참가자가 실시간으로 메뉴를 선택/취소 할 수 있어야 했습니다.
실시간으로 빠른 처리를 위해 Redis에 객체를 저장해두고 수정/조회 작업을 수행하였는데, 거의 동시에 메뉴를 선택하는 경우에 앞 사람이 선택한 메뉴에 대한 정보가 누락되는 현상이 발생하였습니다. (1번 유저가 짜장면을 선택함과 거의 동시에 2번 유저가 짬뽕을 선택하면, Redis에 put 메서드로 저장을 하기 때문에 1번 유저가 선택한 짜장면에 대한 정보는 누락)
또한, 참가자 수에 제한을 두어 4명까지만 참가가 가능해야 하는데 더 많은 유저가 참여하는 상황도 생겼습니다.
Redis에 OrderRoom 객체를 저장하기 이전에 Mysql에 저장했을 때는 단순하게 비관적 락을 걸어서 한 트랜잭션이 접근중일때는 다른 트랜잭션이 접근하지 못하도록 처리를 했었는데, Redis로 옮기다 보니 새로운 해결책이 필요해졌습니다.
동시성 제어방법에는 어떤게 있는지, 그중 제가 Redisson을 이용해 동시성을 처리한 방법을 간단하게 포스팅 해보려고 합니다.
비관적 락(Pessimistic Lock)
비관적 락은 트랜잭션의 충돌이 발생한다고 가정하고 트랜잭션이 시작될 때 DB에 락을 걸어, 다른 트랜잭션이 접근하지 못하도록 하는 방법입니다. Shared Lock과 Exclusive Lock이 존재합니다.
데이터베이스에서 제공하는 락을 사용하여 데이터를 수정 요청과 동시에 충돌 여부를 알 수 있습니다.
Shared Lock
- 특정 Row를 읽을(Read) 때 사용되는 Lock
- 여러 트랜잭션이 동시에 한 Row에 Shared Lock을 걸 수 있음 → 하나의 Row를 여러 트랜잭션이 동시에 읽을 수 있음
- Shared Lock이 설정된 Row에는 Exclusive Lock을 사용할 수 없음
- InnoDB에서 일반적인 SELECT 쿼리는 Lock을 사용하지 않음. 하지만 SELECT .. FOR SHARE 등의 일부 쿼리는 각 Row에 Shared Lock을 건다
Exclusive Lock
- 특정 Row를 변경(write)할 때 사용
- 특정 Row에 Exclusive Lock이 걸려있을 경우, 다른 트랜잭션은 읽기 작업을 위해 Shared Lock을 걸거나, 쓰기 작업을 위해 Exclusive Lock을 걸 수 없음 → 쓰기 작업을 하고 있는 Row에는 모든 접근이 불가
- SELECT … FOR UPDATE, UPDATE, DELETE 등의 수정 쿼리들이 실행될 때 Row에 걸림
낙관적 락(Optimistic Lock)
대부분의 Transaction은 충돌이 생기지 않는다고 낙관적으로 가정하는 방법입니다.
낙관적 락은 비관적락과 다르게 데이터베이스가 제공하는 락이 아닌 애플리케이션 레벨에서 락을 구현합니다. JPA에서는 버전 관리 기능(@Version)을 통해 구현할 수 있습니다. 낙관적 락은 애플리케이션에서 충돌을 관리하기에 트랜잭션을 커밋하기 전까지는 충돌을 알 수 없다는 특징이 있습니다.
version 뿐만 아니라 hashcode 또는 timestamp 컬럼을 이용하기도 합니다.
Redis 분산락
실시간으로 메뉴를 선택/취소 하는등의 많은 작업을 수행해야 하는 로직을 구현하는데, 비관적 락으로 구현하기엔 성능 저하 문제를 고려하지 않을 수 없었습니다. 따라서 최종적으로 Redis 분산락 적용을 고려하게 됐습니다.
분산락은 기존 코드처럼 데이터베이스 Lock을 거는 것이 아닌 직접적으로 함께 결제 참여하기 로직을 수행하는 부분에 외부 저장소를 사용해 락을 획득하고 수행하는 방식입니다. 즉, 실제 데이터를 가져오는 데이터베이스가 아닌 별도의 저장소에서 락을 획득하기 위한 경합을 하게 되어 x-lock획득을 위한 경합으로 인해 데이터베이스의 커넥션을 오래 유지하는 문제를 해결하여 성능을 개선할 수 있습니다.
게다가 저희는 이미 인증번호 인증, 로그인 로직에서도 Redis를 이미 사용하고 있어 추가 비용이 들지 않는점도 Redis 분산락을 사용하게 된 계기중 하나였습니다.
Redis를 통한 분산락 구현은 대표적으로 Lettuce와 Redisson이라는 RedisClient 중 하나를 사용합니다. Lettuce는 Spin Lock 방식으로 락에 대한 획득 시도를 지속적으로 해야하며 락에 대한 타임 아웃을 지정할 수 없는 반면 Redisson은 pub/sub 방식으로 동작해 레디스 서버의 부하 문제를 줄여줄 수 있고 락에 타임 아웃을 지정할 수 있다는 특징이 있습니다.
저는 Reddison을 최종적으로 사용하여 구현하였습니다.
분산락 적용
Redisson은 스핀락이 아닌 pub/sub 방식으로 동작하며 레디스 서버의 부하 문제를 해결할 수 있습니다.
Lock 획득을 원하는 Client들이 Redis 를 구독하고 있다가 Redis 에서 Lock release 가 발생하면 구독하고 있는 Client에게 알림을 주면, 알림을 받은 Client 들은 다시 lock 획득을 시도하는 방식으로 구동됩니다.
분산락을 적용해야 하는 메서드가 여러개다 보니, 코드의 중복이 많아졌습니다. 따라서 중간에 AOP를 이용하여 어노테이션 기반으로 쉽게 락을 획득할 수 있도록 구현하였습니다.
의존성 추가
// 레거시 스프링
implementation 'org.redisson:redisson:3.16.3'
implementation 'org.springframework.data:spring-data-redis:2.7.2'
// 부트를 사용하는 경우
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
저는 레거시 스프링을 사용해 프로젝트를 진행해서, 의존성이 좀 다르지만 아래 부트를 위한 의존성도 작성했습니다.
RedisLock
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RedisLock {
String lockName(); // 락의 이름을 지정하기 위한 속성
}
분산락을 통해 동시성 문제를 해결할 메서드에 붙일 어노테이션을 만들었습니다. 유연한 사용을 위해 락에 사용될 키 값의 속성을 가지고 있어 관련 설정을 쉽게 할 수 있게 하였습니다.
RedisLockAspect
@Aspect
@Component
@RequiredArgsConstructor
public class RedisLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String lockNameTemplate = redisLock.lockName();
String lockName = parseLockName(lockNameTemplate, joinPoint);
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (isLocked) {
return joinPoint.proceed();
} else {
throw new ErrorHandler(ErrorStatus.FAILED_TO_ACQUIRE_LOCK);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String parseLockName(String lockNameTemplate, ProceedingJoinPoint joinPoint) {
// SpEL(Spring Expression Language)을 사용하여 lockNameTemplate에 있는 변수를 실제 값으로 치환
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();
// 메서드의 파라미터를 가져와서 컨텍스트에 추가
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
// lockNameTemplate을 파싱하여 실제 락 이름 생성
return parser.parseExpression('"' + lockNameTemplate + '"').getValue(context, String.class);
}
}
아래 parseLock Name은 Lock에 사용될 키를 생성하는 클래스입니다. 키 값은 전달받은 메서드의 이름과 springframework의 ExpressionParser를 통해 파싱한 값을 통해 생성합니다.
위 around 메서드는 앞서 생성한 @RedisLock 어노테이션을 통해 수행할 AOP 설정 클래스 입니다. 동작과정은 아래와 같습니다.
- parseLockName() 메서드를 통해 Lock에 사용될 키 값을 생성합니다.
- redissonClient를 통해 RLock 인스턴스를 가져옵니다.
- RLock.tryLock() 메서드를 통해 최대 5초의 시간만큼 락의 획득을 기다립니다
- 락의 획득이 가능하다면 10초를 타임아웃으로 설정하며 락을 획득한다.
- 기존 메서드 로직을 수행한다.
실제 사용은 아래와 같이 메뉴를 고르는 메서드 전체에 락을 걸어 사용하였습니다.
@RedisLock(lockName = "lock:orderRoom:#{#orderIdx}")
public OrderRoom selectMenu(Long orderIdx, Long menuIdx, Long memberIdx, int price, ChannelTopic topic) {
OrderRoom orderRoom = getOrderRoom(orderIdx);
HashMap<Long, List<Long>> menuSelect = orderRoom.getMenuSelect();
HashMap<Long, Integer> menuAmount = orderRoom.getMenuAmount();
List<OrderRoomResponseDto.SelectedMenuInfoDto> selectedMenuInfoList = new ArrayList<>();
for (Long idx : orderRoom.getMenuSelect().keySet()) {
List<Long> memberIdxList = orderRoom.getMenuSelect().get(idx);
OrderRoomResponseDto.SelectedMenuInfoDto selectedMenuInfo = OrderRoomResponseDto.SelectedMenuInfoDto.builder()
.menuIdx(idx)
.currentAmount(memberIdxList.size())
.memberIdxList(memberIdxList)
.build();
selectedMenuInfoList.add(selectedMenuInfo);
}
if (!menuSelect.containsKey(menuIdx)) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_NOT_FOUND, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_NOT_FOUND);
}
if (menuSelect.get(menuIdx).size() >= menuAmount.get(menuIdx)) {
redisPublisher.publish(topic, new OrderRoomResponseDto.ErrorResponseDto(memberIdx, orderIdx, ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR, selectedMenuInfoList));
throw new ErrorHandler(ErrorStatus.ORDER_MENU_MEMBER_CNT_ERROR);
}
menuSelect.get(menuIdx).add(memberIdx);
orderRoom.updateCurrentPrice(price);
opsHashOrderRoom.put(ORDER_ROOMS, orderIdx + "", orderRoom);
return orderRoom;
}
한 사람이 저 메서드에 접근하고 있을때는 다른 사람이 저 메서드에 접근하지 못하도록 하여 메뉴가 중복으로 선택되거나 취소되어 데이터 정합성이 깨지는 것을 방지하였습니다.
간단하게 분산락을 구현해보았는데, 다음번에는 Transaction까지 모두 엮어서 좀더 정밀한 분산락 구현을 하고 테스트 까지 꼼꼼하게 해보려고 합니다.
이번 포스팅은 여기까지 하겠습니다. 자세한 코드는 아래 링크 남겨두겠습니다.
깃허브 링크
- https://github.com/Teamirum/server
참고
- https://helloworld.kurly.com/blog/distributed-redisson-lock/