From 0567ce95252f992a1dcb469bbe5a04576921fd1a Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 9 Nov 2025 17:26:50 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[feat]=20x-lock=20=ED=9A=8D=EB=93=9D?= =?UTF-8?q?=ED=95=98=EC=97=AC=20Room=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/RoomCommandPersistenceAdapter.java | 8 ++++++++ .../persistence/repository/RoomJpaRepository.java | 13 ++++++++++--- .../room/application/port/out/RoomCommandPort.java | 2 ++ 3 files changed, 20 insertions(+), 3 deletions(-) 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); From d331f9f06655e51c76cf5e7a2eb91d792404680d Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 9 Nov 2025 17:27:30 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[chore]=20spring=20retry=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/java/konkuk/thip/config/RetryConfig.java | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/konkuk/thip/config/RetryConfig.java 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/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 { +} From b3e31978953976bb7522553593be225f3f0354da Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 9 Nov 2025 17:28:47 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[refactor]=20=EB=B9=84=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BD=20+=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EB=AA=A8=EC=9E=84=EB=B0=A9=20=EC=B0=B8=EC=97=AC=20service=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/common/exception/code/ErrorCode.java | 2 ++ .../application/service/RoomJoinService.java | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) 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/room/application/service/RoomJoinService.java b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java index 2ed6b73e7..f197eaabf 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,7 @@ package konkuk.thip.room.application.service; 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,7 +15,11 @@ import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +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; @@ -30,13 +35,21 @@ public class RoomJoinService implements RoomJoinUseCase { private final RoomNotificationOrchestrator roomNotificationOrchestrator; @Override - @Transactional + @Retryable( + noRetryFor = {InvalidStateException.class, BusinessException.class}, // 재시도 제외 예외 + maxAttempts = 3, + 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)); + + /** 락 타잉아웃 발생 포인트 **/ + Room room = roomCommandPort.getByIdForUpdate(roomJoinCommand.roomId()); room.validateRoomRecruitExpired(); @@ -59,6 +72,11 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { return RoomJoinResult.of(room.getId(), type.getType()); } + @Recover + public RoomJoinResult recover(Exception e, RoomJoinCommand roomJoinCommand) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + private void sendNotifications(RoomJoinCommand roomJoinCommand, Room room) { RoomParticipant targetUser = roomParticipantCommandPort.findHostByRoomId(room.getId()); User actorUser = userCommandPort.findById(roomJoinCommand.userId()); From b5ff13e58b2d097bab711bb0d724e5c8c22367c5 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 9 Nov 2025 17:34:03 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[refactor]=20=EB=AA=A8=EC=9E=84=EB=B0=A9?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=20=EB=A9=80=ED=8B=B0=EC=93=B0=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 정합성이 맞는 결과도 포함할 수 있도록 assert 코드 수정 --- .../thip/room/concurrency/RoomJoinConcurrencyTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java b/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java index d2437deb6..f0339ce15 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); @@ -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) { From ab03c5c586b07d999c45b52e464d21f1113dc47a Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Sun, 9 Nov 2025 17:35:36 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[refactor]=20=EB=AA=A8=EC=9E=84=EB=B0=A9?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=20k6=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모임방 참여 service 코드에 추가된 'RESOURCE_LOCKED exception throw' 를 커버하기 위해, HTTP 423 error도 캐치할 수 있도록 스크립트 수정 --- loadtest/room_join_load_test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/loadtest/room_join_load_test.js b/loadtest/room_join_load_test.js index acb2bd71c..901ccbd94 100644 --- a/loadtest/room_join_load_test.js +++ b/loadtest/room_join_load_test.js @@ -20,11 +20,13 @@ const http4xx = new Counter('rooms_join_4xx'); // 4xx 개수 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_RESOURCE_LOCKED = new Counter('fail_RESOURCE_LOCKED'); // 423 Locked error const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); const ERR = { // THIP error code ROOM_MEMBER_COUNT_EXCEEDED: 100006, USER_ALREADY_PARTICIPATE: 140005, + RESOURCE_LOCKED: 50200, }; function parseError(res) { @@ -133,6 +135,9 @@ export default function (data) { case ERR.USER_ALREADY_PARTICIPATE: fail_USER_ALREADY_PARTICIPATE.add(1); break; + case ERR.RESOURCE_LOCKED: + fail_RESOURCE_LOCKED.add(1); + break; default: fail_OTHER_4XX.add(1); } From 9fd4169e0992d5b0519098fce37c8ff3790fa778 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 10 Nov 2025 00:59:28 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[fix]=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20service=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Room 엔티티 조회하는 메서드 변경으로 인한 테스트 코드 수정 --- .../application/service/RoomJoinServiceTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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)); From f6d805e71542eeb40f52ca3446b301fa343e52a0 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 10 Nov 2025 01:02:40 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[fix]=20=EB=AA=A8=EC=9E=84=EB=B0=A9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20service=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 클래스의 트랜잭션 어노테이션 제거(수정된 모임방 참여 service 메서드가 매번 새로운 트랜잭션을 열기 떄문) - MySQL, H2 DB의 락 획득 방식 차이로 인해 기존 테스트 코드 주석 처리 - test yml에 TIMEOUT 값 명시적으로 설정해도 테스트 코드 제대로 동작하지 X - 일단 깨지는 테스트 메서드는 주석 처리 - 운영 환경과의 동일한 테스트를 위해 test DB를 MySQL로 변경하는게 좋아보임 --- .../room/adapter/in/web/RoomJoinApiTest.java | 104 ++++++++++++------ 1 file changed, 68 insertions(+), 36 deletions(-) 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..982cdea09 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,23 +155,28 @@ void joinRoom_success() throws Exception { assertThat(room.getMemberCount()).isEqualTo(2); // 방 생성 시 1명 + 참여 1명 } - - @Test - @DisplayName("방 중복 참여 실패") - void joinRoom_alreadyParticipated() throws Exception { - // 이미 참여한 상태로 설정 - setUpWithParticipant(); - - Map request = new HashMap<>(); - request.put("type", JOIN.getType()); - - ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") - .requestAttr("userId", participant.getUserId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - result.andExpect(status().isBadRequest()); - } + /** + * 400 error 가 아니라 재시도 횟수 초과로 인해 423 error 발생 + * H2 DB에서 select for update 패턴으로 락 획득할 시에 계속 예외 발생 -> 재시도 반복하여 테스트 의도대로 400 error 응답 X + * test yml에 LOCK_TIMEOUT 명시적으로 설정해도 해결 X + * 일단 주석 처리 + */ +// @Test +// @DisplayName("방 중복 참여 실패") +// void joinRoom_alreadyParticipated() throws Exception { +// // 이미 참여한 상태로 설정 +// setUpWithParticipant(); +// +// Map request = new HashMap<>(); +// request.put("type", JOIN.getType()); +// +// ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") +// .requestAttr("userId", participant.getUserId()) +// .contentType(MediaType.APPLICATION_JSON) +// .content(objectMapper.writeValueAsString(request))); +// +// result.andExpect(status().isBadRequest()); +// } @Test @DisplayName("방 참여 취소 성공 - 참여자 제거 및 인원수 감소 확인") @@ -167,8 +193,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); @@ -179,19 +205,25 @@ void cancelJoin_success() throws Exception { assertThat(room.getMemberCount()).isEqualTo(1); // 다시 원래 인원 } - @Test - @DisplayName("방 미참여자 취소 실패") - void cancelJoin_notParticipated() throws Exception { - setUpWithOnlyHost(); - - Map request = new HashMap<>(); - request.put("type", CANCEL.getType()); - - ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") - .requestAttr("userId", participant.getUserId()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - result.andExpect(status().isBadRequest()); - } + /** + * 400 error 가 아니라 재시도 횟수 초과로 인해 423 error 발생 + * H2 DB에서 select for update 패턴으로 락 획득할 시에 계속 예외 발생 -> 재시도 반복하여 테스트 의도대로 400 error 응답 X + * test yml에 LOCK_TIMEOUT 명시적으로 설정해도 해결 X + * 일단 주석 처리 + */ +// @Test +// @DisplayName("방 미참여자 취소 실패") +// void cancelJoin_notParticipated() throws Exception { +// setUpWithOnlyHost(); +// +// Map request = new HashMap<>(); +// request.put("type", CANCEL.getType()); +// +// ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") +// .requestAttr("userId", participant.getUserId()) +// .contentType(MediaType.APPLICATION_JSON) +// .content(objectMapper.writeValueAsString(request))); +// +// result.andExpect(status().isBadRequest()); +// } } From a182cc835e7707725d4b731530b4333aaf5e3c1e Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 10 Nov 2025 01:19:47 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[refactor]=20=EB=AA=A8=EC=9E=84=EB=B0=A9?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=20service=20=EB=A9=80=ED=8B=B0=EC=93=B0?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멀티쓰레드 환경에서 동시에 모임방 참여 요청을 보낼 때, 200 OK 응답 수와 DB 데이터 정합성을 확인하는게 목적이라 4xx, 5xx error의 구분은 X --- .../konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java b/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java index f0339ce15..7598336a4 100644 --- a/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java +++ b/src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java @@ -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(); } From 87e4d12b25bbe1402825d22c6eb848a38fb69c8d Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 29 Dec 2025 18:31:27 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[refactor]=20=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20api=20service=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InvalidException, BusinessException에 대한 @Recover 메서드 추가 - @Retryable 메서드 내에서 발생한 exception은 모두 @Recover 설정해줘야함 --- .../application/service/RoomJoinService.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) 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 f197eaabf..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,5 +1,6 @@ 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; @@ -15,6 +16,7 @@ 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; @@ -26,6 +28,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class RoomJoinService implements RoomJoinUseCase { private final RoomCommandPort roomCommandPort; @@ -36,8 +39,13 @@ public class RoomJoinService implements RoomJoinUseCase { @Override @Retryable( - noRetryFor = {InvalidStateException.class, BusinessException.class}, // 재시도 제외 예외 - maxAttempts = 3, + retryFor = { + LockTimeoutException.class + }, // 재시도 대상 예외 + noRetryFor = { + InvalidStateException.class, BusinessException.class + }, // 제시도 제외 예외 + maxAttempts = 2, backoff = @Backoff(delay = 100, multiplier = 2) ) @Transactional(propagation = Propagation.REQUIRES_NEW) // 재시도마다 새로운 트랜잭션 @@ -48,7 +56,7 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { // 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(); @@ -73,10 +81,20 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { } @Recover - public RoomJoinResult recover(Exception e, RoomJoinCommand roomJoinCommand) { + 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()); @@ -117,6 +135,4 @@ private void validateCancelable(RoomParticipant roomParticipant) { throw new BusinessException(ErrorCode.HOST_CANNOT_CANCEL); } } - - } From bf5ad7902179672d5b416515a92b1fbd6e990cfc Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 29 Dec 2025 18:31:58 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[refactor]=20=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20API=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InvalidException, BusinessException에 대한 @Recover 메서드 추가로 인해 테스트 코드 주석 해제 --- .../room/adapter/in/web/RoomJoinApiTest.java | 74 ++++++++----------- 1 file changed, 31 insertions(+), 43 deletions(-) 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 982cdea09..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 @@ -155,28 +155,22 @@ void joinRoom_success() throws Exception { assertThat(room.getMemberCount()).isEqualTo(2); // 방 생성 시 1명 + 참여 1명 } - /** - * 400 error 가 아니라 재시도 횟수 초과로 인해 423 error 발생 - * H2 DB에서 select for update 패턴으로 락 획득할 시에 계속 예외 발생 -> 재시도 반복하여 테스트 의도대로 400 error 응답 X - * test yml에 LOCK_TIMEOUT 명시적으로 설정해도 해결 X - * 일단 주석 처리 - */ -// @Test -// @DisplayName("방 중복 참여 실패") -// void joinRoom_alreadyParticipated() throws Exception { -// // 이미 참여한 상태로 설정 -// setUpWithParticipant(); -// -// Map request = new HashMap<>(); -// request.put("type", JOIN.getType()); -// -// ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") -// .requestAttr("userId", participant.getUserId()) -// .contentType(MediaType.APPLICATION_JSON) -// .content(objectMapper.writeValueAsString(request))); -// -// result.andExpect(status().isBadRequest()); -// } + @Test + @DisplayName("방 중복 참여 실패") + void joinRoom_alreadyParticipated() throws Exception { + // 이미 참여한 상태로 설정 + setUpWithParticipant(); + + Map request = new HashMap<>(); + request.put("type", JOIN.getType()); + + ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isBadRequest()); + } @Test @DisplayName("방 참여 취소 성공 - 참여자 제거 및 인원수 감소 확인") @@ -205,25 +199,19 @@ void cancelJoin_success() throws Exception { assertThat(room.getMemberCount()).isEqualTo(1); // 다시 원래 인원 } - /** - * 400 error 가 아니라 재시도 횟수 초과로 인해 423 error 발생 - * H2 DB에서 select for update 패턴으로 락 획득할 시에 계속 예외 발생 -> 재시도 반복하여 테스트 의도대로 400 error 응답 X - * test yml에 LOCK_TIMEOUT 명시적으로 설정해도 해결 X - * 일단 주석 처리 - */ -// @Test -// @DisplayName("방 미참여자 취소 실패") -// void cancelJoin_notParticipated() throws Exception { -// setUpWithOnlyHost(); -// -// Map request = new HashMap<>(); -// request.put("type", CANCEL.getType()); -// -// ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") -// .requestAttr("userId", participant.getUserId()) -// .contentType(MediaType.APPLICATION_JSON) -// .content(objectMapper.writeValueAsString(request))); -// -// result.andExpect(status().isBadRequest()); -// } + @Test + @DisplayName("방 미참여자 취소 실패") + void cancelJoin_notParticipated() throws Exception { + setUpWithOnlyHost(); + + Map request = new HashMap<>(); + request.put("type", CANCEL.getType()); + + ResultActions result = mockMvc.perform(post("/rooms/" + room.getRoomId() + "/join") + .requestAttr("userId", participant.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isBadRequest()); + } } From 51835dad6695a8a613d2f58b4ba165fc24cc2693 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 29 Dec 2025 18:32:53 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[refactor]=20=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20API=20=EB=B6=80=ED=95=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 수행 결과의 400, 423 응답 구분 --- loadtest/room_join_load_test.js | 54 +++++---------------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/loadtest/room_join_load_test.js b/loadtest/room_join_load_test.js index 901ccbd94..95f7c0635 100644 --- a/loadtest/room_join_load_test.js +++ b/loadtest/room_join_load_test.js @@ -14,35 +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_RESOURCE_LOCKED = new Counter('fail_RESOURCE_LOCKED'); // 423 Locked error -const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); - -const ERR = { // THIP error code - ROOM_MEMBER_COUNT_EXCEEDED: 100006, - USER_ALREADY_PARTICIPATE: 140005, - RESOURCE_LOCKED: 50200, -}; - -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의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정] @@ -59,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 }, }; @@ -85,7 +60,6 @@ export function setup() { } else { tokens.push(''); // 실패한 자리도 인덱스 유지 - token_issue_failed.add(1); } } sleep(BATCH_PAUSE_S); @@ -126,24 +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; - case ERR.RESOURCE_LOCKED: - fail_RESOURCE_LOCKED.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, { From 37e4a4b57f2c2dc7d229405f9bff88588b36c2fb Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 29 Dec 2025 18:33:41 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[chore]=20room=5Fparticipants=20unique=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (user, room) 쌍에 대해 unique 제약 추가 --- .../out/jpa/RoomParticipantJpaEntity.java | 17 ++++++++++++++++- .../V251229__Add_unique_room_participants.sql | 4 ++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V251229__Add_unique_room_participants.sql 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/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 From 31f79f8dbb675339d1f3f3561dc164730bbbd591 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Mon, 29 Dec 2025 18:34:17 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[fix]=20room=5Fparticipants=20unique=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...RoomPlayingOrExpiredDetailViewApiTest.java | 96 ++++++++----------- .../web/RoomRecruitingDetailViewApiTest.java | 12 +-- 2 files changed, 45 insertions(+), 63 deletions(-) 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())