diff --git a/build.gradle b/build.gradle index cd2969af9..40cc629a7 100644 --- a/build.gradle +++ b/build.gradle @@ -101,6 +101,9 @@ dependencies { // Spring AI - Google AI(Gemini) 연동 implementation 'org.springframework.ai:spring-ai-starter-model-openai:1.0.1' + + // spring Retry + implementation 'org.springframework.retry:spring-retry' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/loadtest/room_join_load_test.js b/loadtest/room_join_load_test.js index acb2bd71c..95f7c0635 100644 --- a/loadtest/room_join_load_test.js +++ b/loadtest/room_join_load_test.js @@ -14,33 +14,8 @@ const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 const joinLatency = new Trend('rooms_join_latency'); // 참여 API 지연(ms) const http5xx = new Counter('rooms_join_5xx'); // 5xx 개수 const http2xx = new Counter('rooms_join_2xx'); // 2xx 개수 -const http4xx = new Counter('rooms_join_4xx'); // 4xx 개수 - -// 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) -const token_issue_failed = new Counter('token_issue_failed'); -const fail_ROOM_MEMBER_COUNT_EXCEEDED = new Counter('fail_ROOM_MEMBER_COUNT_EXCEEDED'); -const fail_USER_ALREADY_PARTICIPATE = new Counter('fail_USER_ALREADY_PARTICIPATE'); -const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); - -const ERR = { // THIP error code - ROOM_MEMBER_COUNT_EXCEEDED: 100006, - USER_ALREADY_PARTICIPATE: 140005, -}; - -function parseError(res) { - try { - const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 - // BaseResponse: { isSuccess:boolean, code:number, message:string, requestId:string, data:any } - return { - code: Number(j.code), // 정수 코드 - message: j.message || '', - requestId: j.requestId || '', - isSuccess: !!j.isSuccess - }; - } catch (e) { - return { code: NaN, message: '', requestId: '', isSuccess: false }; - } -} +const http400 = new Counter('rooms_join_400'); // 400 개수 +const http423 = new Counter('rooms_join_423'); // 423 개수 // ------------ 시나리오 ------------ // [인기 작가가 만든 모임방에 THIP의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정] @@ -57,6 +32,8 @@ export const options = { }, thresholds: { rooms_join_5xx: ['count==0'], // 서버 오류는 0건이어야 함 + rooms_join_423: ['count>=0'], // 기록용 + rooms_join_400: ['count>=0'], // 기록용 rooms_join_latency: ['p(95)<1000'], // p95 < 1s }, }; @@ -83,7 +60,6 @@ export function setup() { } else { tokens.push(''); // 실패한 자리도 인덱스 유지 - token_issue_failed.add(1); } } sleep(BATCH_PAUSE_S); @@ -124,21 +100,10 @@ export default function (data) { joinLatency.add(res.timings.duration); if (res.status >= 200 && res.status < 300) http2xx.add(1); else if (res.status >= 400 && res.status < 500) { - http4xx.add(1); - const err = parseError(res); - switch (err.code) { - case ERR.ROOM_MEMBER_COUNT_EXCEEDED: - fail_ROOM_MEMBER_COUNT_EXCEEDED.add(1); - break; - case ERR.USER_ALREADY_PARTICIPATE: - fail_USER_ALREADY_PARTICIPATE.add(1); - break; - default: - fail_OTHER_4XX.add(1); - } - } else if (res.status >= 500) { - http5xx.add(1); + if (res.status === 400) http400.add(1); + else if (res.status === 423) http423.add(1); } + else if (res.status >= 500) http5xx.add(1); // === 검증 === check(res, { diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index b7fb384c0..bb262ba7d 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -32,6 +32,8 @@ public enum ErrorCode implements ResponseCode { PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."), + RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), + /* 60000부터 비즈니스 예외 */ /** * 60000 : alias error diff --git a/src/main/java/konkuk/thip/config/RetryConfig.java b/src/main/java/konkuk/thip/config/RetryConfig.java new file mode 100644 index 000000000..069c5f86d --- /dev/null +++ b/src/main/java/konkuk/thip/config/RetryConfig.java @@ -0,0 +1,9 @@ +package konkuk.thip.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry(proxyTargetClass = true) +public class RetryConfig { +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java index e54611c2e..6b231c618 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java @@ -10,7 +10,17 @@ import org.hibernate.annotations.SQLDelete; @Entity -@Table(name = "room_participants") +@Table( + name = "room_participants", + uniqueConstraints = { + @UniqueConstraint( + // TODO : room_participant가 soft delete 된 경우에도 unique 제약조건은 여전히 유효 + // room_participant가 삭제된 방에 다시 참여하는 경우는 일단 고려 X + name = "uk_room_participant_user_room", + columnNames = {"user_id", "room_id"} + ) + } +) @Getter @SQLDelete(sql = "UPDATE room_participants SET status = 'INACTIVE' WHERE room_participant_id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -53,6 +63,11 @@ public void updateUserPercentage(double userPercentage) { this.userPercentage = userPercentage; } + @VisibleForTesting + public void updateRoleToHost() { + this.roomParticipantRole = RoomParticipantRole.HOST; + } + public void updateFrom(RoomParticipant roomParticipant) { this.currentPage = roomParticipant.getCurrentPage(); this.userPercentage = roomParticipant.getUserPercentage(); diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java index 76c0053d7..4fdc5fdac 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java @@ -34,6 +34,14 @@ public Room getByIdOrThrow(Long id) { return roomMapper.toDomainEntity(roomJpaEntity); } + @Override + public Room getByIdForUpdate(Long id) { + RoomJpaEntity roomJpaEntity = roomJpaRepository.findByRoomIdForUpdate(id).orElseThrow( + () -> new EntityNotFoundException(ROOM_NOT_FOUND) + ); + return roomMapper.toDomainEntity(roomJpaEntity); + } + @Override public Optional findById(Long id) { return roomJpaRepository.findByRoomId(id) diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java index 905a89a69..431e4a64b 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomJpaRepository.java @@ -1,9 +1,9 @@ package konkuk.thip.room.adapter.out.persistence.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import java.util.List; @@ -16,6 +16,13 @@ public interface RoomJpaRepository extends JpaRepository, R */ Optional findByRoomId(Long roomId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RoomJpaEntity r WHERE r.roomId = :roomId") + @QueryHints( + @QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000") + ) + Optional findByRoomIdForUpdate(@Param("roomId") Long roomId); + @Query("SELECT COUNT(r) FROM RoomJpaEntity r " + "WHERE r.bookJpaEntity.isbn = :isbn " + "AND r.roomStatus = 'RECRUITING'") diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java index cfa305590..6c7e70d23 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java @@ -17,6 +17,8 @@ default Room getByIdOrThrow(Long id) { .orElseThrow(() -> new EntityNotFoundException(ROOM_NOT_FOUND)); } + Room getByIdForUpdate(Long id); + Long save(Room room); void update(Room room); diff --git a/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java index 2ed6b73e7..7364b0963 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java @@ -1,6 +1,8 @@ package konkuk.thip.room.application.service; +import jakarta.persistence.LockTimeoutException; import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.room.application.port.in.RoomJoinUseCase; @@ -14,13 +16,19 @@ import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Service @RequiredArgsConstructor +@Slf4j public class RoomJoinService implements RoomJoinUseCase { private final RoomCommandPort roomCommandPort; @@ -30,13 +38,26 @@ public class RoomJoinService implements RoomJoinUseCase { private final RoomNotificationOrchestrator roomNotificationOrchestrator; @Override - @Transactional + @Retryable( + retryFor = { + LockTimeoutException.class + }, // 재시도 대상 예외 + noRetryFor = { + InvalidStateException.class, BusinessException.class + }, // 제시도 제외 예외 + maxAttempts = 2, + backoff = @Backoff(delay = 100, multiplier = 2) + ) + @Transactional(propagation = Propagation.REQUIRES_NEW) // 재시도마다 새로운 트랜잭션 public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { RoomJoinType type = roomJoinCommand.type(); // 방이 존재하지 않거나 모집기간이 만료된 경우 예외 처리 - Room room = roomCommandPort.findById(roomJoinCommand.roomId()) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_CANNOT_JOIN_OR_CANCEL)); +// Room room = roomCommandPort.findById(roomJoinCommand.roomId()) +// .orElseThrow(() -> new BusinessException(ErrorCode.USER_CANNOT_JOIN_OR_CANCEL)); + + /** x-lock 획득하여 room 조회 **/ + Room room = roomCommandPort.getByIdForUpdate(roomJoinCommand.roomId()); room.validateRoomRecruitExpired(); @@ -59,6 +80,21 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { return RoomJoinResult.of(room.getId(), type.getType()); } + @Recover + public RoomJoinResult recoverLockTimeout(LockTimeoutException e, RoomJoinCommand roomJoinCommand) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + + @Recover + public RoomJoinResult recoverInvalidStateException(InvalidStateException e, RoomJoinCommand roomJoinCommand) { + throw e; + } + + @Recover + public RoomJoinResult recoverBusinessException(BusinessException e, RoomJoinCommand roomJoinCommand) { + throw e; + } + private void sendNotifications(RoomJoinCommand roomJoinCommand, Room room) { RoomParticipant targetUser = roomParticipantCommandPort.findHostByRoomId(room.getId()); User actorUser = userCommandPort.findById(roomJoinCommand.userId()); @@ -99,6 +135,4 @@ private void validateCancelable(RoomParticipant roomParticipant) { throw new BusinessException(ErrorCode.HOST_CANNOT_CANCEL); } } - - } diff --git a/src/main/resources/db/migration/V251229__Add_unique_room_participants.sql b/src/main/resources/db/migration/V251229__Add_unique_room_participants.sql new file mode 100644 index 000000000..f4b6e90e1 --- /dev/null +++ b/src/main/resources/db/migration/V251229__Add_unique_room_participants.sql @@ -0,0 +1,4 @@ +-- (user_id, room_id) 조합 유니크 제약 추가 +ALTER TABLE room_participants + ADD CONSTRAINT uk_room_participant_user_room + UNIQUE (user_id, room_id); \ No newline at end of file diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java index cf6913a3c..fc1e3e583 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java @@ -5,6 +5,7 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.domain.value.RoomParticipantRole; @@ -15,6 +16,8 @@ import konkuk.thip.user.domain.value.UserRole; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -22,9 +25,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.HashMap; @@ -34,17 +37,21 @@ import static konkuk.thip.room.application.port.in.dto.RoomJoinType.CANCEL; import static konkuk.thip.room.application.port.in.dto.RoomJoinType.JOIN; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @ActiveProfiles("test") -@Transactional +//@Transactional @AutoConfigureMockMvc(addFilters = false) @DisplayName("[통합] 방 참여/취소 API 통합 테스트") class RoomJoinApiTest { @Autowired private MockMvc mockMvc; + @MockitoBean private RoomNotificationOrchestrator roomNotificationOrchestrator; @Autowired private ObjectMapper objectMapper; @Autowired private RoomJpaRepository roomJpaRepository; @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; @@ -57,6 +64,20 @@ class RoomJoinApiTest { private UserJpaEntity participant; private RoomParticipantJpaEntity memberParticipation; + @BeforeEach + void mockNotification() { // 알림 서비스 모킹 + doNothing().when(roomNotificationOrchestrator) + .notifyRoomJoinToHost(anyLong(), anyLong(), anyString(), anyLong(), anyString()); + } + + @AfterEach + void tearDown() { + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + private void setUpWithOnlyHost() { Alias alias = TestEntityFactory.createLiteratureAlias(); createUsers(alias); @@ -134,7 +155,6 @@ void joinRoom_success() throws Exception { assertThat(room.getMemberCount()).isEqualTo(2); // 방 생성 시 1명 + 참여 1명 } - @Test @DisplayName("방 중복 참여 실패") void joinRoom_alreadyParticipated() throws Exception { @@ -167,8 +187,8 @@ void cancelJoin_success() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); - em.flush(); - em.clear(); +// em.flush(); +// em.clear(); // 참여자 삭제 확인 RoomParticipantJpaEntity member = roomParticipantJpaRepository.findById(memberParticipation.getRoomParticipantId()).orElse(null); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingOrExpiredDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingOrExpiredDetailViewApiTest.java index 4df3fb245..97c4c39f9 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingOrExpiredDetailViewApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingOrExpiredDetailViewApiTest.java @@ -1,5 +1,6 @@ package konkuk.thip.room.adapter.in.web; +import jakarta.persistence.EntityManager; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.DateUtil; @@ -49,6 +50,8 @@ class RoomPlayingOrExpiredDetailViewApiTest { @Autowired private MockMvc mockMvc; + @Autowired private EntityManager em; + @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private RoomJpaRepository roomJpaRepository; @@ -56,7 +59,6 @@ class RoomPlayingOrExpiredDetailViewApiTest { @Autowired private VoteJpaRepository voteJpaRepository; @Autowired private VoteItemJpaRepository voteItemJpaRepository; - private RoomJpaEntity saveScienceRoom(String bookTitle, String isbn, String roomName, LocalDate startDate, RoomStatus roomStatus) { BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() .title(bookTitle) @@ -146,15 +148,12 @@ void get_expired_room_detail() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-지난달-활동시작", LocalDate.now().minusDays(31),EXPIRED); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.MEMBER) // Member - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + joiningMember.updateCurrentPage(50); + joiningMember.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); createVoteToRoom(joiningMember.getUserJpaEntity(), room, 2); // 2개의 투표 생성 @@ -194,15 +193,12 @@ void get_playing_room_detail() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1),IN_PROGRESS); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.MEMBER) // Member - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + joiningMember.updateCurrentPage(50); + joiningMember.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); createVoteToRoom(joiningMember.getUserJpaEntity(), room, 2); // 2개의 투표 생성 @@ -242,15 +238,14 @@ void get_playing_room_detail_host() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1),IN_PROGRESS); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity roomHost = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.HOST) // HOST - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity roomHost = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + roomHost.updateRoleToHost(); + roomHost.updateCurrentPage(50); + roomHost.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); + createVoteToRoom(roomHost.getUserJpaEntity(), room, 2); // 2개의 투표 생성 @@ -290,15 +285,12 @@ void get_playing_room_detail_not_belong_to_room() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1),IN_PROGRESS); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.MEMBER) // Member - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + joiningMember.updateCurrentPage(50); + joiningMember.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); createVoteToRoom(joiningMember.getUserJpaEntity(), room, 2); // 2개의 투표 생성 @@ -316,15 +308,12 @@ void get_playing_room_detail_too_many_votes() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1),IN_PROGRESS); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.MEMBER) // Member - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + joiningMember.updateCurrentPage(50); + joiningMember.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); createVoteToRoom(joiningMember.getUserJpaEntity(), room, 6); // 6개의 투표 생성 @@ -368,15 +357,12 @@ void get_playing_room_detail_no_votes() throws Exception { //given RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1),IN_PROGRESS); saveUsersToRoom(room, 4); - RoomParticipantJpaEntity roomParticipantJpaEntity = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); - roomParticipantJpaRepository.delete(roomParticipantJpaEntity); - RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(roomParticipantJpaEntity.getUserJpaEntity()) - .roomJpaEntity(roomParticipantJpaEntity.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.MEMBER) // Member - .currentPage(50) // 현재 member의 마지막 활동 page - .userPercentage(10.6) // 현재 member의 활동 percentage - .build()); + + RoomParticipantJpaEntity joiningMember = roomParticipantJpaRepository.findAllByRoomId(room.getRoomId()).get(0); + joiningMember.updateCurrentPage(50); + joiningMember.updateUserPercentage(10.6); + roomParticipantJpaRepository.flush(); + em.clear(); createVoteToRoom(joiningMember.getUserJpaEntity(), room, 0); // 투표 생성 X diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java index f2959bfe7..508feae0d 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java @@ -185,13 +185,9 @@ void get_recruiting_room_detail_host() throws Exception { //given RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10, RoomStatus.RECRUITING); saveUsersToRoom(targetRoom, 4); - RoomParticipantJpaEntity firstMember = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1); - roomParticipantJpaRepository.delete(firstMember); - RoomParticipantJpaEntity roomCreator = roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() - .userJpaEntity(firstMember.getUserJpaEntity()) - .roomJpaEntity(firstMember.getRoomJpaEntity()) - .roomParticipantRole(RoomParticipantRole.HOST) - .build()); // firstMember 을 MEMBER -> HOST 로 수정 + + RoomParticipantJpaEntity roomHost = roomParticipantJpaRepository.findAllByRoomId(targetRoom.getRoomId()).get(1); + roomHost.updateRoleToHost(); RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10, RoomStatus.RECRUITING); saveUsersToRoom(science_room_2, 5); @@ -210,7 +206,7 @@ void get_recruiting_room_detail_host() throws Exception { //when ResultActions result = mockMvc.perform(get("/rooms/{roomId}/recruiting", targetRoom.getRoomId()) - .requestAttr("userId", roomCreator.getUserJpaEntity().getUserId())); + .requestAttr("userId", roomHost.getUserJpaEntity().getUserId())); //then result.andExpect(status().isOk()) diff --git a/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java index a7ad258f6..e2e040286 100644 --- a/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java +++ b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java @@ -65,7 +65,8 @@ class Join { void alreadyParticipated() { RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, JOIN); - given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); +// given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(roomCommandPort.getByIdForUpdate(ROOM_ID)).willReturn(room); given(roomParticipantCommandPort.findByUserIdAndRoomIdOptional(USER_ID, ROOM_ID)) .willReturn(Optional.of(RoomParticipant.memberWithoutId(USER_ID, ROOM_ID))); @@ -79,7 +80,8 @@ void alreadyParticipated() { void successJoin() { RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, JOIN); - given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); +// given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(roomCommandPort.getByIdForUpdate(ROOM_ID)).willReturn(room); given(roomParticipantCommandPort.findByUserIdAndRoomIdOptional(USER_ID, ROOM_ID)) .willReturn(Optional.empty()); given(roomParticipantCommandPort.findHostByRoomId(any())) @@ -110,7 +112,8 @@ class Cancel { void notParticipated() { RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, CANCEL); - given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); +// given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(roomCommandPort.getByIdForUpdate(ROOM_ID)).willReturn(room); given(roomParticipantCommandPort.findByUserIdAndRoomIdOptional(USER_ID, ROOM_ID)) .willReturn(Optional.empty()); @@ -125,7 +128,8 @@ void successCancel() { RoomJoinCommand command = new RoomJoinCommand(USER_ID, ROOM_ID, CANCEL); RoomParticipant participant = RoomParticipant.memberWithoutId(USER_ID, ROOM_ID); - given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); +// given(roomCommandPort.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(roomCommandPort.getByIdForUpdate(ROOM_ID)).willReturn(room); given(roomParticipantCommandPort.findByUserIdAndRoomIdOptional(USER_ID, ROOM_ID)) .willReturn(Optional.of(participant)); diff --git a/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java b/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java index d2437deb6..7598336a4 100644 --- a/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java +++ b/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java @@ -53,7 +53,7 @@ void room_join_test_in_multi_thread() throws Exception { int requestUserCount = 500; BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); - // 모집인원 10명 방 생성 + // 모집인원 10명 방 생성 -> HOST 1명 + MEMBER 9명 가능 RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createCustomRoom(book, Category.LITERATURE, 10)); List savedUserIds = createUsersRange(requestUserCount + 1); @@ -85,7 +85,7 @@ void room_join_test_in_multi_thread() throws Exception { .andExpect(status().isOk()); return 200; } catch (AssertionError e) { - return 400; + return -1; // 응답이 200이 아닌 경우 (4xx, 5xx error 모두) } finally { finish.countDown(); } @@ -132,11 +132,11 @@ void room_join_test_in_multi_thread() throws Exception { */ // 1) participants가 recruitCount 보다 커질 수 있음 - assertThat(participantRows).isGreaterThan(recruit); + assertThat(participantRows).isGreaterThanOrEqualTo(recruit); // 2) memberCount가 실제 participants 수보다 작을 수 있음 // memberCount 값은 Room 도메인 규칙에 의해 recruitCount를 초과하여 증가하지 않음 - assertThat(memberCountInRoom).isLessThan((int) participantRows); + assertThat(memberCountInRoom).isLessThanOrEqualTo((int) participantRows); } private List createUsersRange(long count) {