본문 바로가기
Projects/D'ONE - 개인프로젝트

트러블슈팅 : 목표에 오늘의 DONE 기록시 중복요청 분산락으로 방지하기 (2/2)

by 우인입니다 2024. 8. 5.

https://thiswooin.tistory.com/147

 

트러블슈팅 : 목표에 오늘의 DONE기록시 동시성 문제로 인한 중복 생성 (1/2)

문제상황동시에 done 테이블에 접근하여 오늘 생성된 데이터가 있는지 조회각 요청 모두 조회된 데이터가 없다고 판단하루에 하나만 존재해야하는 데이터가 두개가 동시에 생성됨한마디로, 우

thiswooin.tistory.com

지난 시간 연관관계가 포함된 객체를 비관적락으로 중복조회를 한 뒤 하나씩 쓰기 시도를 하려다가,

FK를 포함한 객체에 비관적 락을 걸었을 때 인덱스 페이지 전체에 레코드락이 걸려 데드락이 걸리는 현상이 발생하였다.

 

처리중인 요청을 AOP를 통해 set에 담아 체크하는 방식의 시도, 그리고 Redis를 활용하여 분산락 구현을 통한 중복요청 방지를 구현한 과정을 정리해본다.

 


 

시도한 방법 1 : AOP로 요청이 들어온 URI를 SET에 담아두고 중복요청인지 거르는 방식

목적 : 응답이 나가기 전에 같은 요청이 들어오면 예외를 발생시키도록하여 중복요청을 서버단에서 방지하고자 함

 

 

적용 내용

DuplicatedRequestAspect.java

더보기
@Slf4j
@Aspect
@Component
public class DuplicatedRequestAspect {

    private final Set<String> requestSet = Collections.synchronizedSet(new HashSet<>());
    @Pointcut("within(*..*Controller)")
    public void onRequest() {}

    @Around("onRequest()")
    public Object duplicateRequestCheck(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String httpMethod = request.getMethod();

        // GET 메소드인 경우 중복 체크를 하지 않음
        if ("GET".equalsIgnoreCase(httpMethod)) {
            return joinPoint.proceed();
        }

        String reqUri = request.getRequestURI();
        log.debug("reqUri = " + reqUri);
        log.debug("requestSet = " + requestSet);
        if (requestSet.contains(reqUri)) {
            // 중복 요청인 경우
            // 중복 요청에 대한 응답 처리
            throw new DailyoneException(ErrorCode.TOO_MANY_REQUESTS);
        }
        requestSet.add(reqUri);
        try {
            // 핵심 로직 실행
            return joinPoint.proceed();
        } finally {
            // 실행 완료 후 삭제
            requestSet.remove(reqUri);
        }
    }
}

Controller로 끝나는 곳으로 들어오는 모든 요청에 AOP를 적용, GET메소드는 생략하도록 구현했다.

 

결과

 

- 동시에 들어오는 요청에 쓰레드-세이프하지않게 동작하여 Phantom Read가 발생하여 기존의 문제와 동일해졌다.

- 실행순서에 의해 항상 동일한 결과를 보장해 주진 않았다.

- 모든 요청에 해당 과정이 실행됐을때 비효율적이며, 원치않는 결과를 초래할 가능성이 컸다.

- 중요한 것은 uri만으로 동일한 요청임을 확인하기 위해서는 이를 위한 고유키를 발생시키거나 추가적인 보완이 필요했다.

 

-> 기존의 문제를 해결할 수 없음에 철회

 

 


 

시도한 방법 2 : Redis를 통한 분산락 구현 (Redisson 사용)

목적

  • 멀티쓰레드 환경 (+추후 다중 인스턴스환경)에서 동시성제어를 위한 분산락 구현. 추후에도 추가 적용 용이하도록

이유

  • 기존 Redis가 이미 구성되어 있어서 도입이 어렵지 않음.
  • 락에 대한 부하가 RDS에 추가되는 것을 원치 않음.
  • 분산환경을 가정하고 고려했을때, 싱글쓰레드로 동작하는 Redis에서 동시성제어에 적합하다고 판단.
  • Lettuce는 스핀락 방식으로 동작하기에 Redis 부하가 커질 가능성 존재.

 

적용내용

  • Redisson클라이언트를 이용해 pub/sub방식으로 Lock 획득을 시도.
  • 구현 되어있는 RLock을 활용.
  • 어노테이션을 활용한 AOP 방식으로 구현.
  • key 값에 SpEL을 통한 Lock 이름 생성 가능.

위와 같은 방식으로 요청보낸 계정과 각 promiseGoalId를 기반으로 Lock을 구분하도록 지정

 

RedisInsight로 확인한 Lock

 

@DistributedLock

  • RLock을 활용하여 락을 얻고 언락하는 코드를 구성하였다.
더보기
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {

    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.wooin.dailyone.config.annotation.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 = redissonClient.getLock(key);

        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) {
                log.info("already Locked : {}", key);
                return false;
            }
            log.info("get Locked : {}", key);

            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
                log.info("unlocked : {}", key);
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already UnLock {} {}",
                        "serviceName: "+method.getName(),
                        ", key: "+key);
            }
        }
    }
}

 

테스트

한 계정으로 1번에 대한 요청 두 번, 4번에 대한 요청 한번을 동시에 보냈다.

 

1. 처음에는 다른 1, 4번에 대한 락을 각각 얻는다.

 

2. 각각의 트랜잭션이 종료된 후 락을 반납.

 

3. 이후 락 획득 대기중이던 트랜잭션에서 1번에 대한 락을 획득

 

4. 중복요청인 1번에 대한 생성 요청은 두번째 요청이 중복으로 409 CONFLICT 응답이 되었다.

 

 

5. 희망하는 방식인 1번, 4번 각각 하나의 데이터만 생성되었다.

 

 

 

성과

  • 이후 여러 서버가 존재하는 분산환경에서 동시에 같은 자원에 접근하더라도 하나의 Redis서버로 락을 획득하고자 하여, 분산환경에서도 동시성을 제어할 수 있게 되었다.
  • 어노테이션 방식으로 구현하여, 이후 중복요청에 대한 검증이 필요한 경우 손쉽게 적용이 가능하다.
  • 스핀락이 아닌 Pub/Sub 방식을 통하여 반복적으로 redis서버에 요청이 가지 않도록하여 부하를 줄였다.

 

고려할 점

  • 중복요청에 대한 원인 중 클라이언트 측에서 오는 경우 (속칭 '따닥')과 같은 경우는 클라이언트에서의 요청방식을 동기처리하거나 debounce기법과 비슷한 방식으로 근본적으로 중복요청이 오지않게 하는것도 중요하다.
  • 해당방식은 명시적으로 동시성제어가 분명히 필요한 경우에 더욱 유용하게 사용될듯하다.
  • 현재는 락이 있는 경우 획득을 위해 대기하게 되는데, 대기가 필요없이 예외처리로 끝내도 되는 방식도 구현하면 좋을듯하다. (반드시 처리되어야하지 않고 중복 요청은 예외처리만 해도 되는 경우)

 

 

참고링크