Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5ac36e6
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 13, 2025
480e850
[test] 피드 좋아요 상태변경 다중 스레드 테스트 (#322)
hd0rable Oct 18, 2025
f14a490
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
1731bcc
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 25, 2025
9bfc799
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 25, 2025
8827e93
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
56e757f
[refactor] 게시글 좋아요 상태변경 비관적 락 도입 (#322)
hd0rable Oct 25, 2025
27f7532
[refactor] 게시글 좋아요 상태변경 비관적 락 도입하여 조회시 post 레코드에 lock 과함께 조회하는 함수 추가 …
hd0rable Oct 25, 2025
5393625
[refactor] 게시글 좋아요 상태변경 비관적 락 도입하여 조회시 post 레코드에 lock 과함께 조회하는 함수 추가-…
hd0rable Oct 25, 2025
0a6f7b1
[refactor] 게시글 좋아요 상태변경 비관적 락 도입하여 조회시 post 레코드에 lock 과함께 조회하는 함수 추가-…
hd0rable Oct 25, 2025
3946830
[refactor] 게시글 좋아요 상태변경 비관적 락 도입하여 조회시 post 레코드에 lock 과함께 조회하는 함수 추가-…
hd0rable Oct 25, 2025
7b3c2a3
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 26, 2025
8a4c489
Merge remote-tracking branch 'origin/develop' into test/#322-k6-feed-…
hd0rable Nov 12, 2025
d7a5c42
[delete] 사용하지않는 테스트 스크립트 삭제 (#322)
hd0rable Nov 12, 2025
cf0d246
[chore] 테스트 스크립트 파일 위치 변경 (#322)
hd0rable Nov 12, 2025
38aed85
[refactor] 비관락 적용 메서드 네이밍 변경 (#322)
hd0rable Nov 12, 2025
775e16b
[refactor] 동시성 테스트 파일 위치 변경 (#322)
hd0rable Nov 12, 2025
3b9abb9
[test] 서비스 로직 수정하면서 깨지는 테스트 수정 (#322)
hd0rable Nov 12, 2025
7677566
[chore] 주석 오타 수정 (#322)
hd0rable Nov 12, 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
79 changes: 79 additions & 0 deletions loadtest/feed/feed_like_concurrency_test1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 낮은 동시성 20명이서 동시성 기능 안전성 테스트
import http from 'k6/http';
import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지)

const BASE_URL = 'http://localhost:8080';
const FEED_ID = 1; // 테스트할 피드 ID
const VUS = 20; // 원하는 VU 수

export let options = {
thresholds: {
// 요청 95%가 500ms 이내 응답을 받아야 함
http_req_duration: ['p(95)<500'],
// 전체 요청 중 실패율 1% 미만이어야 함
http_req_failed: ['rate<0.01'],
},
vus: VUS,
duration: '30s', // 30초동안 테스트
};

// 테스트 전 사용자 별 토큰 발급
export function setup() {
let tokens = [];
let likeStatus = [];

// 유저 ID에 대해 토큰을 미리 발급
for (let userId = 1; userId <= VUS; userId++) {
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
tokens.push(res.body);
likeStatus.push(true); // 좋아요 요청
}

return { tokens, likeStatus };
}

export default function (data) {
const vuIdx = __VU - 1;
const token = data.tokens[vuIdx];

if (data.lastStatusCode === 200) {
data.likeStatus[vuIdx] = !data.likeStatus[vuIdx];
}

// FeedIsLikeRequest DTO에 맞는 요청 body
const payload = JSON.stringify({
type: data.likeStatus[vuIdx],
});

const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
};

const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);
data.lastStatusCode = res.status;

// 응답 체크
check(res, {
'status 200': (r) => r.status === 200,
'status 400': (r) => r.status === 400,
'Internal server error': (r) => r.status === 500,
});

if (res.status !== 200) {
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
}

sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의
}

// 테스트 결과 html 리포트로 저장
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
export function handleSummary(data) {
return {
"summary.html": htmlReport(data),
};
}
83 changes: 83 additions & 0 deletions loadtest/feed/feed_like_concurrency_test2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 점진적 부하 증가 (Ramp-up) VU: 20 → 50 → 100 → 150 (1분 단위로 증가)
import http from 'k6/http';
import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지)

const BASE_URL = 'http://localhost:8080';
const FEED_ID = 1; // 테스트할 피드 ID

export let options = {
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
stages: [
{ duration: '1m', target: 20 }, // 1분간 VU 20명으로 점진적 증가
{ duration: '1m', target: 50 }, // 1분간 VU 50명으로 증가
{ duration: '1m', target: 100 }, // 1분간 VU 100명으로 증가
{ duration: '1m', target: 150 }, // 1분간 VU 150명으로 증가
{ duration: '30s', target: 0 }, // 30초 동안 VU 0명으로 줄이며 테스트 종료
],
};

// 테스트 전 사용자 별 토큰 발급
export function setup() {
// 점진적 증가하는 최대 VU 수 계산
const maxVUs = 150;
let tokens = [];
let likeStatus = [];

// 유저 ID에 대해 토큰을 미리 발급
for (let userId = 1; userId <= maxVUs; userId++) {
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
tokens.push(res.body);
likeStatus.push(true); // 좋아요 요청
}

return { tokens, likeStatus };
}

export default function (data) {
const vuIdx = __VU - 1;
const token = data.tokens[vuIdx];

if (data.lastStatusCode === 200) {
data.likeStatus[vuIdx] = !data.likeStatus[vuIdx];
}

// FeedIsLikeRequest DTO에 맞는 요청 body
const payload = JSON.stringify({
type: data.likeStatus[vuIdx],
});

const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
};

const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);
data.lastStatusCode = res.status;

// 응답 체크
check(res, {
'status 200': (r) => r.status === 200,
'status 400': (r) => r.status === 400,
'Internal server error': (r) => r.status === 500,
});

if (res.status !== 200) {
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
}

sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의
}

// 테스트 결과 html 리포트로 저장
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
export function handleSummary(data) {
return {
"summary.html": htmlReport(data),
};
}
116 changes: 116 additions & 0 deletions loadtest/feed/feed_like_concurrency_test3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 80%는 상세조회(GET), 20%는 좋아요 변경(POST) 요청
import http from 'k6/http';
import { sleep,check } from 'k6';

const BASE_URL = 'http://localhost:8080';
const FEED_ID = 1; // 테스트할 피드 ID

export let options = {
scenarios: {
read_scenario: {
executor: 'constant-vus',
vus: 160, // 전체 200명 중 160명은 상세 조회 전담
duration: '2m',
exec: 'readFeed',
},
write_scenario: {
executor: 'constant-vus',
vus: 40, // 전체 200명 중 20명은 좋아요 변경 전담
duration: '2m',
exec: 'likeFeed',
},
},
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};

// 테스트 전 사용자 별 토큰 발급
export function setup() {
// 최대 VU 수 계산
const maxVUs = 200;
let tokens = [];

// 유저 ID에 대해 토큰을 미리 발급
for (let userId = 1; userId <= maxVUs; userId++) {
const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`);
check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 });
tokens.push(res.body);
}

return {tokens};
}

// 상세조회만 실행
export function readFeed(data) {
let vuIdx = __VU - 1;
let token = data.tokens[vuIdx];
let params = {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
};

let res = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params);
check(res, {
'feed detail 200': (r) => r.status === 200,
'feed detail status 400': (r) => r.status === 400,
'feed detail Internal server error': (r) => r.status === 500,
});

if (res.status !== 200) {
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
}

sleep(Math.random()); // 0~1초 내 랜덤 대기(실사용 패턴 반영)
}

// 좋아요 변경만 실행
export function likeFeed(data) {
let vuIdx = __VU - 1;
let token = data.tokens[vuIdx];
let params = {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
}
};

// 상세 조회로 좋아요 상태 확인
let getRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params);
let isLiked = false;
if (getRes.status === 200) {
try {
let body = JSON.parse(getRes.body);
isLiked = body.data.isLiked;
} catch (e) {
console.error(`[VU${__VU}] 상세조회 파싱 오류:`, getRes.body);
}
}

// 상태 반대로 좋아요 또는 취소 요청
let payload = JSON.stringify({ type: !isLiked });
let res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params);

check(res, {
'feed like 200': (r) => r.status === 200,
'feed like status 400': (r) => r.status === 400,
'feed like Internal server error': (r) => r.status === 500,
});

if (res.status !== 200) {
console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`);
}

sleep(Math.random() + 0.5); // 0.5~1.5초 랜덤 대기
}

// 테스트 결과 html 리포트로 저장
import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
export function handleSummary(data) {
return {
"summary.html": htmlReport(data),
};
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public Optional<Feed> findById(Long id) {
.map(feedMapper::toDomainEntity);
}

@Override
public Optional<Feed> findByIdForUpdate(Long id) {
return feedJpaRepository.findByPostIdForUpdate(id)
.map(feedMapper::toDomainEntity);
}

@Override
public Long save(Feed feed) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package konkuk.thip.feed.adapter.out.persistence.repository;

import jakarta.persistence.LockModeType;
import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -16,6 +18,10 @@ public interface FeedJpaRepository extends JpaRepository<FeedJpaEntity, Long>, F
*/
Optional<FeedJpaEntity> findByPostId(Long postId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId")
Optional<FeedJpaEntity> findByPostIdForUpdate(@Param("postId") Long postId);

@Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId")
long countAllFeedsByUserId(@Param("userId") Long userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ public interface FeedCommandPort {
Long save(Feed feed);
Long update(Feed feed);
Optional<Feed> findById(Long id);
Optional<Feed> findByIdForUpdate(Long id);
default Feed getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
}
default Feed getByIdOrThrowForUpdate(Long id) {
return findByIdForUpdate(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
}
void delete(Feed feed);
void saveSavedFeed(Long userId, Long feedId);
void deleteSavedFeed(Long userId, Long feedId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,18 @@ public class PostLikeService implements PostLikeUseCase {
public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) {

// 1. 게시물 타입에 맞게 검증 및 조회
CountUpdatable post = postHandler.findPost(command.postType(), command.postId());
CountUpdatable post = postHandler.findPostForUpdate(command.postType(), command.postId());
// 1-1. 게시글 타입에 따른 게시물 좋아요 권한 검증
postLikeAuthorizationValidator.validateUserCanAccessPostLike(command.postType(), post, command.userId());

// 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회
boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId());

// 3. 좋아요 상태변경
//TODO 게시물의 좋아요 수 증가/감소 동시성 제어 로직 추가해야됨
// 3. 게시물 좋아요 수 업데이트
post.updateLikeCount(postCountService,command.isLike());
postHandler.updatePost(command.postType(), post);

// 4. 좋아요 상태변경
if (command.isLike()) {
postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증
postLikeCommandPort.save(command.userId(), command.postId(),command.postType());
Expand All @@ -58,10 +61,6 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) {
postLikeCommandPort.delete(command.userId(), command.postId());
}

// 4. 게시물 좋아요 수 업데이트
post.updateLikeCount(postCountService,command.isLike());
postHandler.updatePost(command.postType(), post);

return PostIsLikeResult.of(post.getId(), command.isLike());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ public CountUpdatable findPost(PostType type, Long postId) {
};
}

public CountUpdatable findPostForUpdate(PostType type, Long postId) {
return switch (type) {
case FEED -> feedCommandPort.getByIdOrThrowForUpdate(postId);
case RECORD -> recordCommandPort.getByIdOrThrowForUpdate(postId);
case VOTE -> voteCommandPort.getByIdOrThrowForUpdate(postId);
};
}

public void updatePost(PostType type, CountUpdatable post) {
switch (type) {
case FEED -> feedCommandPort.update((Feed) post);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public Optional<Record> findById(Long id) {
.map(recordMapper::toDomainEntity);
}

@Override
public Optional<Record> findByIdForUpdate(Long id) {
return recordJpaRepository.findByPostIdForUpdate(id)
.map(recordMapper::toDomainEntity);
}

@Override
public void delete(Record record) {
RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow(
Expand Down
Loading