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
a7685ca
[test] 팔로잉 테이블에 유니크 제약조건 설정 (#326)
buzz0331 Nov 20, 2025
3baa116
[test] 부하 상황에서 데이터 정합성 확인을 위한 테스트 코드 (#326)
buzz0331 Nov 20, 2025
04670bd
[test] k6 부하테스트 시나리오 (특정 사용자에게 팔로잉 요청이 몰리는 경우) (#326)
buzz0331 Nov 20, 2025
d8a5057
[test] k6 부하테스트 시나리오 (특정 사용자에게 팔로잉 요청과 언팔로잉 요청이 연달아 오는 경우) (#326)
buzz0331 Nov 20, 2025
1b9a44c
[fix] User 조회시 비관락 획득하도록 쿼리 수정 (#336)
buzz0331 Nov 24, 2025
52b02f8
[chore] Spring Retry 의존성 주입 (#336)
buzz0331 Nov 24, 2025
0031c52
[fix] 재시도 로직 추가 (#336)
buzz0331 Nov 24, 2025
b7f3ed2
[chore] 마이그레이션 파일 주석 추가 (#336)
buzz0331 Nov 24, 2025
c351557
[fix] 단위 테스트 코드 메서드 시그니처 변경 (#336)
buzz0331 Nov 24, 2025
99e3a4f
[refactor] 이벤트 기반 비동기 처리를 위한 Outbox 테이블 및 엔티티 추가
buzz0331 Dec 27, 2025
8922fe3
[refactor] 팔로우/언팔로우 상태 변경 시 발행할 FollowingEvent 정의
buzz0331 Dec 27, 2025
2023725
[refactor] FollowingEvent를 Outbox에 저장하는 리스너 및 서비스 구현
buzz0331 Dec 27, 2025
50ab46d
[refactor] Outbox의 팔로우 이벤트를 처리하여 팔로워 수를 업데이트하는 스케줄러 구현
buzz0331 Dec 27, 2025
f098b64
[refactor] UserFollowService에 Outbox 이벤트 발행 로직 적용
buzz0331 Dec 27, 2025
5a73959
merge
buzz0331 Dec 28, 2025
5d979d7
[test] outbox 패턴 도입에 따른 테스트 코드 수정
buzz0331 Dec 28, 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
149 changes: 149 additions & 0 deletions loadtest/follow_change_state_load_test.js
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),
});
}
162 changes: 162 additions & 0 deletions loadtest/follow_change_toggle_load_test.js
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
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, "자원이 잠겨 있어 요청을 처리할 수 없습니다."),
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for RESOURCE_LOCKED usage in the codebase
rg -nC3 'RESOURCE_LOCKED' --type=java

Repository: THIP-TextHip/THIP-Server

Length of output: 192


🏁 Script executed:

#!/bin/bash
# RESOURCE_LOCKED 에러 코드 사용처 검색
rg -nC3 'RESOURCE_LOCKED' --type=java

Repository: THIP-TextHip/THIP-Server

Length of output: 1646


자원 잠금 에러 코드 추가 확인

HTTP 423 LOCKED 상태와 에러 코드 50200을 사용한 RESOURCE_LOCKED 추가는 적절합니다. 다만 현재 RESOURCE_LOCKED는 코드베이스에서 활발하게 사용되지 않고 있습니다. UserFollowService.java의 74번 줄에서만 참조되고 있으나, 해당 코드는 주석 처리된 상태입니다. 이 에러 코드가 향후 사용될 예정인지, 아니면 활성화가 필요한지 확인하세요.


/* 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 {
}
Comment on lines +6 to +9
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for @Retryable usage
rg -n '@Retryable' --type=java -A 5

Repository: 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=java

Repository: 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.java

Repository: THIP-TextHip/THIP-Server

Length of output: 998


@EnableRetry 설정이 필요 없음

현재 코드베이스에 활성화된 @Retryable 어노테이션이 없습니다. 유일하게 발견된 @RetryableUserFollowService.java에서 주석 처리되어 있습니다. 따라서 @EnableRetry(proxyTargetClass = true) 설정 전체를 제거하거나, @Retryable을 실제로 사용할 계획이 있다면 재검토가 필요합니다.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/config/RetryConfig.java around lines 6 to 9, the
configuration class only enables @EnableRetry but there are no active @Retryable
usages (the only one is commented out), so either remove the
@EnableRetry(proxyTargetClass = true) annotation (and delete the class if it
becomes empty) to avoid unnecessary configuration, or if you intend to use retry
semantics, uncomment and apply @Retryable where needed and keep @EnableRetry;
update the code accordingly.

13 changes: 13 additions & 0 deletions src/main/java/konkuk/thip/config/WorkerThreadConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ public Executor schedulerAsyncExecutor() {
return executor;
}

@Bean(name = "outboxAsyncExecutor")
public Executor outboxAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // 아웃박스 처리 기본량
executor.setMaxPoolSize(8);
executor.setQueueCapacity(2000); // 적당한 큐 크기
executor.setThreadNamePrefix("outbox-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}

@Override
public Executor getAsyncExecutor() {
return fcmAsyncExecutor(); // 기본은 FCM 풀 사용
Expand Down
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);
}
}
Loading