
개요
최근에 캐치 테이블을 타겟 클론하여 프로젝트를 진행하였다.
예약 하기 전 해당 시간에 대한 선점을 먼저 요구하는 예약기능의 요구사항이 기억에 남는다. 최근에 이 기능에 대한 로직을 쭉 살펴보다가 개선점이 보였다.
기존 로직을 리팩토링 후 글로 남겨본다.
요구사항
예약 시, 요구사항은 다음과 같다.
- 가게의 예약시간을 클릭 시, 그 시간에 대해 해당 유저는 7분 동안 예약 선점권을 갖는다.
- 예약 선점권을 가진 유저가 상세정보를 입력 후 예약을 할 수 있다.
기존 코드
@Service
@RequiredArgsConstructor
public class MemberReservationService {
private final ReservationTimeRepository reservationTimeRepository;
private final ReservationRepository reservationRepository;
private final ReservationAsync reservationAsync;
private final ReservationLockRepository reservationLockRepository;
private final ApplicationEventPublisher publisher;
//예약 선점
@Transactional
public CreateReservationResponse preOccupyReservation(Member member,
CreateReservationRequest request) {
Long reservationTimeId = request.reservationTimeId();
// 1. 스핀락
while (FALSE.equals(reservationLockRepository.lock(reservationTimeId))) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 2. 예약시간 엔티티 조회 -> 존재하지 않는다면 락 해제
ReservationTime reservationTime = reservationTimeRepository.findByIdWithShop(
reservationTimeId)
.orElseThrow(() -> {
reservationLockRepository.unlock(reservationTimeId);
return new NotFoundCustomException(NOT_EXIST_TIME);
}
);
// 3. 예약 선점이 되어있는지 확인
validateIsPreOccupied(reservationTime);
// 4. 7분 선점 스케쥴러 동작
reservationAsync.setPreOcuppied(reservationTime);
Shop shop = reservationTime.getShop();
//5. 락 해제
reservationLockRepository.unlock(reservationTimeId);
return CreateReservationResponse.builder()
.shopName(shop.getName())
.memberName(member.getName())
.date(reservationTime.getTime())
.peopleCount(request.peopleCount())
.build();
}
//예약
@Transactional
public CreateReservationResponse registerReservation(Member member,
CreateReservationRequest request) {
// 1. 예약시간 엔티티 조회
// 2. 이미 예약이 되어있는지 확인
// 3. 예약이 되어있지 않다면, 예약상태로 변경
// 4. 예약 저장
}
}
기존 예약 선점 로직의 흐름은 다음과 같다.

- 스핀락을 통해 락 획득
- 예약시간 엔티티 조회
- 해당 예약시간이 선점이 되어있는지 확인 -> 선점한 유저가 없다면 선점 여부 변경
- 7분 선점 스케쥴러
- 락 해제
기존 로직은 데이터의 정합성을 위해 락을 따로 획득하고, DB 테이블의 예약 선점 여부를 변경해주고, 7분 스케쥴러를 위해 스케줄러를 따로 할당하였다.
개선할 점
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class ReservationTime {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "reservation_time_id")
private Long id;
@Column(name = "time")
private LocalDateTime time;
@Column(name = "is_occupied")
private boolean isOccupied;
@Column(name = "is_pre_occupied")
private boolean isPreOccupied;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Shop shop;
.
.
.
}
예약시간 Entity이다. 필드 중에 isPreOccupied 필드를 갖고있다.
- isPreOccupied - 예약시간 선점 상태 여부
필자는 '예약시간에 선점 상태를 DB로 관리하는 것이 아닌, Redis로 관리하는 것이 더 낫지 않을까' 란 생각이 들었다.
근거 1. 스케줄러 보장 X
@Component
@RequiredArgsConstructor
public class ReservationAsync {
@Transactional
public void setPreOcuppied(ReservationTime reservationTime) {
reservationTime.setPreOccupiedTrue();
//스레드 할당
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
//7분 후 해당 예약시간 선점권 반환
scheduler.schedule(reservationTime::setPreOccupiedFalse, 7, TimeUnit.MINUTES);
scheduler.shutdown();
}
}
예약시간에 대한 선점이 완료 시 실행되는 스케쥴러 로직이다.
선점 완료 시, 스레드를 따로 할당하여 비동기적으로 7분이 지나면 선점권을 반환시킨다.
이 방식에 문제점은 서버가 비정상적으로 종료된다면 해당 스레드도 함께 종료된다는 것이다.
그렇다면 7분이 지나도 선점권 반환 로직을 수행할 수 없고, 해당 예약시간에 선점권은 평생 변하지 않을 것이다.
근거 2. 불필요 락
기존 로직은 예약 선점 상태를 테이블 필드로 관리하기 때문에, 예약 선점 상태를 조회하고 변경하는 로직을 수행하려면, 동시성을 고려하여 해당 데이터의 정합성을 보장하기 위해 락을 걸어야 한다.
예약 선점 상태를 Redis로 관리한다면, 여러 요청이 동시에 들어온다 하더라도 Redis의 원자성 덕분에 동시성을 해결할 수 있으며, 선점 여부 상태 변경을 위해 DB를 거칠 필요도 없다.
리팩토링 코드
@Service
@RequiredArgsConstructor
public class MemberReservationService {
private final ReservationTimeRepository reservationTimeRepository;
private final ReservationRepository reservationRepository;
private final ReservationOccupancyRepository reservationOccupancyRepository;
private final ApplicationEventPublisher publisher;
@Transactional
public CreateReservationResponse preOccupyReservation(Member member,
CreateReservationRequest request) {
Long reservationTimeId = request.reservationTimeId();
Long memberId = member.getId();
// 1. 선점 여부 확인 -> 선점 완료 시 7분 카운트
if (!reservationOccupancyRepository.isOccupy(reservationTimeId, memberId)) {
throw new RuntimeException(ALREADY_PREOCCUPIED_RESERVATION_TIME.getMessage());
}
// 2. 예약시간 엔티티 조회 -> 예약시간이 존재하지 않는다면, 7분 선점 삭제
ReservationTime reservationTime = reservationTimeRepository.findByIdWithShop(
reservationTimeId)
.orElseThrow(() -> {
reservationOccupancyRepository.deleteKey(reservationTimeId);
return new NotFoundCustomException(NOT_EXIST_TIME);
});
Shop shop = reservationTime.getShop();
return CreateReservationResponse.builder()
.shopName(shop.getName())
.memberName(member.getName())
.date(reservationTime.getTime())
.peopleCount(request.peopleCount())
.build();
}
@Transactional
public CreateReservationResponse registerReservation(Member member,
CreateReservationRequest request) {
// 1. 예약 선점 대상여부 확인
// 2. 예약시간 엔티티 조회
// 3. 예약상태로 변경
// 4. 예약 저장
}
}
@Component
@RequiredArgsConstructor
public class ReservationOccupancyRepository {
private final RedisTemplate<String, String> redisTemplate;
//해당 예약시간에 대한 선점 여부 확인 -> 선점한 유저가 없다면 선점권 GET
public Boolean isOccupy(Long reservationTimeId, Long memberId) {
return redisTemplate
.opsForValue()
.setIfAbsent(reservationTimeId.toString(), memberId.toString(), 7, TimeUnit.MINUTES);
}
//선점권 반납
public Boolean deleteKey(Long reservationTimeId) {
return redisTemplate
.delete(reservationTimeId.toString());
}
}
개선된 로직의 흐름은 다음과 같다

- 선점 여부 확인 & 선점한 유저가 없다면 선점권 GET -> 7분 카운트 시작
- 예약시간 엔티티 조회 후, 유저에게 완료 응답
결과
근거 1에 대한 해결
기존 로직에서는 따로 스케줄러를 할당해서 7분이 지나면 상태가 바뀌게 하였다.

유저가 해당 예약시간에 대해 선점 후, 서버를 내렸을 때의 테이블이다.
모종의 이유로 서버가 내려간다면, 다시 서버를 실행시킨다 하더라도 7분 스케줄러는 종료되었기 때문에, 해당 예약시간의 선점 여부를 다시 바꿀 방법은 없었다.
하지만 개선된 로직에서는 Redis 서버에 설정한 TTL이 스케줄러의 역할을 대신해주기에, 서버가 내려가도 시간이 지나면 선점권을 해제한다.

유저가 해당 예약시간에 대해 선점 후, 서버를 내렸을 때의 Redis Key와 Value이다.
서버가 내려가더라도 TTL로 설정한 7분은 흘러가며, 다시 서버가 올라온다면 어떤 유저가 얼만큼 선점했는지 기억할 수 있다.
근거 2에 대한 해결
Redis를 통해 예약시간 선점 여부를 관리하기 때문에 원자성이 보장되므로, 락을 따로 걸지 않고 상태를 관리할 수 있다.
그 이외에도 Redis가 선점 여부 상태 관리, 선점한 유저 관리, 7분 카운트까지 한 번에 다 처리하고 있기에 코드도 확연히 짧아진 것을 볼 수 있다.💡
정리
사실 Redis를 공부하고 적용해 본 것은 이번이 처음이다.
Redis 자체는 어려운 것이 없는 것 같다. 'Redis를 어느 상황에 알맞게 잘 사용하느냐'가 개발자의 역량을 좌우할 것같다 느꼈다. 기간은 짧았지만, 재밌게 만져보고 활용해본 것 같다!
'Database > Redis' 카테고리의 다른 글
| [Redis] RefreshToken을 Redis에 관리한다면? (0) | 2024.02.13 |
|---|