개요
예약 관리 시스템에서 동일한 시간대의 예약에 대한 동시성 제어를 하기 위해 다양한 lock 기법 중 Redisson 분산 락을 적용하기로 했다.
Redisson 분산 락 구현
1. Redisson 의존성 추가
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
- build.gradle 파일에 Redisson 라이브러리 의존성을 추가한다.
2. Redisson 설정
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
- Config 파일을 통해 Redisson Client를 설정한다.
- yml 파일에서 설정하는 것도 가능하다.
3. 분산 락 클래스 구현
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/* 락의 이름*/
String key();
/* 락의 시간 단위*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/* 락을 기다리는 시간 (default- 5s)
* 락 획득을 위해 waitTime 만큼 대기한다.*/
long waitTime() default 5L;
/* 락 임대 시간 (default- 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다 */
long leaseTime() default 3L;
}
4. 락 AOP 클래스 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redisson;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.quit.reservation.infrastructure.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 = redisson.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 {} {}", method.getName(), key);
}
}
}
}
- @DistributedLock 어노테이션 선언 시 수행되는 aop 클래스
- 락의 이름으로 RLock 인스턴스를 가져온다.
- 정의된 waitTime까지 획득을 시도한다. 정의된 leaseTime이 지나면 잠금을 해제한다.
- DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
- 종료 시 무조건 락을 해제한다.
5. Custom SpringEL Parser 클래스
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
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]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
- 전달받은 Lock 이름을 Spring Expression Language로 파싱하여 읽어온다.
6. Aop Transaction 클래스
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
- AOP에서 트랜잭션 분리를 위한 클래스
- 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정
- 반드시 트랜잭션 커밋 이후 락이 해제되게끔 처리
7. 서비스 코드 적용
@DistributedLock(key = "#request.storeId + ':' + #request.reservationDate + ':' + #request.reservationTime")
public CreateReservationResponse createReservation(CreateReservationDto request, String customerId) {
... 서비스 코드 생략...
}
@DistributedLock(key = "#slotId")
public void changeReservationStatusAsync(UUID reservationId, ReservationStatus status, UUID slotId) {
... 서비스 코드 생략...
}
- @DistributedLock 어노테이션을 통해 서비스 코드 호출 시 설정한 key로 락을 획득하도록 설정
테스트
테스트 컨트롤러 사용해서 호출 테스트
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/tests")
public class KafkaTestController {
private final ReservationMessagingProducer reservationMessagingProducer;
@PostMapping("/queue")
public ResponseEntity<String> createSend(@RequestBody ReservationMessage message) {
String key = message.getStoreId() + ":" + message.getUserId();
reservationMessagingProducer.sendMessage("queue.process.success", key, message);
return ResponseEntity.ok(key);
}
@PostMapping("/payment")
public ResponseEntity<String> paymentSend(@RequestBody PaymentMessage message) {
String key = String.valueOf(message.getReservationId());
reservationMessagingProducer.sendMessage("payment.create.success", key, message);
return ResponseEntity.ok(key);
}
}
테스트 결과
예약 생성 테스트 결과
예약 진행 테스트 결과
카프카 토픽
애플리케이션 로그
- 로그에서 서비스 로직 호출 시 락을 획득하고 로직 완료(Commit) 시 락을 해제하는 것을 확인할 수 있다.
정리
- Redisson 분산 락을 사용해 서비스의 동시성 충돌 문제를 효과적으로 해결할 수 있었다.
- Commit이 완료되고 락을 해제하도록 설정하는 것이 어려웠지만, 이번에 배운 것을 활용해 다음 구현 시에는 좀 더 나은 방식으로 할 수 있을 거 같다.
'자바 심화 > TIL' 카테고리의 다른 글
Kafka 비동기 처리 구현 (0) | 2025.01.10 |
---|---|
Kafka - 비동기 처리 (1) | 2024.12.31 |
동시성 제어 시점과 데이터 일관성 유지 관점 (1) | 2024.12.30 |
Redis - Redisson (2) | 2024.12.27 |
DB Lock (1) | 2024.12.26 |