- [redis] 분산락으로 동시성 문제 해결하기2024년 11월 19일 12시 00분 41초에 업로드 된 글입니다.작성자: @kimyu0218
비상, 미션 중복 인증 이슈 발생..!
사이드 프로젝트로 "미션메이트"라는 애플리케이션을 개발하고 있다. 친구와의 경쟁을 통해 목표 달성을 독려하는 귀염뽀짝한 모바일 앱이다 ㅎ
우리 서비스는 하루에 한 번 특정 시간대에만 미션을 인증할 수 있는데, 이 요구사항으로 인해 서버에서 이미 인증을 완료했는지 확인하고 있다.@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())); }
하지만 미션 인증 버튼을 연타하는 등 빠르게 여러 번 요청하는 경우, 미션 인증 레코드가 두 개 이상 생겨버렸다.
- 레코드를 INSERT 하기 전에 오늘 INSERT한 레코드가 있는지 유효성 검사를 진행하고 있지만,
- 빠르게 여러 번 요청을 보내는 경우, 레코드가 두 번 이상 INSERT 된다.
위 문제를 방지하기 위해 트랜잭션 격리 수준을 Serializable하게 만들거나 테이블에 락을 걸 수 있다. 하지만 이는 성능 저하로 이어질 수 있기 때문에 분산락을 이용하여 동시성 문제를 막기로 결정했다. 이미 서비스에서 레디스를 활용하고 있기에 레디스를 이용하여 분산락을 구현할 것이다.
레디스는 메모리에 데이터를 저장하기 때문에 빠른 속도로 데이터를 저장/조회할 수 있다. 게다가 이벤트 큐에 명령어를 적재하고 싱글 스레드로 하나씩 처리하는 이벤트 루프 방식을 따른다. 여러 스레드가 동시에 레디스에 접근해도 차례대로 처리하므로 하나의 자원에 여러 개의 락이 걸리는 일도 발생하지 않는다.
redisson을 이용한 분산락
분산락은 공유 자원에 접근하기 전에 락을 획득하고, 작업을 마친 후에 락을 반환한다. 그러면 작업 시간 동안 다른 스레드가 공유 자원에 접근할 수 없으므로 동시성 문제가 발생하지 않는다.
스프링 부트는 redis 라이브러리로 Lettuce를 사용하고 있는데, 락을 획득하기 위해 CPU를 점유하며 기다리는 스핀 락 방식으로 동작하기 때문에 성능이 좋지 않다. 반면, redisson은 Pub/Sub 방식을 선택하므로 기다리는 동안 CPU를 점유하지 않고, 이로 인해 다른 스레드가 CPU를 이용할 수 있다. 지금부터 redisson을 이용해서 분산락을 걸어보자!
먼저 `build.gradle`에 redisson 의존성을 추가한다.implementation 'org.redisson:redisson-spring-boot-starter:3.38.1'
다음으로, redisson 설정을 위해 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 테스트를 위한 빈과 구성 클래스를 작성했다.@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] 언두 로그 (0) 2025.03.26 [mysql] InnoDB 스토리지 엔진 훑어보기 (0) 2025.03.25 [mysql] 성능 최적화 3편 (데이터 크기) (0) 2024.02.13 [mysql] 성능 최적화 2편 (트랜잭션) (1) 2024.02.06 [mysql] 성능 최적화 1편 (인덱스/커버링 인덱스) (0) 2024.02.05 다음글이 없습니다.이전글이 없습니다.댓글