Skip to content

Conversation

@hd0rable
Copy link
Member

@hd0rable hd0rable commented Nov 12, 2025

#️⃣ 연관된 이슈

closes #333
closes #322

📝 작업 내용

  • 현재 게시글 좋아요에 동시에 요청이 발생할시 데드락이 걸리는 500 에러를 비관락을 적용하여 해결하였습니다.
  • 데드락 에러를 해결하기위해 비관락을 선택한 과정을 노션에 문서화해두었습니다.
  • 저번 회의때 발표한것과는 다르개 낙관락의 재시도 로직을 현실적으로 줄여서 다시 수정하여 문서화 업데이트해두었습니다
    https://separate-snowplow-d8b.notion.site/API-294b701eb0b880088501d46c49b434ad?source=copy_link
  • 추가로 낙관,비관락을 비교하기위해 사용한 k6 부하 스크립트도 추가하였습니닷
  • x-lock을 도입해 성능이 병목될것을 우려하여 성능을 개선하기위해 추후에 부하스크립트 시나리오 구체화 및, 레디스 INCR연산을 사용하여 성능을 개선할 예정입니다! 우선은 데드락 에러를 해결한 부분만 포함하여 pr올립니닷

📸 스크린샷

💬 리뷰 요구사항

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

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

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

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 좋아요 기능에 재시도 메커니즘 추가로 안정성 개선
    • 동시 접근 환경에서 좋아요 토글 요청 처리 최적화
  • 버그 수정

    • 리소스 잠금 상황에 대한 오류 처리 강화
  • 테스트

    • 동시성 시나리오에 대한 부하 테스트 추가
    • 통합 테스트 격리 메커니즘 개선
  • Chores

    • 재시도 기능 지원을 위한 의존성 추가

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

@coderabbitai
Copy link

coderabbitai bot commented Nov 12, 2025

개요

이 PR은 게시글 좋아요 기능의 동시성 이슈를 해결하기 위해 비관적 락(pessimistic write lock)을 도입하고, Spring Retry를 통한 자동 재시도 메커니즘을 추가하며, k6 부하 테스트 스크립트를 포함합니다.

Walkthrough

게시글 좋아요 기능의 동시성 문제 해결을 위해 데이터베이스 레벨의 비관적 쓰기 락을 적용하고, 스프링 Retry를 이용한 재시도 로직과 트랜잭션 격리를 추가합니다. Feed, Record, Vote 엔티티별로 락 기능이 구현되며, k6 부하 테스트 스크립트 3개가 추가됩니다.

Changes

코호트 / 파일 변경 요약
K6 부하 테스트 스크립트
loadtest/feed/feed_like_concurrency_test1.js, loadtest/feed/feed_like_concurrency_test2.js, loadtest/feed/feed_like_concurrency_test3.js
각각 다른 시나리오의 동시성 테스트 스크립트 추가: test1은 20 VU 고정, test2는 20-150 VU 단계적 증가, test3은 읽기/쓰기 병렬 시나리오. 토큰 사전 생성, 좋아요 상태 토글, 응답 검증 포함.
Feed 엔티티 락 기능
src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java, src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java, src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java
findByIdForUpdate 메서드와 getByIdOrThrowForUpdate 헬퍼 메서드 추가. JPA 리포지토리에 PESSIMISTIC_WRITE 락 적용 쿼리 추가.
Record 엔티티 락 기능
src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java
Record 엔티티에 대한 비관적 쓰기 락 기능 구현. Feed와 동일한 패턴 적용.
Vote 엔티티 락 기능
src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java, src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java
Vote 엔티티에 대한 비관적 쓰기 락 기능 구현. 일관된 락 전략 적용.
PostLikeService 재시도 & 트랜잭션 처리
src/main/java/konkuk/thip/post/application/service/PostLikeService.java, src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java
changeLikeStatusPost에 @Retryable(최대 3회, 100ms 지연), @Transactional(REQUIRES_NEW) 추가. findPostForUpdate 메서드 신규 추가. 사전 좋아요 수 업데이트 로직 포함.
Retry 설정 & 에러 코드
src/main/java/konkuk/thip/config/RetryConfig.java, src/main/java/konkuk/thip/common/exception/code/ErrorCode.java, build.gradle
RetryConfig에 @EnableRetry 설정 추가. RESOURCE_LOCKED(상태: LOCKED, 코드: 50200) 에러 상수 추가. spring-retry 의존성 추가.
테스트 업데이트
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java, src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java, src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java, src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java
@Transactional 제거, @AfterEach tearDown() 메서드 추가로 테스트 격리 개선. 동시성 테스트 클래스 신규 추가(CountDownLatch, 스레드 풀 활용). 테스트 전제 조건 명시화.
기타 설정
src/main/java/konkuk/thip/ThipServerApplication.java
EnableRetry 임포트 추가 (사용 대기 중).

예상 코드 리뷰 노력

🎯 3 (보통) | ⏱️ ~20분

