Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ clean.doLast {
}

tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform {
if (System.getenv('CI') == 'true') {
Copy link
Member

Choose a reason for hiding this comment

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

오 좋습니닷 👍🏻👍🏻

Copy link
Contributor

Choose a reason for hiding this comment

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

오 이렇게하면 깃허브 액션에서 CI 작업시에 제외시켜 주는건가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CI, CD 시에 해당 테스트 코드는 제외하기 위해서 찾아봤는데 이런 방법이 있는거 같아서 적용하였습니다.
다만 실제로 잘 동작하는지는 확실하진 않습니다. 좀 더 알아보겠슴다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CI, CD 시에 해당 테스트 코드는 제외하기 위해서 찾아봤는데 이런 방법이 있는거 같아서 적용하였습니다. 다만 실제로 잘 동작하는지는 확실하진 않습니다. 좀 더 알아보겠슴다

@coderabbitai 내가 적용한 방식으로 CI 시에 해당 테스트 메서드 클래스를 제외할 수 있는게 맞나?? 그리고 실제로 잘 적용되었는지를 확인하기 위한 방법이 뭐가 있을까?

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the RoomJoinConcurrencyTest has the @Tag annotation
cat src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java | head -50

Length of output: 2289


🏁 Script executed:

#!/bin/bash
# Search for any @Tag annotations in the test file
rg -n "@Tag" src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java

Length of output: 22


🏁 Script executed:

#!/bin/bash
# Check GitHub Actions workflow files to see how CI environment variable is set
fd -e yml -e yaml . .github/workflows --exec cat {}

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find GitHub Actions workflow files
find .github -type f -name "*.yml" -o -name "*.yaml" 2>/dev/null

Length of output: 341


🏁 Script executed:

#!/bin/bash
# List all files in .github directory
ls -la .github/ 2>/dev/null || echo "No .github directory found"

Length of output: 302


🏁 Script executed:

#!/bin/bash
# Check if CI environment variable is commonly set in GitHub Actions
# Look for any workflow files
fd -t f . .github 2>/dev/null | head -20

Length of output: 32


🏁 Script executed:

#!/bin/bash
# Check the CI workflow file
cat .github/workflows/ci-workflow.yml

Length of output: 2057


🏁 Script executed:

#!/bin/bash
# Also check CD workflow files to see if they run tests
cat .github/workflows/cd-workflow-dev.yml
cat .github/workflows/cd-workflow-prod.yml

Length of output: 5559


@seongjunnoh

네, 적용한 방식이 맞습니다! 확인 결과:

✅ 설정이 올바른 이유

  1. 테스트 클래스에 태그 적용됨: RoomJoinConcurrencyTest@Tag("concurrency")가 38번째 줄에 정확히 적용되어 있습니다
  2. Gradle 설정 정확함: JUnit 5의 excludeTags 사용이 올바릅니다
  3. CI 환경변수: GitHub Actions는 기본적으로 모든 워크플로우에서 CI=true 환경변수를 자동으로 설정합니다

🔍 동작 확인 방법

1. GitHub Actions 로그 확인 (가장 확실한 방법)

PR을 올리면 CI 워크플로우가 실행되는데, Actions 탭에서 로그를 확인하세요:

Test > Task :test
> konkuk.thip.room.concurrency.RoomJoinConcurrencyTest > SKIPPED

또는 전체 테스트 수가 1개 줄어든 것을 확인할 수 있습니다.

2. 로컬에서 테스트

# CI 환경을 시뮬레이션하여 테스트
CI=true ./gradlew test --info

# 또는 상세 로그로 확인
CI=true ./gradlew test --info | grep -i "concurrency\|excluded\|skipped"

3. 임시 확인 코드 추가

build.gradle에 임시로 로그를 추가해서 확인:

tasks.named('test') {
    useJUnitPlatform {
        if (System.getenv('CI') == 'true') {
            excludeTags 'concurrency'
            println "🚫 CI 환경 감지: concurrency 태그 테스트 제외됨"
        } else {
            println "💻 로컬 환경: 모든 테스트 실행"
        }
    }
}

💡 추가 개선 제안

명시성을 위해 .github/workflows/ci-workflow.ymlenv 섹션에 CI: true를 명시적으로 추가하는 것도 좋습니다 (GitHub Actions가 자동으로 설정하지만, 코드로 명확히 표현):

env:
  CI: true  # 명시적 선언
  RESOURCE_PATH: src/main/resources
  TEST_RESOURCE_PATH: src/test/resources
  FIREBASE_KEY_PATH: ${{ secrets.FIREBASE_KEY_PATH }}

다음 PR CI 실행 시 Actions 로그에서 테스트 수를 확인해보시면 RoomJoinConcurrencyTest가 제외된 것을 확인하실 수 있을 겁니다!


🧠 Learnings used
Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 285
File: src/main/java/konkuk/thip/room/adapter/out/jpa/RoomStatus.java:1-7
Timestamp: 2025-08-31T05:25:14.845Z
Learning: seongjunnoh는 enum 의존성에 대해 유연한 접근을 선호하며, 도메인→어댑터 레이어 참조와 같은 아키텍처 layering 원칙보다 실용적인 구현을 우선시한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 195
File: src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java:0-0
Timestamp: 2025-08-13T05:22:32.287Z
Learning: seongjunnoh는 데이터 무결성과 중복 방지에 대한 고민이 깊으며, LinkedHashSet을 활용한 중복 제거와 순서 보장을 동시에 달성하는 솔루션을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 305
File: src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java:9-13
Timestamp: 2025-09-17T06:40:49.863Z
Learning: seongjunnoh는 메서드 네이밍 시 구현 세부사항보다 비즈니스 의도를 반영하는 것을 선호하며, 미래 확장성과 아키텍처 변화를 고려한 설계 철학을 가지고 있다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 180
File: src/main/java/konkuk/thip/room/application/service/AttendanceCheckCreateService.java:22-40
Timestamp: 2025-08-14T09:15:31.371Z
Learning: seongjunnoh는 기술적 완벽성보다 실제 비즈니스 시나리오와 사용자 행동 패턴을 우선 고려하며, 발생 가능성이 낮은 엣지 케이스보다는 실용적인 구현을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 278
File: src/main/java/konkuk/thip/common/exception/code/ErrorCode.java:210-211
Timestamp: 2025-08-24T09:40:48.595Z
Learning: seongjunnoh는 HTTP 상태 코드 선택에 대해 기술적 근거와 코드베이스 내 일관성을 중요하게 생각하며, 구체적인 사례 분석을 통한 설명을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 278
File: src/main/java/konkuk/thip/TestTokenController.java:0-0
Timestamp: 2025-08-24T09:33:52.982Z
Learning: seongjunnoh는 Spring의 ConditionalOnProperty 동작 원리를 정확히 이해하고 있으며, 보안 이슈에 대해서도 실질적인 위험성을 기준으로 판단하는 실용적 접근을 선호한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 166
File: src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java:70-82
Timestamp: 2025-08-07T18:19:55.908Z
Learning: seongjunnoh는 Clean Architecture 원칙을 중시하며, 어댑터는 순수하게 저장/조회 기능만 담당하고 비즈니스 로직은 서비스 레이어에서 처리하는 것을 선호한다.

excludeTags 'concurrency'
}
}
}

