Skip to content

Commit 565cbdd

Browse files
committed
feat: 회의실 방 예약 동시성 처리
1 parent 0c85cdc commit 565cbdd

8 files changed

+153
-10
lines changed

src/main/java/com/example/busan/reservation/ReservationController.java

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.example.busan.reservation.dto.CreateReservationRequest;
77
import com.example.busan.reservation.dto.ReservationResponse;
88
import com.example.busan.reservation.dto.UpdateReservationRequest;
9+
import com.example.busan.reservation.service.ReservationService;
910
import org.springframework.data.domain.Pageable;
1011
import org.springframework.data.web.PageableDefault;
1112
import org.springframework.http.HttpStatus;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.example.busan.reservation.domain;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
import org.springframework.data.jpa.repository.Query;
5+
import org.springframework.data.repository.query.Param;
6+
import org.springframework.stereotype.Repository;
7+
8+
@Repository
9+
public interface ReservationNamedLockRepository extends JpaRepository<Reservation, Long> {
10+
11+
@Query(value = "SELECT get_lock(:roomId, 10)", nativeQuery = true)
12+
Integer getLock(@Param("roomId") Long roomId);
13+
14+
@Query(value = "SELECT release_lock(:roomId)", nativeQuery = true)
15+
void releaseLock(@Param("roomId") Long roomId);
16+
}

src/main/java/com/example/busan/reservation/domain/ReservationRepository.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ WHERE DATE_FORMAT (r.startTime, '%Y-%m-%d') = :date
2121

2222
@Query(value = """
2323
SELECT EXISTS (
24-
SELECT 1
2524
FROM Reservation r
2625
WHERE NOT (r.endTime <= :startTime OR r.startTime >= :endTime)
26+
AND r.roomId = :roomId
2727
)
2828
""")
2929
boolean existDuplicatedTime(@Param("startTime") LocalDateTime startTime,
30-
@Param("endTime") LocalDateTime endTime);
30+
@Param("endTime") LocalDateTime endTime,
31+
@Param("roomId") Long roomId);
3132

3233
Optional<Reservation> findByIdAndReservationEmail(Long id, String email);
3334

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.example.busan.reservation.service;
2+
3+
import com.example.busan.reservation.domain.ReservationNamedLockRepository;
4+
import com.example.busan.reservation.dto.CreateReservationRequest;
5+
import com.example.busan.reservation.dto.UpdateReservationRequest;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.stereotype.Service;
9+
10+
@Service
11+
public class ReservationNamedLockFacade {
12+
13+
private static final Logger log = LoggerFactory.getLogger(ReservationNamedLockFacade.class);
14+
private static final int SUCCESS = 1;
15+
16+
private final ReservationNamedLockRepository lockRepository;
17+
private final ReservationService reservationService;
18+
19+
public ReservationNamedLockFacade(final ReservationNamedLockRepository lockRepository,
20+
final ReservationService reservationService) {
21+
this.lockRepository = lockRepository;
22+
this.reservationService = reservationService;
23+
}
24+
25+
public void create(final CreateReservationRequest request) {
26+
processWithRoomIdLocking(
27+
() -> reservationService.create(request),
28+
request.roomId());
29+
}
30+
31+
private void processWithRoomIdLocking(final Runnable runnable, final Long roomId) {
32+
try {
33+
final Integer lock = lockRepository.getLock(roomId);
34+
35+
if (lock == SUCCESS) {
36+
runnable.run();
37+
return;
38+
}
39+
40+
log.error("방 번호에 대한 네임드락 획득 실패 | 획득 여부 {} | 방 번호 {}", lock, roomId);
41+
} finally {
42+
lockRepository.releaseLock(roomId);
43+
}
44+
}
45+
46+
public void update(final Long id,
47+
final String currentMemberEmail,
48+
final UpdateReservationRequest request) {
49+
processWithRoomIdLocking(
50+
() -> reservationService.update(id, currentMemberEmail, request),
51+
request.roomId());
52+
}
53+
}

src/main/java/com/example/busan/reservation/ReservationService.java src/main/java/com/example/busan/reservation/service/ReservationService.java