추가 검토 대상:

  • PostLikeService의 @Retryable 설정에서 notRecoverable 예외 처리 및 @Recover 메서드의 BusinessException 변환 로직이 의도대로 동작하는지 확인
  • 비관적 쓰기 락의 3초 타임아웃 설정이 모든 시나리오에서 적절한지 검토 (Feed, Record, Vote 일관성)
  • k6 테스트 스크립트의 세 가지 시나리오가 실제 부하 테스트 목표와 일치하는지 확인
  • 테스트 클래스에서 @Transactional 제거 후 데이터 정리가 모든 케이스에서 완전한지 검증
  • findPostForUpdate 메서드가 PostType별로 올바른 포트의 getByIdOrThrowForUpdate를 호출하는지 확인

관련 PR

제안 리뷰어

  • seongjunnoh
  • buzz0331

🐰 부하 테스트 스크립트 k6로 달려가고,
비관락 데이터베이스 지키고,
재시도 메커니즘이 똑똑하게 신호를 보낼 때,
동시성 문제는 사라지고,
좋아요 기능이 데드락 없이 춤을 춥니다! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.68% 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 제목은 게시글 좋아요 500 에러(데드락) 해결이라는 주요 변경사항을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed 변경사항이 연관 이슈 #333(비관적 락 적용으로 데드락 해결)과 #322(k6 부하 테스트 스크립트 추가)의 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 데드락 해결과 부하 테스트라는 PR 목표와 관련 있으며 범위를 벗어난 변경이 없습니다.
✨ 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-pessimistic-lock

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

github-actions bot commented Nov 12, 2025

Test Results

488 tests   488 ✅  50s ⏱️
145 suites    0 💤
145 files      0 ❌

Results for commit 484f714.

♻️ This comment has been updated with latest results.

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: 8

🧹 Nitpick comments (1)
src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java (1)

21-23: 비관적 락 타임아웃 설정 추가 권장

PESSIMISTIC_WRITE 락이 올바르게 적용되었습니다. 그러나 락 대기 시 무한 대기를 방지하기 위해 @QueryHints로 타임아웃을 설정하는 것이 좋습니다.

다음 diff를 적용하여 타임아웃을 추가하세요:

 @Lock(LockModeType.PESSIMISTIC_WRITE)
+@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
 @Query("SELECT r FROM RecordJpaEntity r WHERE r.postId = :postId")
 Optional<RecordJpaEntity> findByPostIdForUpdate(@Param("postId") Long postId);

import 추가:

import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.QueryHints;
📜 Review details

Configuration used: CodeRabbit 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 37247d1 and 3b9abb9.

