diff --git a/loadtest/follow_change_state_load_test.js b/loadtest/follow_change_state_load_test.js new file mode 100644 index 000000000..15daf7e4f --- /dev/null +++ b/loadtest/follow_change_state_load_test.js @@ -0,0 +1,149 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +const BASE_URL = 'http://localhost:8080'; +const TARGET_USER_ID= 1; // 팔로우/언팔 대상 사용자 ID +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 followLatency = new Trend('follow_change_latency'); // API 지연(ms) - 네이밍 포맷 유지 +const http5xx = new Counter('follow_change_5xx'); // 5xx 개수 +const http2xx = new Counter('follow_change_2xx'); // 2xx 개수 +const http4xx = new Counter('follow_change_4xx'); // 4xx 개수 + +const token_issue_failed = new Counter('token_issue_failed'); +const fail_USER_ALREADY_FOLLOWED = new Counter('fail_USER_ALREADY_FOLLOWED'); +const fail_USER_ALREADY_UNFOLLOWED = new Counter('fail_USER_ALREADY_UNFOLLOWED'); +const fail_USER_CANNOT_FOLLOW_SELF = new Counter('fail_USER_CANNOT_FOLLOW_SELF'); +const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); + +const ERR = { + USER_ALREADY_FOLLOWED: 70001, + USER_ALREADY_UNFOLLOWED: 75001, + USER_CANNOT_FOLLOW_SELF: 75002, +}; + +function parseError(res) { + try { + const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 + return { + code: Number(j.code), // 정수 코드 + message: j.message || '', + requestId: j.requestId || '', + isSuccess: !!j.isSuccess + }; + } catch (e) { + return { code: NaN, message: '', requestId: '', isSuccess: false }; + } +} + +// ------------ 시나리오 ------------ +// [다수 유저가 동일 타깃(TARGET_USER_ID)에게 '팔로우' 요청을 동시에 보내는 상황] +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: { + follow_change_5xx: ['count==0'], // 서버 오류는 0건이어야 함 + follow_change_latency: ['p(95)<1000'], // p95 < 1s + }, +}; + +/** ===== setup: 토큰 일괄 발급 ===== */ +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', target: `${TARGET_USER_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]; + + 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: true }); + const url = `${BASE_URL}/users/following/${TARGET_USER_ID}`; + + const res = http.post(url, body, { headers, tags: { phase: 'follow_change', target: `${TARGET_USER_ID}` } }); + + followLatency.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.USER_ALREADY_FOLLOWED: + fail_USER_ALREADY_FOLLOWED.add(1); + break; + case ERR.USER_ALREADY_UNFOLLOWED: + fail_USER_ALREADY_UNFOLLOWED.add(1); + break; + case ERR.USER_CANNOT_FOLLOW_SELF: + fail_USER_CANNOT_FOLLOW_SELF.add(1); + break; + default: + fail_OTHER_4XX.add(1); + } + } + else if (res.status >= 500) { + http5xx.add(1); + } + + check(res, { + 'follow_change responded': (r) => r.status !== 0, + // 이미 팔로우 상태에서 재요청 등 합리적 4xx 허용 + 'follow_change 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), + }); +} \ No newline at end of file diff --git a/loadtest/follow_change_toggle_load_test.js b/loadtest/follow_change_toggle_load_test.js new file mode 100644 index 000000000..f73d94748 --- /dev/null +++ b/loadtest/follow_change_toggle_load_test.js @@ -0,0 +1,162 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +const BASE_URL = 'http://localhost:8080'; +const TARGET_USER_ID = 1; // 팔로우/언팔 대상 사용자 +const USERS_START = 10000; // 토큰 발급 시작 userId +const USERS_COUNT = 500; // 동시 사용자 수 = VU 수 +const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 +const BATCH_PAUSE_S = 0.2; // 배치 간 휴지 (발급 API 보호) +const START_DELAY_S = 5; // 전체 동기 시작 대기 +const TOGGLE_ITER = 20; // 각 VU가 시도할 토글 횟수(팔로우/언팔 번갈아 최대 20회) +const TOGGLE_PAUSE_S = 0.1; // 한 번 호출 후 다음 토글까지 간격 + +const followLatency = new Trend('follow_change_latency'); +const http5xx = new Counter('follow_change_5xx'); +const http2xx = new Counter('follow_change_2xx'); +const http4xx = new Counter('follow_change_4xx'); + +const token_issue_failed = new Counter('token_issue_failed'); +const fail_USER_ALREADY_FOLLOWED = new Counter('fail_USER_ALREADY_FOLLOWED'); +const fail_USER_ALREADY_UNFOLLOWED = new Counter('fail_USER_ALREADY_UNFOLLOWED'); +const fail_USER_CANNOT_FOLLOW_SELF = new Counter('fail_USER_CANNOT_FOLLOW_SELF'); +const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); + +const success_follow_200 = new Counter('success_follow_200'); +const success_unfollow_200 = new Counter('success_unfollow_200'); + +const ERR = { + USER_ALREADY_FOLLOWED: 70001, + USER_ALREADY_UNFOLLOWED: 75001, + USER_CANNOT_FOLLOW_SELF: 75002, +}; + +function parseError(res) { + try { + const j = JSON.parse(res.body || '{}'); + return { + code: Number(j.code), + message: j.message || '', + requestId: j.requestId || '', + isSuccess: !!j.isSuccess, + }; + } catch (_) { + return { code: NaN, message: '', requestId: '', isSuccess: false }; + } +} + +/** ===== 시나리오 ===== */ +export const options = { + scenarios: { + follow_toggle_spam: { + executor: 'per-vu-iterations', + vus: USERS_COUNT, + iterations: TOGGLE_ITER, // 각 VU가 TOGGLE_ITER번 default() 실행 + startTime: '0s', + gracefulStop: '5s', + }, + }, + thresholds: { + follow_change_5xx: ['count==0'], + follow_change_latency: ['p(95)<1000'], + }, +}; + +/** ===== setup: 토큰 일괄 발급 ===== */ +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', target: `${TARGET_USER_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의 토글 상태 ===== + * false = 아직 팔로우 안 한 상태로 가정 → 다음 요청은 follow(type:true) + * true = 이미 팔로우 한 상태로 가정 → 다음 요청은 unfollow(type:false) + */ +let isFollowing = false; + +/** ===== 실행 루프 ===== */ +export default function (data) { + const idx = __VU - 1; + const token = data.tokens[idx]; + + // 동기 시작 + 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', + }; + + // 현재 상태에 따라 요청 결정 + // 규칙: "이전 요청이 200일 때만" 상태를 뒤집는다 → 4xx/5xx면 상태 유지(같은 동작 재시도) + const wantFollow = !isFollowing; // 현재 false면 follow, true면 unfollow + const body = JSON.stringify({ type: wantFollow }); + const url = `${BASE_URL}/users/following/${TARGET_USER_ID}`; + + const res = http.post(url, body, { headers, tags: { phase: 'follow_change', target: `${TARGET_USER_ID}`, want: wantFollow ? 'follow' : 'unfollow' } }); + + followLatency.add(res.timings.duration); + if (res.status >= 200 && res.status < 300) { + http2xx.add(1); + // 200일 때만 상태 반전 + isFollowing = !isFollowing; + if (wantFollow) success_follow_200.add(1); + else success_unfollow_200.add(1); + } else if (res.status >= 400 && res.status < 500) { + http4xx.add(1); + const err = parseError(res); + switch (err.code) { + case ERR.USER_ALREADY_FOLLOWED: + fail_USER_ALREADY_FOLLOWED.add(1); + break; + case ERR.USER_ALREADY_UNFOLLOWED: + fail_USER_ALREADY_UNFOLLOWED.add(1); + break; + case ERR.USER_CANNOT_FOLLOW_SELF: + fail_USER_CANNOT_FOLLOW_SELF.add(1); + break; + default: + fail_OTHER_4XX.add(1); + } + } else if (res.status >= 500) { + http5xx.add(1); + } + + check(res, { + 'follow_change responded': (r) => r.status !== 0, + 'follow_change 200 or expected 4xx': (r) => + r.status === 200 || (r.status >= 400 && r.status < 500), + }); + + // 토글 간격 + sleep(TOGGLE_PAUSE_S); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java index d7a515071..91c9d6a26 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java @@ -5,7 +5,15 @@ import lombok.*; @Entity -@Table(name = "followings") +@Table( + name = "followings", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_followings_user_target", + columnNames = {"user_id", "following_user_id"} + ) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql b/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql new file mode 100644 index 000000000..254049234 --- /dev/null +++ b/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql @@ -0,0 +1,3 @@ +ALTER TABLE followings + ADD CONSTRAINT uq_followings_user_target + UNIQUE (user_id, following_user_id); \ No newline at end of file diff --git a/src/test/java/konkuk/thip/user/concurrency/UserFollowConcurrencyTest.java b/src/test/java/konkuk/thip/user/concurrency/UserFollowConcurrencyTest.java new file mode 100644 index 000000000..5dcbbbc34 --- /dev/null +++ b/src/test/java/konkuk/thip/user/concurrency/UserFollowConcurrencyTest.java @@ -0,0 +1,131 @@ +package konkuk.thip.user.concurrency; + +import konkuk.thip.common.util.TestEntityFactory; +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.assertj.core.api.Assertions; +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.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 UserFollowConcurrencyTest { + + @Autowired private MockMvc mockMvc; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("[동시성] 사용자 팔로우 상태 변경 동시 요청 테스트") + void user_follow_test_in_multi_thread() throws Exception { + //given + final int followerCount = 500; + + // 팔로우 당할 유저 생성 + UserJpaEntity targetUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST, "target_user")); + + // 팔로우 하는 유저들 생성 + List followerIds = createUsersRange(followerCount); + + //when + ExecutorService pool = Executors.newFixedThreadPool(Math.min(followerCount, 100)); + CountDownLatch ready = new CountDownLatch(followerCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(followerCount); + + final String requestBody = """ + { + "type": true + } + """; + + List> results = new ArrayList<>(followerCount); + + for (Long followerId : followerIds) { + results.add(pool.submit(() -> { + ready.countDown(); + start.await(); + try { + mockMvc.perform( + post("/users/following/{followingUserId}", targetUser.getUserId()) + .contentType("application/json") + // 컨트롤러의 @UserId Long userId 에 주입 + .requestAttr("userId", followerId) + .content(requestBody) + ) + .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(); + + //then + long okCount = results.stream() + .filter(result -> { + try { + return result.get() == 200; + } catch (Exception e) { + return false; + } + }).count(); + + Long followingRows = jdbcTemplate.query( + "SELECT COUNT(*) FROM followings WHERE following_user_id = ?", + ps -> ps.setLong(1, targetUser.getUserId()), + rs -> {rs.next(); return rs.getLong(1); } + ); + + Long storedFollowerCount = jdbcTemplate.query( + "SELECT follower_count FROM users WHERE user_id = ?", + ps -> ps.setLong(1, targetUser.getUserId()), + rs -> {rs.next(); return rs.getLong(1); } + ); + + System.out.println("=== RESULT ==="); + System.out.println("OK responses : " + okCount); + System.out.println("followings rows : " + followingRows); + System.out.println("target user's follower_count : " + storedFollowerCount); + + // 실제 생성된 팔로잉 행 수는 팔로우 요청 횟수보다 적을 수 있다. (데이터 정합성이 깨지는 경우) + Assertions.assertThat(followingRows).isLessThanOrEqualTo(followerCount); + // User 테이블의 팔로워 수 컬럼 역시 팔로우 요청 횟수보다 적을 수 있다. (데이터 정합성이 깨지는 경우) + Assertions.assertThat(storedFollowerCount).isLessThanOrEqualTo(followerCount); + + } + + private List createUsersRange(int followerCount) { + List userIds = new ArrayList<>(); + for (int i = 0; i < followerCount; i++) { + UserJpaEntity savedUser = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "follower_user_" + i)); + userIds.add(savedUser.getUserId()); + } + return userIds; + } +}