- [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); } }
참고자료
'backend > 데이터베이스' 카테고리의 다른 글
[mysql] 성능 최적화 3편 (데이터 크기) (0) 2024.02.13 [mysql] 성능 최적화 2편 (트랜잭션) (1) 2024.02.06 [mysql] 성능 최적화 1편 (인덱스/커버링 인덱스) (0) 2024.02.05 [mysql] InnoDB 체인지 버퍼 (0) 2024.02.04 [mysql] InnoDB 버퍼 풀 (0) 2024.02.04 다음글이 없습니다.이전글이 없습니다.댓글