Skip to content

Conversation

@hd0rable
Copy link
Member

@hd0rable hd0rable commented Dec 17, 2025

#️⃣ 연관된 이슈

closes #339

📝 작업 내용

게시글 좋아요 API의 처리량 극대화와 시스템 안정성을 위해, 기존의 동기적 DB 쓰기 방식을 Redis 기반의 비동기 이벤트 기반 아키텍처(EDA) 로 리팩토링하였습니다.

  • 비관적 락 완전 제거 및 즉시성 확보: 게시글 조회 시 X-Lock을 제거하고, 좋아요 여부 검증(isLikedPostByUser)을 DB 대신 Redis Set(SISMEMBER) 을 조회하도록 변경하여 O(1)의 속도로 즉각적인 응답이 가능하게 개선했습니다.
  • 메인 트랜잭션과 DB Write 분리 (비동기 큐잉): PostLike 엔티티의 INSERT/DELETE 작업을 API 트랜잭션에서 제거했습니다.
    대신 Redis List를 활용한 메시지 큐 발행 방식으로 대체하여 API 응답 시간을 최소화했습니다.
  • 데이터 유실 방지 및 정합성 보장: TransactionSynchronization.afterCommit()을 사용하여 DB 트랜잭션이 최종 성공한 경우에만 Redis 업데이트 및 큐 발행이 일어나도록 설계했습니다.
  • 백그라운드 워커 도입: @ Scheduled 기반의 PostLikeRecordSyncToDBService를 통해 큐에 쌓인 데이터를 DB에 순차적으로 영구 기록하는 Write-Back 패턴을 적용했습니다.
  • 성능 검증: 비동기 큐잉 도입 시 고부하 환경(VU 5,000)에서 비관적 락 대비 응답 속도를 77% 개선하였으며, 시스템 붕괴 없이 초당 약 400건의 트랜잭션을 안정적으로 처리함을 확인했습니다.
  • 레디스 키 때문에 yml도 업데이트해두었습니다! pr 닫히기 전에 원상복귀 해두겠습니닷 노션에도 업데이트해두었습니다
  • 관련하여 노션에 문서화 해두었습니다 트러블슈팅 -> count 이슈 -> 피드(게시글) 좋아요 API 동시성 - 성능 최적화

📸 스크린샷

💬 리뷰 요구사항

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 좋아요 기능의 성능 개선을 위해 Redis 기반 캐싱 시스템 추가
    • 좋아요 상태 변경을 위한 비동기 이벤트 기반 아키텍처 도입
    • 대규모 동시 좋아요 작업을 위한 로드 테스트 스크립트 추가
  • 테스트

    • 좋아요 기능에 대한 동시성 테스트 케이스 추가
    • Redis 기반 좋아요 상태 검증을 위한 테스트 업데이트

✏️ Tip: You can customize this high-level summary in your review settings.

…Counts 배치 좋아요 업데이트 함수 추가

