From 5ac36e6743f205d127a3f5d00416ba0089f502b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 13 Oct 2025 21:20:48 +0900 Subject: [PATCH 01/37] =?UTF-8?q?[test]=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/konkuk/thip/k6/feed-get-test.js | 63 +++++++++++++ .../thip/k6/feed-like-concurrency-test.js | 89 +++++++++++++++++++ .../thip/k6/feed-like-concurrency-test2.js | 75 ++++++++++++++++ .../thip/k6/feed-like-concurrency-test3.js | 89 +++++++++++++++++++ .../thip/k6/feed-like-concurrency-test4.js | 74 +++++++++++++++ .../thip/k6/feed-like-concurrency-test5.js | 75 ++++++++++++++++ 6 files changed, 465 insertions(+) create mode 100644 src/test/java/konkuk/thip/k6/feed-get-test.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js diff --git a/src/test/java/konkuk/thip/k6/feed-get-test.js b/src/test/java/konkuk/thip/k6/feed-get-test.js new file mode 100644 index 000000000..c0fbbc036 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-get-test.js @@ -0,0 +1,63 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 10; // 원하는 VU 수 + +export let options = { + vus: VUS, + duration: '30s', // 30초동안 테스트 +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + let tokens = []; + + // 유저 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); + } + + return { tokens}; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const res = http.get(`${BASE_URL}/feeds`, params); + + // 응답 체크 + 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js new file mode 100644 index 000000000..0de37c177 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js @@ -0,0 +1,89 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 5; // 원하는 VU 수 + +export let options = { + vus: VUS, // 동시에 5명 접속 + 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(false); + } + + return { tokens, likeStatus }; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 + 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); + + // 응답 체크 + 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}`); + } + + if(__VU === 1) { + const expectedCount = data.likeStatus.filter(s => s).length; + + // 피드 상세조회 api + let countRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); + + if (countRes.status === 200) { + let body = JSON.parse(countRes.body); + let actualCount = body.data.likeCount; + if (actualCount !== expectedCount) { + console.error(`Like count mismatch! expected=${expectedCount}, actual=${actualCount}`); + } + } + } + + sleep(0.01); // 사용자가 좋아요 버튼을 동시에 연타한다고 가정 10ms +} + +// 테스트 결과 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js new file mode 100644 index 000000000..55e2ec8a3 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js @@ -0,0 +1,75 @@ +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 1; // 한 명 사용자가 연타 테스트 +const ITERATIONS = 60; // 연속 호출 횟수 각 구간마다 20번 + +export let options = { + vus: VUS, + iterations: ITERATIONS, +}; + +export function setup() { + const res = http.get(`${BASE_URL}/api/test/token/access?userId=1`); + check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); + + // 최초 좋아요 상태 false로 초기화 + const likeStatus = false; + + return { token: res.body, likeStatus }; +} + +export default function (data) { + const token = data.token; + + // 요청마다 좋아요 상태 변경 + data.likeStatus = !data.likeStatus; + + const payload = JSON.stringify({ + type: data.likeStatus + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params); + + check(res, { + 'status 200': r => r.status === 200, + 'status 400': r => r.status === 400, + 'Internal server error': r => r.status === 500, + }); + + if (res.status === 400) { + console.error(`[VU${__VU}] 400 Bad Request at iteration ${__ITER} body=${res.body}`); + } + if (res.status === 500) { + console.error(`[VU${__VU}] 500 Internal Server Error at iteration ${__ITER} body=${res.body}`); + } + + // iteration 번호로 구간 구분하여 sleep 시간 변경 + let sleepTime; + if (__ITER <= 20) { + sleepTime = 0.01; // 10ms + } else if (__ITER <= 40) { + sleepTime = 0.05; // 50ms + } else { + sleepTime = 0.1; // 100ms + } + + sleep(sleepTime); +} + +// 테스트 결과 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js new file mode 100644 index 000000000..50ae38739 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js @@ -0,0 +1,89 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 5; // 원하는 VU 수 + +export let options = { + vus: VUS, // 동시에 5명 접속 + 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(false); + } + + return { tokens, likeStatus }; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 + 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); + + // 응답 체크 + 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}`); + } + + if(__VU === 1) { + const expectedCount = data.likeStatus.filter(s => s).length; + + // 피드 상세조회 api + let countRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); + + if (countRes.status === 200) { + let body = JSON.parse(countRes.body); + let actualCount = body.data.likeCount; + if (actualCount !== expectedCount) { + console.error(`Like count mismatch! expected=${expectedCount}, actual=${actualCount}`); + } + } + } + + 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js new file mode 100644 index 000000000..72022bdc9 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js @@ -0,0 +1,74 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 2; // 원하는 VU 수 + +export let options = { + vus: VUS, // 동시에 5명 접속 + 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(false); + } + + return { tokens, likeStatus }; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 + 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); + + // 응답 체크 + 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js new file mode 100644 index 000000000..4c71a1511 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js @@ -0,0 +1,75 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 4; // 테스트할 피드 ID +const VUS = 2; // 원하는 VU 수 + +export let options = { + 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), + }; +} From 480e8502423f762660eabcba244b5504eed09dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 18 Oct 2025 23:48:30 +0900 Subject: [PATCH 02/37] =?UTF-8?q?[test]=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedChangeLikeStatusConcurrencyTest.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java new file mode 100644 index 000000000..dcfbf7465 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java @@ -0,0 +1,121 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; +import konkuk.thip.post.application.service.PostLikeService; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@Slf4j +@Transactional +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 피드 좋아요 상태변경 다중 스레드 테스트") +class FeedChangeLikeStatusConcurrencyTest { + + @Autowired private MockMvc mockMvc; + @Autowired private PostLikeService postLikeService; + @Autowired private EntityManager em; + + @Autowired private ObjectMapper objectMapper; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity book; + private FeedJpaEntity feed; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + } + +// @AfterEach +// void tearDown() { +// postLikeJpaRepository.deleteAllInBatch(); +// feedJpaRepository.deleteAllInBatch(); +// bookJpaRepository.deleteAllInBatch(); +// userJpaRepository.deleteAllInBatch(); +// } + + @Test + public void concurrentLikeToggleTest() throws InterruptedException { + int threadCount = 2; + int repeat = 10; // 스레드별 몇 번 반복할지 + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // 각 스레드별로 현재 상태(true/false)를 관리하기 위한 배열 + boolean[] likeStatus = new boolean[threadCount]; + + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executor.submit(() -> { + likeStatus[userIndex] = true; + for (int r = 0; r < repeat; r++) { + boolean isLike = likeStatus[userIndex]; + try { + postLikeService.changeLikeStatusPost( + new PostIsLikeCommand(user.getUserId(), feed.getPostId(), PostType.FEED, isLike) + ); + successCount.getAndIncrement(); + // 성공했을 때만 현재 상태를 반전 + likeStatus[userIndex] = !likeStatus[userIndex]; + } catch (Exception e) { + log.error(e.getMessage(), e); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + } + }); + } + + latch.await(); + executor.shutdown(); + + // then + assertAll( + () -> assertThat(successCount.get()).isEqualTo(10), + () -> assertThat(failCount.get()).isEqualTo(0) + ); + } + + +} From f14a490f5104935422fd5e01119e665f897a9c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 25 Oct 2025 17:23:15 +0900 Subject: [PATCH 03/37] =?UTF-8?q?[test]=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#3?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedChangeLikeStatusConcurrencyTest.java | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java index dcfbf7465..3a9079395 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java @@ -1,7 +1,5 @@ package konkuk.thip.feed.adapter.in.web; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.EntityManager; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; @@ -22,8 +20,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -35,17 +31,13 @@ @SpringBootTest @Slf4j -@Transactional @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) @DisplayName("[단위] 피드 좋아요 상태변경 다중 스레드 테스트") class FeedChangeLikeStatusConcurrencyTest { - @Autowired private MockMvc mockMvc; @Autowired private PostLikeService postLikeService; - @Autowired private EntityManager em; - @Autowired private ObjectMapper objectMapper; @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; @@ -63,20 +55,14 @@ void setUp() { feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); } -// @AfterEach -// void tearDown() { -// postLikeJpaRepository.deleteAllInBatch(); -// feedJpaRepository.deleteAllInBatch(); -// bookJpaRepository.deleteAllInBatch(); -// userJpaRepository.deleteAllInBatch(); -// } @Test public void concurrentLikeToggleTest() throws InterruptedException { + int threadCount = 2; int repeat = 10; // 스레드별 몇 번 반복할지 ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch latch = new CountDownLatch(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount * repeat); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger(); @@ -112,7 +98,7 @@ public void concurrentLikeToggleTest() throws InterruptedException { // then assertAll( - () -> assertThat(successCount.get()).isEqualTo(10), + () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat), () -> assertThat(failCount.get()).isEqualTo(0) ); } From 1731bcc9287534589d087ce5c40695502275b4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 25 Oct 2025 17:23:54 +0900 Subject: [PATCH 04/37] =?UTF-8?q?[test]=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/k6/feed-like-concurrency-test5.js | 2 +- .../thip/k6/feed-like-concurrency-test6.js | 82 ++++++++++++++++++ .../thip/k6/feed-like-concurrency-test7.js | 82 ++++++++++++++++++ .../thip/k6/feed-like-concurrency-test8.js | 85 +++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js index 4c71a1511..c207fb6d8 100644 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js @@ -5,7 +5,7 @@ import http from 'k6/http'; import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID +const FEED_ID = 1; // 테스트할 피드 ID const VUS = 2; // 원하는 VU 수 export let options = { diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js new file mode 100644 index 000000000..8e4feefc1 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js @@ -0,0 +1,82 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +// 정상적인 테스트 유저 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 +const VUS = 1; // 원하는 VU 수 + +export let options = { + thresholds: { + // 요청 95%가 500ms 이내 응답을 받아야 함 + http_req_duration: ['p(95)<500'], + // 실패율 0% (완벽한 성공률) + http_req_failed: ['rate==0'], + }, + 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js new file mode 100644 index 000000000..e2d508a4c --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js @@ -0,0 +1,82 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +//테스트 코드랑 비슷한테스트 유저 2명이서 동시에 좋아요요청 +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 = 2; // 원하는 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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js new file mode 100644 index 000000000..6797efa90 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js @@ -0,0 +1,85 @@ +// cd ./src/test/java/konkuk/thip/k6 +// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js +// k6 run feed-like-concurrency-test.js +// 2025-10-24 17:28 점진적으로 5->10->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 + +export let options = { + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, + stages: [ + { duration: '1m', target: 5 }, // 1분간 VU 5명으로 점진적 증가 시작 + { duration: '1m', target: 10 }, // 1분간 VU 10명 유지 + { duration: '1m', target: 20 }, // 1분간 VU 20명 유지 + { duration: '30s', target: 0 }, // 30초 동안 VU 0명으로 줄이며 테스트 종료 + ], +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + // 점진적 증가하는 최대 VU 수 계산 + const maxVUs = 20; + 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), + }; +} From 9bfc799d8307ff51748b5d4429ffea86ec81606e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:23:06 +0900 Subject: [PATCH 05/37] =?UTF-8?q?[test]=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/k6/feed-like-concurrency-test10.js | 84 +++++++++++++++++++ .../thip/k6/feed-like-concurrency-test11.js | 81 ++++++++++++++++++ .../thip/k6/feed-like-concurrency-test9.js | 80 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js new file mode 100644 index 000000000..c178e12cc --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js @@ -0,0 +1,84 @@ +// feed-like-concurrency-test10.js +// 점진적 부하 증가 (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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js new file mode 100644 index 000000000..fd2cd5725 --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js @@ -0,0 +1,81 @@ +// feed-like-concurrency-test11.js +// 고정 부하 장시간 테스트 VU: 100명 고정에 10분 +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 = 100; // 원하는 VU 수 + +export let options = { + thresholds: { + // 요청 95%가 500ms 이내 응답을 받아야 함 + http_req_duration: ['p(95)<500'], + // 전체 요청 중 실패율 1% 미만이어야 함 + http_req_failed: ['rate<0.01'], + }, + vus: VUS, + duration: '10m', // 10분 동안 테스트 +}; + +// 테스트 전 사용자 별 토큰 발급 +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), + }; +} + diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js new file mode 100644 index 000000000..b8bc32d1a --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js @@ -0,0 +1,80 @@ +// feed-like-concurrency-test9.js +// 낮은 동시성 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 = 150; // 원하는 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.1); // 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), + }; +} From 8827e9360e23967c4a3447477c52b248f74f2561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:23:50 +0900 Subject: [PATCH 06/37] =?UTF-8?q?[test]=20=ED=94=BC=EB=93=9C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#3?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/FeedChangeLikeStatusConcurrencyTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java index 3a9079395..c7e1d5ab9 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java @@ -5,7 +5,6 @@ import konkuk.thip.common.util.TestEntityFactory; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; -import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; import konkuk.thip.post.application.service.PostLikeService; import konkuk.thip.post.domain.PostType; @@ -41,18 +40,19 @@ class FeedChangeLikeStatusConcurrencyTest { @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; - @Autowired private PostLikeJpaRepository postLikeJpaRepository; - private UserJpaEntity user; + private UserJpaEntity user1; + private UserJpaEntity user2; private BookJpaEntity book; private FeedJpaEntity feed; @BeforeEach void setUp() { Alias alias = TestEntityFactory.createLiteratureAlias(); - user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + user1 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + user2 = userJpaRepository.save(TestEntityFactory.createUser(alias)); book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); - feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user1,book, true)); } @@ -77,8 +77,11 @@ public void concurrentLikeToggleTest() throws InterruptedException { for (int r = 0; r < repeat; r++) { boolean isLike = likeStatus[userIndex]; try { + // 각 스레드별로 서로 다른 user를 사용하도록 user1, user2 분기 처리 + Long userId = (userIndex == 0) ? user1.getUserId() : user2.getUserId(); + postLikeService.changeLikeStatusPost( - new PostIsLikeCommand(user.getUserId(), feed.getPostId(), PostType.FEED, isLike) + new PostIsLikeCommand(userId, feed.getPostId(), PostType.FEED, isLike) ); successCount.getAndIncrement(); // 성공했을 때만 현재 상태를 반전 From 56e757f23acd7a3cc7a5b3efacb047acd1fb903e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:24:42 +0900 Subject: [PATCH 07/37] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/application/service/PostLikeService.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index bf9a2cf1b..74e5fdeab 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -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.findPostWithLock(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()); @@ -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()); } From 27f7532f4265cd093bf2657380b1da97cbf2c732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:25:44 +0900 Subject: [PATCH 08/37] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20pos?= =?UTF-8?q?t=20=EB=A0=88=EC=BD=94=EB=93=9C=EC=97=90=20lock=20=EA=B3=BC?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/application/service/handler/PostHandler.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java b/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java index df798c5df..d8f1ff088 100644 --- a/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java +++ b/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java @@ -31,6 +31,14 @@ public CountUpdatable findPost(PostType type, Long postId) { }; } + public CountUpdatable findPostWithLock(PostType type, Long postId) { + return switch (type) { + case FEED -> feedCommandPort.getByIdOrThrowWithLock(postId); + case RECORD -> recordCommandPort.getByIdOrThrowWithLock(postId); + case VOTE -> voteCommandPort.getByIdOrThrowWithLock(postId); + }; + } + public void updatePost(PostType type, CountUpdatable post) { switch (type) { case FEED -> feedCommandPort.update((Feed) post); From 53936250dbf79f98da3f02d50660732f551909ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:26:09 +0900 Subject: [PATCH 09/37] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20pos?= =?UTF-8?q?t=20=EB=A0=88=EC=BD=94=EB=93=9C=EC=97=90=20lock=20=EA=B3=BC?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80-feed=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/FeedCommandPersistenceAdapter.java | 5 +++++ .../out/persistence/repository/FeedJpaRepository.java | 6 ++++++ .../thip/feed/application/port/out/FeedCommandPort.java | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index 58228d1ec..42de71c96 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -44,6 +44,11 @@ public Optional findById(Long id) { .map(feedMapper::toDomainEntity); } + @Override + public Optional findByIdWithLock(Long id) { + return feedJpaRepository.findByPostIdWithPessimisticLock(id) + .map(feedMapper::toDomainEntity); + } @Override public Long save(Feed feed) { diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index 2c84f0a14..f5c4cd58a 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -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; @@ -16,6 +18,10 @@ public interface FeedJpaRepository extends JpaRepository, F */ Optional findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId") + Optional findByPostIdWithPessimisticLock(@Param("postId") Long postId); + @Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId") long countAllFeedsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index 1839a4b5c..5487afe80 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -12,10 +12,15 @@ public interface FeedCommandPort { Long save(Feed feed); Long update(Feed feed); Optional findById(Long id); + Optional findByIdWithLock(Long id); default Feed getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); } + default Feed getByIdOrThrowWithLock(Long id) { + return findByIdWithLock(id) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + } void delete(Feed feed); void saveSavedFeed(Long userId, Long feedId); void deleteSavedFeed(Long userId, Long feedId); From 0a6f7b1f84bad06c8864912bca3e0fbe41b6740d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:26:42 +0900 Subject: [PATCH 10/37] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20pos?= =?UTF-8?q?t=20=EB=A0=88=EC=BD=94=EB=93=9C=EC=97=90=20lock=20=EA=B3=BC?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80-feed=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/RecordCommandPersistenceAdapter.java | 6 ++++++ .../persistence/repository/record/RecordJpaRepository.java | 6 ++++++ .../roompost/application/port/out/RecordCommandPort.java | 7 +++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java index 8d6351fb3..137afe55c 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java @@ -56,6 +56,12 @@ public Optional findById(Long id) { .map(recordMapper::toDomainEntity); } + @Override + public Optional findByIdWithLock(Long id) { + return recordJpaRepository.findByPostIdWithPessimisticLock(id) + .map(recordMapper::toDomainEntity); + } + @Override public void delete(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java index 9677e8263..08e32557f 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -1,7 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.record; +import jakarta.persistence.LockModeType; import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; 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; @@ -16,6 +18,10 @@ public interface RecordJpaRepository extends JpaRepository findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RecordJpaEntity r WHERE r.postId = :postId") + Optional findByPostIdWithPessimisticLock(@Param("postId") Long postId); + @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.userJpaEntity.userId = :userId") List findRecordIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java index 505a769ce..7dd8bc67f 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java @@ -15,12 +15,15 @@ public interface RecordCommandPort { void update(Record record); Optional findById(Long id); - + Optional findByIdWithLock(Long id); default Record getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); } - + default Record getByIdOrThrowWithLock(Long id) { + return findByIdWithLock(id) + .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); + } void delete(Record record); void deleteAllByUserId(Long userId); From 394683045b14a055688366c966e641f06f78f7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 03:27:10 +0900 Subject: [PATCH 11/37] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20pos?= =?UTF-8?q?t=20=EB=A0=88=EC=BD=94=EB=93=9C=EC=97=90=20lock=20=EA=B3=BC?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80-vote=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/VoteCommandPersistenceAdapter.java | 5 +++++ .../out/persistence/repository/vote/VoteJpaRepository.java | 6 ++++++ .../thip/roompost/application/port/out/VoteCommandPort.java | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java index b74daa966..9287b9a20 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -81,6 +81,11 @@ public Optional findById(Long id) { return voteJpaRepository.findByPostId(id) .map(voteMapper::toDomainEntity); } + @Override + public Optional findByIdWithLock(Long id) { + return voteJpaRepository.findByPostIdWithPessimisticLock(id) + .map(voteMapper::toDomainEntity); + } @Override public Optional findVoteItemById(Long id) { diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java index 90251801d..5b086e45f 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java @@ -1,8 +1,10 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.vote; import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.LockModeType; import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; 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; @@ -16,6 +18,10 @@ public interface VoteJpaRepository extends JpaRepository, V */ Optional findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT v FROM VoteJpaEntity v WHERE v.postId = :postId") + Optional findByPostIdWithPessimisticLock(@Param("postId") Long postId); + @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.userJpaEntity.userId = :userId") List findVoteIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java index de09c2614..cac5f5c07 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java @@ -20,11 +20,15 @@ public interface VoteCommandPort { void saveAllVoteItems(List voteItems); Optional findById(Long id); - + Optional findByIdWithLock(Long id); default Vote getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); } + default Vote getByIdOrThrowWithLock(Long id) { + return findByIdWithLock(id) + .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); + } Optional findVoteItemById(Long id); From 7b3c2a33d859fb2c1854dc60439cfca2b13be061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 26 Oct 2025 23:50:00 +0900 Subject: [PATCH 12/37] =?UTF-8?q?[test]=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/k6/feed-like-concurrency-test12.js | 85 +++++++++++++++++++ .../thip/k6/feed-like-concurrency-test9.js | 4 +- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js new file mode 100644 index 000000000..5cbd1070b --- /dev/null +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js @@ -0,0 +1,85 @@ +// feed-like-concurrency-test12.js +// 피크 이후 점진적 하락 테스트 20 → 100(2분 유지) → 50(1분) → 10(이후 종료) +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 = { + stages: [ + { duration: '2m', target: 20 }, // 2분간 VU 20명 유지 (초기 피크 시작) + { duration: '2m', target: 100 }, // 2분간 VU 100명 유지 (피크 최대) + { duration: '1m', target: 50 }, // 1분간 VU 50명 유지 (감소 시작) + { duration: '2m', target: 10 }, // 2분간 VU 10명 유지 (감소 계속) + { duration: '1m', target: 0 } // 1분간 VU 0명 (테스트 종료) + ], + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + // 최대 VU 수 계산 + const maxVUs = 100; + 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}`); + } + + // 0.5~1.5초 간격 임의 지연 + sleep(Math.random() + 0.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), + }; +} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js index b8bc32d1a..45c7342bd 100644 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js +++ b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js @@ -5,7 +5,7 @@ import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> const BASE_URL = 'http://localhost:8080'; const FEED_ID = 1; // 테스트할 피드 ID -const VUS = 150; // 원하는 VU 수 +const VUS = 20; // 원하는 VU 수 export let options = { thresholds: { @@ -68,7 +68,7 @@ export default function (data) { console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`); } - sleep(0.1); // 500ms 간격으로 요청, 일반적 사용자 환경 모의 + sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의 } // 테스트 결과 html 리포트로 저장 From d7a5c4221cdcd48f839314908aeb22ba9bd36236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:01:47 +0900 Subject: [PATCH 13/37] =?UTF-8?q?[delete]=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=EC=95=8A=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20(#3?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/konkuk/thip/k6/feed-get-test.js | 63 ------------- .../thip/k6/feed-like-concurrency-test.js | 89 ------------------- .../thip/k6/feed-like-concurrency-test11.js | 81 ----------------- .../thip/k6/feed-like-concurrency-test12.js | 85 ------------------ .../thip/k6/feed-like-concurrency-test2.js | 75 ---------------- .../thip/k6/feed-like-concurrency-test3.js | 89 ------------------- .../thip/k6/feed-like-concurrency-test4.js | 74 --------------- .../thip/k6/feed-like-concurrency-test5.js | 75 ---------------- .../thip/k6/feed-like-concurrency-test6.js | 82 ----------------- .../thip/k6/feed-like-concurrency-test7.js | 82 ----------------- 10 files changed, 795 deletions(-) delete mode 100644 src/test/java/konkuk/thip/k6/feed-get-test.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js diff --git a/src/test/java/konkuk/thip/k6/feed-get-test.js b/src/test/java/konkuk/thip/k6/feed-get-test.js deleted file mode 100644 index c0fbbc036..000000000 --- a/src/test/java/konkuk/thip/k6/feed-get-test.js +++ /dev/null @@ -1,63 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -import http from 'k6/http'; -import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID -const VUS = 10; // 원하는 VU 수 - -export let options = { - vus: VUS, - duration: '30s', // 30초동안 테스트 -}; - -// 테스트 전 사용자 별 토큰 발급 -export function setup() { - let tokens = []; - - // 유저 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); - } - - return { tokens}; -} - -export default function (data) { - const vuIdx = __VU - 1; - const token = data.tokens[vuIdx]; - - const params = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }; - - const res = http.get(`${BASE_URL}/feeds`, params); - - // 응답 체크 - 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js deleted file mode 100644 index 0de37c177..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test.js +++ /dev/null @@ -1,89 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -import http from 'k6/http'; -import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID -const VUS = 5; // 원하는 VU 수 - -export let options = { - vus: VUS, // 동시에 5명 접속 - 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(false); - } - - return { tokens, likeStatus }; -} - -export default function (data) { - const vuIdx = __VU - 1; - const token = data.tokens[vuIdx]; - - // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 - 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); - - // 응답 체크 - 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}`); - } - - if(__VU === 1) { - const expectedCount = data.likeStatus.filter(s => s).length; - - // 피드 상세조회 api - let countRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); - - if (countRes.status === 200) { - let body = JSON.parse(countRes.body); - let actualCount = body.data.likeCount; - if (actualCount !== expectedCount) { - console.error(`Like count mismatch! expected=${expectedCount}, actual=${actualCount}`); - } - } - } - - sleep(0.01); // 사용자가 좋아요 버튼을 동시에 연타한다고 가정 10ms -} - -// 테스트 결과 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js deleted file mode 100644 index fd2cd5725..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test11.js +++ /dev/null @@ -1,81 +0,0 @@ -// feed-like-concurrency-test11.js -// 고정 부하 장시간 테스트 VU: 100명 고정에 10분 -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 = 100; // 원하는 VU 수 - -export let options = { - thresholds: { - // 요청 95%가 500ms 이내 응답을 받아야 함 - http_req_duration: ['p(95)<500'], - // 전체 요청 중 실패율 1% 미만이어야 함 - http_req_failed: ['rate<0.01'], - }, - vus: VUS, - duration: '10m', // 10분 동안 테스트 -}; - -// 테스트 전 사용자 별 토큰 발급 -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), - }; -} - diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js deleted file mode 100644 index 5cbd1070b..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test12.js +++ /dev/null @@ -1,85 +0,0 @@ -// feed-like-concurrency-test12.js -// 피크 이후 점진적 하락 테스트 20 → 100(2분 유지) → 50(1분) → 10(이후 종료) -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 = { - stages: [ - { duration: '2m', target: 20 }, // 2분간 VU 20명 유지 (초기 피크 시작) - { duration: '2m', target: 100 }, // 2분간 VU 100명 유지 (피크 최대) - { duration: '1m', target: 50 }, // 1분간 VU 50명 유지 (감소 시작) - { duration: '2m', target: 10 }, // 2분간 VU 10명 유지 (감소 계속) - { duration: '1m', target: 0 } // 1분간 VU 0명 (테스트 종료) - ], - thresholds: { - http_req_duration: ['p(95)<500'], - http_req_failed: ['rate<0.01'], - }, -}; - -// 테스트 전 사용자 별 토큰 발급 -export function setup() { - // 최대 VU 수 계산 - const maxVUs = 100; - 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}`); - } - - // 0.5~1.5초 간격 임의 지연 - sleep(Math.random() + 0.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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js deleted file mode 100644 index 55e2ec8a3..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test2.js +++ /dev/null @@ -1,75 +0,0 @@ -import http from 'k6/http'; -import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID -const VUS = 1; // 한 명 사용자가 연타 테스트 -const ITERATIONS = 60; // 연속 호출 횟수 각 구간마다 20번 - -export let options = { - vus: VUS, - iterations: ITERATIONS, -}; - -export function setup() { - const res = http.get(`${BASE_URL}/api/test/token/access?userId=1`); - check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); - - // 최초 좋아요 상태 false로 초기화 - const likeStatus = false; - - return { token: res.body, likeStatus }; -} - -export default function (data) { - const token = data.token; - - // 요청마다 좋아요 상태 변경 - data.likeStatus = !data.likeStatus; - - const payload = JSON.stringify({ - type: data.likeStatus - }); - - const params = { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }; - - const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params); - - check(res, { - 'status 200': r => r.status === 200, - 'status 400': r => r.status === 400, - 'Internal server error': r => r.status === 500, - }); - - if (res.status === 400) { - console.error(`[VU${__VU}] 400 Bad Request at iteration ${__ITER} body=${res.body}`); - } - if (res.status === 500) { - console.error(`[VU${__VU}] 500 Internal Server Error at iteration ${__ITER} body=${res.body}`); - } - - // iteration 번호로 구간 구분하여 sleep 시간 변경 - let sleepTime; - if (__ITER <= 20) { - sleepTime = 0.01; // 10ms - } else if (__ITER <= 40) { - sleepTime = 0.05; // 50ms - } else { - sleepTime = 0.1; // 100ms - } - - sleep(sleepTime); -} - -// 테스트 결과 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js deleted file mode 100644 index 50ae38739..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test3.js +++ /dev/null @@ -1,89 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -import http from 'k6/http'; -import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID -const VUS = 5; // 원하는 VU 수 - -export let options = { - vus: VUS, // 동시에 5명 접속 - 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(false); - } - - return { tokens, likeStatus }; -} - -export default function (data) { - const vuIdx = __VU - 1; - const token = data.tokens[vuIdx]; - - // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 - 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); - - // 응답 체크 - 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}`); - } - - if(__VU === 1) { - const expectedCount = data.likeStatus.filter(s => s).length; - - // 피드 상세조회 api - let countRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); - - if (countRes.status === 200) { - let body = JSON.parse(countRes.body); - let actualCount = body.data.likeCount; - if (actualCount !== expectedCount) { - console.error(`Like count mismatch! expected=${expectedCount}, actual=${actualCount}`); - } - } - } - - 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js deleted file mode 100644 index 72022bdc9..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test4.js +++ /dev/null @@ -1,74 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -import http from 'k6/http'; -import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 4; // 테스트할 피드 ID -const VUS = 2; // 원하는 VU 수 - -export let options = { - vus: VUS, // 동시에 5명 접속 - 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(false); - } - - return { tokens, likeStatus }; -} - -export default function (data) { - const vuIdx = __VU - 1; - const token = data.tokens[vuIdx]; - - // 현재 좋아요 요청상태 반전 --> 최초요청은 좋아요 하지않았을때 좋아요를 하는 요청 - 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); - - // 응답 체크 - 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js deleted file mode 100644 index c207fb6d8..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test5.js +++ /dev/null @@ -1,75 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -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 = 2; // 원하는 VU 수 - -export let options = { - 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js deleted file mode 100644 index 8e4feefc1..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test6.js +++ /dev/null @@ -1,82 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -// 정상적인 테스트 유저 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 -const VUS = 1; // 원하는 VU 수 - -export let options = { - thresholds: { - // 요청 95%가 500ms 이내 응답을 받아야 함 - http_req_duration: ['p(95)<500'], - // 실패율 0% (완벽한 성공률) - http_req_failed: ['rate==0'], - }, - 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), - }; -} diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js deleted file mode 100644 index e2d508a4c..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test7.js +++ /dev/null @@ -1,82 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -//테스트 코드랑 비슷한테스트 유저 2명이서 동시에 좋아요요청 -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 = 2; // 원하는 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), - }; -} From cf0d246795319938a6ada4d228466907bf3de7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:02:14 +0900 Subject: [PATCH 14/37] =?UTF-8?q?[chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/feed_like_concurrency_test1.js | 1 - .../feed/feed_like_concurrency_test2.js | 1 - loadtest/feed/feed_like_concurrency_test3.js | 116 ++++++++++++++++++ loadtest/{ => room}/room_join_load_test.js | 0 .../thip/k6/feed-like-concurrency-test8.js | 85 ------------- 5 files changed, 116 insertions(+), 87 deletions(-) rename src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js => loadtest/feed/feed_like_concurrency_test1.js (98%) rename src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js => loadtest/feed/feed_like_concurrency_test2.js (98%) create mode 100644 loadtest/feed/feed_like_concurrency_test3.js rename loadtest/{ => room}/room_join_load_test.js (100%) delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js b/loadtest/feed/feed_like_concurrency_test1.js similarity index 98% rename from src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js rename to loadtest/feed/feed_like_concurrency_test1.js index 45c7342bd..7165eed01 100644 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test9.js +++ b/loadtest/feed/feed_like_concurrency_test1.js @@ -1,4 +1,3 @@ -// feed-like-concurrency-test9.js // 낮은 동시성 20명이서 동시성 기능 안전성 테스트 import http from 'k6/http'; import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js b/loadtest/feed/feed_like_concurrency_test2.js similarity index 98% rename from src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js rename to loadtest/feed/feed_like_concurrency_test2.js index c178e12cc..454b62da5 100644 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test10.js +++ b/loadtest/feed/feed_like_concurrency_test2.js @@ -1,4 +1,3 @@ -// feed-like-concurrency-test10.js // 점진적 부하 증가 (Ramp-up) VU: 20 → 50 → 100 → 150 (1분 단위로 증가) import http from 'k6/http'; import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) diff --git a/loadtest/feed/feed_like_concurrency_test3.js b/loadtest/feed/feed_like_concurrency_test3.js new file mode 100644 index 000000000..b298aa445 --- /dev/null +++ b/loadtest/feed/feed_like_concurrency_test3.js @@ -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), + }; +} \ No newline at end of file diff --git a/loadtest/room_join_load_test.js b/loadtest/room/room_join_load_test.js similarity index 100% rename from loadtest/room_join_load_test.js rename to loadtest/room/room_join_load_test.js diff --git a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js b/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js deleted file mode 100644 index 6797efa90..000000000 --- a/src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.js +++ /dev/null @@ -1,85 +0,0 @@ -// cd ./src/test/java/konkuk/thip/k6 -// k6 run --out influxdb=http://localhost:8086/k6 feed-like-concurrency-test.js -// k6 run feed-like-concurrency-test.js -// 2025-10-24 17:28 점진적으로 5->10->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 - -export let options = { - thresholds: { - http_req_duration: ['p(95)<500'], - http_req_failed: ['rate<0.01'], - }, - stages: [ - { duration: '1m', target: 5 }, // 1분간 VU 5명으로 점진적 증가 시작 - { duration: '1m', target: 10 }, // 1분간 VU 10명 유지 - { duration: '1m', target: 20 }, // 1분간 VU 20명 유지 - { duration: '30s', target: 0 }, // 30초 동안 VU 0명으로 줄이며 테스트 종료 - ], -}; - -// 테스트 전 사용자 별 토큰 발급 -export function setup() { - // 점진적 증가하는 최대 VU 수 계산 - const maxVUs = 20; - 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), - }; -} From 38aed856ab8b33fc375f93b94bf1f9fc5be1ec2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:02:43 +0900 Subject: [PATCH 15/37] =?UTF-8?q?[refactor]=20=EB=B9=84=EA=B4=80=EB=9D=BD?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/FeedCommandPersistenceAdapter.java | 4 ++-- .../out/persistence/repository/FeedJpaRepository.java | 2 +- .../thip/feed/application/port/out/FeedCommandPort.java | 6 +++--- .../thip/post/application/service/PostLikeService.java | 2 +- .../post/application/service/handler/PostHandler.java | 8 ++++---- .../out/persistence/RecordCommandPersistenceAdapter.java | 4 ++-- .../out/persistence/VoteCommandPersistenceAdapter.java | 4 ++-- .../repository/record/RecordJpaRepository.java | 2 +- .../persistence/repository/vote/VoteJpaRepository.java | 2 +- .../roompost/application/port/out/RecordCommandPort.java | 6 +++--- .../roompost/application/port/out/VoteCommandPort.java | 6 +++--- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java index 42de71c96..a998f4f67 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -45,8 +45,8 @@ public Optional findById(Long id) { } @Override - public Optional findByIdWithLock(Long id) { - return feedJpaRepository.findByPostIdWithPessimisticLock(id) + public Optional findByIdForUpdate(Long id) { + return feedJpaRepository.findByPostIdForUpdate(id) .map(feedMapper::toDomainEntity); } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index f5c4cd58a..fbaa41129 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -20,7 +20,7 @@ public interface FeedJpaRepository extends JpaRepository, F @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId") - Optional findByPostIdWithPessimisticLock(@Param("postId") Long postId); + Optional findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId") long countAllFeedsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index 5487afe80..6bf3f89dd 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -12,13 +12,13 @@ public interface FeedCommandPort { Long save(Feed feed); Long update(Feed feed); Optional findById(Long id); - Optional findByIdWithLock(Long id); + Optional findByIdForUpdate(Long id); default Feed getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); } - default Feed getByIdOrThrowWithLock(Long id) { - return findByIdWithLock(id) + default Feed getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); } void delete(Feed feed); diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index 74e5fdeab..067f7768d 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -38,7 +38,7 @@ public class PostLikeService implements PostLikeUseCase { public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 1. 게시물 타입에 맞게 검증 및 조회 - CountUpdatable post = postHandler.findPostWithLock(command.postType(), command.postId()); + CountUpdatable post = postHandler.findPostForUpdate(command.postType(), command.postId()); // 1-1. 게시글 타입에 따른 게시물 좋아요 권한 검증 postLikeAuthorizationValidator.validateUserCanAccessPostLike(command.postType(), post, command.userId()); diff --git a/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java b/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java index d8f1ff088..2ad612640 100644 --- a/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java +++ b/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java @@ -31,11 +31,11 @@ public CountUpdatable findPost(PostType type, Long postId) { }; } - public CountUpdatable findPostWithLock(PostType type, Long postId) { + public CountUpdatable findPostForUpdate(PostType type, Long postId) { return switch (type) { - case FEED -> feedCommandPort.getByIdOrThrowWithLock(postId); - case RECORD -> recordCommandPort.getByIdOrThrowWithLock(postId); - case VOTE -> voteCommandPort.getByIdOrThrowWithLock(postId); + case FEED -> feedCommandPort.getByIdOrThrowForUpdate(postId); + case RECORD -> recordCommandPort.getByIdOrThrowForUpdate(postId); + case VOTE -> voteCommandPort.getByIdOrThrowForUpdate(postId); }; } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java index 137afe55c..89e25cb37 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java @@ -57,8 +57,8 @@ public Optional findById(Long id) { } @Override - public Optional findByIdWithLock(Long id) { - return recordJpaRepository.findByPostIdWithPessimisticLock(id) + public Optional findByIdForUpdate(Long id) { + return recordJpaRepository.findByPostIdForUpdate(id) .map(recordMapper::toDomainEntity); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java index 9287b9a20..23559493c 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -82,8 +82,8 @@ public Optional findById(Long id) { .map(voteMapper::toDomainEntity); } @Override - public Optional findByIdWithLock(Long id) { - return voteJpaRepository.findByPostIdWithPessimisticLock(id) + public Optional findByIdForUpdate(Long id) { + return voteJpaRepository.findByPostIdForUpdate(id) .map(voteMapper::toDomainEntity); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java index c409b6529..9b635c3d0 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -20,7 +20,7 @@ public interface RecordJpaRepository extends JpaRepository findByPostIdWithPessimisticLock(@Param("postId") Long postId); + Optional findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.userJpaEntity.userId = :userId") List findRecordIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java index 5b086e45f..fd276a644 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java @@ -20,7 +20,7 @@ public interface VoteJpaRepository extends JpaRepository, V @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT v FROM VoteJpaEntity v WHERE v.postId = :postId") - Optional findByPostIdWithPessimisticLock(@Param("postId") Long postId); + Optional findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.userJpaEntity.userId = :userId") List findVoteIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java index 7dd8bc67f..c2259b9a2 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java @@ -15,13 +15,13 @@ public interface RecordCommandPort { void update(Record record); Optional findById(Long id); - Optional findByIdWithLock(Long id); + Optional findByIdForUpdate(Long id); default Record getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); } - default Record getByIdOrThrowWithLock(Long id) { - return findByIdWithLock(id) + default Record getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); } void delete(Record record); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java index cac5f5c07..0d0de2f91 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java @@ -20,13 +20,13 @@ public interface VoteCommandPort { void saveAllVoteItems(List voteItems); Optional findById(Long id); - Optional findByIdWithLock(Long id); + Optional findByIdForUpdate(Long id); default Vote getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); } - default Vote getByIdOrThrowWithLock(Long id) { - return findByIdWithLock(id) + default Vote getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); } From 775e16bab028f98fff19e40bf1afc097c03856b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:03:03 +0900 Subject: [PATCH 16/37] =?UTF-8?q?[refactor]=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedChangeLikeStatusConcurrencyTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename src/test/java/konkuk/thip/feed/{adapter/in/web => concurrency}/FeedChangeLikeStatusConcurrencyTest.java (97%) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java similarity index 97% rename from src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java rename to src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java index c7e1d5ab9..1833fe99a 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java +++ b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java @@ -1,4 +1,4 @@ -package konkuk.thip.feed.adapter.in.web; +package konkuk.thip.feed.concurrency; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -30,6 +31,7 @@ @SpringBootTest @Slf4j +@Tag("concurrency") @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) @DisplayName("[단위] 피드 좋아요 상태변경 다중 스레드 테스트") From 3b9abb9182d95e282d5ebf755c27a5c641c9d784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:03:23 +0900 Subject: [PATCH 17/37] =?UTF-8?q?[test]=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=ED=95=98=EB=A9=B4?= =?UTF-8?q?=EC=84=9C=20=EA=B9=A8=EC=A7=80=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/adapter/in/web/FeedChangeLikeStatusApiTest.java | 5 ++++- .../room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java index ca31c8511..b50281ee7 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java @@ -135,7 +135,10 @@ void unlikeFeed_Success() throws Exception { @Test @DisplayName("좋아요 하지 않은 피드를 좋아요 취소하면 [400 에러 발생]") void unlikeFeed_NotLiked_Fail() throws Exception { - // given: 좋아요 없음 + + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + feed.updateLikeCount(1); + // given FeedIsLikeRequest request = new FeedIsLikeRequest(false); // when & then diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java index 9d2362577..b6a5c8fcd 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java @@ -154,6 +154,8 @@ void unlikeRecordPost_Success() throws Exception { @DisplayName("좋아요 하지 않은 기록 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeRecordPost_NotLiked_Fail() throws Exception { //given + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + record.updateLikeCount(1); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); //when & then @@ -235,6 +237,8 @@ void unlikeVotePost_Success() throws Exception { @DisplayName("좋아요 하지 않은 투표 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeVotePost_NotLiked_Fail() throws Exception { //given + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + vote.updateLikeCount(1); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); //when & then From 7677566cd04594a116513455c68276899c1249fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 12 Nov 2025 17:18:49 +0900 Subject: [PATCH 18/37] =?UTF-8?q?[chore]=20=EC=A3=BC=EC=84=9D=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java | 2 +- .../room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java index b50281ee7..111c1426b 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java @@ -136,7 +136,7 @@ void unlikeFeed_Success() throws Exception { @DisplayName("좋아요 하지 않은 피드를 좋아요 취소하면 [400 에러 발생]") void unlikeFeed_NotLiked_Fail() throws Exception { - // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) feed.updateLikeCount(1); // given FeedIsLikeRequest request = new FeedIsLikeRequest(false); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java index b6a5c8fcd..17188ae3b 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java @@ -154,7 +154,7 @@ void unlikeRecordPost_Success() throws Exception { @DisplayName("좋아요 하지 않은 기록 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeRecordPost_NotLiked_Fail() throws Exception { //given - // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) record.updateLikeCount(1); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); @@ -237,7 +237,7 @@ void unlikeVotePost_Success() throws Exception { @DisplayName("좋아요 하지 않은 투표 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeVotePost_NotLiked_Fail() throws Exception { //given - // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 오버플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) vote.updateLikeCount(1); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); From 85b736799164418b83e2fff5740fa8659c490da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 23 Nov 2025 00:47:20 +0900 Subject: [PATCH 19/37] =?UTF-8?q?[feat]=20=EB=9D=BD=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/common/exception/code/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index b7fb384c0..38af91a62 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -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부터 비즈니스 예외 */ /** From 2dad00521bd7cdc9f1174938854a75cdd44b0805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 23 Nov 2025 01:16:31 +0900 Subject: [PATCH 20/37] =?UTF-8?q?[refactor]=20=EB=9D=BD=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=20=EC=97=90=EB=9F=AC=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeService.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index 067f7768d..ad98177b6 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -1,5 +1,8 @@ package konkuk.thip.post.application.service; +import jakarta.persistence.PessimisticLockException; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.post.application.port.out.dto.PostQueryDto; @@ -15,7 +18,12 @@ import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -34,7 +42,12 @@ public class PostLikeService implements PostLikeUseCase { private final RoomNotificationOrchestrator roomNotificationOrchestrator; @Override - @Transactional + @Retryable( + retryFor = {PessimisticLockException.class, PessimisticLockingFailureException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 1000, random = true) + ) + @Transactional(propagation = Propagation.REQUIRES_NEW) public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 1. 게시물 타입에 맞게 검증 및 조회 @@ -55,7 +68,7 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); // 좋아요 푸쉬알림 전송 - sendNotifications(command); + //sendNotifications(command); } else { postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 postLikeCommandPort.delete(command.userId(), command.postId()); @@ -64,6 +77,11 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { return PostIsLikeResult.of(post.getId(), command.isLike()); } + @Recover + public void recover(Exception e, PostIsLikeCommand command) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + private void sendNotifications(PostIsLikeCommand command) { PostQueryDto postQueryDto = postHandler.getPostQueryDto(command.postType(), command.postId()); From 08247ea69ab24ec798a8cb1236b896bc0a305bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 23 Nov 2025 01:16:41 +0900 Subject: [PATCH 21/37] =?UTF-8?q?[refactor]=20@=20EnableRetry=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/ThipServerApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index 8a2d53b91..04b879c4c 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing @EnableScheduling @ConfigurationPropertiesScan +@EnableRetry @SpringBootApplication public class ThipServerApplication { From b3cd50c1b1f48d8fbc3708c96222f1ebf7bc9b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 23 Nov 2025 01:17:05 +0900 Subject: [PATCH 22/37] =?UTF-8?q?[refactor]=20findByPostIdForUpdate?= =?UTF-8?q?=EC=8B=9C=20=EB=9D=BD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?3=EC=B4=88=EB=A1=9C=20=EC=84=A4=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/repository/FeedJpaRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index fbaa41129..bc1015086 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -1,11 +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; @@ -20,6 +22,7 @@ public interface FeedJpaRepository extends JpaRepository, F @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId") + @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId") From f0b75235e6f7afc968492af4c0033954b7fd8dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 23 Nov 2025 01:17:23 +0900 Subject: [PATCH 23/37] =?UTF-8?q?[test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loadtest/feed/feed-like-load-test.js | 176 +++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 loadtest/feed/feed-like-load-test.js diff --git a/loadtest/feed/feed-like-load-test.js b/loadtest/feed/feed-like-load-test.js new file mode 100644 index 000000000..b3efbc2d1 --- /dev/null +++ b/loadtest/feed/feed-like-load-test.js @@ -0,0 +1,176 @@ +// feed-like-load-test.js +import http from 'k6/http'; +import { sleep,check } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 1; // 테스트할 피드 ID +const USERS_START = 1; // 토큰 발급 시작 userId +const USERS_COUNT = 1000; // 총 사용자 = VU 수 +const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 +const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지) +const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 요청 동시 시작) + +// ===== 커스텀 메트릭 ===== +const likeLatency = new Trend('feed_like_latency'); // 참여 API 지연(ms) +const http5xx = new Counter('feed_like_5xx'); // 5xx 개수 +const http2xx = new Counter('feed_like_2xx'); // 2xx 개수 +const http4xx = new Counter('feed_like_4xx'); // 4xx 개수 +const http423 = new Counter('feed_like_423'); // 423 (Locked) 전용 카운터 + +// 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) +const token_issue_failed = new Counter('token_issue_failed'); +const fail_POST_ALREADY_LIKED = new Counter('fail_POST_ALREADY_LIKED'); +const fail_POST_NOT_LIKED_CANNOT_CANCEL = new Counter('fail_POST_NOT_LIKED_CANNOT_CANCEL'); +const fail_POST_LIKE_COUNT_UNDERFLOW = new Counter('fail_POST_LIKE_COUNT_UNDERFLOW'); +const fail_RESOURCE_LOCKED = new Counter('fail_RESOURCE_LOCKED'); +const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); + +const ERR = { // THIP error code + POST_ALREADY_LIKED: 185001, + POST_NOT_LIKED_CANNOT_CANCEL: 185002, + POST_LIKE_COUNT_UNDERFLOW: 185000, + RESOURCE_LOCKED: 50200 +}; + +function parseError(res) { + try { + const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 + // BaseResponse: { isSuccess:boolean, code:number, message:string, requestId:string, data:any } + return { + code: Number(j.code), // 정수 코드 + message: j.message || '', + requestId: j.requestId || '', + isSuccess: !!j.isSuccess + }; + } catch (e) { + return { code: NaN, message: '', requestId: '', isSuccess: false }; + } +} + +// ------------ 시나리오 ------------ +// 특정 시점에 한 게시물 (인기 작가,인플루언서가 작성한)에 좋아요 요청이 몰리는 상황 가정 +export let options = { + scenarios: { + // 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저 + feed_like_once: { + executor: 'per-vu-iterations', + vus: USERS_COUNT, + iterations: 1, + startTime: '0s', // 모든 VU가 거의 동시에 스케줄링 + gracefulStop: '5s', + }, + }, + thresholds: { + feed_like_5xx: ['count==0'], // 서버 오류는 0건이어야 함 + feed_like_latency: ['p(95)<500'], // p95 < 500ms + }, +}; + +// 테스트 전 사용자 별 토큰 배치 발급 +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', feed: `${FEED_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 vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + // 동기 시작: startAt까지 대기 → 모든 VU가 거의 같은 타이밍에 시작 + 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}/feeds/${FEED_ID}/likes`; + + const res = http.post(url, body, { headers, tags: { phase: 'like', feed: `${FEED_ID}` } }); + + // === 커스텀 메트릭 기록 === + likeLatency.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.POST_ALREADY_LIKED: + fail_POST_ALREADY_LIKED.add(1); + break; + case ERR.POST_NOT_LIKED_CANNOT_CANCEL: + fail_POST_NOT_LIKED_CANNOT_CANCEL.add(1); + break; + case ERR.POST_LIKE_COUNT_UNDERFLOW: + fail_POST_LIKE_COUNT_UNDERFLOW.add(1); + break; + case ERR.RESOURCE_LOCKED: + http423.add(1); + fail_RESOURCE_LOCKED.add(1); + break; + default: + fail_OTHER_4XX.add(1); + } + } else if (res.status >= 500) { + http5xx.add(1); + } + + // // === 검증 === + // check(res, { + // 'like responded': (r) => r.status !== 0, + // 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), + // }); + + // === 검증 === + check(res, { + 'like responded': (r) => r.status !== 0, + 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), + //423(Locked) 에러가 발생했는지 여부 체크 + 'status is 423 (Resource Locked)': (r) => r.status === 423, + }); +} +// 테스트 결과 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), + }; +} From 405de804edc48e5c15041cacb741ea3fbc942f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 24 Nov 2025 02:20:38 +0900 Subject: [PATCH 24/37] =?UTF-8?q?[feat]=20retry=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index cd2969af9..40cc629a7 100644 --- a/build.gradle +++ b/build.gradle @@ -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 From 1fde7ac2638abaec77a4e5df3830b2a2a4deee5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 24 Nov 2025 02:22:53 +0900 Subject: [PATCH 25/37] =?UTF-8?q?[feat]=20RetryConfig=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/config/RetryConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/konkuk/thip/config/RetryConfig.java diff --git a/src/main/java/konkuk/thip/config/RetryConfig.java b/src/main/java/konkuk/thip/config/RetryConfig.java new file mode 100644 index 000000000..4fc1133ba --- /dev/null +++ b/src/main/java/konkuk/thip/config/RetryConfig.java @@ -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 { +} \ No newline at end of file From 9f39c14ec6e70e81d4e94e3d5d50ee3b44a4e807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 24 Nov 2025 02:23:09 +0900 Subject: [PATCH 26/37] =?UTF-8?q?[chore]=20@=20EnableRetry=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/ThipServerApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index 04b879c4c..a90f106f9 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -10,7 +10,6 @@ @EnableJpaAuditing @EnableScheduling @ConfigurationPropertiesScan -@EnableRetry @SpringBootApplication public class ThipServerApplication { From bc2e40dc0b931cbcda92789d9ff1d12020188063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 24 Nov 2025 02:23:34 +0900 Subject: [PATCH 27/37] =?UTF-8?q?[feat]=20=EB=9D=BD=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=97=90=EB=9F=AC=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/post/application/service/PostLikeService.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index ad98177b6..0a9cbfe66 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -1,7 +1,7 @@ package konkuk.thip.post.application.service; -import jakarta.persistence.PessimisticLockException; import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; @@ -18,7 +18,6 @@ import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; -import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; @@ -43,9 +42,9 @@ public class PostLikeService implements PostLikeUseCase { @Override @Retryable( - retryFor = {PessimisticLockException.class, PessimisticLockingFailureException.class}, + notRecoverable = { BusinessException.class, InvalidStateException.class}, maxAttempts = 3, - backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 1000, random = true) + backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 500, random = true) ) @Transactional(propagation = Propagation.REQUIRES_NEW) public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { @@ -78,7 +77,7 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { } @Recover - public void recover(Exception e, PostIsLikeCommand command) { + public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) { throw new BusinessException(ErrorCode.RESOURCE_LOCKED); } From bdc9ec28889d14bfd75dcf4f80c149d6e48478cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Mon, 24 Nov 2025 02:25:07 +0900 Subject: [PATCH 28/37] =?UTF-8?q?[test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loadtest/feed/feed-like-load-test.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/loadtest/feed/feed-like-load-test.js b/loadtest/feed/feed-like-load-test.js index b3efbc2d1..fc5a4442c 100644 --- a/loadtest/feed/feed-like-load-test.js +++ b/loadtest/feed/feed-like-load-test.js @@ -6,7 +6,7 @@ import { Trend, Counter } from 'k6/metrics'; const BASE_URL = 'http://localhost:8080'; const FEED_ID = 1; // 테스트할 피드 ID const USERS_START = 1; // 토큰 발급 시작 userId -const USERS_COUNT = 1000; // 총 사용자 = VU 수 +const USERS_COUNT = 10000; // 총 사용자 = VU 수 const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지) const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 요청 동시 시작) @@ -16,7 +16,6 @@ const likeLatency = new Trend('feed_like_latency'); // 참여 API 지연(ms) const http5xx = new Counter('feed_like_5xx'); // 5xx 개수 const http2xx = new Counter('feed_like_2xx'); // 2xx 개수 const http4xx = new Counter('feed_like_4xx'); // 4xx 개수 -const http423 = new Counter('feed_like_423'); // 423 (Locked) 전용 카운터 // 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) const token_issue_failed = new Counter('token_issue_failed'); @@ -36,7 +35,6 @@ const ERR = { // THIP error code function parseError(res) { try { const j = JSON.parse(res.body || '{}'); // BaseResponse 구조 - // BaseResponse: { isSuccess:boolean, code:number, message:string, requestId:string, data:any } return { code: Number(j.code), // 정수 코드 message: j.message || '', @@ -63,7 +61,7 @@ export let options = { }, thresholds: { feed_like_5xx: ['count==0'], // 서버 오류는 0건이어야 함 - feed_like_latency: ['p(95)<500'], // p95 < 500ms + feed_like_latency: ['p(95)<1000'], // p95 < 1s }, }; @@ -153,18 +151,10 @@ export default function (data) { http5xx.add(1); } - // // === 검증 === - // check(res, { - // 'like responded': (r) => r.status !== 0, - // 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), - // }); - // === 검증 === check(res, { 'like responded': (r) => r.status !== 0, 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), - //423(Locked) 에러가 발생했는지 여부 체크 - 'status is 423 (Resource Locked)': (r) => r.status === 423, }); } // 테스트 결과 html 리포트로 저장 From 612dca72c81d9c2757243b9540dd7118f928013a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 6 Dec 2025 18:12:11 +0900 Subject: [PATCH 29/37] =?UTF-8?q?[test]=20@=20Transactional(propagation=20?= =?UTF-8?q?=3D=20Propagation.REQUIRES=5FNEW)=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=EA=BA=A0=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EC=97=90=20@=20Transactional=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=8C=80=EC=8B=A0=20=EB=AA=85=EC=8B=9C=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20@=20AfterEach=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=B4=20=EC=82=AC=EC=9A=A9=ED=95=9C=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=82=AD=EC=A0=9C=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/FeedChangeLikeStatusApiTest.java | 12 ++++++++++-- .../in/web/RoomPostChangeLikeStatusApiTest.java | 17 +++++++++++++++-- .../RoomPostChangeLikeStatusControllerTest.java | 12 ++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java index 111c1426b..6e0be57d3 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java @@ -11,6 +11,7 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,7 +21,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; @@ -32,7 +32,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[통합] 피드 좋아요 api 통합 테스트") class FeedChangeLikeStatusApiTest { @@ -59,6 +58,14 @@ void setUp() { feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); } + @AfterEach + void tearDown(){ + postLikeJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + @Test @DisplayName("피드를 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") void likeFeed_Success() throws Exception { @@ -138,6 +145,7 @@ void unlikeFeed_NotLiked_Fail() throws Exception { // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) feed.updateLikeCount(1); + feedJpaRepository.save(feed); //영속성 바로 반영 // given FeedIsLikeRequest request = new FeedIsLikeRequest(false); diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java index 17188ae3b..e591718f0 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java @@ -20,6 +20,7 @@ import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,7 +30,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; @@ -41,7 +41,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[통합] 방 게시물(기록,투표) 좋아요 api 통합 테스트") class RoomPostChangeLikeStatusApiTest { @@ -82,6 +81,18 @@ record = recordJpaRepository.save(TestEntityFactory.createRecord(user,room)); vote = voteJpaRepository.save(TestEntityFactory.createVote(user,room)); } + @AfterEach + void tearDown(){ + postLikeJpaRepository.deleteAllInBatch(); + roomParticipantJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + @Test @DisplayName("기록 게시물을 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") void likeRecordPost_Success() throws Exception { @@ -156,6 +167,7 @@ void unlikeRecordPost_NotLiked_Fail() throws Exception { //given // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) record.updateLikeCount(1); + recordJpaRepository.save(record); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); //when & then @@ -239,6 +251,7 @@ void unlikeVotePost_NotLiked_Fail() throws Exception { //given // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) vote.updateLikeCount(1); + voteJpaRepository.save(vote); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); //when & then diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java index e8b21e592..4402339a2 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java @@ -14,6 +14,7 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,7 +24,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.Map; @@ -37,7 +37,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[단위] 방 게시물(기록,투표) 좋아요 api controller 단위 테스트") class RoomPostChangeLikeStatusControllerTest { @@ -73,6 +72,15 @@ void setUp() { record = recordJpaRepository.save(TestEntityFactory.createRecord(user1,room)); } + @AfterEach + void tearDown(){ + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + private Map buildValidLikeRequest(Boolean isLike, String postType) { Map request = new HashMap<>(); request.put("type", isLike); From e12c850b4a1705a1ba17296deb80264aba61d4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 6 Dec 2025 18:14:40 +0900 Subject: [PATCH 30/37] =?UTF-8?q?[chore]=20=EC=95=8C=EB=A6=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=B2=98=EB=A6=AC=20=ED=95=B4=EC=A0=9C=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konkuk/thip/post/application/service/PostLikeService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index 0a9cbfe66..f63b34e36 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -67,7 +67,7 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); // 좋아요 푸쉬알림 전송 - //sendNotifications(command); + sendNotifications(command); } else { postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 postLikeCommandPort.delete(command.userId(), command.postId()); From 7fee45fcf658a129a80d999f9e123208c77add3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 6 Dec 2025 18:21:50 +0900 Subject: [PATCH 31/37] =?UTF-8?q?[test]=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20db=20=EC=A0=95=ED=95=A9=EC=84=B1?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedChangeLikeStatusConcurrencyTest.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java index 1833fe99a..e973759fb 100644 --- a/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java +++ b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java @@ -5,6 +5,8 @@ import konkuk.thip.common.util.TestEntityFactory; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; import konkuk.thip.post.application.service.PostLikeService; import konkuk.thip.post.domain.PostType; @@ -42,6 +44,7 @@ class FeedChangeLikeStatusConcurrencyTest { @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; private UserJpaEntity user1; private UserJpaEntity user2; @@ -101,10 +104,20 @@ public void concurrentLikeToggleTest() throws InterruptedException { latch.await(); executor.shutdown(); + // 좋아요 저장 여부 확인 + boolean user1Liked = postLikeJpaRepository.existsByUserIdAndPostId(user1.getUserId(),feed.getPostId()); + boolean user2Liked = postLikeJpaRepository.existsByUserIdAndPostId(user2.getUserId(),feed.getPostId()); + + // 좋아요 카운트 증가 확인 + FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); + // then assertAll( () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat), - () -> assertThat(failCount.get()).isEqualTo(0) + () -> assertThat(failCount.get()).isEqualTo(0), + () -> assertThat(updatedFeed.getLikeCount()).isEqualTo(0), + () -> assertThat(user1Liked).isFalse(), + () -> assertThat(user2Liked).isFalse() ); } From 2ebd3e3dc57559d993ab3525d85c3b3269098556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sat, 6 Dec 2025 18:23:07 +0900 Subject: [PATCH 32/37] =?UTF-8?q?[delete]=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loadtest/feed/feed-like-load-test.js | 166 --------------------------- 1 file changed, 166 deletions(-) delete mode 100644 loadtest/feed/feed-like-load-test.js diff --git a/loadtest/feed/feed-like-load-test.js b/loadtest/feed/feed-like-load-test.js deleted file mode 100644 index fc5a4442c..000000000 --- a/loadtest/feed/feed-like-load-test.js +++ /dev/null @@ -1,166 +0,0 @@ -// feed-like-load-test.js -import http from 'k6/http'; -import { sleep,check } from 'k6'; -import { Trend, Counter } from 'k6/metrics'; - -const BASE_URL = 'http://localhost:8080'; -const FEED_ID = 1; // 테스트할 피드 ID -const USERS_START = 1; // 토큰 발급 시작 userId -const USERS_COUNT = 10000; // 총 사용자 = VU 수 -const TOKEN_BATCH = 200; // 토큰 발급 배치 크기 -const BATCH_PAUSE_S = 0.2; // 배치 간 대기 (for 토큰 발급 API 병목 방지) -const START_DELAY_S = 5; // 테스트 시작 전 대기 (for 방 참여 요청 동시 시작) - -// ===== 커스텀 메트릭 ===== -const likeLatency = new Trend('feed_like_latency'); // 참여 API 지연(ms) -const http5xx = new Counter('feed_like_5xx'); // 5xx 개수 -const http2xx = new Counter('feed_like_2xx'); // 2xx 개수 -const http4xx = new Counter('feed_like_4xx'); // 4xx 개수 - -// 실패 원인 분포 파악용(응답 JSON의 code 필드 기준) -const token_issue_failed = new Counter('token_issue_failed'); -const fail_POST_ALREADY_LIKED = new Counter('fail_POST_ALREADY_LIKED'); -const fail_POST_NOT_LIKED_CANNOT_CANCEL = new Counter('fail_POST_NOT_LIKED_CANNOT_CANCEL'); -const fail_POST_LIKE_COUNT_UNDERFLOW = new Counter('fail_POST_LIKE_COUNT_UNDERFLOW'); -const fail_RESOURCE_LOCKED = new Counter('fail_RESOURCE_LOCKED'); -const fail_OTHER_4XX = new Counter('fail_OTHER_4XX'); - -const ERR = { // THIP error code - POST_ALREADY_LIKED: 185001, - POST_NOT_LIKED_CANNOT_CANCEL: 185002, - POST_LIKE_COUNT_UNDERFLOW: 185000, - RESOURCE_LOCKED: 50200 -}; - -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 }; - } -} - -// ------------ 시나리오 ------------ -// 특정 시점에 한 게시물 (인기 작가,인플루언서가 작성한)에 좋아요 요청이 몰리는 상황 가정 -export let options = { - scenarios: { - // 각 VU가 "정확히 1회" 실행 → 1 VU = 1명 유저 - feed_like_once: { - executor: 'per-vu-iterations', - vus: USERS_COUNT, - iterations: 1, - startTime: '0s', // 모든 VU가 거의 동시에 스케줄링 - gracefulStop: '5s', - }, - }, - thresholds: { - feed_like_5xx: ['count==0'], // 서버 오류는 0건이어야 함 - feed_like_latency: ['p(95)<1000'], // p95 < 1s - }, -}; - -// 테스트 전 사용자 별 토큰 배치 발급 -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', feed: `${FEED_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 vuIdx = __VU - 1; - const token = data.tokens[vuIdx]; - - // 동기 시작: startAt까지 대기 → 모든 VU가 거의 같은 타이밍에 시작 - 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}/feeds/${FEED_ID}/likes`; - - const res = http.post(url, body, { headers, tags: { phase: 'like', feed: `${FEED_ID}` } }); - - // === 커스텀 메트릭 기록 === - likeLatency.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.POST_ALREADY_LIKED: - fail_POST_ALREADY_LIKED.add(1); - break; - case ERR.POST_NOT_LIKED_CANNOT_CANCEL: - fail_POST_NOT_LIKED_CANNOT_CANCEL.add(1); - break; - case ERR.POST_LIKE_COUNT_UNDERFLOW: - fail_POST_LIKE_COUNT_UNDERFLOW.add(1); - break; - case ERR.RESOURCE_LOCKED: - http423.add(1); - fail_RESOURCE_LOCKED.add(1); - break; - default: - fail_OTHER_4XX.add(1); - } - } else if (res.status >= 500) { - http5xx.add(1); - } - - // === 검증 === - check(res, { - 'like responded': (r) => r.status !== 0, - 'like 200 or expected 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500), - }); -} -// 테스트 결과 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), - }; -} From 6d7fb32b804ca23a53cfc0fbe84d3a10ba898ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 31 Dec 2025 04:23:22 +0900 Subject: [PATCH 33/37] =?UTF-8?q?[fix]=20@=20Retryable=20retryFor=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=20@=20Re?= =?UTF-8?q?cover=20=EC=98=88=EC=99=B8=EB=B3=84=EB=A1=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=EC=9E=91=EC=84=B1=20(#3?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeService.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index f63b34e36..1d25d7157 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -1,5 +1,6 @@ package konkuk.thip.post.application.service; +import jakarta.persistence.LockTimeoutException; import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.common.exception.code.ErrorCode; @@ -42,7 +43,8 @@ public class PostLikeService implements PostLikeUseCase { @Override @Retryable( - notRecoverable = { BusinessException.class, InvalidStateException.class}, + retryFor = {LockTimeoutException.class}, + noRetryFor = { BusinessException.class, InvalidStateException.class}, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 500, random = true) ) @@ -77,10 +79,20 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { } @Recover - public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) { + public PostIsLikeResult recoverLockTimeout(LockTimeoutException e, PostIsLikeCommand command) { throw new BusinessException(ErrorCode.RESOURCE_LOCKED); } + @Recover + public PostIsLikeResult recoverInvalidStateException(InvalidStateException e, PostIsLikeCommand command) { + throw e; + } + + @Recover + public PostIsLikeResult recoverBusinessException(BusinessException e, PostIsLikeCommand command) { + throw e; + } + private void sendNotifications(PostIsLikeCommand command) { PostQueryDto postQueryDto = postHandler.getPostQueryDto(command.postType(), command.postId()); From 80517298f44ceb457f9de7a439860ddcad65016b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 31 Dec 2025 04:23:58 +0900 Subject: [PATCH 34/37] =?UTF-8?q?[chore]=20=EC=9E=84=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EB=AC=B8=20=EC=A0=95=EB=A6=AC=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/ThipServerApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index a90f106f9..8a2d53b91 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -4,7 +4,6 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing From 95d14f61ee96b4ce07d5092b629abfee6aa1e1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 31 Dec 2025 04:26:02 +0900 Subject: [PATCH 35/37] =?UTF-8?q?[feat]=20record=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EB=9D=BD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/repository/record/RecordJpaRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java index 9b635c3d0..e45cd1473 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -1,11 +1,13 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.record; import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; 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; @@ -20,6 +22,7 @@ public interface RecordJpaRepository extends JpaRepository findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.userJpaEntity.userId = :userId") From 33fb36e32e6bb2d9e6c5671732b6fa491d6f3d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 31 Dec 2025 04:26:07 +0900 Subject: [PATCH 36/37] =?UTF-8?q?[feat]=20vote=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EB=9D=BD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/repository/vote/VoteJpaRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java index fd276a644..642235783 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java @@ -2,6 +2,7 @@ import io.lettuce.core.dynamic.annotation.Param; import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -10,6 +11,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.QueryHints; public interface VoteJpaRepository extends JpaRepository, VoteQueryRepository { @@ -20,6 +22,7 @@ public interface VoteJpaRepository extends JpaRepository, V @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT v FROM VoteJpaEntity v WHERE v.postId = :postId") + @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) Optional findByPostIdForUpdate(@Param("postId") Long postId); @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.userJpaEntity.userId = :userId") From e56da5c4a7afafda44bb4a69aed6a00250aef471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Thu, 1 Jan 2026 18:18:46 +0900 Subject: [PATCH 37/37] =?UTF-8?q?[fix]=20=EB=B3=91=ED=95=A9=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/common/exception/code/ErrorCode.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index ac02f7224..38af91a62 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -33,8 +33,6 @@ public enum ErrorCode implements ResponseCode { PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."), RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), - RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), - /* 60000부터 비즈니스 예외 */ /** * 60000 : alias error