이끌든지 따르든지 비키든지

Framework/Spring

[Spring] Redisson으로 동시성 문제 해결하기

SeongHo5 2024. 2. 28. 23:25

들어가기 앞서 - 동시성 문제란?

동시성 문제는 하나의 데이터에 여러 스레드가 동시에 접근할 때 발생할 수 있는 문제를 말합니다.

멀티스레딩 환경에서 특히 중요한 이슈로, 여러 스레드가 공유 자원에 동시에 액세스 할 때 정확성과 일관성에 문제가 생겨 데이터의 손상, 잘못된 실행 결과 등을 초래할 수 있습니다.

 

이를 방지하고, 공유 자원에 대한 안정적인 접근과 제어를 위해서는 한 번에 하나의 요청만을 처리할 수 있도록 하는 Lock이 필요합니다.

 

1. Synchronized 사용


 

다른 방법보다 비교적 간단하게, Java의 synchronized 키워드를 사용해 해당 메서드나, 코드 블럭을 동기화해 한 스레드에서 해당 자원에 접근 중이라면, 다른 스레드의 접근을 막아 해당 자원을 Thread-safe 하게 만들 수 있습니다.

 

다만, synchronized 키워드는 @Transactional 어노테이션과 같이 사용할 수 없고,

애플리케이션이 분산 환경에 있는 경우, 동시성 문제에 대한 근본적인 해결책이 될 수 없습니다.

 

💡synchronized와 @Transactional을 같이 쓸 수 없는 이유

@Transactional은 대상 객체를 상속해 프록시를 생성하고, 실제 호출을 이 프록시 객체를 통해 처리하는 방식으로 이루어지는데, synchronized 키워드는 상속되지 않아, 프록시 객체에 자동으로 적용되지 않습니다.

 

 

2. Optimistic Lock & Pessimistic Lock


 

  • Optimistic Lock(낙관적 잠금) 
    • 충돌이 발생하지 않을 것이라 낙관하고, Lock 대신 Version을 이용하는 방식입니다.
    • JPA를 사용하는 경우 엔티티에 @Version 어노테이션을 추가해 쉽게 구현할 수 있습니다.
    • 충돌이 자주 발생하는 데이터라면, 롤백 처리 때문에 오히려 성능이 떨어질 수도 있습니다.

 

  • Pessimistic Lock(비관적 잠금)
    • 충돌이 반드시 발생할 것이라 비관하고, 데이터에 Lock을 걸어, 다른 트랜잭션의 접근을 막는 방식입니다.
    • JPA를 사용한다면 'LockModeType'을 원하는 메서드에 적용해 비관적 잠금을 구현할 수 있습니다.
    • 데이터 접근을 막기 때문에, 락이 제대로 관리되지 않으면 성능이 크게 떨어질 수 있습니다.

 

3. Redisson


 

 

Redisson은 Java의 Redis 클라이언트로, 분산락을 구현하기 위해 사용할 수 있는 구현체 중 하나입니다.

 

Redis의 기본 클라이언트인 Lettuce와 비교했을 때, 분산 락 기능을 제공하고, spin-lock(락 획득까지 redis에 계속 요청을 보냄) 대신 pub/sub 방식을 사용하기 때문에 Redis에 대한 부하를 줄일 수 있습니다.

 

 

Redisson 환경 설정


 

먼저 Redis 인프라에 대한 설정이 필요합니다.

Redisson에 대한 의존성과 Bean 설정을 추가합니다.

 

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.redisson:redisson-spring-boot-starter:{your_verison}'
}

 

 

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    public static final String REDISSON_HOST_PREFIX = "redis://";

    private final RedisProperties redisProperties;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String address = REDISSON_HOST_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort();
        config.useSingleServer()
                .setDatabase(1)
                .setAddress(address);

        return Redisson.create(config);
    }
    
    // 기타 Redis 관련 Config
}

 

 

 

분산 락 구현


 

 

@Slf4j
@Service
@RequiredArgsConstructor
public class FooService {

    private final RedissonClient redissonClient;

    public void barMethod() {
    	RLock lock = redissonClient.getLock("1");
        try {
        	boolean isLocked = lock.tryLock(30, 10, TimeUnit.SECONDS);
            if (isLocked) {
            	try {
                	// 락 획득 후 시도할 비즈니스 로직
                } finally {
                	lock.unlock;
                }
            }
        } catch (Exception e) {
        	// 예외 발생 시 로직
        }
   }

}

 

 

이 코드는 '1'이라는 이름의 RLock 객체를 획득하려 시도하고, 락을 획득한 경우 비즈니스 로직을 수행하고, 로직 수행이 끝난 후, 락을 해제합니다. 

 

이 코드는 분산 락 획득 로직과 비즈니스 로직이 혼합되어 있어, 가독성이 저하되고, 분산 락 관련 로직이 여러 메서드에서 반복되어 코드 중복이 발생할 우려가 있습니다.

 

이 문제점을 해결하기 위해 분산 락 획득 & 해제 로직을 AOP를 이용해 분리하는 방법을 알아보겠습니다.

 

 

 

AOP를 이용해 분산 락 관련 로직 분리


 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    String keyName() default "";
    long waitTime() default 10L;
    long leaseTime() default 30L;
    TimeUnit timeUnit() default TimeUnit.SECONDS;

}

 

 

@DistributedLock 어노테이션을 구성하며, 속성을 통해 키의 이름, 락 획득을 위해 대기하는 시간(waitTime), 락을 보유하는 시간(leaseTime), 그리고 이에 대한 시간 단위를 설정할 수 있도록 했습니다.

 

 

