diff --git a/docs/meeting-lifecycle-fix.md b/docs/meeting-lifecycle-fix.md
new file mode 100644
index 0000000..3bfa429
--- /dev/null
+++ b/docs/meeting-lifecycle-fix.md
@@ -0,0 +1,117 @@
+# 장소 투표 ~ 모임 확정 및 종료 로직 수정
+
+> ### 브랜치: fix/#151
+> 장소 투표 ~ 모임 확정/종료 과정의 동시성 문제 해결 및 예외 상황 처리 개선
+
+---
+
+## 1. 요약
+
+### 개요
+`동시 요청, 모임 나가기, 배치 실패` 상황에서 장소 투표 → 모임 확정 → 모임 종료로 이어지는 상태 전환이 틀어지는 상황 발생
+
+```mermaid
+flowchart LR
+ V["VOTING
장소 투표 중"] -->|"마지막 장소 투표
마지막 미투표자 나가기
투표 마감 배치 (자정)"| C["CONFIRMED
모임 확정"]
+ C -->|"모임 종료 배치 (자정, 서버 시작 시)"| D["COMPLETED
모임 종료"]
+```
+
+### 문제
+| 문제 | 대표 상황 | 원인 | 해결 |
+|---|-----------------------------------------------------------------------|----------------------------------------------------|-------------------------------------------------------|
+| **① 동시 요청 충돌** | 마지막 두 명 동시 투표 시 확정 누락, 확정 중 모임 참여/나가기 시 확정 취소, 배치와 겹치면 이중 확정 및 변경 덮어씀 | 잠금 없이 읽은 모임 데이터로 판단 및 저장함(Write Skew, Lost Update) | 모임 단위 비관적 락으로 같은 모임 요청 순차 처리, 배치는 상태 재확인 및 조건부 UPDATE |
+| **② 모임 나가기 처리 누락** | 나간 참여자의 표가 확정에 반영됨, 확정 시점 어긋남, 마지막 미투표자가 나가도 자정까지 미확정됨 | 참여자 기준이 쿼리마다 다르고, 확정 판단이 장소 투표 로직에만 있음 | '현재 참여 중인 투표 가능 참여자' 기준 통일 + 모임 나가기 시 모임 확정 가능 여부 재확인 |
+| **③ 배치 실패 전파** | 모임 하나 실패 시 전체 롤백, 투표 마감 배치 실패 시 모임 종료 배치 미실행됨 | 여러 모임을 한 트랜잭션에서 처리함 | 투표 마감/모임 종료 처리 분리, 모임별 트랜잭션 분리 |
+
+### 수정 후 플로우
+```text
+ [장소 투표 API] [모임 나가기 API] [모임 참여 API]
+ │ │ │
+ ▼ ▼ ▼
+ findByIdForUpdate findByIdForUpdate findByInviteCodeForUpdate ← Meeting 행 락 (같은 모임 요청은 순서대로)
+ │ │ │
+ 투표 저장 나가기 처리 참여 처리 (투표 중이면 NEW_RESTRICTED)
+ │ │
+ └──────────┬─────────────┘
+ ▼
+ confirmMeetingIfAllVoted(meeting) (모임 확정 가능 여부 판단)
+ - VOTING 상태인가? & 장소 추천 완료되었는가?
+ - 투표 가능 참여자 중 미투표자 = 0 ?
+ │ 예
+ ▼
+ confirmMeeting(meeting) (모임 확정)
+ 1) 일정 미정인 경우: 일정 투표 1위 항목으로 일정 확정 (나간 참여자 표 제외)
+ 2) 장소 투표 1위 항목으로 장소 확정 (나간 참여자 표 제외)
+ 3) CONFIRMED + 참여자 확정 + 확정 알림
+
+[스케줄러: 매일 자정 실행]
+ ① 장소 투표 마감 (전체 트랜잭션 없음)
+ └ 마감 대상 모임마다 별도의 트랜잭션으로 처리 (실패한 모임만 롤백)
+ : 모임 행 잠금 → VOTING 상태 재확인 → 모임 확정 처리
+ ② 모임 종료 (조건부 UPDATE 한 번)
+ └ UPDATE meetings SET status=COMPLETED, updated_at=? WHERE status=CONFIRMED AND scheduled_date < 오늘
+```
+
+---
+
+## 2. 커밋 내역
+
+### `f379b6a`: 장소 투표 동시성 문제 해결
+| 항목 | 내용 |
+|---|------------------------------------------------------------------------------------------------------------------------------------------------|
+| 문제 | - 마지막 두 명이 동시에 투표 → 둘 다 '남은 사람 있음'으로 판단해 확정 누락
- 같은 사람 연속 요청 → 투표 중복 저장 |
+| 원인 | 남은 인원 및 기투표 확인과 저장 사이에 잠금 없음 |
+| 해결 | `모임 행 비관적 락(SELECT ... FOR UPDATE)` 으로 같은 모임의 투표 요청은 순차적으로 처리 |
+| 검토 대안 | - 낙관적 락, SERIALIZABLE: 동시에 투표하면 한쪽 투표를 실패시키는 방식이라, 정상 투표가 에러를 받지 않도록 재시도 로직이 따로 필요함
- 커밋 후 재판정: 투표 저장 후 확정 판단 전에 서버가 중단되면 마감 배치까지 확정이 지연됨 |
+
+### `84c7325`: 투표한 참여자가 나갈 때 모임 확정 시점이 어긋나는 문제 해결
+| 항목 | 내용 |
+|---|---------------------------------------------------------------|
+| 문제 | 투표한 참여자가 나갈 경우 남은 사람이 투표하기 전에 모임 확정됨, 전원 투표해도 모임 확정 안됨 |
+| 원인 | `투표한 사람 수(나간 사람 포함) == 투표 가능 인원(나간 사람 제외)`으로 판단 (서로 다른 집합 비교) |
+| 해결 | `투표 가능 참여자 중 투표 기록 없는 사람 = 0`으로 판단 (한 집합만 세는 `NOT EXISTS` 쿼리) |
+
+### `2f02d9f`: 모임 확정 중 모임 참여/나가기 시 확정 취소 문제 해결
+| 항목 | 내용 |
+|---|----------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 문제 | 마지막 투표로 확정되는 순간 모임 참여/나가기 → 확정된 모임이 VOTING 상태로 되돌아감 |
+| 원인 | 잠금 없이 읽은 이전 모임 데이터를 저장 → JPA 변경 감지가 모든 컬럼을 옛 값으로 덮어씀 (Lost Update) |
+| 해결 | 모임 참여/나가기에도 `같은 모임 행 비관적 락` 조회 적용 (조회 방식만 교체) |
+| 검토 대안 | - @DynamicUpdate: 바뀐 컬럼만 저장하지만, 두 요청이 같은 컬럼(상태)을 고치는 경합은 못 막음
- 원자적 UPDATE 쿼리: 모임장 승계 등 관련 로직 추가 변경 필요
- 낙관적 락: 충돌한 참여/나가기 요청이 실패해 재시도 처리가 필요함 |
+
+### `cef14e2`: 일정 미정 모임의 일정 확정 시점 변경 (일정 투표 현황 조회 API → 모임 확정)
+| 항목 | 내용 |
+|---|-------------------------------------------------------------------------------|
+| 문제 | - 일정 투표 현황 조회(GET) 시 일정 확정 → 아무도 조회하지 않으면 일정 없이 확정됨
- 조회 API에서 쓰기 작업이 발생함 |
+| 원인 | 조회 API가 상태 변경(일정 확정)을 진행함 |
+| 해결 | 모임 확정 시점에 일정과 장소를 한 번에 확정 (일정 투표 현황 조회 API는 일정 투표 내역 집계만 담당) |
+
+### `0c367d7`: 장소 투표 집계에서 나간 참여자 표 제외
+| 항목 | 내용 |
+|---|------------------------------------------------------------------------------------------------------------------|
+| 문제 | 장소 투표 현황(투표 인원, 득표수)과 확정 장소에 나간 참여자의 표가 반영됨 |
+| 원인 | 집계 쿼리가 참여자가 나갔는지 보지 않음 |
+| 해결 | 집계 쿼리에서 참여자 테이블과 조인해 현재 참여 중인 사람의 표만 집계 |
+
+### `c85dfb5`: 마지막 미투표자가 나가면 즉시 모임 확정 처리
+| 항목 | 내용 |
+|---|--------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 문제 | 마지막 미투표자가 나가 남은 전원이 투표 완료 상태가 돼도 자정 마감 배치까지 모임 확정 안됨 |
+| 원인 | 확정 판단이 장소 투표 로직에만 존재함 |
+| 해결 | - 모임 확정 처리를 한 곳으로 모으고 모임 나가기 직후 같은 트랜잭션, 같은 잠금 안에서 판단
- 판단 조건을 '장소 추천이 끝난 VOTING'으로 통일 (투표, 모임 나가기, 배치 공통) |
+| 검토 대안 | - 나가기 커밋 후 이벤트로 판단: 트랜잭션이 둘로 나뉘어 구조가 복잡해짐
- 마감 배치가 처리: 최대 하루 확정 지연이 그대로 남음 |
+
+### `91e1297`: 장소 투표 마감 배치 실패 격리 및 이중 확정 방지
+| 항목 | 내용 |
+|---|--------------------------------------------------------------------|
+| 문제 | - 모임 하나 실패 시 전체가 롤백됨
- 배치와 사용자의 마지막 투표가 겹치면 이중 확정 및 덮어쓰기 문제 발생 |
+| 원인 | 모든 모임을 한 트랜잭션에서 잠금 없이 처리함 |
+| 해결 | `모임별 트랜잭션 분리(REQUIRES_NEW)` + 처리 직전 모임 행 잠금 후 VOTING 상태인지 재확인 |
+
+### `3a8e672`: 모임 종료 배치 실패 격리 및 덮어쓰기 문제 해결
+| 항목 | 내용 |
+|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 문제 | - 모임 종료 중 모임을 나갈 경우 인원, 모임장 변경이 덮어써짐
- 투표 마감 배치 실패 시 모임 종료 배치 미실행 |
+| 원인 | - 엔티티를 읽어 상태 변경 후 통째로 저장
- 장소 투표 마감/모임 종료 처리를 한 try에서 실행함 |
+| 해결 | - 모임 종료: 조건부 UPDATE 한 번 (`status=CONFIRMED AND scheduled_date<오늘` 인 행의 상태만 변경 → 덮어쓰기 없음, 여러 번 실행해도 결과 동일), 실패 시 재시도(총 3번)
- 스케줄러 단계 분리: 투표 마감/모임 종료를 각각 따로 예외 처리
- 서버 시작 시 모임 종료 배치 실행 (자정에 서버가 꺼져 있었어도 밀린 모임 종료 처리) |
+| 검토 대안 | - 대상 모임을 잠금으로 한 번에 조회: 여러 모임을 동시에 잠가 다른 요청과 잠금 순서가 엇갈리면 데드락 발생 가능
- 모임별 트랜잭션: 상태 값 하나만 바꾸는 작업에 비해 무거움 |
diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java
index 114f288..d301f32 100644
--- a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java
+++ b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepository.java
@@ -2,9 +2,15 @@
import com.moive.MoiveBE.domain.meeting.entity.Meeting;
import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -17,4 +23,28 @@ public interface MeetingRepository extends JpaRepository {
List findAllByStatus(MeetingStatus status);
List findAllByStatusAndScheduledDateBefore(MeetingStatus status, LocalDate date);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select m from Meeting m where m.id = :id")
+ Optional findByIdForUpdate(@Param("id") Long id);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select m from Meeting m where m.inviteCode = :inviteCode")
+ Optional findByInviteCodeForUpdate(@Param("inviteCode") String inviteCode);
+
+ // 모임 일정이 지난 모임의 상태 일괄 변경 (CONFIRMED -> COMPLETED)
+ @Modifying
+ @Query("""
+ update Meeting m
+ set m.status = :toStatus,
+ m.updatedAt = :updatedAt
+ where m.status = :fromStatus
+ and m.scheduledDate < :date
+ """)
+ int updateStatusByStatusAndScheduledDateBefore(
+ @Param("fromStatus") MeetingStatus fromStatus,
+ @Param("toStatus") MeetingStatus toStatus,
+ @Param("date") LocalDate date,
+ @Param("updatedAt") LocalDateTime updatedAt
+ );
}
\ No newline at end of file
diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java
index ded7124..1c377d1 100644
--- a/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java
+++ b/src/main/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepository.java
@@ -3,6 +3,8 @@
import com.moive.MoiveBE.domain.meeting.entity.Participant;
import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -22,4 +24,25 @@ public interface ParticipantRepository extends JpaRepository
List findAllByMeetingIdAndLeftAtIsNull(Long meetingId);
long countByMeetingIdAndLeftAtIsNullAndStateNot(Long meetingId, ParticipantState state);
+
+ // 장소 투표를 아직 하지 않은 투표 가능 참여자 수 카운트
+ // - 투표 가능 참여자: 모임을 나가지 않았고, 신규 참여(NEW_RESTRICTED)가 아닌 참여자
+ // - 투표 여부: 장소 투표 내역 존재 여부로 판단
+ @Query("""
+ select count(p)
+ from Participant p
+ where p.meetingId = :meetingId
+ and p.leftAt is null
+ and p.state <> :excludedState
+ and not exists (
+ select 1
+ from PlaceVote pv
+ where pv.meetingId = p.meetingId
+ and pv.participantId = p.id
+ )
+ """)
+ long countParticipantsYetToVote(
+ @Param("meetingId") Long meetingId,
+ @Param("excludedState") ParticipantState excludedState
+ );
}
diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java
index 77fb5b3..eac0763 100644
--- a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java
+++ b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveService.java
@@ -5,6 +5,7 @@
import com.moive.MoiveBE.domain.meeting.entity.Participant;
import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.vote.service.VoteService;
import com.moive.MoiveBE.global.exception.CustomErrorCode;
import com.moive.MoiveBE.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
@@ -21,11 +22,13 @@ public class MeetingLeaveService {
private final MeetingRepository meetingRepository;
private final ParticipantRepository participantRepository;
+ private final MeetingService meetingService;
+ private final VoteService voteService;
public void leaveMeeting(Long meetingId) {
Long currentUserId = getCurrentUserId();
- Meeting meeting = meetingRepository.findById(meetingId)
+ Meeting meeting = meetingRepository.findByIdForUpdate(meetingId)
.orElseThrow(() -> new CustomException(CustomErrorCode.MEETING_NOT_FOUND));
if (meeting.getStatus() == MeetingStatus.COMPLETED) {
@@ -60,6 +63,14 @@ public void leaveMeeting(Long meetingId) {
myParticipant.leave();
meeting.decrementParticipantCnt();
if (myParticipant.isConditionCompleted()) meeting.decrementSubmittedCnt();
+
+ // 조건 입력 단계에서 아직 조건을 제출하지 않은 참여자가 나가며 남은 참여자가 모두 제출을 마친 상태가 되는 경우
+ // => 모임 상태 전환 & 추천 트리거
+ meetingService.startVotingIfAllSubmitted(meeting);
+
+ // 장소 투표 단계에서 아직 투표하지 않은 참여자가 나가며 남은 참여자가 모두 투표를 마친 상태가 되는 경우
+ // => 모임 확정
+ voteService.confirmMeetingIfAllVoted(meeting);
}
private Long getCurrentUserId() {
diff --git a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java
index ae4092d..009fbb4 100644
--- a/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java
+++ b/src/main/java/com/moive/MoiveBE/domain/meeting/service/MeetingService.java
@@ -21,6 +21,7 @@
import java.security.SecureRandom;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.Optional;
@@ -130,7 +131,7 @@ public CreateMeetingResponse createMeeting(CreateMeetingRequest request) {
public JoinMeetingResponse joinMeeting(String inviteCode) {
Long userId = getCurrentUserId();
- Meeting meeting = meetingRepository.findByInviteCode(inviteCode)
+ Meeting meeting = meetingRepository.findByInviteCodeForUpdate(inviteCode)
.orElseThrow(() -> new CustomException(CustomErrorCode.INVALID_INVITE_CODE));
if (meeting.getStatus() == MeetingStatus.COMPLETED) {
@@ -174,7 +175,7 @@ public JoinMeetingResponse joinMeeting(String inviteCode) {
public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenceRequest request) {
Long userId = getCurrentUserId();
- Meeting meeting = meetingRepository.findById(meetingId)
+ Meeting meeting = meetingRepository.findByIdForUpdate(meetingId)
.orElseThrow(() -> new CustomException(CustomErrorCode.MEETING_NOT_FOUND));
if (meeting.getStatus() != MeetingStatus.CONDITION_INPUT) {
@@ -261,15 +262,7 @@ public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenc
}
// 전원 완료 시 모임 상태 전환 및 추천 트리거
- boolean triggered = false;
- if (isFirstSubmit && meeting.getSubmittedCnt() >= meeting.getParticipantCnt()) {
- meeting.transitionToVoting();
- triggered = true;
- eventPublisher.publishEvent(
- new AreaRecommendationRequestedEvent(meetingId)
- );
-
- }
+ boolean triggered = isFirstSubmit && startVotingIfAllSubmitted(meeting);
return SubmitPreferenceResponse.of(
meetingId,
@@ -280,19 +273,31 @@ public SubmitPreferenceResponse submitPreference(Long meetingId, SubmitPreferenc
);
}
+ public boolean startVotingIfAllSubmitted(Meeting meeting) {
+ if (meeting.getStatus() != MeetingStatus.CONDITION_INPUT
+ || meeting.getSubmittedCnt() < meeting.getParticipantCnt()) {
+ return false;
+ }
+
+ meeting.transitionToVoting();
+ eventPublisher.publishEvent(
+ new AreaRecommendationRequestedEvent(meeting.getId())
+ );
+ return true;
+ }
+
/**
* 모임 일정이 지난 확정된 모임을 종료 처리 (CONFIRMED -> COMPLETED)
* - 날짜 단위로 판단 (scheduledDate < 오늘)
- * - 매일 MeetingLifecycleScheduler에서 호출됨
+ * - 매일 MeetingLifecycleScheduler에서 호출됨 (서버 시작 시에도 1회 호출)
*/
- public void completeElapsedMeetings() {
- List elapsedMeetings = meetingRepository
- .findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now());
+ public int completeElapsedMeetings() {
+ LocalDate today = LocalDate.now();
+ int completedCnt = meetingRepository.updateStatusByStatusAndScheduledDateBefore(
+ MeetingStatus.CONFIRMED, MeetingStatus.COMPLETED, today, LocalDateTime.now());
- for (Meeting meeting : elapsedMeetings) {
- meeting.complete();
- log.info("[모임 종료 처리] 모임 일정 경과로 자동 종료 (meetingId={})", meeting.getId());
- }
+ log.info("[모임 종료 배치] 완료 - 종료 {}건 (기준: 모임 일정이 {} 이전인 확정 모임)", completedCnt, today);
+ return completedCnt;
}
private Long getCurrentUserId() {
diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java b/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java
index 237b32b..643e3ea 100644
--- a/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java
+++ b/src/main/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepository.java
@@ -12,18 +12,23 @@ public interface PlaceVoteRepository extends JpaRepository {
boolean existsByMeetingIdAndParticipantId(Long meetingId, Long participantId);
+ // 장소 투표 참여 인원 수
+ // - 모임을 나간 참여자의 투표 제외
@Query("""
select count(distinct pv.participantId)
from PlaceVote pv
+ join Participant p on pv.participantId = p.id
where pv.meetingId = :meetingId
+ and p.leftAt is null
""")
long countDistinctVoters(@Param("meetingId") Long meetingId);
// 장소별 득표 집계
+ // - 모임을 나간 참여자의 투표는 제외
// - 서로 다른 recommended_place_id라도 google_place_id(실제 장소)가 같으면 하나로 병합해서 집계
- // - recommendedPlaceId : 병합된 그룹을 대표하는 id (같은 google_place_id 중 가장 작은 recommended_place_id)
- // - voterCnt : 해당 장소(googlePlaceId 기준)에 투표한 서로 다른 참여자 수
- // - votedByMe : 조회 유저가 해당 장소에 투표했으면 1
+ // - recommendedPlaceId: 병합된 그룹을 대표하는 ID (같은 google_place_id 중 가장 작은 recommended_place_id)
+ // - voterCnt: 해당 장소(googlePlaceId 기준)에 투표한 서로 다른 참여자 수
+ // - votedByMe: 조회 유저가 해당 장소에 투표했으면 1
@Query("""
select new com.moive.MoiveBE.domain.vote.dto.PlaceVoteSummary(
min(rp.id),
@@ -32,7 +37,9 @@ select count(distinct pv.participantId)
)
from PlaceVote pv
join RecommendedPlace rp on pv.recommendedPlaceId = rp.id
+ join Participant p on pv.participantId = p.id
where pv.meetingId = :meetingId
+ and p.leftAt is null
group by rp.googlePlaceId
""")
List aggregateByPlace(
diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java b/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java
new file mode 100644
index 0000000..2535904
--- /dev/null
+++ b/src/main/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseService.java
@@ -0,0 +1,98 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationStatus;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 장소 투표 마감 배치
+ * - 장소 투표 마감 기한이 지났는데도 전원이 투표를 마치지 못한 모임 확정
+ * - 매일 MeetingLifecycleScheduler에서 호출됨
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class PlaceVoteCloseService {
+
+ private final MeetingRepository meetingRepository;
+ private final RecommendationRunRepository recommendationRunRepository;
+ private final VoteService voteService;
+
+ @Value("${place-vote.deadline-days}")
+ private int placeVoteDeadlineDays;
+
+ // 트랜잭션 처리 x: 모임별 트랜잭션(VoteService.finalizeExpiredPlaceVote)으로 실패를 격리하기 위함
+ public void finalizeExpiredPlaceVotes() {
+ long startMillis = System.currentTimeMillis();
+ LocalDateTime deadline = LocalDateTime.now().minusDays(placeVoteDeadlineDays);
+
+ List targetMeetingIds = findExpiredVotingMeetingIds(deadline);
+ log.info("[장소 투표 마감 배치] 시작 - 대상 {}건 (마감 기준 시각={}, 대상 meetingId={})",
+ targetMeetingIds.size(), deadline, targetMeetingIds);
+
+ int confirmedCnt = 0;
+ int skippedCnt = 0;
+ List failedMeetingIds = new ArrayList<>();
+
+ for (Long meetingId : targetMeetingIds) {
+ try {
+ if (voteService.finalizeExpiredPlaceVote(meetingId)) {
+ confirmedCnt++;
+ } else {
+ skippedCnt++;
+ }
+ } catch (Exception e) {
+ failedMeetingIds.add(meetingId);
+ log.error("[장소 투표 마감 배치] 모임 확정 실패 - 다음 실행에서 재시도 예정 (meetingId={})", meetingId, e);
+ }
+ }
+
+ long elapsedMillis = System.currentTimeMillis() - startMillis;
+ if (failedMeetingIds.isEmpty()) {
+ log.info("[장소 투표 마감 배치] 완료 - 대상 {}건, 확정 {}건, 건너뜀 {}건, 실패 0건, 소요 {}ms",
+ targetMeetingIds.size(), confirmedCnt, skippedCnt, elapsedMillis);
+ } else {
+ log.warn("[장소 투표 마감 배치] 완료(실패 있음) - 대상 {}건, 확정 {}건, 건너뜀 {}건, 실패 {}건 (실패 meetingId={}), 소요 {}ms",
+ targetMeetingIds.size(), confirmedCnt, skippedCnt, failedMeetingIds.size(), failedMeetingIds, elapsedMillis);
+ }
+ }
+
+ // 마감 기한이 지난 장소 투표 진행 중(VOTING) 모임 ID 목록
+ private List findExpiredVotingMeetingIds(LocalDateTime deadline) {
+ List votingMeetingIds = meetingRepository.findAllByStatus(MeetingStatus.VOTING).stream()
+ .map(Meeting::getId)
+ .toList();
+ if (votingMeetingIds.isEmpty()) {
+ return List.of();
+ }
+
+ Map placeVoteStartedAtByMeetingId = recommendationRunRepository
+ .findAllByMeetingIdInAndStatus(votingMeetingIds, RecommendationStatus.COMPLETED)
+ .stream()
+ .collect(Collectors.toMap(
+ RecommendationRun::getMeetingId,
+ RecommendationRun::getUpdatedAt,
+ (earlier, later) -> earlier.isAfter(later) ? earlier : later
+ ));
+
+ return votingMeetingIds.stream()
+ .filter(meetingId -> {
+ LocalDateTime placeVoteStartedAt = placeVoteStartedAtByMeetingId.get(meetingId);
+ return placeVoteStartedAt != null && !placeVoteStartedAt.isAfter(deadline);
+ })
+ .toList();
+ }
+}
diff --git a/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java b/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java
index c061669..7cc7d65 100644
--- a/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java
+++ b/src/main/java/com/moive/MoiveBE/domain/vote/service/VoteService.java
@@ -28,12 +28,11 @@
import com.moive.MoiveBE.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
-import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
@@ -66,14 +65,9 @@ public class VoteService {
private final AreaDistanceService areaDistanceService;
private final NotificationService notificationService;
- @Value("${place-vote.deadline-days}")
- private int placeVoteDeadlineDays;
-
/**
* 일정 투표 현황 조회
- * - 모임 생성 시 일정을 확정하지 않은 경우, 일정 투표 기반으로 모임 일정 확정
*/
- @Transactional
public DateVoteResultResponse getMeetingScheduleVoteResult(Long userId, Long meetingId) {
// 모임 조회
Meeting meeting = meetingRepository.findById(meetingId)
@@ -113,9 +107,6 @@ public DateVoteResultResponse getMeetingScheduleVoteResult(Long userId, Long mee
List topDates = dateVoteRepository
.aggregateTopDates(meetingId, participant.getId(), validParticipantIds, PageRequest.of(0, TOP_N));
- // - 집계 결과 1위 일정을 모임 일정으로 확정
- confirmScheduleWithTopDate(meeting, topDates);
-
List candidates = topDates.stream()
.map(this::toCandidate)
.toList();
@@ -162,7 +153,7 @@ private DateVoteResultResponse.Candidate toCandidate(DateVoteSummary aggregate)
@Transactional
public void createPlaceVote(Long userId, Long meetingId, PlaceVoteRequest request) {
// 모임 조회
- Meeting meeting = meetingRepository.findById(meetingId)
+ Meeting meeting = meetingRepository.findByIdForUpdate(meetingId)
.orElseThrow(() -> new CustomException(MEETING_NOT_FOUND));
// 모임 진행 상황 검증
@@ -214,22 +205,47 @@ public void createPlaceVote(Long userId, Long meetingId, PlaceVoteRequest reques
placeVoteRepository.saveAll(placeVotes);
participant.completeVote();
- // 마지막 투표자인 경우 => 득표 집계 결과 1위 장소를 모임 장소로 확정
- // NEW_RESTRICTED(투표 불가 신규 참여자) 제외한 투표 가능 인원과 비교
- long voterCnt = placeVoteRepository.countDistinctVoters(meetingId);
- long eligibleCnt = participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(
- meetingId, ParticipantState.NEW_RESTRICTED);
- if (voterCnt == eligibleCnt) {
- confirmMeetingPlace(meeting);
+ // 마지막 투표자인 경우 => 모임 일정 및 장소 확정
+ confirmMeetingIfAllVoted(meeting);
+ }
+
+ /**
+ * 투표 가능 참여자가 모두 장소 투표를 마친 경우 => 모임 확정 (일정, 장소)
+ * - 장소 투표(마지막 투표자), 모임 나가기(마지막 미투표자가 나간 경우)에서 공통으로 사용
+ * - 장소 투표 단계(VOTING + 장소 추천 완료)에서만 판단
+ * - 투표 가능 참여자(모임 나가지 않음 + 신규 참여(NEW_RESTRICTED) 제외) 중 미투표자가 없으면 확정
+ * (투표 가능 참여자가 모두 나가 신규 참여자만 남은 경우도 확정 → 유효한 표가 없으면 장소는 null)
+ */
+ @Transactional
+ public void confirmMeetingIfAllVoted(Meeting meeting) {
+ if (meeting.getStatus() != MeetingStatus.VOTING) {
+ return;
+ }
+
+ boolean isRecommendationCompleted = recommendationRunRepository
+ .findTopByMeetingIdAndStatusOrderByCreatedAtDesc(meeting.getId(), RecommendationStatus.COMPLETED)
+ .isPresent();
+ if (!isRecommendationCompleted) {
+ return;
+ }
+
+ if (participantRepository.countParticipantsYetToVote(meeting.getId(), ParticipantState.NEW_RESTRICTED) == 0) {
+ confirmMeeting(meeting);
}
}
- // 모임 장소 확정
- private void confirmMeetingPlace(Meeting meeting) {
+ // 모임 확정: 일정(미확정인 경우) 및 장소 확정 -> MeetingStatus.CONFIRMED로 전환
+ private void confirmMeeting(Meeting meeting) {
+ // 일정 확정
+ confirmScheduleIfNotConfirmed(meeting);
+
+ // 장소 확정
List ranked = rankCandidates(meeting.getId(), NO_VIEWER_PARTICIPANT_ID);
Long confirmedPlaceId = ranked.isEmpty() ? null : ranked.get(0).recommendedPlaceId();
meeting.confirmPlace(confirmedPlaceId);
- log.info("[장소 투표] 마지막 투표자 완료 => 장소 확정 (meetingId={}, confirmedPlaceId={})", meeting.getConfirmedPlaceId(), confirmedPlaceId);
+
+ log.info("[모임 확정] meetingId={}, scheduledDate={}, scheduledTime={}, confirmedPlaceId={}",
+ meeting.getId(), meeting.getScheduledDate(), meeting.getScheduledTime(), confirmedPlaceId);
// NOTI-004: 장소 확정 알림 (전체 참여자) + 참여자 상태 CONFIRMED 업데이트
// 아무도 투표하지 않아 confirmedPlaceId가 없는 경우, 장소 확정 알림은 생략
@@ -245,42 +261,24 @@ private void confirmMeetingPlace(Meeting meeting) {
}
/**
- * 장소 투표 마감 기한이 지났는데도 전원이 투표를 마치지 못한 모임 처리
- * - Case A) 1명 이상 투표했다면 그때까지의 집계 결과로 장소 확정 o + 모임 상태 CONFIRMED로 전환
- * - Case B) 아무도 투표하지 않았다면 장소 확정 x + 모임 상태 CONFIRMED로 전환
- * - 매일 MeetingLifecycleScheduler에서 호출됨
+ * 장소 투표 마감 처리
+ * - 장소 투표 마감 배치(PlaceVoteCloseService)에서 모임별로 호출
+ * - Case A) 1명 이상 투표했다면 집계 1위 장소로 확정
+ * - Case B) 아무도 투표하지 않았다면 장소 없이(null) 확정
*/
- @Transactional
- public void finalizeExpiredPlaceVotes() {
- List votingMeetings = meetingRepository.findAllByStatus(MeetingStatus.VOTING);
- if(votingMeetings.isEmpty()) {
- return;
- }
-
- // 모임별 장소 추천이 완료된 시각을 장소 투표 시작 시점으로 지정
- List meetingIds = votingMeetings.stream().map(Meeting::getId).toList();
- Map placeVoteStartedAtByMeetingId = recommendationRunRepository
- .findAllByMeetingIdInAndStatus(meetingIds, RecommendationStatus.COMPLETED)
- .stream()
- .collect(Collectors.toMap(
- RecommendationRun::getMeetingId,
- RecommendationRun::getUpdatedAt,
- (earlier, later) -> earlier.isAfter(later) ? earlier : later
- ));
-
- LocalDateTime deadline = LocalDateTime.now().minusDays(placeVoteDeadlineDays);
-
- for (Meeting meeting : votingMeetings) {
- LocalDateTime placeVoteStartedAt = placeVoteStartedAtByMeetingId.get(meeting.getId());
-
- // 장소 투표 단계가 아니거나 마감 기한이 지나지 않았으면 스킵
- if(placeVoteStartedAt == null || placeVoteStartedAt.isAfter(deadline)) {
- continue;
- }
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean finalizeExpiredPlaceVote(Long meetingId) {
+ Meeting meeting = meetingRepository.findByIdForUpdate(meetingId)
+ .orElseThrow(() -> new CustomException(MEETING_NOT_FOUND));
- confirmScheduleIfNotConfirmed(meeting);
- confirmMeetingPlace(meeting);
+ // 타겟 선정 후 락을 잡기 전까지 마지막 투표 및 모임 나가기로 확정(CONFIRMED)/종료(COMPLETED) 처리된 게 있는지 재확인
+ if (meeting.getStatus() != MeetingStatus.VOTING) {
+ log.info("[장소 투표 마감 배치] 투표 진행 중(VOTING)이 아닌 모임 건너뜀 (meetingId={}, status={})", meetingId, meeting.getStatus());
+ return false;
}
+
+ confirmMeeting(meeting);
+ return true;
}
// 일정 미확정 모임의 일정 확정
diff --git a/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java b/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java
index 8a57eec..f0af3d6 100644
--- a/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java
+++ b/src/main/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleScheduler.java
@@ -1,9 +1,11 @@
package com.moive.MoiveBE.global.scheduler;
import com.moive.MoiveBE.domain.meeting.service.MeetingService;
-import com.moive.MoiveBE.domain.vote.service.VoteService;
+import com.moive.MoiveBE.domain.vote.service.PlaceVoteCloseService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -11,13 +13,18 @@
/**
* 모임/투표 상태 처리 배치
+ * - 매일 자정: 장소 투표 마감(VOTING -> CONFIRMED) → 모임 종료(CONFIRMED -> COMPLETED)
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class MeetingLifecycleScheduler {
- private final VoteService voteService;
+ // 모임 종료 처리 최대 시도 횟수, 재시도 전 대기 시간(ms)
+ private static final int COMPLETE_MAX_ATTEMPTS = 3;
+ private long[] completeRetryBackoffMillis = {1_000L, 3_000L};
+
+ private final PlaceVoteCloseService placeVoteCloseService;
private final MeetingService meetingService;
@Scheduled(cron = "${meeting.daily-batch-cron}")
@@ -27,15 +34,61 @@ public void run() {
log.info("[모임/투표 배치] 시작 - 시작 시각: {}", startDateTime);
try {
- voteService.finalizeExpiredPlaceVotes();
- meetingService.completeElapsedMeetings();
- } catch (Exception e) {
- log.error("[모임/투표 배치] 실행 중 오류 발생", e);
- throw e;
+ // 장소 투표 마감 처리
+ closeExpiredPlaceVotes();
+ // 모임 종료 처리
+ completeElapsedMeetings();
} finally {
LocalDateTime endTime = LocalDateTime.now();
long duration = System.currentTimeMillis() - startMillis;
log.info("[모임/투표 배치] 종료 - 종료 시각: {}, 소요 시간: {}ms", endTime, duration);
}
}
+
+ // 장소 투표 마감 처리
+ private void closeExpiredPlaceVotes() {
+ try {
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+ } catch (Exception e) {
+ log.error("[모임/투표 배치] 장소 투표 마감 처리 실패", e);
+ }
+ }
+
+ // 모임 종료 처리
+ private void completeElapsedMeetings() {
+ for (int attempt = 1; attempt <= COMPLETE_MAX_ATTEMPTS; attempt++) {
+ try {
+ meetingService.completeElapsedMeetings();
+ return;
+ } catch (Exception e) {
+ if (attempt == COMPLETE_MAX_ATTEMPTS) {
+ log.error("[모임/투표 배치] 모임 종료 처리 최종 실패 ({}/{}회 시도)",
+ attempt, COMPLETE_MAX_ATTEMPTS, e);
+ return;
+ }
+ log.warn("[모임/투표 배치] 모임 종료 처리 실패 ({}/{}회 시도, 재시도 예정)", attempt, COMPLETE_MAX_ATTEMPTS, e);
+ if (!sleepBeforeRetry(completeRetryBackoffMillis[attempt - 1])) {
+ return;
+ }
+ }
+ }
+ }
+
+ // 서버 시작 시 모임 종료 처리 실행
+ @EventListener(ApplicationReadyEvent.class)
+ public void completeElapsedMeetingsOnStartup() {
+ log.info("[모임/투표 배치] 서버 시작 - 누락된 모임 종료 처리 확인");
+ completeElapsedMeetings();
+ }
+
+ private boolean sleepBeforeRetry(long millis) {
+ try {
+ Thread.sleep(millis);
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn("[모임/투표 배치] 모임 종료 처리 재시도 대기 중 중단됨");
+ return false;
+ }
+ }
}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java
new file mode 100644
index 0000000..aaaa095
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/MeetingRepositoryTest.java
@@ -0,0 +1,80 @@
+package com.moive.MoiveBE.domain.meeting.repository;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@TestPropertySource(properties = {
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.sql.init.mode=never"
+})
+class MeetingRepositoryTest {
+
+ @Autowired
+ private MeetingRepository meetingRepository;
+
+ @Autowired
+ private TestEntityManager em;
+
+ private static final LocalDate TODAY = LocalDate.of(2026, 10, 10);
+
+ /**
+ * updateStatusByStatusAndScheduledDateBefore (모임 종료 배치)
+ */
+
+ @Test
+ void 모임_일정이_기준_날짜_이전인_확정_모임만_상태를_변경한다() {
+ // given
+ Long elapsed = meeting(MeetingStatus.CONFIRMED, TODAY.minusDays(1)); // 어제 일정
+ Long today = meeting(MeetingStatus.CONFIRMED, TODAY); // 오늘 일정은 아직 종료 x
+ Long voting = meeting(MeetingStatus.VOTING, TODAY.minusDays(1)); // 투표 중인 모임은 대상 x
+ Long noSchedule = meeting(MeetingStatus.CONFIRMED, null); // 일정 없이 확정된 모임은 대상 x
+
+ LocalDateTime updatedAt = LocalDateTime.of(2026, 10, 10, 0, 0);
+
+ // when
+ int updated = meetingRepository.updateStatusByStatusAndScheduledDateBefore(
+ MeetingStatus.CONFIRMED, MeetingStatus.COMPLETED, TODAY, updatedAt);
+ em.clear();
+
+ // then
+ assertThat(updated).isEqualTo(1);
+ assertThat(statusOf(elapsed)).isEqualTo(MeetingStatus.COMPLETED);
+
+ // 벌크 UPDATE는 @PreUpdate를 거치지 않으므로 쿼리에서 직접 갱신한 updatedAt이 반영되어야 함
+ assertThat(meetingRepository.findById(elapsed).orElseThrow().getUpdatedAt()).isEqualTo(updatedAt);
+ assertThat(statusOf(today)).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(statusOf(voting)).isEqualTo(MeetingStatus.VOTING);
+ assertThat(statusOf(noSchedule)).isEqualTo(MeetingStatus.CONFIRMED);
+ }
+
+ private Long meeting(MeetingStatus status, LocalDate scheduledDate) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", scheduledDate,
+ scheduledDate != null ? LocalTime.NOON : null, "test-invite-code");
+ if (status != MeetingStatus.CONDITION_INPUT) {
+ meeting.transitionToVoting();
+ }
+ if (status == MeetingStatus.CONFIRMED) {
+ meeting.confirmPlace(100L);
+ }
+ Long meetingId = em.persistAndFlush(meeting).getId();
+ em.clear();
+ return meetingId;
+ }
+
+ private MeetingStatus statusOf(Long meetingId) {
+ return meetingRepository.findById(meetingId).orElseThrow().getStatus();
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java
new file mode 100644
index 0000000..cc4ece7
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/repository/ParticipantRepositoryTest.java
@@ -0,0 +1,146 @@
+package com.moive.MoiveBE.domain.meeting.repository;
+
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.vote.entity.PlaceVote;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
+import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
+import org.springframework.test.context.TestPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@TestPropertySource(properties = {
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.sql.init.mode=never"
+})
+class ParticipantRepositoryTest {
+
+ @Autowired
+ private ParticipantRepository participantRepository;
+
+ @Autowired
+ private TestEntityManager em;
+
+ private static final Long MEETING_ID = 1L;
+ private static final Long OTHER_MEETING_ID = 99L;
+ private static final Long PLACE_ID = 1000L;
+
+ /**
+ * countParticipantsYetToVote
+ */
+
+ @Test
+ void 투표_가능_참여자_중_투표하지_않은_인원만_센다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+
+ participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+ participant(MEETING_ID, 3L, ParticipantState.COND_DONE);
+ vote(MEETING_ID, a);
+
+ assertThat(countParticipantsYetToVote()).isEqualTo(2);
+ }
+
+ @Test
+ void 투표_가능_참여자가_모두_투표했으면_0을_반환한다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+ Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+
+ vote(MEETING_ID, a);
+ vote(MEETING_ID, b);
+
+ assertThat(countParticipantsYetToVote()).isZero();
+ }
+
+ @Test
+ void 여러_장소에_투표한_참여자도_한_명의_투표자로_취급한다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+
+ participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+ vote(MEETING_ID, a, PLACE_ID);
+ vote(MEETING_ID, a, PLACE_ID + 1);
+
+ assertThat(countParticipantsYetToVote()).isEqualTo(1);
+ }
+
+ @Test
+ void 투표하고_나간_참여자는_남은_미투표자_수에_영향을_주지_않는다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+ Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+ Long c = participant(MEETING_ID, 3L, ParticipantState.COND_DONE);
+ participant(MEETING_ID, 4L, ParticipantState.COND_DONE);
+
+ vote(MEETING_ID, a);
+ leave(a);
+ vote(MEETING_ID, b);
+ vote(MEETING_ID, c);
+
+ // D는 미투표 상태이므로 확정 대상 아님
+ assertThat(countParticipantsYetToVote()).isEqualTo(1);
+ }
+
+ @Test
+ void 투표한_참여자가_나간_뒤_남은_인원이_모두_투표하면_0을_반환한다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+ Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+ Long c = participant(MEETING_ID, 3L, ParticipantState.COND_DONE);
+ Long d = participant(MEETING_ID, 4L, ParticipantState.COND_DONE);
+
+ vote(MEETING_ID, a);
+ vote(MEETING_ID, b);
+ vote(MEETING_ID, c);
+ leave(a);
+ vote(MEETING_ID, d);
+
+ assertThat(countParticipantsYetToVote()).isZero();
+ }
+
+ @Test
+ void 투표하지_않고_나간_참여자는_세지_않는다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+ Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+
+ vote(MEETING_ID, a);
+ leave(b);
+
+ assertThat(countParticipantsYetToVote()).isZero();
+ }
+
+ @Test
+ void 나갔다가_다시_입장한_참여자는_이전_투표와_무관하게_신규_참여자로_제외된다() {
+ Long a = participant(MEETING_ID, 1L, ParticipantState.COND_DONE);
+ Long b = participant(MEETING_ID, 2L, ParticipantState.COND_DONE);
+
+ vote(MEETING_ID, a);
+ leave(a);
+ participant(MEETING_ID, 1L, ParticipantState.NEW_RESTRICTED); // A 재입장
+ vote(MEETING_ID, b);
+
+ assertThat(countParticipantsYetToVote()).isZero();
+ }
+
+ private long countParticipantsYetToVote() {
+ return participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED);
+ }
+
+ private Long participant(Long meetingId, Long userId, ParticipantState state) {
+ return em.persistAndFlush(Participant.create(meetingId, userId, state)).getId();
+ }
+
+ private void leave(Long participantId) {
+ Participant participant = em.find(Participant.class, participantId);
+ participant.leave();
+ em.flush();
+ }
+
+ private void vote(Long meetingId, Long participantId) {
+ vote(meetingId, participantId, PLACE_ID);
+ }
+
+ private void vote(Long meetingId, Long participantId, Long recommendedPlaceId) {
+ em.persistAndFlush(PlaceVote.create(meetingId, participantId, recommendedPlaceId));
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java
new file mode 100644
index 0000000..3f89e9d
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingCompleteLeaveConcurrencyTest.java
@@ -0,0 +1,107 @@
+package com.moive.MoiveBE.domain.meeting.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.vote.service.VoteService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+
+/**
+ * [모임 종료 배치 ↔ 모임 나가기 API] 동시성 테스트
+ * - '모임 나가기' 커밋 전 모임 종료 배치가 실행될 때, '모임 나가기'의 변경 사항이 덮어써지지 않는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:meeting-complete;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class MeetingCompleteLeaveConcurrencyTest {
+
+ private static final long PAUSE_MILLIS = 300;
+
+ @Autowired private MeetingService meetingService;
+ @Autowired private MeetingLeaveService meetingLeaveService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+
+ @MockitoBean private VoteService voteService;
+
+ @AfterEach
+ void tearDown() {
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 모임_나가기가_커밋되기_전에_종료_배치가_실행돼도_나가기의_변경이_유지된다() throws Exception {
+ // given: 일정이 어제인 확정 모임, 모임장 A(userId 1) + B(userId 2)
+ Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().minusDays(1), LocalTime.NOON, "test-invite-code");
+ meeting.incrementParticipantCnt();
+ meeting.transitionToVoting();
+ meeting.confirmPlace(100L);
+ Long meetingId = meetingRepository.save(meeting).getId();
+ participantRepository.save(Participant.create(meetingId, 1L, ParticipantState.CONFIRMED));
+ participantRepository.save(Participant.create(meetingId, 2L, ParticipantState.CONFIRMED));
+
+ CountDownLatch leaving = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ leaving.countDown();
+ Thread.sleep(PAUSE_MILLIS);
+ return null;
+ }).when(voteService).confirmMeetingIfAllVoted(any());
+
+ // when: 모임장 A가 나가는 중(인원 감소 + 모임장 B로 승계, 커밋 전)에 종료 배치 실행
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future> leave = executor.submit(() -> {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken(1L, null, List.of()));
+ try {
+ meetingLeaveService.leaveMeeting(meetingId);
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
+ });
+ assertThat(leaving.await(5, TimeUnit.SECONDS)).isTrue(); // 모임 나가기가 '모임 수정 후~커밋 전' 상태가 될 때까지 대기
+ Future> complete = executor.submit(() -> meetingService.completeElapsedMeetings());
+ leave.get(10, TimeUnit.SECONDS);
+ complete.get(10, TimeUnit.SECONDS);
+ executor.shutdown();
+
+ // then: 모임은 종료되고, 나가기의 변경(인원 감소, 모임장 승계)도 유지됨
+ Meeting result = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(result.getStatus()).isEqualTo(MeetingStatus.COMPLETED);
+ assertThat(result.getParticipantCnt()).isEqualTo(1);
+ assertThat(result.getCreatorUserId()).isEqualTo(2L);
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java
index dfc5d5f..7ddba6a 100644
--- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveServiceTest.java
@@ -6,6 +6,7 @@
import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.vote.service.VoteService;
import com.moive.MoiveBE.global.exception.CustomErrorCode;
import com.moive.MoiveBE.global.exception.CustomException;
import org.junit.jupiter.api.AfterEach;
@@ -30,6 +31,8 @@ class MeetingLeaveServiceTest {
@Mock private MeetingRepository meetingRepository;
@Mock private ParticipantRepository participantRepository;
+ @Mock private MeetingService meetingService;
+ @Mock private VoteService voteService;
private MeetingLeaveService meetingLeaveService;
private static final Long CURRENT_USER_ID = 1L;
@@ -37,7 +40,7 @@ class MeetingLeaveServiceTest {
@BeforeEach
void setUp() {
- meetingLeaveService = new MeetingLeaveService(meetingRepository, participantRepository);
+ meetingLeaveService = new MeetingLeaveService(meetingRepository, participantRepository, meetingService, voteService);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
CURRENT_USER_ID, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
@@ -57,7 +60,7 @@ void tearDown() {
Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false);
Participant other = mockParticipant(2L, ParticipantState.COND_PENDING, false);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID))
.thenReturn(Optional.of(me));
when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID))
@@ -77,7 +80,7 @@ void tearDown() {
Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_DONE, true);
Participant other = mockParticipant(2L, ParticipantState.COND_PENDING, false);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID))
.thenReturn(Optional.of(me));
when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID))
@@ -94,7 +97,7 @@ void tearDown() {
Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false);
Participant next = mockParticipant(2L, ParticipantState.COND_PENDING, false);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID))
.thenReturn(Optional.of(me));
when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID))
@@ -110,7 +113,7 @@ void tearDown() {
Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.CONDITION_INPUT, CURRENT_USER_ID);
Participant me = mockParticipant(CURRENT_USER_ID, ParticipantState.COND_PENDING, false);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID))
.thenReturn(Optional.of(me));
when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID))
@@ -125,7 +128,7 @@ void tearDown() {
@Test
void 존재하지_않는_모임이면_MEETING_NOT_FOUND_예외가_발생한다() {
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.empty());
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> meetingLeaveService.leaveMeeting(MEETING_ID))
.isInstanceOf(CustomException.class)
@@ -136,7 +139,7 @@ void tearDown() {
@Test
void COMPLETED_모임에서_나가려하면_CANNOT_LEAVE_COMPLETED_MEETING_예외가_발생한다() {
Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.COMPLETED, CURRENT_USER_ID);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
assertThatThrownBy(() -> meetingLeaveService.leaveMeeting(MEETING_ID))
.isInstanceOf(CustomException.class)
@@ -149,7 +152,7 @@ void tearDown() {
@Test
void 모임_참여자가_아니면_NOT_A_PARTICIPANT_예외가_발생한다() {
Meeting meeting = mockMeeting(MEETING_ID, MeetingStatus.CONDITION_INPUT, 99L);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(MEETING_ID, CURRENT_USER_ID))
.thenReturn(Optional.empty());
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java
new file mode 100644
index 0000000..73012f8
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingLeaveStartVotingTest.java
@@ -0,0 +1,123 @@
+package com.moive.MoiveBE.domain.meeting.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.service.AreaRecommendationService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * [모임 나가기 API] 모임 나가기 후 조건 입력 완료 여부 재판정 테스트
+ * - 조건 입력 단계에서 참여자가 나가 남은 참여자가 모두 조건 입력을 마친 상태가 되면 => VOTING 전환 + 장소 추천 시작 여부 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:leave-start-voting;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class MeetingLeaveStartVotingTest {
+
+ @Autowired private MeetingLeaveService meetingLeaveService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+
+ @MockitoBean private AreaRecommendationService areaRecommendationService;
+ @MockitoBean private NotificationService notificationService;
+
+ @AfterEach
+ void tearDown() {
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 조건을_입력하지_않은_마지막_참여자가_나가면_장소_투표가_시작된다() {
+ // given: A, B는 조건 입력 완료, C는 미입력
+ Long meetingId = conditionInputMeeting(List.of(1L, 2L), List.of(3L));
+
+ // when: C가 모임을 나감
+ asUser(3L, () -> meetingLeaveService.leaveMeeting(meetingId));
+
+ // then: 남은 A, B가 모두 조건 입력을 마친 상태 => 장소 투표 단계로 전환, 장소 추천 한 번 시작
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING);
+ assertThat(meeting.getParticipantCnt()).isEqualTo(2);
+ assertThat(meeting.getSubmittedCnt()).isEqualTo(2);
+ verify(areaRecommendationService, times(1)).recommend(meetingId);
+ }
+
+ @Test
+ void 조건을_입력한_참여자가_나가면_미입력자가_남아있으므로_장소_투표가_시작되지_않는다() {
+ // given: A, B는 조건 입력 완료, C는 미입력
+ Long meetingId = conditionInputMeeting(List.of(1L, 2L), List.of(3L));
+
+ // when: 조건을 입력한 B가 모임을 나감
+ asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId));
+
+ // then: C가 아직 미입력 => 조건 입력 단계 유지, 장소 추천 시작 x
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONDITION_INPUT);
+ assertThat(meeting.getParticipantCnt()).isEqualTo(2);
+ assertThat(meeting.getSubmittedCnt()).isEqualTo(1);
+ verify(areaRecommendationService, never()).recommend(anyLong());
+ }
+
+ private Long conditionInputMeeting(List submittedUserIds, List pendingUserIds) {
+ Meeting meeting = Meeting.create(submittedUserIds.get(0), "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, "test-invite-code");
+ for (int i = 1; i < submittedUserIds.size() + pendingUserIds.size(); i++) {
+ meeting.incrementParticipantCnt();
+ }
+ submittedUserIds.forEach(userId -> meeting.incrementSubmittedCnt());
+ Long meetingId = meetingRepository.save(meeting).getId();
+
+ for (Long userId : submittedUserIds) {
+ Participant participant = Participant.create(meetingId, userId, ParticipantState.COND_PENDING);
+ participant.completeCondition();
+ participantRepository.save(participant);
+ }
+ for (Long userId : pendingUserIds) {
+ participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_PENDING));
+ }
+ return meetingId;
+ }
+
+ private void asUser(Long userId, Runnable task) {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of()));
+ try {
+ task.run();
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java
new file mode 100644
index 0000000..79bfdba
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingPreferenceSubmitConcurrencyTest.java
@@ -0,0 +1,214 @@
+package com.moive.MoiveBE.domain.meeting.service;
+
+import com.moive.MoiveBE.domain.meeting.dto.SubmitPreferenceRequest;
+import com.moive.MoiveBE.domain.meeting.entity.ActivityType;
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.ActivityRepository;
+import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantPreferenceRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.meeting.repository.PreferenceActivityRepository;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.service.AreaRecommendationService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * [조건 제출 API] 동시성 테스트
+ * - 여러 참여자가 동시에 조건을 제출해도 제출 인원이 정확히 집계되고, VOTING 전환 및 장소 추천이 한 번만 일어나는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:preference-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class MeetingPreferenceSubmitConcurrencyTest {
+
+ private static final String INVITE_CODE = "test-invite-code";
+
+ @Autowired private MeetingService meetingService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private ParticipantPreferenceRepository participantPreferenceRepository;
+ @Autowired private PreferenceActivityRepository preferenceActivityRepository;
+ @Autowired private DateVoteRepository dateVoteRepository;
+ @Autowired private ActivityRepository activityRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+
+ @MockitoBean private AreaRecommendationService areaRecommendationService;
+ @MockitoBean private NotificationService notificationService;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate.update("insert into activities (name) values (?)", ActivityType.CAFE_DESSERT.name());
+ }
+
+ @AfterEach
+ void tearDown() {
+ preferenceActivityRepository.deleteAllInBatch();
+ participantPreferenceRepository.deleteAllInBatch();
+ dateVoteRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ activityRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 참여자_전원이_동시에_조건을_제출해도_제출_인원이_정확하고_장소_추천은_한_번만_시작된다() throws Exception {
+ // given: 참여자 5명 모두 조건 미제출
+ int participantCnt = 5;
+ Long meetingId = conditionInputMeetingWithParticipants(participantCnt);
+
+ // when: 5명이 동시에 조건 제출
+ List> results = submitConcurrently(meetingId, List.of(1L, 2L, 3L, 4L, 5L));
+ for (Future> result : results) {
+ result.get(10, TimeUnit.SECONDS);
+ }
+
+ // then: 제출 인원: 5명, 모임: VOTING, 장소 추천: 한 번만 시작
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getSubmittedCnt()).isEqualTo(participantCnt);
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING);
+ verify(areaRecommendationService, times(1)).recommend(meetingId);
+
+ assertThat(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingId))
+ .extracting(Participant::getState)
+ .containsOnly(ParticipantState.COND_DONE);
+ assertThat(participantPreferenceRepository.count()).isEqualTo(participantCnt);
+ }
+
+ @Test
+ void 마지막_제출자가_조건을_동시에_두_번_제출해도_제출_인원은_한_번만_증가하고_장소_추천은_한_번만_시작된다() throws Exception {
+ // given: 참여자 3명 중 2명은 조건 제출 완료, 3번 유저만 미제출
+ Long meetingId = conditionInputMeetingWithParticipants(3);
+ submitConcurrently(meetingId, List.of(1L, 2L)).forEach(this::await);
+
+ // when: 3번 유저가 같은 조건을 동시에 두 번 제출
+ List> results = submitConcurrently(meetingId, List.of(3L, 3L));
+ long succeeded = results.stream().filter(this::succeeded).count();
+
+ // then: 한 요청만 반영 (제출 인원: 3명, 3번 유저의 선호 조건: 1건, 장소 추천: 한 번만 시작)
+ assertThat(succeeded).isEqualTo(1);
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getSubmittedCnt()).isEqualTo(3);
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.VOTING);
+ verify(areaRecommendationService, times(1)).recommend(meetingId);
+ assertThat(participantPreferenceRepository.count()).isEqualTo(3);
+ }
+
+ private Long conditionInputMeetingWithParticipants(int count) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, INVITE_CODE);
+ for (int i = 1; i < count; i++) {
+ meeting.incrementParticipantCnt();
+ }
+ Long meetingId = meetingRepository.save(meeting).getId();
+ for (long userId = 1; userId <= count; userId++) {
+ participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_PENDING));
+ }
+ return meetingId;
+ }
+
+ private List> submitConcurrently(Long meetingId, List userIds) throws InterruptedException {
+ ExecutorService executor = Executors.newFixedThreadPool(userIds.size());
+ CountDownLatch ready = new CountDownLatch(userIds.size());
+ CountDownLatch start = new CountDownLatch(1);
+
+ List> results = new ArrayList<>();
+ for (Long userId : userIds) {
+ results.add(executor.submit(asUser(userId, () -> {
+ ready.countDown();
+ awaitLatch(start);
+ meetingService.submitPreference(meetingId, preferenceRequest());
+ })));
+ }
+ assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
+ start.countDown();
+ executor.shutdown();
+ assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
+ return results;
+ }
+
+ private SubmitPreferenceRequest preferenceRequest() {
+ return new SubmitPreferenceRequest(
+ null,
+ "합정역",
+ new BigDecimal("37.5540203935757"),
+ new BigDecimal("126.916389124374"),
+ 60,
+ List.of(ActivityType.CAFE_DESSERT)
+ );
+ }
+
+ private Runnable asUser(Long userId, Runnable task) {
+ return () -> {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of()));
+ try {
+ task.run();
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
+ };
+ }
+
+ private void awaitLatch(CountDownLatch latch) {
+ try {
+ latch.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void await(Future> result) {
+ try {
+ result.get(10, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private boolean succeeded(Future> result) {
+ try {
+ result.get(10, TimeUnit.SECONDS);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java
index 28c30e0..2ef9a33 100644
--- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceCompleteTest.java
@@ -1,6 +1,5 @@
package com.moive.MoiveBE.domain.meeting.service;
-import com.moive.MoiveBE.domain.meeting.entity.Meeting;
import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
import com.moive.MoiveBE.domain.meeting.repository.ActivityRepository;
import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository;
@@ -16,11 +15,11 @@
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
-import java.util.List;
+import java.time.LocalDateTime;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -37,45 +36,16 @@ class MeetingServiceCompleteTest {
@InjectMocks private MeetingService meetingService;
@Test
- void 일정이_지난_CONFIRMED_모임을_COMPLETED로_전환한다() {
+ void 일정이_오늘_이전인_CONFIRMED_모임을_COMPLETED로_일괄_변경하고_변경_건수를_반환한다() {
// given
- Meeting elapsed = mock(Meeting.class);
- when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now()))
- .thenReturn(List.of(elapsed));
+ when(meetingRepository.updateStatusByStatusAndScheduledDateBefore(
+ eq(MeetingStatus.CONFIRMED), eq(MeetingStatus.COMPLETED), eq(LocalDate.now()), any(LocalDateTime.class)))
+ .thenReturn(2);
// when
- meetingService.completeElapsedMeetings();
+ int completedCnt = meetingService.completeElapsedMeetings();
// then
- verify(elapsed).complete();
- }
-
- @Test
- void 대상_모임이_없으면_아무것도_하지_않는다() {
- // given
- when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now()))
- .thenReturn(List.of());
-
- // when
- meetingService.completeElapsedMeetings();
-
- // then
- verifyNoInteractions(participantRepository, dateVoteRepository);
- }
-
- @Test
- void 여러_모임을_한번에_COMPLETED로_전환한다() {
- // given
- Meeting a = mock(Meeting.class);
- Meeting b = mock(Meeting.class);
- when(meetingRepository.findAllByStatusAndScheduledDateBefore(MeetingStatus.CONFIRMED, LocalDate.now()))
- .thenReturn(List.of(a, b));
-
- // when
- meetingService.completeElapsedMeetings();
-
- // then
- verify(a).complete();
- verify(b).complete();
+ assertThat(completedCnt).isEqualTo(2);
}
}
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java
index 01fd954..0a090a0 100644
--- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServiceJoinTest.java
@@ -74,7 +74,7 @@ void tearDown() {
// given
Meeting meeting = mockMeeting(1L, MeetingStatus.CONDITION_INPUT, 3);
- when(meetingRepository.findByInviteCode("ABC123XY")).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByInviteCodeForUpdate("ABC123XY")).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L))
.thenReturn(Optional.empty());
@@ -99,7 +99,7 @@ void tearDown() {
// given
Meeting meeting = mockMeeting(1L, MeetingStatus.VOTING, 5);
- when(meetingRepository.findByInviteCode("CODE1234")).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByInviteCodeForUpdate("CODE1234")).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L))
.thenReturn(Optional.empty());
@@ -124,7 +124,7 @@ void tearDown() {
when(existing.getId()).thenReturn(3L);
when(existing.getState()).thenReturn(ParticipantState.COND_PENDING);
- when(meetingRepository.findByInviteCode("ABC123XY")).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByInviteCodeForUpdate("ABC123XY")).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L))
.thenReturn(Optional.of(existing));
@@ -141,7 +141,7 @@ void tearDown() {
@Test
void 유효하지_않은_초대코드면_INVALID_INVITE_CODE_예외가_발생한다() {
- when(meetingRepository.findByInviteCode("WRONGCOD")).thenReturn(Optional.empty());
+ when(meetingRepository.findByInviteCodeForUpdate("WRONGCOD")).thenReturn(Optional.empty());
assertThatThrownBy(() -> meetingService.joinMeeting("WRONGCOD"))
.isInstanceOf(CustomException.class)
@@ -153,7 +153,7 @@ void tearDown() {
void 정원이_가득_찬_모임이면_MEETING_FULL_예외가_발생한다() {
Meeting meeting = mockMeeting(1L, MeetingStatus.CONDITION_INPUT, 10);
- when(meetingRepository.findByInviteCode("FULLCODE")).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByInviteCodeForUpdate("FULLCODE")).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(1L, 2L))
.thenReturn(Optional.empty());
@@ -169,7 +169,7 @@ void tearDown() {
void 완료된_모임이면_MEETING_COMPLETED_예외가_발생한다() {
Meeting meeting = mockMeeting(1L, MeetingStatus.COMPLETED, 5);
- when(meetingRepository.findByInviteCode("DONEXXXX")).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByInviteCodeForUpdate("DONEXXXX")).thenReturn(Optional.of(meeting));
assertThatThrownBy(() -> meetingService.joinMeeting("DONEXXXX"))
.isInstanceOf(CustomException.class)
diff --git a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java
index e492f90..0d57bf7 100644
--- a/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/meeting/service/MeetingServicePreferenceTest.java
@@ -115,7 +115,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
when(savedPref.getId()).thenReturn(100L);
when(activity.getId()).thenReturn(1L);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty());
@@ -145,15 +145,15 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
when(savedPref.getId()).thenReturn(100L);
when(activity.getId()).thenReturn(1L);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty());
when(preferenceRepository.save(any())).thenReturn(savedPref);
when(activityRepository.findByName(any())).thenReturn(Optional.of(activity));
when(meeting.getSubmittedCnt()).thenReturn(2);
- // 첫 호출(상태 체크)=CONDITION_INPUT, 이후 호출(응답 생성)=VOTING
- when(meeting.getStatus()).thenReturn(MeetingStatus.CONDITION_INPUT, MeetingStatus.VOTING);
+ // 조건 입력 가능 여부 확인, 장소 투표 시작 여부 확인=CONDITION_INPUT, 이후 호출(응답 생성)=VOTING
+ when(meeting.getStatus()).thenReturn(MeetingStatus.CONDITION_INPUT, MeetingStatus.CONDITION_INPUT, MeetingStatus.VOTING);
// when
SubmitPreferenceResponse response = meetingService.submitPreference(10L, validRequest(true));
@@ -178,7 +178,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
when(savedPref.getId()).thenReturn(100L);
when(activity.getId()).thenReturn(1L);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.empty());
@@ -204,7 +204,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
when(existingPref.getId()).thenReturn(100L);
when(activity.getId()).thenReturn(1L);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
when(preferenceRepository.findByParticipantId(3L)).thenReturn(Optional.of(existingPref));
@@ -224,7 +224,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
@Test
void 모임이_없으면_MEETING_NOT_FOUND() {
- when(meetingRepository.findById(10L)).thenReturn(Optional.empty());
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> meetingService.submitPreference(10L, validRequest(true)))
.isInstanceOf(CustomException.class)
@@ -235,7 +235,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
@Test
void 모임_상태가_CONDITION_INPUT이_아니면_MEETING_STATUS_INVALID() {
Meeting meeting = mockMeeting(MeetingStatus.VOTING, false, 2, 1);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
assertThatThrownBy(() -> meetingService.submitPreference(10L, validRequest(true)))
.isInstanceOf(CustomException.class)
@@ -246,7 +246,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
@Test
void 모임_참여자가_아니면_NOT_A_PARTICIPANT() {
Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.empty());
@@ -260,7 +260,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
void 출발지가_없으면_DEPARTURE_MISSING() {
Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0);
Participant participant = mockParticipant(false);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
@@ -280,7 +280,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
void 유효하지_않은_이동시간이면_MAX_TRAVEL_MINUTES_INVALID() {
Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0);
Participant participant = mockParticipant(false);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
@@ -303,7 +303,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
void 취향이_없으면_ACTIVITY_TYPES_EMPTY() {
Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0);
Participant participant = mockParticipant(false);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
@@ -326,7 +326,7 @@ private SubmitPreferenceRequest validRequest(boolean includeSchedules) {
void hasSchedule_false인데_일정이_없으면_AVAILABLE_SCHEDULES_EMPTY() {
Meeting meeting = mockMeeting(MeetingStatus.CONDITION_INPUT, false, 2, 0);
Participant participant = mockParticipant(false);
- when(meetingRepository.findById(10L)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(meeting));
when(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(10L, 1L))
.thenReturn(Optional.of(participant));
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java
index 5d1ed56..0de8085 100644
--- a/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/repository/PlaceVoteRepositoryTest.java
@@ -1,5 +1,7 @@
package com.moive.MoiveBE.domain.vote.repository;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
import com.moive.MoiveBE.domain.vote.dto.PlaceVoteSummary;
@@ -33,10 +35,10 @@ class PlaceVoteRepositoryTest {
private static final Long MEETING_ID = 1L;
private static final Long OTHER_MEETING_ID = 99L;
- // 참여자 ID
- private static final Long A = 10L;
- private static final Long B = 20L;
- private static final Long C = 30L;
+ // 참여자
+ private Long A;
+ private Long B;
+ private Long C;
private static final Long RECOMMENDATION_RUN_ID = 1000L;
@@ -46,7 +48,11 @@ class PlaceVoteRepositoryTest {
private Long PLACE_3;
@BeforeEach
- void setUpRecommendedPlaces() {
+ void setUp() {
+ A = participant(MEETING_ID, 1L);
+ B = participant(MEETING_ID, 2L);
+ C = participant(MEETING_ID, 3L);
+
Long areaId = area(RECOMMENDATION_RUN_ID, "합정역");
PLACE_1 = place(areaId, "google-place-1", "카페", 3);
PLACE_2 = place(areaId, "google-place-2", "레스토랑", 2);
@@ -121,6 +127,16 @@ void setUpRecommendedPlaces() {
assertThat(placeVoteRepository.countDistinctVoters(MEETING_ID)).isEqualTo(1);
}
+ @Test
+ void 모임을_나간_참여자의_투표는_투표자_수에서_제외한다() {
+ vote(MEETING_ID, A, PLACE_1);
+ vote(MEETING_ID, B, PLACE_1);
+ vote(MEETING_ID, C, PLACE_2);
+ leave(B);
+
+ assertThat(placeVoteRepository.countDistinctVoters(MEETING_ID)).isEqualTo(2);
+ }
+
/**
* aggregateByPlace
*/
@@ -235,10 +251,60 @@ void setUpRecommendedPlaces() {
assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId).containsExactly(duplicatedPlace1);
}
+ @Test
+ void 모임을_나간_참여자의_투표는_장소별_득표수에서_제외한다() {
+ vote(MEETING_ID, A, PLACE_1);
+ vote(MEETING_ID, B, PLACE_1);
+ vote(MEETING_ID, C, PLACE_2);
+ leave(B);
+
+ List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A);
+
+ assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId, PlaceVoteSummary::voterCnt)
+ .containsExactlyInAnyOrder(
+ tuple(PLACE_1, 1L),
+ tuple(PLACE_2, 1L)
+ );
+ }
+
+ @Test
+ void 모임을_나간_참여자만_투표한_장소는_집계_결과에_나타나지_않는다() {
+ vote(MEETING_ID, A, PLACE_1);
+ vote(MEETING_ID, B, PLACE_2);
+ leave(B);
+
+ List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A);
+
+ assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId).containsExactly(PLACE_1);
+ }
+
+ @Test
+ void 병합된_장소의_대표_id는_남은_참여자가_투표한_추천_장소_중_가장_작은_id다() {
+ // PLACE_1(더 작은 id)에는 나간 참여자만 투표, duplicatedPlace1에는 남은 참여자가 투표
+ Long duplicatedPlace1 = duplicatedPlace("google-place-1");
+ vote(MEETING_ID, B, PLACE_1);
+ vote(MEETING_ID, A, duplicatedPlace1);
+ leave(B);
+
+ List result = placeVoteRepository.aggregateByPlace(MEETING_ID, A);
+
+ assertThat(result).extracting(PlaceVoteSummary::recommendedPlaceId, PlaceVoteSummary::voterCnt)
+ .containsExactly(tuple(duplicatedPlace1, 1L));
+ }
+
/**
* helpers
*/
+ private Long participant(Long meetingId, Long userId) {
+ return em.persistAndFlush(Participant.create(meetingId, userId, ParticipantState.COND_DONE)).getId();
+ }
+
+ private void leave(Long participantId) {
+ em.find(Participant.class, participantId).leave();
+ em.flush();
+ }
+
private Long area(Long recommendationRunId, String areaName) {
return em.persistAndFlush(RecommendedArea.create(recommendationRunId, areaName)).getId();
}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java
new file mode 100644
index 0000000..eb79fdb
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseBatchTest.java
@@ -0,0 +1,148 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.DateVote;
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.notification.entity.NotificationType;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository;
+import com.moive.MoiveBE.domain.vote.entity.PlaceVote;
+import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+
+/**
+ * [장소 투표 마감 배치] 통합 테스트
+ * - PlaceVoteCloseService.finalizeExpiredPlaceVotes() → 모임별 트랜잭션(VoteService.finalizeExpiredPlaceVote) 연결 시
+ * 모임마다 따로 커밋 및 롤백되는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:place-vote-close;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never",
+ "place-vote.deadline-days=0" //마감 대상으로 설정
+})
+class PlaceVoteCloseBatchTest {
+
+ private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3);
+ private static final LocalTime TOP_TIME = LocalTime.of(18, 0);
+
+ @Autowired private PlaceVoteCloseService placeVoteCloseService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private DateVoteRepository dateVoteRepository;
+ @Autowired private PlaceVoteRepository placeVoteRepository;
+ @Autowired private RecommendationRunRepository recommendationRunRepository;
+ @Autowired private RecommendedAreaRepository recommendedAreaRepository;
+ @Autowired private RecommendedPlaceRepository recommendedPlaceRepository;
+
+ @MockitoBean private GooglePlacesClient googlePlacesClient;
+ @MockitoBean private NotificationService notificationService;
+
+ @AfterEach
+ void tearDown() {
+ placeVoteRepository.deleteAllInBatch();
+ dateVoteRepository.deleteAllInBatch();
+ recommendedPlaceRepository.deleteAllInBatch();
+ recommendedAreaRepository.deleteAllInBatch();
+ recommendationRunRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 마감_배치에서_한_모임_처리가_실패해도_다른_모임의_확정은_커밋되고_실패한_모임만_롤백된다() {
+ // given
+ // - A, B, C: 장소 추천 완료 + 장소 투표 1건 → 마감 대상 o
+ // - B: 확정의 마지막 단계(확정 알림 발송)에서 예외 발생
+ // - D: 장소 추천 미완료 → 마감 대상 x
+ Long meetingA = votingMeetingWithOneVote(true);
+ Long meetingB = votingMeetingWithOneVote(true);
+ Long meetingC = votingMeetingWithOneVote(true);
+ Long meetingD = votingMeetingWithOneVote(false);
+ doThrow(new IllegalStateException("확정 알림 저장 실패"))
+ .when(notificationService)
+ .sendNotification(anyLong(), eq(meetingB), eq(NotificationType.MEETING_CONFIRMED), anyString());
+
+ // when: 스케줄러가 호출하는 마감 배치 실행
+ assertThatCode(() -> placeVoteCloseService.finalizeExpiredPlaceVotes()).doesNotThrowAnyException();
+
+ // then
+ // - A, C: 모임 확정 커밋됨 (B의 실패와 무관)
+ assertConfirmed(meetingA);
+ assertConfirmed(meetingC);
+
+ // - B: 예외 전에 수행한 모임 확정까지 모두 롤백 → 다음 실행에서 다시 대상이 됨
+ Meeting b = meetingRepository.findById(meetingB).orElseThrow();
+ assertThat(b.getStatus()).isEqualTo(MeetingStatus.VOTING);
+ assertThat(b.getConfirmedPlaceId()).isNull();
+ assertThat(b.getScheduledDate()).isNull();
+
+ // - D: 장소 투표 단계가 아니므로 처리하지 않음
+ assertThat(meetingRepository.findById(meetingD).orElseThrow().getStatus()).isEqualTo(MeetingStatus.VOTING);
+ }
+
+ private void assertConfirmed(Long meetingId) {
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isNotNull();
+ assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE);
+ assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME);
+ }
+
+ private Long votingMeetingWithOneVote(boolean recommendationCompleted) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code");
+ meeting.transitionToVoting();
+ Long meetingId = meetingRepository.save(meeting).getId();
+
+ Long participantId = participantRepository.save(
+ Participant.create(meetingId, 1L, ParticipantState.VOTE_DONE)).getId();
+ dateVoteRepository.save(DateVote.create(meetingId, participantId, TOP_DATE, TOP_TIME));
+
+ RecommendationRun run = RecommendationRun.create(meetingId);
+ if (recommendationCompleted) {
+ run.complete();
+ }
+ Long runId = recommendationRunRepository.save(run).getId();
+ Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId();
+ Long placeId = recommendedPlaceRepository.save(
+ RecommendedPlace.create(areaId, "google-place-" + meetingId, "카페", 3)).getId();
+
+ placeVoteRepository.save(PlaceVote.create(meetingId, participantId, placeId));
+
+ return meetingId;
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java
new file mode 100644
index 0000000..58a7cda
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteCloseServiceTest.java
@@ -0,0 +1,150 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationStatus;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PlaceVoteCloseServiceTest {
+
+ private static final int DEADLINE_DAYS = 3;
+
+ @Mock private MeetingRepository meetingRepository;
+ @Mock private RecommendationRunRepository recommendationRunRepository;
+ @Mock private VoteService voteService;
+
+ @InjectMocks private PlaceVoteCloseService placeVoteCloseService;
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(placeVoteCloseService, "placeVoteDeadlineDays", DEADLINE_DAYS);
+ }
+
+ @Test
+ void VOTING_상태인_모임이_없으면_아무것도_하지_않는다() {
+ when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of());
+
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ verifyNoInteractions(recommendationRunRepository, voteService);
+ }
+
+ @Test
+ void 장소_추천이_완료되지_않은_모임은_대상에서_제외한다() {
+ // given: VOTING 상태지만 완료된 추천 실행이 아직 없음 (장소 투표 전)
+ stubVotingMeetings(10L);
+ when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(10L), RecommendationStatus.COMPLETED))
+ .thenReturn(List.of());
+
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ verifyNoInteractions(voteService);
+ }
+
+ @Test
+ void 마감_기한이_지나지_않은_모임은_대상에서_제외한다() {
+ stubVotingMeetings(10L);
+ stubCompletedRuns(completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1)));
+
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ verifyNoInteractions(voteService);
+ }
+
+ @Test
+ void 마감_기한이_지난_모임만_모임별로_마감_처리를_요청한다() {
+ // given: A=기한 안 지남, B&C=기한 지남
+ stubVotingMeetings(21L, 22L, 23L);
+ stubCompletedRuns(
+ completedRunAt(21L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1)),
+ completedRunAt(22L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 2)),
+ completedRunAt(23L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 2))
+ );
+
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ verify(voteService, never()).finalizeExpiredPlaceVote(21L);
+ verify(voteService).finalizeExpiredPlaceVote(22L);
+ verify(voteService).finalizeExpiredPlaceVote(23L);
+ }
+
+ @Test
+ void 한_모임의_마감_처리가_실패해도_나머지_모임은_계속_처리한다() {
+ // given: B 처리 중 예외 발생
+ stubVotingMeetings(21L, 22L, 23L);
+ LocalDateTime expired = LocalDateTime.now().minusDays(DEADLINE_DAYS + 2);
+ stubCompletedRuns(completedRunAt(21L, expired), completedRunAt(22L, expired), completedRunAt(23L, expired));
+ when(voteService.finalizeExpiredPlaceVote(21L)).thenReturn(true);
+ when(voteService.finalizeExpiredPlaceVote(22L)).thenThrow(new IllegalStateException("장소 정보 누락"));
+ when(voteService.finalizeExpiredPlaceVote(23L)).thenReturn(true);
+
+ // when: 예외가 밖으로 전파되지 않음
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ // then: 실패한 B 이후의 C까지 처리
+ verify(voteService).finalizeExpiredPlaceVote(21L);
+ verify(voteService).finalizeExpiredPlaceVote(22L);
+ verify(voteService).finalizeExpiredPlaceVote(23L);
+ }
+
+ @Test
+ void 추천_실행이_여러_번_완료된_모임은_가장_늦게_완료된_시각을_장소_투표_시작_시점으로_본다() {
+ // given: 이전 추천은 기한이 지났지만, 가장 최근 추천은 기한 전
+ stubVotingMeetings(10L);
+ stubCompletedRuns(
+ completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS + 5)),
+ completedRunAt(10L, LocalDateTime.now().minusDays(DEADLINE_DAYS - 1))
+ );
+
+ placeVoteCloseService.finalizeExpiredPlaceVotes();
+
+ verify(voteService, never()).finalizeExpiredPlaceVote(anyLong());
+ }
+
+ private void stubVotingMeetings(Long... meetingIds) {
+ List meetings = Arrays.stream(meetingIds)
+ .map(id -> {
+ Meeting meeting = mock(Meeting.class);
+ lenient().when(meeting.getId()).thenReturn(id);
+ return meeting;
+ })
+ .toList();
+ when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(meetings);
+ }
+
+ private void stubCompletedRuns(RecommendationRun... runs) {
+ when(recommendationRunRepository.findAllByMeetingIdInAndStatus(anyList(), eq(RecommendationStatus.COMPLETED)))
+ .thenReturn(List.of(runs));
+ }
+
+ private RecommendationRun completedRunAt(Long meetingId, LocalDateTime updatedAt) {
+ RecommendationRun run = mock(RecommendationRun.class);
+ lenient().when(run.getMeetingId()).thenReturn(meetingId);
+ lenient().when(run.getUpdatedAt()).thenReturn(updatedAt);
+ return run;
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java
new file mode 100644
index 0000000..8ed6c6d
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteJoinLeaveConcurrencyTest.java
@@ -0,0 +1,209 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.meeting.service.MeetingLeaveService;
+import com.moive.MoiveBE.domain.meeting.service.MeetingService;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository;
+import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest;
+import com.moive.MoiveBE.domain.vote.entity.PlaceVote;
+import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+
+/**
+ * [장소 투표 확정 ↔ 모임 참여/모임 나가기 API] 동시성 테스트
+ * - 마지막 장소 투표가 장소 확정을 커밋하기 전 모임 참여/모임 나가기 요청이 들어올 경우 확정 결과가 덮어써지지 않는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class PlaceVoteJoinLeaveConcurrencyTest {
+
+ private static final String INVITE_CODE = "test-invite-code";
+ private static final long PAUSE_MILLIS = 300;
+
+ @Autowired private VoteService voteService;
+ @Autowired private MeetingLeaveService meetingLeaveService;
+ @Autowired private MeetingService meetingService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private PlaceVoteRepository placeVoteRepository;
+ @Autowired private RecommendationRunRepository recommendationRunRepository;
+ @Autowired private RecommendedAreaRepository recommendedAreaRepository;
+ @Autowired private RecommendedPlaceRepository recommendedPlaceRepository;
+
+ @MockitoBean private GooglePlacesClient googlePlacesClient;
+ @MockitoBean private NotificationService notificationService;
+
+ @AfterEach
+ void tearDown() {
+ placeVoteRepository.deleteAllInBatch();
+ recommendedPlaceRepository.deleteAllInBatch();
+ recommendedAreaRepository.deleteAllInBatch();
+ recommendationRunRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 마지막_투표로_확정되는_중에_투표한_참여자가_나가도_확정이_유지된다() throws Exception {
+ // given: A, B는 투표 완료, C가 마지막 투표자
+ Long meetingId = votingMeetingWithParticipants(3);
+ Long placeId = recommendedPlace(meetingId);
+ Long leavingParticipantId = participantIdOf(meetingId, 2L);
+ vote(meetingId, participantIdOf(meetingId, 1L), placeId);
+ vote(meetingId, leavingParticipantId, placeId);
+ int participantCntBefore = meetingRepository.findById(meetingId).orElseThrow().getParticipantCnt();
+
+ CountDownLatch confirming = pauseWhenConfirmNotificationSent();
+
+ // when: C의 마지막 투표로 장소 확정을 커밋하기 전에 B가 모임을 나감
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future> lastVote = executor.submit(asUser(3L,
+ () -> voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(placeId)))));
+ assertThat(confirming.await(5, TimeUnit.SECONDS)).isTrue(); // 마지막 투표가 '장소 확정 후 ~ 커밋 전' 상태가 될 때까지 대기
+ Future> leave = executor.submit(asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId)));
+ lastVote.get(10, TimeUnit.SECONDS);
+ leave.get(10, TimeUnit.SECONDS);
+ executor.shutdown();
+
+ // then: 장소 확정은 유지되고, 모임 나가기 처리(인원 감소, leftAt)도 함께 반영됨
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId);
+
+ assertThat(meeting.getParticipantCnt()).isEqualTo(participantCntBefore - 1);
+ Participant leftParticipant = participantRepository.findById(leavingParticipantId).orElseThrow();
+ assertThat(leftParticipant.getLeftAt()).isNotNull();
+ }
+
+ @Test
+ void 마지막_투표로_확정되는_중에_새로운_참여자가_입장해도_확정이_유지된다() throws Exception {
+ // given: A, B는 투표 완료, C가 마지막 투표자
+ Long meetingId = votingMeetingWithParticipants(3);
+ Long placeId = recommendedPlace(meetingId);
+ vote(meetingId, participantIdOf(meetingId, 1L), placeId);
+ vote(meetingId, participantIdOf(meetingId, 2L), placeId);
+ int participantCntBefore = meetingRepository.findById(meetingId).orElseThrow().getParticipantCnt();
+
+ CountDownLatch confirming = pauseWhenConfirmNotificationSent();
+
+ // when: C가 마지막으로 투표하며 장소 확정 커밋이 이뤄지기 전, D가 모임 참여
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future> lastVote = executor.submit(asUser(3L,
+ () -> voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(placeId)))));
+ assertThat(confirming.await(5, TimeUnit.SECONDS)).isTrue(); // 마지막 투표가 "확정 후, 커밋 전" 상태가 될 때까지 대기
+ Future> join = executor.submit(asUser(4L, () -> meetingService.joinMeeting(INVITE_CODE)));
+ lastVote.get(10, TimeUnit.SECONDS);
+ join.get(10, TimeUnit.SECONDS);
+ executor.shutdown();
+
+ // then: 장소 확정은 유지되고, 모임 참여 처리(인원 증가, 신규 참여자)도 함께 반영됨
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId);
+
+ assertThat(meeting.getParticipantCnt()).isEqualTo(participantCntBefore + 1);
+ assertThat(participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, 4L))
+ .get()
+ .extracting(Participant::getState)
+ .isEqualTo(ParticipantState.NEW_RESTRICTED);
+ }
+
+ private Long votingMeetingWithParticipants(int count) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", LocalDate.now().plusDays(7), LocalTime.NOON, INVITE_CODE);
+ for (int i = 1; i < count; i++) {
+ meeting.incrementParticipantCnt();
+ }
+ meeting.transitionToVoting();
+ Long meetingId = meetingRepository.save(meeting).getId();
+ for (long userId = 1; userId <= count; userId++) {
+ participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_DONE));
+ }
+ return meetingId;
+ }
+
+ private Long recommendedPlace(Long meetingId) {
+ RecommendationRun run = RecommendationRun.create(meetingId);
+ run.complete();
+ Long runId = recommendationRunRepository.save(run).getId();
+ Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId();
+ return recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId();
+ }
+
+ private Long participantIdOf(Long meetingId, Long userId) {
+ return participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, userId).orElseThrow().getId();
+ }
+
+ private void vote(Long meetingId, Long participantId, Long placeId) {
+ placeVoteRepository.save(PlaceVote.create(meetingId, participantId, placeId));
+ }
+
+ private CountDownLatch pauseWhenConfirmNotificationSent() {
+ CountDownLatch confirming = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ if (confirming.getCount() > 0) {
+ confirming.countDown();
+ Thread.sleep(PAUSE_MILLIS);
+ }
+ return null;
+ }).when(notificationService).sendNotification(anyLong(), anyLong(), any(), anyString());
+ return confirming;
+ }
+
+ private Runnable asUser(Long userId, Runnable task) {
+ return () -> {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of()));
+ try {
+ task.run();
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
+ };
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java
new file mode 100644
index 0000000..577ee3d
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteLeaveConfirmTest.java
@@ -0,0 +1,205 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.DateVote;
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.meeting.service.MeetingLeaveService;
+import com.moive.MoiveBE.domain.notification.entity.NotificationType;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository;
+import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest;
+import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+
+/**
+ * [모임 나가기 API] 모임 나가기 후 모임 확정 재판정 테스트
+ * - 장소 투표 단계에서 참여자가 나가 남은 투표 가능 참여자가 모두 투표를 마친 상태가 되면 모임이 확정되는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class PlaceVoteLeaveConfirmTest {
+
+ private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3);
+ private static final LocalTime TOP_TIME = LocalTime.of(18, 0);
+
+ @Autowired private VoteService voteService;
+ @Autowired private MeetingLeaveService meetingLeaveService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private DateVoteRepository dateVoteRepository;
+ @Autowired private PlaceVoteRepository placeVoteRepository;
+ @Autowired private RecommendationRunRepository recommendationRunRepository;
+ @Autowired private RecommendedAreaRepository recommendedAreaRepository;
+ @Autowired private RecommendedPlaceRepository recommendedPlaceRepository;
+
+ @MockitoBean private GooglePlacesClient googlePlacesClient;
+ @MockitoBean private NotificationService notificationService;
+
+ @AfterEach
+ void tearDown() {
+ placeVoteRepository.deleteAllInBatch();
+ dateVoteRepository.deleteAllInBatch();
+ recommendedPlaceRepository.deleteAllInBatch();
+ recommendedAreaRepository.deleteAllInBatch();
+ recommendationRunRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 투표하지_않은_마지막_참여자가_나가면_모임이_즉시_확정된다() {
+ // given: A, B는 장소 투표 완료, C는 미투표
+ Long meetingId = votingMeeting(3);
+ Long placeId = recommendedPlace(meetingId);
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId)));
+ voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeId)));
+ assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING);
+
+ // when: C가 모임을 나감
+ asUser(3L, () -> meetingLeaveService.leaveMeeting(meetingId)).run();
+
+ // then: 남은 A, B가 모두 투표한 상태 → 모임 확정 (일정, 장소)
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId);
+ assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE);
+ assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME);
+ }
+
+ @Test
+ void 투표_가능_참여자가_모두_나가_신규_참여자만_남으면_장소_없이_모임이_확정된다() {
+ // given: A, B(투표 가능), D(투표 시작 후 입장한 신규 참여자) (A만 장소 투표 완료)
+ Long meetingId = votingMeeting(2);
+ participantRepository.save(Participant.create(meetingId, 4L, ParticipantState.NEW_RESTRICTED));
+ Long placeId = recommendedPlace(meetingId);
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId)));
+
+ // when: A, B 모두 나감
+ asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run();
+ assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING);
+ asUser(2L, () -> meetingLeaveService.leaveMeeting(meetingId)).run();
+
+ // then: 더 이상 투표할 사람이 없으므로 모임 확정, 나간 A의 표는 제외되어 유효한 표가 없으므로 장소 null & 확정 알림 x
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isNull();
+ verify(notificationService, never())
+ .sendNotification(anyLong(), anyLong(), eq(NotificationType.MEETING_CONFIRMED), anyString());
+ }
+
+ @Test
+ void 장소_추천이_완료되기_전에는_신규_참여자만_남아도_모임을_확정하지_않는다() {
+ // given: 투표 단계지만 장소 추천이 아직 완료되지 않은 모임, A(투표 가능) + D(신규 참여자)
+ Long meetingId = votingMeeting(1);
+ participantRepository.save(Participant.create(meetingId, 4L, ParticipantState.NEW_RESTRICTED));
+
+ // when: A가 나감
+ asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run();
+
+ // then: 장소 투표 단계가 아니므로 확정하지 않음 (추천 완료 후 마감 배치에서 처리 예정)
+ assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.VOTING);
+ }
+
+ @Test
+ void 이미_확정된_모임에서_참여자가_나가도_다시_확정하지_않는다() {
+ // given: A, B 모두 투표해 확정된 모임
+ Long meetingId = votingMeeting(2);
+ Long placeId = recommendedPlace(meetingId);
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeId)));
+ voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeId)));
+ assertThat(statusOf(meetingId)).isEqualTo(MeetingStatus.CONFIRMED);
+ clearInvocations(notificationService);
+
+ // when: A가 나감 (나간 A의 표를 제외하면 순위가 달라질 수 있는 상황)
+ asUser(1L, () -> meetingLeaveService.leaveMeeting(meetingId)).run();
+
+ // then: 확정 결과는 유지, 확정 알림 재발송 x
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeId);
+ verify(notificationService, never())
+ .sendNotification(anyLong(), anyLong(), eq(NotificationType.MEETING_CONFIRMED), anyString());
+ }
+
+ private Long votingMeeting(int count) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code");
+ for (int i = 1; i < count; i++) {
+ meeting.incrementParticipantCnt();
+ }
+ meeting.transitionToVoting();
+ Long meetingId = meetingRepository.save(meeting).getId();
+ for (long userId = 1; userId <= count; userId++) {
+ Long participantId = participantRepository.save(
+ Participant.create(meetingId, userId, ParticipantState.COND_DONE)).getId();
+ dateVoteRepository.save(DateVote.create(meetingId, participantId, TOP_DATE, TOP_TIME));
+ }
+ return meetingId;
+ }
+
+ private Long recommendedPlace(Long meetingId) {
+ RecommendationRun run = RecommendationRun.create(meetingId);
+ run.complete();
+ Long runId = recommendationRunRepository.save(run).getId();
+ Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId();
+ return recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId();
+ }
+
+ private MeetingStatus statusOf(Long meetingId) {
+ return meetingRepository.findById(meetingId).orElseThrow().getStatus();
+ }
+
+ private Runnable asUser(Long userId, Runnable task) {
+ return () -> {
+ SecurityContextHolder.getContext()
+ .setAuthentication(new UsernamePasswordAuthenticationToken(userId, null, List.of()));
+ try {
+ task.run();
+ } finally {
+ SecurityContextHolder.clearContext();
+ }
+ };
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java
new file mode 100644
index 0000000..2e1ffc9
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteMeetingConfirmTest.java
@@ -0,0 +1,217 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.DateVote;
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.DateVoteRepository;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository;
+import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest;
+import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * [장소 투표 생성 API] 모임 확정(일정,장소) 테스트
+ * - 모임 생성 시 일정 미정인 모임에서 마지막 장소 투표로 모임이 확정될 때, 일정(일정 투표 집계 1위)과 장소(장소 투표 득표 1위)가 함께 확정되는지 검증
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class PlaceVoteMeetingConfirmTest {
+
+ private static final LocalDate TOP_DATE = LocalDate.of(2026, 10, 3);
+ private static final LocalTime TOP_TIME = LocalTime.of(18, 0);
+
+ @Autowired private VoteService voteService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private DateVoteRepository dateVoteRepository;
+ @Autowired private PlaceVoteRepository placeVoteRepository;
+ @Autowired private RecommendationRunRepository recommendationRunRepository;
+ @Autowired private RecommendedAreaRepository recommendedAreaRepository;
+ @Autowired private RecommendedPlaceRepository recommendedPlaceRepository;
+
+ @MockitoBean private GooglePlacesClient googlePlacesClient;
+ @MockitoBean private NotificationService notificationService;
+
+ @AfterEach
+ void tearDown() {
+ placeVoteRepository.deleteAllInBatch();
+ dateVoteRepository.deleteAllInBatch();
+ recommendedPlaceRepository.deleteAllInBatch();
+ recommendedAreaRepository.deleteAllInBatch();
+ recommendationRunRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 마지막으로_장소_투표하면_모임_일정과_장소가_함께_확정된다() {
+ // given: 일정 미정 모임, 조건 입력(일정 투표) 완료
+ Long meetingId = votingMeetingWithoutSchedule(2);
+
+ List placeIds = recommendedPlaces(meetingId, 2);
+ Long topPlaceId = placeIds.get(0);
+ Long otherPlaceId = placeIds.get(1);
+
+ LocalDate otherDate = TOP_DATE.plusDays(1);
+
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, LocalTime.of(12, 0)));
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME));
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), otherDate, TOP_TIME));
+
+ // when: A는 두 장소 모두, B는 첫 번째 장소에 투표 (B가 마지막 투표자)
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(topPlaceId, otherPlaceId)));
+ Meeting afterFirstVote = meetingRepository.findById(meetingId).orElseThrow();
+ voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(topPlaceId)));
+
+ // then: 첫 투표 후에는 아직 미확정 (일정, 장소 모두 비어 있음)
+ assertThat(afterFirstVote.getStatus()).isEqualTo(MeetingStatus.VOTING);
+ assertThat(afterFirstVote.getScheduledDate()).isNull();
+ assertThat(afterFirstVote.getConfirmedPlaceId()).isNull();
+
+ // 마지막 투표로 모임 확정
+ // - 장소: 득표 1위(topPlaceId: 2표, otherPlaceId: 1표)
+ // - 일정: 집계 1위 날짜(TOP_DATE: 2명) + 그 날짜의 가장 늦은 시간(TOP_TIME)
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(topPlaceId);
+ assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE);
+ assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME);
+ }
+
+ @Test
+ void 모임을_나간_참여자의_투표는_모임_확정_시_장소_1위_계산에서_제외된다() {
+ // given: A, B, C 모두 조건 입력(일정 투표) 완료, 추천 장소 2개 (득표가 같으면 id가 작은 첫 번째 장소가 앞섬)
+ Long meetingId = votingMeetingWithoutSchedule(3);
+ List placeIds = recommendedPlaces(meetingId, 2);
+ Long firstPlaceId = placeIds.get(0);
+ Long secondPlaceId = placeIds.get(1);
+ for (long userId = 1; userId <= 3; userId++) {
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, userId), TOP_DATE, TOP_TIME));
+ }
+
+ // A는 두 번째 장소에 투표한 뒤 모임을 나감
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(secondPlaceId)));
+ Participant leaving = participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, 1L).orElseThrow();
+ leaving.leave();
+ participantRepository.save(leaving);
+
+ // when: B는 첫 번째 장소, C는 두 번째 장소에 투표 (C가 마지막 투표자)
+ voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(firstPlaceId)));
+ voteService.createPlaceVote(3L, meetingId, new PlaceVoteRequest(List.of(secondPlaceId)));
+
+ // then: 나간 A의 표를 제외하면 1:1 동점 → 첫 번째 장소로 확정 (A의 표를 포함하면 두 번째 장소가 2표로 확정됨)
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(firstPlaceId);
+ }
+
+ @Test
+ void 마감_처리_시_일부만_투표했어도_일정과_현재까지의_득표_1위_장소로_모임이_확정된다() {
+ // given: A&B 일정 투표 완료, 장소 투표는 A만 완료 (마감 기한 경과 가정)
+ Long meetingId = votingMeetingWithoutSchedule(2);
+ List placeIds = recommendedPlaces(meetingId, 2);
+ Long votedPlaceId = placeIds.get(1);
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, TOP_TIME));
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME));
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(votedPlaceId)));
+
+ // when: 마감 배치가 이 모임을 처리
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(meetingId);
+
+ // then
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(confirmed).isTrue();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(votedPlaceId);
+ assertThat(meeting.getScheduledDate()).isEqualTo(TOP_DATE);
+ assertThat(meeting.getScheduledTime()).isEqualTo(TOP_TIME);
+ }
+
+ @Test
+ void 마감_배치가_대상을_고른_뒤_마지막_투표로_확정된_모임은_다시_확정하지_않는다() {
+ // given: 배치가 VOTING 모임을 대상으로 고른 뒤, 처리하기 전에 마지막 투표로 모임이 확정됨
+ Long meetingId = votingMeetingWithoutSchedule(2);
+ List placeIds = recommendedPlaces(meetingId, 2);
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 1L), TOP_DATE, TOP_TIME));
+ dateVoteRepository.save(DateVote.create(meetingId, participantIdOf(meetingId, 2L), TOP_DATE, TOP_TIME));
+ voteService.createPlaceVote(1L, meetingId, new PlaceVoteRequest(List.of(placeIds.get(0))));
+ voteService.createPlaceVote(2L, meetingId, new PlaceVoteRequest(List.of(placeIds.get(0))));
+ clearInvocations(notificationService);
+
+ // when: 배치가 해당 모임 처리
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(meetingId);
+
+ // then: 락을 잡은 뒤 최신 상태(CONFIRMED)를 보고 건너뜀 → 확정 결과 유지, 확정 알림 중복 발송 x
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(confirmed).isFalse();
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeIds.get(0));
+ verifyNoInteractions(notificationService);
+ }
+
+ private Long votingMeetingWithoutSchedule(int count) {
+ Meeting meeting = Meeting.create(1L, "테스트 모임", null, null, "test-invite-code");
+ for (int i = 1; i < count; i++) {
+ meeting.incrementParticipantCnt();
+ }
+ meeting.transitionToVoting();
+ Long meetingId = meetingRepository.save(meeting).getId();
+ for (long userId = 1; userId <= count; userId++) {
+ participantRepository.save(Participant.create(meetingId, userId, ParticipantState.COND_DONE));
+ }
+ return meetingId;
+ }
+
+ private List recommendedPlaces(Long meetingId, int count) {
+ RecommendationRun run = RecommendationRun.create(meetingId);
+ run.complete();
+ Long runId = recommendationRunRepository.save(run).getId();
+ Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId();
+ List placeIds = new ArrayList<>();
+ for (int i = 1; i <= count; i++) {
+ placeIds.add(recommendedPlaceRepository.save(
+ RecommendedPlace.create(areaId, "google-place-" + i, "카페", 3)).getId());
+ }
+ return placeIds;
+ }
+
+ private Long participantIdOf(Long meetingId, Long userId) {
+ return participantRepository.findByMeetingIdAndUserIdAndLeftAtIsNull(meetingId, userId).orElseThrow().getId();
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java
new file mode 100644
index 0000000..6f71ddc
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/PlaceVoteServiceConcurrencyTest.java
@@ -0,0 +1,221 @@
+package com.moive.MoiveBE.domain.vote.service;
+
+import com.moive.MoiveBE.domain.meeting.entity.Meeting;
+import com.moive.MoiveBE.domain.meeting.entity.MeetingStatus;
+import com.moive.MoiveBE.domain.meeting.entity.Participant;
+import com.moive.MoiveBE.domain.meeting.entity.ParticipantState;
+import com.moive.MoiveBE.domain.meeting.repository.MeetingRepository;
+import com.moive.MoiveBE.domain.meeting.repository.ParticipantRepository;
+import com.moive.MoiveBE.domain.notification.service.NotificationService;
+import com.moive.MoiveBE.domain.recommendation.client.GooglePlacesClient;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendationRun;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedArea;
+import com.moive.MoiveBE.domain.recommendation.entity.RecommendedPlace;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendationRunRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedAreaRepository;
+import com.moive.MoiveBE.domain.recommendation.repository.RecommendedPlaceRepository;
+import com.moive.MoiveBE.domain.vote.dto.PlaceVoteRequest;
+import com.moive.MoiveBE.domain.vote.entity.PlaceVote;
+import com.moive.MoiveBE.domain.vote.repository.PlaceVoteRepository;
+import com.moive.MoiveBE.global.exception.CustomErrorCode;
+import com.moive.MoiveBE.global.exception.CustomException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * [장소 투표 생성 API] 동시성 테스트
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@TestPropertySource(properties = {
+ "spring.datasource.url=jdbc:h2:mem:vote-concurrency;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.hikari.maximum-pool-size=20",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.jpa.show-sql=false",
+ "spring.sql.init.mode=never"
+})
+class PlaceVoteServiceConcurrencyTest {
+
+ private static final int VOTER_CNT = 10;
+
+ @Autowired private VoteService voteService;
+ @Autowired private MeetingRepository meetingRepository;
+ @Autowired private ParticipantRepository participantRepository;
+ @Autowired private PlaceVoteRepository placeVoteRepository;
+ @Autowired private RecommendationRunRepository recommendationRunRepository;
+ @Autowired private RecommendedAreaRepository recommendedAreaRepository;
+ @Autowired private RecommendedPlaceRepository recommendedPlaceRepository;
+
+ @MockitoBean private GooglePlacesClient googlePlacesClient;
+ @MockitoBean private NotificationService notificationService;
+
+ private Long meetingId;
+ private List participants;
+ private List placeIds;
+
+ @BeforeEach
+ void setUp() {
+ Meeting meeting = Meeting.create(1L, "장소 투표 동시성 테스트용 모임", LocalDate.now().plusDays(7), LocalTime.NOON, "test-invite-code");
+ meeting.transitionToVoting();
+ meetingId = meetingRepository.save(meeting).getId();
+
+ participants = new ArrayList<>();
+ for (long userId = 1; userId <= VOTER_CNT; userId++) {
+ participants.add(participantRepository.save(
+ Participant.create(meetingId, userId, ParticipantState.COND_DONE)));
+ }
+
+ RecommendationRun run = RecommendationRun.create(meetingId);
+ run.complete();
+ Long runId = recommendationRunRepository.save(run).getId();
+ Long areaId = recommendedAreaRepository.save(RecommendedArea.create(runId, "합정역")).getId();
+ placeIds = List.of(
+ recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-1", "카페", 3)).getId(),
+ recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-2", "레스토랑", 2)).getId(),
+ recommendedPlaceRepository.save(RecommendedPlace.create(areaId, "google-place-3", "영화관", 1)).getId()
+ );
+ }
+
+ @AfterEach
+ void tearDown() {
+ placeVoteRepository.deleteAllInBatch();
+ recommendedPlaceRepository.deleteAllInBatch();
+ recommendedAreaRepository.deleteAllInBatch();
+ recommendationRunRepository.deleteAllInBatch();
+ participantRepository.deleteAllInBatch();
+ meetingRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void 전원이_동시에_투표해도_마지막_투표에서_장소가_한_번만_확정된다() throws InterruptedException {
+ // given: 투표 가능 인원 10명이 한 명도 투표하지 않은 상태 가정
+
+ // when: 10명이 동시에 투표
+ List tasks = participants.stream()
+ .map(p -> () -> voteService.createPlaceVote(
+ p.getUserId(), meetingId, new PlaceVoteRequest(List.of(placeIds.get(0)))))
+ .toList();
+ Result result = runConcurrently(tasks);
+
+ // then: 모든 투표 성공 + 모임 장소 확정 + 참여자당 1회 확정 알림
+ assertThat(result.successCnt()).isEqualTo(VOTER_CNT);
+ assertThat(placeVoteRepository.countDistinctVoters(meetingId)).isEqualTo(VOTER_CNT);
+
+ Meeting meeting = meetingRepository.findById(meetingId).orElseThrow();
+ assertThat(meeting.getStatus()).isEqualTo(MeetingStatus.CONFIRMED);
+ assertThat(meeting.getConfirmedPlaceId()).isEqualTo(placeIds.get(0));
+
+ assertThat(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingId))
+ .extracting(Participant::getState)
+ .containsOnly(ParticipantState.CONFIRMED);
+ verify(notificationService, times(VOTER_CNT))
+ .sendNotification(anyLong(), anyLong(), any(), anyString());
+ }
+
+ @Test
+ void 같은_참여자가_서로_다른_장소로_동시에_여러_번_요청해도_한_번만_저장된다() throws InterruptedException {
+ // given: 같은 참여자가 서로 다른 장소 조합으로 5번 동시 요청
+ Participant me = participants.get(0);
+ List> requests = List.of(
+ List.of(placeIds.get(0)),
+ List.of(placeIds.get(1)),
+ List.of(placeIds.get(2)),
+ List.of(placeIds.get(0), placeIds.get(1)),
+ List.of(placeIds.get(1), placeIds.get(2))
+ );
+
+ // when
+ List tasks = requests.stream()
+ .map(ids -> () -> voteService.createPlaceVote(
+ me.getUserId(), meetingId, new PlaceVoteRequest(ids)))
+ .toList();
+ Result result = runConcurrently(tasks);
+
+ // then: 1건만 성공, 나머지는 기투표 예외 발생
+ assertThat(result.successCnt()).isEqualTo(1);
+ assertThat(result.errorCnt(CustomErrorCode.PLACE_VOTE_ALREADY_DONE)).isEqualTo(requests.size() - 1);
+
+ List myVotes = placeVoteRepository.findAll().stream()
+ .filter(v -> v.getParticipantId().equals(me.getId()))
+ .toList();
+ assertThat(requests).anySatisfy(ids ->
+ assertThat(myVotes).extracting(PlaceVote::getRecommendedPlaceId)
+ .containsExactlyInAnyOrderElementsOf(ids));
+ }
+
+ // 모든 작업 동시 실행 후 성공 수/에러코드별 실패 수 집계
+ private Result runConcurrently(List tasks) throws InterruptedException {
+ int threadCnt = tasks.size();
+ ExecutorService executor = Executors.newFixedThreadPool(threadCnt);
+ CountDownLatch ready = new CountDownLatch(threadCnt);
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(threadCnt);
+
+ AtomicInteger successCnt = new AtomicInteger();
+ Map errorCnts = new ConcurrentHashMap<>();
+ List unexpected = new ArrayList<>();
+
+ for (Runnable task : tasks) {
+ executor.submit(() -> {
+ ready.countDown();
+ try {
+ start.await();
+ task.run();
+ successCnt.incrementAndGet();
+ } catch (CustomException e) {
+ errorCnts.computeIfAbsent(e.getCustomErrorCode(), k -> new AtomicInteger()).incrementAndGet();
+ } catch (Throwable t) {
+ synchronized (unexpected) {
+ unexpected.add(t);
+ }
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+
+ ready.await();
+ start.countDown();
+ assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
+ executor.shutdown();
+
+ assertThat(unexpected).isEmpty();
+ return new Result(successCnt.get(), errorCnts);
+ }
+
+ private record Result(int successCnt, Map errorCnts) {
+ int errorCnt(CustomErrorCode code) {
+ AtomicInteger cnt = errorCnts.get(code);
+ return cnt == null ? 0 : cnt.get();
+ }
+ }
+}
diff --git a/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java b/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java
index 94345d8..844ae2f 100644
--- a/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java
+++ b/src/test/java/com/moive/MoiveBE/domain/vote/service/VoteServiceTest.java
@@ -29,19 +29,17 @@
import com.moive.MoiveBE.global.exception.CustomErrorCode;
import com.moive.MoiveBE.global.exception.CustomException;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Pageable;
-import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
-import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Arrays;
import java.util.List;
@@ -54,6 +52,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -83,11 +82,6 @@ class VoteServiceTest {
@InjectMocks private VoteService voteService;
- @BeforeEach
- void setUp() {
- ReflectionTestUtils.setField(voteService, "placeVoteDeadlineDays", 3);
- }
-
/**
* [일정 투표 현황 조회] 테스트
*/
@@ -223,7 +217,7 @@ void setUp() {
}
@Test
- void 일정_미확정이고_후보가_있으면_1위_일정으로_모임_일정이_확정된다() {
+ void 일정_미확정이어도_일정_투표_현황_조회는_집계만_하고_모임_일정을_확정하지_않는다() {
// given: scheduledDate/scheduledTime 모두 null
Meeting meeting = mock(Meeting.class);
stubMeetingAndParticipant(meeting, MY_PARTICIPANT_ID);
@@ -236,10 +230,12 @@ void setUp() {
));
// when
- voteService.getMeetingScheduleVoteResult(USER_ID, MEETING_ID);
+ DateVoteResultResponse response = voteService.getMeetingScheduleVoteResult(USER_ID, MEETING_ID);
- // then: 1위(9/15 18:00)로 모임 일정이 확정됨
- verify(meeting).confirmSchedule(LocalDate.of(2026, 9, 15), LocalTime.of(18, 0));
+ // then: 집계 결과 반환, 일정 확정은 진행x
+ assertThat(response.candidates()).extracting(DateVoteResultResponse.Candidate::meetingDate)
+ .containsExactly(LocalDate.of(2026, 9, 15), LocalDate.of(2026, 9, 12));
+ verify(meeting, never()).confirmSchedule(any(), any());
}
@Test
@@ -267,7 +263,7 @@ void setUp() {
void 이미_장소가_확정된_모임이면_PLACE_VOTE_CLOSED() {
// given
Meeting meeting = meetingWithStatus(MeetingStatus.CONFIRMED);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
// when & then
assertErrorCode(
@@ -282,7 +278,7 @@ void setUp() {
void 조건_입력_중인_모임이면_PLACE_VOTE_NOT_STARTED() {
// given
Meeting meeting = meetingWithStatus(MeetingStatus.CONDITION_INPUT);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
// when & then
assertErrorCode(
@@ -297,7 +293,7 @@ void setUp() {
void 장소_추천이_실행되지_않았다면_PLACE_VOTE_NOT_STARTED() {
// given
Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(recommendationRunRepository.findTopByMeetingIdAndStatusOrderByCreatedAtDesc(MEETING_ID, RecommendationStatus.COMPLETED))
.thenReturn(Optional.empty());
@@ -399,7 +395,7 @@ void setUp() {
stubMeetingAndRecommendation(meeting, 101L, 102L, 103L);
stubParticipant();
when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
- when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(3L);
// when: 101L 중복 선택
voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L, 102L, 101L));
@@ -429,8 +425,7 @@ void setUp() {
.thenReturn(Optional.of(participant));
when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
- when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L);
- when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(5L);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(3L);
// when
voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L));
@@ -439,6 +434,23 @@ void setUp() {
verify(participant).completeVote();
}
+ @Test
+ void 투표_가능_참여자_중_미투표자가_남아있으면_모임이_확정되지_않는다() {
+ // given
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ stubMeetingAndRecommendation(meeting, 101L, 102L);
+ stubParticipant();
+ when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(1L);
+
+ // when
+ voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L));
+
+ // then
+ verify(meeting, never()).confirmPlace(any());
+ verify(notificationService, never()).sendNotification(any(), any(), any(), any());
+ }
+
@Test
void 마지막_투표자가_투표하면_득표_1위_장소로_모임이_확정된다() {
// given: 참여자 2명 모임, 마지막 투표 상황 가정
@@ -449,8 +461,7 @@ void setUp() {
lenient().when(participantRepository.findAllByMeetingIdAndLeftAtIsNullOrderByJoinedAtAsc(MEETING_ID))
.thenReturn(List.of());
when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
- when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L);
- when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(2L);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L);
// 투표 집계: 101L=2표 (1위), 102L=1표
// (확정 시점의 rankCandidates는 "조회하는 나"가 없는 배치성 호출이라 viewer 참여자 id는 구현 세부사항 -> any() 매칭)
@@ -471,6 +482,62 @@ void setUp() {
verify(meeting).confirmPlace(101L);
}
+ @Test
+ void 마지막_투표자가_투표하면_투표_집계_결과_기반으로_모임_일정과_장소가_함께_확정된다() {
+ // given: 일정 미정 모임(scheduledDate/scheduledTime null), 마지막 투표 상황
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ stubMeetingAndRecommendation(meeting, 101L, 102L);
+ stubParticipant();
+ when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L);
+
+ // 일정 집계: 유효 참여자 기준 1위 10/3 18:00
+ Participant active = mock(Participant.class);
+ when(active.getId()).thenReturn(500L);
+ when(participantRepository.findAllByMeetingIdAndLeftAtIsNull(MEETING_ID)).thenReturn(List.of(active));
+ LocalDate topDate = LocalDate.of(2026, 10, 3);
+ LocalTime topTime = LocalTime.of(18, 0);
+ when(dateVoteRepository.aggregateTopDates(eq(MEETING_ID), any(), eq(List.of(500L)), any()))
+ .thenReturn(List.of(new DateVoteSummary(topDate, topTime, 3L, 0L)));
+
+ // 장소 집계: 101L 1위
+ when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of(
+ new PlaceVoteSummary(101L, 2L, 1L)
+ ));
+ RecommendedPlace place101 = recommendedPlaceWithId(101L, "gp-101", 1);
+ when(recommendedPlaceRepository.findAllById(anyList())).thenReturn(List.of(place101));
+ lenient().when(googlePlacesClient.getPlaceLocation(any())).thenThrow(new CustomException(PLACE_INFO_LOOKUP_FAILED));
+
+ // when
+ voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L));
+
+ // then
+ InOrder inOrder = inOrder(meeting);
+ inOrder.verify(meeting).confirmSchedule(topDate, topTime);
+ inOrder.verify(meeting).confirmPlace(101L);
+ }
+
+ @Test
+ void 모임_생성_시_일정이_확정된_모임은_마지막_투표로_확정될_때_일정_집계를_하지_않는다() {
+ // given: 일정이 이미 있는 모임, 마지막 투표 상황
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ when(meeting.getScheduledDate()).thenReturn(LocalDate.of(2026, 10, 3));
+ when(meeting.getScheduledTime()).thenReturn(LocalTime.of(18, 0));
+ stubMeetingAndRecommendation(meeting, 101L);
+ stubParticipant();
+ when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L);
+ when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of());
+
+ // when
+ voteService.createPlaceVote(USER_ID, MEETING_ID, placeVoteRequest(101L));
+
+ // then: 일정은 그대로 두고 장소만 확정
+ verifyNoInteractions(dateVoteRepository);
+ verify(meeting, never()).confirmSchedule(any(), any());
+ verify(meeting).confirmPlace(null);
+ }
+
@Test
void 마지막_투표자가_투표하면_전체_참여자에게_MEETING_CONFIRMED_알림이_발송된다() {
// given: 참여자 2명 모임, 마지막 투표 상황
@@ -488,8 +555,7 @@ void setUp() {
.thenReturn(List.of(p1, p2));
when(placeVoteRepository.existsByMeetingIdAndParticipantId(MEETING_ID, MY_PARTICIPANT_ID)).thenReturn(false);
- when(placeVoteRepository.countDistinctVoters(MEETING_ID)).thenReturn(2L);
- when(participantRepository.countByMeetingIdAndLeftAtIsNullAndStateNot(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(2L);
+ when(participantRepository.countParticipantsYetToVote(MEETING_ID, ParticipantState.NEW_RESTRICTED)).thenReturn(0L);
when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of(
new PlaceVoteSummary(101L, 2L, 1L),
@@ -511,67 +577,15 @@ void setUp() {
}
/**
- * [장소 투표 마감 배치] 테스트
+ * [장소 투표 마감 처리 (모임 1건)] 테스트
+ * - 대상 모임 선정 및 모임별 격리는 PlaceVoteCloseServiceTest에서 검증
*/
@Test
- void VOTING_상태인_모임이_없으면_아무것도_하지_않는다() {
+ void 마감_처리_시_1명이라도_투표했으면_그때까지의_집계_1위로_확정한다() {
// given
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of());
-
- // when
- voteService.finalizeExpiredPlaceVotes();
-
- // then
- verifyNoInteractions(recommendationRunRepository, placeVoteRepository);
- }
-
- @Test
- void 장소_투표가_아직_시작되지_않은_모임은_대상에서_제외한다() {
- // given: VOTING 상태지만 완료된 추천 실행이 아직 없음(추천 생성 전)
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of());
-
- // when
- voteService.finalizeExpiredPlaceVotes();
-
- // then
- verify(meeting, never()).confirmPlace(any());
- verifyNoInteractions(placeVoteRepository);
- }
-
- @Test
- void 마감_기한이_지나지_않았으면_대상에서_제외한다() {
- // given
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
-
- RecommendationRun freshRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(1));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(freshRun));
-
- // when
- voteService.finalizeExpiredPlaceVotes();
-
- // then
- verify(meeting, never()).confirmPlace(any());
- verifyNoInteractions(placeVoteRepository);
- }
-
- @Test
- void 마감_기한이_지났고_1명이라도_투표했으면_그때까지의_집계_1위로_확정한다() {
- // given
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
- RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(4));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(expiredRun));
-
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of(
new PlaceVoteSummary(101L, 3L, 0L),
new PlaceVoteSummary(102L, 1L, 0L)
@@ -583,41 +597,34 @@ void setUp() {
lenient().when(googlePlacesClient.getPlaceLocation(any())).thenThrow(new CustomException(PLACE_INFO_LOOKUP_FAILED));
// when
- voteService.finalizeExpiredPlaceVotes();
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID);
// then
+ assertThat(confirmed).isTrue();
verify(meeting).confirmPlace(101L);
}
@Test
- void 마감_기한이_지났고_아무도_투표하지_않았으면_장소_없이_상태만_확정한다() {
+ void 마감_처리_시_아무도_투표하지_않았으면_장소_없이_상태만_확정한다() {
// given
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
-
- RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(expiredRun));
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of());
// when
- voteService.finalizeExpiredPlaceVotes();
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID);
// then: 장소 확정 없이(null) 상태만 확정, 장소 상세 조회 진행 x
+ assertThat(confirmed).isTrue();
verify(meeting).confirmPlace(null);
verifyNoInteractions(recommendedPlaceRepository, googlePlacesClient);
}
@Test
- void 마감_기한이_지났고_아무도_투표하지_않은_경우_일정이_미확정이면_일정_투표_집계_1위로_일정을_확정한다() {
+ void 마감_처리_시_일정이_미확정이면_일정_투표_집계_1위로_일정도_함께_확정한다() {
// given: 일정 미확정 모임 + 전원 장소 투표 x
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
- RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(expiredRun));
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of());
Long activeParticipantId = 500L;
@@ -631,7 +638,7 @@ void setUp() {
.thenReturn(List.of(new DateVoteSummary(topDate, topTime, 3L, 0L)));
// when
- voteService.finalizeExpiredPlaceVotes();
+ voteService.finalizeExpiredPlaceVote(MEETING_ID);
// then: 일정은 일정 투표 집계 1위로 확정, 장소는 미정인 채로 모임 상태만 확정
verify(meeting).confirmSchedule(topDate, topTime);
@@ -639,20 +646,16 @@ void setUp() {
}
@Test
- void 마감_배치_시_이미_일정이_확정된_모임은_일정_투표_집계를_하지_않는다() {
- // given: 모임 생성 시 일정을 확정한 모임 (또는 이미 일정 투표 현황 조회로 확정된 모임)
- Meeting meeting = mock(Meeting.class);
- lenient().when(meeting.getId()).thenReturn(MEETING_ID);
+ void 마감_처리_시_이미_일정이_확정된_모임은_일정_투표_집계를_하지_않는다() {
+ // given: 모임 생성 시 일정을 확정한 모임
+ Meeting meeting = meetingWithStatus(MeetingStatus.VOTING);
when(meeting.getScheduledDate()).thenReturn(LocalDate.of(2026, 10, 3));
when(meeting.getScheduledTime()).thenReturn(LocalTime.of(18, 0));
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(meeting));
- RecommendationRun expiredRun = completedRunAt(MEETING_ID, LocalDateTime.now().minusDays(10));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(MEETING_ID), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(expiredRun));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
when(placeVoteRepository.aggregateByPlace(eq(MEETING_ID), any())).thenReturn(List.of());
// when
- voteService.finalizeExpiredPlaceVotes();
+ voteService.finalizeExpiredPlaceVote(MEETING_ID);
// then
verifyNoInteractions(dateVoteRepository);
@@ -661,42 +664,32 @@ void setUp() {
}
@Test
- void 여러_모임을_한번에_처리하며_각각_기한_경과_여부에_따라_다르게_처리한다() {
- // given: A=아직 기한 안 지남(스킵), B=기한 지나고 투표 있음(확정), C=기한 지나고 투표 없음(장소 없이 확정)
- Long meetingA = 21L, meetingB = 22L, meetingC = 23L;
- Meeting a = mock(Meeting.class);
- Meeting b = mock(Meeting.class);
- Meeting c = mock(Meeting.class);
-
- lenient().when(a.getId()).thenReturn(meetingA);
- lenient().when(b.getId()).thenReturn(meetingB);
- lenient().when(c.getId()).thenReturn(meetingC);
- when(meetingRepository.findAllByStatus(MeetingStatus.VOTING)).thenReturn(List.of(a, b, c));
-
- RecommendationRun runA = completedRunAt(meetingA, LocalDateTime.now().minusDays(1));
- RecommendationRun runB = completedRunAt(meetingB, LocalDateTime.now().minusDays(5));
- RecommendationRun runC = completedRunAt(meetingC, LocalDateTime.now().minusDays(5));
+ void 마감_처리_시_대상_조회_이후_이미_확정된_모임은_다시_확정하지_않는다() {
+ // given: 배치가 대상을 고른 뒤, 락을 잡기 전에 마지막 투표(또는 모임 나가기)로 확정 처리된 모임
+ Meeting meeting = meetingWithStatus(MeetingStatus.CONFIRMED);
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
- when(recommendationRunRepository.findAllByMeetingIdInAndStatus(List.of(meetingA, meetingB, meetingC), RecommendationStatus.COMPLETED))
- .thenReturn(List.of(runA, runB, runC));
+ // when
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID);
- when(placeVoteRepository.aggregateByPlace(eq(meetingB), any())).thenReturn(List.of(
- new PlaceVoteSummary(201L, 1L, 0L)
- ));
- when(placeVoteRepository.aggregateByPlace(eq(meetingC), any())).thenReturn(List.of());
+ // then: 확정, 알림 건너뜀
+ assertThat(confirmed).isFalse();
+ verify(meeting, never()).confirmPlace(any());
+ verifyNoInteractions(placeVoteRepository, dateVoteRepository, notificationService);
+ }
- RecommendedPlace place201 = recommendedPlaceWithId(201L, "gp-201", 1);
- when(recommendedPlaceRepository.findAllById(anyList())).thenReturn(List.of(place201));
- when(participantRepository.findAllByMeetingIdAndLeftAtIsNull(meetingB)).thenReturn(List.of());
- when(participantPreferenceRepository.findAllByParticipantIdIn(anyList())).thenReturn(List.of());
+ @Test
+ void 마감_처리_시_대상_조회_이후_종료된_모임은_확정하지_않는다() {
+ // given: 배치가 대상을 고른 뒤, 마지막 참여자가 나가 종료 처리된 모임
+ Meeting meeting = meetingWithStatus(MeetingStatus.COMPLETED);
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
// when
- voteService.finalizeExpiredPlaceVotes();
+ boolean confirmed = voteService.finalizeExpiredPlaceVote(MEETING_ID);
- // then
- verify(a, never()).confirmPlace(any());
- verify(b).confirmPlace(201L);
- verify(c).confirmPlace(null);
+ // then: 종료된 모임을 CONFIRMED로 변경하지 않음
+ assertThat(confirmed).isFalse();
+ verify(meeting, never()).confirmPlace(any());
}
/**
@@ -912,7 +905,7 @@ private Meeting meetingWithStatus(MeetingStatus status) {
}
private void stubMeetingAndRecommendation(Meeting meeting, Long... validRecommendedPlaceIds) {
- when(meetingRepository.findById(MEETING_ID)).thenReturn(Optional.of(meeting));
+ when(meetingRepository.findByIdForUpdate(MEETING_ID)).thenReturn(Optional.of(meeting));
RecommendationRun run = mock(RecommendationRun.class);
lenient().when(run.getId()).thenReturn(RECOMMENDATION_RUN_ID);
@@ -934,13 +927,6 @@ private void stubMeetingAndAccess(Meeting meeting) {
stubParticipant();
}
- private RecommendationRun completedRunAt(Long meetingId, LocalDateTime updatedAt) {
- RecommendationRun run = mock(RecommendationRun.class);
- lenient().when(run.getMeetingId()).thenReturn(meetingId);
- lenient().when(run.getUpdatedAt()).thenReturn(updatedAt);
- return run;
- }
-
private void stubRecommendedPlaces(
Long placeId1, String googlePlaceId1, int preferenceMatchCnt1,
Long placeId2, String googlePlaceId2, int preferenceMatchCnt2
diff --git a/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java b/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java
new file mode 100644
index 0000000..241cd68
--- /dev/null
+++ b/src/test/java/com/moive/MoiveBE/global/scheduler/MeetingLifecycleSchedulerTest.java
@@ -0,0 +1,90 @@
+package com.moive.MoiveBE.global.scheduler;
+
+import com.moive.MoiveBE.domain.meeting.service.MeetingService;
+import com.moive.MoiveBE.domain.vote.service.PlaceVoteCloseService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataAccessResourceFailureException;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MeetingLifecycleSchedulerTest {
+
+ @Mock private PlaceVoteCloseService placeVoteCloseService;
+ @Mock private MeetingService meetingService;
+
+ @InjectMocks private MeetingLifecycleScheduler scheduler;
+
+ @BeforeEach
+ void setUp() {
+ // 재시도 대기 시간 제거
+ ReflectionTestUtils.setField(scheduler, "completeRetryBackoffMillis", new long[]{0L, 0L});
+ }
+
+ @Test
+ void 자정_배치는_장소_투표_마감_후_모임_종료_순서로_실행한다() {
+ scheduler.run();
+
+ InOrder inOrder = inOrder(placeVoteCloseService, meetingService);
+ inOrder.verify(placeVoteCloseService).finalizeExpiredPlaceVotes();
+ inOrder.verify(meetingService).completeElapsedMeetings();
+ }
+
+ @Test
+ void 장소_투표_마감_단계가_실패해도_모임_종료_단계는_실행하고_예외를_전파하지_않는다() {
+ // given
+ doThrow(new DataAccessResourceFailureException("DB 연결 실패"))
+ .when(placeVoteCloseService).finalizeExpiredPlaceVotes();
+
+ // when & then
+ assertThatCode(() -> scheduler.run()).doesNotThrowAnyException();
+ verify(meetingService).completeElapsedMeetings();
+ }
+
+ @Test
+ void 모임_종료_단계가_일시적으로_실패하면_재시도해_처리한다() {
+ // given: 두 번 실패 후 성공
+ when(meetingService.completeElapsedMeetings())
+ .thenThrow(new DataAccessResourceFailureException("DB 일시 오류"))
+ .thenThrow(new DataAccessResourceFailureException("DB 일시 오류"))
+ .thenReturn(2);
+
+ // when
+ scheduler.run();
+
+ // then
+ verify(meetingService, times(3)).completeElapsedMeetings();
+ }
+
+ @Test
+ void 모임_종료_단계가_계속_실패하면_최대_3회까지만_시도하고_예외를_전파하지_않는다() {
+ // given
+ when(meetingService.completeElapsedMeetings())
+ .thenThrow(new DataAccessResourceFailureException("DB 연결 실패"));
+
+ // when & then
+ assertThatCode(() -> scheduler.run()).doesNotThrowAnyException();
+ verify(meetingService, times(3)).completeElapsedMeetings();
+ }
+
+ @Test
+ void 서버_시작_시에는_모임_종료_단계만_실행한다() {
+ scheduler.completeElapsedMeetingsOnStartup();
+
+ verify(meetingService).completeElapsedMeetings();
+ verifyNoInteractions(placeVoteCloseService);
+ }
+}