<이전 글>
이전 글에서는 로직 설계를 하면서 맞닥뜨린 동시성 문제와, 이 문제에 대한 분석 및 해결 방안 등을 찾아보고 비교해봤다. 이번 글에서는 지난 글을 통해 도출해낸 해결 방법을 내 로직에 적용해보며 동시성 문제를 해결해보는 과정을 담아보려 한다.
문제 해결 과정
먼저 Redis를 설치해주어야 하지만 필자의 환경에서는 전에 레디스를 통한 캐싱 작업을 해 본 경험이 있어 Redis가 이미 설치된 상황이다. 이 글을 보시는 분들 중 Redis가 설치되지 않은 분들은 설치 먼저 하고 오시는 걸 추천드린다.
[레디스 환경설정]
1. docker-compose 파일에 쿠폰 모듈 레디스 환경 설정
redis_coupon:
hostname: redis_coupon
container_name: redis_coupon
image: redis:6
ports:
- "6380:6379"
networks:
- neulpoom_network
2. application-dev.yml 파일에 레디스 관련 설정 추가
data:
redis:
host: localhost
port: {포트 번호]
3. 레디스 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
4. Config 파일 추가
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Long> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));
return redisTemplate;
}
}
5. redis-cli로 진입
docker ps // redis 컨테이너 아이디 검색
docker exec -it {컨테이너 아이디} redis-cli
* 이 과정에서 redis가 잘 연결됐는지 확인하려면 PING을 입력했을 때 PONG이 오면 잘 연결됐다는 뜻 !!
6. incr로 coupon_count 키 생성
incr coupon_count
[Redis 명령어를 실행할 Repository 생성]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Repository
public class CouponCountRepository {
private final RedisTemplate<String, Long> redisTemplate;
public CouponCountRepository(RedisTemplate<String, Long> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Long increment(Long couponId) {
String key = "coupon_count:" + couponId;
return redisTemplate.opsForValue().increment(key, 1);
}
public Long getCount(Long couponId) {
String key = "coupon_count:" + couponId;
Long count = redisTemplate.opsForValue().get(key);
return count != null ? count : 0L;
}
}
|
cs |
- 레디스 명령어를 실행할 수 있어야 하므로, RedisTemplate을 변수로 추가해주고, 생성자 또한 추가
- 특정 쿠폰 Id에 대한 카운트를 1 증가시키는 increment 메서드
- couponId를 받아 Redis에서 사용할 키를 `coupon_count:{couponId} 형태로 생성
- redisTemplate.opsForValue().increment(key, 1)를 호출하여 해당 키의 값을 1 증가시키고, 증가된 값을 반
- 특정 쿠폰 Id에 대한 현재 카운트를 반환하는 getCount 메서드
- redisTemplate.opsForValue().get(key)를 호출하여 해당 키의 값을 가져옴
- 만약 해당 키에 저장된 값이 없으면 null을 반환할 수 있으므로, count가 null인 경우 0을 반환
[쿠폰 발급 Service 코드 수정]
Repository 클래스를 만들어줬다면 이제 Service 코드를 수정해줘야한다.
기존 MySQL에서 쿠폰의 개수를 가져왔던 로직을, CouponCountRepository의 increment 메소드로 대체해주면 된다.
👉 즉! 쿠폰을 발급하기 전에, 쿠폰 카운트를 1 증가시키고, 리턴되는 값이 100보다 크다면 쿠폰 발급이 더 이상 되지 않도록 변경해준다.
👉 이 방법을 사용하면 모든 스레드에서는 언제나 최신값을 가져갈 수 있기 때문에 쿠폰이 100개보다 더 발급되는 일은 없을 거라고 예상했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
/**
* 선착순 쿠폰 발급
*/
@Transactional
public CouponIssuedResponseDto issueFirstComeCoupon(Long userId, CouponIssuedRequestDto request) {
// 쿠폰 ID로 쿠폰을 찾고, 존재하지 않으면 예외 처리
Coupon coupon = couponRepository.findById(request.couponId())
.orElseThrow(() -> new CustomException(ErrorCode.COUPON_NOT_FOUND));
// 발급 가능한 쿠폰 수량 확인 -> Redis
Long currentCount = couponCountRepository.getCount(request.couponId());
if (currentCount > coupon.getMaxQuantity()) {
throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
}
// 새로운 쿠폰 발급 -> Redis에서 수량 증가
Long newCount = couponCountRepository.increment(request.couponId());
if (newCount > coupon.getMaxQuantity()) {
throw new CustomException(ErrorCode.COUPON_ISSUE_LIMIT_EXCEEDED);
}
CouponIssued issued = CouponIssued.builder()
.coupon(coupon)
.userId(userId)
.issuedAt(LocalDateTime.now())
.build();
CouponIssued saved = couponIssuedRepository.save(issued);
return CouponIssuedResponseDto.fromEntity(saved);
}
|
cs |
결과
전에 만들어뒀던 테스트 케이스도 잘 돌아가고, 데이터베이스에 쿠폰도 100개에 알맞게 발급되며, redis-cli를 통해서도 응답 요청 시 마다 value 값이 잘 들어감을 확인했다 😀
특정 키의 값 조회하기
GET coupon_count:{couponId}
++ 번외) 이 방식에서 일어날 수 있는 문제점
현재 방식에서도 일어날 수 있는 문제점이 있다. 현재 로직은, 쿠폰 발급 요청이 들어오면 레디스를 활용해서 쿠폰의 발급 개수를 가져온 후에, 발급이 가능하다면 RDB에 저장하는 방식이다.
문제가 없어 보일 수 있으나, 발급하는 쿠폰의 갯수가 많아지면 많아질수록 RDB에 부하를 주게 된다. 만약 사용하는 RDB가 쿠폰 전용 DB가 아니라 다양한 곳에서 사용하고 있는 DB라면, 다른 서비스까지 장애가 발생할 수 있다. 이 방식을 사용하는 분들은 이 점을 참고하셔서 사용하시길 바란다.
(필자의 프로젝트는 MSA 프로젝트이고 쿠폰 모듈은 자신만의 DB를 갖고 있기 때문에 필자는 이 부분은 고려하지 않았다.)
마치며
이번 트러블슈팅을 통해서 Race condition과 동시성 문제에서 발생할 수 있는 이슈들을 깊게 학습해보고 적용해본 것 같다.
동시성 이슈라는 것이 코드를 짜면서 발생하는 에러도 아니고, 당장은 잘 돌아가지만 미리 예측하지 않는 이상 언제 어디서 문제가 발생할 지 모르는 이슈라서 더 어렵게 느껴졌었다. 그래도 이제 대충 '어떤 상황에서 발생하겠구나 ~' 라는 느낌은 알게되서 개발을 하면서 마음에 새겨두고 개발해야겠다. 우리 플젝에서 동시성 이슈가 더 발생될 만한 기능은 없는지 다시 한번 체크해봐야겠다!
'Project > MSA 프로젝트' 카테고리의 다른 글
[리팩토링] MSA 구조에서의 인증/인가 처리 (0) | 2024.07.21 |
---|---|
[프로젝트][ES] ElasticSearch와 Kibana 설치 (feat. docker-compose) (0) | 2024.07.11 |
[트러블슈팅] '선착순 쿠폰 발급' 로직 - 동시성 문제 발생 (w/ Race Condition) (1/2) (2) | 2024.06.30 |
[프로젝트] 선착순/일반 쿠폰 발급 로직 분리에 대한 고민 (0) | 2024.06.29 |
[프로젝트][MSA] API Gateway 작성으로 모듈 별 연결하기 (0) | 2024.06.25 |