Skip to content

Commit 2fc3733

Browse files
authored
Merge pull request #334 from THIP-TextHip/test/#322-k6-feed-like-pessimistic-lock
[fix] 게시글 좋아요 500 error(deadlock) 해결
2 parents 776c5d6 + e56da5c commit 2fc3733

21 files changed

Lines changed: 549 additions & 20 deletions
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+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ 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-
3534
RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."),
3635

3736
/* 60000부터 비즈니스 예외 */

src/main/java/konkuk/thip/config/RetryConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
@Configuration
77
@EnableRetry(proxyTargetClass = true)
88
public class RetryConfig {
9-
}
9+
}

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) {

src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package konkuk.thip.feed.adapter.out.persistence.repository;
22

3+
import jakarta.persistence.LockModeType;
4+
import jakarta.persistence.QueryHint;
35
import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
46
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Lock;
58
import org.springframework.data.jpa.repository.Modifying;
69
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.jpa.repository.QueryHints;
711
import org.springframework.data.repository.query.Param;
812

913
import java.util.List;
@@ -16,6 +20,11 @@ public interface FeedJpaRepository extends JpaRepository<FeedJpaEntity, Long>, F
1620
*/
1721
Optional<FeedJpaEntity> findByPostId(Long postId);
1822

23+
@Lock(LockModeType.PESSIMISTIC_WRITE)
24+
@Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId")
25+
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
26+
Optional<FeedJpaEntity> findByPostIdForUpdate(@Param("postId") Long postId);
27+
1928
@Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId")
2029
long countAllFeedsByUserId(@Param("userId") Long userId);
2130

src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@ public interface FeedCommandPort {
1212
Long save(Feed feed);
1313
Long update(Feed feed);
1414
Optional<Feed> findById(Long id);
15+
Optional<Feed> findByIdForUpdate(Long id);
1516
default Feed getByIdOrThrow(Long id) {
1617
return findById(id)
1718
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
1819
}
20+
default Feed getByIdOrThrowForUpdate(Long id) {
21+
return findByIdForUpdate(id)
22+
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
23+
}
1924
void delete(Feed feed);
2025
void saveSavedFeed(Long userId, Long feedId);
2126
void deleteSavedFeed(Long userId, Long feedId);

0 commit comments

Comments
 (0)