apply from: "$rootDir/jacoco.gradle"
148 changes: 148 additions & 0 deletions loadtest/room_join_load_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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_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 };
}
}

// ------------ 시나리오 ------------
// [인기 작가가 만든 모임방에 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회만 실행
Copy link
Member

Choose a reason for hiding this comment

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

아하 이렇게 진짜 어느 특정시점에 방참여가 몰리는 부하테스트를 실행하신거군요 좋네요

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;
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 @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package konkuk.thip.room.concurrency;

import com.fasterxml.jackson.databind.ObjectMapper;
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.room.adapter.in.web.request.RoomJoinRequest;
import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity;
import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository;
import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository;
import konkuk.thip.room.domain.value.Category;
import konkuk.thip.room.domain.value.RoomParticipantRole;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import konkuk.thip.user.domain.value.Alias;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc(addFilters = false)
@DisplayName("[동시성] 방 참여 동시 요청 테스트")
@Tag("concurrency")
public class RoomJoinConcurrencyTest {

@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private RoomJpaRepository roomJpaRepository;
@Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository;
@Autowired private BookJpaRepository bookJpaRepository;
@Autowired private UserJpaRepository userJpaRepository;
@Autowired private JdbcTemplate jdbcTemplate;

@Test
@DisplayName("[동시성] 방 참여 동시 요청 테스트")
Copy link
Member

Choose a reason for hiding this comment

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

이테스트 h2환경에서 실행해도 데드락 상황이 로그에 남나요??

Copy link
Collaborator Author

@seongjunnoh seongjunnoh Nov 2, 2025

Choose a reason for hiding this comment

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

아니요 테스트 코드에서 명확한 500 error 관련 로그를 확인하지는 못했습니다!
테스트 코드로는 api 요청에 대한 200 OK 응답 개수와 테스트 이후 DB 에 기록된 room_participants, rooms 레코드의 데이터를 확인하여 기댓값과 비교하는 것을 목표로 했습니다!
저희 운영환경에서의 DB는 MySQL 이므로, 로컬 서버환경에서 부하 테스트를 통해 500 error의 원인을 파악하는 것을 생각하였습니다! (테스트 코드는 동시성 이슈 발생 여부를 확인하는 정도로 생각했습니다)

Copy link
Contributor

Choose a reason for hiding this comment

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

테스트 코드로는 데이터 정합성만 판단하고 정확한 에러율과 에러 메시지는 MySQL을 사용하여 부하 테스트로 확인하는 것으로 이해했는데 맞을까요?
저번에 회의에서 말씀해주신대로 테스트 코드 목표가 명확해서 좋은 것 같네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

테스트 코드로는 데이터 정합성만 판단하고 정확한 에러율과 에러 메시지는 MySQL을 사용하여 부하 테스트로 확인하는 것으로 이해했는데 맞을까요? 저번에 회의에서 말씀해주신대로 테스트 코드 목표가 명확해서 좋은 것 같네요!

넵 맞습니다!

void room_join_test_in_multi_thread() throws Exception {
//given
int requestUserCount = 500;

BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook());
// 모집인원 10명 방 생성
RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createCustomRoom(book, Category.LITERATURE, 10));
List<Long> savedUserIds = createUsersRange(requestUserCount + 1);

