현재 포스트는 해당 포스트에서 이어지는 내용입니다.
✅ Redisson 분산 락으로 해결하기
@Lock를 활용한 비관적 락 적용은 Spring Data Jpa를 사용하는 프로젝트에서 매우 간편한 솔루션이 되지만 큰 단점들이 있다.
👿 단점1: 높은 병목 가능성
@Lock 어노테이션은 실제 데이터베이스 내부 자원에 락을 설정한다. 데이터베이스는 그 락을 관리하기 위해 트랜잭션과 연결된 리소스를 지속적으로 소비하게 된다. 따라서 락 요청이 많아지면 데이터베이스 커넥션 풀이 고갈될 수 있으며, 락 상태를 유지하는 동안 I/O 작업이 늘어나 성능 병목이 발생할 가능성이 높다.
그렇게되면 데이터베이스 본연의 역할(쿼리 처리, 데이터 저장)에 영향을 미치게 된다.
👿 단점2: 분산 데이터베이스 환경에서의 한계
단일 인스턴스라면 괜찮지만 다중 인스턴스 환경이라면 특정 인스턴스 내부에서 설정된 Lock이 다른 인스턴스에까지 적용되지 않기때문에 필요에 따라 동기화 과정이 필요하다. 이는 복잡성을 높인다.
물론 현재 프로젝트에서는 분산 데이터베이스 환경까지 고려하진 않지만 추후 확장성을 고려하면 분명 좋지 않는 설계이다.
💡 Redisson 선택 이유
우선 현재 프로젝트에서 Redis를 사용하고 있었기 때문에 Redis 기반의 분산 락을 적용해야겠다고 생각했다.
Redis 안에서도 Lettuce, Redisson이라는 두 가지 선택지가 존재하는데 이 둘의 락 사용방식에는 여러 차이가 있다.
Lettuce
우선 Lettuce는 Spin Lock 기반으로 동작한다.
분산 락을 사용하기 위해 setnx 명령어를 사용하게 되는데 이는 해당 키가 존재하면 실패하고 존재하지 않으면 성공하게 된다. 즉, 락을 획득하려는 스레드가 반복적으로 락을 사용할 수 있는지 확인하면서 락 획득을 시도하는 방식이다. 별도의 Retry기능도 제공되지 않아 직접 구현해야한다.
그렇기 때문에 요청이 많을수록 Redis가 받는 부하가 커지게 된다.
Redisson
Redisson은 Pub/Sub 기반으로 동작한다.
Lock을 관리하기 위한 채널을 만들어 한 스레드가 락을 점유하다가 해제되면 다른 스레드에게 그 소식을 알려주고 다음 스레드가 락을 점유하게된다.
이 방식은 별도의 Retry 로직을 작성하지 않아도 된다.
💡 Redisson 선택
Redisson은 Lettuce와는 달리 Retry 로직을 작성하지 않아도 돼 그만큼 코드의 양이 줄어들게 된다.
또한 분산환경에서 요청이 많아지게되면 부하가 커지는 Lettuce와는 달리 Redisson은 비교적 안정적으로 처리가 가능하다.
그렇기 때문에 Redisson을 선택했다.
✅ Redisson을 사용해 분산락 구현하기
1️⃣ 라이브러리 의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.39.0'
2️⃣ RedissonClient 빈 등록
@Configuration
public class RedissonConfig {
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
Redisson은 기본적으로 localhost 6379로 연결을 시도한다.
만약 Redis가 다른 환경에 띄워져있다면 반드시 위의 설정정보를 적어주어야 한다.
3️⃣ 분산락 어노테이션 추가 및 AOP 적용
서비스 로직에 분산락 로직이 추가되는 것을 방지하기 위해서 어노테이션을 만들고 이에 대한 AOP를 적용했다.
@DistributedLock 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락 이름
*/
String key();
/**
* 락 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 락을 기다리는 시간
*/
long waitTime() default 500L;
/**
* 락 임대 시간
*/
long leaseTime() default 300L;
}
- 락 대기 시간과 임대 시간을 커스텀하게 설정할 수 있게 했다.
DistributedLockAop
@DistributedLock 어노테이션에 대한 AOP이다.
@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(com.wsws.moduleinfra.aop.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); // 1. Lock 인스턴스 가져오기
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); // 2. Lock 획득 시도
if (!available) return false;
log.info("Lock 획득 성공: {}", key);
return aopForTransaction.proceed(joinPoint); // 3. 해당 트랜잭션 로직 실행
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // 4. Lock 해제
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}", method.getName(), key);
}
}
}
}
- 설정된 Key를 기반으로 RLock 객체를 가져온다. 참고로 RLock은 Redisson의 Lock 인터페이스이다.
- 락 획득을 시도한다.
- 락 획득을 성공하면 해당 트랜잭션 로직을 실행한다. 좋아요 추가 로직이 이에 해당한다.
- 모든 로직이 끝나면 최종적으로 Lock을 해제한다.
4️⃣ 동적 Key 생성 로직
Redisson에서 Lock을 구분하기 위한 Key는 Lock의 대상이 될 데이터를 반영해야 의도한대로 동작하게된다.
1번 글에 좋아요를 누르는 요청과 2번 글에 좋아요를 누르는 요청은 별도의 락으로 구분할 수 있어야 하기 때문에 동적으로 Lock의 Key를 만들어주는 로직이 필요하다.
위의 Aop 코드를 보면 다음과 같은 부분이 존재했다.
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
- 해당 메서드의 파라미터 이름, 파리미터 값, 입력받은 분산락 key 정보를 통해 하나의 Lock Key를 만든다.
이때 Lock Key는 SpEL 표현식을 통해 만들어지는데 여기서 분산락 key정보에 표현식이 들어간다.
CustomSpringELParser
@Slf4j
public class CustomSpringELParser {
private 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]); // 1) 메서드의 파라미터 이름과 값을 EvaluationContext에 설정한다.
}
try {
return parser.parseExpression(key).getValue(context, Object.class); // 2)SpEL 표현식(key)을 통해 파싱한다.
} catch (Exception e) {
log.error("SpEL evaluation error: key={}, error={}", key, e.getMessage());
throw e;
}
}
}
- 메서드의 파라미터 이름과 값을 EvaluationContext에 설정한다.
- SpEL 표현식(key)을 통해 파싱한다.
예를 들어 다음과 같이 @DistrubutedLock 어노테이션에 key를 설정하면
@DistributedLock(key = "key:#serviceDto.name:#serviceDto.number")
public void 비즈니스_로직(XXXServiceDto serviceDto){}
public record XXXServiceDto {
private String name;
private int number;
}
- 메서드 실행:
- 비즈니스_로직(new XXXServiceDto("user1", 42)) 호출.
- AOP에서 SpEL 평가:
- parameterNames = ["serviceDto"]
- args = [XXXServiceDto(name="user1", number=42)]
- SpEL Key: "key:#serviceDto.name:#serviceDto.number"
- SpEL 평가 결과:
- key = "key:user1:42"
이런식으로 요청 값을 기반으로 자유롭게 Key값을 설정할 수 있다.
5️⃣ Lock 획득 후 트랜잭션 동작
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); // 2. Lock 획득 시도
if (!available) return false;
log.info("Lock 획득 성공: {}", key);
return aopForTransaction.proceed(joinPoint); // 3. 해당 트랜잭션 로직 실행
그때 사용되는 AopForTransaction 클래스는 다음과 같다.
AopForTransaction
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@DistributeLock이 적용된 메서드는 부모 트랜잭션과는 상관없이 별도의 트랜잭션으로 동작하게끔 설정했다.
6️⃣ 좋아요 추가 로직에 @DistributedLock 적용
/**
* 좋아요 추가
*/
@DistributedLock(key = "'like-' + #request.targetType + '_' + #request.targetId")
public void addLikeToAnswer(LikeServiceRequest request) {
createLike(request); // Like 객체 생성
Answer answer = answerRepository.findById(request.targetId())
.orElseThrow(() -> AnswerNotFoundException.EXCEPTION);
answer.addLikeCount();// Answer의 likeCount 1증가
// 수정 반영
answerRepository.edit(answer);
}
좋아요 요청에는 사용자 ID, 글 종류, 글의 ID가 들어오는데 여기서 글 종류와 글 ID를 key값으로 설정하여 동일한 글에 좋아요를 추가시 발생하는 동시성 문제를 해결했다.
또한 AOP를 통해 비즈니스 로직에 Lock 관련 코드를 추가하지 않고 깔끔하게 유지했다.
✅ 테스트하기
테스트 코드는 이전에 사용했던 코드를 그대로 사용했다.
@Test
public void 답변_좋아요_추가_다중유저_단일답변() throws Exception {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 1; i <= threadCount; i++) {
int user_num = i;
executorService.submit(() -> {
try {
answerService.addLikeToAnswer(new LikeServiceRequest("user_id_" + user_num, "ANSWER", savedAnswer.getId()));
} finally {
latch.countDown();
}
});
}
latch.await();
AnswerEntity findAnswer = answerRepository.findById(savedAnswer.getId())
.orElseThrow(() -> AnswerNotFoundException.EXCEPTION);
// then
assertThat(findAnswer.getLikeCount()).isEqualTo(100);
}
정상적으로 테스트가 성공하는 것을 볼 수 있다.
✅ 참고자료
'데브코스 > 실습 & 프로젝트' 카테고리의 다른 글
[최종 프로젝트] 답변 조회 API 쿼리 최적화하기 (0) | 2025.01.22 |
---|---|
[최종 프로젝트] 오늘의 질문 조회 캐싱하기 (0) | 2024.12.31 |
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 2-1. Answer 테이블 데이터 정합성 문제 해결: 비관적 락(@Lock) 적용 (0) | 2024.12.31 |
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 1. Like 테이블 데이터 정합성 문제 (0) | 2024.12.31 |
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 0. 배경 설명 (1) | 2024.12.31 |