Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
9 changes: 8 additions & 1 deletion 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 All @@ -118,7 +121,11 @@ clean.doLast {
}

tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform {
if (System.getenv('CI') == 'true') {
excludeTags 'concurrency'
}
}
}

apply from: "$rootDir/jacoco.gradle"
153 changes: 153 additions & 0 deletions loadtest/room_join_load_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

const BASE_URL = 'http://localhost:8080';
const ROOM_ID = 12345;
const USERS_START = 10000; // 토큰 발급 시작 userId
const USERS_COUNT = 500; // 총 사용자 = VU 수
const TOKEN_BATCH = 200; // 토큰 발급 배치 크기
const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지)
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 };
}
}

// ------------ 시나리오 ------------
// [인기 작가가 만든 모임방에 THIP의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정]
export const options = {
scenarios: {
// 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저
join_once_burst: {
executor: 'per-vu-iterations',
vus: USERS_COUNT,
iterations: 1,
startTime: '0s', // 모든 VU가 거의 동시에 스케줄링
gracefulStop: '5s',
},
},
thresholds: {
rooms_join_5xx: ['count==0'], // 서버 오류는 0건이어야 함
rooms_join_latency: ['p(95)<1000'], // p95 < 1s
},
};

// setup: 토큰 배치 발급
// roomId 12345 방 & userId 10000 ~ 유저들은 사전에 만들어져 있어야 함
export function setup() {
const userIds = Array.from({ length: USERS_COUNT }, (_, i) => USERS_START + i);
const tokens = [];

for (let i = 0; i < userIds.length; i += TOKEN_BATCH) {
const slice = userIds.slice(i, i + TOKEN_BATCH);
const reqs = slice.map((uid) => [
'GET',
`${BASE_URL}/api/test/token/access?userId=${uid}`,
null,
{ tags: { phase: 'setup_token_issue', room: `${ROOM_ID}` } },
]);

const responses = http.batch(reqs);
for (const r of responses) {
if (r.status === 200 && r.body) {
tokens.push(r.body.trim());
}
else {
tokens.push(''); // 실패한 자리도 인덱스 유지
token_issue_failed.add(1);
}
}
sleep(BATCH_PAUSE_S);
}
if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT;

const startAt = Date.now() + START_DELAY_S * 1000; // 동시 시작 시간

return { tokens, startAt };
}

// VU : 각자 자기 토큰으로 참여 호출 & 각자 1회만 실행
export default function (data) {
const idx = __VU - 1; // VU <-> user 매핑(1:1)
const token = data.tokens[idx];

// 동기 시작: startAt까지 대기 → 모든 VU가 거의 같은 타이밍에 시작
const now = Date.now();
if (now < data.startAt) {
sleep((data.startAt - now) / 1000);
}

if (!token) { // 토큰 발급 실패 -> 스킵
return;
}

const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};

const body = JSON.stringify({ type: 'join' });
const url = `${BASE_URL}/rooms/${ROOM_ID}/join`;

const res = http.post(url, body, { headers, tags: { phase: 'join', room: `${ROOM_ID}` } });

// === 커스텀 메트릭 기록 ===
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);
}

// === 검증 ===
check(res, {
'join responded': (r) => r.status !== 0,
'join 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500),
});
}
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 @@ -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,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;
Expand All @@ -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;
Expand All @@ -30,13 +35,21 @@ public class RoomJoinService implements RoomJoinUseCase {
private final RoomNotificationOrchestrator roomNotificationOrchestrator;

@Override
@Transactional
@Retryable(
noRetryFor = {InvalidStateException.class, BusinessException.class}, // 재시도 제외 예외
Copy link
Member

Choose a reason for hiding this comment

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

좋네여

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());
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 +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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,38 @@ void findById_returns_null_if_entity_removed_in_first_level_cache() throws Excep
//when //then
assertThat(testUserRepository.findById(id)).isEmpty();
}

@Test
@DisplayName("flush 시점에 dirty 엔티티에 대하여 어떤 SQL이 실행되는지 확인")
void check_dirty_entity_sql_query_when_flush() throws Exception {
//given
TestUser u1 = new TestUser("노성준");
em.persist(u1); // u1 엔티티 영속화
Long id = u1.getUserId();
em.flush(); // insert 쿼리 즉시 반영

//when
TestUser loaded = testUserRepository.findByUserId(id).orElseThrow();
loaded.setNickname("김희용"); // dirty 상태

//then
em.flush();
/**
* flush 시점에 영속성 컨텍스트 상에 dirty 상태인 엔티티에 대하여 UPDATE 쿼리가 발생하는 것 확인
*/
}

@Test
@DisplayName("flush 시점에 신규 엔티티에 대하여 어떤 SQL이 실행되는지 확인")
void check_new_entity_sql_query_when_flush() throws Exception {
//given
TestUser u2 = new TestUser("노성준");
em.persist(u2); // u2 엔티티 영속화

//when //then
em.flush();
/**
* flush 시점에 영속성 컨텍스트 상에 새로 추가된 엔티티에 대하여 INSERT 쿼리가 발생하는 것 확인
*/
}
}
14 changes: 14 additions & 0 deletions src/test/java/konkuk/thip/common/util/TestEntityFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ public static RoomJpaEntity createRoom(BookJpaEntity book, Category category) {
.build();
}

public static RoomJpaEntity createCustomRoom(BookJpaEntity book, Category category, int recruitCount) {
return RoomJpaEntity.builder()
.title("방이름")
.description("설명")
.isPublic(true)
.startDate(LocalDate.now())
.endDate(LocalDate.now().plusDays(5))
.recruitCount(recruitCount)
.bookJpaEntity(book)
.category(category)
.roomStatus(RoomStatus.RECRUITING)
.build();
}

public static RoomJpaEntity createCustomRoom(BookJpaEntity book, Category category, LocalDate startDate, LocalDate endDate, RoomStatus roomStatus) {
return RoomJpaEntity.builder()
.title("방이름")
Expand Down
Loading
Loading