UserJpaEntity hostUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER));
roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, hostUser, RoomParticipantRole.HOST, 0.0));

List<Long> requestUserIds = savedUserIds.subList(1, savedUserIds.size());

//when : 동시에 방 참여 요청
ExecutorService pool = Executors.newFixedThreadPool(Math.min(requestUserCount, 100));
CountDownLatch ready = new CountDownLatch(requestUserCount);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(requestUserCount);

RoomJoinRequest body = new RoomJoinRequest("join");
String json = objectMapper.writeValueAsString(body);

List<Future<Integer>> results = new ArrayList<>(requestUserCount);
for (int i = 0; i < requestUserCount; i++) {
final long userId = requestUserIds.get(i);
results.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
mockMvc.perform(post("/rooms/{roomId}/join", room.getRoomId())
.contentType("application/json")
.content(json)
.requestAttr("userId", userId))
.andExpect(status().isOk());
return 200;
} catch (AssertionError e) {
return 400;
} finally {
finish.countDown();
}
}));
}

// 동시에 시작
ready.await(10, TimeUnit.SECONDS);
start.countDown();
finish.await(60, TimeUnit.SECONDS);
pool.shutdown();
Comment on lines +66 to +99
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

동시성 동기화 로직은 적절하나, 타임아웃 처리 개선이 필요합니다.

CountDownLatch를 활용한 동기화 패턴은 정확하나, 다음 사항들을 고려해야 합니다:

  1. 타임아웃 검증 부재: ready.await(10, TimeUnit.SECONDS)finish.await(60, TimeUnit.SECONDS)가 타임아웃될 경우 반환값을 확인하지 않고 진행합니다. 일부 스레드가 준비되지 않았거나 완료되지 않은 상태에서 테스트가 진행될 수 있습니다.

  2. 예외 처리 불완전: Callable 내부에서 AssertionError만 처리하고 다른 예외는 처리하지 않습니다. 예외 발생 시 Future.get()에서 ExecutionException이 발생하여 Line 103의 스트림 처리에서 문제가 될 수 있습니다.

다음과 같이 개선할 수 있습니다:

 // 동시에 시작
-ready.await(10, TimeUnit.SECONDS);
+boolean allReady = ready.await(10, TimeUnit.SECONDS);
+assertThat(allReady).isTrue();
 start.countDown();
-finish.await(60, TimeUnit.SECONDS);
+boolean allFinished = finish.await(60, TimeUnit.SECONDS);
+assertThat(allFinished).isTrue();
 pool.shutdown();

