레디스를 적용해보자


시나리오

1. Coupon 이 생성 될때, 발행갯수만큼 CouponTicket(userId가 null 상태)을 DB에 저장한다.

2. 쿠폰 발급, CouponTicket의 ID를 Redis에 쌓는다.

3. 쿠폰 배급 Redis에 쌓이 CouponTicket을 가져온다.

  • 가져온 CouponTicket이 이미 userId가 정해져있는지 DB에 확인하고 정해져 있지 않다면 userId를 맵핑하고 저장한다.

  • 쿠폰티켓 결과를 반환해준다.

구현

1. Coupon 이 생성 될때, 발행갯수만큼 CouponTicket(userId가 null 상태)을 DB에 저장한다.

@RestController
public class CouponController {
	
    ...
    
    @PostMapping("api/create")
    public ResponseEntity<Void> create(@RequestBody int limit) {
        couponGenerateService.generate(limit);

        return ResponseEntity.ok().build();
    }
}

---

@Service
public class CouponGenerateService {
    private final CouponRepository couponRepository;
    private final CouponTicketRepository couponTicketRepository;

    public CouponGenerateService(CouponRepository couponRepository, CouponTicketRepository couponTicketRepository) {
        this.couponRepository = couponRepository;
        this.couponTicketRepository = couponTicketRepository;
    }

    @Transactional
    public void generate(int limit) {
        Coupon coupon = Coupon.limitOf(limit);
        couponRepository.save(coupon);

        List<CouponTicket> couponTickets = coupon.allPublish();
        couponTicketRepository.saveAll(couponTickets);
    }
}

2. 쿠폰 발급, CouponTicket의 ID를 Redis에 쌓는다.

여러 방법이 있겠지만, 스케쥴러를 적용해보기로 했다.

일정 갯수 이하일때 Redis에 쌓기.

Redis 설정하기

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return redisTemplate;
    }
}

스케쥴러 적용

  • 1초에 한번씩 체크
  • 2000개 이하일때 10000개씩 밀어 넣게 구현
@Service
public class CouponTicketRedisScheduler {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponTicketRepository couponTicketRepository;

    public CouponTicketRedisScheduler(RedisTemplate<String, Object> redisTemplate, CouponTicketRepository couponTicketRepository) {
        this.redisTemplate = redisTemplate;
        this.couponTicketRepository = couponTicketRepository;
    }

    @Scheduled(fixedDelay = 1000)
    public void run() {
        Long couponId = 2L;

        ListOperations<String, Object> valueOperations = redisTemplate.opsForList();
        Long remainCount = valueOperations.size(generateKey(couponId));

        generateCouponTicket(remainCount, couponId);

        System.out.println("finished");
    }

    private synchronized void generateCouponTicket(Long remainCount, Long couponId) {
        Pageable page = Pageable.ofSize(10000);

        Stream<CouponTicket> couponTickets = couponTicketRepository.findByCouponIdAndUserIdNull(couponId, page).get();

        if (remainCount < 2000) {
            ListOperations<String, Object> valueOperations = redisTemplate.opsForList();
            List<Long> couponTicketIds = couponTickets.map(CouponTicket::getId).toList();

            valueOperations.rightPushAll(generateKey(couponId), couponTicketIds.toArray());
        }
    }

    private String generateKey(Long couponId) {
        return "couponTickets:" + couponId;
    }
}

3. 쿠폰 배급 Redis에 쌓이 CouponTicket을 가져온다.

- 컨트롤러

    @GetMapping("api/publish/{userId}/{couponId}")
    public ResponseEntity<TicketDto> publish(@PathVariable Long userId, @PathVariable Long couponId) {
        CouponTicket couponTicket = couponTicketRedis.publish(couponId, userId);
        if (couponTicket != null) {
            return ResponseEntity.ok().body(TicketDto.of(couponTicket.getCouponId()));
        }

        return ResponseEntity.ok().body(TicketDto.fail());
    }

- Reids 에서 쿠폰 티켓 가져오기 서비스

@Component
public class CouponTicketRedis {
    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponTicketRepository couponTicketRepository;

    public CouponTicketRedis(RedisTemplate<String, Object> redisTemplate, CouponTicketRepository couponTicketRepository) {
        this.redisTemplate = redisTemplate;
        this.couponTicketRepository = couponTicketRepository;
    }