📒 Files selected for processing (17)
  • loadtest/feed/feed_like_concurrency_test1.js (1 hunks)
  • loadtest/feed/feed_like_concurrency_test2.js (1 hunks)
  • loadtest/feed/feed_like_concurrency_test3.js (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java (2 hunks)
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/service/PostLikeService.java (1 hunks)
  • src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteCommandPersistenceAdapter.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java (2 hunks)
  • src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java (2 hunks)
  • src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java (1 hunks)
  • src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (2 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 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/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java
📚 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/roompost/application/port/out/RecordCommandPort.java
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java
  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.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/RecordCommandPersistenceAdapter.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/adapter/out/persistence/VoteCommandPersistenceAdapter.java
  • src/main/java/konkuk/thip/roompost/application/port/out/VoteCommandPort.java
🧬 Code graph analysis (4)
loadtest/feed/feed_like_concurrency_test2.js (2)
loadtest/feed/feed_like_concurrency_test1.js (8)
  • BASE_URL (5-5)
  • setup (21-34)
  • tokens (22-22)
  • likeStatus (23-23)
  • userId (26-26)
  • res (27-27)
  • res (56-56)
  • token (38-38)
loadtest/feed/feed_like_concurrency_test3.js (10)
  • BASE_URL (5-5)
  • setup (30-43)
  • maxVUs (32-32)
  • tokens (33-33)
  • userId (36-36)
  • res (37-37)
  • res (56-56)
  • res (95-95)
  • token (48-48)
  • token (73-73)
loadtest/feed/feed_like_concurrency_test1.js (2)
loadtest/feed/feed_like_concurrency_test2.js (10)
  • BASE_URL (5-5)
  • options (8-20)
  • options (8-20)
  • setup (23-38)
  • tokens (26-26)
  • likeStatus (27-27)
  • userId (30-30)
  • res (31-31)
  • res (60-60)
  • token (42-42)
loadtest/feed/feed_like_concurrency_test3.js (11)
  • BASE_URL (5-5)
  • options (8-27)
  • options (8-27)
  • setup (30-43)
  • tokens (33-33)
  • userId (36-36)
  • res (37-37)
  • res (56-56)
  • res (95-95)
  • token (48-48)
  • token (73-73)
loadtest/feed/feed_like_concurrency_test3.js (2)
loadtest/feed/feed_like_concurrency_test1.js (10)
  • BASE_URL (5-5)
  • options (9-18)
  • options (9-18)
  • setup (21-34)
  • tokens (22-22)
  • userId (26-26)
  • res (27-27)
  • res (56-56)
  • token (38-38)
  • handleSummary (75-79)
loadtest/feed/feed_like_concurrency_test2.js (11)
  • BASE_URL (5-5)
  • options (8-20)
  • options (8-20)
  • setup (23-38)
  • maxVUs (25-25)
  • tokens (26-26)
  • userId (30-30)
  • res (31-31)
  • res (60-60)
  • token (42-42)
  • handleSummary (79-83)
src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (35-417)
⏰ 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 (10)
src/main/java/konkuk/thip/post/application/service/handler/PostHandler.java (1)

34-40: LGTM! 일관된 패턴으로 update용 조회 메서드 추가

기존 findPost 메서드와 동일한 구조로 update-lock 경로를 제공하여 코드 일관성이 유지됩니다.

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

59-63: LGTM! Adapter 레이어의 update-lock 경로 구현

Repository의 findByPostIdForUpdate를 호출하고 도메인 엔티티로 매핑하는 일관된 패턴입니다.

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

23-31: LGTM! Port 인터페이스의 update-lock 경로 추가

기존 findById/getByIdOrThrow 패턴과 일관되게 update용 메서드를 제공합니다. default 메서드를 통해 예외 처리를 캡슐화한 것도 좋습니다.

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

47-51: LGTM! Feed Adapter의 update-lock 경로 구현

다른 도메인(Record, Vote)과 일관된 패턴으로 구현되어 있습니다.

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

15-23: LGTM! Feed Port의 update-lock 경로 추가

다른 CommandPort들(VoteCommandPort, RecordCommandPort)과 동일한 패턴으로 일관성이 유지됩니다.

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

84-88: LGTM! Vote Adapter의 update-lock 경로 구현

다른 Adapter들과 일관된 패턴으로 구현되어 있습니다.

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

18-26: LGTM! Record Port의 update-lock 경로 추가

다른 CommandPort들과 동일한 패턴으로 일관성이 잘 유지됩니다.

src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java (1)

32-58: 테스트 설정이 적절합니다.

동시성 테스트를 위한 기본 설정이 올바르게 구성되어 있습니다.

src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteJpaRepository.java (1)

21-23: 비관적 락 구현이 올바릅니다.

PESSIMISTIC_WRITE 락을 사용한 findByPostIdForUpdate 메서드가 올바르게 구현되었습니다. 데드락 해결을 위한 적절한 접근 방식입니다.

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

21-23: 비관적 락 구현이 올바릅니다.

PESSIMISTIC_WRITE 락을 사용한 findByPostIdForUpdate 메서드가 올바르게 구현되었습니다. Feed 엔티티에 대한 동시성 제어가 적절하게 적용되었습니다.

Comment on lines 61 to 109
@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

@coderabbitai coderabbitai bot Nov 12, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

최종 데이터베이스 상태 검증이 누락되었습니다.

현재 테스트는 작업 성공 횟수만 확인하지만, 실제 데이터베이스의 최종 상태(좋아요 개수, PostLike 엔티티 존재 여부)를 검증하지 않습니다. 각 사용자가 10번씩 토글하므로 최종적으로 두 사용자 모두 좋아요 상태여야 하며, feed의 likeCount는 2여야 합니다.

테스트 마지막에 다음 검증을 추가하는 것을 권장합니다:

 latch.await();
 executor.shutdown();
+
+// 최종 데이터베이스 상태 검증
+FeedJpaEntity finalFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow();
+long finalLikeCount = postLikeQueryPort.countByPostId(feed.getPostId());

 // then
 assertAll(
         () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat),
-        () -> assertThat(failCount.get()).isEqualTo(0)
+        () -> assertThat(failCount.get()).isEqualTo(0),
+        () -> assertThat(finalFeed.getLikeCount()).isEqualTo(2),
+        () -> assertThat(finalLikeCount).isEqualTo(2)
 );
🤖 Prompt for AI Agents
In
src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java
around lines 61 to 109, the test only asserts success/fail counters but misses
verifying the final DB state; update the end of the test to reload the feed and
post-like data from the repository (or service) after
latch.await()/executor.shutdown(), then assert that each user has a PostLike
record with isLike==true and that feed.getLikeCount() (reloaded) equals 2 (or
the expected value), ensuring you use repository/service methods to fetch fresh
entities (and wrap reload/asserts in the same transactional/test context if
needed).

Copy link
Contributor

Choose a reason for hiding this comment

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

코리가 말하는 것처럼 최종적으로 마지막에 DB 정합성이 깨지는지 확인하는 것도 좋을 것 같네욥

Copy link

Choose a reason for hiding this comment

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

@buzz0331, 좋은 지적입니다! 동시성 테스트에서 최종 DB 정합성 검증을 추가하는 것이 중요하네요.

다만 테스트 로직을 분석해보니, 각 사용자가 true로 시작해서 10번 토글하므로:

  • 1번째: 좋아요 추가
  • 2번째: 좋아요 취소
  • 3번째: 좋아요 추가
  • ...
  • 10번째: 좋아요 취소

최종적으로 두 사용자 모두 좋아요 취소 상태가 되어, likeCount는 0이 되어야 합니다.

다음과 같이 최종 DB 상태 검증을 추가하는 것을 권장합니다:

latch.await();
executor.shutdown();

// 최종 데이터베이스 상태 검증
FeedJpaEntity finalFeed = feedJpaRepository.findById(feed.getPostId()).orElseThrow();

// then
assertAll(
        () -> assertThat(successCount.get()).isEqualTo(threadCount * repeat),
        () -> assertThat(failCount.get()).isEqualTo(0),
        () -> assertThat(finalFeed.getLikeCount()).isEqualTo(0),
        () -> assertThat(finalFeed.getLikeCount()).as("DB의 likeCount와 실제 상태가 일치해야 함").isEqualTo(0)
);

더 완벽한 검증을 위해서는 PostLike 엔티티의 실제 개수도 확인하면 좋습니다. 만약 PostLikeCrudRepository 같은 레포지토리가 있다면:

@Autowired private PostLikeCrudRepository postLikeCrudRepository;

// 테스트 마지막에
long actualPostLikeCount = postLikeCrudRepository.countByPostIdAndIsLike(feed.getPostId(), true);
assertThat(actualPostLikeCount).isEqualTo(0);

이렇게 하면 동시성 제어가 제대로 작동해서 데이터 정합성이 유지되는지 확실하게 검증할 수 있습니다! 👍


🧠 Learnings used
Learnt from: buzz0331
Repo: THIP-TextHip/THIP-Server PR: 309
File: src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java:36-44
Timestamp: 2025-09-23T08:31:05.161Z
Learning: buzz0331은 기술적 이슈에 대해 실용적인 해결책을 제시하면서도 과도한 엔지니어링을 피하는 균형감을 선호한다. 복잡도 대비 실제 발생 가능성을 고려하여 "굳이" 불필요한 솔루션보다는 심플함을 유지하는 것을 중요하게 생각한다.

buzz0331
buzz0331 previously approved these changes Nov 17, 2025
Copy link
Contributor

@buzz0331 buzz0331 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다~ 문서가 아주 깔끔하네여 👍🏻 👍🏻
희진님도 찾아보셨겠지만 업데이트 충돌이 잦은 환경에서는 낙관락보다는 비즈니스 로직 성공을 반드시 보장해주는 비관락을 권장하는 것으로 알고있어욥
다만, 현재 희진님이 작성해두신 노션을 확인해보니 비관락을 걸었을때 대략 300 RPS까지 오르고 요청 실패가 한번도 없었던 것으로 확인되는데 비관락이 기본적으로 명시적으로 락을 걸면서 리소스 접근 자체를 막는거다보니 더 높은 RPS에서 과연 request time out이 나지 않고 버틸 수 있을지 궁금해지네요.
RPS를 조금 비현실적이더라도 비약적으로 높여보면서 request time out이 나는 지점을 확인하고 그 다음 단계인 Redis 도입, 이벤트 처리를 적용시켜보는 것도 좋을 것 같다는 생각입니다!

Comment on lines 61 to 109
@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
Contributor

Choose a reason for hiding this comment

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

코리가 말하는 것처럼 최종적으로 마지막에 DB 정합성이 깨지는지 확인하는 것도 좋을 것 같네욥

seongjunnoh
seongjunnoh previously approved these changes Nov 21, 2025
Copy link
Collaborator

@seongjunnoh seongjunnoh left a comment

Choose a reason for hiding this comment

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

확인했습니다! 그런데 비관적 락에 타임아웃 관련해서 명시적으로 설정 하지 않으셨던데, 비즈니스 로직 상 게시글 좋아요에 그렇게 많은 부하가 발생할라나? 라는 의도로 락 타임아웃 설정은 제외하신 걸까요??
mysql은 타임아웃의 디폴트 값이 50초라고 알고 있는데, jpa 환경에서도 이게 유지되는지는 확인해봐야 할 것 같습니다!

개인적으로 락을 걸 때 혹시 모를 장애상황을 대비해서 타임아웃 + 이후 처리(디스코드 알림 or 보상 재시도 등등) 를 고려하는게 좋지 않나 생각합니다!

@hd0rable
Copy link
Member Author

확인했습니다! 그런데 비관적 락에 타임아웃 관련해서 명시적으로 설정 하지 않으셨던데, 비즈니스 로직 상 게시글 좋아요에 그렇게 많은 부하가 발생할라나? 라는 의도로 락 타임아웃 설정은 제외하신 걸까요?? mysql은 타임아웃의 디폴트 값이 50초라고 알고 있는데, jpa 환경에서도 이게 유지되는지는 확인해봐야 할 것 같습니다!

개인적으로 락을 걸 때 혹시 모를 장애상황을 대비해서 타임아웃 + 이후 처리(디스코드 알림 or 보상 재시도 등등) 를 고려하는게 좋지 않나 생각합니다!

제가 진행해본 부하테스트(최대 vu200명이 동시에 좋아요 요청/취소를 반복하는경우)로는 락 타임아웃의 요청이 1건도 발생하지않아서 고려하지않았습니다!! 지금은 성준님이 작성한 부하 테스트 스크립트처럼 어떤 특정상황에 좋아요요청이 동시에 몰릴경우 부하 테스트를 진행하려고하는데 이테스트로 리퀘스트 타임아웃이 나는 지점을 확인해보고 그 후 처리도 고려해보겠습니닷!!

@hd0rable hd0rable dismissed stale reviews from seongjunnoh and buzz0331 via 612dca7 December 6, 2025 09:12
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: 1

♻️ Duplicate comments (1)
src/main/java/konkuk/thip/post/application/service/PostLikeService.java (1)

60-74: 좋아요 수 업데이트 순서가 검증 이전에 수행됩니다.

이전 리뷰에서 지적된 것처럼, 좋아요 수 업데이트(60-62줄)가 검증(66, 72줄) 이전에 수행됩니다. 트랜잭션 롤백으로 데이터 일관성은 유지되지만, 로직 흐름상 검증 후 상태 변경이 더 자연스럽습니다.

비관적 락 환경에서 트랜잭션이 롤백되면 복구되므로 기능적으로는 문제없지만, 가독성과 유지보수성을 위해 순서 변경을 권장합니다:

 // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회
 boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId());

