Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 분산락을 이용한 중복 예약 방지 #25

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ services:
TZ: Asia/Seoul
ports:
- "3308:3306"

nowait-local-redis:
image: redis:latest
ports:
- "6379:6379"

nowait-test-redis:
image: redis:latest
ports:
- "6380:6379"
3 changes: 3 additions & 0 deletions nowait-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ dependencies {
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// Flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
Expand Down
46 changes: 29 additions & 17 deletions nowait-api/src/main/java/com/nowait/application/BookingService.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
package com.nowait.application;

import com.nowait.application.dto.response.booking.BookingRes;
import com.nowait.application.dto.response.booking.DailyBookingStatusRes;
import com.nowait.application.dto.response.booking.TimeSlotDto;
import com.nowait.application.event.BookingEventPublisher;
import com.nowait.domain.model.booking.Booking;
import com.nowait.domain.model.booking.BookingSlot;
import com.nowait.domain.model.booking.BookingStatus;
import com.nowait.domain.repository.BookingRepository;
import com.nowait.domain.repository.BookingSlotRepository;
import jakarta.persistence.EntityNotFoundException;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -33,36 +32,49 @@ public DailyBookingStatusRes getDailyBookingStatus(Long placeId, LocalDate date)
placeId, date);

List<TimeSlotDto> timeSlots = bookingSlots.stream()
.collect(Collectors.groupingBy(BookingSlot::getTime))
.entrySet().stream()
.map(entry -> new TimeSlotDto(entry.getKey(), isAvailable(entry.getValue())))
.map(slot -> new TimeSlotDto(slot.getTime(), !isAllBooked(slot)))
.toList();

return new DailyBookingStatusRes(placeId, date, timeSlots);
}

