kimyu0218
  • [redis] redisson으로 분산락 걸어서 동시성 문제 해결하기
    2024년 11월 19일 12시 00분 41초에 업로드 된 글입니다.
    작성자: @kimyu0218

    Repeatable Read 격리 수준으로 인한 동시성 문제 발생

    내가 진행중인 프로젝트에서 동시성 문제가 발생했다. 요구사항에 의해 하루에 한 번만 미션을 수행할 수 있다. 이를 위해 미션 인증 레코드를 생성하기 전에 오늘 인증 내역이 있는지 확인하고 INSERT 쿼리를 수행한다.

    @Transactional
    public void createVerification(final CreateMissionVerificationCommand command) {
        MissionMember missionMember = missionMemberRepository.getMissionMember(command.memberId(), command.missionId());
        // 유효성 검사 진행 (이미 레코드가 있는 경우 예외 던짐)
        missionVerificationValidator.validate(missionMember);
        String imageUrl = objectStorageClient.uploadFile(command.imageFile());
        // 유효성 검사 통과 후 레코드 삽입
        missionMember.verify();
        missionVerificationRepository.save(new MissionVerification(missionMember.getMember(), missionMember.getMission(), imageUrl, missionMember.getVerificationCount()));
    }

    하지만 미션 인증 요청을 빠르게 여러 번 날리는 경우, 미션 인증 레코드가 두 개 이상 생기는 문제에 부딪혔다. 이는 RDBMS의 격리 수준이 Repeatable Read이기 때문이다. Repeatable Read는 INSERT 작업을 예방할 수 없다.

    • 레코드를 INSERT 하기 전에 오늘 INSERT한 레코드가 있는지 유효성 검사를 진행하고 있지만,
    • 빠르게 여러 번 요청을 보내는 경우, 레코드가 두 번 이상 INSERT 된다.

    이를 방지하기 위해 트랜잭션 격리 수준을 Serializable하게 만들거나 테이블/레코드에 락을 걸 수 있다. 하지만 이는 성능 저하로 이어질 수 있기 때문에 분산락을 이용하여 동시성 문제를 막을 것이다.
     
    레디스의 키워드는 메모리싱글 스레드다. 메모리 기반 데이터 저장소로, 메모리를 사용하기 때문에 빠른 속도로 데이터를 저장/조회할 수 있다.
    레디스는 이벤트 루프 방식으로 명령어를 처리한다. 이벤트 큐에 명령어를 적재하고 싱글 스레드로 하나씩 처리한다. 여러 스레드가 동시에 공유 자원에 접근하면 데드락이 발생하는데, 레디스는 싱글 스레드로 구성되어 있어 데드락이 발생하지 않는다. 이 특성을 이용하여 분산락으로 활용한다. (공유 자원의 점유 여부 확인!)
     

    redisson 분산락 걸기

    여러 인스턴스가 동시에 공유 자원에 접근하면 데이터 무결성이 깨질 수 있다. 분산락은 원자성을 제공하는 저장소를 사용하여 이러한 동시성 문제를 해결한다.
    분산락은 공유 자원에 작업하기 전에 락을 생성하고, 작업을 마친 후에 락을 제거한다. 그러면 작업 시간 동안 다른 인스턴스가 공유 자원에 접근할 수 없다.
     
    스프링 부트는 Lettuce를 redis 라이브러리로 사용하고 있다. 하지만 스핀 락 방식으로 동작하기 때문에 성능이 좋지 못하다. 반면, redisson은 자바 redis 클라이언트로, Pub/Sub 방식을 이용하여 더 이상 락을 획득하기 위해 CPU를 점유하며 기다릴 필요가 없다. 지금부터 redisson을 이용해서 분산락을 걸어보자.
     
    먼저 `build.gradle`에 redisson 의존성을 추가한다.

    implementation 'org.redisson:redisson-spring-boot-starter:3.38.1'

     
    redisson을 구성하기 위해서는 API를 이용하거나 YAML 파일을 사용해야 한다. 나는 레퍼런스를 참고하여 YAML을 사용하는 방법을 선택했다.

    # application.yml
    spring:
      redisson:
        ...
        file: classpath:redisson-config.yml
    # [CLASSPATH]/redisson-config.yml
    singleServerConfig:
      address: "redis://${REDIS_HOST:-localhost}:6379"
    @Configuration
    public class RedissonConfig {
    
        @Value("${spring.redisson.file}")
        private Resource redissonConfigFile;
    
        @Bean
        public RedissonClient redissonClient() throws IOException {
            Config config = Config.fromYAML(redissonConfigFile.getInputStream());
            return Redisson.create(config);
        }
    }

     
    분산락을 다른 부분에서도 활용할 수 있도록 AOP를 활용하기로 결정했다. 애스펙트가 실행되는, AOP의 포인트컷은 `@RedissonLock` 어노테이션이다. 메서드 앞에 해당 어노테이션이 붙어있다면 메서드 실행 전후에 락을 획득하고 반환하는 작업을 수행하게 된다.

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface RedissonLock {
        String value() default "Unknown";
    
        long waitTime() default 5L;
    
        TimeUnit timeUnit() default TimeUnit.SECONDS;
    }
    @RequiredArgsConstructor
    @Slf4j
    @Aspect
    @Component
    public class RedissonLockAspect {
    
        private final RedissonClient redissonClient;
    
        @Around("@annotation(com.nexters.goalpanzi.common.annotation.RedissonLock)")
        public Object lockMissionVerification(final ProceedingJoinPoint joinPoint) throws Throwable {
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            String methodName = methodSignature.getName();
            RedissonLock annotation = methodSignature.getMethod().getAnnotation(RedissonLock.class);
            String lockTarget = annotation.value();
    
            var args = joinPoint.getArgs();
            LockKey lockKey = LockKey.of(lockTarget, args);
            RLock lock = redissonClient.getLock(lockKey.toString());
            
            // joinPoint 수행 전에 락 획득하기
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.timeUnit());
            if (!lockable) {
                // 락 획득하지 못한 경우, 예외 던지기
            }
            log.info("{} acquired {} lock with key: {}.", methodName, lockTarget, lockKey);
            
            // joinPoint 수행
            joinPoint.proceed();
            
            // joinPoint 수행 후 락 반환하기
            lock.unlock();
            log.info("{} released {} lock with key: {}.", methodName, lockTarget, lockKey);
    
            return joinPoint;
        }
    }

     
    이제 문제의 메서드 앞에 `@RedissonLock`을 붙여주면 동시성 문제를 해결할 수 있다!

    @RedissonLock("MissionVerification")
    @Transactional
    public void createVerification(final CreateMissionVerificationCommand command) {
       ...
    }

     
    이제 분산락이 제대로 동작하는지 테스트 해보자. 서비스 말고 분산락 자체가 잘 동작하는지 확인할 것이다. 먼저, 스프링 AOP를 사용하기 위해 테스트 구성 클래스를 작성하고 빈을 등록해야 한다. (`test` 하위 파일들은 컴포넌트 스캔 대상이 아니기 때문!)

    @TestConfiguration
    public class RedissonTestConfig {
    
        @Bean
        public RedissonLockTestBean redissonLockTestBean() {
            return new RedissonLockTestBean();
        }
    }
    public class RedissonLockTestBean {
    
        @RedissonLock(waitTime = 1L)
        void serializeFunction(final Object args) throws InterruptedException {
            Thread.sleep(2000);
        }
    }

     
    스프링 부트 테스트에 앞서 작성한 테스트 구성 클래스를 추가하고, 두 가지 테스트 케이스를 작성했다.

    • 여러 자원이 동시에 공유 자원에 접근하는 경우
    • 여러 자원이 동시에 서로 다른 자원에 접근하는 경우

    아래 테스트 코드를 통해 한 번에 하나의 스레드만 공유 자원에 접근할 수 있음을 확인했다!!

    @SpringBootTest(
            classes = RedissonTestConfig.class
    )
    public class RedissonLockTest {
    
        private static final int THREAD_CNT = 2;
    
        @Autowired
        private RedissonLockTestBean redissonLockTestBean;
    
        private ExecutorService executorService;
        private AtomicInteger acquiredLockCnt;
    
        @BeforeEach
        void setUp() {
            executorService = Executors.newFixedThreadPool(THREAD_CNT);
            acquiredLockCnt = new AtomicInteger(0);
        }
    
        @Test
        void 여러_스레드가_동시에_공유_자원에_접근할_수_없다() throws InterruptedException {
            for (int i = 0; i < THREAD_CNT; i++) {
                executorService.submit(() -> {
                    try {
                        redissonLockTestBean.serializeFunction("Shared Resource");
                        acquiredLockCnt.incrementAndGet();
                    } catch (InterruptedException ignored) {
                    }
                });
            }
            executorService.shutdown();
            executorService.awaitTermination(5, TimeUnit.SECONDS);
    
            assertThat(acquiredLockCnt.get()).isNotEqualTo(THREAD_CNT);
        }
    
        @Test
        void 여러_스레드가_동시에_서로_다른_자원에_접근할_수_있다() throws InterruptedException {
            for (int i = 0; i < THREAD_CNT; i++) {
                String resource = "Resource" + i;
                executorService.submit(() -> {
                    try {
                        redissonLockTestBean.serializeFunction(resource);
                        acquiredLockCnt.incrementAndGet();
                    } catch (InterruptedException ignored) {
                    }
                });
            }
            executorService.shutdown();
            executorService.awaitTermination(5, TimeUnit.SECONDS);
    
            assertThat(acquiredLockCnt.get()).isEqualTo(THREAD_CNT);
        }
    }

    참고자료

    댓글