-// 3. 게시물 좋아요 수 업데이트
-post.updateLikeCount(postCountService,command.isLike());
-postHandler.updatePost(command.postType(), post);
-
-// 4. 좋아요 상태변경
+// 3. 좋아요 상태변경
 if (command.isLike()) {
     postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked);
     postLikeCommandPort.save(command.userId(), command.postId(),command.postType());
 } else {
     postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked);
     postLikeCommandPort.delete(command.userId(), command.postId());
 }
+
+// 4. 게시물 좋아요 수 업데이트
+post.updateLikeCount(postCountService, command.isLike());
+postHandler.updatePost(command.postType(), post);
🧹 Nitpick comments (5)
src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (2)

168-170: 테스트 안정성을 위해 saveAndFlush() 사용을 권장합니다.

save()만으로는 MockMvc 요청 전에 DB에 즉시 반영되지 않을 수 있습니다. 테스트 전제 조건("다른 유저가 이미 좋아요한 상태")을 확실히 보장하려면 saveAndFlush()를 사용하세요.

다음 diff를 적용하세요:

-        recordJpaRepository.save(record);
+        recordJpaRepository.saveAndFlush(record);

252-254: 테스트 안정성을 위해 saveAndFlush() 사용을 권장합니다.

Record 테스트와 동일하게, save()만으로는 MockMvc 요청 시점에 변경사항이 DB에 반영되지 않을 수 있습니다. 테스트 전제 조건의 확실한 반영을 위해 saveAndFlush()를 사용하세요.

