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), + }; +} 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); 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 풀 사용 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..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 @@ -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,13 @@ public Optional findById(Long id) { .map(feedMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); + return feedJpaRepository.findByPostIds(ids); + } + @Override public Long save(Feed feed) { @@ -114,6 +129,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()) 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); } 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); } 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/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); + } + } +} 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 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 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..0080ca313 --- /dev/null +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java @@ -0,0 +1,137 @@ +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) { + String recordKey = makeRecordRedisKey(postId); + + // SISMEMBER : Set 안에 userId(멤버)가 존재하는지 확인 + 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; + } + +} 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에 동기화되어야 할 최종 카운트 + ); +} 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 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(); +} 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); +} 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 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..98e580152 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java @@ -0,0 +1,88 @@ +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.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; +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; + private final PostLikeRedisCommandPort 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); + if (existingIds.isEmpty()) continue; + // 해당 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); + } +} 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..93f8887c5 --- /dev/null +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java @@ -0,0 +1,40 @@ +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.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 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 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..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,14 +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.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; @@ -22,9 +22,8 @@ @RequiredArgsConstructor public class PostLikeService implements PostLikeUseCase { - private final PostLikeQueryPort postLikeQueryPort; - private final PostLikeCommandPort postLikeCommandPort; private final UserCommandPort userCommandPort; + private final PostLikeRedisQueryPort postLikeRedisQueryPort; private final PostHandler postHandler; private final PostCountService postCountService; @@ -32,6 +31,7 @@ public class PostLikeService implements PostLikeUseCase { private final FeedNotificationOrchestrator feedNotificationOrchestrator; private final RoomNotificationOrchestrator roomNotificationOrchestrator; + private final PostLikeEventCommandPort postLikeEventCommandPort; @Override @Transactional @@ -43,25 +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. 좋아요 상태변경 - //TODO 게시물의 좋아요 수 증가/감소 동시성 제어 로직 추가해야됨 + // 3. 좋아요 가능 여부 검증 if (command.isLike()) { - postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 - postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); - + postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 푸쉬알림 전송 - sendNotifications(command); + //sendNotifications(command); } else { - postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); // 좋아요 취소 가능 여부 검증 - postLikeCommandPort.delete(command.userId(), command.postId()); + postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked); } - // 4. 게시물 좋아요 수 업데이트 - post.updateLikeCount(postCountService,command.isLike()); - postHandler.updatePost(command.postType(), post); + // 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()); } 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); + } + } + + } 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 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..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 @@ -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,13 @@ public Optional findById(Long id) { .map(recordMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); + return recordJpaRepository.findByPostIds(ids); + } + @Override public void delete(Record record) { RecordJpaEntity recordJpaEntity = recordJpaRepository.findByPostId(record.getId()).orElseThrow( @@ -83,6 +98,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( 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..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 @@ -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,13 @@ public Optional findById(Long id) { .map(voteMapper::toDomainEntity); } + @Override + public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) + return new ArrayList<>(); + return voteJpaRepository.findByPostIds(ids); + } + @Override public Optional findVoteItemById(Long id) { return voteItemJpaRepository.findById(id) @@ -190,6 +205,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) { 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..8e6bcb297 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 @@ -23,6 +23,9 @@ public interface RecordJpaRepository extends JpaRepository findByPostIds(List postIds); + @Query("SELECT r FROM RecordJpaEntity r " + "WHERE r.roomJpaEntity.roomId = :roomId " + "AND r.userJpaEntity.userId = :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..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); } 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); } 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); } 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() { 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(); 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..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 @@ -1,13 +1,15 @@ 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; 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; @@ -17,8 +19,10 @@ 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.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -43,7 +47,10 @@ 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; private BookJpaEntity book; @@ -53,6 +60,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")); @@ -76,13 +88,24 @@ 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(); - // 좋아요 카운트 증가 확인 - 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 @@ -90,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 @@ -107,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); @@ -123,13 +146,24 @@ 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(); - // 좋아요 카운트 감소 확인 - 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/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java new file mode 100644 index 000000000..c7e1d5ab9 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java @@ -0,0 +1,110 @@ +package konkuk.thip.feed.adapter.in.web; + +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.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 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 +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[단위] 피드 좋아요 상태변경 다중 스레드 테스트") +class FeedChangeLikeStatusConcurrencyTest { + + @Autowired private PostLikeService postLikeService; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + + 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(); + + // then + assertAll( + () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat), + () -> assertThat(failCount.get()).isEqualTo(0) + ); + } + + +} 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..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 @@ -46,6 +46,7 @@ class FeedUpdateControllerTest { @Autowired private BookJpaRepository bookJpaRepository; @Autowired private FeedJpaRepository feedJpaRepository; + private Long savedFeedId; private Long creatorUserId; @@ -53,7 +54,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(); } 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/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java index 9d2362577..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 @@ -1,12 +1,12 @@ 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; -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; @@ -26,8 +26,10 @@ 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.context.transaction.TestTransaction; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -51,16 +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; @@ -70,10 +72,14 @@ 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")); - feed = feedJpaRepository.save(TestEntityFactory.createFeed(user,book, true)); category = TestEntityFactory.createLiteratureCategory(); room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); // 1번방에 유저 1이 호스트 @@ -97,14 +103,28 @@ 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(); - // 좋아요 카운트 증가 확인 - RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); - assertThat(updatedRecord.getLikeCount()).isEqualTo(1); +// // 좋아요 카운트 증가 확인 +// RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); +// assertThat(updatedRecord.getLikeCount()).isEqualTo(1); } @@ -112,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 @@ -128,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"); @@ -142,12 +162,26 @@ 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(); - assertThat(updatedRecord.getLikeCount()).isEqualTo(0); +// RecordJpaEntity updatedRecord = recordJpaRepository.findById(record.getPostId()).orElseThrow(); +// assertThat(updatedRecord.getLikeCount()).isEqualTo(0); } @Test @@ -182,19 +216,33 @@ 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(); - assertThat(updatedVote.getLikeCount()).isEqualTo(1); +// VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); +// assertThat(updatedVote.getLikeCount()).isEqualTo(1); } @Test @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 @@ -210,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"); @@ -224,11 +272,25 @@ 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(); - assertThat(updatedVote.getLikeCount()).isEqualTo(0); +// VoteJpaEntity updatedVote = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); +// assertThat(updatedVote.getLikeCount()).isEqualTo(0); } @Test diff --git a/src/test/java/konkuk/thip/roompost/domain/RecordTest.java b/src/test/java/konkuk/thip/roompost/domain/RecordTest.java index e7719eff1..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; @@ -143,10 +142,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 +155,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 +173,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());