-
Notifications
You must be signed in to change notification settings - Fork 2
SCRUM-107 예약 로직 동시성 제어 #30
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
Changes from all commits
1fcf265
98c2b05
c33b0d2
ec19bf6
253592e
1e525a6
0d4f86c
b659e87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.wellmeet.domain.common; | ||
|
|
||
| import org.hibernate.exception.ConstraintViolationException; | ||
| import org.springframework.dao.DataIntegrityViolationException; | ||
|
|
||
| public class RepositoryErrorDecoder { | ||
|
|
||
| private static final String UNIQUE_RESERVATION_ERROR_MESSAGE = "unique_member_restaurant_available_date"; | ||
|
|
||
| private RepositoryErrorDecoder() { | ||
| } | ||
|
|
||
| public static boolean isUniqueConstraintViolation(DataIntegrityViolationException exception) { | ||
| Throwable cause = exception.getCause(); | ||
| while (cause != null) { | ||
| if (cause instanceof ConstraintViolationException cve) { | ||
| return UNIQUE_RESERVATION_ERROR_MESSAGE.equals(cve.getConstraintName()); | ||
| } | ||
| cause = cause.getCause(); | ||
| } | ||
| return false; | ||
|
Comment on lines
+13
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 유니크 위반 감지가 Hibernate 전용에 치우침 — SQLState/에러코드 기반 fallback 추가 권장 드라이버/DB에 따라 cause 체인이 아래처럼 보강을 제안합니다(핵심 변경만 표시): - public static boolean isUniqueConstraintViolation(DataIntegrityViolationException exception) {
- Throwable cause = exception.getCause();
- while (cause != null) {
- if (cause instanceof ConstraintViolationException cve) {
- return UNIQUE_RESERVATION_ERROR_MESSAGE.equals(cve.getConstraintName());
- }
- cause = cause.getCause();
- }
- return false;
- }
+ public static boolean isUniqueConstraintViolation(DataIntegrityViolationException exception) {
+ Throwable cause = exception;
+ while (cause != null) {
+ if (cause instanceof ConstraintViolationException cve) {
+ if (UNIQUE_RESERVATION_CONSTRAINT_NAME.equals(cve.getConstraintName())) {
+ return true;
+ }
+ if (isUniqueViolation(cve.getSQLException())) {
+ return true;
+ }
+ } else if (cause instanceof SQLException sqlEx) {
+ if (isUniqueViolation(sqlEx)) {
+ return true;
+ }
+ }
+ cause = cause.getCause();
+ }
+ return false;
+ }추가로 필요한 보조 코드(선택 위치에 추가): // import 추가
import java.sql.SQLException;
// helper 추가
private static boolean isUniqueViolation(SQLException ex) {
if (ex == null) return false;
String sqlState = ex.getSQLState();
int errorCode = ex.getErrorCode();
return "23000".equals(sqlState) // ANSI/Oracle/MySQL: integrity constraint violation
|| "23505".equals(sqlState) // PostgreSQL: unique_violation
|| errorCode == 1062; // MySQL: Duplicate entry
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| package com.wellmeet.domain.reservation; | ||
|
|
||
| import com.wellmeet.domain.common.RepositoryErrorDecoder; | ||
| import com.wellmeet.domain.reservation.entity.Reservation; | ||
| import com.wellmeet.domain.reservation.exception.ReservationErrorCode; | ||
| import com.wellmeet.domain.reservation.exception.ReservationException; | ||
| import com.wellmeet.domain.reservation.repository.ReservationRepository; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.dao.DataIntegrityViolationException; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
|
|
@@ -15,7 +17,14 @@ public class ReservationDomainService { | |
| private final ReservationRepository reservationRepository; | ||
|
|
||
| public Reservation save(Reservation reservation) { | ||
| return reservationRepository.save(reservation); | ||
| try { | ||
| return reservationRepository.save(reservation); | ||
| } catch (DataIntegrityViolationException exception) { | ||
| if (RepositoryErrorDecoder.isUniqueConstraintViolation(exception)) { | ||
| throw new ReservationException(ReservationErrorCode.ALREADY_RESERVED); | ||
| } | ||
| throw exception; | ||
| } | ||
|
Comment on lines
19
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain제약 위반이 flush/commit 시점에 발생하면 현재 catch에 걸리지 않습니다 — @transactional + flush 보장 필요 JPA/Hibernate는 실제 DB 제약 위반을 flush/commit 시 던집니다. 현재 권장 수정(Repository가 JpaRepository인 경우): @@
- public Reservation save(Reservation reservation) {
- try {
- return reservationRepository.save(reservation);
+ @Transactional
+ public Reservation save(Reservation reservation) {
+ try {
+ return reservationRepository.saveAndFlush(reservation);
} catch (DataIntegrityViolationException exception) {
if (RepositoryErrorDecoder.isUniqueConstraintViolation(exception)) {
throw new ReservationException(ReservationErrorCode.ALREADY_RESERVED);
}
throw exception;
}
}
만약 @Transactional
public Reservation save(Reservation reservation) {
try {
reservationRepository.save(reservation);
reservationRepository.flush(); // 혹은 EntityManager.flush()
return reservation;
} catch (DataIntegrityViolationException e) {
if (RepositoryErrorDecoder.isUniqueConstraintViolation(e)) {
throw new ReservationException(ReservationErrorCode.ALREADY_RESERVED);
}
throw e;
}
}
🏁 Script executed: #!/bin/bash
rg -n "interface\s+ReservationRepository" -A5
rg -n "extends\s+JpaRepository<" -A1Length of output: 3002 트랜잭션 범위 내에서 제약 위반 예외를 안전하게 처리하도록 save 메서드 수정 필요
제안하는 변경사항: @@
- public Reservation save(Reservation reservation) {
+ @Transactional
+ public Reservation save(Reservation reservation) {
try {
- return reservationRepository.save(reservation);
+ return reservationRepository.saveAndFlush(reservation);
} catch (DataIntegrityViolationException exception) {
if (RepositoryErrorDecoder.isUniqueConstraintViolation(exception)) {
throw new ReservationException(ReservationErrorCode.ALREADY_RESERVED);
}
throw exception;
}
}추가 import: import org.springframework.transaction.annotation.Transactional;🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| public Reservation getByIdAndMemberId(Long reservationId, Long memberId) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,21 @@ | |
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.JoinColumn; | ||
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.Table; | ||
| import jakarta.persistence.UniqueConstraint; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import java.time.LocalDateTime; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Table( | ||
| name = "reservation", | ||
| uniqueConstraints = @UniqueConstraint( | ||
| name = "unique_member_restaurant_available_date", | ||
| columnNames = {"member_id", "restaurant_id", "available_date_id"} | ||
| ) | ||
| ) | ||
|
Comment on lines
+24
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain제약 이름 지정으로 저장 시 예외 분기 안정화 — 구현 가이드 제안. 이름이 부여된 유니크 제약(“unique_member_restaurant_available_date”)은 저장 시 중복 예약을 명확히 식별하는 데 유용합니다. 메시지 파싱 대신 제약 이름으로 분기하세요.
import org.springframework.dao.DataIntegrityViolationException;
import org.hibernate.exception.ConstraintViolationException;
public void createReservation(...) {
try {
reservationRepository.save(reservation);
} catch (DataIntegrityViolationException ex) {
ConstraintViolationException cve = unwrapConstraintViolation(ex);
if (cve != null && "unique_member_restaurant_available_date".equals(cve.getConstraintName())) {
// 동일 회원·식당·시간대 중복 예약
throw new ReservationException(ReservationErrorCode.DUPLICATE_RESERVATION);
}
// 그 외 DB 오류는 상위 핸들러로
throw ex;
}
}
private ConstraintViolationException unwrapConstraintViolation(Throwable t) {
while (t != null) {
if (t instanceof ConstraintViolationException cve) return cve;
t = t.getCause();
}
return null;
}
원하시면 공통 예외 변환기/리트라이 어드바이스를 만들어 드릴게요. 다음 스크립트로 코드베이스에 이미 제약 이름 기반 분기가 있는지 확인하세요. 🏁 Script executed: #!/bin/bash
rg -n "unique_member_restaurant_available_date|ConstraintViolationException|DeadlockLoserDataAccessException|ER_DUP_ENTRY" -SLength of output: 877 서비스 계층에 제약 이름 기반 예외 처리를 추가해주세요. 현재 코드베이스에서 DB 제약 위반(“unique_member_restaurant_available_date”)을 잡아내는 로직은 없고,
public void createReservation(Reservation reservation) {
- reservationRepository.save(reservation);
+ try {
+ reservationRepository.save(reservation);
+ } catch (DataIntegrityViolationException ex) {
+ ConstraintViolationException cve = unwrapConstraintViolation(ex);
+ if (cve != null && "unique_member_restaurant_available_date".equals(cve.getConstraintName())) {
+ throw new ReservationException(ReservationErrorCode.DUPLICATE_RESERVATION);
+ }
+ throw ex;
+ }
}
private ConstraintViolationException unwrapConstraintViolation(Throwable t) {
while (t != null) {
if (t instanceof ConstraintViolationException cve) return cve;
t = t.getCause();
}
return null;
}
필요하다면 공통 예외 변환기 또는 @retryable 어드바이스 구현을 도와드릴 수 있습니다.
🤖 Prompt for AI Agents |
||
| @Getter | ||
| @Entity | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
용량 복원 실패 여부 확인 로직이 없습니다
increaseAvailableDateCapacity가 내부적으로 업데이트된 행 수를 반환한다면 그 값을 검사하여 동시 취소 충돌(이미 취소됐거나 만석 해제 실패) 여부를 판단해야 합니다. 현재는 void 호출 후 바로reservation.cancel()만 수행하므로 실제 용량이 복원되지 못해도 일관성이 깨질 수 있습니다.예외 처리를 추가하거나 반환값 기반 검증으로 보강해 주세요.
증가 로직 실패 여부 검증을 위해
restaurantDomainService.increaseAvailableDateCapacity메소드 정의를 확인합니다.🏁 Script executed:
Length of output: 2858
더 깊은 검증을 위해
increaseCapacity의 시그니처를 확인합니다.🏁 Script executed:
Length of output: 2445
용량 복원 실패 검증 로직 추가 필요
increaseAvailableDateCapacity는 내부적으로void메소드(AvailableDateRepository.increaseCapacity)만 호출하여 반환값이 없어, 실제 업데이트 실패(동시 취소 충돌 등)를 알 수 없습니다. 반면decreaseCapacity는 업데이트된 행 수를 반환하여 실패 여부를 검증하고 있습니다.다음 위치를 참고하여 복원 실패 검증 로직을 추가해 주세요:
restaurantDomainService.increaseAvailableDateCapacity(...)호출 직후 복원 성공 여부 점검increaseAvailableDateCapacity를 반환값 있는 형태로 변경하거나 예외를 던지도록 수정@Modifying쿼리를int increaseCapacity(...)로 변경하고, 호출부에서 반환값 > 0 여부 검증그 후
ReservationService.cancel()흐름에서 반환된 행 수를 확인하여 실패 시 예외 처리하거나 롤백 로직을 적용해 주세요.🤖 Prompt for AI Agents