diff --git a/build.gradle b/build.gradle index cd2969af9..40cc629a7 100644 --- a/build.gradle +++ b/build.gradle @@ -101,6 +101,9 @@ dependencies { // Spring AI - Google AI(Gemini) 연동 implementation 'org.springframework.ai:spring-ai-starter-model-openai:1.0.1' + + // spring Retry + implementation 'org.springframework.retry:spring-retry' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/loadtest/feed/feed_like_concurrency_test1.js b/loadtest/feed/feed_like_concurrency_test1.js new file mode 100644 index 000000000..7165eed01 --- /dev/null +++ b/loadtest/feed/feed_like_concurrency_test1.js @@ -0,0 +1,79 @@ +// 낮은 동시성 20명이서 동시성 기능 안전성 테스트 +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 1; // 테스트할 피드 ID +const VUS = 20; // 원하는 VU 수 + +export let options = { + thresholds: { + // 요청 95%가 500ms 이내 응답을 받아야 함 + http_req_duration: ['p(95)<500'], + // 전체 요청 중 실패율 1% 미만이어야 함 + http_req_failed: ['rate<0.01'], + }, + vus: VUS, + duration: '30s', // 30초동안 테스트 +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + let tokens = []; + let likeStatus = []; + + // 유저 ID에 대해 토큰을 미리 발급 + for (let userId = 1; userId <= VUS; userId++) { + const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); + check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); + tokens.push(res.body); + likeStatus.push(true); // 좋아요 요청 + } + + return { tokens, likeStatus }; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + if (data.lastStatusCode === 200) { + data.likeStatus[vuIdx] = !data.likeStatus[vuIdx]; + } + + // FeedIsLikeRequest DTO에 맞는 요청 body + const payload = JSON.stringify({ + type: data.likeStatus[vuIdx], + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params); + data.lastStatusCode = res.status; + + // 응답 체크 + check(res, { + 'status 200': (r) => r.status === 200, + 'status 400': (r) => r.status === 400, + 'Internal server error': (r) => r.status === 500, + }); + + if (res.status !== 200) { + console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`); + } + + sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의 +} + +// 테스트 결과 html 리포트로 저장 +import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; +export function handleSummary(data) { + return { + "summary.html": htmlReport(data), + }; +} diff --git a/loadtest/feed/feed_like_concurrency_test2.js b/loadtest/feed/feed_like_concurrency_test2.js new file mode 100644 index 000000000..454b62da5 --- /dev/null +++ b/loadtest/feed/feed_like_concurrency_test2.js @@ -0,0 +1,83 @@ +// 점진적 부하 증가 (Ramp-up) VU: 20 → 50 → 100 → 150 (1분 단위로 증가) +import http from 'k6/http'; +import { sleep,check } from 'k6'; // sleep 기능 사용 시 추가 (sleep(n) -> 지정한 n 기간 동한 VU 실행을 일시 중지) + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 1; // 테스트할 피드 ID + +export let options = { + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, + stages: [ + { duration: '1m', target: 20 }, // 1분간 VU 20명으로 점진적 증가 + { duration: '1m', target: 50 }, // 1분간 VU 50명으로 증가 + { duration: '1m', target: 100 }, // 1분간 VU 100명으로 증가 + { duration: '1m', target: 150 }, // 1분간 VU 150명으로 증가 + { duration: '30s', target: 0 }, // 30초 동안 VU 0명으로 줄이며 테스트 종료 + ], +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + // 점진적 증가하는 최대 VU 수 계산 + const maxVUs = 150; + let tokens = []; + let likeStatus = []; + + // 유저 ID에 대해 토큰을 미리 발급 + for (let userId = 1; userId <= maxVUs; userId++) { + const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); + check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); + tokens.push(res.body); + likeStatus.push(true); // 좋아요 요청 + } + + return { tokens, likeStatus }; +} + +export default function (data) { + const vuIdx = __VU - 1; + const token = data.tokens[vuIdx]; + + if (data.lastStatusCode === 200) { + data.likeStatus[vuIdx] = !data.likeStatus[vuIdx]; + } + + // FeedIsLikeRequest DTO에 맞는 요청 body + const payload = JSON.stringify({ + type: data.likeStatus[vuIdx], + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }; + + const res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params); + data.lastStatusCode = res.status; + + // 응답 체크 + check(res, { + 'status 200': (r) => r.status === 200, + 'status 400': (r) => r.status === 400, + 'Internal server error': (r) => r.status === 500, + }); + + if (res.status !== 200) { + console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`); + } + + sleep(0.5); // 500ms 간격으로 요청, 일반적 사용자 환경 모의 +} + +// 테스트 결과 html 리포트로 저장 +import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; +export function handleSummary(data) { + return { + "summary.html": htmlReport(data), + }; +} diff --git a/loadtest/feed/feed_like_concurrency_test3.js b/loadtest/feed/feed_like_concurrency_test3.js new file mode 100644 index 000000000..b298aa445 --- /dev/null +++ b/loadtest/feed/feed_like_concurrency_test3.js @@ -0,0 +1,116 @@ +// 80%는 상세조회(GET), 20%는 좋아요 변경(POST) 요청 +import http from 'k6/http'; +import { sleep,check } from 'k6'; + +const BASE_URL = 'http://localhost:8080'; +const FEED_ID = 1; // 테스트할 피드 ID + +export let options = { + scenarios: { + read_scenario: { + executor: 'constant-vus', + vus: 160, // 전체 200명 중 160명은 상세 조회 전담 + duration: '2m', + exec: 'readFeed', + }, + write_scenario: { + executor: 'constant-vus', + vus: 40, // 전체 200명 중 20명은 좋아요 변경 전담 + duration: '2m', + exec: 'likeFeed', + }, + }, + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +// 테스트 전 사용자 별 토큰 발급 +export function setup() { + // 최대 VU 수 계산 + const maxVUs = 200; + let tokens = []; + + // 유저 ID에 대해 토큰을 미리 발급 + for (let userId = 1; userId <= maxVUs; userId++) { + const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); + check(res, { 'token received': (r) => r.status === 200 && r.body.length > 0 }); + tokens.push(res.body); + } + + return {tokens}; +} + +// 상세조회만 실행 +export function readFeed(data) { + let vuIdx = __VU - 1; + let token = data.tokens[vuIdx]; + let params = { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + } + }; + + let res = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); + check(res, { + 'feed detail 200': (r) => r.status === 200, + 'feed detail status 400': (r) => r.status === 400, + 'feed detail Internal server error': (r) => r.status === 500, + }); + + if (res.status !== 200) { + console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`); + } + + sleep(Math.random()); // 0~1초 내 랜덤 대기(실사용 패턴 반영) +} + +// 좋아요 변경만 실행 +export function likeFeed(data) { + let vuIdx = __VU - 1; + let token = data.tokens[vuIdx]; + let params = { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + } + }; + + // 상세 조회로 좋아요 상태 확인 + let getRes = http.get(`${BASE_URL}/feeds/${FEED_ID}`, params); + let isLiked = false; + if (getRes.status === 200) { + try { + let body = JSON.parse(getRes.body); + isLiked = body.data.isLiked; + } catch (e) { + console.error(`[VU${__VU}] 상세조회 파싱 오류:`, getRes.body); + } + } + + // 상태 반대로 좋아요 또는 취소 요청 + let payload = JSON.stringify({ type: !isLiked }); + let res = http.post(`${BASE_URL}/feeds/${FEED_ID}/likes`, payload, params); + + check(res, { + 'feed like 200': (r) => r.status === 200, + 'feed like status 400': (r) => r.status === 400, + 'feed like Internal server error': (r) => r.status === 500, + }); + + if (res.status !== 200) { + console.error(`[VU${__VU}] ERROR status=${res.status} body=${res.body}`); + } + + sleep(Math.random() + 0.5); // 0.5~1.5초 랜덤 대기 +} + +// 테스트 결과 html 리포트로 저장 +import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; +export function handleSummary(data) { + return { + "summary.html": htmlReport(data), + }; +} \ No newline at end of file diff --git a/loadtest/room_join_load_test.js b/loadtest/room/room_join_load_test.js similarity index 100% rename from loadtest/room_join_load_test.js rename to loadtest/room/room_join_load_test.js diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index 8a2d53b91..a90f106f9 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index b7fb384c0..38af91a62 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -31,6 +31,7 @@ public enum ErrorCode implements ResponseCode { WEB_DOMAIN_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, 50102, "허용된 웹 도메인 설정이 비어있습니다."), PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."), + RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), /* 60000부터 비즈니스 예외 */ /** diff --git a/src/main/java/konkuk/thip/config/RetryConfig.java b/src/main/java/konkuk/thip/config/RetryConfig.java new file mode 100644 index 000000000..4fc1133ba --- /dev/null +++ b/src/main/java/konkuk/thip/config/RetryConfig.java @@ -0,0 +1,9 @@ +package konkuk.thip.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry(proxyTargetClass = true) +public class RetryConfig { +} \ No newline at end of file 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..a998f4f67 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java @@ -44,6 +44,11 @@ public Optional findById(Long id) { .map(feedMapper::toDomainEntity); } + @Override + public Optional findByIdForUpdate(Long id) { + return feedJpaRepository.findByPostIdForUpdate(id) + .map(feedMapper::toDomainEntity); + } @Override public Long save(Feed feed) { diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java index 2c84f0a14..bc1015086 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java @@ -1,9 +1,13 @@ package konkuk.thip.feed.adapter.out.persistence.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import java.util.List; @@ -16,6 +20,11 @@ public interface FeedJpaRepository extends JpaRepository, F */ Optional findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT f FROM FeedJpaEntity f WHERE f.postId = :postId") + @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) + Optional findByPostIdForUpdate(@Param("postId") Long postId); + @Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId") long countAllFeedsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java index 1839a4b5c..6bf3f89dd 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java @@ -12,10 +12,15 @@ public interface FeedCommandPort { Long save(Feed feed); Long update(Feed feed); Optional findById(Long id); + Optional findByIdForUpdate(Long id); default Feed getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); } + default Feed getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) + .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); + } void delete(Feed feed); void saveSavedFeed(Long userId, Long feedId); void deleteSavedFeed(Long userId, Long feedId); 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..f63b34e36 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -1,5 +1,8 @@ package konkuk.thip.post.application.service; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.post.application.port.out.dto.PostQueryDto; @@ -15,7 +18,11 @@ import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -34,19 +41,27 @@ public class PostLikeService implements PostLikeUseCase { private final RoomNotificationOrchestrator roomNotificationOrchestrator; @Override - @Transactional + @Retryable( + notRecoverable = { BusinessException.class, InvalidStateException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 500, random = true) + ) + @Transactional(propagation = Propagation.REQUIRES_NEW) public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 1. 게시물 타입에 맞게 검증 및 조회 - CountUpdatable post = postHandler.findPost(command.postType(), command.postId()); + CountUpdatable post = postHandler.findPostForUpdate(command.postType(), command.postId()); // 1-1. 게시글 타입에 따른 게시물 좋아요 권한 검증 postLikeAuthorizationValidator.validateUserCanAccessPostLike(command.postType(), post, command.userId()); // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회 boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId()); - // 3. 좋아요 상태변경 - //TODO 게시물의 좋아요 수 증가/감소 동시성 제어 로직 추가해야됨 + // 3. 게시물 좋아요 수 업데이트 + post.updateLikeCount(postCountService,command.isLike()); + postHandler.updatePost(command.postType(), post); + + // 4. 좋아요 상태변경 if (command.isLike()) { postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); @@ -58,13 +73,14 @@ 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()); } + @Recover + public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + private void sendNotifications(PostIsLikeCommand command) { PostQueryDto postQueryDto = postHandler.getPostQueryDto(command.postType(), command.postId()); 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..2ad612640 100644 --- a/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java +++ b/src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java @@ -31,6 +31,14 @@ public CountUpdatable findPost(PostType type, Long postId) { }; } + public CountUpdatable findPostForUpdate(PostType type, Long postId) { + return switch (type) { + case FEED -> feedCommandPort.getByIdOrThrowForUpdate(postId); + case RECORD -> recordCommandPort.getByIdOrThrowForUpdate(postId); + case VOTE -> voteCommandPort.getByIdOrThrowForUpdate(postId); + }; + } + public void updatePost(PostType type, CountUpdatable post) { switch (type) { case FEED -> feedCommandPort.update((Feed) post); 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..89e25cb37 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java @@ -56,6 +56,12 @@ public Optional findById(Long id) { .map(recordMapper::toDomainEntity); } + @Override + public Optional findByIdForUpdate(Long id) { + return recordJpaRepository.findByPostIdForUpdate(id) + .map(recordMapper::toDomainEntity); + } + @Override public void delete(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java index b74daa966..23559493c 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -81,6 +81,11 @@ public Optional findById(Long id) { return voteJpaRepository.findByPostId(id) .map(voteMapper::toDomainEntity); } + @Override + public Optional findByIdForUpdate(Long id) { + return voteJpaRepository.findByPostIdForUpdate(id) + .map(voteMapper::toDomainEntity); + } @Override public Optional findVoteItemById(Long id) { 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 a600d8165..9b635c3d0 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -1,7 +1,9 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.record; +import jakarta.persistence.LockModeType; import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +18,10 @@ public interface RecordJpaRepository extends JpaRepository findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RecordJpaEntity r WHERE r.postId = :postId") + Optional findByPostIdForUpdate(@Param("postId") Long postId); + @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.userJpaEntity.userId = :userId") List findRecordIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java index 90251801d..fd276a644 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java @@ -1,8 +1,10 @@ package konkuk.thip.roompost.adapter.out.persistence.repository.vote; import io.lettuce.core.dynamic.annotation.Param; +import jakarta.persistence.LockModeType; import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -16,6 +18,10 @@ public interface VoteJpaRepository extends JpaRepository, V */ Optional findByPostId(Long postId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT v FROM VoteJpaEntity v WHERE v.postId = :postId") + Optional findByPostIdForUpdate(@Param("postId") Long postId); + @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.userJpaEntity.userId = :userId") List findVoteIdsByUserId(@Param("userId") Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java index 505a769ce..c2259b9a2 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java @@ -15,12 +15,15 @@ public interface RecordCommandPort { void update(Record record); Optional findById(Long id); - + Optional findByIdForUpdate(Long id); default Record getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); } - + default Record getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) + .orElseThrow(() -> new EntityNotFoundException(RECORD_NOT_FOUND)); + } void delete(Record record); void deleteAllByUserId(Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java index de09c2614..0d0de2f91 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java @@ -20,11 +20,15 @@ public interface VoteCommandPort { void saveAllVoteItems(List voteItems); Optional findById(Long id); - + Optional findByIdForUpdate(Long id); default Vote getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); } + default Vote getByIdOrThrowForUpdate(Long id) { + return findByIdForUpdate(id) + .orElseThrow(() -> new EntityNotFoundException(VOTE_NOT_FOUND)); + } Optional findVoteItemById(Long id); 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..6e0be57d3 100644 --- a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java @@ -11,6 +11,7 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,7 +21,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; @@ -32,7 +32,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[통합] 피드 좋아요 api 통합 테스트") class FeedChangeLikeStatusApiTest { @@ -59,6 +58,14 @@ void setUp() { feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); } + @AfterEach + void tearDown(){ + postLikeJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + @Test @DisplayName("피드를 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") void likeFeed_Success() throws Exception { @@ -135,7 +142,11 @@ void unlikeFeed_Success() throws Exception { @Test @DisplayName("좋아요 하지 않은 피드를 좋아요 취소하면 [400 에러 발생]") void unlikeFeed_NotLiked_Fail() throws Exception { - // given: 좋아요 없음 + + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + feed.updateLikeCount(1); + feedJpaRepository.save(feed); //영속성 바로 반영 + // given FeedIsLikeRequest request = new FeedIsLikeRequest(false); // when & then diff --git a/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java new file mode 100644 index 000000000..e973759fb --- /dev/null +++ b/src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java @@ -0,0 +1,125 @@ +package konkuk.thip.feed.concurrency; + +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.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; +import konkuk.thip.post.application.port.in.dto.PostIsLikeCommand; +import konkuk.thip.post.application.service.PostLikeService; +import konkuk.thip.post.domain.PostType; +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.Tag; +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 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 +@Tag("concurrency") +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 피드 좋아요 상태변경 다중 스레드 테스트") +class FeedChangeLikeStatusConcurrencyTest { + + @Autowired private PostLikeService postLikeService; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + + private UserJpaEntity user1; + private UserJpaEntity user2; + private BookJpaEntity book; + private FeedJpaEntity feed; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user1 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + user2 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + feed = feedJpaRepository.save(TestEntityFactory.createFeed(user1,book, true)); + } + + + @Test + public void concurrentLikeToggleTest() throws InterruptedException { + + int threadCount = 2; + int repeat = 10; // 스레드별 몇 번 반복할지 + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount * repeat); + + 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 { + // 각 스레드별로 서로 다른 user를 사용하도록 user1, user2 분기 처리 + Long userId = (userIndex == 0) ? user1.getUserId() : user2.getUserId(); + + postLikeService.changeLikeStatusPost( + new PostIsLikeCommand(userId, 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(); + + // 좋아요 저장 여부 확인 + boolean user1Liked = postLikeJpaRepository.existsByUserIdAndPostId(user1.getUserId(),feed.getPostId()); + boolean user2Liked = postLikeJpaRepository.existsByUserIdAndPostId(user2.getUserId(),feed.getPostId()); + + // 좋아요 카운트 증가 확인 + FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow(); + + // then + assertAll( + () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat), + () -> assertThat(failCount.get()).isEqualTo(0), + () -> assertThat(updatedFeed.getLikeCount()).isEqualTo(0), + () -> assertThat(user1Liked).isFalse(), + () -> assertThat(user2Liked).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 9d2362577..e591718f0 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java @@ -20,6 +20,7 @@ import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,7 +30,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import static konkuk.thip.common.exception.code.ErrorCode.POST_ALREADY_LIKED; import static konkuk.thip.common.exception.code.ErrorCode.POST_NOT_LIKED_CANNOT_CANCEL; @@ -41,7 +41,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[통합] 방 게시물(기록,투표) 좋아요 api 통합 테스트") class RoomPostChangeLikeStatusApiTest { @@ -82,6 +81,18 @@ record = recordJpaRepository.save(TestEntityFactory.createRecord(user,room)); vote = voteJpaRepository.save(TestEntityFactory.createVote(user,room)); } + @AfterEach + void tearDown(){ + postLikeJpaRepository.deleteAllInBatch(); + roomParticipantJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + voteJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + @Test @DisplayName("기록 게시물을 처음 좋아요하면 좋아요 저장 및 카운트 증가 [성공]") void likeRecordPost_Success() throws Exception { @@ -154,6 +165,9 @@ void unlikeRecordPost_Success() throws Exception { @DisplayName("좋아요 하지 않은 기록 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeRecordPost_NotLiked_Fail() throws Exception { //given + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + record.updateLikeCount(1); + recordJpaRepository.save(record); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "RECORD"); //when & then @@ -235,6 +249,9 @@ void unlikeVotePost_Success() throws Exception { @DisplayName("좋아요 하지 않은 투표 게시물을 좋아요 취소하면 [400 에러 발생]") void unlikeVotePost_NotLiked_Fail() throws Exception { //given + // 다른 유저가 해당 게시글에 좋아요한 상태(좋아요 0 이하 언더플로우 예외 피하기위해서 해당피드에 이미 좋아요 1이상이라고 가정) + vote.updateLikeCount(1); + voteJpaRepository.save(vote); RoomPostIsLikeRequest request = new RoomPostIsLikeRequest(false, "VOTE"); //when & then diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java index e8b21e592..4402339a2 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java @@ -14,6 +14,7 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,7 +24,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.Map; @@ -37,7 +37,6 @@ @SpringBootTest @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) -@Transactional @DisplayName("[단위] 방 게시물(기록,투표) 좋아요 api controller 단위 테스트") class RoomPostChangeLikeStatusControllerTest { @@ -73,6 +72,15 @@ void setUp() { record = recordJpaRepository.save(TestEntityFactory.createRecord(user1,room)); } + @AfterEach + void tearDown(){ + roomParticipantJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + private Map buildValidLikeRequest(Boolean isLike, String postType) { Map request = new HashMap<>(); request.put("type", isLike);