[Springboot/Performance] 분산 락을 이용한 동시성 제어

푸른별·2024년 8월 19일
0

Web

목록 보기
17/17
post-thumbnail

I. 개요

  • 프로젝트 진행 과정 중 Jmeter로 사용자들이 동시에 접속하는 경우의 조회수를 점검하는데, 실제 DB에는 많이 누락되는 현상이 발생했음을 확인


10000건을 기준으로 테스트를 진행했고 Error Ratio가 0이었기에, 처음에는 문제 없이 들어가는 줄 알았습니다. 그런데 DB를 체크해보니 실제 저장된 값이 거의 1/10정도밖에 없다면.. 뭔가 단단히 잘못되었다는 건 자명한 상황입니다.

따라서 JMeter의 Result Tree에 Request를 열어보았고, ViewCount가 overwritten이 되고 있는 것을 확인할 수 있었습니다.

이를 통해 동시성 문제가 발생하였고, 그렇기에 동시에 접근할 때 어떻게 처리해줘야 할지를 고민해야 했습니다.

II. Lock 기법 선택

이번 프로젝트에서는 Lock을 통해 진행하기로 했고, 조사 결과 대표적으로 3가지의 Lock 기법이 있었습니다.

  • 비관 락(Pessimistic Lock)

    충돌이 일어날 것이라는 상황을 가정한 Locking 기법입니다.
    데이터 읽기 과정에서 다른 트랜잭션의 접근을 막습니다.
    @Lock 어노테이션으로 구현 가능

  • 낙관 락(Optimistic Lock)

    충돌이 일어날 가능성을 고려하지 않는 Locking 기법
    데이터 수정 시 충돌을 감지하고 합니다.
    충돌의 가능성을 염두한다면 개발측에서 충돌 감지 및 재시도 로직을 작성해줘야 함
    @Version 어노테이션을 이용하여 구현 가능

  • 분산 락

    다양한 서버 및 인스턴스 간 공유 리소스의 접근을 동기화하는 Locking 기법입니다.
    Redis와 같은 분산 저장소를 사용하여 구현하며, 대표적으로 Lettuce와 Redisson이 있습니다.
    @interface 어노테이션으로 직접 만들어서 사용해야 한다(대기 시간 및 점유 시간 설정)

이 중에서도 분산 락을 사용하여 동시성 처리를 진행하기로 했습니다.
그 이유는 다음과 같이 3가지로 정리할 수 있겠습니다.

  1. 3가지 Lock 기법 중에 유일하게 다중 서버 환경에서 사용할 수 있기 때문에, 확장성을 고려한 선택을 했습니다.
  2. 비관적 락 같은 경우 DB에 부하가 많이 가는데, Redis에서 이를 처리해 줌으로써 DB 부하를 상당히 줄일 수 있습니다.
  3. 분산 락에 대한 지원이 잘 되어있는 Redisson 라이브러리를 사용하기로 했습니다.

III. 코드

-> 본 코드는 설명을 위한 예제 코드로, 실제 프로젝트 적용 시에는 상황에 맞게 변형해야 할 것입니다.

application.yml에서는 Redis를 로컬에서 돌릴 것을 가정하여 다음과 같이 설정해줬습니다.

  • RedissonConfig
    다음으로는 Config 설정입니다. Redisson을 사용하기 위해 RedissonClient에 대한 Bean을 등록해줬습니다.
@Configuration
public class RedissonConfig {

    private static final String REDISSON_HOST_PREFIX = "redis://";
    private static final String REDISSON_HOST_URL = "localhost";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + REDISSON_HOST_URL + ":6379");
        return Redisson.create(config);
    }

}
  • CustomeSpringELParser
    DistributeLock 어노테이션과 함께 쓰기 위한 CustomeSpringELParser 클래스를 작성해줍니다. 해당 클래스를 통해 전달받은 Lock의 정보를 Parsing할 수 있습니다.
public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        SpelExpressionParser 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);
    }

}
  • RedissonLockAspect
    분산 락 구현을 위한 AOP 기능을 제공하기 위한 클래스입니다.
    @Aspect 어노테이션으로 AOP를 활성화하고, 특정 메서드에 분산 락을 적용하는 역할을 담당합니다.
    redissonLock 메서드에 @Around 어노테이션으로 분산 락이 필요한 메서드에 해당 락을 적용할 수 있도록 합니다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(com.example.global.aop.DistributeLock)")
    public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock annotation = method.getAnnotation(DistributeLock.class);
        String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.key());

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean tryLock = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!tryLock) {
                log.info("Failed to get Lock = {}", lockKey);
                return;
            }
            log.info("Logic Processing");
            joinPoint.proceed();
        } catch (InterruptedException e) {
            log.info("Error Occurred");
            throw e;
        } finally {
            log.info("Unlock");
            lock.unlock();
        }
    }

}
  • DistributeLock
    분산 락을 위한 커스텀 어노테이션입니다. RedissonLockAspect 클래스에서 사용되며, Lock 식별을 위한 key, 시간 단위 지정을 위한 timeUnit, 락 획득을 위한 최대 대기 시간인 waitTime, 락 획득 후 최대 점유 시간인 leaseTime을 제공합니다.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributeLock {

    String key(); // Lock의 이름 (고유값)

    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
    long leaseTime() default 3000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)

}

이렇게 만든 DistributeLock 어노테이션을 적용하고자 하는 서비스의 메서드에 적용하면 분산 락이 정상적으로 적용됩니다.

    @DistributeLock(key = "#postId")
    public void redissonLookup(Long postId, int views) {
        Article article = articleRepository.findById(postId).orElseThrow();
        article.increase(views);
        articleRepository.saveAndFlush(views);
    }

IV. 결론

분산 락 적용 후 실제 DB에 저장되는 상황을 확인해보니 다음과 같이 동시성 적용 전보다 훨씬 성능이 개선되는 것을 확인했습니다.

  • Thread 1000(사용자 1천 명 동시 10회 접속)
  • Thread 10000(사용자 1만 명 동시 접속 기준)

V. 생각해볼 점

  • 아직 성능 개선이 확실하다는 느낌이 들지는 않았습니다. 당장 임영웅 콘서트 예매할때만 해도 거의 백만 회의 접속이 들어왔는데, Thread가 많아질 때는 성능 개선을 위해 waitTime, leaseTime을 조정하고 해당 기능에 대한 서버를 추가하는 등의 대안을 고려해야 할 것이라 생각합니다.
  • 또한 계좌 이체가 빈번하게 이뤄지는 추석, 설날 등의 상황에서 계좌 이체의 동시성이 관리되고 있다는 걸 보면서, 실생활에 꽤나 밀접하다는 점을 이해했습니다.
  • 무엇보다도 현재 대기 시간과 점유 시간을 짧게 설정해서 Error 발생 비율이 조금 높다는 것을 확인할 수 있습니다. 따라서 해당 시간을 적절히 조율하여 Throughout과의 최적 Trade-off를 고려한 설정을 진행해주면 이러한 부분을 잘 해결할 수 있을 것이라 전망합니다.
profile
묵묵히 꾸준하게

0개의 댓글