들어가며
이번 포스팅에서는 100명의 사용자가 동시에 같은 게시글에 좋아요 또는 좋아요 취소를 누르는 상황에서 발생하는 예시 상황과 같은, 동시성 문제를 어떻게 해결할 수 있을지 다루어보려 한다
일단 내가 구현한 기능에서 좋아요는 인스타의 좋아요처럼 누르고, 다시 누르면 취소하는 로직이였다. 그래서 좋아요 수를 올리는 과정에서 락을 걸지 않으면, 동시에 여러 쓰레드나 요청이 동일한 데이터를 수정하면서 정합성 이나 데이터 유실 문제가 발생할 수 있다고 판단했어서 여러 방법을 고민하다가 분산락을 적용하는 방향으로 생각했다
Redisson

먼저 Redisson이 뭔지에 대해서 먼저 알아보자
Redisson이란 ?
자바 언어로 구현된 Redis 클라이언트 라이브러리이다. 주로 분산 시스템에서 동시성 문제를 해결하기 위해 분산 락을 구현할 때 사용된다
Redisson 동작 방식은 ?
Pub/Sub 방식을 사용해 락 해제 시 subscribe 중인 클라이언트들이 신호를 받고 락 획득을 시도하는 구조. 이는 Lettuce의 setnx/setex를 이용한 스핀락 방식과 비교해 Redis 부하를 줄여준다
https://redisson.pro/docs/overview/
Overview - Redisson Reference Guide
Overview Redisson is the Java Client and Real-Time Data Platform for Valkey and Redis. Providing the most convenient and easiest way to work with Valkey or Redis. Redisson objects provide an abstraction layer between Valkey or Redis and your Java code, whi
redisson.pro
Redisson을 도입하게된 이유는 아래의 여러가지 이유가 있었다
1. Redisson은 이미 캐싱에서 Redis를 사용하고 있어 별도의 기술 스택 추가 없이 사용할 수 있었다
2. Lock 인터페이스를 지원해 timeout, retry 같은 기능을 직접 구현할 필요가 없었다 (Redisson은 Lock 인터페이스를 제공해 락 획득과 타임아웃 설정을 쉽게 적용할 수 있다. 근데 Lettuce를 쓰면 개발자가 직접 retry, timeout 로직을 구현해야 하는 번거로움이 있다고한다)
3. 물론 MySQL에서 DB 락을 사용하는 방법도 있었지만, 별도 커넥션 풀 관리 및 DB 부하를 생각했을 때, Redis를 통한 분산락이 더 효율적이라고 판단했다
이제 실제 로직을 보면서 내가 고민했었던 내용을 추가적으로 풀어보려고 한다
본문
분산락은 처음 구현하는 내용이기도 했었고, 구글링하면서 찾아보니까 정말 깊은거같아 많은 지식이 필요했었다.... 그래서 구현하면서 고민했던 내용은 아래와 같았는데
분산락 구현으로 동시성 처리를 위해서 고민한 점
첫번째, 분산락 처리 로직은 비즈니스 로직이 오염되지 않게 분리해서 사용한다
락 처리 로직이 서비스 단 비즈니스 로직에 섞이면 코드 가독성이 떨어지고 유지보수가 어려워진다. 이를 해결하기 위해 AOP로 락 처리 부분을 분리해 관리했다
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 3L;
}
두번째, waitTime, leaseTime을 커스텀 하게 지정한다
락을 얻을 때 얼마나 기다릴지(waitTime), 락을 얻은 뒤 언제까지 점유할지(leaseTime) 를 유연하게 설정할 수 있게 했다. 이 설정값은 상황에 따라 다르게 적용할 수 있도록 애노테이션에서 받을 수 있게 설계했다
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
kv("serviceName", method.getName()),
kv("key", key)
);
}
}
세번째, 락의 name에 대해 사용자로부터 커스텀 하게 받아 처리한다
게시글 ID, 사용자 ID 등 복합 키로 락 이름을 만들어야 하는 경우가 많았다. 이때 Spring Expression Language(SpEL)를 사용해 키를 동적으로 생성하도록 만들었다
@Around("@annotation(org.sopt.global.common.aop.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
네번째, 추가 요구사항에 대해서 공통으로 관리한다
앞으로 다른 분산락 요구사항이 추가되더라도 동일한 AOP 로직으로 처리할 수 있도록 범용적으로 설계했다. 예외 처리, 트랜잭션 커밋 후 락 해제 등 공통 관리 포인트를 포함했다
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
왜 트랜잭션 커밋 이후 락 해제가 필요할까?
-> 동시성 환경에서 데이터 정합성을 보장하기 위해서 커밋 전 락 해제는 잘못된 데이터 노출 위험을 만든다
마치며
구현한 나머지 전체 코드
@RequiredArgsConstructor
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(org.sopt.global.common.aop.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
kv("serviceName", method.getName()),
kv("key", key)
);
}
}
}
}
실제 서비스단 적용 후 테스트



락을 적용한 뒤, 사진처럼 100명의 유저가 동시에 좋아요를 눌러도 테스트가 안정적으로 통과하고, 좋아요 수가 정확히 100으로 기록되는 걸 확인
AOP 개념과 장점 등, 추가적인 내용이 궁금하다면 아래 링크를 참고해주면 더 좋을거같다
https://huncozyboy.tistory.com/44
AOP 활용해서 Request 로그 추적 (Spring)
들어가며먼저, AOP를 사용해야겠다라고 생각했던 이유는 Redis 캐싱을 적용해서, 게시글 상세 조회나 전체 목록 조회 등의 내용의 응답 시간을 단축 시켰는데, 실제로 구체적인 Request 로그로 확인
huncozyboy.tistory.com
'Spring > AOP' 카테고리의 다른 글
| AOP 활용해서 Request 로그 추적 (Spring) (0) | 2025.05.23 |
|---|