Search
👨🏼‍🎤

분산 락은 뭐에요? - Redisson을 활용하여 동시성 제어 개선하기

글감
BE
작성자
작성 일자
2025/03/20 01:32
상태
작성 중
공개여부
공개
Date
2025/03/20
생성자
작업자

목표

기존에는 동시성 문제가 생길 수 있는 리소스에 대해서는 X Lock을 통해 데이터를 가져왔었다.
해당 방식에서 조금 더 업그레이드해서 동시성 제어 문제는 Redis의 분산 락에게 맡기고, Transaction과 별개로 흐르되, 정합성을 잘 유지해보도록 하자!
또한, Transaction과 별개로! 라고 말했으니, 특정 Entity에만 국한되는 서비스가 아닌, 분산 락이 필요할 땐 언제든 해당 어노테이션을 달아 별개의 AOP로 작동하도록 만들자.

기존 동시성 제어방식

@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT c " + "FROM Cabinet c " + "WHERE c.id = :cabinetId") Optional<Cabinet> findByIdWithXLock(@Param("cabinetId") Long cabinetId);
Java
복사
다음과 같이 비관적 락 방식으로 데이터에 X Lock을 걸어 트랜잭션이 실행되는 동안, 다른 요청을 모두 막아낸다.
단일 사물함 조회 로직을 예시로 가져왔지만, 다른 동시성 제어가 필요한 리소스에 대해 기본적으로 XLock 방식을 사용하고 있따

그래서 뭐가 문제임?

1.
트랜잭션 길이에 따라 Lock 지속 시간도 길어진다
a.
하나의 트랜잭션이 오래 걸리면, 그만큼 다른 트랜잭션들은 모두 기다려야함
2.
DB 락은 트랜잭션에 강하게 묶여있음
a.
비관적 락 방식이라면 wait 및 leaseTime을 조절할 수 없이 커밋 전까지 락은 풀리지 않으므로, 여러 프로세스 혹은 스레드가 해당 리소스를 기다리며 병목 발생
3.
확장성의 한계
a.
단일 DB에만 의존하므로, 멀티 인스턴스 환경에서의 분산처리 불가 → DB 자체가 병목 지점
→ 단일 인스턴스 & 충돌 상황이 잦지 않은 우리 서비스에는 해당하지 않기는 함..
4.
데드락, 기아 현상 발생 가능성
a.
잘못된 락 순서 또는 중첩 락 요청으로 인한 데드락위험 존재
→ 이건 근데 분산락해도 신경써야하는 부분이라..

개선 방법

동시성 문제는 Redisson이 처리했다구!
@ContributeLock 커스텀 어노테이션을 만든다
해당 어노테이션이 붙어있으면 AOP로 Redisson의 fairLock을 진행한다
fairLock? : 요청을 순서대로 큐에 담아 FIFO 방식으로 처리. 기아현상을 막기 위해 순차적으로 처리하는 방식을 택했음. 성능이 일반 락에 비해 조금 떨어진다고는 하지만 트래픽이 무지막지하게 몰렸을 때 30% 차이고, 우리 서비스는 그정도로 요청이 동시다발적으로 몰릴 케이스가 적다고 판별 → 데이터 정합성을 더 중요시생각해 fairLock을 택했다.
실행중인 Transactional Context가 커밋을 성공하면 안전하게 lock을 해제한다
CUD할 리소스는 단순히 getById 형식으로 가져오도록 하자.

X Lock? 데드락? 기아? 예? 필로소퍼 과제 켜라고요?

아주 깔끔하게 이미 정리를 잘 해주셨다. 뭔소리여.. 싶으면 일단 드셔보셈

진행