다음 diff를 적용하세요:

-        voteJpaRepository.save(vote);
+        voteJpaRepository.saveAndFlush(vote);
src/main/java/konkuk/thip/ThipServerApplication.java (1)

7-7: 사용되지 않는 import입니다.

EnableRetry import가 추가되었지만, 실제로 이 클래스에서는 사용되지 않습니다. RetryConfig.java에서 이미 @EnableRetry를 활성화하고 있으므로 이 import는 제거해도 됩니다.

-import org.springframework.retry.annotation.EnableRetry;
src/main/java/konkuk/thip/post/application/service/PostLikeService.java (1)

79-82: @recover 메서드에서 원본 예외 정보가 손실됩니다.

재시도 실패 시 원본 예외를 로깅하지 않고 새로운 BusinessException만 던지면, 실제 실패 원인 추적이 어렵습니다. 디버깅을 위해 원본 예외를 로깅하거나 cause로 전달하는 것을 권장합니다.

+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class PostLikeService implements PostLikeUseCase {
     // ...
 
     @Recover
     public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
+        log.warn("좋아요 처리 재시도 실패 - postId: {}, userId: {}, isLike: {}", 
+                 command.postId(), command.userId(), command.isLike(), e);
         throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
     }
loadtest/feed/feed-like-load-test.js (1)

161-166: import 문이 파일 하단에 위치해 있습니다.

k6에서는 동작하지만, 가독성을 위해 다른 import 문들(lines 2-4)과 함께 파일 상단으로 이동하는 것이 좋습니다.

📜 Review details

Configuration used: CodeRabbit 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 7677566 and 612dca7.

