실전 프로젝트 6주차. 오늘 TIL에는 프로젝트를 하면서 트러블 슈팅을 했던 내용에 대해 최종적으로 정리한 것을 내 블로그에도 적어보려 한다.
캐시 전략 FlowChart
인메모리 저장소, 싱글 스레드, 단순연산의 경우 원자성 보장이라는 특성을 가진 Redis를 도입해서 속도 개선과 동시성제어 두 마리 토끼를 한 번에 잡아보고자 했다.
Key는 “ls+ticketInfoId”, Value는 TicketInfo Entity에 있는 LeftSeats(남은 좌석 수) 컬럼의 데이터로 <String, Integer> 구조의 ‘Redis 기본 String 자료구조’로 Redis에 저장했다.
예매 트랜잭션이 시작되면 먼저 Redis로 남은 좌석 수를 차감해 예매를 진행하고, 키가 없거나 Redis 연결에 문제가 생기면 기존 DB의 비관적 락 로직으로 남은 좌석 수를 차감해 예매를 진행한다.
Redis로 아래의 Lua Script를 보내서 남은 좌석 수가 예매하려는 자리 수(count) 이상일 경우 value를 차감한 뒤 true를 반환하고, 아니면 false를 반환한다. 이 스크립트는 단순 연산으로 원자성이 보장된다.
Write Back 캐시 쓰기 전략으로 캐시에 있는 남은 좌석 수 값이 DB의 TicketInfo 테이블의 LeftSeats 컬럼에 매 분마다 저장된다.
해당 캐시는 예매가 오픈될 때 생성되어 공연일이 되어 예매가 닫히면 expire 된다. 캐시가 사라지면 Reservation 테이블에서 해당 TicketInfo의 예매 count를 다 더해서 정확한 남은 좌석 수를 DB 안의 TicketInfo에 넣는다.
예매 서비스 로직
// 2. 예매하기
@Transactional(isolation = Isolation.READ_COMMITTED)
public Long makeReservation(ReservationRequestDto dto, User user) {
// 먼저 redis 캐시를 조회
Boolean hasLeftSeats = redisRepository.hasLeftSeatsInRedis(dto.getTicketInfoId());
if (hasLeftSeats) {
// 캐시가 있으면 redis에서 남은 좌석 수 차감
decrementLeftSeatInRedis(dto);
} else {
// 캐시가 없으면 DB를 통해 남은 좌석 수 차감
decrementLeftSeatInDB(dto);
}
Reservation reservation = new Reservation(dto, user);
reservationRepository.saveAndFlush(reservation);
return reservation.getId();
}
// 2-1. redis로 좌석 수 변경
private void decrementLeftSeatInRedis(ReservationRequestDto dto) {
Boolean success = redisRepository.decrementLeftSeatInRedis(dto.getTicketInfoId(), dto.getCount());
if (!success) {
throw new CustomException(ExceptionType.OUT_OF_TICKET_EXCEPTION);
}
}
// 2-2. 캐시 없으면 DB로 좌석수 변경
private void decrementLeftSeatInDB(ReservationRequestDto dto) {
TicketInfo ticketInfo = ticketInfoRepository.findByIdWithLock(dto.getTicketInfoId()).orElseThrow(
() -> new CustomException(ExceptionType.NOT_FOUND_TICKET_INFO_EXCEPTION)
);
if (!ticketInfo.isAvailable()) {
throw new CustomException(ExceptionType.RESERVATION_UNAVAILABLE_EXCEPTION);
}
if (ticketInfo.getLeftSeats() - dto.getCount() < 0) {
throw new CustomException(ExceptionType.OUT_OF_TICKET_EXCEPTION);
}
ticketInfo.minusSeats(dto.getCount());
ticketInfoRepository.save(ticketInfo);
}
@Component
@RequiredArgsConstructor
public class RedisRepository {
private final RedisTemplate<String, Integer> redisTemplate;
private final TicketInfoRepository ticketInfoRepository;
private final ReservationRepository reservationRepository;
private static final String DECREMENT_LEFT_SEAT_SCRIPT =
"local leftSeats = tonumber(redis.call('get', KEYS[1])) " +
"if leftSeats - ARGV[1] >= 0 then " +
" redis.call('decrby', KEYS[1], ARGV[1]) " +
" return true " +
"else " +
" return false " +
"end";
// 매분 캐시 변경분을 db에 저장
@Scheduled(cron = "0 * * * * *")
public void saveTicketInfoFromRedis(){
Set<String> keys = redisTemplate.keys("ls*");
if (keys.isEmpty() || keys == null) return;
for (String key : keys) {
Integer leftSeatsInRedis = redisTemplate.opsForValue().get(key);
Long ticketInfoId = Long.parseLong(key.substring(2));
TicketInfo ticketInfo = ticketInfoRepository.findById(ticketInfoId).orElseThrow(
() -> new CustomException(ExceptionType.NOT_FOUND_TICKET_INFO_EXCEPTION));
ticketInfo.setLeftSeats(leftSeatsInRedis);
ticketInfoRepository.save(ticketInfo);
}
}
// 값 변경. count만큼 남은좌석수 차감
public Boolean decrementLeftSeatInRedis(Long ticketInfoId, int count){
String key = "ls" + ticketInfoId;
// Lua Script 실행
return redisTemplate.execute(decrementLeftSeatRedisScript, Collections.singletonList(key), count);
}
@Configuration
@EnableCaching
public class CacheConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
// LeftSeats에 사용하는 redisTemplate
@Bean
public RedisTemplate<String, Integer> redisTemplate() {
RedisTemplate<String, Integer> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Integer.class));
return redisTemplate;
}