    public CouponTicket publish(Long couponId, Long userId) {
        ListOperations<String, Object> valueOperations = redisTemplate.opsForList();

        // 티켓 POP
        CouponTicket couponTicket = (CouponTicket) valueOperations.leftPop(generateKey(couponId));
        if (couponTicket == null) {
            throw new NoSuchElementException("쿠폰이 존재하지 않습니다.");
        }

        // 유저 세팅
        couponTicket.owner(userId);

        // 유저 세팅 후 저장
        couponTicketRepository.save(couponTicket);

        return couponTicket;
    }

    private String generateKey(Long couponId) {
        return "couponTickets:" + couponId;
    }

}

부하 테스트

k6 부하 테스트

- script.js

import http from 'k6/http';
import {check, sleep} from 'k6';

export const options = {
    stages: [
        {duration: "10s", target: 500},
        {duration: "10s", target: 3000},
        {duration: "10s", target: 3000},
        {duration: "10s", target: 0},
    ],
    thresholds: {
        http_req_duration: ['p(99)<100'],
    },
};

export default function () {
    const res = http.get(`http://localhost:8080/api/publish/${getRandomInt(0, 1000000)}/2`);

    check(res, {'status was 200': (r) => r.status === 200});
    sleep(1);
}

function getRandomInt(min, max) {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min)) + min; //최댓값은 제외, 최솟값은 포함
}

테스트 결과

평균 100ms 이하로 나왔다.

지연되는 부분은

        // 유저 세팅 후 저장
        couponTicketRepository.save(couponTicket);

DB POOL SIZE 조정해보면 어떨까?

문득 DB 커넥션 POOL을 늘리면 빨라지지 좀 더 최적화 할 수 있지 않을까 생각이 되었다.

설정을 추가했다.

spring.datasource.hikari.maximum-pool-size: 100

그리고 pool size 를 나누어 테스트 해보았다.

# DB pool size 25
avg=403.13ms min=2.63ms med=521.26ms max=999.98ms p(90)=712.01ms p(95)=755.75ms
avg=453.87ms min=2.79ms med=584.58ms max=1.17s   p(90)=808.83ms p(95)=942.38ms
avg=430.86ms min=2.79ms med=564.97ms max=1.24s    p(90)=861.28ms p(95)=947.41ms

# DB pool size 50
avg=327.05ms min=2.65ms med=354.36ms max=1.62s   p(90)=634.81ms p(95)=1.07s
avg=167.88ms min=2.33ms med=192.51ms max=580.31ms p(90)=329.93ms p(95)=395.59ms
avg=229.95ms min=2.6ms  med=312.7ms  max=686.1ms  p(90)=431.14ms p(95)=460.81ms

# DB pool size 75
avg=295.76ms min=3.05ms med=327.56ms max=927.11ms p(90)=614.81ms p(95)=676.6ms

# DB pool size 100
avg=178.74ms min=2.55ms med=208.44ms max=560.36ms p(90)=342.46ms p(95)=376.09ms
avg=163.61ms min=2.71ms med=133.92ms max=626.94ms p(90)=374.09ms p(95)=451.05ms
avg=205.32ms min=1.11ms med=215.16ms max=708.67ms p(90)=426.21ms p(95)=503.23ms

나름 유의미한 결과가 나왔다.
현재 로컬 구조는 docker Mysql, docker Redis, Local Spring boot Server 이다.
PC는 맥북 에어 m2이다.

현재 사양에서는 DB pool size 100 일때 가장 최적화된것으로 확인되었다.
pool size 를 더 늘려보았지만, DB 사양 한계치가 있어서인지 그이상은 오히려 느려졌다.

서버 스레드 늘리면?

server.tomcat.threads.max=20

DB pool size 50, tomcat threads 20
avg=464.32ms min=2.6ms  med=623.78ms max=882.89ms p(90)=821.75ms p(95)=838.93ms
avg=496.68ms min=2.68ms med=649.88ms max=987.8ms  p(90)=866.58ms p(95)=934.47ms
avg=539.1ms  min=2.77ms med=553.2ms  max=1.35s   p(90)=1.08s p(95)=1.15s

어차피 DB의 한계 때문에 input 을 마냥 많이 받는게 답은 아닌것 같다.

정리

(1) 에서는 로컬 시스템 만으로 쿠폰 도메인 로직과 동시성 이슈에 대해서 알아보았고,
(2) 에서는 웹 서버로 올려서 부하테스트를 해보았다. 이벤트 기반으로 DB save 로직을 분리해보았다.
(3) 다중 서버일때의 환경을 고려해서 Redis 를 적용해보고 테스트 까지 진행해보았다.


여러가지 시나리오를 생각하며 진행해본 작업이었는데 재미있었다.