@Slf4j(topic = "distributedLockAspect")
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Pointcut("@annotation(distributedLock)")
    public void lockPointcut(DistributedLock distributedLock) {
    }

    @Around(value = "lockPointcut(distributedLock)")
    public Object aroundLockPointcut(
            ProceedingJoinPoint joinPoint,
            DistributedLock distributedLock
    ) throws Throwable {
        RLock lock = redissonClient.getLock(distributedLock.keyName());
        boolean isLocked = false;
        try {
            isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (isLocked) {
                return joinPoint.proceed();
            } else {
                throw new CouldNotObtainLockException("Could not obtain lock");
            }
        } finally {
            if (isLocked) {
                lock.unlock();
            }
        }
    }
}

 

 

그리고, 이 어노테이션을 위한 Aspect를 구성했습니다.

 

메서드 실행 전에 락을 획득하고, 실행 후에 락을 해제하고, 락을 획득하지 못한 경우 CloudNotObtainException을 던지도록 했습니다.

 

 

AOP 적용 후의 로직

@Slf4j
@Service
@RequiredArgsConstructor
public class FooService {

    @DistributedLock(keyName = "Bar", waitTime = 3, leaseTime = 10)
    public void barMethod() {
    	// 락 획득 후 시도할 비즈니스 로직
   }

}

 

 

@DistributedLock 어노테이션을 통해 선언적으로 분산 락 관련 로직을 관리해 가독성과 유지보수성을 높일 수 있습니다.

 

 

 

부록: 프로젝트에 적용해 본 코드


 

 

'마켓체리' 프로젝트의 상품 재고 업데이트 로직에 분산 락 관련 로직을 적용해 보았습니다.

 

 

@Service
@RequiredArgsConstructor
public class GoodsInventoryService {

    @DistributedLock(waitTime = 3, leaseTime = 10)
    @Transactional(propagation = Propagation.MANDATORY, isolation = Isolation.SERIALIZABLE)
    @Retryable(retryFor = {CouldNotObtainLockException.class},
            backoff = @Backoff(delay = 100, maxDelay = 500, multiplier = 2))
    public void processInventoryUpdate(Goods goods, int requestedQuantity) {
        em.refresh(goods);
        checkInventory(goods, requestedQuantity);
        goods.updateInventory(requestedQuantity);
        em.flush();
    }

    private void checkInventory(Goods goods, int requestedQuantity) {
        if (goods.getInventory() < requestedQuantity) {
            throw new InsufficientStockException(INSUFFICIENT_STOCK, goods.getName());
        }
    }

}

 

 

락 획득을 위해 최대 3초간 대기하고, 락을 획득하고 최대 10초 동안 락을 유지할 수 있도록 했습니다.

 

락을 획득하지 못해 CouldNotObtainLockException이 발생하면 잠시 대기하고 재시도할 수 있도록 Retry 설정을 구성했습니다.

 


 

이슈: 고유한 키를 어떻게 동적으로 할당하지?

상품 재고를 갱신하는 과정에서는 동시성 문제를 피하기 위해 각 상품별로 고유한 락을 획득해야 합니다. 이를 위해, 상품의 고유 코드를 락의 키로 사용하는 것이 필요했습니다. 

 

처음에는 분산 락의 키를 동적으로 설정하는 방법을 찾아보았습니다. Spring Expression Language(SpEL)를 사용하여 어노테이션 속성에 동적 값을 할당하는 방법을 고려해 보았으나, 커스텀 어노테이션에는 이를 적용할 수 없다는 점을 알게 되었습니다.

 

이 문제를 해결하기 위해 CustomSpELParser라는 클래스를 구현했습니다. SpEL 표현식을 파싱하고 평가하는 로직을 담당하며, 분산 락 키를 동적으로 생성할 수 있도록 getDynamicValue 메서드를 작성했습니다.

 

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class CustomSpELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String name) {
        ExpressionParser parser = new SpelExpressionParser();
        EvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(name).getValue(context);
    }

}

 

 

CustomSpELParser으로 고유한 키를 생성하고, 락을 획득해 로직을 수행하는 최종 코드입니다.

 

 

@Slf4j(topic = "distributedLockAspect")
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private static final String REDISSON_KEY_PREFIX = "LOCK::";

    private final RedissonClient redissonClient;

    @Around("@annotation(distributedLock)")
    public Object aroundLockPointcut(
            ProceedingJoinPoint joinPoint,
            DistributedLock distributedLock
    ) throws Throwable {
        String key = getKeyFromMethodSignature(joinPoint, distributedLock);
        RLock lock = redissonClient.getLock(key);
        try {
            log.info("Acquiring Lock: {}", key);
            if (!acquireLock(lock, distributedLock)) {
                return false;
            }
            return joinPoint.proceed();
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            releaseLock(lock);
        }
    }

    private String getKeyFromMethodSignature(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        String key = CustomSpELParser.getDynamicValue(
                ((MethodSignature) joinPoint.getSignature()).getParameterNames(),
                joinPoint.getArgs(),
                distributedLock.keyName()
        ).toString();
        return REDISSON_KEY_PREFIX + key;
    }

    private boolean acquireLock(RLock lock, DistributedLock distributedLock) throws InterruptedException {
        return lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
    }

    private void releaseLock(RLock lock) {
        try {
            lock.unlock();
        } catch (IllegalMonitorStateException e) {
            log.error("Error While Releasing Lock", e);
        }
    }
}