Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6d2ddea
Merge branch 'test/#325-room-join-concurrency' into fix/#330-room-joi…
seongjunnoh Oct 31, 2025
0567ce9
[feat] x-lock 획득하여 Room 엔티티 조회하는 영속성 메서드 추가 (#330)
seongjunnoh Nov 9, 2025
d331f9f
[chore] spring retry 의존성 추가 (#330)
seongjunnoh Nov 9, 2025
b3e3197
[refactor] 비관적 락 + 재시도 로직을 적용하여 모임방 참여 service 코드 수정 (#330)
seongjunnoh Nov 9, 2025
b5ff13e
[refactor] 모임방 참여 멀티쓰레드 테스트코드 수정 (#330)
seongjunnoh Nov 9, 2025
ab03c5c
[refactor] 모임방 참여 k6 부하테스트 스크립트 수정 (#330)
seongjunnoh Nov 9, 2025
9fd4169
[fix] 모임방 참여 service 단위 테스트 코드 수정 (#330)
seongjunnoh Nov 9, 2025
f6d805e
[fix] 모임방 참여 service 통합 테스트 코드 수정 (#330)
seongjunnoh Nov 9, 2025
30c61d3
Merge remote-tracking branch 'origin' into fix/#330-room-join-server-…
seongjunnoh Nov 9, 2025
a182cc8
[refactor] 모임방 참여 service 멀티쓰레드 테스트 코드 수정 (#330)
seongjunnoh Nov 9, 2025
87e4d12
[refactor] 방 참여 api service 코드 수정 (#330)
seongjunnoh Dec 29, 2025
bf5ad79
[refactor] 방 참여 API 통합 테스트 코드 수정 (#330)
seongjunnoh Dec 29, 2025
51835da
[refactor] 방 참여 API 부하 테스트 스크립트 수정 (#330)
seongjunnoh Dec 29, 2025
37e4a4b
[chore] room_participants unique 제약 조건 추가 (#330)
seongjunnoh Dec 29, 2025
31f79f8
[fix] room_participants unique 제약 조건 추가에 따른 테스트 코드 수정 (#330)
seongjunnoh Dec 29, 2025
7bfe838
Merge remote-tracking branch 'origin' into fix/#330-room-join-server-…
seongjunnoh Dec 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 7 additions & 42 deletions loadtest/room_join_load_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정]
Expand All @@ -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
},
};
Expand All @@ -83,7 +60,6 @@ export function setup() {
}
else {
tokens.push(''); // 실패한 자리도 인덱스 유지
token_issue_failed.add(1);
}
}
sleep(BATCH_PAUSE_S);
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/konkuk/thip/config/RetryConfig.java
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
}
)
Comment on lines +13 to +23
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

소프트 딜리트와 유니크 제약 조건 조합의 데이터 정합성 문제를 해결해야 합니다.

TODO 주석에서 언급한 것처럼, 현재 구현은 중대한 제약사항이 있습니다:

  • status = 'INACTIVE'로 소프트 딜리트된 참여자도 유니크 제약 조건에 포함됩니다
  • 사용자가 방을 나갔다가 다시 참여하려 하면 유니크 제약 위반으로 실패합니다
  • "일단 고려 X"라고 했지만, 이는 실제로 발생 가능한 일반적인 시나리오입니다

해결 방안:

  1. PostgreSQL의 경우 partial unique index 사용: WHERE status = 'ACTIVE'
  2. MySQL의 경우 별도 컬럼으로 유니크 제약 관리 (예: unique_key = CASE WHEN status='ACTIVE' THEN CONCAT(user_id,'_',room_id) ELSE NULL END)
  3. Hard delete 전환 고려 (감사 로그가 필요하면 별도 이력 테이블 사용)

이 문제에 대한 구체적인 해결 방안을 제시해 드릴까요?

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java
around lines 13–23 the current JPA @UniqueConstraint on (user_id, room_id)
conflicts with soft-delete (status='INACTIVE') and prevents rejoining; remove
the static UniqueConstraint and instead implement a DB-compatible fix: for
PostgreSQL create a partial unique index on (user_id, room_id) WHERE
status='ACTIVE'; for MySQL add a computed/auxiliary column (e.g., unique_key)
that is populated only when status='ACTIVE' and add a unique constraint on that
column (or use a functional index if supported); update the JPA entity to
include the status field and the auxiliary column mapping (or keep the index
managed by migrations), and add a migration script to drop the old unique
constraint and create the new index/column so active participants remain unique
while inactive rows do not cause violations.

@Getter
@SQLDelete(sql = "UPDATE room_participants SET status = 'INACTIVE' WHERE room_participant_id = ?")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Room> findById(Long id) {
return roomJpaRepository.findByRoomId(id)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +16,13 @@ public interface RoomJpaRepository extends JpaRepository<RoomJpaEntity, Long>, R
*/
Optional<RoomJpaEntity> 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<RoomJpaEntity> findByRoomIdForUpdate(@Param("roomId") Long roomId);

@Query("SELECT COUNT(r) FROM RoomJpaEntity r " +
"WHERE r.bookJpaEntity.isbn = :isbn " +
"AND r.roomStatus = 'RECRUITING'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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());
Copy link
Member

Choose a reason for hiding this comment

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

p3: 존재하지 않는 방에대해 방참여/취소 요청에대한 예외를 기존에는 USER_CANNOT_JOIN_OR_CANCEL로 처리했었는데 수정된 코드에서는 ROOM_NOT_FOUND로 처리하고있는데 이렇게 수정하신 이유가 따로있나용??

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Room 엔티티 조회 시 x-lock을 걸고 조회하게끔 수정하는데 집중하다보니, 기존 코드처럼 조회가 되지 않을 경우에 대한 고려를 깊게 하지는 않았습니다!

현재 작업 중인 브랜치에서 이 부분 또한 고려해보도록 하겠습니다!


room.validateRoomRecruitExpired();

Expand All @@ -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;
}
Comment on lines +88 to +96
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@buzz0331 @hd0rable 이전에 방 참여 API 실행 중 도메인 로직으로 인해 발생한 InvalidStateException, BusinessException 이 @recover 메서드로 흡수되어 모두 423 error로 응답되었던 이슈를 해결했습니다

spring @retryable 로 정의한 메서드내에서 발생하는 exception을 재시도 여부와 관계없이 각 exception에 대한 @recover 메서드로 exception handling 이 필요하다고 하네요


private void sendNotifications(RoomJoinCommand roomJoinCommand, Room room) {
RoomParticipant targetUser = roomParticipantCommandPort.findHostByRoomId(room.getId());
User actorUser = userCommandPort.findById(roomJoinCommand.userId());
Expand Down Expand Up @@ -99,6 +135,4 @@ private void validateCancelable(RoomParticipant roomParticipant) {
throw new BusinessException(ErrorCode.HOST_CANNOT_CANCEL);
}
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- (user_id, room_id) 조합 유니크 제약 추가
ALTER TABLE room_participants
ADD CONSTRAINT uk_room_participant_user_room
UNIQUE (user_id, room_id);
30 changes: 25 additions & 5 deletions src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,16 +16,18 @@
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;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -134,7 +155,6 @@ void joinRoom_success() throws Exception {
assertThat(room.getMemberCount()).isEqualTo(2); // 방 생성 시 1명 + 참여 1명
}


@Test
@DisplayName("방 중복 참여 실패")
void joinRoom_alreadyParticipated() throws Exception {
Expand Down Expand Up @@ -167,8 +187,8 @@ void cancelJoin_success() throws Exception {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());

em.flush();
em.clear();
// em.flush();
// em.clear();
Comment on lines +190 to +191
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

주석 처리된 영속성 컨텍스트 초기화로 인한 검증 누락 가능성이 있습니다.

em.flush()em.clear()가 주석 처리되어:

  • 영속성 컨텍스트에 캐시된 엔티티가 그대로 사용될 수 있습니다
  • Lines 194-195의 findById()가 실제 DB가 아닌 1차 캐시에서 조회할 수 있습니다
  • 실제 DB 변경사항이 제대로 반영되었는지 검증하지 못할 수 있습니다

@Transactional을 제거했다면 이 코드가 필요 없을 수도 있지만, 명시적인 검증을 위해 em.flush(); em.clear();를 복원하거나, 별도의 트랜잭션에서 조회하는 것이 더 안전합니다.

🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/room/adapter/in/web/RoomJoinApiTest.java around
lines 190-191, the commented out em.flush() and em.clear() can leave entities in
the persistence context so subsequent findById() may read from the 1st-level
cache instead of the DB; restore em.flush(); em.clear(); after
persisting/updating to force SQL execution and clear the persistence context, or
alternatively perform the verification in a new transaction/EntityManager to
ensure the findById() reads the actual database state.


// 참여자 삭제 확인
RoomParticipantJpaEntity member = roomParticipantJpaRepository.findById(memberParticipation.getRoomParticipantId()).orElse(null);
Expand Down
Loading