📒 Files selected for processing (10)
  • build.gradle (1 hunks)
  • loadtest/feed/feed-like-load-test.js (1 hunks)
  • src/main/java/konkuk/thip/ThipServerApplication.java (1 hunks)
  • src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1 hunks)
  • src/main/java/konkuk/thip/config/RetryConfig.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java (2 hunks)
  • src/main/java/konkuk/thip/post/application/service/PostLikeService.java (3 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java (3 hunks)
  • src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (4 hunks)
  • src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedJpaRepository.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeLikeStatusApiTest.java
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
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의 참가자인지 검증이 필요하다.
📚 Learning: 2025-08-16T10:52:23.225Z
Learnt from: hd0rable
Repo: THIP-TextHip/THIP-Server PR: 238
File: src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java:190-216
Timestamp: 2025-08-16T10:52:23.225Z
Learning: THIP 프로젝트에서 User와 Alias는 1:1 매핑 관계이며, 데이터베이스 제약조건도 nullable = false로 설정되어 있어 모든 사용자는 반드시 alias를 가진다.

Applied to files:

  • src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java
📚 Learning: 2025-10-13T08:39:43.833Z
Learnt from: buzz0331
Repo: THIP-TextHip/THIP-Server PR: 323
File: build.gradle:102-104
Timestamp: 2025-10-13T08:39:43.833Z
Learning: Spring AI 1.0.0-M6에서 Google AI Gemini 전용 스타터가 빈 등록에 실패하는 경우, spring-ai-openai-spring-boot-starter를 사용하고 외부 설정(환경 변수 등)으로 spring.ai.openai.base-url을 Google의 OpenAI 호환 엔드포인트로, spring.ai.openai.api-key를 Google Cloud 액세스 토큰으로, spring.ai.openai.chat.options.model을 Gemini 모델명으로 지정하여 우회할 수 있습니다.

Applied to files:

  • build.gradle
📚 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/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.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/ThipServerApplication.java
⏰ 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 (8)
src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusControllerTest.java (1)

75-82: LGTM!

테스트 격리를 위한 명시적 정리 로직이 올바르게 구현되었습니다. deleteAllInBatch() 사용으로 효율적이며, 삭제 순서도 외래 키 제약조건(roomParticipant/record → room → book, user)을 준수합니다.

src/test/java/konkuk/thip/room/adapter/in/web/RoomPostChangeLikeStatusApiTest.java (1)

84-94: LGTM!

통합 테스트를 위한 정리 로직이 올바르게 구현되었습니다. 삭제 순서가 복잡한 외래 키 관계(postLike → feed/record/vote → room → book/user)를 정확히 반영하고 있습니다.

build.gradle (1)

104-106: LGTM!

spring-retry 의존성 추가가 적절합니다. spring-boot-starter-aop가 이미 포함되어 있어 @Retryable 기능이 정상 동작할 것입니다.

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

1-9: LGTM!

Spring Retry 설정이 적절합니다. proxyTargetClass = true 설정으로 인터페이스 기반이 아닌 클래스에서도 @Retryable이 정상 동작합니다.

src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1)

34-34: LGTM!

RESOURCE_LOCKED 에러 코드 추가가 적절합니다. HTTP 423 상태 코드와 50200 코드 번호가 기존 규칙을 따르고 있습니다.

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

44-49: Retry 설정이 적절합니다.

비관적 락 경합 시 재시도 로직이 잘 구성되어 있습니다. REQUIRES_NEW propagation과 함께 사용하여 각 재시도가 새로운 트랜잭션에서 실행됩니다. exponential backoff와 random jitter 설정도 적절합니다.


70-70: 주석 처리된 알림 전송 코드 확인이 필요합니다.

sendNotifications(command) 호출이 주석 처리되어 있습니다. 의도적인 비활성화인지, 아니면 추후 활성화할 TODO인지 명확히 해주세요. 의도적이라면 주석으로 이유를 남기거나, 불필요하다면 코드를 제거하는 것이 좋습니다.

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

122-122: 좋아요 요청 body의 type 값이 문자열입니다.

{ type: 'true' } 형태로 문자열 'true'를 전송하고 있습니다. API가 boolean이 아닌 문자열을 기대하는지 확인해주세요. 일반적으로 boolean 값이 예상됩니다.

-const body = JSON.stringify({ type: 'true' });
+const body = JSON.stringify({ type: true });

Comment on lines 143 to 146
case ERR.RESOURCE_LOCKED:
http423.add(1);
fail_RESOURCE_LOCKED.add(1);
break;
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

정의되지 않은 메트릭 http423 사용으로 런타임 에러가 발생합니다.

Line 144에서 http423.add(1)을 호출하지만, 이 Counter는 정의되지 않았습니다. k6 실행 시 ReferenceError가 발생합니다.

 const fail_RESOURCE_LOCKED              = new Counter('fail_RESOURCE_LOCKED');
 const fail_OTHER_4XX                    = new Counter('fail_OTHER_4XX');
+const http423                           = new Counter('feed_like_423');

또는 http423.add(1) 호출을 제거하고 fail_RESOURCE_LOCKED.add(1)만 유지:

             case ERR.RESOURCE_LOCKED:
-                http423.add(1);
                 fail_RESOURCE_LOCKED.add(1);
                 break;
