들어가며
팝업 스토어 정보 조회 및 예약하는 서비스인 Poppy! 프로젝트를 진행하면서, 예약 처리에 대한 고민이 생겼다. 분산 서버를 통해 예약을 구성하기 때문에 DB나 Redis를 이용해서 Lock을 걸어야 하는 상황이었다. 동시성 처리를 위해 Redis를 사용하게 되었는데, 이 과정을 포스트로 작성하려고 한다.
데이터베이스의 Lock 종류
우선 데이터베이스의 Lock 종류를 알아보자. DB Lock에는 비관적 락과 낙관적 락이 있다.
Pessimistic Lock (비관적 락)
비관적 락은 트랜잭션이 시작될 때 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 미리 Lock하는 방식으로, 해당 Lock의 과정은 다음과 같다.
- 예약을 시도할 때, 해당 시간대 데이터를 조회하면서 동시에 Lock
- 다른 트랜잭션이 해당 데이터를 수정하려고 하면 첫 번째 트랜잭션이 종료될 때까지 대기
- 트랜잭션이 종료되면 Lock이 해제되고 다른 트랜잭션이 처리
주로 SELECT ... FOR UPDATE과 같은 쿼리에 사용되며, SELECT * FROM reservations WHERE time_slot_id = ? FOR UPDATE 형태로 사용된다. 즉, 하나의 트랜잭션이 점유를 하고 있으면 그 트랜잭션이 종료되기 전까지는 다른 사용자가 접근하지 못하게 막는 구조이다.
Optimistic Lock (낙관적 락)
낙관적 락은 버전 번호를 관리하여 데이터 수정 시 버전이 변경되었는지 확인하는 방식으로, 해당 Lock의 과정은 다음과 같다.
- 데이터를 조회하고 트랜잭션을 시작
- 데이터를 수정할 때, 기존에 조회한 버전과 현재 DB에 저장된 버전을 비교
- 버전이 다르면 충돌이 발생한 것으로 간주하고 작업을 취소하거나 재시도
이 방식은 트랜잭션 간의 충돌이 드물다고 가정하고 트랜잭션이 완료될 때 충돌을 검증한다. 예를 들어, 한 사용자가 예약이 완료됐을 때 충돌을 검증해 충돌이 없다면 예약 처리를 완료하게 된다.
Redis를 이용한 분산 Lock
Redis를 사용한 이유는 인메모리 기반인 Redis에 읽고 쓰는 과정이 디스크 기반인 DB보다 빠르기 때문에 선택하게 되었다. 예약 로직 구현시 미리 DB에 저장해 놓은 시간 슬롯을 이용해 Redis에서 처리하고, 이를 DB에 반영하는 과정으로 작성하였다.
Redis 클라이언트 방식
Redis에는 클라이언트 방식이 2가지가 있는데, Spring Boot에서 기본적으로 지원하는 클라이언트 방식은 Lettuce이다. Lettuce를 통해 분산 락을 구성해야 하는 경우, 스핀 Lock으로 구성해야 한다. 이는 Lock을 획득하기 위해 SETNX 명령어로 계속해서 Lock 획득 요청을 보내는 방식이다. 때문에 Redis에 부하를 줄 수밖에 없다. 또한, Timeout 처리를 하기 힘들어지므로 Lock를 반환하지 않거나 무한 루프를 돌 가능성이 있다.
따라서 Redission 클라이언트 방식을 선택하였다. Redission은 Lettuce과 달리 Lock 획득 요청을 보내지 않아도 된다. Pub/Sub 기능을 지원하기 때문이다.
Redission을 이용한 분산 락 구현
1. Redission을 이용하기 위해서 SpringBoot에 의존성을 추가해주어야 한다.
// Redission
implementation group: 'org.redisson', name: 'redisson', version: '3.39.0'
2. RedisConfig를 통해 Redission 설정을 한다.
@Configuration
public class RedisConfig {
@Value("${redis.host}")
private String host;
@Value("${redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
}
// 직렬화 방식 지정
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 키와 값의 직렬화 방식 설정
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
return template;
}
@Bean
public RedisTemplate<String, Integer> redisTemplateInteger(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Integer> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 키 직렬화는 String, 값 직렬화는 Integer 처리
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericToStringSerializer<>(Integer.class));
return template;
}
}
현재 프로젝트에서 사용하는 key-value의 타입은 두 가지라 Bean을 두 개 등록해주었다.
3. 등록한 팝업 스토어의 시간 슬롯을 초기화한다.
아직 관리자 서버를 만들지 않아 initialize를 따로 해두었지만, 서버가 시작되면 RedisInitializer를 통해 현재 등록되어 있는 팝업 스토어의 시간 슬롯을 분배해 Redis에 저장하게 된다.
@Component
@RequiredArgsConstructor
public class RedisInitializer implements ApplicationRunner {
private final RedisTemplate<String, Integer> redisTemplate;
private final ReservationAvailableSlotRepository slotRepository;
@Override
public void run(ApplicationArguments args) {
// 오늘 이후의 모든 예약 가능 슬롯을 조회해서 Redis에 저장
List<ReservationAvailableSlot> slots = slotRepository
.findByDateGreaterThanEqualAndPopupStoreStatusEquals(LocalDate.now(), PopupStoreStatus.AVAILABLE);
for (ReservationAvailableSlot slot: slots) {
String slotKey = String.format("slot:%d:%s:%s",
slot.getPopupStore().getId(),
slot.getDate(),
slot.getTime());
redisTemplate.opsForValue().set(slotKey, slot.getAvailableSlot(), 24, TimeUnit.HOURS);
}
}
}
4. 분산 락을 통한 예약 로직을 구현한다.
예약 로직 구현 시, DB와 Redis를 모두 사용하기 때문에 해당 로직을 구분해서 2개의 메소드로 처리하였다. 따라서 Redis에서 문제가 일어난 경우 롤백을 진행하며, DB의 경우에는 @Transactional 어노테이션을 통해 롤백을 진행하게 된다. Redis에서 슬롯에 대해 조회, 생성, 증가 및 감소를 용이하게 하기 위해 RedisSlotService을 따로 구현하였다.
RedisSlotService
@Service
@RequiredArgsConstructor
public class RedisSlotService {
private final RedisTemplate<String, Integer> redisTemplate;
// Redis에 슬롯 정보 저장하는 공통 메서드
public void setSlotToRedis(Long storeId, LocalDate date, LocalTime time, int availableSlot) {
String slotKey = String.format("slot:%d:%s:%s", storeId, date, time);
redisTemplate.opsForValue().set(slotKey, availableSlot, 24, TimeUnit.HOURS);
}
// Redis에서 슬롯 정보 조회
public Integer getSlotFromRedis(Long storeId, LocalDate date, LocalTime time) {
String slotKey = String.format("slot:%d:%s:%s", storeId, date, time);
return redisTemplate.opsForValue().get(slotKey);
}
// Redis의 슬롯 감소
public void decrementSlot(Long storeId, LocalDate date, LocalTime time) {
String slotKey = String.format("slot:%d:%s:%s", storeId, date, time);
redisTemplate.opsForValue().decrement(slotKey);
}
// Redis의 슬롯 증가
public void incrementSlot(Long storeId, LocalDate date, LocalTime time) {
String slotKey = String.format("slot:%d:%s:%s", storeId, date, time);
redisTemplate.opsForValue().increment(slotKey);
}
}
ReservationService
@Service
@RequiredArgsConstructor
public class ReservationService {
private final RedissonClient redissonClient;
private final UserService userService;
private final PopupStoreRepository popupStoreRepository;
private final ReservationAvailableSlotRepository reservationAvailableSlotRepository;
private final ReservationRepository reservationRepository;
private final RedisSlotService redisSlotService;
private static final String LOCK_PREFIX = "reservation:lock:";
private static final long WAIT_TIME = 3L;
private static final long LEASE_TIME = 3L;
// 어플에서 진행하는 예약 메서드
public Reservation reservation(Long storeId, LocalDate date, LocalTime time) {
String lockKey = LOCK_PREFIX + storeId + ":" + date + ":" + time;
RLock lock = redissonClient.getLock(lockKey);
try {
// Redisson을 이용해 락을 시도
boolean isLocked = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);
if (!isLocked) {
throw new BusinessException(ErrorCode.RESERVATION_CONFLICT); // 다른 사용자가 이미 락을 획득한 상태
}
// Redis 슬롯 확인
Integer redisSlot = redisSlotService.getSlotFromRedis(storeId, date, time);
if (redisSlot == null || redisSlot <= 0) {
throw new BusinessException(ErrorCode.NO_AVAILABLE_SLOT);
}
// Redis 업데이트
redisSlotService.decrementSlot(storeId, date, time);
// DB 작업 처리
Reservation reservation = processReservation(storeId, date, time);
return reservation;
}
catch (BusinessException e) {
if (e.getCode() != ErrorCode.NO_AVAILABLE_SLOT.getCode()) {
redisSlotService.incrementSlot(storeId, date, time);
}
throw e;
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException(ErrorCode.RESERVATION_FAILED);
}
finally {
// 락 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// DB 작업 처리
@Transactional
protected Reservation processReservation(Long storeId, LocalDate date, LocalTime time) {
PopupStore popupStore = popupStoreRepository.findById(storeId)
.orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
User user = userService.getLoggedInUser();
ReservationAvailableSlot slot = reservationAvailableSlotRepository
.findByPopupStoreIdAndDateAndTime(storeId, date, time)
.orElseThrow(() -> new BusinessException(ErrorCode.SLOT_NOT_FOUND));
if (slot.getAvailableSlot() <= 0) {
throw new BusinessException(ErrorCode.NO_AVAILABLE_SLOT); // 예약 가능한 슬롯 없음
}
if (slot.getStatus() != PopupStoreStatus.AVAILABLE) {
throw new BusinessException(ErrorCode.INVALID_RESERVATION_DATE);
}
// 슬롯 업데이트
slot.updateSlot();
if (slot.getAvailableSlot() == 0) {
slot.updatePopupStatus(PopupStoreStatus.FULL);
}
reservationAvailableSlotRepository.save(slot);
// 예약 생성
Reservation reservation = Reservation.builder()
.popupStore(popupStore)
.user(user)
.date(date)
.time(time)
.status(ReservationStatus.CHECKED)
.build();
return reservationRepository.save(reservation);
}
}
예외 설정은 BusinessException 클래스를 통해 따로 설정하였다. 슬롯이 없는 경우를 제외하고 문제가 발생했을 때, Redis의 슬롯을 롤백하는 로직을 catch 문에 작성하였다. Redission을 통해 Lock을 얻고, Redis의 슬롯을 확인하고 먼저 예약 처리를 한 후에 DB에 슬롯을 하나 줄이는 방식이다. 해당 로직이 잘 작동되는지 확인하기 위해 테스트 코드를 작성하였다.
테스트 코드 작성
테스트 코드에서 확인할 내용은 다음 세 가지이다.
- 해당 시간의 슬롯에서 더 많은 사람이 들어올 때 예약 로직 확인
- DB 예외 발생 시 Redis 롤백 확인
- 예약 예외 (NO_AVAILABLE_SLOT) 발생 시 Redis와 DB 확인
@SpringBootTest
class ReservationServiceTest {
private ReservationService reservationService;
// Mock 객체 생략
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
reservationService = new ReservationService(
redissonClient,
userService,
popupStoreRepository,
reservationAvailableSlotRepository,
reservationRepository,
redisSlotService
);
when(redissonClient.getLock(anyString())).thenReturn(rLock);
}
@Test
void 슬롯보다_많이_들어올_때_예약_처리() throws InterruptedException {
// given
Long storeId = 1L;
LocalDate date = LocalDate.of(2024, 11, 22);
LocalTime time = LocalTime.of(19, 0);
AtomicInteger redisSlot = new AtomicInteger(28); // Redis 슬롯을 원자적 변수로 관리
PopupStore popupStore = PopupStore.builder().id(storeId).build();
User user = User.builder().id(1L).build();
ReservationAvailableSlot slot = ReservationAvailableSlot.builder()
.popupStore(popupStore)
.date(date)
.time(time)
.availableSlot(28)
.totalSlot(28)
.status(PopupStoreStatus.AVAILABLE)
.build();
// Mock 설정
when(popupStoreRepository.findById(storeId)).thenReturn(Optional.of(popupStore));
when(userService.getLoggedInUser()).thenReturn(user);
when(reservationAvailableSlotRepository.findByPopupStoreIdAndDateAndTime(storeId, date, time))
.thenReturn(Optional.of(slot));
// Redis 동작 모사
when(redisSlotService.getSlotFromRedis(storeId, date, time))
.thenAnswer(inv -> redisSlot.get());
doAnswer(inv -> {
int current = redisSlot.decrementAndGet();
if (current < 0) {
redisSlot.incrementAndGet();
throw new BusinessException(ErrorCode.NO_AVAILABLE_SLOT);
}
return current;
}).when(redisSlotService).decrementSlot(storeId, date, time);
when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
// DB 저장
when(reservationRepository.save(any(Reservation.class)))
.thenAnswer(inv -> inv.getArgument(0));
// when
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(100);
AtomicInteger successfulReservations = new AtomicInteger(0);
for (int i = 0; i < 100; i++) {
executorService.submit(() -> {
try {
reservationService.reservation(storeId, date, time);
successfulReservations.incrementAndGet();
} catch (BusinessException ignored) {
// 예약 실패는 무시
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
// then
assertThat(successfulReservations.get()).isEqualTo(28);
assertThat(redisSlot.get()).isEqualTo(0);
verify(reservationRepository, times(28)).save(any(Reservation.class));
}
@Test
void 예약_예외_발생_시_Redis_롤백() throws InterruptedException {
// Arrange
Long storeId = 1L;
LocalDate date = LocalDate.of(2024, 11, 28);
LocalTime time = LocalTime.of(19, 0);
PopupStore popupStore = PopupStore.builder().id(storeId).build();
User user = User.builder().id(1L).build();
ReservationAvailableSlot slot = ReservationAvailableSlot.builder()
.popupStore(popupStore)
.date(date)
.time(time)
.availableSlot(1)
.totalSlot(1)
.status(PopupStoreStatus.AVAILABLE)
.build();
// Mock 설정
when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
when(redisSlotService.getSlotFromRedis(storeId, date, time)).thenReturn(1);
when(popupStoreRepository.findById(storeId)).thenReturn(Optional.of(popupStore));
when(userService.getLoggedInUser()).thenReturn(user);
when(reservationAvailableSlotRepository.findByPopupStoreIdAndDateAndTime(storeId, date, time))
.thenReturn(Optional.of(slot));
// DB 작업 중 예외 발생
doThrow(new BusinessException(ErrorCode.RESERVATION_FAILED))
.when(reservationAvailableSlotRepository).save(any());
// Act & Assert
assertThatThrownBy(() -> reservationService.reservation(storeId, date, time))
.isInstanceOf(BusinessException.class)
.hasMessage(ErrorCode.RESERVATION_FAILED.getMessage()); // 예외 메시지 검증
// Redis 롤백 확인
verify(redisSlotService).incrementSlot(storeId, date, time);
// 예약 저장 호출되지 않음
verify(reservationRepository, never()).save(any());
}
@Test
void 예약_예외_발생_시_DB와_Redis_상태_검증() throws InterruptedException {
// Arrange
Long storeId = 2L;
LocalDate date = LocalDate.of(2024, 12, 1);
LocalTime time = LocalTime.of(18, 0);
PopupStore popupStore = PopupStore.builder().id(storeId).build();
User user = User.builder().id(2L).build();
ReservationAvailableSlot slot = ReservationAvailableSlot.builder()
.popupStore(popupStore)
.date(date)
.time(time)
.availableSlot(0) // 슬롯 없음
.totalSlot(10)
.status(PopupStoreStatus.FULL)
.build();
when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
when(redisSlotService.getSlotFromRedis(storeId, date, time)).thenReturn(0);
when(popupStoreRepository.findById(storeId)).thenReturn(Optional.of(popupStore));
when(userService.getLoggedInUser()).thenReturn(user);
when(reservationAvailableSlotRepository.findByPopupStoreIdAndDateAndTime(storeId, date, time))
.thenReturn(Optional.of(slot));
// Act & Assert
assertThatThrownBy(() -> reservationService.reservation(storeId, date, time))
.isInstanceOf(BusinessException.class)
.hasMessage(ErrorCode.NO_AVAILABLE_SLOT.getMessage());
// Redis 롤백 호출되지 않음
verify(redisSlotService, never()).incrementSlot(storeId, date, time);
// DB 저장 호출되지 않음
verify(reservationRepository, never()).save(any());
}
}
3개의 메소드 중 첫 번째 메소드는 28개의 슬롯에서 100명이 예약할 때를 예상하는 로직이다. 분산 서버의 경우를 생각해 스레드 100개를 만들어 테스트를 해보았다. 처음엔 14개만 들어오는 문제가 있었는데, 이는 Redis의 원자성이 보장되지 않았던 이유 때문이었다. 이는 트랜잭션의 특징인 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 영속성(Durability)에 있어서도 중요한 문제였다. 이는 Service에서 Redis와 DB의 로직을 분리해 줌으로써 해결할 수 있었다.
결과
Controller를 통해 예약을 수행하면 예약 처리가 잘 되는 것을 확인할 수 있다. 팝업 스토어는 더미 데이터를 이용하였다. 테스트 코드를 통해 예외 상황에 대해서도 더 생각해 볼 수 있는 문제였고, 동시성 처리에 대한 해결에 있어서도 이전보다 실력을 향상할 수 있었다.