Project/MSA 프로젝트

[트러블슈팅] '선착순 쿠폰 발급' 로직 - Redis를 통한 동시성 문제 해결 (2/2)

쉬지마 이굥진 2024. 6. 30. 22:02

<이전 글>

 

[트러블슈팅] '선착순 쿠폰 발급' 로직 - 동시성 문제 발생 (w/ Race Condition) (1/2)

일반 쿠폰 발급 로직 설계를 마치고, 선착순 쿠폰 발급 로직 개발에 들어왔다가 동시성 문제에 맞닥뜨렸다. 드디어 여태 들어만 봤던 동시성 문제를 해결할 기회가 왔다. 맞다이로 드루와 개요

developer-jinnie.tistory.com


이전 글에서는 로직 설계를 하면서 맞닥뜨린 동시성 문제와, 이 문제에 대한 분석 및 해결 방안 등을 찾아보고 비교해봤다. 이번 글에서는 지난 글을 통해 도출해낸 해결 방법을 내 로직에 적용해보며 동시성 문제를 해결해보는 과정을 담아보려 한다.

 

문제 해결 과정

먼저 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과 동시성 문제에서 발생할 수 있는 이슈들을 깊게 학습해보고 적용해본 것 같다.

동시성 이슈라는 것이 코드를 짜면서 발생하는 에러도 아니고, 당장은 잘 돌아가지만 미리 예측하지 않는 이상 언제 어디서 문제가 발생할 지 모르는 이슈라서 더 어렵게 느껴졌었다. 그래도 이제 대충 '어떤 상황에서 발생하겠구나 ~' 라는 느낌은 알게되서 개발을 하면서 마음에 새겨두고 개발해야겠다. 우리 플젝에서 동시성 이슈가 더 발생될 만한 기능은 없는지 다시 한번 체크해봐야겠다!