📝 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
case ERR.RESOURCE_LOCKED:
http423.add(1);
fail_RESOURCE_LOCKED.add(1);
break;
case ERR.RESOURCE_LOCKED:
fail_RESOURCE_LOCKED.add(1);
break;
🤖 Prompt for AI Agents
In loadtest/feed/feed-like-load-test.js around lines 143 to 146, the code calls
an undefined metric http423 (http423.add(1)) causing a ReferenceError; fix by
either (A) defining http423 as a Counter near the other HTTP-status counters
(same naming/initialization pattern used in the file) so the add call is valid,
or (B) remove the http423.add(1) call and keep only fail_RESOURCE_LOCKED.add(1)
if you don't intend to track a separate 423 counter; ensure whichever option you
choose follows the file's existing metric declaration style and is consistently
used elsewhere.

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: 2

♻️ Duplicate comments (1)
src/main/java/konkuk/thip/post/application/service/PostLikeService.java (1)

60-74: 검증 전 상태 변경으로 트랜잭션 무결성 위험이 있습니다.

이 문제는 이전 리뷰에서도 지적되었으나 여전히 수정되지 않았습니다. 현재 코드는 다음 순서로 동작합니다:

  1. 60-62줄: 좋아요 수를 먼저 업데이트
  2. 66, 72줄: 그 후에 검증 수행

이로 인해 발생하는 문제:

  • 이미 좋아요한 상태에서 다시 좋아요를 시도하면 66줄에서 예외가 발생하지만, 그 전에 이미 카운트가 증가되어 있습니다(트랜잭션 롤백으로 복구되지만 로직상 부자연스럽습니다)
  • 검증 실패 시점과 상태 변경 시점이 뒤섞여 코드 이해가 어렵습니다
  • 향후 추가 로직이 실패할 경우 일관성 문제가 발생할 수 있습니다

다음과 같이 순서를 변경하세요:

 // 2. 유저가 해당 게시물에 대해 좋아요 했는지 조회
 boolean alreadyLiked = postLikeQueryPort.isLikedPostByUser(command.userId(), command.postId());

-// 3. 게시물 좋아요 수 업데이트
-post.updateLikeCount(postCountService,command.isLike());
-postHandler.updatePost(command.postType(), post);
-
-// 4. 좋아요 상태변경
+// 3. 좋아요 상태변경
 if (command.isLike()) {
     postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked);
     postLikeCommandPort.save(command.userId(), command.postId(),command.postType());
 
     // 좋아요 푸쉬알림 전송
     sendNotifications(command);
 } else {
     postLikeAuthorizationValidator.validateUserCanUnLike(alreadyLiked);
     postLikeCommandPort.delete(command.userId(), command.postId());
 }
+
+// 4. 게시물 좋아요 수 업데이트
+post.updateLikeCount(postCountService, command.isLike());
+postHandler.updatePost(command.postType(), post);

 return PostIsLikeResult.of(post.getId(), command.isLike());
📜 Review details

Configuration used: CodeRabbit 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 612dca7 and 2ebd3e3.

📒 Files selected for processing (2)
  • src/main/java/konkuk/thip/post/application/service/PostLikeService.java (4 hunks)
  • src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/java/konkuk/thip/feed/concurrency/FeedChangeLikeStatusConcurrencyTest.java
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
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의 참가자인지 검증이 필요하다.
⏰ 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 (1)
src/main/java/konkuk/thip/post/application/service/PostLikeService.java (1)

53-53: 비관적 락 적용이 적절합니다.

findPostForUpdate를 사용하여 비관적 락을 적용한 것은 데드락 문제 해결을 위한 핵심 변경사항입니다.

Comment on lines +44 to +49
@Retryable(
notRecoverable = { BusinessException.class, InvalidStateException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 500, random = true)
)
@Transactional(propagation = Propagation.REQUIRES_NEW)
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. notRecoverable 설정 불일치: PessimisticLockException, LockTimeoutException 등 DB 락 관련 예외는 notRecoverable 목록에 없어 재시도되지만, 최종적으로 recover 메서드에서 RESOURCE_LOCKED로 변환됩니다. 반면 비즈니스 로직 예외(BusinessException)는 재시도되지 않습니다. 이는 일관성 없는 동작을 야기합니다.

  2. Backoff 시간 부족 가능성: 총 재시도 시간이 ~800ms(100ms + 200ms + 500ms)로 짧습니다. 실제 락 경합 상황에서 다른 트랜잭션이 락을 더 오래 보유할 수 있어 재시도 창이 부족할 수 있습니다. PR 댓글에서 언급한 MySQL의 기본 락 타임아웃(~50초)과 비교하면 매우 짧습니다.

  3. REQUIRES_NEW의 영향: 각 재시도마다 새 트랜잭션이 생성됩니다. 첫 시도에서 락 획득 → 카운트 업데이트 → 검증 실패 → 롤백 후, 다음 재시도는 완전히 새로운 트랜잭션으로 시작하여 DB 부하가 증가합니다.

  4. 명시적 락 타임아웃 미설정: PR 댓글에서 언급된 대로, JPA 수준에서 명시적 락 타임아웃을 설정하지 않았습니다. 이로 인해 데이터베이스 기본값에 의존하게 되며, 예상치 못한 긴 대기 시간이 발생할 수 있습니다.

다음 개선사항을 고려하세요:

 @Retryable(
-        notRecoverable = { BusinessException.class, InvalidStateException.class},
+        retryFor = {PessimisticLockException.class, CannotAcquireLockException.class},
+        noRetryFor = {BusinessException.class, InvalidStateException.class},
         maxAttempts = 3,
-        backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 500, random = true)
+        backoff = @Backoff(delay = 200, multiplier = 2, maxDelay = 2000, random = true)
 )

추가로, @Lock(LockModeType.PESSIMISTIC_WRITE) 사용 시 명시적 타임아웃을 설정하는 것을 권장합니다:

@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")})
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/post/application/service/PostLikeService.java
around lines 44-49, adjust the retry/transaction configuration to handle DB lock
semantics consistently: add DB lock exceptions (e.g., PessimisticLockException,
LockTimeoutException) to notRecoverable or otherwise ensure they are treated the
same as BusinessException (so they are not blindly retried), increase the
backoff window (larger initial delay and maxDelay and/or more attempts) to allow
for realistic lock contention, change propagation from REQUIRES_NEW to REQUIRED
(or remove REQUIRES_NEW) so retries reuse the same transactional context instead
of spawning new transactions, and set an explicit JPA lock timeout on the
repository PESSIMISTIC_WRITE query via
@QueryHints(name="javax.persistence.lock.timeout", value="3000") to avoid
relying on DB defaults.

Comment on lines +79 to +82
@Recover
public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
}
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

복구 메서드가 모든 예외를 RESOURCE_LOCKED로 마스킹합니다.

현재 recover 메서드는 모든 ExceptionRESOURCE_LOCKED 에러로 변환합니다. 이는 다음 문제를 야기합니다:

  • 실제 락 관련 예외 → RESOURCE_LOCKED (올바름)
  • DB 연결 오류 → RESOURCE_LOCKED (잘못된 정보)
  • NPE 또는 기타 예상치 못한 예외 → RESOURCE_LOCKED (잘못된 정보)

디버깅이 어려워지고 클라이언트에게 부정확한 오류 정보를 제공하게 됩니다.

다음과 같이 개선하세요:

 @Recover
 public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
+    log.error("Failed to change like status after retries for postId: {}, userId: {}", 
+              command.postId(), command.userId(), e);
+    
+    // 락 관련 예외만 RESOURCE_LOCKED로 처리
+    if (e instanceof PessimisticLockException || e instanceof CannotAcquireLockException 
+        || e instanceof LockTimeoutException) {
+        throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
+    }
+    
+    // 다른 예외는 원본 예외 정보 유지
+    throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, e);
-    throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
 }

또는 최소한 원본 예외를 로깅하세요:

 @Recover
 public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
+    log.error("Failed to process like request after retries", e);
     throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
 }
📝 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
@Recover
public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
}
@Recover
public PostIsLikeResult recover(Exception e, PostIsLikeCommand command) {
log.error("Failed to change like status after retries for postId: {}, userId: {}",
command.postId(), command.userId(), e);
// 락 관련 예외만 RESOURCE_LOCKED로 처리
if (e instanceof PessimisticLockException || e instanceof CannotAcquireLockException
|| e instanceof LockTimeoutException) {
throw new BusinessException(ErrorCode.RESOURCE_LOCKED);
}
// 다른 예외는 원본 예외 정보 유지
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, e);
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/post/application/service/PostLikeService.java
around lines 79-82, the @Recover method currently maps every Exception to
ErrorCode.RESOURCE_LOCKED which masks non-lock failures; change the method to
inspect the exception type (e.g., catch/instanceof for lock-related exceptions
such as CannotAcquireLockException or specific DB lock exceptions) and only
throw new BusinessException(ErrorCode.RESOURCE_LOCKED) for those; for other
exceptions either rethrow the original exception or throw a more appropriate
BusinessException (e.g., INTERNAL_SERVER_ERROR) and ensure the original
exception is logged with its stack trace before returning/throwing so debugging
information is preserved.

@hd0rable
Copy link
Member Author

hd0rable commented Dec 6, 2025

재시도 로직 추가한 코드 수정해서 push했습니다!!
재시도 로직을 추가하면서 설정한 @ Transactional(propagation = Propagation.REQUIRES_NEW) 옵션에 따라서 테스트가 깨지는 문제를 해결하기 위해 해당 테스트 클래스에 설정한 @ Transactional 속성을 제거했습니다.
저희 팀이 테스트 클래스에 @ Transactional을 설정하는 것으로 컨벤션은 지정했지만 이와같은 특수한상황에 한해서는 @ AfterEach 사용해서 수동으로 엔티티를 직접 삭제하는 방법도 나쁘지않은것같은데 어떻게 생각하시나요??
시간날때 확인해주시면 감사하겠습니다랏 @buzz0331 @seongjunnoh
관련해서 문서화 해두었습니다 !! 트러블슈팅 -> @Transactional(propagation = Propagation.REQUIRES_NEW) 적용 시 통합 테스트 깨짐

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-385] [fix] 게시글 좋아요 500 error(deadlock) 해결 [THIP2025-366] [test] k6 부하 테스트 스크립트 추가 및 관리

4 participants