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/53] =?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/53] =?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/53] =?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/53] =?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 20c2b95b161531d610b95d2b9fadc3b42a56f684 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:31:20 +0900 Subject: [PATCH 05/53] =?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 74aa272fb485dcb432039aecb53a76ecc13cd453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 9 Nov 2025 20:49:47 +0900 Subject: [PATCH 06/53] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=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=20s-lock=EC=97=90=EC=84=9C=20x-lock=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=8A=B9=EA=B2=A9=ED=95=A0=EB=95=8C=20=EB=8D=B0=EB=93=9C?= =?UTF-8?q?=EB=9D=BD=20=EC=83=81=ED=99=A9=EC=9D=84=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=ED=95=98=EA=B8=B0=EC=9C=84=ED=95=B4=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=EC=A7=81=20=EC=88=9C=EC=84=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/application/service/PostLikeService.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 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..f47ddec84 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -45,8 +45,11 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 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 be10beddf8c1bc83ba64f2856f4ef930f2bc4af1 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 17:58:22 +0900 Subject: [PATCH 07/53] =?UTF-8?q?[chore]=20=EC=95=88=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BE=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20(#338)?= 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 ---------------- .../thip/k6/feed-like-concurrency-test6.js | 82 ----------------- .../thip/k6/feed-like-concurrency-test7.js | 82 ----------------- .../thip/k6/feed-like-concurrency-test8.js | 85 ------------------ 9 files changed, 714 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-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 delete mode 100644 src/test/java/konkuk/thip/k6/feed-like-concurrency-test8.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-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), - }; -} 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 b3b465588350d628ad83ea2f90fadd1997bc57af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:03:36 +0900 Subject: [PATCH 08/53] =?UTF-8?q?[test]=20=ED=8A=B9=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=EC=97=90=20=ED=95=9C=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?(=EC=9D=B8=EA=B8=B0=20=EC=9E=91=EA=B0=80,=EC=9D=B8=ED=94=8C?= =?UTF-8?q?=EB=A3=A8=EC=96=B8=EC=84=9C=EA=B0=80=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=ED=95=9C)=EC=97=90=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=B4=20=EB=AA=B0=EB=A6=AC=EB=8A=94=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20k6=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loadtest/feed/feed-like-load-test.js | 161 +++++++++++++++++++++++++++ 1 file changed, 161 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..92de4a793 --- /dev/null +++ b/loadtest/feed/feed-like-load-test.js @@ -0,0 +1,161 @@ +// 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 = 5000; // 총 사용자 = 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_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 +}; + +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)<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; + 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 a9b91ca9243c8fb7e1239ea819da90c4d390465f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:07:57 +0900 Subject: [PATCH 09/53] =?UTF-8?q?[refactor]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20getLikeCount()=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/post/domain/CountUpdatable.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/post/domain/CountUpdatable.java b/src/main/java/konkuk/thip/post/domain/CountUpdatable.java index ab6d22a1d..a85f8d395 100644 --- a/src/main/java/konkuk/thip/post/domain/CountUpdatable.java +++ b/src/main/java/konkuk/thip/post/domain/CountUpdatable.java @@ -5,6 +5,7 @@ public interface CountUpdatable { void increaseCommentCount(); void decreaseCommentCount(); - void updateLikeCount(PostCountService postCountService, boolean isLike); + void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount); Long getId(); + Integer getLikeCount(); } \ No newline at end of file From a34c0501ffc3b0d7742568f61d48f0a2a422084a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:09:41 +0900 Subject: [PATCH 10/53] =?UTF-8?q?[refactor]=20=EB=A0=88=EB=94=94=EC=8A=A4?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=EC=8B=9C=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=B4=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=8B=9C=EC=97=90=20likeCount=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A3=BC=EC=9E=85=EB=B0=9B=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/feed/domain/Feed.java | 4 ++-- src/main/java/konkuk/thip/roompost/domain/Record.java | 4 ++-- src/main/java/konkuk/thip/roompost/domain/Vote.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/domain/Feed.java b/src/main/java/konkuk/thip/feed/domain/Feed.java index 29e965922..0f48e239c 100644 --- a/src/main/java/konkuk/thip/feed/domain/Feed.java +++ b/src/main/java/konkuk/thip/feed/domain/Feed.java @@ -155,8 +155,8 @@ public void decreaseCommentCount() { } @Override - public void updateLikeCount(PostCountService postCountService, boolean isLike) { - likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) { + likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount); } private void checkCommentCountNotUnderflow() { diff --git a/src/main/java/konkuk/thip/roompost/domain/Record.java b/src/main/java/konkuk/thip/roompost/domain/Record.java index f62c314ac..6a9501f7b 100644 --- a/src/main/java/konkuk/thip/roompost/domain/Record.java +++ b/src/main/java/konkuk/thip/roompost/domain/Record.java @@ -85,8 +85,8 @@ public void decreaseCommentCount() { } @Override - public void updateLikeCount(PostCountService postCountService, boolean isLike) { - likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) { + likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount); } private void checkCommentCountNotUnderflow() { diff --git a/src/main/java/konkuk/thip/roompost/domain/Vote.java b/src/main/java/konkuk/thip/roompost/domain/Vote.java index 3f3111193..5359e31c3 100644 --- a/src/main/java/konkuk/thip/roompost/domain/Vote.java +++ b/src/main/java/konkuk/thip/roompost/domain/Vote.java @@ -79,8 +79,8 @@ public void decreaseCommentCount() { } @Override - public void updateLikeCount(PostCountService postCountService, boolean isLike) { - likeCount = postCountService.updatePostLikeCount(isLike, likeCount); + public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) { + likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount); } private void checkCommentCountNotUnderflow() { From 5dfae3b28ba61803db57278e4707eb8a3c79f05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:48:38 +0900 Subject: [PATCH 11/53] =?UTF-8?q?[refactor]=20feed=20findPostIdsByIds,batc?= =?UTF-8?q?hUpdateLikeCounts=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/application/port/out/FeedCommandPort.java | 4 ++++ 1 file changed, 4 insertions(+) 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..a51c0acbd 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 @@ -1,6 +1,8 @@ package konkuk.thip.feed.application.port.out; +import java.util.List; +import java.util.Map; import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.feed.domain.Feed; @@ -12,6 +14,7 @@ public interface FeedCommandPort { Long save(Feed feed); Long update(Feed feed); Optional findById(Long id); + List findByIds(List ids); default Feed getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); @@ -21,4 +24,5 @@ default Feed getByIdOrThrow(Long id) { void deleteSavedFeed(Long userId, Long feedId); void deleteAllSavedFeedByUserId(Long userId); void deleteAllFeedByUserId(Long userId); + void batchUpdateLikeCounts(Map idToLikeCount); } From f4d80410f1dab95807e02cad9e9598e78028f4d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:49:48 +0900 Subject: [PATCH 12/53] =?UTF-8?q?[feat]=20FeedJpaRepository.findByPostIds?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/repository/FeedJpaRepository.java | 2 ++ 1 file changed, 2 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 2c84f0a14..cd6d93901 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 @@ -29,4 +29,6 @@ public interface FeedJpaRepository extends JpaRepository, F @Query("UPDATE FeedJpaEntity f SET f.status = 'INACTIVE' WHERE f.userJpaEntity.userId = :userId") void softDeleteAllByUserId(@Param("userId") Long userId); + @Query("SELECT f.postId FROM FeedJpaEntity f WHERE f.postId IN :postIds") + List findByPostIds(@Param("postIds") List postIds); } From 36d5524b89a36e35b4d2335cea7570b5e935c94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:53:36 +0900 Subject: [PATCH 13/53] =?UTF-8?q?[feat]=20FeedCommandPersistenceAdapter.fi?= =?UTF-8?q?ndByIds()=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=20batchUpdateLike?= =?UTF-8?q?Counts=20=EB=B0=B0=EC=B9=98=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedCommandPersistenceAdapter.java | 33 +++++++++++++++++++ 1 file changed, 33 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..d28a9acde 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 @@ -1,5 +1,9 @@ package konkuk.thip.feed.adapter.out.persistence; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Map; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; @@ -16,6 +20,8 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.List; @@ -27,6 +33,8 @@ @RequiredArgsConstructor public class FeedCommandPersistenceAdapter implements FeedCommandPort { + private final JdbcTemplate jdbcTemplate; + private final FeedJpaRepository feedJpaRepository; private final UserJpaRepository userJpaRepository; private final BookJpaRepository bookJpaRepository; @@ -44,6 +52,11 @@ public Optional findById(Long id) { .map(feedMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + return feedJpaRepository.findByPostIds(ids); + } + @Override public Long save(Feed feed) { @@ -114,6 +127,26 @@ public void deleteAllFeedByUserId(Long userId) { feedJpaRepository.softDeleteAllByUserId(userId); } + @Override + public void batchUpdateLikeCounts(Map idToLikeCount) { + String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?"; + List> entries = new ArrayList<>(idToLikeCount.entrySet()); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Map.Entry entry = entries.get(i); + ps.setInt(1, entry.getValue()); // Redis에서 가져온 좋아요 수 (증분 값) + ps.setLong(2, entry.getKey()); //게시글 ID + } + + @Override + public int getBatchSize() { + return entries.size(); + } + }); + } + @Override public void delete(Feed feed) { FeedJpaEntity feedJpaEntity = feedJpaRepository.findByPostId(feed.getId()) From bc6b49e79d6354a49f02a7a714f466fbf360a25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:54:03 +0900 Subject: [PATCH 14/53] =?UTF-8?q?[feat]=20PostHandler=20findPostIdsByIds,b?= =?UTF-8?q?atchUpdateLikeCounts=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/handler/PostHandler.java | 20 +++++++++++++++++++ 1 file changed, 20 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..c6be4f366 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 @@ -1,5 +1,7 @@ package konkuk.thip.post.application.service.handler; +import java.util.List; +import java.util.Map; import konkuk.thip.common.annotation.application.HelperService; import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.domain.Feed; @@ -46,4 +48,22 @@ public PostQueryDto getPostQueryDto(PostType type, Long postId) { case VOTE -> postQueryPort.getPostQueryDtoByVoteId(postId); }; } + + public List findPostIdsByIds(PostType type, List postIds) { + return switch(type) { + case FEED -> feedCommandPort.findByIds(postIds); + case RECORD -> recordCommandPort.findByIds(postIds); + case VOTE -> voteCommandPort.findByIds(postIds); + }; + } + + public void batchUpdateLikeCounts(PostType type, Map idToLikeCount) { + switch(type) { + case FEED -> feedCommandPort.batchUpdateLikeCounts(idToLikeCount); + case RECORD -> recordCommandPort.batchUpdateLikeCounts(idToLikeCount); + case VOTE -> voteCommandPort.batchUpdateLikeCounts(idToLikeCount); + } + } + + } From fcf3e5cf41c218d2fd67b019eb1bdc15ec36167a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:54:55 +0900 Subject: [PATCH 15/53] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=BA=90=EC=8B=B1=20PostLikeCoun?= =?UTF-8?q?tRedisCommand,Query=20Port=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/PostLikeCountRedisCommandPort.java | 9 +++++++++ .../port/out/PostLikeCountRedisQueryPort.java | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java new file mode 100644 index 000000000..f132715ee --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java @@ -0,0 +1,9 @@ +package konkuk.thip.post.application.port.out; + +import java.util.Set; +import konkuk.thip.post.domain.PostType; + +public interface PostLikeCountRedisCommandPort { + void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike); + void bulkResetLikeCounts(Set keysToReset); +} diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java new file mode 100644 index 000000000..805b212a6 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java @@ -0,0 +1,9 @@ +package konkuk.thip.post.application.port.out; + +import java.util.Map; +import konkuk.thip.post.domain.PostType; + +public interface PostLikeCountRedisQueryPort { + Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount); + Map getAllLikeCounts(); +} From 7302cc30f7566be7b20416fad984662333da8dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:55:20 +0900 Subject: [PATCH 16/53] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=BA=90=EC=8B=B1=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20PostLikeCountRedisAdapter=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PostLikeCountRedisAdapter.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java new file mode 100644 index 000000000..3101c647d --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java @@ -0,0 +1,107 @@ +package konkuk.thip.post.adapter.out.persistence; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; +import konkuk.thip.post.domain.PostType; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +@Component +@RequiredArgsConstructor +public class PostLikeCountRedisAdapter implements PostLikeCountRedisCommandPort, PostLikeCountRedisQueryPort { + + private final RedisTemplate redisTemplate; + private static final Duration TTL = Duration.ofMinutes(10); + + @Value("${app.redis.post-like-count-prefix}") + private String postLikeCountPrefix; + + @Override + public Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount) { + String redisKey = makeRedisKey(postType, postId); + Integer likeCount = redisTemplate.opsForValue().get(redisKey); + if (likeCount != null) { + return likeCount; //cache hit + } + + // cache miss 캐시에 없으면 DB값을 캐시에 저장 후 리턴 + redisTemplate.opsForValue().set(redisKey, dbLikeCount, TTL); + return dbLikeCount; + } + + @Override + public Map getAllLikeCounts() { + // 모든 좋아요 카운트 키 검색 + Set keys = redisTemplate.keys(postLikeCountPrefix + "*"); + if (keys == null || keys.isEmpty()) { + return Collections.emptyMap(); + } + + List values = redisTemplate.opsForValue().multiGet(keys); + + Map result = new HashMap<>(); + List keyList = new ArrayList<>(keys); + + // key와 value를 매핑하여 맵 생성 + for (int i = 0; i < keyList.size(); i++) { + if (values.get(i) != null) { + result.put(keyList.get(i), values.get(i)); + } + } + return result; //각 키의 좋아요 수를 맵으로 반환 + } + + @Override + public void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike) { + String redisKey = makeRedisKey(postType, postId); + // 키가 없으면 getLikeCount로 초기화 + if (!redisTemplate.hasKey(redisKey)) { + getLikeCount(postType,postId,likeCount); + } + if(isLike) incrementLikeCount(postType,postId); + else decrementLikeCount(postType,postId); + } + + @Override + public void bulkResetLikeCounts(Set keysToReset) { + if (keysToReset.isEmpty()) { + return; + } + + // Pipeline을 사용하여 일괄적으로 값을 0으로 설정 + redisTemplate.executePipelined((RedisCallback) connection -> { + for (String key : keysToReset) { + // connection.set(key.getBytes(StandardCharsets.UTF_8), "0".getBytes(StandardCharsets.UTF_8)); + // RedisTemplate를 사용하므로 OpsForValue().set 사용 + redisTemplate.opsForValue().set(key, 0); + } + return null; + }); + } + + private void incrementLikeCount(PostType postType, Long postId) { + String redisKey = makeRedisKey(postType, postId); + redisTemplate.opsForValue().increment(redisKey); + redisTemplate.expire(redisKey, TTL); + } + + private void decrementLikeCount(PostType postType, Long postId) { + String redisKey = makeRedisKey(postType, postId); + redisTemplate.opsForValue().decrement(redisKey); + redisTemplate.expire(redisKey, TTL); + } + + private String makeRedisKey(PostType type, Long postId) { + return postLikeCountPrefix + type.name() + ":" + postId; + } + +} From 5a9cc954e5a9aeb5576d8522c2fa1cf50550ce99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:56:06 +0900 Subject: [PATCH 17/53] =?UTF-8?q?[feat]=20RecordCommandPersistenceAdapterA?= =?UTF-8?q?dapter.findByIds()=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=20batchU?= =?UTF-8?q?pdateLikeCounts=20=EB=B0=B0=EC=B9=98=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecordCommandPersistenceAdapter.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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..a78e2567d 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 @@ -1,5 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Map; import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; @@ -14,6 +18,8 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.List; @@ -25,6 +31,8 @@ @RequiredArgsConstructor public class RecordCommandPersistenceAdapter implements RecordCommandPort { + private final JdbcTemplate jdbcTemplate; + private final RecordJpaRepository recordJpaRepository; private final UserJpaRepository userJpaRepository; private final RoomJpaRepository roomJpaRepository; @@ -56,6 +64,11 @@ public Optional findById(Long id) { .map(recordMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + return recordJpaRepository.findByPostIds(ids); + } + @Override public void delete(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( @@ -83,6 +96,26 @@ public void deleteAllByUserId(Long userId) { recordJpaRepository.softDeleteAllByUserId(userId); } + @Override + public void batchUpdateLikeCounts(Map idToLikeCount) { + String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?"; + List> entries = new ArrayList<>(idToLikeCount.entrySet()); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Map.Entry entry = entries.get(i); + ps.setInt(1, entry.getValue()); + ps.setLong(2, entry.getKey()); + } + + @Override + public int getBatchSize() { + return entries.size(); + } + }); + } + @Override public void update(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( From 4154b49313a7cc98ac8d27ac9fb7a13dcdf7d41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:57:01 +0900 Subject: [PATCH 18/53] =?UTF-8?q?[refactor]=20RecordCommandPort=20findPost?= =?UTF-8?q?IdsByIds,batchUpdateLikeCounts=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roompost/application/port/out/RecordCommandPort.java | 5 +++++ 1 file changed, 5 insertions(+) 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..a5eabdcc2 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 @@ -1,6 +1,8 @@ package konkuk.thip.roompost.application.port.out; +import java.util.List; +import java.util.Map; import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.roompost.domain.Record; @@ -15,6 +17,7 @@ public interface RecordCommandPort { void update(Record record); Optional findById(Long id); + List findByIds(List ids); default Record getByIdOrThrow(Long id) { return findById(id) @@ -24,4 +27,6 @@ default Record getByIdOrThrow(Long id) { void delete(Record record); void deleteAllByUserId(Long userId); + + void batchUpdateLikeCounts(Map idToLikeCount); } From 3e7a47c63162ed4bbe193756f4c48b519adaf799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:57:16 +0900 Subject: [PATCH 19/53] =?UTF-8?q?[feat]=20RecordJpaRepository.findByPostId?= =?UTF-8?q?s=20=EC=B6=94=EA=B0=80=20(#338)?= 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 9677e8263..ed75fa9ee 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 @@ -22,4 +22,7 @@ public interface RecordJpaRepository extends JpaRepository findByPostIds(List postIds); } From 73902801648ce42ec0dd5cdb0d40ad09b86d7af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:57:47 +0900 Subject: [PATCH 20/53] =?UTF-8?q?[feat]=20RedisConfig.redisIntegerTemplate?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/config/RedisConfig.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/konkuk/thip/config/RedisConfig.java b/src/main/java/konkuk/thip/config/RedisConfig.java index 088dde588..57d22200d 100644 --- a/src/main/java/konkuk/thip/config/RedisConfig.java +++ b/src/main/java/konkuk/thip/config/RedisConfig.java @@ -8,6 +8,7 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -29,6 +30,16 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec return redisTemplate; } + @Bean + public RedisTemplate redisIntegerTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Integer.class)); + return redisTemplate; + } + + @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(host, port); From 567f1e6eab05a599ab260240bf0722f3d1ee08fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:58:11 +0900 Subject: [PATCH 21/53] =?UTF-8?q?[feat]=20VoteCommandPersistenceAdapter.fi?= =?UTF-8?q?ndByIds()=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=20batchUpdateLike?= =?UTF-8?q?Counts=20=EB=B0=B0=EC=B9=98=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VoteCommandPersistenceAdapter.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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..9ce7fb6ca 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 @@ -1,5 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Map; import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; @@ -22,6 +26,8 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.List; @@ -33,6 +39,8 @@ @RequiredArgsConstructor public class VoteCommandPersistenceAdapter implements VoteCommandPort { + private final JdbcTemplate jdbcTemplate; + private final VoteJpaRepository voteJpaRepository; private final VoteItemJpaRepository voteItemJpaRepository; private final UserJpaRepository userJpaRepository; @@ -82,6 +90,11 @@ public Optional findById(Long id) { .map(voteMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + return voteJpaRepository.findByPostIds(ids); + } + @Override public Optional findVoteItemById(Long id) { return voteItemJpaRepository.findById(id) @@ -190,6 +203,26 @@ public void deleteAllVoteByUserId(Long userId) { voteJpaRepository.softDeleteAllByUserId(userId); } + @Override + public void batchUpdateLikeCounts(Map idToLikeCount) { + String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?"; + List> entries = new ArrayList<>(idToLikeCount.entrySet()); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Map.Entry entry = entries.get(i); + ps.setInt(1, entry.getValue()); + ps.setLong(2, entry.getKey()); + } + + @Override + public int getBatchSize() { + return entries.size(); + } + }); + } + @Override public void updateVote(Vote vote) { From c972f51ccb8d8610ea21f93b37c37808a51c8f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:58:32 +0900 Subject: [PATCH 22/53] =?UTF-8?q?[refactor]=20VoteCommandPort.findPostIdsB?= =?UTF-8?q?yIds,batchUpdateLikeCounts=20=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/roompost/application/port/out/VoteCommandPort.java | 5 +++++ 1 file changed, 5 insertions(+) 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..1f925f874 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 @@ -1,5 +1,6 @@ package konkuk.thip.roompost.application.port.out; +import java.util.Map; import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.roompost.domain.Vote; import konkuk.thip.roompost.domain.VoteItem; @@ -21,6 +22,8 @@ public interface VoteCommandPort { Optional findById(Long id); + List findByIds(List ids); + default Vote getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); @@ -50,4 +53,6 @@ default VoteItem getVoteItemByIdOrThrow(Long id) { void deleteAllVoteParticipantByUserId(Long userId); void deleteAllVoteByUserId(Long userId); + + void batchUpdateLikeCounts(Map idToLikeCount); } From 35f345603208e5a46b9e7c4eefc7b5aaabe41c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 01:58:48 +0900 Subject: [PATCH 23/53] =?UTF-8?q?[feat]=20VoteJpaRepository.findByPostIds?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#338)?= 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 90251801d..8f1408910 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 @@ -22,4 +22,7 @@ public interface VoteJpaRepository extends JpaRepository, V @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE VoteJpaEntity v SET v.status = 'INACTIVE' WHERE v.userJpaEntity.userId = :userId") void softDeleteAllByUserId(@Param("userId") Long userId); + + @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.postId IN :postIds") + List findByPostIds(List ids); } From c6401d71dfb4266337cb5e5db4208f1c6f22b795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 02:00:54 +0900 Subject: [PATCH 24/53] =?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=8B=9C,=20post=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=EC=97=90=20=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EB=9D=BD=20=ED=95=B4=EC=A0=9C=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=A0=88=EB=94=94=EC=8A=A4=EC=9D=98=20INCR/DECR=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=96=B4=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9B=90=EC=9E=90=EC=A0=81=20=EC=97=B0=EC=82=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeService.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 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 f47ddec84..781170511 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -2,6 +2,8 @@ import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; +import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; import konkuk.thip.post.application.port.out.dto.PostQueryDto; import konkuk.thip.post.application.service.handler.PostHandler; import konkuk.thip.post.domain.CountUpdatable; @@ -25,6 +27,8 @@ public class PostLikeService implements PostLikeUseCase { private final PostLikeQueryPort postLikeQueryPort; private final PostLikeCommandPort postLikeCommandPort; private final UserCommandPort userCommandPort; + private final PostLikeCountRedisCommandPort postLikeCountRedisCommandPort; + private final PostLikeCountRedisQueryPort postLikeCountRedisQueryPort; private final PostHandler postHandler; private final PostCountService postCountService; @@ -45,22 +49,24 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회 boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId()); - // 3. 게시물 좋아요 수 업데이트 - post.updateLikeCount(postCountService,command.isLike()); - postHandler.updatePost(command.postType(), post); - - // 4. 좋아요 상태변경 + // 3. 좋아요 상태변경 if (command.isLike()) { postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); // 좋아요 푸쉬알림 전송 - sendNotifications(command); + //sendNotifications(command); } else { postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 postLikeCommandPort.delete(command.userId(), command.postId()); } + // 4. 게시물 좋아요 수 + int redisLikeCount = postLikeCountRedisQueryPort.getLikeCount(command.postType(),post.getId(), post.getLikeCount()); + post.updateLikeCount(postCountService,command.isLike(), redisLikeCount); // 도메인 상태 갱신 (외부에서 읽은 최신값 주입) + // 4-1. 좋아요 수를 Redis INCR/DECR 이용해 원자적으로 갱신 + postLikeCountRedisCommandPort.updateLikeCount(command.postType(),post.getId(),post.getLikeCount(),command.isLike()); + return PostIsLikeResult.of(post.getId(), command.isLike()); } From cb00e01d7bea6f35b9b4bda9bb05ca597947dec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Tue, 25 Nov 2025 02:02:38 +0900 Subject: [PATCH 25/53] =?UTF-8?q?[feat]=20Redis=EC=9D=98=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=88=98(=EC=A6=9D=EA=B0=90)=EB=A5=BC=20?= =?UTF-8?q?=EC=A3=BC=EA=B8=B0=EC=A0=81=EC=9C=BC=EB=A1=9C=20DB=EC=97=90=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=ED=95=98=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=EC=B6=94=EA=B0=80=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PostLikeCountSyncToDBService.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java new file mode 100644 index 000000000..48a38df5b --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -0,0 +1,85 @@ +package konkuk.thip.post.application.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; +import konkuk.thip.post.application.service.handler.PostHandler; +import konkuk.thip.post.domain.PostType; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostLikeCountSyncToDBService { + + private final PostLikeCountRedisQueryPort redisQueryPort; + private final PostLikeCountRedisCommandPort redisCommandPort; + private final PostHandler postHandler; + + @Value("${app.redis.post-like-count-prefix}") + private String postLikeCountPrefix; + + @Scheduled(fixedRate = 60000)// 1분마다 실행 + @Transactional + public void syncLikeCountsToDB() { + Map allKeyToLikeCount = redisQueryPort.getAllLikeCounts(); + if (allKeyToLikeCount.isEmpty()) { + return; + } + + // 타입별 ID 및 좋아요수 맵으로 분리 + Map> typeToIdList = new HashMap<>(); + Map keyToLikeCountToUpdate = new HashMap<>(); // DB에 업데이트할 키만 저장 + boolean hasUpdates = false; + + for (Map.Entry entry : allKeyToLikeCount.entrySet()) { + String key = entry.getKey(); + Integer likeCount = entry.getValue(); + + if (likeCount == null || likeCount.intValue() == 0) continue; + // 0보다 큰 값이 있으면 업데이트가 필요함을 기록 + hasUpdates = true; + + String[] parts = key.split(":"); + if (parts.length != 4) continue; + PostType type = PostType.valueOf(parts[2]); + Long postId = Long.valueOf(parts[3]); + + typeToIdList.computeIfAbsent(type, k -> new ArrayList<>()).add(postId); + keyToLikeCountToUpdate.put(key, likeCount); + } + + if (!hasUpdates) { + return; + } + + for (Map.Entry> entry : typeToIdList.entrySet()) { + PostType type = entry.getKey(); + List ids = entry.getValue(); + + // 도메인별 id 리스트 중 실제 존재하는 id만 필터링 + List existingIds = postHandler.findPostIdsByIds(type, ids); + // 해당 id와 좋아요 수만 맵으로 생성 + Map idToLikeCount = existingIds.stream() + .collect(Collectors.toMap(id -> id, id -> { + String redisKey = postLikeCountPrefix + type.name() + ":" + id; + return keyToLikeCountToUpdate.getOrDefault(redisKey, 0); + })); + + // 도메인별 벌크 좋아요 DB 업데이트 + postHandler.batchUpdateLikeCounts(type, idToLikeCount); + } + + // 레디스 리셋 + Set updatedKeys = keyToLikeCountToUpdate.keySet(); + redisCommandPort.bulkResetLikeCounts(updatedKeys); + } +} From e4891a48b57871be27ae2e5a402814ca63af4c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Fri, 28 Nov 2025 15:13:17 +0900 Subject: [PATCH 26/53] =?UTF-8?q?[chore]=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/adapter/out/persistence/PostLikeCountRedisAdapter.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java index 3101c647d..db792b759 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java @@ -80,8 +80,6 @@ public void bulkResetLikeCounts(Set keysToReset) { // Pipeline을 사용하여 일괄적으로 값을 0으로 설정 redisTemplate.executePipelined((RedisCallback) connection -> { for (String key : keysToReset) { - // connection.set(key.getBytes(StandardCharsets.UTF_8), "0".getBytes(StandardCharsets.UTF_8)); - // RedisTemplate를 사용하므로 OpsForValue().set 사용 redisTemplate.opsForValue().set(key, 0); } return null; From f6b6ba98d391cb69925f73727a8b90e6ec617f5f 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 23:16:04 +0900 Subject: [PATCH 27/53] =?UTF-8?q?[fix]=20=EC=BF=BC=EB=A6=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A0=84=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/FeedCommandPersistenceAdapter.java | 2 ++ .../out/persistence/RecordCommandPersistenceAdapter.java | 2 ++ .../adapter/out/persistence/VoteCommandPersistenceAdapter.java | 2 ++ 3 files changed, 6 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 d28a9acde..eeb32a752 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 @@ -54,6 +54,8 @@ public Optional findById(Long id) { @Override public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); return feedJpaRepository.findByPostIds(ids); } 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 a78e2567d..f04650d2c 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 @@ -66,6 +66,8 @@ public Optional findById(Long id) { @Override public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); return recordJpaRepository.findByPostIds(ids); } 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 9ce7fb6ca..be981c762 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 @@ -92,6 +92,8 @@ public Optional findById(Long id) { @Override public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); return voteJpaRepository.findByPostIds(ids); } From f4d3a2a674f4cf381774f963012410d076a9549a 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 23:16:47 +0900 Subject: [PATCH 28/53] =?UTF-8?q?[refactor]=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20ID=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20DB=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=A9=EC=A7=80=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/application/service/PostLikeCountSyncToDBService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java index 48a38df5b..34d39392b 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -67,6 +67,7 @@ public void syncLikeCountsToDB() { // 도메인별 id 리스트 중 실제 존재하는 id만 필터링 List existingIds = postHandler.findPostIdsByIds(type, ids); + if (existingIds.isEmpty()) continue; // 해당 id와 좋아요 수만 맵으로 생성 Map idToLikeCount = existingIds.stream() .collect(Collectors.toMap(id -> id, id -> { From bb2a260fc0f4ed057b417a774e118f8523f7855d 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 23:24:19 +0900 Subject: [PATCH 29/53] =?UTF-8?q?[test]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 좋아요 로직이 레디스 원자적 연산으로 수정되면서 게시글 엔티티의 좋아요 정보(likeCount)는 더이상 실시간정보가 아니므로 db 정합성을 확인하는 코드 주석 처리 (#324) --- .../in/web/FeedChangeLikeStatusApiTest.java | 12 ++++++------ .../web/RoomPostChangeLikeStatusApiTest.java | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 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 ca31c8511..984eedbaa 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 @@ -80,9 +80,9 @@ void likeFeed_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); assertThat(liked).isTrue(); - // 좋아요 카운트 증가 확인 - FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); - assertThat(updatedFeed.getLikeCount()).isEqualTo(1); +// // 좋아요 카운트 증가 확인 +// FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); +// assertThat(updatedFeed.getLikeCount()).isEqualTo(1); } @Test @@ -127,9 +127,9 @@ void unlikeFeed_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); assertThat(liked).isFalse(); - // 좋아요 카운트 감소 확인 - FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); - assertThat(updatedFeed.getLikeCount()).isEqualTo(0); +// // 좋아요 카운트 감소 확인 +// FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); +// assertThat(updatedFeed.getLikeCount()).isEqualTo(0); } @Test 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..b56d64fa5 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 @@ -102,9 +102,9 @@ void likeRecordPost_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); assertThat(liked).isTrue(); - // 좋아요 카운트 증가 확인 - RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); - assertThat(updatedRecord.getLikeCount()).isEqualTo(1); +// // 좋아요 카운트 증가 확인 +// RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); +// assertThat(updatedRecord.getLikeCount()).isEqualTo(1); } @@ -146,8 +146,8 @@ void unlikeRecordPost_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); assertThat(liked).isFalse(); - RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); - assertThat(updatedRecord.getLikeCount()).isEqualTo(0); +// RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); +// assertThat(updatedRecord.getLikeCount()).isEqualTo(0); } @Test @@ -186,8 +186,8 @@ void likeVotePost_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); assertThat(liked).isTrue(); - VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); - assertThat(updatedVote.getLikeCount()).isEqualTo(1); +// VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); +// assertThat(updatedVote.getLikeCount()).isEqualTo(1); } @Test @@ -227,8 +227,8 @@ void unlikeVotePost_Success() throws Exception { boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); assertThat(liked).isFalse(); - VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); - assertThat(updatedVote.getLikeCount()).isEqualTo(0); +// VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); +// assertThat(updatedVote.getLikeCount()).isEqualTo(0); } @Test From 59f89c291d7a85f3fafbf5c3ad5a947ce06ee3f6 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 23:25:43 +0900 Subject: [PATCH 30/53] =?UTF-8?q?[test]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80(=EB=8F=84=EB=A9=94=EC=9D=B8)=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateLikeCount()의newLikeCount 매개변수가 추가되면서 테스트코드 수정 (#324) --- .../java/konkuk/thip/feed/domain/FeedTest.java | 14 +++++++------- .../konkuk/thip/roompost/domain/RecordTest.java | 14 +++++++------- .../java/konkuk/thip/roompost/domain/VoteTest.java | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/domain/FeedTest.java b/src/test/java/konkuk/thip/feed/domain/FeedTest.java index e4aa33d34..44e3140a6 100644 --- a/src/test/java/konkuk/thip/feed/domain/FeedTest.java +++ b/src/test/java/konkuk/thip/feed/domain/FeedTest.java @@ -227,10 +227,10 @@ private Feed makeFeedWithPublicStatus(Boolean isPublic) { void updateLikeCount_likeTrue_increments() { Feed feed = createPublicFeed(); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(1, feed.getLikeCount()); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(2, feed.getLikeCount()); } @@ -239,14 +239,14 @@ void updateLikeCount_likeTrue_increments() { void updateLikeCount_likeFalse_decrements() { Feed feed = createPublicFeed(); // 먼저 likeCount 증가 셋업 - feed.updateLikeCount(postCountService, true); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(2, feed.getLikeCount()); - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()); assertEquals(1, feed.getLikeCount()); - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()); assertEquals(0, feed.getLikeCount()); } @@ -257,7 +257,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, feed.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()-1); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); diff --git a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java index e7719eff1..b2c819068 100644 --- a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java +++ b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java @@ -143,10 +143,10 @@ void decreaseCommentCount_belowZero_throws() { void updateLikeCount_likeTrue_increments() { konkuk.thip.roompost.domain.Record record = createWithCommentRecord(); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(1, record.getLikeCount()); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(2, record.getLikeCount()); } @@ -156,14 +156,14 @@ void updateLikeCount_likeFalse_decrements() { konkuk.thip.roompost.domain.Record record = createWithCommentRecord(); // 먼저 likeCount 증가 셋업 - record.updateLikeCount(postCountService,true); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(2, record.getLikeCount()); - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()); assertEquals(1, record.getLikeCount()); - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()); assertEquals(0, record.getLikeCount()); } @@ -174,7 +174,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, record.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()-1); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); diff --git a/src/test/java/konkuk/thip/roompost/domain/VoteTest.java b/src/test/java/konkuk/thip/roompost/domain/VoteTest.java index 7383fdd3a..872d8a7cd 100644 --- a/src/test/java/konkuk/thip/roompost/domain/VoteTest.java +++ b/src/test/java/konkuk/thip/roompost/domain/VoteTest.java @@ -142,10 +142,10 @@ void decreaseCommentCount_belowZero_throws() { void updateLikeCount_likeTrue_increments() { Vote vote = createWithCommentVote(); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(1, vote.getLikeCount()); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(2, vote.getLikeCount()); } @@ -155,14 +155,14 @@ void updateLikeCount_likeFalse_decrements() { Vote vote = createWithCommentVote(); // 먼저 likeCount 증가 셋업 - vote.updateLikeCount(postCountService,true); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(2, vote.getLikeCount()); - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); assertEquals(1, vote.getLikeCount()); - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); assertEquals(0, vote.getLikeCount()); } @@ -173,7 +173,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, vote.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); From 90115842cd39bee05db7c30285c313f91a63a304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 7 Dec 2025 00:29:29 +0900 Subject: [PATCH 31/53] =?UTF-8?q?[refactor]=20db=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20test?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=EB=8A=94=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/application/service/PostLikeCountSyncToDBService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java index 34d39392b..9a504a4f5 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -12,12 +12,14 @@ import konkuk.thip.post.domain.PostType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Profile({"!test"}) public class PostLikeCountSyncToDBService { private final PostLikeCountRedisQueryPort redisQueryPort; From a6cdc57fa68f45caf5c000dec1f6ac70cc88ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 7 Dec 2025 00:51:35 +0900 Subject: [PATCH 32/53] =?UTF-8?q?[test]=20book=20=EC=A4=91=EB=B3=B5=20inse?= =?UTF-8?q?rt=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B9=A8=EC=A7=80=EB=8A=94?= =?UTF-8?q?=20=EA=B1=B0=20=EC=88=98=EC=A0=95=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/adapter/in/web/FeedChangeSavedApiTest.java | 2 +- .../thip/feed/adapter/in/web/FeedCreateApiTest.java | 10 +++++----- .../thip/feed/adapter/in/web/FeedDeleteApiTest.java | 2 +- .../thip/feed/adapter/in/web/FeedUpdateApiTest.java | 2 +- .../feed/adapter/in/web/FeedUpdateControllerTest.java | 4 +++- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java index d51dce63e..8d7b41247 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java @@ -59,7 +59,7 @@ void setUp() { user = userJpaRepository.save(TestEntityFactory.createUser(alias)); Category category = TestEntityFactory.createLiteratureCategory(); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682151")); feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); } diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java index f59955975..2a44ddfea 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java @@ -72,7 +72,7 @@ void setUp() { void createFeedWithBookExistsInDB() throws Exception { // given - bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("3788954682152")); Map request = new HashMap<>(); request.put("isbn", "9788954682152"); // 책 ISBN @@ -158,7 +158,7 @@ void createFeedWithBookNotExists_usesExternalAPI() throws Exception { void createFeedWithImages_createsContentEntities() throws Exception { // given - bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1788954682152")); Map request = new HashMap<>(); request.put("isbn", "9788954682152"); // 책 ISBN @@ -206,7 +206,7 @@ void createFeedWithImages_createsContentEntities() throws Exception { @DisplayName("이미지가 없는 피드를 생성하면 Feed의 contentList는 비어 있어야 한다.") void createFeedWithoutImages_shouldHaveEmptyContentList() throws Exception { // given - bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("4788954682152")); Map request = new HashMap<>(); request.put("isbn", "9788954682152"); @@ -239,7 +239,7 @@ void createFeedWithoutImages_shouldHaveEmptyContentList() throws Exception { void createFeedWithTags_createsFeedTagMappings() throws Exception { // given - bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("2788954682152")); Map request = new HashMap<>(); request.put("isbn", "9788954682152"); @@ -279,7 +279,7 @@ void createFeedWithTags_createsFeedTagMappings() throws Exception { @DisplayName("태그가 없는 피드는 태그가 없는 채로 DB에 저장된다.") void createFeedWithoutTags_shouldNotHaveFeedTags() throws Exception { // given - bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + bookJpaRepository.save(TestEntityFactory.createBookWithISBN("5788954682152")); Map request = new HashMap<>(); request.put("isbn", "9788954682152"); diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java index 0d4627b3d..81711b320 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java @@ -59,7 +59,7 @@ class FeedDeleteApiTest { @BeforeEach void setUp() { user = userJpaRepository.save(TestEntityFactory.createUser(Alias.ARTIST)); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("6788954682152")); feed = feedJpaRepository.save(TestEntityFactory.createFeed(user, book, true,1,1,List.of("url1", "url2", "url3"))); postLikeJpaRepository.save(TestEntityFactory.createPostLike(user,feed)); comment = commentJpaRepository.save(TestEntityFactory.createComment(feed, user, FEED)); diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java index e25950dbc..c4273f52e 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java @@ -58,7 +58,7 @@ void setUp() { user = userJpaRepository.save(TestEntityFactory.createUser(alias)); Category category = TestEntityFactory.createLiteratureCategory(); - book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("7788954682152")); tags = List.of(KOREAN_NOVEL, FOREIGN_NOVEL, CLASSIC_LITERATURE); feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true, tags)); diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java index 3fd5aa97e..233df9bb3 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java @@ -1,6 +1,7 @@ package konkuk.thip.feed.adapter.in.web; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Set; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; @@ -46,6 +47,7 @@ class FeedUpdateControllerTest { @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; + private Long savedFeedId; private Long creatorUserId; @@ -53,7 +55,7 @@ class FeedUpdateControllerTest { void setUp() { Alias alias = TestEntityFactory.createLiteratureAlias(); UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(alias)); - BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("8788954682152")); savedFeedId = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true, List.of(KOREAN_NOVEL, FOREIGN_NOVEL, CLASSIC_LITERATURE))).getPostId(); creatorUserId = user.getUserId(); } From 02794c23b8545cdfe5e62d6aaf1aa83a62a678e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 7 Dec 2025 00:52:44 +0900 Subject: [PATCH 33/53] =?UTF-8?q?[test]=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EB=A0=88=EB=94=94?= =?UTF-8?q?=EC=8A=A4=20=EC=B4=88=EA=B8=B0=ED=99=94=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=85=8B=EC=97=85=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feed/adapter/in/web/FeedChangeLikeStatusApiTest.java | 9 +++++++++ .../adapter/in/web/RoomPostChangeLikeStatusApiTest.java | 8 ++++++++ 2 files changed, 17 insertions(+) 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 984eedbaa..899de1d8c 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 @@ -1,6 +1,7 @@ package konkuk.thip.feed.adapter.in.web; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Set; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; @@ -17,6 +18,7 @@ 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.data.redis.core.RedisTemplate; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -44,6 +46,8 @@ class FeedChangeLikeStatusApiTest { @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; @Autowired private PostLikeJpaRepository postLikeJpaRepository; + @Autowired private RedisTemplate redisTemplate; + private UserJpaEntity user; private BookJpaEntity book; @@ -53,6 +57,11 @@ class FeedChangeLikeStatusApiTest { @BeforeEach void setUp() { + // Redis 초기화 (모든 키 삭제) + Set keys = redisTemplate.keys("*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } Alias alias = TestEntityFactory.createLiteratureAlias(); user = userJpaRepository.save(TestEntityFactory.createUser(alias)); book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); 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 b56d64fa5..7e3f11536 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 @@ -1,6 +1,7 @@ package konkuk.thip.room.adapter.in.web; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Set; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; @@ -26,6 +27,7 @@ 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.data.redis.core.RedisTemplate; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -57,6 +59,7 @@ class RoomPostChangeLikeStatusApiTest { @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; @Autowired private RecordJpaRepository recordJpaRepository; @Autowired private VoteJpaRepository voteJpaRepository; + @Autowired private RedisTemplate redisTemplate; private UserJpaEntity user; private BookJpaEntity book; @@ -70,6 +73,11 @@ class RoomPostChangeLikeStatusApiTest { @BeforeEach void setUp() { + // Redis 초기화 (모든 키 삭제) + Set keys = redisTemplate.keys("*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } Alias alias = TestEntityFactory.createLiteratureAlias(); user = userJpaRepository.save(TestEntityFactory.createUser(alias)); book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); From 17f23d30cf6e0e135fd2793666b89833e48818da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Sun, 7 Dec 2025 00:53:42 +0900 Subject: [PATCH 34/53] =?UTF-8?q?[chore]=20import=EB=AC=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/adapter/in/web/FeedUpdateControllerTest.java | 1 - src/test/java/konkuk/thip/roompost/domain/RecordTest.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java index 233df9bb3..1a3b30f00 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java @@ -1,7 +1,6 @@ package konkuk.thip.feed.adapter.in.web; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Set; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; diff --git a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java index b2c819068..dbb397ace 100644 --- a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java +++ b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java @@ -2,7 +2,6 @@ import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.post.domain.service.PostCountService; -import konkuk.thip.roompost.domain.Record; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From c66eb33a2c9dffa855da818352189784d66e1eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:22:53 +0900 Subject: [PATCH 35/53] =?UTF-8?q?[feat]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20dto?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/event/dto/PostLikeChangedEvent.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java b/src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java new file mode 100644 index 000000000..d93795063 --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java @@ -0,0 +1,14 @@ +package konkuk.thip.post.adapter.out.event.dto; + +import konkuk.thip.post.domain.PostType; +import lombok.Builder; + +@Builder +public record PostLikeChangedEvent( + Long userId, + Long postId, + boolean isLike, // true: 좋아요 요청, false: 취소 요청 + PostType postType, + int finalLikeCount // DB에 동기화되어야 할 최종 카운트 +) { +} \ No newline at end of file From 94d579a6259ba7c8b06329c54b65b2e914a4823f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:25:05 +0900 Subject: [PATCH 36/53] =?UTF-8?q?[rename]=20PostLikeCountRedis***=20->=20P?= =?UTF-8?q?ostLikeRedis=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BDr=20(#339?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PostLikeCountRedisAdapter.java | 107 ------------------ .../out/PostLikeCountRedisCommandPort.java | 9 -- .../port/out/PostLikeCountRedisQueryPort.java | 9 -- 3 files changed, 125 deletions(-) delete mode 100644 src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java delete mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java delete mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java deleted file mode 100644 index 3101c647d..000000000 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeCountRedisAdapter.java +++ /dev/null @@ -1,107 +0,0 @@ -package konkuk.thip.post.adapter.out.persistence; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; -import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; -import konkuk.thip.post.domain.PostType; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -@Component -@RequiredArgsConstructor -public class PostLikeCountRedisAdapter implements PostLikeCountRedisCommandPort, PostLikeCountRedisQueryPort { - - private final RedisTemplate redisTemplate; - private static final Duration TTL = Duration.ofMinutes(10); - - @Value("${app.redis.post-like-count-prefix}") - private String postLikeCountPrefix; - - @Override - public Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount) { - String redisKey = makeRedisKey(postType, postId); - Integer likeCount = redisTemplate.opsForValue().get(redisKey); - if (likeCount != null) { - return likeCount; //cache hit - } - - // cache miss 캐시에 없으면 DB값을 캐시에 저장 후 리턴 - redisTemplate.opsForValue().set(redisKey, dbLikeCount, TTL); - return dbLikeCount; - } - - @Override - public Map getAllLikeCounts() { - // 모든 좋아요 카운트 키 검색 - Set keys = redisTemplate.keys(postLikeCountPrefix + "*"); - if (keys == null || keys.isEmpty()) { - return Collections.emptyMap(); - } - - List values = redisTemplate.opsForValue().multiGet(keys); - - Map result = new HashMap<>(); - List keyList = new ArrayList<>(keys); - - // key와 value를 매핑하여 맵 생성 - for (int i = 0; i < keyList.size(); i++) { - if (values.get(i) != null) { - result.put(keyList.get(i), values.get(i)); - } - } - return result; //각 키의 좋아요 수를 맵으로 반환 - } - - @Override - public void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike) { - String redisKey = makeRedisKey(postType, postId); - // 키가 없으면 getLikeCount로 초기화 - if (!redisTemplate.hasKey(redisKey)) { - getLikeCount(postType,postId,likeCount); - } - if(isLike) incrementLikeCount(postType,postId); - else decrementLikeCount(postType,postId); - } - - @Override - public void bulkResetLikeCounts(Set keysToReset) { - if (keysToReset.isEmpty()) { - return; - } - - // Pipeline을 사용하여 일괄적으로 값을 0으로 설정 - redisTemplate.executePipelined((RedisCallback) connection -> { - for (String key : keysToReset) { - // connection.set(key.getBytes(StandardCharsets.UTF_8), "0".getBytes(StandardCharsets.UTF_8)); - // RedisTemplate를 사용하므로 OpsForValue().set 사용 - redisTemplate.opsForValue().set(key, 0); - } - return null; - }); - } - - private void incrementLikeCount(PostType postType, Long postId) { - String redisKey = makeRedisKey(postType, postId); - redisTemplate.opsForValue().increment(redisKey); - redisTemplate.expire(redisKey, TTL); - } - - private void decrementLikeCount(PostType postType, Long postId) { - String redisKey = makeRedisKey(postType, postId); - redisTemplate.opsForValue().decrement(redisKey); - redisTemplate.expire(redisKey, TTL); - } - - private String makeRedisKey(PostType type, Long postId) { - return postLikeCountPrefix + type.name() + ":" + postId; - } - -} diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java deleted file mode 100644 index f132715ee..000000000 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisCommandPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package konkuk.thip.post.application.port.out; - -import java.util.Set; -import konkuk.thip.post.domain.PostType; - -public interface PostLikeCountRedisCommandPort { - void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike); - void bulkResetLikeCounts(Set keysToReset); -} diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java deleted file mode 100644 index 805b212a6..000000000 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeCountRedisQueryPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package konkuk.thip.post.application.port.out; - -import java.util.Map; -import konkuk.thip.post.domain.PostType; - -public interface PostLikeCountRedisQueryPort { - Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount); - Map getAllLikeCounts(); -} From 6ed0014c961f3302d1f7f95b30d654c65519f83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:25:56 +0900 Subject: [PATCH 37/53] =?UTF-8?q?[rename]=20PostLikeCountRedis***=20->=20P?= =?UTF-8?q?ostLikeRedis=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BDr=20(#339?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeCountSyncToDBService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java index 48a38df5b..cc8f983df 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -6,8 +6,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; -import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; +import konkuk.thip.post.application.port.out.PostLikeRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisQueryPort; import konkuk.thip.post.application.service.handler.PostHandler; import konkuk.thip.post.domain.PostType; import lombok.RequiredArgsConstructor; @@ -20,8 +20,8 @@ @RequiredArgsConstructor public class PostLikeCountSyncToDBService { - private final PostLikeCountRedisQueryPort redisQueryPort; - private final PostLikeCountRedisCommandPort redisCommandPort; + private final PostLikeRedisQueryPort redisQueryPort; + private final PostLikeRedisCommandPort redisCommandPort; private final PostHandler postHandler; @Value("${app.redis.post-like-count-prefix}") From 9aae94f5c04ed540ac27010189ac83edb3b65557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:27:51 +0900 Subject: [PATCH 38/53] =?UTF-8?q?[feat]=20=EC=A2=8B=EC=95=84=EC=9A=94/?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=9B=84=EC=86=8D=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Output=20Port=EC=9D=B8=20PostLikeEventComm?= =?UTF-8?q?andPort=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/out/PostLikeEventCommandPort.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java new file mode 100644 index 000000000..5460118e0 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java @@ -0,0 +1,10 @@ +package konkuk.thip.post.application.port.out; + +import konkuk.thip.post.domain.PostType; + +public interface PostLikeEventCommandPort { + void publishEvent( + Long userId, Long postId, boolean isLike, // true: 좋아요, false: 좋아요 취소 + PostType postType, int finalLikeCount // DB에 동기화되어야 할 최종 카운트 + ); +} From 3505110df66e0e0f86a70f6a1d635b3010f70f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:30:27 +0900 Subject: [PATCH 39/53] =?UTF-8?q?[feat]=20PostLikeEventCommandPort?= =?UTF-8?q?=EC=9D=98=20=EA=B5=AC=ED=98=84=EC=B2=B4=20PostLikeEventSyncAdap?= =?UTF-8?q?ter=20=EA=B5=AC=ED=98=84=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 발행: Service 계층의 요청을 받아 PostLikeChangedEvent 발행 - 이벤트 리스너: DB 커밋 성공 후 전용 스레드('postLikeAsyncExecutor')에서 후속 작업 처리 리스너 내부에서 Redis Set/Count 갱신, Queue 삽입 순차적으로 실행 --- .../out/event/PostLikeEventSyncAdapter.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java new file mode 100644 index 000000000..5fb09216c --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java @@ -0,0 +1,58 @@ +package konkuk.thip.post.adapter.out.event; + +import konkuk.thip.post.adapter.out.event.dto.PostLikeChangedEvent; +import konkuk.thip.post.application.port.out.PostLikeEventCommandPort; +import konkuk.thip.post.application.port.out.PostLikeQueueCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisCommandPort; +import konkuk.thip.post.domain.PostType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostLikeEventSyncAdapter implements PostLikeEventCommandPort { + + private final ApplicationEventPublisher publisher; + + private final PostLikeRedisCommandPort postLikeRedisCommandPort; + private final PostLikeQueueCommandPort postLikeQueueCommandPort; + + @Override + public void publishEvent(Long userId, Long postId, boolean isLike, PostType postType, int finalLikeCount) { + publisher.publishEvent(PostLikeChangedEvent.builder() + .userId(userId) + .postId(postId) + .isLike(isLike) + .postType(postType) + .finalLikeCount(finalLikeCount) + .build()); + } + + @Async("postLikeAsyncExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + protected void handlePostLikeChangedEvent(PostLikeChangedEvent event) { + // 1. Redis Set 기록 (좋아요 상태 반영) + if (event.isLike()) { + postLikeRedisCommandPort.addLikeRecordToSet(event.userId(), event.postId()); + } else { + postLikeRedisCommandPort.removeLikeRecordFromSet(event.userId(), event.postId()); + } + + // 2. Redis 카운트 갱신 (INCR/DECR) + postLikeRedisCommandPort.updateLikeCount(event.postType(), event.postId(), + event.finalLikeCount(), event.isLike()); + + // 3. 비동기 DB 기록 메시지를 큐에 삽입 (Redis List LPUSH) + try { + postLikeQueueCommandPort.enqueueFromEvent(event); + } catch (Exception e) { + log.error("CRITICAL: Failed to publish Like Record to Queue. Event Data: {}", event, e); + } + } +} From ec0f5a166a2ba3a8f6274697eb5445c9ebe7789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:32:19 +0900 Subject: [PATCH 40/53] =?UTF-8?q?[feat]=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=ED=81=90=EC=9E=85=20=EC=82=BD=EC=9E=85=20PostLikeQueueCommandP?= =?UTF-8?q?ort=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/out/PostLikeQueueCommandPort.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java new file mode 100644 index 000000000..7b014d17d --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java @@ -0,0 +1,7 @@ +package konkuk.thip.post.application.port.out; + +import konkuk.thip.post.adapter.out.event.dto.PostLikeChangedEvent; + +public interface PostLikeQueueCommandPort { + void enqueueFromEvent(PostLikeChangedEvent event); +} \ No newline at end of file From ac12ae8dc224ba75568891a1fa9fb9102f260dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:32:45 +0900 Subject: [PATCH 41/53] =?UTF-8?q?[feat]=20=EB=A9=94=EC=84=B8=EC=A7=80=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=20PostLikeQueueConsumerPort=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EC=9D=98=20(#3?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/out/PostLikeQueueConsumerPort.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java new file mode 100644 index 000000000..8795c14b3 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java @@ -0,0 +1,8 @@ +package konkuk.thip.post.application.port.out; + +import java.util.Optional; +import konkuk.thip.post.application.port.out.dto.PostLikeQueueMessage; + +public interface PostLikeQueueConsumerPort { + Optional consumeOne(); +} From 485c288f43d3ab265e73ac286e6ebcbd730c9407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 18:33:18 +0900 Subject: [PATCH 42/53] =?UTF-8?q?[feat]=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=ED=81=90=EC=97=90=20=EC=82=BD=EC=9E=85=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20PostLikeQueueMessage=20dto=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/dto/PostLikeQueueMessage.java | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/dto/PostLikeQueueMessage.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/dto/PostLikeQueueMessage.java b/src/main/java/konkuk/thip/post/application/port/out/dto/PostLikeQueueMessage.java new file mode 100644 index 000000000..5b0421953 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/dto/PostLikeQueueMessage.java @@ -0,0 +1,11 @@ +package konkuk.thip.post.application.port.out.dto; + +import konkuk.thip.post.domain.PostType; + +public record PostLikeQueueMessage( + Long userId, + Long postId, + String action, // "SAVE" or "DELETE" + PostType postType +) { +} \ No newline at end of file From d7012e6bd19381b2a9fa2f5a6aa904ac6cbc490f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 19:24:23 +0900 Subject: [PATCH 43/53] =?UTF-8?q?[feat]=20PostLikeQueueCommand/ConsumerPor?= =?UTF-8?q?t=20=EA=B5=AC=ED=98=84=EC=B2=B4=20PostLikeQueueRedisAdapter=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis List를 활용한 비동기 좋아요 큐 Adapter 구현 - 발행 (Enqueue): enqueueFromEvent()에서 PostLikeChangedEvent를 받아 PostLikeQueueMessage로 변환하고, JSON 직렬화 후 Redis List에 LPUSH하여 메시지 삽입. - 소비 (Consume): consumeOne 메서드에서 Redis List의 Tail에서 메시지를 BRPOP으로 꺼내 역직렬화 --- .../PostLikeQueueRedisAdapter.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java new file mode 100644 index 000000000..81f9bcd5a --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java @@ -0,0 +1,63 @@ +package konkuk.thip.post.adapter.out.persistence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.Optional; +import konkuk.thip.post.adapter.out.event.dto.PostLikeChangedEvent; +import konkuk.thip.post.application.port.out.PostLikeQueueCommandPort; +import konkuk.thip.post.application.port.out.PostLikeQueueConsumerPort; +import konkuk.thip.post.application.port.out.dto.PostLikeQueueMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostLikeQueueRedisAdapter implements PostLikeQueueCommandPort, PostLikeQueueConsumerPort { + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + + @Value("${app.redis.post-like-queue-prefix}") + private String postLikeQueuePrefix; + + @Override + public void enqueueFromEvent(PostLikeChangedEvent event) { + PostLikeQueueMessage message = new PostLikeQueueMessage( + event.userId(), + event.postId(), + event.isLike() ? "SAVE" : "DELETE", + event.postType() + ); + + try { + String jsonRecord = objectMapper.writeValueAsString(message); + stringRedisTemplate.opsForList().leftPush(postLikeQueuePrefix, jsonRecord); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize and publish event to queue.", e); + } + } + + @Override + public Optional consumeOne() { + // BRPOP: 큐에 메시지가 있을 때까지 최대 1초 블로킹 대기 + String jsonRecord = stringRedisTemplate.opsForList().rightPop(postLikeQueuePrefix, Duration.ofSeconds(1)); + + if (jsonRecord == null) { + return Optional.empty(); + } + + try { + PostLikeQueueMessage message = objectMapper.readValue(jsonRecord, PostLikeQueueMessage.class); + return Optional.of(message); + } catch (Exception e) { + log.error("Failed to deserialize like record: {}", jsonRecord, e); + // 디시리얼라이즈 실패 메시지는 무시하고 다음 메시지를 시도하거나, DLQ로 보내야 함. + // 여기서는 유실을 가정하고 Optional.empty() 반환 + return Optional.empty(); + } + } +} \ No newline at end of file From 4614116f8acfc20fb7dc576ba103bf36ff150956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:02:11 +0900 Subject: [PATCH 44/53] =?UTF-8?q?[feat]=20=EB=B0=B1=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=EC=9B=8C=EC=BB=A4=20PostLikeRecordSyncToD?= =?UTF-8?q?BService=20=EC=9E=91=EC=84=B1=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Scheduled(fixedDelay = 1000)를 통해 1초마다 트랜잭션을 시작하고 큐 소비 시도 - PostLikeQueueConsumerPort를 통해 메시지를 가져옴, 큐가 비어있지 않은 동안 루프를 반복하여 메시지 일괄처리 -메시지의 action 필드에 따라 PostLikeCommandPort를 호출하여 DB에 INSERT 또는 DELETE 작업 실행 --- .../PostLikeRecordSyncToDBService.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java new file mode 100644 index 000000000..cbf9fb89b --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java @@ -0,0 +1,38 @@ +package konkuk.thip.post.application.service; + +import java.util.Optional; +import konkuk.thip.post.application.port.out.PostLikeCommandPort; +import konkuk.thip.post.application.port.out.PostLikeQueueConsumerPort; +import konkuk.thip.post.application.port.out.dto.PostLikeQueueMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class PostLikeRecordSyncToDBService { + + private final PostLikeCommandPort postLikeCommandPort; + private final PostLikeQueueConsumerPort postLikeQueueConsumerPort; + + @Scheduled(fixedDelay = 1000) + @Transactional + public void syncRecordsFromQueue() { + Optional messageOptional = postLikeQueueConsumerPort.consumeOne(); + + while (messageOptional.isPresent()) { + PostLikeQueueMessage command = messageOptional.get(); + + // DB에 실제 INSERT/DELETE 작업 수행 + if ("SAVE".equals(command.action())) { + postLikeCommandPort.save(command.userId(), command.postId(), command.postType()); + } else if ("DELETE".equals(command.action())) { + postLikeCommandPort.delete(command.userId(), command.postId()); + } + + // 다음 메시지 가져오기 + messageOptional = postLikeQueueConsumerPort.consumeOne(); + } + } +} \ No newline at end of file From 4fea9860fde167d269e953589ef6c9424afe5c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:04:13 +0900 Subject: [PATCH 45/53] =?UTF-8?q?[feat]=20PostLikeRedisCommand/QueryPort?= =?UTF-8?q?=EC=97=90=20set=20=EC=97=B0=EC=82=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/PostLikeRedisCommandPort.java | 11 +++++++++++ .../application/port/out/PostLikeRedisQueryPort.java | 10 ++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java create mode 100644 src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java new file mode 100644 index 000000000..075426808 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java @@ -0,0 +1,11 @@ +package konkuk.thip.post.application.port.out; + +import java.util.Set; +import konkuk.thip.post.domain.PostType; + +public interface PostLikeRedisCommandPort { + void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike); + void bulkResetLikeCounts(Set keysToReset); + void addLikeRecordToSet(Long userId, Long postId); + void removeLikeRecordFromSet(Long userId, Long postId); +} diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java new file mode 100644 index 000000000..1a1e1e712 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java @@ -0,0 +1,10 @@ +package konkuk.thip.post.application.port.out; + +import java.util.Map; +import konkuk.thip.post.domain.PostType; + +public interface PostLikeRedisQueryPort { + Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount); + Map getAllLikeCounts(); + boolean isLikedPostByUser(Long userId, Long postId); +} From 5270bef39ffb4b29e35790d2fd76b14172b91566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:05:25 +0900 Subject: [PATCH 46/53] =?UTF-8?q?[feat]=20PostLikeRedisAdapter=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=EC=97=90=20redis=20set=20=EC=97=B0=EC=82=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/PostLikeRedisAdapter.java | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java new file mode 100644 index 000000000..8f3aef666 --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java @@ -0,0 +1,139 @@ +package konkuk.thip.post.adapter.out.persistence; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import konkuk.thip.post.application.port.out.PostLikeRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisQueryPort; +import konkuk.thip.post.domain.PostType; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +@Component +@RequiredArgsConstructor +public class PostLikeRedisAdapter implements PostLikeRedisCommandPort, PostLikeRedisQueryPort { + + private final RedisTemplate redisTemplate; //카운트용 + private final StringRedisTemplate stringRedisTemplate; // Set 기록용 + private static final Duration TTL = Duration.ofMinutes(10); + + @Value("${app.redis.post-like-count-prefix}") + private String postLikeCountPrefix; + @Value("${app.redis.post-like-record-prefix}") + private String postLikeRecordPrefix; + + @Override + public Integer getLikeCount(PostType postType, Long postId, Integer dbLikeCount) { + String redisKey = makeLikeCountRedisKey(postType, postId); + Integer likeCount = redisTemplate.opsForValue().get(redisKey); + if (likeCount != null) { + return likeCount; //cache hit + } + + // cache miss 캐시에 없으면 DB값을 캐시에 저장 후 리턴 + redisTemplate.opsForValue().set(redisKey, dbLikeCount, TTL); + return dbLikeCount; + } + + @Override + public Map getAllLikeCounts() { + // 모든 좋아요 카운트 키 검색 + Set keys = redisTemplate.keys(postLikeCountPrefix + "*"); + if (keys == null || keys.isEmpty()) { + return Collections.emptyMap(); + } + + List values = redisTemplate.opsForValue().multiGet(keys); + + Map result = new HashMap<>(); + List keyList = new ArrayList<>(keys); + + // key와 value를 매핑하여 맵 생성 + for (int i = 0; i < keyList.size(); i++) { + if (values.get(i) != null) { + result.put(keyList.get(i), values.get(i)); + } + } + return result; //각 키의 좋아요 수를 맵으로 반환 + } + + @Override + public void updateLikeCount(PostType postType, Long postId, Integer likeCount, boolean isLike) { + String redisKey = makeLikeCountRedisKey(postType, postId); + // 키가 없으면 getLikeCount로 초기화 + if (!redisTemplate.hasKey(redisKey)) { + getLikeCount(postType,postId,likeCount); + } + if(isLike) incrementLikeCount(postType,postId); + else decrementLikeCount(postType,postId); + } + + @Override + public void bulkResetLikeCounts(Set keysToReset) { + if (keysToReset.isEmpty()) { + return; + } + + // Pipeline을 사용하여 일괄적으로 값을 0으로 설정 + redisTemplate.executePipelined((RedisCallback) connection -> { + for (String key : keysToReset) { + redisTemplate.opsForValue().set(key, 0); + } + return null; + }); + } + + @Override + public boolean isLikedPostByUser(Long userId, Long postId) { + // Post ID별로 Set을 구성하고, Set 안에 userId가 있는지 확인합니다. + String recordKey = makeRecordRedisKey(postId); + + // SISMEMBER 명령어 실행: Set 안에 userId(멤버)가 존재하는지 확인 + // redisTemplate.opsForSet().isMember(key, member)는 Boolean을 반환합니다. + Boolean isMember = stringRedisTemplate.opsForSet().isMember(recordKey, userId.toString()); + + return isMember != null && isMember; + } + + @Override + public void addLikeRecordToSet(Long userId, Long postId) { + String recordKey = makeRecordRedisKey(postId); + // SADD 명령어: Set에 userId를 추가 + stringRedisTemplate.opsForSet().add(recordKey, userId.toString()); + } + + @Override + public void removeLikeRecordFromSet(Long userId, Long postId) { + String recordKey = makeRecordRedisKey(postId); + // SREM 명령어: Set에서 userId를 제거 + stringRedisTemplate.opsForSet().remove(recordKey, userId.toString()); + } + + private void incrementLikeCount(PostType postType, Long postId) { + String redisKey = makeLikeCountRedisKey(postType, postId); + redisTemplate.opsForValue().increment(redisKey); + redisTemplate.expire(redisKey, TTL); + } + + private void decrementLikeCount(PostType postType, Long postId) { + String redisKey = makeLikeCountRedisKey(postType, postId); + redisTemplate.opsForValue().decrement(redisKey); + redisTemplate.expire(redisKey, TTL); + } + + private String makeLikeCountRedisKey(PostType type, Long postId) { + return postLikeCountPrefix + type.name() + ":" + postId; + } + + private String makeRecordRedisKey(Long postId) { + return postLikeRecordPrefix + postId; + } + +} From e54c50e2b7c07d7b58774dc53a436ae5a37275df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:06:07 +0900 Subject: [PATCH 47/53] =?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=20Redis=20List=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=ED=81=90=EC=9E=89=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(DB=20Write=20=EB=B6=80=ED=95=98=20=EB=B6=84=EC=82=B0)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeService.java | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 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 781170511..0eb9bd9e0 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -2,16 +2,14 @@ import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; -import konkuk.thip.post.application.port.out.PostLikeCountRedisCommandPort; -import konkuk.thip.post.application.port.out.PostLikeCountRedisQueryPort; +import konkuk.thip.post.application.port.out.PostLikeEventCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisQueryPort; import konkuk.thip.post.application.port.out.dto.PostQueryDto; import konkuk.thip.post.application.service.handler.PostHandler; import konkuk.thip.post.domain.CountUpdatable; import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; import konkuk.thip.post.application.port.in.dto.PostIsLikeResult; import konkuk.thip.post.application.port.in.PostLikeUseCase; -import konkuk.thip.post.application.port.out.PostLikeCommandPort; -import konkuk.thip.post.application.port.out.PostLikeQueryPort; import konkuk.thip.post.application.service.validator.PostLikeAuthorizationValidator; import konkuk.thip.post.domain.service.PostCountService; import konkuk.thip.user.application.port.out.UserCommandPort; @@ -24,11 +22,8 @@ @RequiredArgsConstructor public class PostLikeService implements PostLikeUseCase { - private final PostLikeQueryPort postLikeQueryPort; - private final PostLikeCommandPort postLikeCommandPort; private final UserCommandPort userCommandPort; - private final PostLikeCountRedisCommandPort postLikeCountRedisCommandPort; - private final PostLikeCountRedisQueryPort postLikeCountRedisQueryPort; + private final PostLikeRedisQueryPort postLikeRedisQueryPort; private final PostHandler postHandler; private final PostCountService postCountService; @@ -36,6 +31,7 @@ public class PostLikeService implements PostLikeUseCase { private final FeedNotificationOrchestrator feedNotificationOrchestrator; private final RoomNotificationOrchestrator roomNotificationOrchestrator; + private final PostLikeEventCommandPort postLikeEventCommandPort; @Override @Transactional @@ -47,26 +43,24 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { postLikeAuthorizationValidator.validateUserCanAccessPostLike(command.postType(), post, command.userId()); // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회 - boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId()); + boolean alreadyLiked = postLikeRedisQueryPort.isLikedPostByUser(command.userId(), command.postId()); - // 3. 좋아요 상태변경 + // 3. 좋아요 가능 여부 검증 if (command.isLike()) { - postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 - postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); - + postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 푸쉬알림 전송 //sendNotifications(command); } else { - postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 - postLikeCommandPort.delete(command.userId(), command.postId()); + postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); } - // 4. 게시물 좋아요 수 - int redisLikeCount = postLikeCountRedisQueryPort.getLikeCount(command.postType(),post.getId(), post.getLikeCount()); - post.updateLikeCount(postCountService,command.isLike(), redisLikeCount); // 도메인 상태 갱신 (외부에서 읽은 최신값 주입) - // 4-1. 좋아요 수를 Redis INCR/DECR 이용해 원자적으로 갱신 - postLikeCountRedisCommandPort.updateLikeCount(command.postType(),post.getId(),post.getLikeCount(),command.isLike()); + // 4. 도메인 상태 갱신 (게시물 좋아요 수) + int redisLikeCount = postLikeRedisQueryPort.getLikeCount(command.postType(), post.getId(), post.getLikeCount()); + post.updateLikeCount(postCountService, command.isLike(), redisLikeCount); // 도메인 상태 갱신 (외부에서 읽은 최신값 주입) + // 5. 트랜잭션 후속 처리 (DB 커밋 성공 후 비동기 작업을 위한 이벤트 발행) + postLikeEventCommandPort.publishEvent(command.userId(), command.postId(), command.isLike(), + command.postType(), post.getLikeCount()); return PostIsLikeResult.of(post.getId(), command.isLike()); } From bd73f80da5d7db0f609f04c0bf47fdac1918d7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:08:16 +0900 Subject: [PATCH 48/53] =?UTF-8?q?[refactor]=20-=20WorkerThreadConfig?= =?UTF-8?q?=EC=97=90=20PostLike=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=8B=A4=ED=96=89=EA=B8=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=95=88=EC=A0=95=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20CGLIB=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=95=EC=A0=9C=20=EC=A0=81=EC=9A=A9=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/config/WorkerThreadConfig.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/config/WorkerThreadConfig.java b/src/main/java/konkuk/thip/config/WorkerThreadConfig.java index 0f32626ef..66243eee2 100644 --- a/src/main/java/konkuk/thip/config/WorkerThreadConfig.java +++ b/src/main/java/konkuk/thip/config/WorkerThreadConfig.java @@ -13,7 +13,7 @@ import java.util.concurrent.ThreadPoolExecutor; @Configuration -@EnableAsync +@EnableAsync(proxyTargetClass = true) @Profile("!test") public class WorkerThreadConfig implements AsyncConfigurer { @@ -54,6 +54,23 @@ public Executor schedulerAsyncExecutor() { return executor; } + /** + * PostLike 이벤트 전용 실행기 + * - 좋아요/취소 시 Redis Set/Count 갱신 및 큐 발행 처리 + * - 순간적인 높은 트래픽에 대비하여 큐 용량 확보 + */ + @Bean(name = "postLikeAsyncExecutor") + public Executor postLikeAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(30); + executor.setQueueCapacity(300); + executor.setThreadNamePrefix("like-async-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.initialize(); + return executor; + } + @Override public Executor getAsyncExecutor() { return fcmAsyncExecutor(); // 기본은 FCM 풀 사용 From 11b083392e4ef31ed95aecd22b1686c8eee69dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:12:27 +0900 Subject: [PATCH 49/53] =?UTF-8?q?[test]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80(=EB=8F=84=EB=A9=94=EC=9D=B8)=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateLikeCount()의newLikeCount 매개변수가 추가되면서 테스트코드 수정 --- .../java/konkuk/thip/feed/domain/FeedTest.java | 14 +++++++------- .../konkuk/thip/roompost/domain/RecordTest.java | 14 +++++++------- .../java/konkuk/thip/roompost/domain/VoteTest.java | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/test/java/konkuk/thip/feed/domain/FeedTest.java b/src/test/java/konkuk/thip/feed/domain/FeedTest.java index e4aa33d34..44e3140a6 100644 --- a/src/test/java/konkuk/thip/feed/domain/FeedTest.java +++ b/src/test/java/konkuk/thip/feed/domain/FeedTest.java @@ -227,10 +227,10 @@ private Feed makeFeedWithPublicStatus(Boolean isPublic) { void updateLikeCount_likeTrue_increments() { Feed feed = createPublicFeed(); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(1, feed.getLikeCount()); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(2, feed.getLikeCount()); } @@ -239,14 +239,14 @@ void updateLikeCount_likeTrue_increments() { void updateLikeCount_likeFalse_decrements() { Feed feed = createPublicFeed(); // 먼저 likeCount 증가 셋업 - feed.updateLikeCount(postCountService, true); - feed.updateLikeCount(postCountService, true); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); + feed.updateLikeCount(postCountService, true,feed.getLikeCount()); assertEquals(2, feed.getLikeCount()); - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()); assertEquals(1, feed.getLikeCount()); - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()); assertEquals(0, feed.getLikeCount()); } @@ -257,7 +257,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, feed.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - feed.updateLikeCount(postCountService, false); + feed.updateLikeCount(postCountService, false,feed.getLikeCount()-1); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); diff --git a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java index e7719eff1..b2c819068 100644 --- a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java +++ b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java @@ -143,10 +143,10 @@ void decreaseCommentCount_belowZero_throws() { void updateLikeCount_likeTrue_increments() { konkuk.thip.roompost.domain.Record record = createWithCommentRecord(); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(1, record.getLikeCount()); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(2, record.getLikeCount()); } @@ -156,14 +156,14 @@ void updateLikeCount_likeFalse_decrements() { konkuk.thip.roompost.domain.Record record = createWithCommentRecord(); // 먼저 likeCount 증가 셋업 - record.updateLikeCount(postCountService,true); - record.updateLikeCount(postCountService,true); + record.updateLikeCount(postCountService,true,record.getLikeCount()); + record.updateLikeCount(postCountService,true,record.getLikeCount()); assertEquals(2, record.getLikeCount()); - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()); assertEquals(1, record.getLikeCount()); - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()); assertEquals(0, record.getLikeCount()); } @@ -174,7 +174,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, record.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - record.updateLikeCount(postCountService,false); + record.updateLikeCount(postCountService,false,record.getLikeCount()-1); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); diff --git a/src/test/java/konkuk/thip/roompost/domain/VoteTest.java b/src/test/java/konkuk/thip/roompost/domain/VoteTest.java index 7383fdd3a..872d8a7cd 100644 --- a/src/test/java/konkuk/thip/roompost/domain/VoteTest.java +++ b/src/test/java/konkuk/thip/roompost/domain/VoteTest.java @@ -142,10 +142,10 @@ void decreaseCommentCount_belowZero_throws() { void updateLikeCount_likeTrue_increments() { Vote vote = createWithCommentVote(); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(1, vote.getLikeCount()); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(2, vote.getLikeCount()); } @@ -155,14 +155,14 @@ void updateLikeCount_likeFalse_decrements() { Vote vote = createWithCommentVote(); // 먼저 likeCount 증가 셋업 - vote.updateLikeCount(postCountService,true); - vote.updateLikeCount(postCountService,true); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); + vote.updateLikeCount(postCountService,true,vote.getLikeCount()); assertEquals(2, vote.getLikeCount()); - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); assertEquals(1, vote.getLikeCount()); - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); assertEquals(0, vote.getLikeCount()); } @@ -173,7 +173,7 @@ void updateLikeCount_likeFalse_underflow_throws() { assertEquals(0, vote.getLikeCount()); InvalidStateException ex = assertThrows(InvalidStateException.class, () -> { - vote.updateLikeCount(postCountService,false); + vote.updateLikeCount(postCountService,false,vote.getLikeCount()); }); assertEquals(POST_LIKE_COUNT_UNDERFLOW, ex.getErrorCode()); From a2d8f1b308b3ce193ead827387d6958a0e09cbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:20:39 +0900 Subject: [PATCH 50/53] =?UTF-8?q?[refactor]=20DB=20=EC=94=BD=ED=81=AC?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EB=8A=94=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=EB=8A=94=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=98=EC=A7=80=EC=95=8A=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20@=20Profile("!test")=20=EC=A0=81=EC=9A=A9=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PostLikeCountSyncToDBService.java | 2 ++ .../application/service/PostLikeRecordSyncToDBService.java | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java index cc8f983df..8bb2f8241 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -12,12 +12,14 @@ import konkuk.thip.post.domain.PostType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Profile("!test") public class PostLikeCountSyncToDBService { private final PostLikeRedisQueryPort redisQueryPort; diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java index cbf9fb89b..93f8887c5 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java @@ -5,12 +5,14 @@ import konkuk.thip.post.application.port.out.PostLikeQueueConsumerPort; import konkuk.thip.post.application.port.out.dto.PostLikeQueueMessage; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Component +@Service @RequiredArgsConstructor +@Profile("!test") public class PostLikeRecordSyncToDBService { private final PostLikeCommandPort postLikeCommandPort; From 8f48fdd20fed3d83e39fe53e6ff5b8ff7acd587b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Wed, 10 Dec 2025 20:22:11 +0900 Subject: [PATCH 51/53] =?UTF-8?q?[test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20-=20TestAsyncConfig?= =?UTF-8?q?=EC=97=90=20PostLike=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=8B=A4=ED=96=89=EA=B8=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=95=88=EC=A0=95=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20CGLIB=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=95=EC=A0=9C=20=EC=A0=81=EC=9A=A9=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/konkuk/thip/config/TestAsyncConfig.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/java/konkuk/thip/config/TestAsyncConfig.java b/src/test/java/konkuk/thip/config/TestAsyncConfig.java index e6b2d97cd..cc1e739fe 100644 --- a/src/test/java/konkuk/thip/config/TestAsyncConfig.java +++ b/src/test/java/konkuk/thip/config/TestAsyncConfig.java @@ -10,7 +10,7 @@ import java.util.concurrent.Executor; // 테스트용: 동기 실행 강제 -@EnableAsync +@EnableAsync(proxyTargetClass = true) @Configuration @Profile("test") public class TestAsyncConfig implements AsyncConfigurer { @@ -25,6 +25,11 @@ public Executor schedulerAsyncExecutor() { return new SyncTaskExecutor(); } + @Bean(name = "postLikeAsyncExecutor") + public Executor postLikeAsyncExecutor() { + return new SyncTaskExecutor(); + } + @Override public Executor getAsyncExecutor() { return new SyncTaskExecutor(); From 12ab6799e0123a0045609f8ddd246e67f071a342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Thu, 11 Dec 2025 01:34:47 +0900 Subject: [PATCH 52/53] =?UTF-8?q?[chore]=20=EC=A3=BC=EC=84=9D=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/adapter/out/persistence/PostLikeRedisAdapter.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java index 8f3aef666..0080ca313 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java @@ -92,11 +92,9 @@ public void bulkResetLikeCounts(Set keysToReset) { @Override public boolean isLikedPostByUser(Long userId, Long postId) { - // Post ID별로 Set을 구성하고, Set 안에 userId가 있는지 확인합니다. String recordKey = makeRecordRedisKey(postId); - // SISMEMBER 명령어 실행: Set 안에 userId(멤버)가 존재하는지 확인 - // redisTemplate.opsForSet().isMember(key, member)는 Boolean을 반환합니다. + // SISMEMBER : Set 안에 userId(멤버)가 존재하는지 확인 Boolean isMember = stringRedisTemplate.opsForSet().isMember(recordKey, userId.toString()); return isMember != null && isMember; From 360e8d383c7a26be9b9091849881af0380e35d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=ED=9D=AC=EC=A7=84?= Date: Thu, 11 Dec 2025 01:35:10 +0900 Subject: [PATCH 53/53] =?UTF-8?q?[test]=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=A7=9E=EA=B2=8C=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?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/FeedChangeLikeStatusApiTest.java | 37 ++++++-- .../web/RoomPostChangeLikeStatusApiTest.java | 84 +++++++++++++++---- 2 files changed, 100 insertions(+), 21 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 899de1d8c..7ac8c9941 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 @@ -8,7 +8,8 @@ import konkuk.thip.feed.adapter.in.web.request.FeedIsLikeRequest; 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.out.PostLikeRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisQueryPort; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; @@ -21,6 +22,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -45,8 +47,9 @@ class FeedChangeLikeStatusApiTest { @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; - @Autowired private PostLikeJpaRepository postLikeJpaRepository; @Autowired private RedisTemplate redisTemplate; + @Autowired private PostLikeRedisCommandPort postLikeRedisCommandPort; + @Autowired private PostLikeRedisQueryPort postLikeRedisQueryPort; private UserJpaEntity user; @@ -85,8 +88,19 @@ void likeFeed_Success() throws Exception { .andExpect(jsonPath("$.data.feedId").value(feed.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(true)); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + // 좋아요 저장 여부 확인 - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),feed.getPostId()); assertThat(liked).isTrue(); // // 좋아요 카운트 증가 확인 @@ -99,7 +113,7 @@ void likeFeed_Success() throws Exception { void likeFeed_AlreadyLiked_Fail() throws Exception { // given: 미리 좋아요 저장 - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, feed)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),feed.getPostId()); FeedIsLikeRequest request = new FeedIsLikeRequest(true); // when & then @@ -116,7 +130,7 @@ void likeFeed_AlreadyLiked_Fail() throws Exception { void unlikeFeed_Success() throws Exception { // given: 좋아요가 저장되어 있고, likeCount도 1 반영 - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, feed)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),feed.getPostId()); feed.updateLikeCount(1); // 좋아요 1개로 세팅 feedJpaRepository.save(feed); @@ -132,8 +146,19 @@ void unlikeFeed_Success() throws Exception { .andExpect(jsonPath("$.data.feedId").value(feed.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(false)); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + // 좋아요 삭제 확인 - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(),feed.getPostId()); + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),feed.getPostId()); assertThat(liked).isFalse(); // // 좋아요 카운트 감소 확인 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 7e3f11536..e7c82c850 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 @@ -5,9 +5,8 @@ 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.out.PostLikeRedisCommandPort; +import konkuk.thip.post.application.port.out.PostLikeRedisQueryPort; import konkuk.thip.room.domain.value.Category; import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; @@ -30,6 +29,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -53,17 +53,16 @@ class RoomPostChangeLikeStatusApiTest { @Autowired private ObjectMapper objectMapper; @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; - @Autowired private FeedJpaRepository feedJpaRepository; - @Autowired private PostLikeJpaRepository postLikeJpaRepository; @Autowired private RoomJpaRepository roomJpaRepository; @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; @Autowired private RecordJpaRepository recordJpaRepository; @Autowired private VoteJpaRepository voteJpaRepository; @Autowired private RedisTemplate redisTemplate; + @Autowired private PostLikeRedisCommandPort postLikeRedisCommandPort; + @Autowired private PostLikeRedisQueryPort postLikeRedisQueryPort; private UserJpaEntity user; private BookJpaEntity book; - private FeedJpaEntity feed; private Category category; private RoomJpaEntity room; private RecordJpaEntity record; @@ -81,7 +80,6 @@ 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)); category = TestEntityFactory.createLiteratureCategory(); room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); // 1번방에 유저 1이 호스트 @@ -105,9 +103,23 @@ void likeRecordPost_Success() throws Exception { .andExpect(jsonPath("$.data.postId").value(record.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(true)); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + //then // 좋아요 저장 확인 - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),record.getPostId()); assertThat(liked).isTrue(); // // 좋아요 카운트 증가 확인 @@ -120,7 +132,7 @@ void likeRecordPost_Success() throws Exception { @DisplayName("이미 좋아요한 기록 게시물을 다시 좋아요하면 [400 에러 발생]") void likeRecordPost_AlreadyLiked_Fail() throws Exception { //given - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, record)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),record.getPostId()); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "RECORD"); //when & then @@ -136,7 +148,7 @@ void likeRecordPost_AlreadyLiked_Fail() throws Exception { @DisplayName("좋아요한 기록 게시물 좋아요 취소하면 좋아요 삭제 및 카운트 감소 [성공]") void unlikeRecordPost_Success() throws Exception { //given - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, record)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),record.getPostId()); record.updateLikeCount(1); recordJpaRepository.save(record); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); @@ -150,8 +162,22 @@ void unlikeRecordPost_Success() throws Exception { .andExpect(jsonPath("$.data.postId").value(record.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(false)); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + //then - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), record.getPostId()); + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),record.getPostId()); assertThat(liked).isFalse(); // RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); @@ -190,8 +216,22 @@ void likeVotePost_Success() throws Exception { .andExpect(jsonPath("$.data.postId").value(vote.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(true)); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + //then - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),vote.getPostId()); assertThat(liked).isTrue(); // VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); @@ -202,7 +242,7 @@ void likeVotePost_Success() throws Exception { @DisplayName("이미 좋아요한 투표 게시물을 다시 좋아요하면 [400 에러 발생]") void likeVotePost_AlreadyLiked_Fail() throws Exception { //given - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, vote)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),vote.getPostId()); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(true, "VOTE"); //when & then @@ -218,7 +258,7 @@ void likeVotePost_AlreadyLiked_Fail() throws Exception { @DisplayName("좋아요한 투표 게시물 좋아요 취소하면 좋아요 삭제 및 카운트 감소 [성공]") void unlikeVotePost_Success() throws Exception { //given - postLikeJpaRepository.save(TestEntityFactory.createPostLike(user, vote)); + postLikeRedisCommandPort.addLikeRecordToSet(user.getUserId(),vote.getPostId()); vote.updateLikeCount(1); voteJpaRepository.save(vote); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); @@ -232,7 +272,21 @@ void unlikeVotePost_Success() throws Exception { .andExpect(jsonPath("$.data.postId").value(vote.getPostId())) .andExpect(jsonPath("$.data.isLiked").value(false)); - boolean liked = postLikeJpaRepository.existsByUserIdAndPostId(user.getUserId(), vote.getPostId()); + // 트랜잭션을 강제로 커밋 (리스너 실행을 유발) + TestTransaction.flagForCommit(); + TestTransaction.end(); // AFTER_COMMIT 리스너 실행 + + // 트랜잭션이 커밋되었으므로, DB에 영구적으로 남은 데이터 수동 정리 + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + + TestTransaction.start(); // 다음 테스트를 위해 새 트랜잭션 시작 + + boolean liked = postLikeRedisQueryPort.isLikedPostByUser(user.getUserId(),vote.getPostId()); assertThat(liked).isFalse(); // VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow();