Redisson 을 이용한 동시성 제어 및 로직 구조 개선

Yoonhwan Kim·2023년 3월 6일
1

실무문제해결

목록 보기
2/2

개요

지난 작성 글에서는 Spring Retry를 통해서 재시도 로직을 구현했었습니다.

회사에서 만들고 있는 서비스의 경우 EKS 환경의 아키텍처를 갖추고 있기 때문에, 여러 대의 서버가 컨테이너 환경에서 동작하고 있습니다.

따라서 스케줄링 작업의 경우 하나의 서버에서만 동작하게 해야 하며, 스케줄링 작업이 실패 했을 경우에 재시도가 필요 했기 때문에, 동시성재시도 에 대한 구현이 필요합니다.

AS-IS

지난 게시글에도 적혀있지만, 현재 아래의 메서드를 통해 DB의 데이터만을 가지고 동시성 제어(?) 를 하고 있습니다.

public Code getCode(Long codeId) throws InterruptedException {
        Thread.sleep(getRandomNumber()); //랜덤 초 대기
        Code statusCode = codeRepository.findCodeById(codeId);
       
       if(statusCode==null || statusCode.getUseYn().equals("Y")) {
            return null;
        }
        
        statusCode.setUseYn("Y");
        codeRepository.save(statusCode);
        return statusCode;
    }
public void 병원_업데이트() throws InterruptedException {
        // Code 정보에서 스케줄 사용중여부를 확인해서 사용중이 아니면 스케줄 실행
        Code statusCode = getCode(204L);
        if (statusCode == null) return;

        try {
            병원_업데이트_메서드();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            statusCode.setUseYn("N");
            codeRepository.save(statusCode);
        }
    }

Code 엔티티의 데이터를 조회하고, 그 데이터의 값을 통해 실행처리를 하고 있습니다. 이 방식이 사실 간단하게 생각해서는 나올 수 있기도 하지만, 실제로 동시성 테스트를 하게 될 경우에는, 기대하는 동작을 하지 않습니다.

synchronized 를 붙여준다면 가능하게 될 것입니다.

하지만, 단일 서버가 아니라 컨테이너 환경의 여러 서버를 운영하는 구조라면?..

Redis의 Redisson 선택 이유

  1. 서비스 아키텍처의 특성을 고려
  2. 메모리 저장소의 유무

우선 저희 서비스는 EKS 환경의 여러 컨테이너를 통해 운영되고 있습니다. 그렇다고 해서 EKS + Redis 환경이 강요되는 것은 아닙니다.

JPA 를 쓰고 있는 저희 서비스의 경우 낙관적 락, 비관적 락, 네임드 락 등등의 다른 선택지를 고려할 수 도 있었지만, 비즈니스 로직에 대한 동시성 처리를 위해서 Redis 를 사용하고 있는 환경이 였기 때문에 Redisson 을 사용하기로 채택했습니다.

동시성 테스트

Redisson 을 사용하기 위해서 의존성을 추가해야 합니다.

implementation 'org.redisson:redisson:3.2.0'

그리고, 이전에 만들었던 재시도 로직 예제를 가지고 테스트를 진행했습니다.

  • 동시성 적용시킬 재시도 관련 서비스 클래스

랜덤으로 나온 값을 가지고 4로 나눈 나머지 값이 0이면 예외를 발생시키고 재시도를 시작하게 됩니다..

@Service
public class RetryService {
    @Scheduled(fixedDelay = 1000*60) //1분마다 실행
    @Retryable(
            value = {RuntimeException.class},
            backoff = @Backoff(delay = 2000)
    )
    public void doRetrySomething() throws InterruptedException {
        System.out.println("로직 시작.");
        System.out.println("쓰레드 : " +Thread.currentThread());

        for (int i =0; i < 5; i++) {
            int random = (int)(Math.random() * 10) + 1;
            System.out.println("random = " + random);

            if(random % 4 == 0) {
                throw new RuntimeException();
            }
        }
        Thread.sleep(3000);
    }

    /*
        예외 발생시 maxAttempt만큼 재시도 후 그래도 복구가 안되었을 경우엔 recover() 메서드가 최종 호출됩니다.
     */
    @Recover
    public void recover() {
        System.out.println("예외발생으로 재시도 종료");
    }
}
  • 동시성 테스트를 위해 적용해둔 예제 클래스
private final RedissonClient redissonClient;

private final RetryService retryService;

@Transactional
public void test() throws IOException {
  RLock lock = redissonClient.getLock(HOSPITAL_LOCK);

  try {
      System.out.println(Thread.currentThread() + " 의 Lock 획득 시도.");
			
      boolean hasLock = lock.tryLock(3, 10, TimeUnit.SECONDS);

      if(!hasLock) {
          System.out.println("락 획득 실패 쓰레드 : "+Thread.currentThread());
          return;
      }

      // 수행될 실제 로직,
      retryService.doRetrySomething();
      System.out.println(Thread.currentThread() + " 의 로직 실행완료");
  } catch (InterruptedException e) {
      System.out.println("예외발생..");
      throw new RuntimeException(e);
  } finally {
      if(lock.isLocked() && lock.isHeldByCurrentThread()) {
          System.out.println(Thread.currentThread() + ", Lock 반납..");
          lock.unlock();
      }
  }
}

테스트 결과

Thread[pool-2-thread-2,5,main]Lock 획득 시도.
Thread[pool-2-thread-4,5,main]Lock 획득 시도.
Thread[pool-2-thread-3,5,main]Lock 획득 시도.
Thread[pool-2-thread-1,5,main]Lock 획득 시도.
로직 시작.
쓰레드 : Thread[pool-2-thread-2,5,main]
random = 6
random = 7
random = 10
random = 1
random = 9
Thread[pool-2-thread-2,5,main] 의 로직 실행완료
DEBUG: [2023-02-14 14:22:24] debug_log - com.kb.medino.hospital.service.HospitalScheduleFacade.test()[51] finally...
Thread[pool-2-thread-2,5,main], Lock 반납..
로직 시작.
쓰레드 : Thread[pool-2-thread-3,5,main]
random = 10
random = 1
random = 7
random = 4
내린다
로직 시작.
쓰레드 : Thread[pool-2-thread-3,5,main]
random = 5
random = 10
random = 2
random = 4
락 획득 실패 쓰레드 : Thread[pool-2-thread-4,5,main]
DEBUG: [2023-02-14 14:22:27] debug_log - com.kb.medino.hospital.service.HospitalScheduleFacade.test()[51] finally...
락 획득 실패 쓰레드 : Thread[pool-2-thread-1,5,main]
DEBUG: [2023-02-14 14:22:27] debug_log - com.kb.medino.hospital.service.HospitalScheduleFacade.test()[51] finally...
내린다
내린다
로직 시작.
쓰레드 : Thread[pool-2-thread-3,5,main]
random = 3
random = 5
random = 5
random = 2
random = 6
Thread[pool-2-thread-3,5,main] 의 로직 실행완료
DEBUG: [2023-02-14 14:22:28] debug_log - com.kb.medino.hospital.service.HospitalScheduleFacade.test()[51] finally...
Thread[pool-2-thread-3,5,main], Lock 반납..
내린다

원래 원했던 결과는 하나의 쓰레드만이 해당로직을 수행하게 하는 것입니다. 예제의 경우 실제 로직과 다르게 짧은 로직이기 때문에, 쓰레드2의 작업이 빠르게 끝남과 동시에 쓰레드3Lock을 획득하고 다음순서로 로직을 수행했고, 쓰레드1쓰레드4 의 경우 Lock 획득 실패로 로직을 실행하지 못했음을 확인했습니다.


TO-BE

퍼사드 패턴으로

동시성 을 위한 코드 구조를 만들었지만, 동시성 제어를 위한 코드들을 서비스 로직에 넣기에는 가독성면에서 좋지 않다고 생각했습니다. 따라서 해당 서비스를 조금 더 깔끔하게 구조를 만들어두기 위해서, 퍼사드 패턴 을 사용해서 코드를 조금 개선 했습니다.

public class 병원퍼사드클래스 {
  private final RedissonClient redissonClient;

  private final 병원서비스클래스 병원서비스;

  @Scheduled(cron = "0 02 00 * * ?")
  @Transactional
  public void 병원_업데이트_스케줄러() {

      RLock lock = redissonClient.getLock(HOSPITAL_LOCK);

      try {
          /**
           * waitTime = 락을 사용할 수 있을떄까지 기다리는 시간
           * leaseTime = 락 획득시 점유하고 있는시간.
           */
          boolean hasLock = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);

          JLog.logd(Thread.currentThread() + " 쓰레드가 락 획득을 시도합니다. hasLock : " + hasLock);

          if(!hasLock) {
              return;
          }

          // 수행될 실제 로직,
          병원서비스.병원_업데이트_로직();

      } catch (InterruptedException | IOException e) {

          JLog.loge("예외발생 : " + e.getMessage());
          throw new RuntimeException(e);
      } finally {
          // 해당 락을 획득한 쓰레드에 한해서만 락 해제를 요청해줘야 한다.
          // 타 쓰레드가 락 해제 요청을 할 경우 예외 발생
          if(lock.isLocked() && lock.isHeldByCurrentThread()) {
              lock.unlock();
          }
      }
  }
}

반복적인 코드 개선

위 코드에서 퍼사드 패턴 을 통해서 클래스 구조를 개선했지만, 저희가 사용해야 할 동시성 구조는 다른 스케줄러 메서드에도 쓰여야 하기 때문에 여러번 작성해야 합니다.

이 때, 동시성 구조는 변하지 않고 스케줄러 메서드만 변합니다.

즉, 비즈니스 로직관심사 를 분리하는 구조와 비슷하고 추가적인 개선을 할 수 있다고 생각했습니다.

제가 생각한 선택지는 다음과 같습니다.

  1. Template Method Pattern
  2. AOP

템플릿 메서드 패턴 은 기본적으로 상속 을 통해서 구조가 만들어지고 하위 클래스에서 행동에 대한 알고리즘을 위임하는 특징이 있습니다.

제가 생각하는 상속 은 계층 구조를 이루기 위한 목적으로 사용될 때 가장 이상적인 코드 구조라고 생각했기 때문에, 조금 더 유연하게 만들어 갈 수 있는 AOP 를 선택했습니다.

AOP 적용

1) 동시성 적용을 위한 메타 애노테이션을 선언했습니다.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DistributeLock {
    long waitTime();
    long leaseTime();
    TimeUnit unit() default TimeUnit.SECONDS;

    String lockName();
}

2) 메타 애노테이션의 값들을 통해 Lock 에 대한 설정을 AOP로 풀어냅니다.

@Aspect
@Component
public class RedissonAop {

    private final RedissonClient redissonClient;

    public RedissonAop(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Around("@annotation(com.kb.medino.aop.DistributeLock)")
    public Object concurrencyLock(final ProceedingJoinPoint pjp) throws Throwable {

        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();

        DistributeLock annotation = method.getAnnotation(DistributeLock.class);

        final long waitTime = annotation.waitTime();
        final long leaseTime = annotation.leaseTime();
        final TimeUnit unit = annotation.unit();
        final String lockName = annotation.lockName();

        RLock lock = redissonClient.getLock(lockName);

        try {
            /**
             * waitTime = 락을 사용할 수 있을떄까지 기다리는 시간
             * leaseTime = 락 획득시 점유하고 있는시간.
             */
            boolean hasLock = lock.tryLock(waitTime, leaseTime, unit);

            JLog.logd(Thread.currentThread() + " 쓰레드가 락 획득을 시도합니다. hasLock : " + hasLock);

            if(!hasLock) {
                return false;
            }

            // 수행될 실제 로직,
            return pjp.proceed();

        } catch (InterruptedException | IOException e) {
            JLog.loge("DistributeLock error : " + e.getMessage());
            throw new RuntimeException(e);
        } finally {
            if(lock.isLocked() && lock.isHeldByCurrentThread()) {
                JLog.logd(String.format("key : %s , unlock : %s", lockName, signature));
                lock.unlock();
            }
        }
    }
}

3) 최종적으로 적용한 코드

	@Scheduled(cron = "0 30 01 * * ?") // 오전 01시 30분에 실행
    @DistributeLock(
    	waitTime = WAIT_TIME, 
        leaseTime = LEASE_TIME, 
        lockName = "삭제"
    )
    public void 병원_삭제_스케줄러() throws IOException {
        재시도서비스.병원_삭제_메서드();
    }

    @Scheduled(cron = "0 00 02 * * ?") // 오전 2시 00분에 실행
    @DistributeLock(
    	waitTime = WAIT_TIME, 
        leaseTime = LEASE_TIME, 
        lockName = "생성"
    )
    public void 병원_생성_스케줄러() throws IOException {
        재시도서비스.병원_생성_메서드();
    }

    @Scheduled(cron = "0 20 02 * * ?") // 오전 02시 20분에 실행
    @DistributeLock(
    	waitTime = WAIT_TIME, 
        leaseTime = LEASE_TIME, 
        lockName = "업데이트"
    )
    public void 병원_업데이트_스케줄러() throws IOException {
        재시도서비스.병원_업데이트_메서드();
    }

    @Scheduled(cron = "0 50 02 * * ?") // 오전 02시 50분에 실행
    @DistributeLock(
    	waitTime = WAIT_TIME, 
        leaseTime = LEASE_TIME, 
        lockName = "동기화"
    )
    public void DB_동기화_스케줄러()  {
        재시도서비스.DB_동기화_메서드();
    }

정리

동시성 관련해서 대중적으로 Redis를 통해 처리할 수 있는 Redisson 을 사용했습니다.

현재 서비스의 경우 Redis를 사용하고 있었지만, Redis 를 사용하지 않는 상황이였더라면, 또 다른 동시성 처리 관련 방법을 생각해야 했을 것입니다.

사실 Redis 를 쓰고 있기 때문에 Redisson 을 택했다는 것이, 다른 방법과 비교를 하지 않고, 쓰고있다는 이유 하나를 바라보고 선택한 느낌도 있어서 깔끔한 정도의 느낌은 아니지만,

추후에 다른 서비스에서 Redis 를 사용하지 않고 동시성 을 처리해야 할 경우를 대비해서 다른 종류의 동시성 처리 방식들을 deep dive 해보고 싶네요.

0개의 댓글