예외 처리 개선:

             try {
                 mockMvc.perform(post("/rooms/{roomId}/join", room.getRoomId())
                                 .contentType("application/json")
                                 .content(json)
                                 .requestAttr("userId", userId))
                         .andExpect(status().isOk());
                 return 200;
             } catch (AssertionError e) {
                 return 400;
+            } catch (Exception e) {
+                return 500;
             } finally {
                 finish.countDown();
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExecutorService pool = Executors.newFixedThreadPool(Math.min(requestUserCount, 100));
CountDownLatch ready = new CountDownLatch(requestUserCount);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(requestUserCount);
RoomJoinRequest body = new RoomJoinRequest("join");
String json = objectMapper.writeValueAsString(body);
List<Future<Integer>> results = new ArrayList<>(requestUserCount);
for (int i = 0; i < requestUserCount; i++) {
final long userId = requestUserIds.get(i);
results.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
mockMvc.perform(post("/rooms/{roomId}/join", room.getRoomId())
.contentType("application/json")
.content(json)
.requestAttr("userId", userId))
.andExpect(status().isOk());
return 200;
} catch (AssertionError e) {
return 400;
} finally {
finish.countDown();
}
}));
}
// 동시에 시작
ready.await(10, TimeUnit.SECONDS);
start.countDown();
finish.await(60, TimeUnit.SECONDS);
pool.shutdown();
ExecutorService pool = Executors.newFixedThreadPool(Math.min(requestUserCount, 100));
CountDownLatch ready = new CountDownLatch(requestUserCount);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(requestUserCount);
RoomJoinRequest body = new RoomJoinRequest("join");
String json = objectMapper.writeValueAsString(body);
List<Future<Integer>> results = new ArrayList<>(requestUserCount);
for (int i = 0; i < requestUserCount; i++) {
final long userId = requestUserIds.get(i);
results.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
mockMvc.perform(post("/rooms/{roomId}/join", room.getRoomId())
.contentType("application/json")
.content(json)
.requestAttr("userId", userId))
.andExpect(status().isOk());
return 200;
} catch (AssertionError e) {
return 400;
} catch (Exception e) {
return 500;
} finally {
finish.countDown();
}
}));
}
// 동시에 시작
boolean allReady = ready.await(10, TimeUnit.SECONDS);
assertThat(allReady).isTrue();
start.countDown();
boolean allFinished = finish.await(60, TimeUnit.SECONDS);
assertThat(allFinished).isTrue();
pool.shutdown();
🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java
around lines 66 to 99, the test ignores await() boolean returns and only catches
AssertionError inside the tasks; update the test to (1) verify the boolean
results of ready.await(10, TimeUnit.SECONDS) and finish.await(60,
TimeUnit.SECONDS) and fail the test immediately (e.g., throw AssertionError or
call Assertions.fail) if either returns false, and (2) broaden the task's
exception handling to catch Throwable/Exception (not just AssertionError),
ensure finish.countDown() still runs in finally, and return a distinct failure
code or rethrow a runtime exception so Future.get() does not produce unexpected
ExecutionExceptions; finally, after pool.shutdown(), iterate futures and handle
InterruptedException/ExecutionException explicitly to fail the test with clear
messages if any task failed.


long okCount = results.stream().filter(f -> {
try {
return f.get() == 200;
} catch (Exception e) {
return false;
}
}).count();

//then : DB 실측값 검증
RoomJpaEntity reloadedRoom = roomJpaRepository.findByRoomId(room.getRoomId()).orElseThrow();

long participantRows = jdbcTemplate.query(
"SELECT COUNT(*) FROM room_participants WHERE room_id = ?",
ps -> ps.setLong(1, room.getRoomId()),
rs -> {rs.next(); return rs.getLong(1); }
);
Comment on lines +112 to +116
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

JDBC 쿼리 패턴이 비정상적입니다.

jdbcTemplate.queryResultSetExtractor 람다가 단일 값을 반환하고 있지만, query 메서드는 ResultSetExtractor가 null을 반환할 수 있어 NPE 위험이 있습니다.

더 안전하고 명확한 방법을 사용하세요:

-long participantRows = jdbcTemplate.query(
-        "SELECT COUNT(*) FROM room_participants WHERE room_id = ?",
-        ps -> ps.setLong(1, room.getRoomId()),
-        rs -> {rs.next(); return rs.getLong(1); }
-);
+Long participantRows = jdbcTemplate.queryForObject(
+        "SELECT COUNT(*) FROM room_participants WHERE room_id = ?",
+        Long.class,
+        room.getRoomId()
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
long participantRows = jdbcTemplate.query(
"SELECT COUNT(*) FROM room_participants WHERE room_id = ?",
ps -> ps.setLong(1, room.getRoomId()),
rs -> {rs.next(); return rs.getLong(1); }
);
Long participantRows = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM room_participants WHERE room_id = ?",
Long.class,
room.getRoomId()
);
🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/room/concurrency/RoomJoinConcurrencyTest.java
around lines 112-116, the jdbcTemplate.query call uses a ResultSetExtractor
lambda that returns a single long and risks an NPE; replace it with
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM room_participants WHERE
room_id = ?", Long.class, room.getRoomId()) (or Integer.class if preferred) and
handle possible null by unboxing safely or using Objects.requireNonNull/Optional
to ensure a non-null primitive long for assertions.


int memberCountInRoom = reloadedRoom.getMemberCount();
int recruit = reloadedRoom.getRecruitCount();

System.out.println("=== RESULT ===");
System.out.println("OK responses(= 방 참여 OK 응답을 받은 thread 수) : " + okCount);
System.out.println("participants rows(= host 1명 포함된 결과) : " + participantRows);
System.out.println("room.memberCount : " + memberCountInRoom);
System.out.println("recruitCount : " + recruit);

/**
* 현재 방 참여 로직
* 1. 방 참여자에 해당하는 RoomParticipant 엔티티 저장
* 2. Room 엔티티의 memberCount 증가 (Room의 도메인 규칙으로 memberCount가 recruitCount를 초과하지 않도록 제한됨)
* -> 동시성 경쟁이 발생하는 트래픽 규모에서는 participants 테이블의 행 수와 Room.memberCount 동기화 보장 X
*/

// 1) participants가 recruitCount 보다 커질 수 있음
assertThat(participantRows).isGreaterThan(recruit);

// 2) memberCount가 실제 participants 수보다 작을 수 있음
// memberCount 값은 Room 도메인 규칙에 의해 recruitCount를 초과하여 증가하지 않음
assertThat(memberCountInRoom).isLessThan((int) participantRows);
Comment on lines +134 to +139
Copy link
Contributor

Choose a reason for hiding this comment

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

현재 시나리오에서는 다수의 사용자가 동시에 동일한 방에 참여하려고 할 때,
1. 실제 방 참여자 수가 방의 모집 인원을 초과하고,
2. 방 테이블의 memberCount 값이 실제 참여자 수보다 적게 나타나는 문제가 발생하고 있습니다.

성준님께서 정리해주신 노션 내용을 참고했을 때, 데드락이 발생하면 하나의 트랜잭션은 롤백되고 다른 하나의 트랜잭션만 UPDATE 쿼리가 반영되는 것으로 이해했습니다.
그렇다면 이러한 트랜잭션 처리 방식이 현재 시나리오에서도 동일하게 유지된다면, 데이터 정합성 자체는 깨지지 않아야 하지 않나 라는 의문이 생기게 되었습니다. 그냥 데드락으로 인한 연쇄적인 현상으로 이해하면 될까요?

Copy link
Collaborator Author

@seongjunnoh seongjunnoh Nov 3, 2025

Choose a reason for hiding this comment

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

노션에 희진님께서 동일한 내용을 질문해주셔서 관련해서 댓글 단 내용 참고해주시면 될 것 같습니다!

저는 데드락 상황이 발생하면 MySQL이 트랜잭션 하나를 롤백시키는게 맞지만, 데드락 상황이 발생하지 않는 경우에는 두 트랜잭션이 서로 rooms 레코드의 memberCount 의 값을 동일하게 update 시키고 commit 하므로 데이터 정합성이 깨질 수 있다(= 방의 멤버 수와 방 참여자 의 전체 수가 다를 수 있다) 라고 생각합니다

}

private List<Long> createUsersRange(long count) {
List<Long> userIds = new ArrayList<>();
for (long i = 0; i < count; i++) {
UserJpaEntity saved = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER));
userIds.add(saved.getUserId());
}
return userIds;
}
}