10000건을 기준으로 테스트를 진행했고 Error Ratio가 0이었기에, 처음에는 문제 없이 들어가는 줄 알았습니다. 그런데 DB를 체크해보니 실제 저장된 값이 거의 1/10정도밖에 없다면.. 뭔가 단단히 잘못되었다는 건 자명한 상황입니다.
따라서 JMeter의 Result Tree에 Request를 열어보았고, ViewCount가 overwritten이 되고 있는 것을 확인할 수 있었습니다.
이를 통해 동시성 문제가 발생하였고, 그렇기에 동시에 접근할 때 어떻게 처리해줘야 할지를 고민해야 했습니다.
이번 프로젝트에서는 Lock을 통해 진행하기로 했고, 조사 결과 대표적으로 3가지의 Lock 기법이 있었습니다.
비관 락(Pessimistic Lock)
충돌이 일어날 것이라는 상황을 가정한 Locking 기법입니다.
데이터 읽기 과정에서 다른 트랜잭션의 접근을 막습니다.
@Lock 어노테이션으로 구현 가능
낙관 락(Optimistic Lock)
충돌이 일어날 가능성을 고려하지 않는 Locking 기법
데이터 수정 시 충돌을 감지하고 합니다.
충돌의 가능성을 염두한다면 개발측에서 충돌 감지 및 재시도 로직을 작성해줘야 함
@Version 어노테이션을 이용하여 구현 가능
분산 락
다양한 서버 및 인스턴스 간 공유 리소스의 접근을 동기화하는 Locking 기법입니다.
Redis와 같은 분산 저장소를 사용하여 구현하며, 대표적으로 Lettuce와 Redisson이 있습니다.
@interface 어노테이션으로 직접 만들어서 사용해야 한다(대기 시간 및 점유 시간 설정)
이 중에서도 분산 락을 사용하여 동시성 처리를 진행하기로 했습니다.
그 이유는 다음과 같이 3가지로 정리할 수 있겠습니다.
- 3가지 Lock 기법 중에 유일하게 다중 서버 환경에서 사용할 수 있기 때문에, 확장성을 고려한 선택을 했습니다.
- 비관적 락 같은 경우 DB에 부하가 많이 가는데, Redis에서 이를 처리해 줌으로써 DB 부하를 상당히 줄일 수 있습니다.
- 분산 락에 대한 지원이 잘 되어있는 Redisson 라이브러리를 사용하기로 했습니다.
-> 본 코드는 설명을 위한 예제 코드로, 실제 프로젝트 적용 시에는 상황에 맞게 변형해야 할 것입니다.
application.yml에서는 Redis를 로컬에서 돌릴 것을 가정하여 다음과 같이 설정해줬습니다.
@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);
}
}
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);
}
}
@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();
}
}
}
@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);
}
분산 락 적용 후 실제 DB에 저장되는 상황을 확인해보니 다음과 같이 동시성 적용 전보다 훨씬 성능이 개선되는 것을 확인했습니다.