위의 글들을 통해 대략 무슨말을 하고있는지 느꼈다면, 이제 코드로 ㄱㄱ
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedLock { String lockName(); // Lock 획득 시 Redis에 저장될 Key String pk(); // 해당 entity 에서 특정할수 있는 PK 이름 TimeUnit timeUnit() default TimeUnit.SECONDS; long waitTime() default 5L; // lock 획득을 위한 대기시간 long leaseTime() default 3L; // lock 획득 시 갖고있을 시간 }
Java
복사
락 획득 시 Redis의 key로 저장될 값을 ${pk}:${lockName}_LOCK 이런 형식으로 구상했다.
@Component public class TransactionAspect { /** * leaseTime 보다, 트랜잭션 타임아웃을 작게 설정합니다. * leaseTimeOut 발생 이전에 rollback을 시키기 위함. * * @param joinPoint * @return * @throws Throwable */ @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2) public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { return joinPoint.proceed(); } }
Java
복사
동시성 환경에서 데이터 정합성을 보장하기 위해 트랜잭션 커밋 이후 락이 해제되어야하기 때문에, 분산락 수행 시 REQUEIRES_NEW를 사용해 별도의 트랜잭션에서 수행되도록 만들었다.
AOP에서 실행될 분산락은 별도의 트랜잭션에서 수행되고, timeout을 leaseTime보다 작게 설정해 서비스의 트랜잭션이 끝난 후에 Lock을 해제하자
@Around("@annotation(org.ftclub.cabinet.cabinet.domain.DistributedLock) && args(targetId)") public Object applyDistributedLock(ProceedingJoinPoint joinPoint, Long targetId) throws Throwable { DistributedLock annotation = getAnnotation(joinPoint); String key = getLockName(targetId, annotation); RLock lock = redissonClient.getfairLock(key); log.info("lock 획득 시도 : {}", key); try { boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit()); if (!available) { log.error("Lock 획득 실패, {}", key); throw ExceptionStatus.LOCK_ACQUISITION_FAILED.asServiceException(); } log.info("lock 획득 성공, {}", key); return transactionAspect.proceed(joinPoint); } catch (RedisConnectionException e) { return handleWithXLockFallback(joinPoint, targetId, annotation.lockName()); } finally { try { lock.unlock(); } catch (IllegalMonitorStateException e) { // leaseTime 지난 후 자동해제가 되는데 unlock 시도 시 발생 log.error("Lock 반납 오류 (이미 만료): {}{}", e, key); } } }
JavaScript
복사
Aspect 부분은 Redisson을 사용해 lock 획득 및 반환을 진행하고, Redis 연결에 오류가 있을 경우 기존 X Lock을 호출하도록 fallback도 작성
작성한 커스텀 어노테이션을 붙이고, X Lock 방식으로 가져왔던 리소스를 단순 조회로 변경하면 끗

테스트 코드

k6를 이용해서 테스트 시나리오 작성, InfluxDB + Grafana를 도커에 띄워 데이터 수집 및 시각화 ㄱㄱ
{duration: '10s', target: 10}, // 점진적 증가 {duration: '20s', target: 50}, // 중간 부하 {duration: '20s', target: 100}, // 높은 부하 {duration: '10s', target: 0} // 종료
JavaScript
복사
로컬 환경에서 점진적인 부하 테스트를 진행했고, 각각의 유저들이 짧은 시간동안 반복해서 요청을 보낸다.
결과 요약:
X Lock 평균 응답시간: 50.5ms
분산 락 평균 응답시간: 38.3ms
평균 응답시간, 최대 응답시간 측면에서 분산 락이 조금 더 빠른 양상을 보여준다
근데 내 눈으로 보기엔.. 그정둔감? 그래프 생긴거 전반적으로 둘 다 비슷해보이는데.. GPT에게 로그 일부와 Grafana 자료를 첨부해서 유의미한 개선임? 이라고 물어보자
그정돈가2…지만, 답변을 보고 확실히 정했다
우리 서비스는 일단 단일 인스턴스 환경에서 다중으로 바뀔 확률이 극히 낮고, 앞으로 유저가 늘어날 가능성이 현저히 낮음 → 자연스럽게 경쟁상황이 거의 발생하지 않을 환경이니 분산락 까지는.. 오버엔지니어링임을 외부(Redis)에 의존하지 않고, 정합성에 대해 안정성을 보장하는 X Lock 방식을 유지하는게 맞다고 결론이 났음다..
하지만다른 리소스에 범용적으로 사용할 수 있으니 혹..시 모르니까 코드베이스에 남겨두고 추가적인 테스트를 혼자라도 진행해봐야겠다..

참고자료