세종대학교 사물함 예약사이트 을 개발하며 분산락을 통해 동시성 문제를 방지하고 캐싱을 통해 성능을 개선한 과정에서 배운점들에 대해 기록합니다.
세종대학교 사물예약사이트(이하 세종버킷)를 개발하며 학우들이 사물함에 관해 문의할때 이전 기록들을 확인하기 위해 예약정보를 완전 삭제하는 것이 아닌 Soft-Delete를 통해 사물함을 예약을 관리했습니다.
사물함 예약시스템에서 예약api를 개발하고, 테스트코드를 작성하던 중 다음과 같은 에러를 마주했습니다.
분명 1개의 사물함을 예약을 진행했는데, 10개의 예약정보가 조회되었습니다.
예약된 사물함을 찾아볼때 개수를 카운트하여 비교하였기에, 우선 예약된 사물함을 확인해보았더니..
예약된 사물함이 모두 같은 사물함인것을 보아 동시에 같은 사물함이 예약된 것을 알수 있었습니다.. 예약시스템인데 동시에 같은 사물함을 예약할것이라고는 생각하지 못해 발생한 문제였습니다..
동시성은 두 사건이 같은 시간에 일어나는 상황이라 말할수 있습니다. 여러 클라이언트에서 동시에 여러 요청을 보내 같은 자원에 접근한 상황입니다.
사물함의 예약 로직은 다음과 같습니다.
- 예약할 사물함을 조회합니다.
- 사물함이 예약된 상태인지 확인합니다.
- 예약이 되지 않았다면 예약합니다.
이렇게 보면 사실 정상적으로 동작할것 같습니다. 어느시점에 동시접근을 한것일까요?
둘 이상의 사용자가 동시에 접근하면 다음과 같은 상황이 발생할수 있다. 둘 이상의 쓰레드가 각자 트랜잭션을 열고, 사물함을 동시에 조회하면 조회하는 시점에는 commit되지 않아 어느 사물함도 예약되지 않아 예약이 가능하다고 조회되어 중복 예약이 되게된다...
그렇다고 예외적인 상황때문에 Serializable로 격상할순 없으니... 사물함에 동시에 접근하지 못하게 Lock을 통해 순차적으로 자원에 접근해봅시다!
결론적으로 말하면 Redis를 이용하여 분산락을 구현했습니다.
Redis외에도 3가지 방법을 고려했는데, Redis를 이용한 이유는 다음과 같습니다..
Synchronized
가능하다면 가장 간단한 방법이지 않을까 생각합니다! 하지만 같은 WAS에서만 유효하기때문에 Scale-Out을 고려중인 상황이라 서비스에 적용할수 없었습니다.낙관락
애플리케이션에서 Version을 관리하기에 실패시 재시도 로직을 처리해줘야한다. 충돌이 많은 경우 오히려 재시도 로직때문에 100개의 요청이면 100번 충돌하고 재시도하게 되므로 부하가 클것이라 판단비관락
레코드에 락을 걸기때문에 데드락이 발생할수 있고, 다른 비즈니스 로직에 영향을 끼칠수 있기때문에 기각
이외에도 다른방법이 존재하겠지만, 추가적인 인프라 구축이 필요없는 Redis를 사용하기로 결정했습니다!!
Redis를 사용하기 결정 후 Lettuce와 Redisson 두가지 라이브러리 중 Redisson을 선택하였습니다. Lettuce는 스핀락 Redisson은 pub/sub을 지원하는 차이점이 있습니다.
Lettuce(스핀락)
A,B,C: Redis야 Lock줘!!
Redis: A가 제일 빨랐어. 너가 가져가(A, Lock획득)
B,C: Lock 내놔!
B,C: Lock 내놔!
B,C: Lock 내놔!
B,C: Lock 내놔!
B,C: Lock 내놔!
...
(A의 작업 완료)
Redis: B가 제일 빨랐어. 너가 가져가(A, Lock획득)
C: Lock 내놔!
C: Lock 내놔!
C: Lock 내놔!
C: Lock 내놔!
C: Lock 내놔!
...
(B의 작업 완료)
Redis: C야 Lock가져가!
(C의 작업 완료)
Redisson(pub/sub)
A,B,C: Redis야 Lock줘!!
Redis: A가 제일 빨랐어. 너가 가져가(A, Lock획득)
B,C: Lock있으면 말해줘(구독)
(A의 작업 완료)
Redis: Lock가져가!
B,C: Redis야 Lock줘!
Redis: B가 제일 빨랐어. 너가 가져가(A, Lock획득)
C: Lock있으면 말해줘(구독)
(B의 작업 완료)
Redis: Lock가져가!
C: Redis야 Lock줘!
Redis: C야 Lock가져가
동시성 문제는 어디에서든 발생할수 있기때문에 AOP를 적용하여 만들면 추후에도 편하게 재사용할수 있을것이라 생각했습니다.
Redis에 대한 설정은 다루지 않고있습니다!
분산락 어노테이션 선언
해당 어노테이션을 메서드 레벨에서 사용할것임을 명시해준다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
String key();
String identifier();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 3L;
}
분산락 구현체
@Order(1)
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeAop {
private static final String REDISSON_KEY_PREFIX = "RLOCK_";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.ime.lockmanager.common.aop.meta.DistributeLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);
String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key(), distributeLock.identifier());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());
if (!available) {
throw new InterruptedException();
}
log.info("get lock success {}", key);
return aopForTransaction.proceed(joinPoint);
} finally {
System.out.println("락 푼다~");
rLock.unlock();
}
}
}
애노테이션에 작성한 key값에 대한 Lock을 획득한 후, 예약 로직을 실행하게된다.
AopForTransaction
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint)throws Throwable{
return joinPoint.proceed();
}
}
해당 클래스를 통해 예약로직을 실행합니다. 이때 @Transactional(propagation=Progation.REQUIRES_NEW)
를 왜 붙힐까요? 해당 어노테이션을 통해 트랜잭션이 분리되는데, 이는 데이터의 정합성을 지키기 위해서 입니다.
tx-1에서 변경된 내용이 커밋된 후, tx2에서 로직을 실행하기 때문에 정합성에 문제가 없습니다.
하지만 다음과 같은 상황이면 어떻게 될까요?
예상하지 못한 상황으로 tx-1의 커밋이 늦어진 상황에서 tx-2가 변경 전의 자원에 접근하여 로직을 실행하였습니다..! 따라서 분산락을 사용했지만 정합성이 지켜지지 않을겁니다!
따라서 로직실행을 위한 트랜잭션을 분리해주어, 분산락으로 다른 서버에서 접근을 못하는 상황에서 변경된 내용을 커밋시켜 정합성을 지켜줍니다!
AOP를 적용하여 어노테이션 형태로 완성되었습니다!!
이제 분산락이 필요한 부분에 쉽게 적용할수 있습니다!!
@DistributeLock(identifier = USER_KEY, key = "#dto.id")
성공~!!
락을 건다는 것자체가 오버헤드가 큰 작업이라 생각했습니다.
락을 걸고, 풀고의 반복... 예약 서비스에서 정상적으로 락이 걸리고 예약하면 이후의 모든 트래픽은 락을 걸고 푸는 오버헤드를 겪어도 예약이 불가함...
따라서 트래픽이 터질때 성능이 좋지 못해 처리가 늦어지면서 db connection고갈문제와 서버가 다운되는 문제가 발생했습니다.
동시성이 만족되면서 성능이 빠른 다른 방법이 필요했는데, 다른방법이...
예약사이트 특성상 한번 예약하면 해제를 잘 하지 않는데 그렇다면 예약정보를 캐싱하면 빠르게 응답할수 있지 않을까? 라는 생각이 들었습니다! 게다가 사용하고 있는 Redis는 싱글 쓰레드니까 동시성도 지킬수 있을거라 생각했습니다.
떠올린 로직은 다음과 같습니다.
- 요청
- 요청한 사물함 번호가 Redis에 등록되었는지 확인
- 이미 등록되었다면 false보내고, Exception던지기
- 등록되지 않았다면 Redis에 저장하고 true 반환 및 예약 로직실행
한가지를 더 고려해야했는데, Redis는 명령어 단위로 원자성이 보장되는데 만약 조회, 등록 명령어를 따로 처리하면 원자성이 보장되지 않는다는 것이었습니다.
따라서 Lua스크립트를 통해 직접 명령어를 필요한 로직에 맞춰 새로 작성했습니다.
private boolean validateIsPossibleToReserve(String locker, String user) {
String luaScript = "if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', ARGV[1]) == 1 then "
+ "return false "
+ "else "
+ "redis.call('SET', KEYS[1], 1) "
+ "redis.call('SET', ARGV[1], 1) "
+ "return true "
+ "end";
RedisScript<Boolean> script = RedisScript.of(luaScript, Boolean.class);
Boolean result = redisTemplate.execute(script, Collections.singletonList(locker), user);
return result != null && result; // true 또는 false 반환
}
사물함번호 또는 사용자가 존재하는지 확인한후, 존재한다면 예외를 반환하고, 존재하지않다면 Redis에 등록후, 로직을 진행하도록 구현했습니다.
분산락을 구현한것과 같은 방법으로 AOP로 구현하였고 다음과 같은 성능개선을 얻을수 있었습니다.
TPS : 33.1/sec → 981.1/sec (2964% 개선)
Receive rate : 88.22/sec → 508.2/sec (576% 개선)