안녕하세요! 저는 최근 실무에서 동시성 문제와 관련된 예약 관련 시스템을 구축하게 되었는데, 이에 따른 문제 해결에 대한 여러선택 과정과 해결한 방법에 대해서 설명드리려고 합니다. 😄
목차
- Lock을 사용하게 된 이유
- 왜 Redisson Lock을 사용했는지?
- 분산 Lock을 사용할 때 주의점
- Lock 사용 예제 코드와 설명
- Multi Lock이란?
- 마무리 하며
1. Lock을 사용하게 된 이유
이번에 제가 Lock을 사용하게 된 이유는, 여러 명의 사용자가 한정된 수량을 예약하기 위해서 사용하게 되었습니다.
위처럼 1번 스레드가 예약 가능 수량을 조회하여 받을 때, 2번 스레드도 비슷한 시간에 조회하였을 경우 아직 갱신되지 않았으므로, 두 스레드 모두 동일한 수량을(50개) 조회하게 됩니다. 그렇게 1번 스레드에서 30개를 예약하고, 2번 스레드에서는 20개를 각각 받아온 50개라는 수량에서 처리하는 것이 문제가 발생할 수 있게 됩니다.
따라서 이러한 동시적으로 요청이 발생하는 상황에 대해서 관리하기 위해 저는 `Lock`을 사용하여 처리하게 되었습니다.
2. 왜 Redisson Lock을 사용했는지?
✅ Redisson Lock
Redis가 설치되어 있으면 바로 사용할 수 있으며, 메모리 기반 데이터 저장소로 동작하기 때문에 Lock 획득 및 해제가 매우 빠릅니다. 또한 고수준의 API(RLock, MultiLock 등)를 제공하여 개발자가 복잡한 Lock 관리 로직을 구현하지 않아도 됩니다. 또한 서버가 확장 될 경우 효율적인 분산 Lock을 사용할 수 있어 사용하게 되었습니다.
Java synchronized
Java에서는 `synchronized`를 이용한 어플리케이션에서 동시성 문제를 해결하기 위한 thread-safe한 기능을 제공하는데, 이는 애플리케이션 서버가 1개로 구성된 것이 아닌 다중 서버 환경에서는 제대로 동작하지 않기 때문에 사용하지 않았습니다.
DB Lock
DB Lock의 경우는 Application Level이 아닌 데이터베이스에서 직접 Lock를 걸어 사용하는 방식입니다.
하지만 오래전부터 사용하던 레거시한 데이터와 테이블들을 하나의 DB에서 계속 관리하고 있으며, 버전도 낮아 현재 회사에선 마이그레이션을 하는 것을 목표로 두고있는 상황이라 DB에 부하를 주는 것은 좋지 않다고 판단하였습니다. (물론 아니더라도 저는 DB보단 최대한 확장이 편리한 Application Level로 부하를 덜어내는 것을 선호하는 편이긴 합니다.🙂)
Kafka 메시지 기반
Kafka에서는 메세지 도착 순서 보장과 트랜잭션, 메시지 중복 방지 기능으로 정확성을 보장하지만, Redisson Lock을 사용하는 것과 비교를 해본다면
- 복잡성 증가: 트랜잭션 설정, 파티션 설계, 메시지 순서 제어 등 추가적인 설계가 필요.
- 지연 가능성: 메시지가 소비자에게 전달되고 처리되는 데 시간이 걸릴 수 있음.
- 비용 효율성: Redis는 하나의 인스턴스만으로도 작동 가능하지만, Kafka는 브로커, Zookeeper(또는 KRaft), 토픽 설정 등 추가적인 인프라 관리 비용이 발생합니다.
때문에 현재는 Kafka를 사용할 정도로 대규모 시스템이 아니기 때문에 Redisson Lock만으로 충분하다 생각을 하였고, 만일 나중에 시스템이 확장되어 규모가 커지게 되면 전환을 하거나 병행하여 사용하면 좋겠다고 생각하게 되었습니다.
3. 분산 Lock을 사용할 때 주의할 점
1) Lock 해제
Lock을 해제하지 않으면 Deadlock 발생
Lock을 해제 하지 않는 경우 동일한 자원에 대한 Lock이 필요한 스레드에서 Deadlock 현상이 발생할 수 있습니다.
따라서 Lock을 획득하고 전부 사용 후에는 Lock을 바로 반환해 주어야 하고, 너무 오래 걸려서 일정 시간 이상 Lock을 사용할 경우에는 강제로 반환을 하도록 로직을 구현해야 합니다.
2) Lock 범위
Lock 반환 전 예외 상황이 발생할 경우에 대비
Lock을 획득한 이후 로직을 처리 중에 예외가 발생하게 되면, Lock을 반환하는 로직이 실행되지 않을 수 있습니다.
따라서, Lock을 반환하는 로직을 예외가 발생하여도 항상(무조건) 실행될 수 있을 만한 위치에 반환 로직을 두어 처리를 해주는 것이 중요합니다.
항상 트랜잭션 외부에서 Lock을 설정 및 해제
또한 동일한 트랜잭션 내에서 Lock을 획득하고 반환하는 경우 데이터가 제대로 DB에 커밋되기 이전에 Lock이 반환될 수 있습니다.
위 처럼 스프링에서는 트랜잭션이 끝난 이후 커밋단계에서 실제 DB에 변경된 값을 반영을 하게 되므로, 실제로 트랜잭션이 종료되기 직전 Lock을 해제하여도 커밋 단계보다 먼저 Lock이 해제될 가능성이 높습니다.
따라서, Lock은 트랜잭션 범위 바깥에서 Lock을 획득하고 트랜잭션이 모두 커밋된 이후에 Lock을 해제하여 주는 것 또한 중요합니다.
3) Lock 점유 시간
마지막으로 Lock을 사용할 때 중요하다고 생각하는 것은 바로 Lock을 점유할 수 있는 시간이라고 생각합니다.
Lock이 필요한 로직은 Lock을 획득할 때까지 대기 상태에 머물게 됩니다. 이때, 특정 스레드가 Lock을 오랫동안 점유하여 반환하지 않는다면, 시스템 전체의 병목 현상이 발생할 수 있습니다. 이를 방지하려면 최대 임대 허용 시간(lease time)을 설정하고, 해당 시간이 초과되면 Lock을 강제로 해제하는 메커니즘을 구현하는 것이 중요하며 이를 통해 시스템의 안정성을 크게 향상시킬 수 있습니다.
만약 금융 거래 시스템처럼 예외 상황에서도 작업을 건너뛰지 않고 순차적으로 처리해야 한다면 무기한 대기 Lock도 고려해 보아야 합니다.
다만 무기한 대기 Lock은 남용할 경우 교착 상태를 유발할 가능성이 크므로, 추후 필요에 따라 재시도 로직이나 백오프 전략 같은 대안도 함께 검토하여 사용하는 것을 검토해 보는 것이 좋습니다.
4. Lock 사용 예제 코드와 설명
Lock의 경우는 위에서 설명한 것처럼 항상 트랜잭션 외부에서 동작하여야 하고, 예외가 발생하더라도 Lock을 반환할 수 있는 것이 중요합니다. 따라서 이러한 패턴들은 모두 동일하게 반복되므로 `AOP`를 사용하여 반복적인 작업을 최소화하였습니다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String DISTRIBUTED_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.~.common.annotation.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
// 1
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
// 2
LocalDate lockDate;
try {
lockDate = (LocalDate) CustomSpELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
distributedLock.key()
);
if (lockDate == null) {
throw new IllegalArgumentException("Lock Key는 null일 수 없습니다.");
}
} catch (ClassCastException ex) {
throw new IllegalArgumentException("Lock Key의 타입이 LocalDate가 아닙니다. key: " + distributedLock.key(), ex);
}
String key = DISTRIBUTED_LOCK_PREFIX + lockDate;
// 3
RLock rLock = redissonClient.getLock(key);
try {
// 4
boolean available = rLock.tryLock(distributedLock.waitTime(),
distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
throw new IllegalStateException("Lock을 획득하지 못하였습니다. key: " + key);
}
// 5
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException ex) {
// 6
Thread.currentThread().interrupt();
log.warn("Lock 획득 중에 스레드가 중단되었습니다. method: {} in class: {}, key: {}",
method.getName(), method.getDeclaringClass().getSimpleName(), key);
throw new IllegalStateException("Lock 획득 중에 스레드가 중단되었습니다.", ex);
} finally {
try {
// 7
if (rLock != null && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (IllegalMonitorStateException ex) {
log.error("Redisson Lock이 이미 해제되었습니다. \"serviceName\" : {},
\"key\" : {} ex : {}", method.getName(), key, ex);
}
}
}
}
- 현재 실행 중인 메서드의 메타데이터(메서드 이름, 파라미터 등)를 가져옵니다.
- SpEL 표현식으로 정의된 `key` 값을 파싱하여 동적으로 생성합니다.
- SpEL 표현식을 통해 메서드 파라미터 값을 기반으로 Lock Key를 유연하게 생성하며,
- 저는 `LocalDate` 타입인 날짜를 기준으로 Lock Key를 생성하고, null이면 예외를 발생시켜 주었습니다.
- Redisson의 `getLock()` 메서드를 통해 주어진 키에 대한 RLock 객체를 가져옵니다.
- `tryLock`을 통해 대기 시간, Lock 유지 시간, 시간 단위를 설정하여 Lock 획득을 시도합니다.
- 만약 Lock 획득에 실패하면 예외를 발생시킵니다.
- 실제 비즈니스 로직을 수행합니다.
- AOP로 분리된 트랜잭션 관리 객체 `aopForTransaction`을 통해 JoinPoint를 실행합니다.
- 스레드가 중단된 경우 인터럽트를 복구하고 예외를 발생시킵니다.
- `finally` 구문에 두어 무조건 Lock을 해제하도록 합니다.
- 현재 스레드가 보유한 Lock인 경우에만 `unlock()`을 호출하여 Lock을 해제합니다.
또한 `SpEL` 표현식으로 정의한 `key` 값을 파싱하여 동적으로 반환하는 class을 생성하여 주었고,
public class CustomSpELParser {
private CustomSpELParser() {
}
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을 사용할 수 있는 커스텀 `@DistributedLock`를 만들어 주었습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // Lock 이름
TimeUnit timeUnit() default TimeUnit.SECONDS; // Lock 시간 단위
long waitTime() default 30L; // Lock 획득을 기다리는 시간
long leaseTime() default 10L; // Lock 획득 후 임대 시간
}
마지막으로 사용하는 서비스에선 `@Transaction` 대신 직접 만든 Lock을 통해 트랜잭션을 관리하는 @DistributedLock를 사용하여 주었습니다.
// LockService
@DistributedLock(key = "#inboundDate")
public void reserveQty(
LocalDate inboundDate,
예약 수량 등...
) {
예약처리 로직...
}
해당 코드를 작성하게 되면 이제 @DistributedLock 사용하여 key에는 `Lock:2025-01-01` 형식의 날짜형식으로 Lock을 잡아 해당 일자의 예정 수량들을 예약할 수 있게 되었습니다.😄 라며 끝난 줄 알았지만..
문뜩 수정을 통해 오늘 예정되어 있던 수량을 내일로 변경시키게 되면, 오늘과 내일 날짜를 한 번에 모두 Lock을 잡아야 하는데 어떻게 해결해야하지..? 라며 고민을 하게 되었습니다.🤣
5. 한발 더 나아가 Multi Lock을 사용해보자!
그렇게 찾아본 결과 Redisson에서 Multi Lock을 편리하게 구현할 수 있는 `RedissonMultiLock`을 고수준에서 지원하고 있었고, 현재 코드에서 일부분만 수정하면 빠르게 적용할 수 있어서 바로 사용하게 되었습니다.☺️
먼저 어노테이션인 DistributedLock의 key를 배열로 변경해 주었습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String[] keys(); // 배열로 변경
...
}
다음으로는 AOP 로직에서 멀티 Lock을 획득할 수 있도록 로직을 수정하여 주었습니다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String DISTRIBUTED_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.corner.logis_api.common.annotation.DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
List<RLock> rLocks = getLocks(signature, joinPoint, distributedLock);
if (rLocks.isEmpty()) {
log.error("Lock Key 값이 존재하지 않습니다. method: {} in class: {}",
method.getName(), method.getDeclaringClass().getSimpleName());
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
}
if (rLocks.size() == 1) {
return handleSingleLock(rLocks.get(0), distributedLock, joinPoint, method);
} else {
return handleMultiLock(rLocks, distributedLock, joinPoint, method, distributedLock.keys());
}
}
private List<RLock> getLocks(
MethodSignature signature,
ProceedingJoinPoint joinPoint,
DistributedLock distributedLock
) {
List<RLock> rLocks = new ArrayList<>();
for (String keyExpression : distributedLock.keys()) {
Object result = CustomSpELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
keyExpression
);
if (result instanceof List<?> list) {
if (!list.isEmpty() && list.get(0) instanceof LocalDate localDate) {
String key = DISTRIBUTED_LOCK_PREFIX + localDate;
rLocks.add(redissonClient.getLock(key));
} else {
log.error("Lock Key가 비어있거나 LocalDate 타입이어야 합니다.");
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
}
} else {
log.error("Lock Key가 null이거나 List<LocalDate> 타입이어야 합니다.");
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
}
}
return rLocks;
}
private Object handleSingleLock(
RLock rLock,
DistributedLock distributedLock,
ProceedingJoinPoint joinPoint,
Method method
) throws Throwable {
try {
boolean available = rLock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!available) {
log.error("Single Lock을 획득하지 못하였습니다. key:{} ", rLock.getName());
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
log.error("Single Lock 획득 중에 스레드가 중단되었습니다. method: {} in class: {}, key: {}",
method.getName(), method.getDeclaringClass().getSimpleName(), rLock.getName());
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
} finally {
try {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (IllegalMonitorStateException ex) {
log.warn("Redisson Single Lock이 이미 해제되었습니다. \"serviceName\" : {}, " +
"\"key\" : {} ex : {}", method.getName(), rLock.getName(), ex);
}
}
}
private Object handleMultiLock(
List<RLock> rLocks,
DistributedLock distributedLock,
ProceedingJoinPoint joinPoint,
Method method, String[] keys
) throws Throwable {
RedissonMultiLock multiLock = new RedissonMultiLock(rLocks.toArray(new RLock[0]));
try {
boolean available = multiLock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!available) {
log.error("Multi Lock을 획득하지 못하였습니다. keys: {}", Arrays.toString(keys));
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
log.error("Multi Lock 획득 중에 스레드가 중단되었습니다. method: {} in class: {}, keys: {}",
method.getName(), method.getDeclaringClass().getSimpleName(), Arrays.toString(keys));
throw new BusinessException(EExceptionStatus.INTERNAL_SERVER_ERROR);
} finally {
try {
if (multiLock.isHeldByCurrentThread()) {
multiLock.unlock();
}
} catch (IllegalMonitorStateException ex) {
log.warn("Redisson Multi Lock이 이미 해제되었습니다. \"serviceName\" : {}, \"keys\" : {} ex : {}",
method.getName(), Arrays.toString(keys), ex);
}
}
}
}
변경된 사항
- @DistributedLock의 `keys` 속성에서 여러 키를 받을 수 있습니다.
- 멀티 Lock 처리가 가능한 `RedissonMultiLock`을 사용하여 여러 키를 동시에 Lock할 수 있게 되어 하나의 트랜잭션 내에서 여러 개의 Lock을 획득하고 진행할 수 있게 되었습니다.
- handleSingleLock과 handleMultiLock 메서드로 분리되어, 기존의 단일 Lock과 멀티 Lock 처리를 구분하여 유동적으로 처리할 수 있도록 변경하였습니다.
이제 사용하는 로직에서는 Multi Lock을 사용하게 될 경우, 아래와 같이 2개의 키를 전달해 주어 Multi Lock을 사용할 수 있게 되었습니다.
@DistributedLock(key = "{#beforeInboundDate, updateInboundDate}")
public void updateReservedQty(
LocalDate inboundDate,
LocalDate updateInboundDate,
변경 수량 등...
) {
변경 로직...
}
마무리 하며
이번 글에서는 동시성 문제를 해결하기 위한 Lock의 필요성에서 시작해, Redisson Lock을 선택한 이유와 분산 Lock 사용 시 주의할 점, 그리고 멀티 Lock을 활용한 확장된 구현 방법까지 살펴보았습니다.
Redisson Lock을 통해 분산 환경에서의 동시성 문제를 효과적으로 해결할 수 있었으며, 이를 활용해 트랜잭션 외부에서 Lock을 관리하고, 다양한 상황에 유연하게 대처할 수 있는 구조를 설계할 수 있었습니다. 특히, AOP와 Custom Annotation을 활용해 반복적인 코드 작성을 줄이고, 유지보수성을 크게 높였다는 점에서 많은 성과가 있었던 것 같습니다.
현재는 Redisson Lock만을 활용한 방식으로 충분한 성능을 확보하고 있지만, 시스템 규모가 커지거나 요구사항이 복잡해질 경우 Kafka와 같은 메시지 큐와의 병행 사용, 혹은 다른 분산 트랜잭션 관리 시스템을 도입해 볼 수 있을 것 같습니다. 앞으로도 이와 같은 더 나은 설계와 확장성을 고민하고 지속적으로 성장해 봐야겠습니다. 😊
'JAVA' 카테고리의 다른 글
JAVA 람다(Lambda)를 사용할 때 장단점 (0) | 2024.08.13 |
---|---|
전역 변수, 지역 변수, 클래스 변수 차이를 이해하자! (1) | 2024.04.23 |
UUID가 겹치면 어떻게 하지? (중복을 최소화 해보자!) (2) | 2024.03.11 |