Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public CreateReservationResponse reserve(Long memberId, CreateReservationRequest
request.getRestaurantId());
reservationDomainService.alreadyReserved(memberId, request.getRestaurantId(), request.getAvailableDateId());
Member member = memberDomainService.getById(memberId);
availableDate.reduceCapacity(request.getPartySize());
restaurantDomainService.decreaseAvailableDateCapacity(availableDate, request.getPartySize());
Reservation reservation = request.toDomain(availableDate.getRestaurant(), availableDate, member);

Reservation savedReservation = reservationDomainService.save(reservation);
Expand Down Expand Up @@ -60,8 +60,9 @@ public CreateReservationResponse updateReservation(
AvailableDate availableDate = restaurantDomainService.getAvailableDate(request.getAvailableDateId(),
request.getRestaurantId());
Reservation reservation = reservationDomainService.getByIdAndMemberId(reservationId, memberId);
availableDate.increaseCapacity(reservation.getPartySize());
availableDate.reduceCapacity(request.getPartySize());
restaurantDomainService.increaseAvailableDateCapacity(reservation.getAvailableDate(),
reservation.getPartySize());
restaurantDomainService.decreaseAvailableDateCapacity(availableDate, request.getPartySize());
reservation.update(
availableDate,
request.getPartySize(),
Expand Down
89 changes: 89 additions & 0 deletions api-user/src/test/java/com/wellmeet/BaseServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.wellmeet;

import com.wellmeet.domain.reservation.repository.ReservationRepository;
import com.wellmeet.domain.restaurant.availabledate.repository.AvailableDateRepository;
import com.wellmeet.fixture.AvailableDateGenerator;
import com.wellmeet.fixture.MemberGenerator;
import com.wellmeet.fixture.MenuGenerator;
import com.wellmeet.fixture.OwnerGenerator;
import com.wellmeet.fixture.ReservationGenerator;
import com.wellmeet.fixture.RestaurantGenerator;
import com.wellmeet.fixture.ReviewGenerator;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@ExtendWith(DataBaseCleaner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public abstract class BaseServiceTest {

@Autowired
protected AvailableDateGenerator availableDateGenerator;

@Autowired
protected ReservationGenerator reservationGenerator;

@Autowired
protected MemberGenerator memberGenerator;

@Autowired
protected OwnerGenerator ownerGenerator;

@Autowired
protected RestaurantGenerator restaurantGenerator;

@Autowired
protected MenuGenerator menuGenerator;

@Autowired
protected ReviewGenerator reviewGenerator;

@Autowired
protected ReservationRepository reservationRepository;

@Autowired
protected AvailableDateRepository availableDateRepository;

protected void runAtSameTime(int count, Runnable task) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(count);
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
executorService.submit(() -> {
try {
task.run();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
Comment on lines +55 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

스레드 내부 예외가 테스트에 전파되지 않습니다

task.run() 안에서 발생한 예외를 스레드 내부에서 RuntimeException으로 다시 던지지만, Future를 수집하지 않아 메인 테스트 스레드가 이를 감지하지 못합니다.
실제 예외가 발생해도 테스트가 성공으로 끝날 수 있으므로, 예외를 ConcurrentLinkedQueue 등에 저장한 뒤 latch.await() 후 메인 스레드에서 한 번에 throw 하거나, ExecutorService.invokeAllFuture를 회수해 확인하는 방식을 권장합니다.

🤖 Prompt for AI Agents
In api-user/src/test/java/com/wellmeet/BaseServiceTest.java around lines 55 to
64, exceptions thrown inside the thread are caught and rethrown as
RuntimeException but not propagated to the main test thread because the Future
returned by executorService.submit is not collected. To fix this, collect the
Future objects returned by submit, then after latch.await(), iterate over these
Futures and call get() to rethrow any exceptions in the main thread, or
alternatively store exceptions in a thread-safe collection inside the catch
block and after latch.await(), check and throw them in the main thread to ensure
test failures are detected properly.

}
latch.await();
executorService.shutdown();
}

protected void runAtSameTime(List<Runnable> tasks) throws InterruptedException {
int size = tasks.size();
ExecutorService executorService = Executors.newFixedThreadPool(size);
CountDownLatch latch = new CountDownLatch(size);
for (Runnable task : tasks) {
executorService.submit(() -> {
try {
task.run();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
Comment on lines +74 to +84
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

동일 문제: 예외 누락

위와 같은 이유로 리스트 기반 메서드도 예외가 숨겨집니다. 두 메서드를 공통 유틸로 추출하면서 예외 수집·재throw 로직을 함께 적용해 주세요.

🤖 Prompt for AI Agents
In api-user/src/test/java/com/wellmeet/BaseServiceTest.java around lines 74 to
84, the current code submits tasks to an executor but exceptions thrown inside
tasks are caught and rethrown inside the thread, causing them to be lost
outside. Refactor by extracting the task submission and exception handling into
a common utility method that collects exceptions from all tasks and rethrows
them after all tasks complete, ensuring no exceptions are silently swallowed.

}
latch.await();
executorService.shutdown();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.wellmeet.reservation;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.wellmeet.BaseServiceTest;
import com.wellmeet.domain.member.entity.Member;
import com.wellmeet.domain.owner.entity.Owner;
import com.wellmeet.domain.reservation.entity.Reservation;
import com.wellmeet.domain.restaurant.availabledate.entity.AvailableDate;
import com.wellmeet.domain.restaurant.entity.Restaurant;
import com.wellmeet.reservation.dto.CreateReservationRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

class ReservationServiceTest extends BaseServiceTest {

@Autowired
private ReservationService reservationService;

@Nested
class Reserve {

@Test
void 한_사람이_같은_예약_요청을_동시에_여러번_신청해도_한_번만_처리된다() throws InterruptedException {
Owner owner1 = ownerGenerator.generate("owner1");
Restaurant restaurant1 = restaurantGenerator.generate("restaurant1", owner1);
int capacity = 100;
AvailableDate availableDate = availableDateGenerator.generate(LocalDateTime.now().plusDays(1), capacity,
restaurant1);
int partySize = 4;
CreateReservationRequest request = new CreateReservationRequest(
restaurant1.getId(), availableDate.getId(), partySize, "request"
);
Member member = memberGenerator.generate("test");

runAtSameTime(500, () -> reservationService.reserve(member.getId(), request));
List<Reservation> reservations = reservationRepository.findAll();
AvailableDate foundAvailableDate = availableDateRepository.findById(availableDate.getId()).get();

assertAll(
() -> assertThat(reservations).hasSize(1),
() -> assertThat(foundAvailableDate.getMaxCapacity()).isEqualTo(capacity - partySize)
);
Comment on lines +41 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🛠️ Refactor suggestion

🧩 Analysis chain

스레드 실행 중 발생한 예외가 숨겨질 수 있습니다.

runAtSameTime(500, …) 내부에서 발생한 예외가 잡히지 않으면 테스트가 정상 통과하더라도 실제로는 일부 예약 로직이 실패했을 가능성이 있습니다.
CompletableFuture.allOf() 사용 등으로 각 태스크의 예외를 수집한 뒤 assertAll로 검증하도록 runAtSameTime 유틸을 개선하는 것을 권장합니다.


🏁 Script executed:

#!/bin/bash
rg -n "runAtSameTime"

Length of output: 598


🏁 Script executed:

#!/bin/bash
rg -n -A5 -B5 "protected void runAtSameTime" api-user/src/test/java/com/wellmeet/BaseServiceTest.java

Length of output: 1054


runAtSameTime 예외 처리 개선 필요

BaseServiceTest#runAtSameTime(int, Runnable) 메서드(파일: api-user/src/test/java/com/wellmeet/BaseServiceTest.java, 51–61라인)에서 task.run() 중 발생한 예외를 빈 catch 블록으로 처리해 버려, 스레드 실행 중 오류가 발생하더라도 테스트는 정상 통과하여 실제 로직 실패가 감춰질 수 있음이 확인되었습니다.

해결 방안 예시:

  • CompletableFuture를 이용해 CompletableFuture.allOf(...).join() 호출 시점에 모든 예외를 수집·재던지
  • ExecutorService.invokeAll(List<Callable<?>>) 후 반환된 Future.get() 호출로 예외를 명시적으로 확인

위와 같이 runAtSameTime 유틸을 개선하여 각 태스크 예외를 누락 없이 검증하도록 변경을 권장합니다.

· 대상 위치
– api-user/src/test/java/com/wellmeet/BaseServiceTest.java 51–61라인

🤖 Prompt for AI Agents
In api-user/src/test/java/com/wellmeet/BaseServiceTest.java around lines 51 to
61, the runAtSameTime method currently catches and ignores exceptions thrown by
tasks, causing test failures to be hidden. To fix this, refactor runAtSameTime
to use CompletableFuture.allOf(...).join() or ExecutorService.invokeAll with
Future.get() to collect and rethrow all exceptions from the concurrent tasks,
ensuring that any errors during task execution cause the test to fail as
expected.

}

@Test
void 여러_사람이_예약_요청을_동시에_신청해도_적절히_처리된다() throws InterruptedException {
Owner owner1 = ownerGenerator.generate("owner1");
Restaurant restaurant1 = restaurantGenerator.generate("restaurant1", owner1);
int capacity = 100;
AvailableDate availableDate = availableDateGenerator.generate(LocalDateTime.now().plusDays(1), capacity,
restaurant1);
int partySize = 2;
CreateReservationRequest request = new CreateReservationRequest(
restaurant1.getId(), availableDate.getId(), partySize, "request"
);
List<Runnable> tasks = new ArrayList<>();
for (int i = 0; i < 500; i++) {
Member member = memberGenerator.generate("member" + i);
tasks.add(() -> reservationService.reserve(member.getId(), request));
}

runAtSameTime(tasks);
List<Reservation> reservations = reservationRepository.findAll();
AvailableDate foundAvailableDate = availableDateRepository.findById(availableDate.getId()).get();

assertAll(
() -> assertThat(reservations).hasSize(50),
() -> assertThat(foundAvailableDate.getMaxCapacity()).isZero()
);
}
}
}
6 changes: 3 additions & 3 deletions api-user/src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ spring:
username: root
password:
jpa:
show-sql: true
# show-sql: true
properties:
hibernate:
format_sql: true
# format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
hibernate:
ddl-auto: create-drop
ddl-auto: create
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

ddl-auto를 create로 변경: 병렬 테스트/복수 컨텍스트 공유 시 스키마 경합 가능성 점검 필요

create는 컨텍스트 시작 시 기존 스키마를 드롭 후 재생성(종료 시 드롭 없음)합니다. 테스트가 병렬 실행되거나, 서로 다른 컨텍스트가 동일 DB를 공유하면 간헐적인 DROP/CREATE 경합, 락 대기, 실패가 발생할 수 있습니다. Testcontainers로 격리된 DB 사용 또는 테스트별 고유 스키마/DB명을 권장합니다. 최소한 환경변수로 제어 가능하게 두는 것이 안전합니다.

권장 변경(선택):

-      ddl-auto: create
+      ddl-auto: ${JPA_DDL_AUTO:create}

검증 스크립트(레포지토리 루트에서 실행):


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) ddl-auto 설정이 중복/충돌되는 곳이 없는지 확인"
rg -n "spring\.jpa\.hibernate\.ddl-auto|ddl-auto" -A 2 || true

echo
echo "2) JUnit 병렬 실행 설정 확인 (junit-platform.properties 등)"
fd -a junit-platform.properties || true
rg -n "junit\.jupiter\.execution\.parallel\.enabled" -A 2 || true

echo
echo "3) 컨텍스트 재시작을 유발할 수 있는 @DirtiesContext 사용 여부 확인"
rg -n "@DirtiesContext" -A 2 || true

echo
echo "4) 테스트 DB 격리를 위한 Testcontainers 사용 여부 확인"
rg -n "Testcontainers|MySQLContainer|DynamicPropertySource" -A 2 || true

Length of output: 1762


ddl-auto 설정 환경 변수화 적용 및 모듈별 중복 변경 필요
테스트 실행 시 create/create-drop 모드가 병렬 실행 또는 여러 컨텍스트가 동일 DB를 공유할 때 스키마 DROP/CREATE 충돌을 유발할 수 있습니다. 현재 다음 모듈의 테스트용 application.yml에 고정값으로 설정되어 있으므로 환경변수화해 기본값을 지정하도록 변경하세요.

수정 대상:

  • domain/src/test/resources/application.yml
  • api-owner/src/test/resources/application.yml
  • api-user/src/test/resources/application.yml

예시 (api-user 모듈):

--- a/api-user/src/test/resources/application.yml
+++ b/api-user/src/test/resources/application.yml
@@ -23,1 +23,1 @@
-      ddl-auto: create
+      ddl-auto: ${JPA_DDL_AUTO:create}

필요 시 JPA_DDL_AUTO 환경변수를 통해 각기 다른 모드(create, create-drop, none 등)를 유연히 제어할 수 있도록 설정해 두면 안전합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ddl-auto: create
ddl-auto: ${JPA_DDL_AUTO:create}
🤖 Prompt for AI Agents
In api-user/src/test/resources/application.yml at line 23, the ddl-auto property
is hardcoded to "create", which can cause schema conflicts during parallel test
executions or shared DB contexts. Modify this setting to use an environment
variable, such as JPA_DDL_AUTO, with a default value of "create" to allow
flexible control of the schema generation mode. Apply the same environment
variable approach to the ddl-auto settings in
domain/src/test/resources/application.yml and
api-owner/src/test/resources/application.yml to avoid duplication and ensure
consistent configuration across modules.

defer-datasource-initialization: true

cors:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,15 @@ public List<Menu> getMenuByRestaurantId(String restaurantId) {
public AvailableDate getAvailableDate(Long availableDateId, String restaurantId) {
return availableDateDomainService.getByIdAndRestaurantId(availableDateId, restaurantId);
}

public void decreaseAvailableDateCapacity(AvailableDate availableDate, int partySize) {
if (availableDate.canNotReserve(partySize)) {
throw new RestaurantException(RestaurantErrorCode.NOT_ENOUGH_CAPACITY);
}
availableDateDomainService.decreaseCapacity(availableDate, partySize);
}
Comment on lines +57 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

경합 조건으로 초과 예약 가능

canNotReserve() 검사는 메모리 상 값만 확인하므로 동시에 두 요청이 들어오면 둘 다 통과할 수 있습니다.
DB 레벨에서 UPDATE … SET capacity = capacity - :partySize WHERE id = :id AND capacity >= :partySize처럼 조건부 감소 후, 영향을 받은 행 수가 0이면 NOT_ENOUGH_CAPACITY 예외를 던지는 방식으로 원자성을 확보해 주세요.

🤖 Prompt for AI Agents
In
domain/src/main/java/com/wellmeet/domain/restaurant/RestaurantDomainService.java
around lines 57 to 62, the current capacity check using canNotReserve() is not
atomic and can lead to race conditions allowing overbooking. Refactor the
decreaseAvailableDateCapacity method to perform a conditional update directly in
the database that decrements capacity only if sufficient capacity exists, such
as using an UPDATE statement with a WHERE clause checking capacity >= partySize.
After the update, check the number of affected rows; if zero, throw the
NOT_ENOUGH_CAPACITY exception to ensure atomicity and prevent concurrent
overbooking.


public void increaseAvailableDateCapacity(AvailableDate availableDate, int partySize) {
availableDateDomainService.increaseCapacity(availableDate, partySize);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@ public AvailableDate getByIdAndRestaurantId(Long id, String restaurantId) {
return availableDateRepository.findByIdAndRestaurantId(id, restaurantId)
.orElseThrow(() -> new RestaurantException(RestaurantErrorCode.AVAILABLE_DATE_NOT_FOUND));
}

public void decreaseCapacity(AvailableDate availableDate, int partySize) {
int row = availableDateRepository.decreaseCapacity(availableDate.getId(), partySize);
if (row == 0) {
throw new RestaurantException(RestaurantErrorCode.NOT_ENOUGH_CAPACITY);
}
}

public void increaseCapacity(AvailableDate availableDate, int partySize) {
availableDateRepository.increaseCapacity(availableDate.getId(), partySize);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

import com.wellmeet.domain.common.BaseEntity;
import com.wellmeet.domain.restaurant.entity.Restaurant;
import com.wellmeet.domain.restaurant.exception.RestaurantErrorCode;
import com.wellmeet.domain.restaurant.exception.RestaurantException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
Expand Down Expand Up @@ -51,14 +49,8 @@ public AvailableDate(LocalDate date, LocalTime time, int maxCapacity, Restaurant
this.restaurant = restaurant;
}

public void reduceCapacity(int partySize) {
if (maxCapacity < partySize) {
throw new RestaurantException(RestaurantErrorCode.NOT_ENOUGH_CAPACITY);
}
maxCapacity -= partySize;
if (maxCapacity == 0) {
isAvailable = false;
}
public boolean canNotReserve(int partySize) {
return !isAvailable || maxCapacity < partySize;
}

public void increaseCapacity(int partySize) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
Expand All @@ -12,4 +15,15 @@ public interface AvailableDateRepository extends JpaRepository<AvailableDate, Lo
List<AvailableDate> findAllByRestaurantId(String restaurantId);

Optional<AvailableDate> findByIdAndRestaurantId(Long id, String restaurantId);

@Modifying
@Query("update AvailableDate a "
+ "set a.maxCapacity = a.maxCapacity - :partySize, "
+ "a.isAvailable = case when (a.maxCapacity - :partySize) = 0 then false else a.isAvailable end "
+ "where a.id = :id and a.maxCapacity >= :partySize")
int decreaseCapacity(@Param("id") Long id, @Param("partySize") int partySize);

@Modifying
@Query("update AvailableDate a set a.maxCapacity = a.maxCapacity + :partySize, a.isAvailable = true where a.id = :id")
void increaseCapacity(@Param("id") Long id, @Param("partySize") int partySize);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public enum RestaurantErrorCode {
RESTAURANT_NOT_FOUND(404, "해당 레스토랑을 찾을 수 없습니다."),

AVAILABLE_DATE_NOT_FOUND(404, "해당 예약 가능한 날짜를 찾을 수 없습니다."),
NOT_ENOUGH_CAPACITY(400, "예약 가능한 인원이 부족합니다."),
NOT_ENOUGH_CAPACITY(400, "해당 시간에 예약이 불가능합니다."),
TIME_SEQUENCE_INVALID(400, "시간 순서가 잘못되었습니다."),
INVALID_MENU_PRICE(400, "유효하지 않은 메뉴 가격입니다."),
;
Expand Down
Loading