Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5ac36e6
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 13, 2025
480e850
[test] 피드 좋아요 상태변경 다중 스레드 테스트 (#322)
hd0rable Oct 18, 2025
f14a490
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
1731bcc
[test] k6 테스트파일 추가 (#322)
hd0rable Oct 25, 2025
20c2b95
[test] 피드 좋아요 상태변경 다중 스레드 테스트코드 수정 (#322)
hd0rable Oct 25, 2025
74aa272
[fix] 게시글 좋아요 상태변경 s-lock에서 x-lock으로 승격할때 데드락 상황을 해결하기위해 서비스로직 순서 변경 …
hd0rable Nov 9, 2025
be10bed
[chore] 안쓰는 테스트코드 스크릾트 삭제 (#338)
hd0rable Nov 24, 2025
b3b4655
[test] 특정 시점에 한 게시물 (인기 작가,인플루언서가 작성한)에 좋아요 요청이 몰리는 시나리오 k6 테스트 스크립트 …
hd0rable Nov 24, 2025
a9b91ca
[refactor] 게시글 공통인터페이스에 getLikeCount() 함수 추가 (#338)
hd0rable Nov 24, 2025
a34c050
[refactor] 레디스 도입시 게시글 도메인이 좋아요 업데이트 검증시에 likeCount 외부에서 주입받도록 수정 (#338)
hd0rable Nov 24, 2025
5dfae3b
[refactor] feed findPostIdsByIds,batchUpdateLikeCounts 추가 (#338)
hd0rable Nov 24, 2025
f4d8041
[feat] FeedJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
36d5524
[feat] FeedCommandPersistenceAdapter.findByIds() 추가 및 batchUpdateLik…
hd0rable Nov 24, 2025
bc6b49e
[feat] PostHandler
hd0rable Nov 24, 2025
fcf3e5c
[feat] 게시글 좋아요 캐싱 PostLikeCountRedisCommand,Query Port 추가 (#338)
hd0rable Nov 24, 2025
7302cc3
[feat] 게시글 좋아요 캐싱 구현체 PostLikeCountRedisAdapter 추가 (#338)
hd0rable Nov 24, 2025
5a9cc95
[feat] RecordCommandPersistenceAdapterAdapter.findByIds() 추가 및 batch…
hd0rable Nov 24, 2025
4154b49
[refactor] RecordCommandPort findPostIdsByIds,batchUpdateLikeCounts 추…
hd0rable Nov 24, 2025
3e7a47c
[feat] RecordJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
7390280
[feat] RedisConfig.redisIntegerTemplate 추가 (#338)
hd0rable Nov 24, 2025
567f1e6
[feat] VoteCommandPersistenceAdapter.findByIds() 추가 및 batchUpdateLik…
hd0rable Nov 24, 2025
c972f51
[refactor] VoteCommandPort.findPostIdsByIds,batchUpdateLikeCounts 추가 …
hd0rable Nov 24, 2025
35f3456
[feat] VoteJpaRepository.findByPostIds 추가 (#338)
hd0rable Nov 24, 2025
c6401d7
[refactor] 게시글 좋아요 시, post 최초 조회시에 비관락 해제하고 레디스의 INCR/DECR 명령어를 사용하는 …
hd0rable Nov 24, 2025
cb00e01
[feat]
hd0rable Nov 24, 2025
e4891a4
[chore] 주석 정리 (#332)
hd0rable Nov 28, 2025
f6b6ba9
[fix] 쿼리 실행 전 빈 목록 파라미터 유효성 검사 추가 (#324)
hd0rable Dec 6, 2025
f4d3a2a
[refactor] 실제 존재하는 ID가 없을 때 DB 업데이트 방지 (#324)
hd0rable Dec 6, 2025
bb2a260
[test] 게시글 좋아요 로직 변경에 따른 테스트 코드 수정
hd0rable Dec 6, 2025
59f89c2
[test] 게시글 좋아요 로직 변경에 따른 게시글(도메인) 테스트 코드 수정
hd0rable Dec 6, 2025
9011584
[refactor] db 동기화 스케줄러 test환경에서는 실행 안되도록 수정 (#338)
hd0rable Dec 6, 2025
a6cdc57
[test] book 중복 insert 테스트 깨지는 거 수정 (#338)
hd0rable Dec 6, 2025
02794c2
[test] 캐싱 로직 추가로 레디스 초기화하는 셋업 처리 추가 (#338)
hd0rable Dec 6, 2025
17f23d3
[chore] import문 정리 (#338)
hd0rable Dec 6, 2025
c66eb33
[feat] 게시글 좋아요 이벤트 dto 추가 (#339)
hd0rable Dec 10, 2025
94d579a
[rename] PostLikeCountRedis*** -> PostLikeRedis 이름 변경r (#339)
hd0rable Dec 10, 2025
6ed0014
[rename] PostLikeCountRedis*** -> PostLikeRedis 이름 변경r (#339)
hd0rable Dec 10, 2025
9aae94f
[feat]
hd0rable Dec 10, 2025
3505110
[feat] PostLikeEventCommandPort의 구현체 PostLikeEventSyncAdapter 구현 (#339)
hd0rable Dec 10, 2025
ec0f5a1
[feat] 비동기 큐입 삽입 PostLikeQueueCommandPort 인터페이스 정의 (#339)
hd0rable Dec 10, 2025
ac12ae8
[feat] 메세지 소비 PostLikeQueueConsumerPort 인터페이스 정의 (#339)
hd0rable Dec 10, 2025
485c288
[feat] 비동기 큐에 삽입되는 메세지 PostLikeQueueMessage dto 작성 (#339)
hd0rable Dec 10, 2025
d7012e6
[feat] PostLikeQueueCommand/ConsumerPort 구현체 PostLikeQueueRedisAdapte…
hd0rable Dec 10, 2025
4614116
[feat] 백그라운드 워커 PostLikeRecordSyncToDBService 작성 (#339)
hd0rable Dec 10, 2025
4fea986
[feat] PostLikeRedisCommand/QueryPort에 set 연산 추가 정의 (#339)
hd0rable Dec 10, 2025
5270bef
[feat] PostLikeRedisAdapter 구현체에 redis set 연산 추가 (#339)
hd0rable Dec 10, 2025
e54c50e
[refactor] 게시글 좋아요 Redis List 비동기 큐잉 도입 (DB Write 부하 분산)으로 인한 로직 수정 (…
hd0rable Dec 10, 2025
bd73f80
[refactor]
hd0rable Dec 10, 2025
11b0833
[test] 게시글 좋아요 로직 변경에 따른 게시글(도메인) 테스트 코드 수정 (#339)
hd0rable Dec 10, 2025
a2d8f1b
[refactor] DB 씽크맞추는 스케줄러 서비스 테스트환경에서는 실행되지않도록 @ Profile("!test") 적용 (…
hd0rable Dec 10, 2025
8f48fdd
[test] 테스트환경에서
hd0rable Dec 10, 2025
a74bed6
Merge remote-tracking branch 'origin/test/#322-k6-feed-like-redis-INC…
hd0rable Dec 10, 2025
12ab679
[chore] 주석정리 (#339)
hd0rable Dec 10, 2025
360e8d3
[test] 수정된 로직에 맞게 테스트코드 수정 (#339)
hd0rable Dec 10, 2025
0c0deb0
Merge branch 'develop' into test/#322-k6-feed-like-redis-event
hd0rable Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions loadtest/feed/feed-like-load-test.js
Original file line number Diff line number Diff line change
@@ -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),
};
}
11 changes: 11 additions & 0 deletions src/main/java/konkuk/thip/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,16 @@ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connec
return redisTemplate;
}

@Bean
public RedisTemplate<String, Integer> redisIntegerTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Integer> 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);
Expand Down
19 changes: 18 additions & 1 deletion src/main/java/konkuk/thip/config/WorkerThreadConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
@EnableAsync(proxyTargetClass = true)
@Profile("!test")
public class WorkerThreadConfig implements AsyncConfigurer {

Expand Down Expand Up @@ -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;
}
Comment on lines +57 to +72
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

종료 시 태스크 완료 대기 시간 및 거부 정책을 명시적으로 설정하세요.

현재 구성에 두 가지 잠재적 데이터 유실 위험이 있습니다:

  1. awaitTerminationSeconds 미설정: waitForTasksToCompleteOnShutdown만으로는 무한정 대기할 수 있습니다. 애플리케이션 종료 시 작업 완료를 위한 최대 대기 시간을 설정해야 합니다.

  2. RejectedExecutionHandler 미설정: 큐가 가득 차고 최대 스레드 수에 도달하면 기본 AbortPolicy가 작동하여 새 작업이 예외와 함께 거부됩니다. 좋아요 이벤트 유실을 방지하려면 CallerRunsPolicy를 사용하는 것을 권장합니다.

다음 diff를 적용하세요:

     @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.setAwaitTerminationSeconds(60);
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
         executor.initialize();
         return executor;
     }
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/config/WorkerThreadConfig.java around lines 57 to
72, set an explicit shutdown wait time and a rejection policy to avoid silent
task loss: call executor.setAwaitTerminationSeconds(...) with a sensible timeout
(e.g., 30–60 seconds) and set the rejected execution handler to CallerRunsPolicy
via executor.setRejectedExecutionHandler(new
java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()), then reinitialize;
this ensures the pool will wait a bounded time for in-flight tasks and will
execute rejected submissions on the calling thread instead of aborting.


@Override
public Executor getAsyncExecutor() {
return fcmAsyncExecutor(); // 기본은 FCM 풀 사용
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -44,6 +52,13 @@ public Optional<Feed> findById(Long id) {
.map(feedMapper::toDomainEntity);
}

@Override
public List<Long> findByIds(List<Long> ids) {
if (ids == null || ids.isEmpty())
return new ArrayList<>();
return feedJpaRepository.findByPostIds(ids);
}


@Override
public Long save(Feed feed) {
Expand Down Expand Up @@ -114,6 +129,26 @@ public void deleteAllFeedByUserId(Long userId) {
feedJpaRepository.softDeleteAllByUserId(userId);
}

@Override
public void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount) {
String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?";
List<Map.Entry<Long, Integer>> entries = new ArrayList<>(idToLikeCount.entrySet());

jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Map.Entry<Long, Integer> 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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ public interface FeedJpaRepository extends JpaRepository<FeedJpaEntity, Long>, 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<Long> findByPostIds(@Param("postIds") List<Long> postIds);
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -12,6 +14,7 @@ public interface FeedCommandPort {
Long save(Feed feed);
Long update(Feed feed);
Optional<Feed> findById(Long id);
List<Long> findByIds(List<Long> ids);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine FeedCommandPort interface
find . -name "FeedCommandPort.java" -type f

Repository: THIP-TextHip/THIP-Server

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Search for FeedCommandPersistenceAdapter
find . -name "FeedCommandPersistenceAdapter.java" -type f

Repository: THIP-TextHip/THIP-Server

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Look for Feed entity definition
find . -path "*/feed/*" -name "Feed.java" -type f

Repository: THIP-TextHip/THIP-Server

Length of output: 117


🏁 Script executed:

#!/bin/bash
# Search for usages of findByIds method
rg "findByIds" --type java

Repository: THIP-TextHip/THIP-Server

Length of output: 1691


🏁 Script executed:

#!/bin/bash
# Examine FeedCommandPort interface
cat -n ./src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java

Repository: THIP-TextHip/THIP-Server

Length of output: 1179


🏁 Script executed:

#!/bin/bash
# Examine FeedCommandPersistenceAdapter implementation
cat -n ./src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java

Repository: THIP-TextHip/THIP-Server

Length of output: 7510


🏁 Script executed:

#!/bin/bash
# Check context where findByIds is called in PostHandler
rg -A 5 -B 5 "findByIds" ./src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java

Repository: THIP-TextHip/THIP-Server

Length of output: 508


🏁 Script executed:

#!/bin/bash
# Check UserCommandPort for comparison (it returns entities)
cat -n ./src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java

Repository: THIP-TextHip/THIP-Server

Length of output: 507


findByIds 메서드의 반환 타입을 수정하세요.

List<Long> findByIds(List<Long> ids)는 프로젝트의 CQRS CommandPort 컨벤션과 맞지 않습니다. CommandPort의 findByXXX 메서드는 도메인 엔티티를 반환해야 하는데(예: UserCommandPort의 Map<Long, User> findByIds), 현재 구현은 ID만 반환합니다.

다음 중 하나로 수정하세요:

  • 메서드명을 findIdsByIds 등으로 변경하거나
  • 반환 타입을 List<Feed>로 변경하고 호출 코드 조정
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java
around line 17, the method signature List<Long> findByIds(List<Long> ids)
violates the CommandPort CQRS convention (should return domain entities); either
rename it to something like findIdsByIds to reflect it returns IDs, or change
its return type to List<Feed> and update all callers to expect Feed entities
(and adjust imports, tests, and any mapping logic where IDs were used); pick one
approach and make consistent changes across the codebase so CommandPort methods
return domain entities as required.

default Feed getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
Expand All @@ -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<Long, Integer> idToLikeCount);
}
4 changes: 2 additions & 2 deletions src/main/java/konkuk/thip/feed/domain/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Comment on lines +37 to +57
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

부분 실패 시 데이터 정합성 문제가 발생할 수 있습니다.

현재 구현에서 Redis Set 업데이트(1단계) 또는 카운트 갱신(2단계)이 성공한 후 큐 삽입(3단계)이 실패하면, 사용자에게는 좋아요가 반영된 것처럼 보이지만 DB에는 영구 기록되지 않습니다.

최소한의 복구 전략으로:

  1. 재시도 로직 추가 (예: Spring Retry)
  2. 또는 Dead Letter Queue 패턴 적용
  3. 또는 실패한 이벤트를 별도 저장소에 기록하여 수동 복구 지원
 try {
     postLikeQueueCommandPort.enqueueFromEvent(event);
 } catch (Exception e) {
     log.error("CRITICAL: Failed to publish Like Record to Queue. Event Data: {}", event, e);
+    // 복구를 위해 실패한 이벤트를 별도로 저장하거나 재시도 큐에 추가
+    // failedEventRepository.save(event);
 }

}
Loading