(#338)
findPostIdsByIds,batchUpdateLikeCounts 추가 (#338)
…pdateLikeCounts 배치 좋아요 업데이트 함수 추가

(#338)
…Counts 배치 좋아요 업데이트 함수 추가

(#338)
Redis의 좋아요 수(증감)를 주기적으로 DB에 동기화하는 스케줄러추가 (#338)
- 게시글 좋아요 로직이 레디스 원자적 연산으로 수정되면서 게시글 엔티티의 좋아요 정보(likeCount)는 더이상 실시간정보가 아니므로 db 정합성을 확인하는 코드 주석 처리
(#324)
- updateLikeCount()의newLikeCount 매개변수가 추가되면서 테스트코드 수정
(#324)
좋아요/좋아요 취소 요청 후속 처리를 위한 Output Port인 PostLikeEventCommandPort 인터페이스 정의
(#339)
- 이벤트 발행: Service 계층의 요청을 받아 PostLikeChangedEvent 발행
- 이벤트 리스너: DB 커밋 성공 후 전용 스레드('postLikeAsyncExecutor')에서 후속 작업 처리
리스너 내부에서 Redis Set/Count 갱신, Queue 삽입 순차적으로 실행
…r 구현 (#339)

- Redis List를 활용한 비동기 좋아요 큐 Adapter 구현
- 발행 (Enqueue): enqueueFromEvent()에서 PostLikeChangedEvent를 받아 PostLikeQueueMessage로 변환하고, JSON 직렬화 후 Redis List에 LPUSH하여 메시지 삽입.
- 소비 (Consume): consumeOne 메서드에서 Redis List의 Tail에서 메시지를 BRPOP으로 꺼내 역직렬화
- @scheduled(fixedDelay = 1000)를 통해 1초마다 트랜잭션을 시작하고 큐 소비 시도
- PostLikeQueueConsumerPort를 통해 메시지를 가져옴, 큐가 비어있지 않은 동안 루프를 반복하여 메시지 일괄처리
-메시지의 action 필드에 따라 PostLikeCommandPort를 호출하여 DB에 INSERT 또는 DELETE 작업 실행
- WorkerThreadConfig에 PostLike 이벤트 전용 실행기 추가
- 비동기 이벤트 리스너 안정화를 위한 CGLIB 프록시 강제 적용 (#339)
- updateLikeCount()의newLikeCount 매개변수가 추가되면서 테스트코드 수정
- TestAsyncConfig에 PostLike 이벤트 전용 실행기 추가
- 비동기 이벤트 리스너 안정화를 위한 CGLIB 프록시 강제 적용 (#339)
…R' into test/#322-k6-feed-like-redis-event

# Conflicts:
#	src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java
# Conflicts:
#	src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java
@coderabbitai
Copy link

coderabbitai bot commented Dec 17, 2025

Walkthrough

이 PR은 게시글 좋아요 API의 처리량을 개선하기 위한 대규모 리팩토링입니다. Redis 기반의 즉시 검증(원자적 연산), 이벤트 기반 비동기 큐잉, 그리고 예약된 DB 동기화를 도입하여 좋아요 기록 영속화를 API 트랜잭션 경로에서 제거합니다.

Changes

응집군 / 파일(s) 변경 요약
부하 테스트 스크립트
loadtest/feed/feed-like-load-test.js
k6 부하 테스트 스크립트 신규 추가: 토큰 배치 발급, 동기화된 시작, 좋아요 엔드포인트 호출, 메트릭 기록 및 HTML 리포트 생성 기능 구현
Redis 설정 및 비동기 실행자
src/main/java/konkuk/thip/config/RedisConfig.java, src/main/java/konkuk/thip/config/WorkerThreadConfig.java
RedisTemplate<String, Integer> 빈 추가 및 좋아요 전용 ThreadPoolTaskExecutor(postLikeAsyncExecutor) 설정 추가
포트 인터페이스 (Redis/이벤트/큐)
src/main/java/konkuk/thip/post/application/port/out/PostLike*.java, src/main/java/konkuk/thip/post/application/port/out/dto/*
Redis 조회/명령, 이벤트 발행, 큐 소비 포트 및 관련 DTO(PostLikeChangedEvent, PostLikeQueueMessage) 신규 정의
Redis 어댑터
src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java, src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java
Redis 기반 좋아요 카운트 캐싱, 원자적 업데이트, 사용자별 좋아요 기록(Set), 그리고 큐 기반 메시지 처리 구현
이벤트 어댑터
src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java, src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java
좋아요 변경 이벤트 발행 및 트랜잭션 후 처리 메커니즘 구현
배치 업데이트 지원 (Feed/Record/Vote)
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java
JDBC 배치 업데이트를 통한 좋아요 카운트 일괄 동기화 및 ID 배치 조회 메서드 추가
저장소 쿼리 메서드
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java
findByPostIds(List) 메서드 신규 추가로 배치 ID 조회 지원
포트 정의 (배치/명령)
src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java, src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java, src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java
배치 조회 및 좋아요 카운트 일괄 업데이트 포트 메서드 추가
도메인 모델 업데이트
src/main/java/konkuk/thip/feed/domain/Feed.java, src/main/java/konkuk/thip/roompost/domain/Record.java, src/main/java/konkuk/thip/roompost/domain/Vote.java, src/main/java/konkuk/thip/post/domain/CountUpdatable.java
updateLikeCount(…, int newLikeCount) 서명 추가, getLikeCount() 메서드 신규 정의
서비스 레이어
src/main/java/konkuk/thip/post/application/service/PostLikeService.java, src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java, src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java
PostLikeService는 Redis 기반 검증 및 이벤트 발행으로 전환; 새로운 스케줄된 동기화 서비스로 Redis→DB 동기화 구현
핸들러
src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java
배치 ID 조회 및 배치 좋아요 카운트 업데이트 메서드 추가
테스트 설정
src/test/java/konkuk/thip/config/TestAsyncConfig.java
@EnableAsync(proxyTargetClass = true) 적용 및 postLikeAsyncExecutor 빈 추가
통합 테스트
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java, src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java
Redis 기반 좋아요 상태 검증으로 전환 및 트랜잭션 동기화 처리 추가
추가 테스트
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java, src/test/java/konkuk/thip/feed/domain/FeedTest.java, src/test/java/konkuk/thip/roompost/domain/RecordTest.java, src/test/java/konkuk/thip/roompost/domain/VoteTest.java
동시성 테스트 신규 추가 및 도메인 모델 서명 변경에 맞춘 기존 테스트 업데이트
기타 테스트 데이터
src/test/java/konkuk/thip/feed/adapter/in/web/Feed*ApiTest.java
ISBN 값 변경으로 테스트 데이터 조정

Sequence Diagram

sequenceDiagram
    actor Client
    participant API as Like API
    participant Service as PostLikeService
    participant Redis as Redis
    participant EventBus as EventBus
    participant Queue as Redis Queue
    participant Scheduler as LikeRecordSyncService
    participant DB as Database

    rect rgb(200, 220, 255)
    Note over Client,DB: 기존 흐름 (동기적 DB 영속화)
    Client->>API: POST /like
    API->>DB: Check isLikedByUser (DB Query)
    DB-->>API: Result
    API->>DB: INSERT/UPDATE PostLike
    DB-->>API: Persisted
    API-->>Client: 200 OK
    end

    rect rgb(200, 255, 220)
    Note over Client,DB: 신규 흐름 (Redis 비동기 큐잉)
    Client->>API: POST /like
    API->>Redis: SISMEMBER userId:postId (O(1) Check)
    Redis-->>API: Result
    API->>Service: updateLikeCount()
    Service->>Redis: INCR/DECR count (Atomic)
    Redis-->>Service: New Count
    Service->>EventBus: PublishEvent (afterCommit)
    EventBus->>Redis: Add to Set + Enqueue
    Redis-->>EventBus: Done
    API-->>Client: 200 OK (즉시 응답)
    
    par Async Background Processing
    Scheduler->>Queue: BRPOP (Consume Message)
    Queue-->>Scheduler: Message
    Scheduler->>DB: INSERT/UPDATE PostLike
    DB-->>Scheduler: Persisted
    Scheduler->>Redis: bulkResetLikeCounts
    Redis-->>Scheduler: Done
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

특별히 주의 깊게 검토해야 할 부분:

  • Redis 어댑터 구현 (PostLikeRedisAdapter.java, PostLikeQueueRedisAdapter.java): TTL 관리, 키 구성, 직렬화/역직렬화 로직의 정확성
  • 이벤트 기반 동기화 메커니즘 (PostLikeEventSyncAdapter.java): @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 동작 검증 및 데이터 유실 방지 로직
  • 배치 동기화 서비스 (PostLikeCountSyncToDBService.java, PostLikeRecordSyncToDBService.java): 스케줄링된 작업의 트랜잭션 경계, 중복 제거, 예외 처리
  • 도메인 모델 시그니처 변경: Feed/Record/Vote의 updateLikeCount() 매개변수 추가가 모든 호출 사이트에서 올바르게 전파되었는지 확인
  • PostLikeService 리팩토링: Redis 포트로의 전환 및 기존 JPA 포트 제거가 완전한지, 새로운 이벤트 발행 로직이 올바른지 검증
  • JDBC 배치 업데이트: BatchPreparedStatementSetter 구현의 인덱싱 및 SQL 정확성
  • 부하 테스트 스크립트: 토큰 배치 발급 로직, 동기화된 시작 시간, 메트릭 수집의 정확성

Possibly related issues

Possibly related PRs

  • PR #66: Feed/Record/Vote 도메인 엔티티 및 좋아요 카운트 관련 메서드 시그니처 변경이 겹치므로 관련성 높음
  • PR #105: 좋아요 기록 조회 최적화(findPostIdsLikedByUser) 및 PostLikeQueryPort 리팩토링과, 이 PR의 Redis 기반 PostLikeRedisQueryPort 도입이 같은 영역을 다루므로 관련성 높음
  • PR #229: 도메인 인터페이스(CountUpdatable) 및 Feed/Record/Vote updateLikeCount 시그니처 변경이 동일하므로 관련성 높음

Suggested reviewers

  • seongjunnoh
  • buzz0331

Poem

🐰 레디스 세상에 뛰어들어,
원자적 연산으로 번개같이,
비동기 큐에 담아 흘러가니,
동기화 서비스가 밤새 일하고,
부하 테스트까지 여유롭게 통과! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.78% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항인 Redis 기반 비동기 큐잉을 통한 좋아요 API 개선을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed 코드 변경사항이 연결된 이슈 #339의 모든 주요 요구사항을 충족합니다: Redis Set 조회로 O(1) 성능 확보, PostLike DB 연산 제거 및 Redis 큐로 대체, TransactionSynchronization.afterCommit() 적용, @scheduled 워커 도입, k6 부하 테스트 스크립트 작성.
Out of Scope Changes check ✅ Passed 테스트 파일의 ISBN 변경(test data 수정)과 테스트 설정 변경이 있으나, 이는 Redis 기반 구조로의 전환에 필요한 테스트 업데이트로 범위 내 변경입니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch test/#322-k6-feed-like-redis-event

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

Test Results

489 tests   489 ✅  45s ⏱️
146 suites    0 💤
146 files      0 ❌

Results for commit 0c0deb0.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java (1)

3-3: 잘못된 @Param 어노테이션 import를 사용하고 있습니다.

Line 3에서 io.lettuce.core.dynamic.annotation.Param을 import하고 있는데, JPA 쿼리 메서드에는 org.springframework.data.repository.query.Param을 사용해야 합니다. Lettuce의 @Param은 Redis 명령어용이며 JPA와 호환되지 않습니다.

다음 diff를 적용하여 올바른 import로 수정하세요:

-import io.lettuce.core.dynamic.annotation.Param;
+import org.springframework.data.repository.query.Param;
🟡 Minor comments (5)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java-57-57 (1)

57-57: 테스트 데이터의 ISBN 형식이 유효하지 않습니다.

ISBN이 "9788954682152"(13자리)에서 "8788954682152"(12자리)로 변경되었는데, 12자리는 유효한 ISBN 형식이 아닙니다. ISBN-13은 13자리, ISBN-10은 10자리여야 합니다. 선행 "9"가 실수로 제거된 것으로 보입니다.

테스트 데이터라도 실제와 유사한 형식을 사용하는 것이 좋으며, ISBN 검증 로직이 존재할 경우 테스트가 실패할 수 있습니다.

다음 diff를 적용하여 유효한 ISBN 형식으로 복원하세요:

-        BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("8788954682152"));
+        BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java-62-62 (1)

62-62: ISBN 테스트 데이터가 표준 형식을 따르지 않으며 PR 목적과 무관함

이 변경은 다음과 같은 문제가 있습니다:

  • 유효한 ISBN-13은 978 또는 979 접두사로 시작해야 합니다. 새 ISBN "6788954682152"는 이 요구사항을 충족하지 않습니다. 기존 값 "9788954682152"는 올바른 형식이었습니다.
  • 이 PR의 목적은 게시글 좋아요 API의 Redis 비동기 큐잉 적용인데, 테스트 데이터 변경이 포함된 이유가 불명확합니다.
  • 응용 프로그램의 ISBN 검증 로직(@Pattern(regexp = "\\d{13}"))은 13자리 숫자만 확인하므로 이 형식 위반이 직접적인 검증 실패를 유발하지는 않지만, 표준을 준수하지 않는 테스트 데이터입니다.
  • 이는 다른 피드 관련 테스트 파일에서 "9788954682152"를 사용하는 패턴과 일관성이 없습니다.

유효한 ISBN-13 형식(978 또는 979 접두사)을 사용하거나, 이 변경의 의도를 설명해야 합니다.

loadtest/feed/feed-like-load-test.js-120-120 (1)

120-120: type 필드는 불린값으로 전달해야 합니다

Line 120에서 type: 'true'를 문자열로 전달하고 있습니다. API는 Boolean 타입을 기대하므로 type: true로 변경해야 합니다.

const body = JSON.stringify({ type: true });
src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java-208-226 (1)

208-226: 빈 맵에 대한 처리가 필요합니다.

idToLikeCount가 비어있을 때 불필요한 JDBC 배치 연산이 실행됩니다. findByIds처럼 early return을 추가하세요.

 @Override
 public void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount) {
+    if (idToLikeCount == null || idToLikeCount.isEmpty()) {
+        return;
+    }
     String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?";
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java-132-150 (1)

132-150: 빈 맵 처리 추가 및 중복 코드 추출을 권장합니다.

VoteCommandPersistenceAdapter와 동일하게 빈 맵에 대한 early return이 필요합니다.

또한 batchUpdateLikeCounts 구현이 VoteCommandPersistenceAdapter와 완전히 동일합니다. 공통 유틸리티 클래스나 상위 추상 클래스로 추출하면 유지보수성이 향상됩니다.

 @Override
 public void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount) {
+    if (idToLikeCount == null || idToLikeCount.isEmpty()) {
+        return;
+    }
     String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?";
🧹 Nitpick comments (21)
loadtest/feed/feed-like-load-test.js (3)

6-7: 환경 변수를 통한 설정 가능성 고려

BASE_URLFEED_ID가 하드코딩되어 있어 다른 환경(스테이징, 프로덕션 등)에서 테스트하기 어렵습니다. k6의 __ENV 객체를 사용하여 환경 변수로 설정하는 것을 권장합니다.

-const BASE_URL = 'http://localhost:8080';
-const FEED_ID = 1; // 테스트할 피드 ID
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const FEED_ID = __ENV.FEED_ID || 1; // 테스트할 피드 ID

92-92: 불필요한 배열 길이 조정

tokens 배열은 루프에서 최대 USERS_COUNT개의 요소만 추가하므로, 이 조건은 절대 참이 될 수 없습니다. 불필요한 코드입니다.

-    if (tokens.length > USERS_COUNT) tokens.length = USERS_COUNT;

155-161: 외부 의존성 및 import 위치

  1. Import 문이 파일 하단에 있어 가독성이 저하됩니다. 파일 상단으로 이동하는 것을 권장합니다.
  2. 외부 URL에서 직접 가져오는 방식은 네트워크 불안정 시 테스트 실패 원인이 될 수 있습니다. 특정 커밋 해시나 버전 태그를 사용하여 재현성을 확보하세요.
+// 파일 상단으로 이동 권장
+import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/2.4.0/dist/bundle.js";
+
 // feed-like-load-test.js
 import http from 'k6/http';
 // 테스트 결과 html 리포트로 저장
-import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js";
 export function handleSummary(data) {
src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java (1)

26-27: 명시적인 @Param 어노테이션 추가를 권장합니다.

Line 27의 메서드 파라미터에 @Param("postIds") 어노테이션이 누락되어 있습니다. 파라미터명이 JPQL 플레이스홀더와 일치하여 작동할 수 있지만, 같은 파일의 다른 쿼리 메서드들(Line 20 참조)과의 일관성 및 명시성을 위해 @Param을 추가하는 것이 좋습니다.

다음 diff를 적용하세요:

     @Query("SELECT r.postId FROM RecordJpaEntity r WHERE r.postId IN :postIds")
-    List<Long> findByPostIds(List<Long> postIds);
+    List<Long> findByPostIds(@Param("postIds") List<Long> postIds);
src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java (2)

52-58: 입력 검증 추가를 고려하세요.

postIds가 null이거나 비어있는 경우의 처리가 명시되어 있지 않습니다. 빈 리스트를 조기에 반환하거나, 최소한 JavaDoc으로 동작을 문서화하는 것을 권장합니다.

+    /**
+     * 주어진 postIds 중 실제로 DB에 존재하는 ID만 필터링하여 반환
+     * @param type 게시글 타입
+     * @param postIds 검증할 게시글 ID 목록 (null이거나 빈 리스트인 경우 빈 리스트 반환)
+     * @return 실제 존재하는 게시글 ID 목록
+     */
     public List<Long> findPostIdsByIds(PostType type, List<Long> postIds) {
+        if (postIds == null || postIds.isEmpty()) {
+            return List.of();
+        }
         return switch(type) {
             case FEED -> feedCommandPort.findByIds(postIds);
             case RECORD -> recordCommandPort.findByIds(postIds);
             case VOTE -> voteCommandPort.findByIds(postIds);
         };
     }

60-66: 입력 검증 추가를 고려하세요.

idToLikeCount가 null이거나 비어있는 경우의 처리가 명시되어 있지 않습니다. 불필요한 DB 호출을 방지하기 위해 조기 반환을 추가하거나, JavaDoc으로 전제 조건을 문서화하는 것을 권장합니다.

+    /**
+     * 게시글 좋아요 카운트를 배치로 업데이트
+     * @param type 게시글 타입
+     * @param idToLikeCount 게시글 ID와 새로운 좋아요 수의 매핑 (null이거나 빈 맵인 경우 작업 수행 안 함)
+     */
     public void batchUpdateLikeCounts(PostType type, Map<Long, Integer> idToLikeCount) {
+        if (idToLikeCount == null || idToLikeCount.isEmpty()) {
+            return;
+        }
         switch(type) {
             case FEED -> feedCommandPort.batchUpdateLikeCounts(idToLikeCount);
             case RECORD -> recordCommandPort.batchUpdateLikeCounts(idToLikeCount);
             case VOTE -> voteCommandPort.batchUpdateLikeCounts(idToLikeCount);
         }
     }
src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java (1)

7-13: 필드 유효성 검증 추가를 권장합니다.

이벤트 DTO의 필드들(userId, postId, finalLikeCount)에 대한 유효성 검증이 없어, null 값이나 음수 카운트가 전파될 수 있습니다. 이벤트 기반 아키텍처에서 잘못된 데이터가 큐를 통해 전파되면 다운스트림 컴포넌트에서 데이터 정합성 문제나 예외가 발생할 수 있습니다.

다음과 같이 compact constructor에서 검증을 추가하는 것을 고려해보세요:

 @Builder
 public record PostLikeChangedEvent(
         Long userId,
         Long postId,
         boolean isLike,
         PostType postType,
         int finalLikeCount
-) {
+) {
+    public PostLikeChangedEvent {
+        if (userId == null || postId == null || postType == null) {
+            throw new IllegalArgumentException("userId, postId, postType는 null일 수 없습니다");
+        }
+        if (finalLikeCount < 0) {
+            throw new IllegalArgumentException("finalLikeCount는 음수일 수 없습니다");
+        }
+    }
 }
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java (1)

73-96: 실제 경쟁 상태를 테스트하도록 개선이 필요합니다.

현재 테스트는 각 스레드가 서로 다른 userId를 사용하므로 진짜 race condition을 테스트하지 않습니다. Redis Set 키가 userId:postId 조합이라면, user1과 user2는 완전히 독립적인 키를 사용하여 동시성 충돌이 발생하지 않습니다.

실제 동시성 문제를 검증하려면:

  1. 같은 사용자가 동시에 토글하는 케이스 (최종 상태가 하나만 반영되어야 함)
  2. 다른 사용자들이 동시에 좋아요하는 케이스 (카운트가 정확히 증가해야 함)

추가 테스트 메서드를 작성하세요:

@Test
public void concurrentSameUserLikeToggleTest() throws InterruptedException {
    // 같은 사용자가 동시에 여러 번 토글 시도
    // 최종 상태는 1회만 반영되어야 함 (Redis의 원자적 연산 검증)
}

@Test
public void concurrentMultipleUsersLikeTest() throws InterruptedException {
    // 100명의 사용자가 동시에 좋아요
    // 최종 likeCount가 정확히 100이어야 함
}
src/main/java/konkuk/thip/roompost/domain/Record.java (1)

88-90: 파라미터명이 실제 의도와 일치하지 않습니다.

newLikeCount라는 이름은 "업데이트 후의 새로운 값"을 의미하는 것처럼 보이지만, 실제로는 "현재 좋아요 수"를 전달받아 postCountServiceisLike에 따라 증감 계산을 수행합니다.

명확성을 위해 파라미터명을 currentLikeCount로 변경하는 것을 권장합니다. 이는 Vote.java와 Feed.java에도 동일하게 적용되어야 합니다.

-    public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) {
-        likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount);
+    public void updateLikeCount(PostCountService postCountService, boolean isLike, int currentLikeCount) {
+        likeCount = postCountService.updatePostLikeCount(isLike, currentLikeCount);
     }
src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java (1)

6-11: Redis 연산의 원자성 보장에 대한 문서화가 필요합니다.

이 포트의 메서드들이 Redis에서 원자적으로 실행되는지, 아니면 별도 트랜잭션이 필요한지 명확하지 않습니다. 특히:

  1. updateLikeCountaddLikeRecordToSet/removeLikeRecordFromSet이 함께 호출될 때 원자성이 보장되는가?
  2. 여러 메서드 호출 사이에 데이터 불일치가 발생할 수 있는가?
  3. Redis 파이프라인이나 트랜잭션(MULTI/EXEC)을 사용하는가?

PR 설명에서 "정합성 보장"을 언급했으므로, 인터페이스에 원자성 보장 정책을 JavaDoc으로 명시하는 것을 권장합니다.

+/**
+ * Redis 기반 좋아요 상태 관리 Command Port
+ * 
+ * <p>주의: 개별 메서드는 원자적으로 실행되지만, 여러 메서드를 순차 호출할 경우
+ * Redis Pipeline 또는 Transaction을 사용하여 원자성을 보장해야 합니다.
+ * (예: updateLikeCount + addLikeRecordToSet을 함께 실행)
+ */
 public interface PostLikeRedisCommandPort {
src/main/java/konkuk/thip/roompost/domain/Vote.java (1)

82-84: 파라미터명이 실제 의도와 일치하지 않습니다.

Record.java와 동일한 이슈입니다. newLikeCount는 "업데이트 후의 새로운 값"처럼 읽히지만, 실제로는 현재 좋아요 수를 받아서 isLike 플래그에 따라 증감 계산합니다.

일관성과 명확성을 위해 Record.java, Vote.java, Feed.java 모두에서 파라미터명을 currentLikeCount로 변경하는 것을 권장합니다.

-    public void updateLikeCount(PostCountService postCountService, boolean isLike, int newLikeCount) {
-        likeCount = postCountService.updatePostLikeCount(isLike, newLikeCount);
+    public void updateLikeCount(PostCountService postCountService, boolean isLike, int currentLikeCount) {
+        likeCount = postCountService.updatePostLikeCount(isLike, currentLikeCount);
     }
src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java (1)

21-22: 스케줄링 간격 설정을 외부화하고 조정 고려

fixedDelay = 1000(1초)는 프로덕션 환경에서 너무 짧을 수 있습니다. 특히 큐가 자주 비어있는 경우 불필요한 오버헤드가 발생합니다.

다음을 권장합니다:

  1. 스케줄링 간격을 application.yml로 외부화
  2. 초기 설정값을 실제 부하 패턴에 따라 조정 (예: 5-10초)
  3. 큐 크기 기반 동적 조정 고려
-    @Scheduled(fixedDelay = 1000)
+    @Scheduled(fixedDelayString = "${post.like.sync.delay:5000}")
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java (3)

63-67: 테스트 환경에서는 허용되나, 프로덕션 코드에서는 사용 금지 패턴입니다.

keys("*") 명령은 O(N) 복잡도로 모든 키를 스캔하므로 프로덕션에서는 Redis 블로킹을 유발할 수 있습니다. 테스트 환경에서는 괜찮지만, 테스트용 Redis 유틸리티로 분리하거나 @DirtiesContext 또는 테스트 전용 Redis 인스턴스 사용을 권장합니다.


106-109: 주석 처리된 코드 정리가 필요합니다.

비동기 Write-Back 패턴 도입으로 DB 좋아요 카운트 검증이 불가능해진 것으로 보입니다. 주석 처리된 코드는 제거하거나, 비동기 처리 완료 후 검증하는 별도 통합 테스트로 분리하는 것이 좋습니다.


91-100: 중복되는 트랜잭션 커밋/정리 패턴을 헬퍼 메서드로 추출하는 것을 권장합니다.

동일한 패턴이 여러 테스트에서 반복됩니다. 테스트 유지보수성을 위해 공통 헬퍼 메서드로 추출하면 좋겠습니다.

private void commitAndCleanup() {
    TestTransaction.flagForCommit();
    TestTransaction.end();
    
    feedJpaRepository.deleteAllInBatch();
    bookJpaRepository.deleteAllInBatch();
    userJpaRepository.deleteAllInBatch();
    
    TestTransaction.start();
}
src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java (1)

39-39: 이벤트 리스너 메서드의 가시성을 public으로 변경하는 것을 권장합니다.

protected 메서드는 Spring의 CGLIB 프록시에서 동작할 수 있지만, 인터페이스 기반 프록시나 향후 설정 변경 시 문제가 발생할 수 있습니다. Spring 이벤트 리스너는 일반적으로 public으로 선언합니다.

-    protected void handlePostLikeChangedEvent(PostLikeChangedEvent event) {
+    public void handlePostLikeChangedEvent(PostLikeChangedEvent event) {
src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (2)

75-79: FeedChangeLikeStatusApiTest와 동일한 패턴이 반복됩니다.

Redis keys("*") 초기화 및 트랜잭션 커밋/정리 패턴이 FeedChangeLikeStatusApiTest와 중복됩니다. 테스트 베이스 클래스나 공통 유틸리티로 추출하면 테스트 코드 유지보수가 용이해집니다.


125-128: 주석 처리된 DB 좋아요 카운트 검증 코드를 정리하세요.

FeedChangeLikeStatusApiTest와 마찬가지로, 비동기 Write-Back 패턴으로 인해 즉시 DB 검증이 불가능합니다. 주석 코드를 제거하거나 별도 통합 테스트로 분리하세요.

src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java (1)

32-38: fixedRate 대신 fixedDelay 사용을 권장합니다.

fixedRate는 이전 작업 완료 여부와 관계없이 일정 간격으로 실행되므로, 대량 데이터 처리 시 작업이 겹칠 수 있습니다. PostLikeRecordSyncToDBService와 동일하게 fixedDelay를 사용하면 이전 작업 완료 후 대기 시간을 보장합니다.

-    @Scheduled(fixedRate = 60000)// 1분마다 실행
+    @Scheduled(fixedDelay = 60000) // 이전 작업 완료 후 1분 대기
src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java (1)

101-119: 빈 맵에 대한 조기 반환 처리를 추가하세요.

idToLikeCount가 빈 맵일 경우 불필요한 JDBC 배치 작업이 실행됩니다.

     @Override
     public void batchUpdateLikeCounts(Map<Long, Integer> idToLikeCount) {
+        if (idToLikeCount == null || idToLikeCount.isEmpty()) {
+            return;
+        }
         String sql = "UPDATE posts SET like_count = like_count + ? WHERE post_id = ?";
         List<Map.Entry<Long, Integer>> entries = new ArrayList<>(idToLikeCount.entrySet());
src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java (1)

117-127: incrementexpire가 원자적으로 실행되지 않습니다.

두 연산 사이에 장애가 발생하면 TTL 없이 키가 남을 수 있습니다. Lua 스크립트를 사용하거나 Redis 7+의 INCREXPIRE 조합 대신 INCRBY ... EXAT 옵션을 고려하세요.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 4052fb7 and 0c0deb0.

📒 Files selected for processing (42)
  • loadtest/feed/feed-like-load-test.js (1 hunks)
  • src/main/java/konkuk/thip/config/RedisConfig.java (2 hunks)
  • src/main/java/konkuk/thip/config/WorkerThreadConfig.java (2 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (5 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (3 hunks)
  • src/main/java/konkuk/thip/feed/domain/Feed.java (1 hunks)
  • src/main/java/konkuk/thip/post/adapter/out/event/PostLikeEventSyncAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/post/adapter/out/event/dto/PostLikeChangedEvent.java (1 hunks)
  • src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/port/out/dto/PostLikeQueueMessage.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/service/PostLikeService.java (3 hunks)
  • src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java (2 hunks)
  • src/main/java/konkuk/thip/post/domain/CountUpdatable.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java (5 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java (5 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java (3 hunks)
  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java (3 hunks)
  • src/main/java/konkuk/thip/roompost/domain/Record.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/domain/Vote.java (1 hunks)
  • src/test/java/konkuk/thip/config/TestAsyncConfig.java (2 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java (7 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java (5 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/domain/FeedTest.java (3 hunks)
  • src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (10 hunks)
  • src/test/java/konkuk/thip/roompost/domain/RecordTest.java (3 hunks)
  • src/test/java/konkuk/thip/roompost/domain/VoteTest.java (3 hunks)
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-07-03T03:05:05.031Z
Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

Applied to files:

  • src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisCommandPort.java
  • src/main/java/konkuk/thip/post/application/service/PostLikeService.java
  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java
  • src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java
  • src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueCommandPort.java
📚 Learning: 2025-09-01T13:18:13.652Z
Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 287
File: src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java:8-14
Timestamp: 2025-09-01T13:18:13.652Z
Learning: seongjunnoh는 JpaRepository의 findById 메서드 재정의보다는 도메인별 명시적 메서드(findByUserId, findByRoomId 등)를 정의하여 Hibernate Filter 적용을 보장하는 방식을 선호하며, 이를 통해 더 안전하고 의도가 명확한 코드 구조를 구축한다.

Applied to files:

  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java
📚 Learning: 2025-06-29T09:17:39.787Z
Learnt from: hd0rable
Repo: THIP-TextHip/THIP-Server PR: 36
File: build.gradle:30-30
Timestamp: 2025-06-29T09:17:39.787Z
Learning: THIP-Server 프로젝트에서 Redis 의존성(spring-boot-starter-data-redis)은 향후 사용 예정으로 의도적으로 추가되었음. 현재는 사용하지 않지만 추후 개발에서 활용할 계획임.

Applied to files:

  • src/main/java/konkuk/thip/config/RedisConfig.java
📚 Learning: 2025-07-14T14:19:38.796Z
Learnt from: buzz0331
Repo: THIP-TextHip/THIP-Server PR: 75
File: src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java:50-83
Timestamp: 2025-07-14T14:19:38.796Z
Learning: Vote와 VoteItem 엔티티는 자주 함께 사용되므로, N+1 문제를 방지하기 위해 양방향 매핑과 fetch join을 고려하는 것이 좋습니다. 특히 기록장 조회 API 등에서도 함께 사용될 가능성이 높습니다.

Applied to files:

  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java
📚 Learning: 2025-07-26T06:09:00.850Z
Learnt from: seongjunnoh
Repo: THIP-TextHip/THIP-Server PR: 101
File: src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java:36-39
Timestamp: 2025-07-26T06:09:00.850Z
Learning: THIP 프로젝트에서 Record와 Vote는 Room에 속하지만 Feed는 Room에 속하지 않는 구조이며, 댓글 작성 시 Record/Vote에 대해서만 사용자가 해당 Room의 참가자인지 검증이 필요하다.

Applied to files:

  • src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java
🧬 Code graph analysis (9)
src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java (1)
src/main/java/konkuk/thip/post/application/port/out/PostLikeCommandPort.java (1)
  • PostLikeCommandPort (5-10)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedDeleteApiTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusConcurrencyTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java (1)
src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java (1)
  • Service (20-88)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
src/main/java/konkuk/thip/post/application/service/PostLikeCountSyncToDBService.java (1)
src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java (1)
  • Service (13-40)
src/main/java/konkuk/thip/config/WorkerThreadConfig.java (1)
src/test/java/konkuk/thip/config/TestAsyncConfig.java (1)
  • EnableAsync (13-37)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (26)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedApiTest.java (1)

62-62: PR 목표와 무관한 변경사항을 확인해주세요.

이 테스트는 피드 저장 기능에 대한 것으로, PR의 주요 목표인 좋아요 API의 Redis 비동기 큐잉 리팩토링과 관련이 없습니다. ISBN 변경(9788954682152 → 9788954682151)이 의도적인 것인지 확인이 필요합니다.

  • 이 변경이 의도적이라면, 별도의 커밋으로 분리하거나 커밋 메시지에 변경 이유를 명시하는 것이 좋습니다.
  • 만약 실수로 포함된 변경이라면, 제거하거나 별도 PR로 분리해주세요.
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateApiTest.java (1)

61-61: 테스트 데이터 ISBN 변경이 PR 목표와 관련 없어 보입니다.

이 PR의 핵심 목표는 Redis 기반 비동기 큐잉을 통한 좋아요 API 성능 개선인데, 테스트 데이터의 ISBN 값이 "9788954682152"에서 "7788954682152"로 변경되었습니다.

추가로, 새로운 ISBN "7788954682152"는 유효한 ISBN-13 형식(978 또는 979로 시작)을 따르지 않습니다. 테스트 데이터임을 명확히 하기 위한 의도적 변경인지, 아니면 다른 이유가 있는지 확인이 필요합니다.

다른 테스트 파일들(FeedCreateApiTest, FeedChangeSavedApiTest)에서도 유사한 변경이 있다고 하니, 혹시 테스트 데이터 표준화 작업의 일환인가요? 이 변경의 목적과 PR에 포함된 이유를 명확히 해주시면 좋을 것 같습니다.

loadtest/feed/feed-like-load-test.js (3)

14-31: LGTM!

커스텀 메트릭 정의와 에러 코드 매핑이 잘 구성되어 있습니다. 특정 에러 코드별 카운터를 통해 실패 원인 분석이 용이합니다.


33-45: LGTM!

방어적 코딩이 잘 적용되어 있으며, JSON 파싱 실패 시 안전한 기본값을 반환합니다.


47-64: LGTM!

per-vu-iterations executor를 사용한 시나리오 설계가 적절합니다. 모든 VU가 정확히 1회 실행되어 동시 요청 시뮬레이션에 적합하며, 임계값 설정도 합리적입니다.

src/main/java/konkuk/thip/config/RedisConfig.java (1)

33-40: 좋아요 카운트용 Integer 전용 RedisTemplate 설정이 적절합니다.

GenericToStringSerializer<Integer>를 사용하여 정수값을 문자열로 직렬화하는 방식은 Redis 카운터 연산에 적합하며, 가독성도 좋습니다. Redis의 INCR/DECR 명령어와도 호환되는 구성입니다.

src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java (1)

32-33: 배치 조회 메서드가 올바르게 구현되었습니다.

@Param 어노테이션과 파라미터명이 정확히 일치하며, JPQL의 IN 절을 사용한 배치 조회 패턴이 적절합니다. 다른 repository들도 이 구현 방식을 따르도록 해야 합니다.

src/main/java/konkuk/thip/post/application/port/out/PostLikeEventCommandPort.java (1)

5-9: 이벤트 발행 포트 설계가 적절합니다.

메서드 시그니처가 명확하며 이벤트 기반 아키텍처 패턴에 적합합니다. isLike 플래그와 finalLikeCount를 통해 최종 정합성을 보장하는 설계가 잘 반영되어 있습니다.

src/main/java/konkuk/thip/config/WorkerThreadConfig.java (1)

16-16: proxyTargetClass = true 설정이 적절합니다.

CGLIB 기반 프록시로 변경하여 인터페이스 없이도 @Async 메서드 호출이 가능하도록 구성되었습니다. 테스트 설정과도 일관성이 유지됩니다.

src/test/java/konkuk/thip/config/TestAsyncConfig.java (1)

13-13: 테스트용 비동기 설정이 올바르게 구성되었습니다.

proxyTargetClass = true 설정이 프로덕션 설정과 일치하며, SyncTaskExecutor를 사용하여 테스트 환경에서 동기 실행을 강제함으로써 테스트의 결정성과 안정성을 보장합니다.

Also applies to: 28-31

src/test/java/konkuk/thip/roompost/domain/VoteTest.java (1)

145-180: 테스트 시그니처 업데이트가 올바르게 적용되었습니다.

updateLikeCount 메서드의 세 번째 파라미터로 vote.getLikeCount()를 전달하여 현재 좋아요 수를 명시적으로 제공하는 것은 Redis 기반 동기화 아키텍처에서 명시적 상태 전달을 위한 적절한 변경입니다.

다만, 도메인 메서드의 파라미터명이 newLikeCount인데 실제로는 currentLikeCount를 전달하고 있어 혼란스러울 수 있습니다. 이는 Vote.java, Record.java 도메인 파일에서 파라미터명을 currentLikeCount로 변경하면 더 명확할 것입니다.

src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1)

27-27: 배치 업데이트 메서드가 적절하게 추가되었습니다.

Redis 비동기 큐잉에서 워커가 여러 게시글의 좋아요 수를 한 번에 DB에 동기화하기 위한 batchUpdateLikeCounts 메서드는 성능 최적화에 효과적입니다. Write-Back 패턴 구현에 적합한 설계입니다.

src/main/java/konkuk/thip/post/application/port/out/PostLikeQueueConsumerPort.java (1)

1-8: LGTM!

큐 소비를 위한 명확하고 간결한 인터페이스입니다. Optional 반환 타입을 사용하여 메시지가 없는 경우를 적절히 처리하고 있습니다.

src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java (2)

31-31: LGTM!

배치 좋아요 카운트 업데이트 메서드가 적절하게 추가되었습니다. Redis에서 DB로의 Write-Back 동기화 패턴을 효율적으로 지원합니다. 프로젝트 학습 내용에 따라 CommandPort에 적합한 메서드입니다.


20-20: 이 메서드는 의도적으로 List<Long>을 반환하고 있습니다. findByIds는 입력된 ID 목록 중 데이터베이스에 실제로 존재하는 ID들을 필터링하여 반환하는 메서드로, PostHandler.findPostIdsByIds() 메서드에서 사용 중입니다. 유사한 목적으로 FeedCommandPort, VoteCommandPort도 동일하게 List<Long>을 반환하고 있으며, 이는 엔티티 조회가 아닌 ID 존재 여부 검증을 위한 설계입니다.

Likely an incorrect or invalid review comment.

src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java (2)

57-57: LGTM!

배치 좋아요 카운트 업데이트 메서드가 일관되게 추가되었습니다. RecordCommandPortFeedCommandPort와 동일한 패턴을 따릅니다.


25-25: 메서드 반환 타입은 정확함 - 재검토 불필요

findByIds 메서드는 List을 반환하는 것이 올바른 설계입니다. 이 메서드는 벌크 엔티티 조회가 아닌 ID 검증/필터링 목적으로 설계되었습니다. FeedCommandPort, RecordCommandPort, VoteCommandPort 모두 동일한 패턴을 따르고 있으며, 데이터베이스에 존재하는 ID들을 필터링하여 반환합니다. 엔티티 객체 조회가 필요한 경우 findById(Long id) 같은 별도의 메서드를 사용합니다.

Likely an incorrect or invalid review comment.

src/test/java/konkuk/thip/roompost/domain/RecordTest.java (1)

145-176: LGTM!

테스트가 새로운 updateLikeCount 시그니처에 맞춰 올바르게 업데이트되었습니다. 특히 Line 176의 언더플로우 테스트에서 record.getLikeCount()-1을 전달하여 검증 로직을 적절히 테스트하고 있습니다.

src/main/java/konkuk/thip/feed/domain/Feed.java (1)

158-160: Redis 기반 좋아요 수 업데이트는 설계 의도대로 구현됨

updateLikeCount 메서드 변경사항을 검증했습니다. PostLikeService에서는 Redis 캐시를 통해 최신 좋아요 수를 조회한 후 정확하게 전달하고 있으며, 캐시 미스 시 데이터베이스 값을 fallback으로 사용합니다. PostCountService는 언더플로우 검증을 수행하므로 데이터 일관성이 보장됩니다.

src/main/java/konkuk/thip/post/domain/CountUpdatable.java (1)

8-10: 인터페이스 변경사항 확인 완료

CountUpdatable 인터페이스 변경이 구현체에서 올바르게 반영되었습니다:

  1. updateLikeCount 메서드의 newLikeCount 파라미터 추가 - Feed에서 정상 구현됨 (lines 157-160)
  2. getLikeCount() 메서드 - Feed의 @Getter 어노테이션에 의해 자동 생성됨 (likeCount 필드에서)

Feed 클래스가 유일한 직접 구현체이며, 모든 인터페이스 계약을 정상적으로 이행하고 있습니다.

src/main/java/konkuk/thip/post/application/port/out/PostLikeRedisQueryPort.java (1)

6-10: LGTM!

인터페이스 설계가 CQRS QueryPort 컨벤션을 잘 따르고 있습니다. Redis 기반 좋아요 조회를 위한 책임이 명확하게 정의되어 있습니다.

getAllLikeCounts()의 반환 타입이 Map<String, Integer>인 것은 Redis 키 형식(예: "FEED:123")을 그대로 사용하기 위한 것으로 보입니다. 필요시 호출부에서 파싱하여 사용하면 됩니다.

src/test/java/konkuk/thip/feed/domain/FeedTest.java (1)

225-264: LGTM!

updateLikeCount API 시그니처 변경에 맞춰 테스트가 올바르게 업데이트되었습니다. 증가, 감소, 언더플로우 케이스가 모두 적절히 검증되고 있습니다.

src/main/java/konkuk/thip/post/application/service/PostLikeService.java (2)

51-52: 알림 전송 코드가 주석 처리되어 있습니다.

좋아요 푸시 알림 기능이 비활성화되어 있습니다. 의도적인 변경이라면 코드를 완전히 제거하거나, 임시 조치라면 TODO 주석과 함께 관련 이슈를 추적할 수 있도록 해주세요.


57-64: Redis 기반 비동기 아키텍처가 올바르게 구현되었습니다.

PostLikeEventSyncAdapter에서 이벤트 발행 시 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 어노테이션을 통해 트랜잭션 커밋 후에만 이벤트 리스너가 실행되도록 보장합니다. 추가로 @Async 어노테이션으로 비동기 처리를 통해 도메인 상태 갱신(Redis 카운트 주입), 이벤트 발행, 후속 처리(Redis 업데이트, 큐 삽입)의 순서가 안전하게 구분되어 있습니다.

src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java (1)

67-72: LGTM!

null/empty 입력에 대한 방어 로직이 적절히 구현되어 있습니다.

src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java (1)

27-42: 이벤트 큐잉 구현이 적절합니다.

leftPush/rightPop 패턴으로 FIFO 순서가 보장되며, 직렬화 실패 시 RuntimeException으로 명확히 실패를 전파합니다.

Comment on lines +57 to +72
/**
* 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;
}
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.

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.

Comment on lines +37 to +57
@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);
}
}
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);
 }

Comment on lines +56 to +61
} catch (Exception e) {
log.error("Failed to deserialize like record: {}", jsonRecord, e);
// 디시리얼라이즈 실패 메시지는 무시하고 다음 메시지를 시도하거나, DLQ로 보내야 함.
// 여기서는 유실을 가정하고 Optional.empty() 반환
return Optional.empty();
}
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

역직렬화 실패 시 메시지 유실 문제가 있습니다.

주석에서 DLQ(Dead Letter Queue) 처리의 필요성을 언급하고 있으나 실제 구현이 없습니다. 역직렬화 실패 메시지를 별도 키(예: post-like-queue:dlq)에 보관하여 추후 분석 및 복구가 가능하도록 해주세요. 좋아요 기록의 정합성 보장을 위해 중요합니다.

         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() 반환
+            // DLQ에 실패 메시지 저장
+            stringRedisTemplate.opsForList().leftPush(postLikeQueuePrefix + ":dlq", jsonRecord);
             return Optional.empty();
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (Exception e) {
log.error("Failed to deserialize like record: {}", jsonRecord, e);
// 디시리얼라이즈 실패 메시지는 무시하고 다음 메시지를 시도하거나, DLQ로 보내야 함.
// 여기서는 유실을 가정하고 Optional.empty() 반환
return Optional.empty();
}
} catch (Exception e) {
log.error("Failed to deserialize like record: {}", jsonRecord, e);
// DLQ에 실패 메시지 저장
stringRedisTemplate.opsForList().leftPush(postLikeQueuePrefix + ":dlq", jsonRecord);
return Optional.empty();
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueueRedisAdapter.java
around lines 56 to 61, the catch block currently logs deserialization failures
and drops the message; instead, push the failed jsonRecord into a Redis DLQ key
(e.g., "post-like-queue:dlq") before returning Optional.empty(). Implement this
by using the existing Redis client/redisTemplate (or inject StringRedisTemplate
if not present), call the appropriate leftPush/rightPush to store the raw
jsonRecord and include a log.info/log.error that records the DLQ key and
original payload; ensure any Redis errors are caught and logged but do not
rethrow so the method still returns Optional.empty().

Comment on lines +46 to +51
public Map<String, Integer> getAllLikeCounts() {
// 모든 좋아요 카운트 키 검색
Set<String> keys = redisTemplate.keys(postLikeCountPrefix + "*");
if (keys == null || keys.isEmpty()) {
return Collections.emptyMap();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

프로덕션 환경에서 keys() 명령어 사용은 위험합니다.

keys()는 O(N) 복잡도로 Redis를 블로킹하며, 키가 많을 경우 심각한 성능 저하를 유발합니다. SCAN 명령어로 대체하여 점진적으로 키를 조회해야 합니다.

     @Override
     public Map<String, Integer> getAllLikeCounts() {
-        // 모든 좋아요 카운트 키 검색
-        Set<String> keys = redisTemplate.keys(postLikeCountPrefix + "*");
-        if (keys == null || keys.isEmpty()) {
-            return Collections.emptyMap();
-        }
+        Set<String> keys = new HashSet<>();
+        ScanOptions scanOptions = ScanOptions.scanOptions()
+                .match(postLikeCountPrefix + "*")
+                .count(100)
+                .build();
+        try (Cursor<String> cursor = redisTemplate.scan(scanOptions)) {
+            while (cursor.hasNext()) {
+                keys.add(cursor.next());
+            }
+        }
+        if (keys.isEmpty()) {
+            return Collections.emptyMap();
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Map<String, Integer> getAllLikeCounts() {
// 모든 좋아요 카운트 키 검색
Set<String> keys = redisTemplate.keys(postLikeCountPrefix + "*");
if (keys == null || keys.isEmpty()) {
return Collections.emptyMap();
}
public Map<String, Integer> getAllLikeCounts() {
Set<String> keys = new HashSet<>();
ScanOptions scanOptions = ScanOptions.scanOptions()
.match(postLikeCountPrefix + "*")
.count(100)
.build();
try (Cursor<String> cursor = redisTemplate.scan(scanOptions)) {
while (cursor.hasNext()) {
keys.add(cursor.next());
}
}
if (keys.isEmpty()) {
return Collections.emptyMap();
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeRedisAdapter.java
around lines 46 to 51, the method uses redisTemplate.keys(postLikeCountPrefix +
"*") which is blocking and unsafe in production; replace it with a non-blocking
SCAN-based iteration (Redis SCAN via RedisTemplate.scan or RedisConnection.scan
with a ScanOptions match pattern) to progressively fetch matching keys, iterate
the Cursor, collect key names, read their values and aggregate into the Map, and
ensure the cursor/connection is closed after use; also handle empty results and
any Redis exceptions gracefully.

Comment on lines +23 to +38
public void syncRecordsFromQueue() {
Optional<PostLikeQueueMessage> 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();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

트랜잭션 경계 및 에러 처리 개선 필요

현재 구현에 여러 심각한 문제가 있습니다:

Critical:

  1. 무제한 트랜잭션: 큐에 메시지가 많을 경우 하나의 긴 트랜잭션에서 모두 처리되어 락 경합 및 성능 저하 발생
  2. 에러 처리 부재: 하나의 메시지 처리 실패 시 전체 배치가 롤백됨
  3. 배치 크기 제한 없음: 메모리 및 성능 문제 가능성

Major:
4. 문자열 비교: "SAVE"/"DELETE" 대신 enum 사용 권장
5. 관찰성 부족: 로깅/메트릭 없음

다음과 같은 개선을 제안합니다:

     @Scheduled(fixedDelay = 1000)
-    @Transactional
     public void syncRecordsFromQueue() {
+        int batchSize = 100;  // 설정 가능하게 만들기
+        int processedCount = 0;
+        
         Optional<PostLikeQueueMessage> messageOptional = postLikeQueueConsumerPort.consumeOne();
 
-        while (messageOptional.isPresent()) {
+        while (messageOptional.isPresent() && processedCount < batchSize) {
-            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());
+            try {
+                processOneMessage(messageOptional.get());
+                processedCount++;
+            } catch (Exception e) {
+                log.error("Failed to process like message: {}", messageOptional.get(), e);
+                // DLQ로 이동 또는 재시도 로직 추가
             }
 
             // 다음 메시지 가져오기
             messageOptional = postLikeQueueConsumerPort.consumeOne();
         }
+        
+        if (processedCount > 0) {
+            log.info("Processed {} like records from queue", processedCount);
+        }
+    }
+    
+    @Transactional
+    private void processOneMessage(PostLikeQueueMessage command) {
+        // enum 사용 권장
+        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());
+        }
     }

각 메시지를 별도 트랜잭션으로 처리하여 격리성을 확보하고, 에러 처리 및 배치 크기 제한을 추가합니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java
around lines 23-38, refactor the loop so each queue message is processed in its
own transaction and add per-message error handling and a batch-size cap: consume
up to a configurable maxBatch (e.g. loop counter) instead of unlimited, for each
message invoke a transactional helper (or annotate a small private method
@Transactional) that switches on an Action enum (replace raw "SAVE"/"DELETE"
string checks by parsing into an enum) and performs save/delete, wrap that call
in try/catch to log the error/metric and continue to the next message (do not
let one failure roll back other messages), and ensure basic logging/metrics are
emitted for success and failure.

Comment on lines +30 to +34
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());
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

액션 타입을 enum으로 변경 권장

문자열 리터럴 "SAVE""DELETE" 대신 enum을 사용하면 타입 안전성과 유지보수성이 향상됩니다. 또한 알 수 없는 액션에 대한 처리도 추가해야 합니다.

public enum PostLikeAction {
    SAVE, DELETE
}

// PostLikeQueueMessage에서 사용
public record PostLikeQueueMessage(
    Long userId,
    Long postId,
    PostLikeAction action,  // String -> enum
    PostType postType
) {}

// 처리 로직
switch (command.action()) {
    case SAVE -> postLikeCommandPort.save(command.userId(), command.postId(), command.postType());
    case DELETE -> postLikeCommandPort.delete(command.userId(), command.postId());
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/post/application/service/PostLikeRecordSyncToDBService.java
around lines 30-34, replace the string literal action checks with a
PostLikeAction enum to make actions type-safe: add a PostLikeAction enum with
SAVE and DELETE, update PostLikeQueueMessage (and any deserialization/mapping)
to use PostLikeAction instead of String, change the if/else to a switch on
command.action() calling postLikeCommandPort.save(...) for SAVE and
postLikeCommandPort.delete(...) for DELETE, and add a default branch that logs
or throws for unknown/null actions to ensure explicit handling.

Comment on lines +26 to +27
@Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.postId IN :postIds")
List<Long> findByPostIds(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 | 🔴 Critical

JPQL 파라미터명과 메서드 파라미터명이 불일치하여 런타임 오류가 발생합니다.

Line 26의 JPQL은 :postIds를 참조하지만, Line 27의 메서드 파라미터명은 ids로 선언되어 있습니다. 이로 인해 메서드 호출 시 org.springframework.dao.InvalidDataAccessApiUsageException이 발생합니다. 또한 @Param 어노테이션이 누락되어 있습니다.

다음 diff를 적용하여 수정하세요:

     @Query("SELECT v.postId FROM VoteJpaEntity v WHERE v.postId IN :postIds")
-    List<Long> findByPostIds(List<Long> ids);
+    List<Long> findByPostIds(@Param("postIds") List<Long> postIds);
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java
around lines 26-27, the JPQL uses :postIds but the method parameter is named ids
and lacks @Param, causing a runtime InvalidDataAccessApiUsageException; fix it
by either renaming the method parameter to postIds or annotate the existing
parameter with @Param("postIds") so the JPQL parameter and method parameter
match, then run tests to verify the query executes without error.

Comment on lines +59 to +107
@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)
);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

최종 상태 검증이 누락되었습니다.

동시성 테스트에서 예외 발생 여부만 확인하고 있으나, 실제 데이터 정합성은 검증하지 않고 있습니다. 각 사용자가 독립적으로 토글하므로(user1과 user2가 서로 다른 userId 사용) 진짜 race condition을 테스트하지 않습니다.

Redis 비동기 큐잉 아키텍처의 핵심 검증 사항:

  • 최종 좋아요 상태(user1, user2 각각 좋아요 여부)가 예상과 일치하는가?
  • Redis Set의 상태와 DB의 최종 상태가 일관성 있는가?
  • 게시글의 최종 likeCount가 정확한가?

테스트 종료 후 다음 검증을 추가하세요:

         latch.await();
         executor.shutdown();
+        
+        // 백그라운드 워커가 큐를 처리할 시간 대기
+        Thread.sleep(2000);

         // then
         assertAll(
                 () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat),
-                () -> assertThat(failCount.get()).isEqualTo(0)
+                () -> assertThat(failCount.get()).isEqualTo(0),
+                // 각 사용자의 최종 좋아요 상태 검증 (짝수번 토글이므로 초기값과 동일)
+                () -> assertThat(postLikeService.isLikedPostByUser(
+                        new PostIsLikeCommand(user1.getUserId(), feed.getPostId(), PostType.FEED, true)
+                )).isFalse(),
+                () -> assertThat(postLikeService.isLikedPostByUser(
+                        new PostIsLikeCommand(user2.getUserId(), feed.getPostId(), PostType.FEED, true)
+                )).isFalse(),
+                // 최종 좋아요 카운트 검증
+                () -> {
+                    FeedJpaEntity updatedFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow();
+                    assertThat(updatedFeed.getLikeCount()).isEqualTo(0);
+                }
         );

Committable suggestion skipped: line range outside the PR's diff.


// given
bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
bookJpaRepository.save(TestEntityFactory.createBookWithISBN("3788954682152"));
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

테스트 데이터 불일치로 인한 테스트 로직 오류

다섯 개의 테스트 모두에서 저장된 Book의 ISBN과 요청 시 사용하는 ISBN이 일치하지 않습니다:

  • 75번 라인: DB에 "3788954682152" 저장, 요청은 "9788954682152" 사용, 검증은 "9788954682152" 기대 (106번 라인)
  • 161번 라인: DB에 "1788954682152" 저장, 요청은 "9788954682152" 사용, 검증은 "9788954682152" 기대 (190번 라인)
  • 209, 242, 282번 라인도 동일한 패턴

특히 createFeedWithBookExistsInDB 테스트는 "DB에 책이 존재할 때"를 테스트한다고 명시하지만, 실제로는 요청한 ISBN의 책이 DB에 존재하지 않는 상황을 만들고 있습니다. 이 경우 외부 API를 호출하거나 실패해야 하는데, 검증은 요청한 ISBN을 가진 책이 저장되었다고 기대합니다.

또한 이 변경사항들은 PR의 목적(좋아요 API 리팩토링)과 무관해 보입니다.

모든 테스트에서 저장하는 Book의 ISBN을 요청 시 사용하는 ISBN과 동일하게 수정하세요:

-        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("3788954682152"));
+        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
-        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1788954682152"));
+        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
-        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("4788954682152"));
+        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
-        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("2788954682152"));
+        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));
-        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("5788954682152"));
+        bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152"));

만약 테스트 간 격리를 위해 서로 다른 ISBN이 필요하다면, 요청 payload의 ISBN도 함께 변경하여 일관성을 유지해야 합니다.

Also applies to: 161-161, 209-209, 242-242, 282-282

🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateApiTest.java around
lines 75, 161, 209, 242, 282, the tests save Books with ISBNs that do not match
the ISBN used in the request payloads and assertions (e.g., line 75 saves
"3788954682152" while the test requests and expects "9788954682152"), causing
incorrect test logic; update each saved
TestEntityFactory.createBookWithISBN(...) call so the ISBN exactly matches the
ISBN used in that test's request and assertion (or alternatively change the test
request payloads to match the saved ISBNs) to ensure each test truly reflects
the intended DB-existing vs non-existing scenarios and maintains isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[THIP2025-395] [refactor] 게시글 좋아요 api 서버 처리율 개선 - 레디스 비동기 큐잉 적용

2 participants