@Transactional
public BookingRes book(Long loginId, Long placeId, LocalDate date, LocalTime time,
Integer partySize) {
public Booking book(Long loginId, BookingSlot slot, Integer partySize) {
validateUserExist(loginId, "존재하지 않는 사용자의 요청입니다.");
validatePlaceExist(placeId, "존재하지 않는 식당입니다.");

BookingSlot slot = findAvailableSlot(placeId, date, time);
validateBookingPossible(slot);
Booking booking = bookingRepository.save(Booking.of(loginId, slot, partySize));

bookingEventPublisher.publishBookedEvent(booking, placeId);
bookingEventPublisher.publishBookedEvent(booking, slot.getPlaceId());

return BookingRes.of(booking, slot);
return booking;
}

private boolean isAvailable(List<BookingSlot> slots) {
// 모든 슬롯이 예약된 경우에만 false 반환
return slots.stream().anyMatch(slot -> !slot.isBooked());
public BookingSlot getSlotBy(Long placeId, LocalDate date, LocalTime time) {
validatePlaceExist(placeId, "존재하지 않는 식당입니다.");
return bookingSlotRepository.findByPlaceIdAndDateAndTime(placeId, date, time)
.orElseThrow(() -> new IllegalArgumentException("해당 시간대의 예약 슬롯이 존재하지 않습니다."));
}

private BookingSlot findAvailableSlot(Long placeId, LocalDate date, LocalTime time) {
return bookingSlotRepository.findFirstByPlaceIdAndDateAndTimeAndIsBookedFalse(placeId, date,
time).orElseThrow(() -> new IllegalArgumentException("예약 가능한 테이블이 없습니다."));
private boolean isAllBooked(BookingSlot slot) {
List<Booking> bookings = bookingRepository.findAllByBookingSlotId(slot.getId());

long activeCount = bookings.stream()
.filter(this::isActiveBooking)
.count();

return activeCount >= slot.getCapacity();
}

private boolean isActiveBooking(Booking booking) {
// TODO: 결제 대기 중이라면 결제 서비스에게 유효한 결제 상태인지 확인하는 로직 추가
return booking.getStatus() != BookingStatus.CANCELLED;
}

private void validateBookingPossible(BookingSlot slot) {
if (isAllBooked(slot)) {
throw new IllegalArgumentException("예약 가능한 테이블이 없습니다.");
}
}

private void validateUserExist(Long userId, String errorMessage) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.nowait.application;

import com.nowait.application.dto.response.booking.BookingRes;
import com.nowait.domain.model.booking.BookingSlot;
import com.nowait.domain.repository.LockRepository;
import java.time.LocalDate;
import java.time.LocalTime;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class LockBookingFacade {

private static final String PREFIX = "booking:";

private final LockRepository lockRepository;
private final BookingService bookingService;


public BookingRes book(Long loginId, Long placeId, LocalDate date, LocalTime time,
Integer partySize) {
BookingSlot slot = bookingService.getSlotBy(placeId, date, time);
String key = generateKey(slot);

while (!lockRepository.lock(key)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

try {
return BookingRes.of(bookingService.book(loginId, slot, partySize), slot);
} finally {
lockRepository.unlock(key);
}
}

private String generateKey(BookingSlot slot) {
return PREFIX + slot.getId();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static java.util.Objects.isNull;

import com.nowait.application.BookingService;
import com.nowait.application.LockBookingFacade;
import com.nowait.application.dto.response.booking.BookingRes;
import com.nowait.application.dto.response.booking.DailyBookingStatusRes;
import com.nowait.application.dto.response.booking.GetBookingInfoRes;
Expand Down Expand Up @@ -32,8 +33,10 @@
public class BookingApi {

private final BookingService bookingService;
private final LockBookingFacade lockBookingFacade;
private final ExecutorService executorService;


/**
* 가게 예약 현황 조회 API
*
Expand Down Expand Up @@ -66,7 +69,7 @@ public CompletableFuture<ApiResult<BookingRes>> book(
// TODO: Auth 기능 구현 시 loginId를 Authentication에서 가져오도록 수정
Long loginId = 1L;
return CompletableFuture.supplyAsync(
() -> bookingService.book(loginId, request.placeId(), request.date(),
() -> lockBookingFacade.book(loginId, request.placeId(), request.date(),
request.time(), request.partySize()), executorService)
.thenApply((data) -> ApiResult.of(HttpStatus.CREATED, data));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ public static Booking of(Long userId, BookingSlot slot, Integer partySize) {
validateBookingSlot(slot);
validatePartySize(partySize);

slot.setBooked(true);

return new Booking(
null,
slot.getId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,14 @@ public class BookingSlot extends BaseTimeEntity {
@Column(name = "place_id", nullable = false)
private Long placeId;

@Column(name = "table_id", nullable = false)
private Long tableId;

@Column(name = "date", nullable = false)
private LocalDate date;

@Column(name = "time", nullable = false)
private LocalTime time;

@Column(name = "is_booked")
private boolean isBooked;
@Column(name = "capacity", nullable = false)
private int capacity;

@Column(name = "deposit_required")
private boolean depositRequired;
Expand All @@ -49,18 +46,4 @@ public class BookingSlot extends BaseTimeEntity {

@Column(name = "deposit_policy_id")
private Long depositPolicyId;

public void setBooked(boolean isBooked) {
if (isBooked) {
validateBookingPossible();
}

this.isBooked = isBooked;
}

private void validateBookingPossible() {
if (isBooked()) {
throw new IllegalArgumentException("이미 예약된 슬롯입니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.nowait.domain.repository;

import com.nowait.domain.model.booking.Booking;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookingRepository extends JpaRepository<Booking, Long> {

List<Booking> findAllByBookingSlotId(Long slotId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@ public interface BookingSlotRepository extends JpaRepository<BookingSlot, Long>

List<BookingSlot> findAllByPlaceIdAndDate(Long placeId, LocalDate date);

Optional<BookingSlot> findFirstByPlaceIdAndDateAndTimeAndIsBookedFalse(Long placeId,
LocalDate date, LocalTime time);
Optional<BookingSlot> findByPlaceIdAndDateAndTime(Long placeId, LocalDate date, LocalTime time);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.nowait.domain.repository;

public interface LockRepository {

Boolean lock(String key);

Boolean unlock(String key);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.nowait.external.persistence;

import com.nowait.domain.repository.LockRepository;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RedisRockRepository implements LockRepository {

private static final String LOCK = "lock";
private static final Duration LOCK_TIMEOUT = Duration.ofSeconds(3);

private final RedisTemplate<String, String> redisTemplate;

@Override
public Boolean lock(String key) {
return redisTemplate
.opsForValue()
.setIfAbsent(key, LOCK, LOCK_TIMEOUT);
}

@Override
public Boolean unlock(String key) {
return redisTemplate.delete(key);
}
}
5 changes: 5 additions & 0 deletions nowait-api/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ spring:
username: user
password: password

data:
redis:
host: localhost
port: 6379

jpa:
show-sql: true
hibernate:
Expand Down
3 changes: 1 addition & 2 deletions nowait-api/src/main/resources/db/migration/V1__init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ CREATE TABLE booking_slot
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
place_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
is_booked BOOLEAN NOT NULL,
capacity INT NOT NULL,
deposit_required BOOLEAN NOT NULL,
confirm_required BOOLEAN NOT NULL,
deposit_policy_id BIGINT,
Expand Down
Loading
Loading