-
Notifications
You must be signed in to change notification settings - Fork 0
[fix] 팔로잉 동시성 이슈 문제 2차 해결시도 #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
a7685ca
3baa116
04670bd
d8a5057
1b9a44c
52b02f8
0031c52
b7f3ed2
c351557
99e3a4f
8922fe3
2023725
50ab46d
f098b64
5a73959
5d979d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| 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 { | ||
| } | ||
|
Comment on lines
+6
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Search for @Retryable usage
rg -n '@Retryable' --type=java -A 5Repository: THIP-TextHip/THIP-Server Length of output: 783 🏁 Script executed: #!/bin/bash
# Search for uncommented @Retryable annotations (not in comments)
rg '^\s*@Retryable' --type=java -A 3
# Also check all instances of @Retryable including commented ones
rg '@Retryable' --type=java | grep -v '//' | head -20
# Check the class structure where @Retryable is used
fd RetryConfig.java -x cat {}
</parameter>Repository: THIP-TextHip/THIP-Server Length of output: 422 🏁 Script executed: #!/bin/bash
# Search for @Retryable not preceded by // (uncommented)
rg '@Retryable' --type=java | grep -v '//' | grep -v '^\s*//'
# Count total @Retryable occurrences
echo "=== Total @Retryable occurrences ==="
rg '@Retryable' --type=java | wc -l
# Search more precisely for active annotations
rg -B 2 -A 10 '^\s*@Retryable\(' --type=javaRepository: THIP-TextHip/THIP-Server Length of output: 106 🏁 Script executed: #!/bin/bash
# Read the commented @Retryable annotation context
sed -n '25,50p' src/main/java/konkuk/thip/user/application/service/following/UserFollowService.javaRepository: THIP-TextHip/THIP-Server Length of output: 998 @EnableRetry 설정이 필요 없음 현재 코드베이스에 활성화된 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package konkuk.thip.outbox.adapter.in.event; | ||
|
|
||
| import konkuk.thip.outbox.application.port.in.FollowingDispatchUseCase; | ||
| import konkuk.thip.user.adapter.out.event.dto.FollowingEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class FollowingEventListener { | ||
|
|
||
| private final FollowingDispatchUseCase followingDispatchUseCase; | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | ||
| public void onUserFollowed(FollowingEvent.UserFollowedEvent event) { | ||
| followingDispatchUseCase.handleUserFollow(event); | ||
| } | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) | ||
| public void onUserUnfollowed(FollowingEvent.UserUnfollowedEvent event) { | ||
| followingDispatchUseCase.handleUserUnfollow(event); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 192
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 1646
자원 잠금 에러 코드 추가 확인
HTTP 423 LOCKED 상태와 에러 코드 50200을 사용한 RESOURCE_LOCKED 추가는 적절합니다. 다만 현재 RESOURCE_LOCKED는 코드베이스에서 활발하게 사용되지 않고 있습니다. UserFollowService.java의 74번 줄에서만 참조되고 있으나, 해당 코드는 주석 처리된 상태입니다. 이 에러 코드가 향후 사용될 예정인지, 아니면 활성화가 필요한지 확인하세요.