Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
85b7367
[feat] 락 타임아웃 에러코드 추가 (#322)
hd0rable Nov 22, 2025
2dad005
[refactor] 락 타임아웃 에러 발생시 재시도로직 추가 (#322)
hd0rable Nov 22, 2025
08247ea
[refactor] @ EnableRetry 설정 (#322)
hd0rable Nov 22, 2025
b3cd50c
[refactor] findByPostIdForUpdate시 락 타임아웃 3초로 설정 (#322)
hd0rable Nov 22, 2025
f0b7523
[test] 테스트코드 수정 (#322)
hd0rable Nov 22, 2025
405de80
[feat] retry 의존성 추가(#322)
hd0rable Nov 23, 2025
1fde7ac
[feat] RetryConfig 추가(#322)
hd0rable Nov 23, 2025
9f39c14
[chore] @ EnableRetry 삭제(#322)
hd0rable Nov 23, 2025
bc2e40d
[feat] 락 타임아웃 에러 발생시 재시도 로직 추가 (#322)
hd0rable Nov 23, 2025
bdc9ec2
[test] 테스트 코드 수정 (#322)
hd0rable Nov 23, 2025
612dca7
[test] @ Transactional(propagation = Propagation.REQUIRES_NEW) 옵션 추가로…
hd0rable Dec 6, 2025
e12c850
[chore] 알림 주석처리 해제 (#322)
hd0rable Dec 6, 2025
7fee45f
[test] 동시성 테스트 db 정합성 검증 추가 (#322)
hd0rable Dec 6, 2025
2ebd3e3
[delete] 부하 테스트 스크립트 삭제 (#322)
hd0rable Dec 6, 2025
484f714
Merge branch 'develop' into test/#322-k6-feed-like-pessimistic-lock
hd0rable Dec 10, 2025
6d7fb32
[fix] @ Retryable retryFor 옵션 추가 및 @ Recover 예외별로 메서드 추가작성 (#322)
hd0rable Dec 30, 2025
8051729
[chore] 임포트문 정리 (#322)
hd0rable Dec 30, 2025
95d14f6
[feat] record 조회시 락 타임아웃 옵션 추가 (#322)
hd0rable Dec 30, 2025
33fb36e
[feat] vote 조회시 락 타임아웃 옵션 추가 (#322)
hd0rable Dec 30, 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
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 @@ -31,6 +31,7 @@ public enum ErrorCode implements ResponseCode {
WEB_DOMAIN_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, 50102, "허용된 웹 도메인 설정이 비어있습니다."),

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

/* 60000부터 비즈니스 예외 */
/**
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
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 pr과 겹치네요ㅎ

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,9 +1,13 @@
package konkuk.thip.feed.adapter.out.persistence.repository;

import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
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.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;

import java.util.List;
Expand All @@ -16,6 +20,11 @@ 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")
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
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
Loading