Skip to content

Commit 4819810

Browse files
committed
[merge] merge
2 parents c351557 + 2fc3733 commit 4819810

33 files changed

Lines changed: 738 additions & 147 deletions

build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ dependencies {
102102
// Spring AI - Google AI(Gemini) 연동
103103
implementation 'org.springframework.ai:spring-ai-starter-model-openai:1.0.1'
104104

105-
// spring Retry
106-
implementation 'org.springframework.retry:spring-retry'
105+
// spring Retry
106+
implementation 'org.springframework.retry:spring-retry'
107107
}
108108

109109
def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// 낮은 동시성 20명이서 동시성 기능 안전성 테스트
2+
import http from 'k6/http';
3+
import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지)
4+
5+
const BASE_URL = 'http://localhost:8080';
6+
const FEED_ID = 1; // 테스트할 피드 ID
7+
const VUS = 20; // 원하는 VU 수
8+
9+
export let options = {
10+
thresholds: {
11+
// 요청 95%가 500ms 이내 응답을 받아야 함
12+
http_req_duration: ['p(95)<500'],
13+
// 전체 요청 중 실패율 1% 미만이어야 함
14+
http_req_failed: ['rate<0.01'],
15+
},
16+
vus: VUS,
17+
duration: '30s', // 30초동안 테스트
18+
};
19+
20+
// 테스트 전 사용자 별 토큰 발급
21+
export function setup() {
22+
let tokens = [];
23+
let likeStatus = [];
24+
25+
// 유저 ID에 대해 토큰을 미리 발급
26+
for (let userId = 1; userId <= VUS; userId++) {
27+
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
28+
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
29+
tokens.push(res.body);
30+
likeStatus.push(true); // 좋아요 요청
31+
}
32+
33+
return { tokens, likeStatus };
34+
}
35+
36+
export default function (data) {
37+
const vuIdx = __VU - 1;
38+
const token = data.tokens[vuIdx];
39+
40+
if (data.lastStatusCode === 200) {
41+
data.likeStatus[vuIdx] = !data.likeStatus[vuIdx];
42+
}
43+
44+
// FeedIsLikeRequest DTO에 맞는 요청 body
45+
const payload = JSON.stringify({
46+
type: data.likeStatus[vuIdx],
47+
});
48+
49+
const params = {
50+
headers: {
51+
'Content-Type': 'application/json',
52+
'Authorization': `Bearer ${token}`,
53+
},
54+
};
55+
56+
const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);
57+
data.lastStatusCode = res.status;
58+
59+
// 응답 체크
60+
check(res, {
61+
'status 200': (r) => r.status === 200,
62+
'status 400': (r) => r.status === 400,
63+
'Internal server error': (r) => r.status === 500,
64+
});
65+
66+
if (res.status !== 200) {
67+
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
68+
}
69+
70+
sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의
71+
}
72+
73+
// 테스트 결과 html 리포트로 저장
74+
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
75+
export function handleSummary(data) {
76+
return {
77+
"summary.html": htmlReport(data),
78+
};
79+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// 점진적 부하 증가 (Ramp-up) VU: 20 → 50 → 100 → 150 (1분 단위로 증가)
2+
import http from 'k6/http';
3+
import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지)
4+
5+
const BASE_URL = 'http://localhost:8080';
6+
const FEED_ID = 1; // 테스트할 피드 ID
7+
8+
export let options = {
9+
thresholds: {
10+
http_req_duration: ['p(95)<500'],
11+
http_req_failed: ['rate<0.01'],
12+
},
13+
stages: [
14+
{ duration: '1m', target: 20 }, // 1분간 VU 20명으로 점진적 증가
15+
{ duration: '1m', target: 50 }, // 1분간 VU 50명으로 증가
16+
{ duration: '1m', target: 100 }, // 1분간 VU 100명으로 증가
17+
{ duration: '1m', target: 150 }, // 1분간 VU 150명으로 증가
18+
{ duration: '30s', target: 0 }, // 30초 동안 VU 0명으로 줄이며 테스트 종료
19+
],
20+
};
21+
22+
// 테스트 전 사용자 별 토큰 발급
23+
export function setup() {
24+
// 점진적 증가하는 최대 VU 수 계산
25+
const maxVUs = 150;
26+
let tokens = [];
27+
let likeStatus = [];
28+
29+
// 유저 ID에 대해 토큰을 미리 발급
30+
for (let userId = 1; userId <= maxVUs; userId++) {
31+
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
32+
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
33+
tokens.push(res.body);
34+
likeStatus.push(true); // 좋아요 요청
35+
}
36+
37+
return { tokens, likeStatus };
38+
}
39+
40+
export default function (data) {
41+
const vuIdx = __VU - 1;
42+
const token = data.tokens[vuIdx];
43+
44+
if (data.lastStatusCode === 200) {
45+
data.likeStatus[vuIdx] = !data.likeStatus[vuIdx];
46+
}
47+
48+
// FeedIsLikeRequest DTO에 맞는 요청 body
49+
const payload = JSON.stringify({
50+
type: data.likeStatus[vuIdx],
51+
});
52+
53+
const params = {
54+
headers: {
55+
'Content-Type': 'application/json',
56+
'Authorization': `Bearer ${token}`,
57+
},
58+
};
59+
60+
const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);
61+
data.lastStatusCode = res.status;
62+
63+
// 응답 체크
64+
check(res, {
65+
'status 200': (r) => r.status === 200,
66+
'status 400': (r) => r.status === 400,
67+
'Internal server error': (r) => r.status === 500,
68+
});
69+
70+
if (res.status !== 200) {
71+
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
72+
}
73+
74+
sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의
75+
}
76+
77+
// 테스트 결과 html 리포트로 저장
78+
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
79+
export function handleSummary(data) {
80+
return {
81+
"summary.html": htmlReport(data),
82+
};
83+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// 80%는 상세조회(GET), 20%는 좋아요 변경(POST) 요청
2+
import http from 'k6/http';
3+
import { sleep,check } from 'k6';
4+
5+
const BASE_URL = 'http://localhost:8080';
6+
const FEED_ID = 1; // 테스트할 피드 ID
7+
8+
export let options = {
9+
scenarios: {
10+
read_scenario: {
11+
executor: 'constant-vus',
12+
vus: 160, // 전체 200명 중 160명은 상세 조회 전담
13+
duration: '2m',
14+
exec: 'readFeed',
15+
},
16+
write_scenario: {
17+
executor: 'constant-vus',
18+
vus: 40, // 전체 200명 중 20명은 좋아요 변경 전담
19+
duration: '2m',
20+
exec: 'likeFeed',
21+
},
22+
},
23+
thresholds: {
24+
http_req_duration: ['p(95)<500'],
25+
http_req_failed: ['rate<0.01'],
26+
},
27+
};
28+
29+
// 테스트 전 사용자 별 토큰 발급
30+
export function setup() {
31+
// 최대 VU 수 계산
32+
const maxVUs = 200;
33+
let tokens = [];
34+
35+
// 유저 ID에 대해 토큰을 미리 발급
36+
for (let userId = 1; userId <= maxVUs; userId++) {
37+
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
38+
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
39+
tokens.push(res.body);
40+
}
41+
42+
return {tokens};
43+
}
44+
45+
// 상세조회만 실행
46+
export function readFeed(data) {
47+
let vuIdx = __VU - 1;
48+
let token = data.tokens[vuIdx];
49+
let params = {
50+
headers: {
51+
'Authorization': `Bearer ${token}`,
52+
'Content-Type': 'application/json',
53+
}
54+
};
55+
56+
let res = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params);
57+
check(res, {
58+
'feed detail 200': (r) => r.status === 200,
59+
'feed detail status 400': (r) => r.status === 400,
60+
'feed detail Internal server error': (r) => r.status === 500,
61+
});
62+
63+
if (res.status !== 200) {
64+
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
65+
}
66+
67+
sleep(Math.random()); // 0~1초 내 랜덤 대기(실사용 패턴 반영)
68+
}
69+
70+
// 좋아요 변경만 실행
71+
export function likeFeed(data) {
72+
let vuIdx = __VU - 1;
73+
let token = data.tokens[vuIdx];
74+
let params = {
75+
headers: {
76+
'Authorization': `Bearer ${token}`,
77+
'Content-Type': 'application/json',
78+
}
79+
};
80+
81+
// 상세 조회로 좋아요 상태 확인
82+
let getRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params);
83+
let isLiked = false;
84+
if (getRes.status === 200) {
85+
try {
86+
let body = JSON.parse(getRes.body);
87+
isLiked = body.data.isLiked;
88+
} catch (e) {
89+
console.error(`[VU${__VU}] 상세조회 파싱 오류:`, getRes.body);
90+
}
91+
}
92+
93+
// 상태 반대로 좋아요 또는 취소 요청
94+
let payload = JSON.stringify({ type: !isLiked });
95+
let res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);
96+
97+
check(res, {
98+
'feed like 200': (r) => r.status === 200,
99+
'feed like status 400': (r) => r.status === 400,
100+
'feed like Internal server error': (r) => r.status === 500,
101+
});
102+
103+
if (res.status !== 200) {
104+
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
105+
}
106+
107+
sleep(Math.random() + 0.5); // 0.5~1.5초 랜덤 대기
108+
}
109+
110+
// 테스트 결과 html 리포트로 저장
111+
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
112+
export function handleSummary(data) {
113+
return {
114+
"summary.html": htmlReport(data),
115+
};
116+
}
Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,8 @@ const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여
1414
const joinLatency = new Trend('rooms_join_latency'); // 참여 API 지연(ms)
1515
const http5xx = new Counter('rooms_join_5xx'); // 5xx 개수
1616
const http2xx = new Counter('rooms_join_2xx'); // 2xx 개수
17-
const http4xx = new Counter('rooms_join_4xx'); // 4xx 개수
18-
19-
// 실패 원인 분포 파악용(응답 JSON의 code 필드 기준)
20-
const token_issue_failed = new Counter('token_issue_failed');
21-
const fail_ROOM_MEMBER_COUNT_EXCEEDED = new Counter('fail_ROOM_MEMBER_COUNT_EXCEEDED');
22-
const fail_USER_ALREADY_PARTICIPATE = new Counter('fail_USER_ALREADY_PARTICIPATE');
23-
const fail_OTHER_4XX = new Counter('fail_OTHER_4XX');
24-
25-
const ERR = { // THIP error code
26-
ROOM_MEMBER_COUNT_EXCEEDED: 100006,
27-
USER_ALREADY_PARTICIPATE: 140005,
28-
};
29-
30-
function parseError(res) {
31-
try {
32-
const j = JSON.parse(res.body || '{}'); // BaseResponse 구조
33-
// BaseResponse: { isSuccess:boolean, code:number, message:string, requestId:string, data:any }
34-
return {
35-
code: Number(j.code), // 정수 코드
36-
message: j.message || '',
37-
requestId: j.requestId || '',
38-
isSuccess: !!j.isSuccess
39-
};
40-
} catch (e) {
41-
return { code: NaN, message: '', requestId: '', isSuccess: false };
42-
}
43-
}
17+
const http400 = new Counter('rooms_join_400'); // 400 개수
18+
const http423 = new Counter('rooms_join_423'); // 423 개수
4419