+12-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.example.busan.reservation;
1+
package com.example.busan.reservation.service;
22

33
import com.example.busan.member.domain.Member;
44
import com.example.busan.member.domain.MemberRepository;
@@ -16,6 +16,7 @@
1616
import org.springframework.stereotype.Service;
1717
import org.springframework.transaction.annotation.Transactional;
1818

19+
import java.time.LocalDateTime;
1920
import java.util.List;
2021
import java.util.Map;
2122
import java.util.function.Function;
@@ -40,26 +41,30 @@ public ReservationService(final ReservationRepository reservationRepository,
4041

4142
@Transactional
4243
public void create(final CreateReservationRequest request) {
43-
if (reservationRepository.existDuplicatedTime(request.startTime(), request.endTime())) {
44+
validateDuplicated(request.startTime(), request.endTime(), request.roomId());
45+
reservationRepository.saveAndFlush(request.toEntity());
46+
}
47+
48+
private void validateDuplicated(final LocalDateTime startTime, final LocalDateTime endTime, final Long roomId) {
49+
if (reservationRepository.existDuplicatedTime(startTime, endTime, roomId)) {
4450
throw new IllegalArgumentException("다른 예약과 겹칠 수 없습니다.");
4551
}
46-
reservationRepository.save(request.toEntity());
4752
}
4853

4954
@Transactional
5055
public void deleteById(final Long id, final String currentMemberEmail, final CancelReservationRequest request) {
5156
final Reservation reservation = reservationRepository.findByIdAndReservationEmail(id, currentMemberEmail)
5257
.orElseThrow(() -> new IllegalArgumentException("자신이 예약한 회의실만 취소할 수 있습니다."));
58+
5359
reservation.cancel(request.reason());
5460
}
5561

5662
@Transactional
5763
public void update(final Long id, final String currentMemberEmail, final UpdateReservationRequest request) {
64+
validateDuplicated(request.startTime(), request.endTime(), request.roomId());
5865
final Reservation reservation = reservationRepository.findByIdAndReservationEmail(id, currentMemberEmail)
5966
.orElseThrow(() -> new IllegalArgumentException("자신이 예약한 회의실만 수정할 수 있습니다."));
60-
if (reservationRepository.existDuplicatedTime(request.startTime(), request.endTime())) {
61-
throw new IllegalArgumentException("다른 예약과 겹칠 수 없습니다.");
62-
}
67+
6368
reservation.update(request.roomId(), request.startTime(), request.endTime());
6469
}
6570

@@ -81,6 +86,7 @@ private Member getMember(final String currentMemberEmail) {
8186
private List<Reservation> getReservations(final String currentMemberEmail, final Pageable pageable) {
8287
final PageRequest pageRequest = PageRequest.of(
8388
pageable.getPageNumber(), pageable.getPageSize(), Sort.by("startTime").descending());
89+
8490
return reservationRepository.findAllByReservationEmail(currentMemberEmail, pageRequest);
8591
}
8692

src/test/java/com/example/busan/reservation/ReservationControllerTest.java

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.example.busan.reservation.dto.CreateReservationRequest;
1010
import com.example.busan.reservation.dto.ReservationResponse;
1111
import com.example.busan.reservation.dto.UpdateReservationRequest;
12+
import com.example.busan.reservation.service.ReservationService;
1213
import org.junit.jupiter.api.DisplayName;
1314
import org.junit.jupiter.api.Test;
1415
import org.springframework.boot.test.mock.mockito.MockBean;

src/test/java/com/example/busan/reservation/ReservationRepositoryTest.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ void existDuplicatedTime() {
9898
final LocalDateTime end = LocalDateTime.of(2050, 11, 10, 16, 30);
9999

100100
//when
101-
final boolean exist = reservationRepository.existDuplicatedTime(start, end);
101+
final boolean exist = reservationRepository.existDuplicatedTime(start, end, 1L);
102102

103103
//then
104104
assertThat(exist).isTrue();
@@ -116,7 +116,7 @@ void existDuplicatedTime2() {
116116
final LocalDateTime end = LocalDateTime.of(2023, 11, 10, 16, 30);
117117

118118
//when
119-
final boolean exist = reservationRepository.existDuplicatedTime(start, end);
119+
final boolean exist = reservationRepository.existDuplicatedTime(start, end, 1L);
120120

121121
//then
122122
assertThat(exist).isFalse();

src/test/java/com/example/busan/reservation/ReservationServiceTest.java

+65
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import com.example.busan.reservation.dto.CreateReservationRequest;
1313
import com.example.busan.reservation.dto.ReservationResponse;
1414
import com.example.busan.reservation.dto.UpdateReservationRequest;
15+
import com.example.busan.reservation.service.ReservationNamedLockFacade;
16+
import com.example.busan.reservation.service.ReservationService;
1517
import com.example.busan.room.domain.Room;
1618
import com.example.busan.room.domain.RoomRepository;
1719
import com.example.busan.room.dto.CreateRoomRequest;
@@ -23,9 +25,13 @@
2325
import org.springframework.boot.test.mock.mockito.MockBean;
2426
import org.springframework.data.domain.Pageable;
2527

28+
import java.time.LocalDateTime;
2629
import java.util.List;
2730
import java.util.Objects;
2831
import java.util.Optional;
32+
import java.util.concurrent.CountDownLatch;
33+
import java.util.concurrent.ExecutorService;
34+
import java.util.concurrent.Executors;
2935

3036
import static java.time.LocalDateTime.now;
3137
import static java.time.LocalDateTime.of;
@@ -40,6 +46,8 @@ class ReservationServiceTest {
4046
@Autowired
4147
private ReservationService reservationService;
4248
@Autowired
49+
private ReservationNamedLockFacade reservationFacade;
50+
@Autowired
4351
private ReservationRepository reservationRepository;
4452
@Autowired
4553
private MemberRepository memberRepository;
@@ -71,6 +79,63 @@ void create() {
7179
assertThat(reservationRepository.findAll()).hasSize(1);
7280
}
7381

82+
@Test
83+
@DisplayName("동시적으로 중복 예약을 하면 1개만 생성된다")
84+
void create_concurrency() throws InterruptedException {
85+
//given
86+
final int userCount = 100;
87+
final ExecutorService executorService = Executors.newFixedThreadPool(32);
88+
final CountDownLatch countDownLatch = new CountDownLatch(userCount);
89+
90+
//when
91+
for (int i = 0; i < userCount; i++) {
92+
executorService.submit(() -> {
93+
try {
94+
reservationFacade.create(
95+
new CreateReservationRequest(
96+
1L,
97+
LocalDateTime.of(2050, 11, 10, 13, 0),
98+
LocalDateTime.of(2050, 11, 10, 15, 30)));
99+
} finally {
100+
countDownLatch.countDown();
101+
}
102+
});
103+
}
104+
countDownLatch.await();
105+
106+
//then
107+
assertThat(reservationRepository.count()).isOne();
108+
}
109+
110+
@Test
111+
@DisplayName("동시적으로 100개의 요청 성공")
112+
void create_concurrency2() throws InterruptedException {
113+
//given
114+
final int userCount = 100;
115+
final ExecutorService executorService = Executors.newFixedThreadPool(32);
116+
final CountDownLatch countDownLatch = new CountDownLatch(userCount);
117+
118+
//when
119+
for (int i = 0; i < userCount; i++) {
120+
final long roomId = i;
121+
executorService.submit(() -> {
122+
try {
123+
reservationFacade.create(
124+
new CreateReservationRequest(
125+
roomId,
126+
LocalDateTime.of(2050, 11, 10, 13, 0),
127+
LocalDateTime.of(2050, 11, 10, 15, 30)));
128+
} finally {
129+
countDownLatch.countDown();
130+
}
131+
});
132+
}
133+
countDownLatch.await();
134+
135+
//then
136+
assertThat(reservationRepository.count()).isEqualTo(userCount);
137+
}
138+
74139
@Test
75140
@DisplayName("다른 예약과 시간이 겹치면 생성할 수 없다")
76141
void create_fail() {

0 commit comments

Comments
 (0)