❐ Description
과거 위웃 프로젝트를 담당하고 있던 때, 초대코드 `입력시 동시성 이슈 + Dead Lock` 를 마주친 적이 있다.
빠른 대응이 우선되었기 때문에, 비관적 잠금을 통해 이슈를 대응했다.
하지만, 위와 같이 잠금 매커니즘으로 대응하는 방법은 추후 `Dead Lock`을 유발할 수 잇는 위험성이 있다.
아니나 다를까 간헐적으로 CannotAcquireLockException이 Slack(Sentry 연동) 알람으로 날아온 기억이 난다.
따라서 추후에 DB 잠금을 걸지 않고 Redis로 동시성을 제어하도록 개선한 경험이 있는데 이것을 기록해봐야겠다.
테스트 도구로 JMeter를 사용했다.
❐ 과거 문제 및 재현
1. 문제 상황
120명이 동시에 A라는 사람의 초대코드를 사용해서 회원가입을 하는 상황이였다.
예상대로라면 초대코드를 입력한 N개의 요청이 정상적으로 Update되어 아래와 같은 결과가 나와야한다.
+------------------------------------+--------------------+-------------+
|id |invitee_member_count|referral_code|
+------------------------------------+--------------------+-------------+
|00000000 |120 |m5w76p5h9971 |
+------------------------------------+--------------------+-------------+
하지만 결과는 (정확하진 않지만) 75명정도만 초대가 되었다고 DB가 update 되었다.
+------------------------------------+--------------------+-------------+
|id |invitee_member_count|referral_code|
+------------------------------------+--------------------+-------------+
|00000000 |75 |m5w76p5h9971 |
+------------------------------------+--------------------+-------------+
2. 재현
우선 최대한 동일한 상황을 만들기 위해 JMeter 셋팅을 해주었다.
- Number of Threads (users) : 동시에 시뮬레잉션할 가상 사용자(스레드)의 수를 정의한다.
- Ramp-up period (seconds) : 스레드가 모두 실행될 때까지 걸리는 시간을 초 단위로 설정
- Loop Count : 각 스레드가 테스트 계획을 몇 번 반복할지 설정한다.
테스트용 메소드는 실무에서 사용하던 메소드보다 트랜잭션 길이가 짧기 때문에,
최초에 Ramp-up period 설정을 0.7로 해줬었는데, 이렇게 설정하면 손실되는 update가
생각보다 너무 많아 기본 값인 1로 유지했다.
원래는 요청하는 사용자의 `memberId`가 모두 달라야 하지만, lost update를 해결하는 방법을 찾는 것이므로
단일 사용자가 동일한 POST 요청을 N번 보내는 것으로 테스트를 진행했다.
3. 재현 결과
120개의 요청을 보냈지만, 91개만 정상 update되고 29개의 요청을 lost되었음을 확인할 수 있다.
이렇게 문제 상황을 재현해봤으니, 이제 이를 Redis를 사용해서 해결해보자!!!
❐ Redis Keyspace notifications
1. 아이디어
기존에는 매 요청마다 DB에 두 번의 wirte 작업이 이루어졌다.
- ReferralHistory : 초대코드 입력 이력
- Referral 테이블 : 초대한 인원수 증가
120번의 요청이 발생한다면 총 240번의 write 작업이 이루어지는 것이다.
만약에 초대 인원수 증가를 딱 한 번만 수행한다면?
아이디어 Flow는 아래와 같다.
- ReferralHistory는 요청시 바로바로 DB에 Insert 해준다.
- 입력한 초대코드를 키 값으로 하는 데이터를 생성해준다. 이때 TTL은 20으로 설정
- 동일한 초대코드를 처리하는 요청이 들어올 때마다 TTL을 20으로 재설정
- TTL이 만료되면 Redis는 이벤트를 publish한다.
- Expire Key Event를 SpringBoot가 받는다.(구독)
- ReferralHistory의 수를 조회해서 Referral 테이블을 Update한다.
위 흐름대로라면, Redis의 싱글스레드한 특성 덕분에 동시성을 제얼할 수 있으면 120번의 요청이 발생할 때
RDB에 총 121번의 write 작업이 수행된다.
- 120개 번의 ReferralHistory 테이블 Insert 작업
- 단 1번의 Referral 테이블 Update 작업
2. 구현하기
Commit Link 바로가기
1️⃣ redis.conf 설정 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯
위 아이디어를 구현하기 위해선 `notify-keyspace-events` 설정을 활성화 해줘야한다.
notify-keyspace-events Kx
Redis Docs : redis keyspace notifications
설정값 관련 정리된 블로그 : 바로가기
2️⃣ RedisConf.java ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯
먼저 어떤 키에 대한 Expire를 구독할지, 키 패턴을 지정해줘야 한다.
private final static String PATTERN = "__keyspace@*__:referral*";
그리고 `RedisMessageListnerContainer` 빈을 등록해줘야 한다.
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory redisConnectionFactory,
RedisKeyExpiredListener expiredListener
) {
RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer();
listenerContainer.setConnectionFactory(redisConnectionFactory);
listenerContainer.addMessageListener(expiredListener, new PatternTopic(PATTERN));
listenerContainer.setErrorHandler(CustomErrorHandler.newOne());
return listenerContainer;
}
`RedisMessageListenerContainer`는 Redis의 Pub/Sub 메시징 패턴을 Spring 애플리케이션에서
사용할 수 있도록 돕는 핵심 컴포넌트이다.
이를 통해 채널 구독, 메시지 수신 및 처리와 같은 작업을 효율적으로 관리할 수 있다.
3. 테스트를 통한 검증
테스트 케이스는 아래와 같다.
- 최초에 120개의 요청을 보낸다.
- TTL이 만료되기 전 추가로 120개의 요청을 보낸다.
테스트 검증은 아래와 같다.
- 총 240개의 ReferralHistory 데이터가 저장되어야 한다.
- 초대한 인원이 240이 되어야 한다.
- redis의 key가 만료되어야 한다.
❐ Redis의 Distributed(분산) Lock 사용
1. Redisson이란?
Posting : Redisson 이란?
2. 구현하기
2-1. Config Redisson
우선 dependecncy를 설정해준다.
implementation("org.redisson:redisson-spring-boot-starter:3.42.0")
Redis와 마찬가지로 SpringBoot에 Redisson 관련 설정을 추가해준다.
@Configuration
class RedissonConfig {
@Bean
fun redissonClient() : RedissonClient {
var resource = new ClassPathResource("application-redisson.yml");
var config = Config.fromYAML(resource.getInputStream());
return Redisson.create(config);
}
}
그리고 아래와 같이 락을 획득 여부를 확인하는 로직을 메인 로직 앞에서 호출해주면 된다.
private boolean tryGetLock() {
// 락 조회
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked;
try {
isLocked = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
return !isLocked ? null : true;
} catch (InterruptedException e) {
log.error("Lock 획득 중 에러 발생: {}", lockKey, e);
throw e;
}
}
하지만 위와 같이 앞에 로직을 넣어주기 보단, Spring AOP를 사용해서 생산성과 가독성을 챙겨보자!
2-2. Redisson with AOP
git hub source code : link
3. 테스트를 통한 검증
Redis key expire 이벤트를 사용한 동시성 처리 때와 test case는 동일하다.
- 가정 : 최초 120개의 요청을 처리하는 도중 120개의 추가 요청이 들어온다.
- 요청 쓰레드 수 : 240
테스트 검증은 아래와 같다.
- 총 240개의 ReferralHistory 데이터가 저장되어야 한다.
- 초대한 인원이 240이 되어야 한다.
'Back-End > Redis' 카테고리의 다른 글
레디스 데이터 백업 방식 (0) | 2025.01.08 |
---|---|
Redis를 캐시로 사용하기 (0) | 2024.12.20 |
Redis 자료구조 활용 사례 (0) | 2024.12.18 |
Redis 기본 개념 (0) | 2024.12.18 |
Redis 설정하기 (0) | 2024.12.17 |