4520
// ------------ 시나리오 ------------
4621
// [인기 작가가 만든 모임방에 THIP의 수많은 유저들이 '모임방 참여' 요청을 보내는 상황 가정]
@@ -57,6 +32,8 @@ export const options = {
5732
},
5833
thresholds: {
5934
rooms_join_5xx: ['count==0'], // 서버 오류는 0건이어야 함
35+
rooms_join_423: ['count>=0'], // 기록용
36+
rooms_join_400: ['count>=0'], // 기록용
6037
rooms_join_latency: ['p(95)<1000'], // p95 < 1s
6138
},
6239
};
@@ -83,7 +60,6 @@ export function setup() {
8360
}
8461
else {
8562
tokens.push(''); // 실패한 자리도 인덱스 유지
86-
token_issue_failed.add(1);
8763
}
8864
}
8965
sleep(BATCH_PAUSE_S);
@@ -124,21 +100,10 @@ export default function (data) {
124100
joinLatency.add(res.timings.duration);
125101
if (res.status >= 200 && res.status < 300) http2xx.add(1);
126102
else if (res.status >= 400 && res.status < 500) {
127-
http4xx.add(1);
128-
const err = parseError(res);
129-
switch (err.code) {
130-
case ERR.ROOM_MEMBER_COUNT_EXCEEDED:
131-
fail_ROOM_MEMBER_COUNT_EXCEEDED.add(1);
132-
break;
133-
case ERR.USER_ALREADY_PARTICIPATE:
134-
fail_USER_ALREADY_PARTICIPATE.add(1);
135-
break;
136-
default:
137-
fail_OTHER_4XX.add(1);
138-
}
139-
} else if (res.status >= 500) {
140-
http5xx.add(1);
103+
if (res.status === 400) http400.add(1);
104+
else if (res.status === 423) http423.add(1);
141105
}
106+
else if (res.status >= 500) http5xx.add(1);
142107

143108
// === 검증 ===
144109
check(res, {

src/main/java/konkuk/thip/common/exception/code/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public enum ErrorCode implements ResponseCode {
3131
WEB_DOMAIN_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, 50102, "허용된 웹 도메인 설정이 비어있습니다."),
3232

3333
PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."),
34+
RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."),
3435

3536
RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."),
3637

src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ public Optional<Feed> findById(Long id) {
4444
.map(feedMapper::toDomainEntity);
4545
}
4646

47+
@Override
48+
public Optional<Feed> findByIdForUpdate(Long id) {
49+
return feedJpaRepository.findByPostIdForUpdate(id)
50+
.map(feedMapper::toDomainEntity);
51+
}
4752

4853
@Override
4954
public Long save(Feed feed) {

0 commit comments

Comments
 (0)