Skip to content

Conversation

@hyxklee
Copy link
Collaborator

@hyxklee hyxklee commented Jan 12, 2026

문제상황

  1. 동일 피드에 동시에 공감하기 기능 동작시 아래와 같은 오류 발생
code: 3001,
message: 'could not execute statement
[Deadlock found when trying to get lock; try restarting transaction]
[insert into reactions (feed_id,reaction_count,user_id) values (?,?,?)]

추가 문제 발생 (시나리오)

  1. 동일 피드에 같은 사람이 동시에 요청 (멀티 기기)을 하는 경우 오류 발생
SQL Error: 1062, SQLState: 23000
Duplicate entry '2-201' for key 'reactions.UKti5l7owlc01k76for0f28p36s'

원인

1번 문제: 같은 피드에 동시에 요청을 하는 경우 Reaction INSERT시 락 충돌로 데드락 발생

2번 문제: 동시에 요청을 하는 경우 트랜잭션이 순서대로 동작하지 않아 Reaction이 중복으로 생성

3번 문제: 다른 피드더라도 작성자의 경우 “totalReactionCount” 필드를 동시에 업데이트 하기 때문에 데드락 발생

해결방안

  • Feed에 비관적락을 걸어 조회
    • 락순서가 중요한데 트랜잭션의 가장 첫 부분에 락을 걸어서 트랜잭션이 정렬되도록 해야 2, 3번 문제가 발생하지 않음
  • 추가적으로 동일한 작성자의 totalReactionCount를 업데이트할 때 발생할 수 있는 데드락을 방지하기 위해 작성자 조회시 락을 걸어 조회
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
    @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id")
    Optional<Feed> findByIdWithPessimisticLock(@Param("id") Long id);
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
    @Query("SELECT u FROM User u WHERE u.id = :userId AND u.leaveDate IS NULL AND u.deleteDate IS NULL")
    Optional<User> findByIdWithPessimisticLock(@Param("userId") long userId);

동시성 제어 방안

비관적락

  • 엔티티 조회시 락을 선점하고 조회를 하는 방식으로 무조건 락을 잡고 이후 트랜잭션을 진행하기 때문에 확실한 처리가 가능
  • 동시성 문제나 트래픽이 많지 않은 경우에도 매번 락을 잡기 때문에 비효율적일 수 잇음

낙관적락

  • 버저닝과 재시도 로직을 통해 제어하는 방법
  • 동시성 문제의 발생 빈도가 낮다면 효율적일 수 있음
  • 낙관적락은 목적이 “덮어쓰기 방지” 용도지 데드락 방지, 동시성 제어에 있지 않기 때문에 적절한 케이스에서만 사용
  • 또한 동시성 문제가 많이 발생한다면 오히려 많은 오버헤드가 발생

분산락

  • Redis 같은 외부 메모리 저장소를 활용해 분산 환경에서 동시성 제어를 진행
  • 외부 메모리 저장소를 활용하기 때문에 내부 DB의 과부하를 줄일 수 있으며, DB 락보다 빠른 처리가 가능

원자적 연산

  • 비즈니스 로직이 간단한 경우 여러 쿼리를 하나의 쿼리로 묶어 처리
  • “단순 카운트 증가” 등의 간단한 로직이라면 문제 없으나 복합 로직을 처리해야하는 경우는 유지보수가 극악

네임드락

  • 특정 “문자열”에 락을 거는 방법
  • 외부 메모리 저장소 없이도 분산락과 유사한 효과를 낼 수 있음
  • 락을 걸고 푸는 것을 수동으로 처리 해줘야함
  • 반드시 별도 DataSource가 필요

ReentrantLock

  • 자바 애플리케이션 단에서 동작하는 락
  • DB 왕복이 없어 빠른 처리
  • 단일 서버에서만 처리 가능
  • 여러 테이블이 묶인 경우는 결국 DB 단에서 처리를 해줘야함

비관적락 선정 이유

  • 간단한 구현과 확실한 동시성 제어
  • 여러 엔티티가 묶여있기 때문에 DB 단에서 처리가 필수였음
  • 낮은 트래픽에서는 효율에 문제가 없음

스크린샷 📷

image

같이 얘기해보고 싶은 내용이 있다면 작성 📢

좋은 내용이라 메이커스 백엔드 전부 CC 했습니당. 다들 한 번씩 읽어보시길
잘못된 내용이 있다면 고쳐주세용

totalReactionCount를 업데이트 하는 경우는 락을 잡지 않고 쿼리를 하나로 묶어주는 "원자적 연산"을 도입할 순 있으나, 현재 트래픽 수준에서는 성능 차이가 크지 않을 것으로 보여 현행으로 작성했습니당

Summary by CodeRabbit

릴리스 노트

  • 버그 수정

    • 피드 반응 처리 시 동시성으로 인한 충돌 방지(잠금 처리)로 데이터 일관성 개선
    • 잠금 충돌 시 사용자에게 재시도 안내 메시지 제공
  • 테스트

    • 동시성 시나리오에 대한 통합 테스트 추가
    • 테스트 환경 구성 및 테스트 컨테이너 설정 개선

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

@hyxklee hyxklee self-assigned this Jan 12, 2026
@hyxklee hyxklee added 🐛BugFix 버그 수정 ✅Test labels Jan 12, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 12, 2026

Walkthrough

reactToFeed 호출 경로에 피드와 사용자에 대한 비관적 쓰기 락을 도입했습니다. 저장소와 서비스에 락 전용 조회 메서드를 추가하고, 테스트 인프라를 Kotlin 기반으로 전환하며 동시성 통합 테스트를 추가했습니다.

Changes

코호트 / 파일(s) 변경 요약
비관적 잠금 - Feed 관련
src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java, src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java, src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
reactToFeed에서 피드를 잠금 조회(findByIdWithPessimisticLock / findByIdWithLock)로 변경. Repository에 @Lock(LockModeType.PESSIMISTIC_WRITE) 쿼리와 타임아웃 힌트 추가, 서비스는 PessimisticLockException을 ResourceLockedException으로 변환. 문서 주석 추가.
비관적 잠금 - User 관련
src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java, src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java
사용자 조회에 대한 findByIdWithPessimisticLock/findByIdWithLock 추가. Repository에 락 및 타임아웃 힌트 추가.
예외/코드 추가
src/main/java/leets/leenk/global/common/exception/ErrorCode.java, src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java
RESOURCE_LOCKED 에러 코드 추가 및 이를 래핑하는 ResourceLockedException 클래스 추가.
테스트 인프라 마이그레이션
src/test/java/leets/leenk/config/* (삭제), src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt, src/test/kotlin/leets/leenk/config/MongoTestConfig.kt, src/test/kotlin/leets/leenk/config/TestContainersTest.kt, src/test/resources/application-test.yml, src/test/resources/application.yml
Java 기반 TestContainers 설정 파일/테스트 삭제 후 Kotlin으로 재구성. MongoDB 컨테이너 추가 및 통합 테스트용 테스트 설정(yml) 추가.
통합 테스트 및 픽스처
src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt, src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt
동시 반응 시나리오를 검증하는 통합 테스트 추가(멀티스레드 시뮬레이션) 및 피드 생성용 테스트 픽스처 추가.
단위/기존 테스트 업데이트
src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java, src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt
기존 테스트에서 findById 호출을 findByIdWithLock으로 교체 및 PessimisticLockException -> ResourceLockedException 경로 검증용 단위 테스트 추가/수정.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant FeedUsecase
    participant FeedGetService
    participant FeedRepository
    participant UserGetService
    participant UserRepository
    participant Database

    Client->>FeedUsecase: reactToFeed(userId, feedId, request)
    FeedUsecase->>FeedGetService: findByIdWithLock(feedId)
    FeedGetService->>FeedRepository: findByIdWithPessimisticLock(feedId)
    FeedRepository->>Database: SELECT ... FOR UPDATE (PESSIMISTIC_WRITE, timeout)
    Database-->>FeedRepository: locked Feed row (+fetch join author)
    FeedRepository-->>FeedGetService: Optional<Feed>
    FeedGetService-->>FeedUsecase: Feed

    FeedUsecase->>UserGetService: findByIdWithLock(authorId)
    UserGetService->>UserRepository: findByIdWithPessimisticLock(authorId)
    UserRepository->>Database: SELECT ... FOR UPDATE (PESSIMISTIC_WRITE, timeout)
    Database-->>UserRepository: locked User row
    UserRepository-->>UserGetService: Optional<User>
    UserGetService-->>FeedUsecase: User

    FeedUsecase->>FeedUsecase: validate/create/update Reaction (in-memory)
    FeedUsecase->>Database: persist Reaction and update counters
    Database-->>FeedUsecase: commit
    FeedUsecase-->>Client: Reaction 응답
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50분

Possibly related PRs

Suggested reviewers

  • jj0526
  • 1winhyun

Poem

🐰 동시성 숲 속에서 토끼가 뛰네,
잠금으로 길을 닦고 안전을 보네,
피드의 반응들 하나씩 차곡차곡,
테스트는 Kotlin으로 박자에 맞추고,
당근 들고 환호하노라! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 동시성 문제 해결이라는 핵심 변경사항을 명확하게 요약하고 있으며, 공감하기 기능의 데드락 문제를 직접적으로 다루고 있습니다.
Description check ✅ Passed PR 설명이 제공된 템플릿의 주요 섹션들을 포함하고 있으나, 템플릿 구조와 정확히 일치하지 않습니다. 문제상황, 원인, 해결방안을 상세히 기술했지만 템플릿의 '작업 내용' 섹션과 직접 대응되지 않습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

🧹 Recent nitpick comments
src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java (1)

1-7: LGTM!

FeedNotFoundException 등 기존 예외 클래스와 동일한 패턴을 따르고 있습니다.

🔧 사소한 포맷팅 개선 (선택 사항)
-public class ResourceLockedException extends BaseException{
+public class ResourceLockedException extends BaseException {
src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt (1)

12-27: LGTM!

Kotest와 MockK를 사용한 테스트 구조가 깔끔합니다. 예외 변환 로직을 잘 검증하고 있습니다.

파일명이 FeedGetService.kt로 되어 있는데, 테스트 클래스명과 일치하도록 FeedGetServiceTest.kt로 변경하는 것이 일반적인 컨벤션입니다.

또한, 앞서 언급한 대로 프로덕션 코드에서 PessimisticLockingFailureException도 처리하게 된다면, 해당 케이스에 대한 테스트도 추가하는 것을 권장합니다.

src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java (1)

35-42: 락 예외 처리 개선 권장

현재 코드는 jakarta.persistence.PessimisticLockException을 정상적으로 catch하고 있습니다. Spring Boot 3.5의 Hibernate 6 환경에서는 Spring의 예외 변환이 활성화되더라도 원본 JPA 예외가 직접 발생할 수 있으므로 현재 구현이 작동합니다.

다만 더욱 견고한 예외 처리를 위해, Spring이 래핑한 PessimisticLockingFailureException도 함께 catch하는 것을 권장합니다. 이렇게 하면 다양한 Spring Data JPA 실행 경로에서 발생 가능한 모든 비관적 락 예외를 안정적으로 처리할 수 있습니다.

참고: UserGetService.findByIdWithLock()에서는 예외를 처리하지 않으므로, 필요시 동일한 처리를 적용할지 검토하시기 바랍니다.

src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt (2)

218-218: latch.await()에 타임아웃 추가를 권장합니다.

타임아웃 없이 대기하면 스레드가 예상치 못한 이유로 완료되지 않을 경우 테스트가 무한히 멈출 수 있습니다.

♻️ 타임아웃 추가 제안
+import java.util.concurrent.TimeUnit
+
-    latch.await()
+    val completed = latch.await(30, TimeUnit.SECONDS)
+    if (!completed) {
+        throw IllegalStateException("테스트 타임아웃: 모든 스레드가 30초 내에 완료되지 않음")
+    }

209-211: 예외 로깅 개선을 고려해 주세요.

println 대신 로거를 사용하면 스택 트레이스 확인이 용이합니다. 선택적 개선 사항입니다.

♻️ 로거 사용 제안
+import org.slf4j.LoggerFactory
+
+private val logger = LoggerFactory.getLogger("FeedUsecaseIntegrationTest")
+
             } catch (e: Exception) {
-                println("스레드 $index 실패: ${e.message}")
+                logger.error("스레드 $index 실패", e)
                 failureCount.incrementAndGet()
             }

📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 474589a and 28690a2.

📒 Files selected for processing (8)
  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
  • src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java
  • src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
  • src/main/java/leets/leenk/global/common/exception/ErrorCode.java
  • src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java
  • src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java
  • src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt
  • src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-12T14:17:56.674Z
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/domain/service/LinkedUserDeleteService.java:18-18
Timestamp: 2025-08-12T14:17:56.674Z
Learning: The LinkedUser entity in the Leenk-BE project has a unique constraint on the combination of feed_id and user_id columns (UniqueConstraint(columnNames = {"feed_id", "user_id"})), which requires explicit flush() calls after delete operations to prevent constraint violations during the delete-and-recreate pattern used in feed updates.

Applied to files:

  • src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
  • src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java
  • src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java
📚 Learning: 2025-08-12T14:17:56.674Z
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/domain/service/LinkedUserDeleteService.java:18-18
Timestamp: 2025-08-12T14:17:56.674Z
Learning: In the Leenk-BE project, the LinkedUser entity has DB unique constraints that require explicit flush() calls after deleteAllByFeed operations to prevent constraint violations when recreating the relationships immediately afterward.

Applied to files:

  • src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java
🧬 Code graph analysis (1)
src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java (3)
src/main/java/leets/leenk/domain/feed/application/exception/FeedNotFoundException.java (1)
  • FeedNotFoundException (5-9)
src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java (1)
  • ResourceLockedException (3-7)
src/test/kotlin/leets/leenk/domain/feed/application/domain/service/FeedGetService.kt (1)
  • feedRepository (12-27)
⏰ 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 (7)
src/main/java/leets/leenk/global/common/exception/ErrorCode.java (1)

13-13: LGTM!

락 경합 상황에 HttpStatus.CONFLICT (409) 사용은 적절합니다. 에러 코드 번호 체계와 메시지도 잘 정의되어 있습니다.

src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java (1)

30-38: LGTM!

비관적 락 구현이 적절합니다. 주석에서 f.user에도 락이 함께 걸린다는 점을 명시한 것이 좋습니다. 락 타임아웃 2000ms 설정과 JOIN FETCH로 사용자까지 락을 거는 설계는 이전 리뷰에서 논의된 대로 totalReactionCount 동시 업데이트 문제를 해결합니다.

src/test/java/leets/leenk/domain/feed/application/FeedUsecaseTest.java (1)

359-359: LGTM! 테스트 스텁이 새로운 락킹 메서드에 맞게 올바르게 업데이트되었습니다.

reactToFeed 관련 테스트 3개 모두 findByIdfindByIdWithLock으로 일관되게 변경되어 프로덕션 코드의 비관적 락 적용과 정확히 일치합니다.

Also applies to: 383-383, 411-411

src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseIntegrationTest.kt (4)

40-44: LGTM! 테스트 정리 순서가 FK 제약 조건을 올바르게 고려합니다.

reactions → feeds → users 순서로 삭제하여 외래 키 제약 조건 위반을 방지합니다.


46-88: PR 목표에 부합하는 동시성 테스트 시나리오입니다.

여러 사용자의 동시 공감 요청 시 데드락 방지를 검증하며, 피드와 작성자의 totalReactionCount 정합성까지 확인합니다.


90-131: 동일 사용자의 중복 삽입(Duplicate entry) 방지를 검증하는 시나리오입니다.

비관적 락으로 동시 INSERT를 직렬화하여 유니크 제약 조건 위반을 방지하는지 확인합니다.


133-185: 동일 작성자의 여러 피드에 대한 동시 공감 시 데드락 방지를 검증합니다.

PR 목표에 명시된 "여러 피드에서 작성자 카운트 동시 업데이트로 인한 락 충돌" 시나리오를 정확히 테스트합니다.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


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.

@hyxklee hyxklee changed the title LNK-48 공감하기 동시성 문제 해결 [LNK-48] 공감하기 동시성 문제 해결 Jan 12, 2026
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

🤖 Fix all issues with AI agents
In @src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt:
- Around line 8-18: The createFeed function's id parameter is unused (it isn't
applied to Feed.builder()), causing calls like createFeed(id = FEED_ID_100, ...)
to be misleading; fix by removing the id parameter from the createFeed signature
in FeedTestFixture (or, if you intentionally need a settable id for tests,
explicitly set the id on the built entity after construction or provide an
alternate constructor/helper that sets id), and update callers like
FeedUsecaseTest.kt to stop passing id or to use the new helper; ensure
references to createFeed and Feed.builder() reflect the change and add a brief
comment if you keep the parameter to explain why it's ignored with JPA
@GeneratedValue.
🧹 Nitpick comments (3)
src/test/resources/application-test.yml (1)

18-20: Hibernate 방언 설정 확인

org.hibernate.dialect.MySQL8Dialect는 Hibernate 6.x에서 deprecated되었습니다. Hibernate가 자동으로 방언을 감지하므로, 명시적 설정이 필요하지 않을 수 있습니다. 현재 사용 중인 Hibernate 버전을 확인해 주세요.

src/test/kotlin/leets/leenk/config/TestContainersTest.kt (1)

15-35: 컨테이너 검증 테스트가 잘 작성되었습니다.

테스트 인프라 구성을 검증하는 좋은 접근 방식입니다. println 문은 디버깅에 유용하지만, CI 환경에서는 로그를 깔끔하게 유지하기 위해 제거하거나 로거를 사용하는 것을 고려해 볼 수 있습니다.

src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt (1)

220-223: 타임아웃 추가를 권장합니다.

latch.await()에 타임아웃이 없으면 스레드가 완료되지 않을 경우 테스트가 무한 대기할 수 있습니다. 또한 executor.shutdown()awaitTermination을 호출하여 모든 태스크가 완료될 때까지 대기하는 것이 좋습니다.

♻️ 타임아웃 추가 제안
-    latch.await()
-    executor.shutdown()
+    val completed = latch.await(30, java.util.concurrent.TimeUnit.SECONDS)
+    executor.shutdown()
+    executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)
+    
+    if (!completed) {
+        throw IllegalStateException("동시성 테스트 타임아웃")
+    }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccfa238 and cac3f5d.

📒 Files selected for processing (14)
  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
  • src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java
  • src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java
  • src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java
  • src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java
  • src/test/java/leets/leenk/config/MysqlTestConfig.java
  • src/test/java/leets/leenk/config/TestContainersTest.java
  • src/test/kotlin/leets/leenk/config/MongoTestConfig.kt
  • src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt
  • src/test/kotlin/leets/leenk/config/TestContainersTest.kt
  • src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt
  • src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt
  • src/test/resources/application-test.yml
  • src/test/resources/application.yml
💤 Files with no reviewable changes (2)
  • src/test/java/leets/leenk/config/MysqlTestConfig.java
  • src/test/java/leets/leenk/config/TestContainersTest.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-12T14:17:56.674Z
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/domain/service/LinkedUserDeleteService.java:18-18
Timestamp: 2025-08-12T14:17:56.674Z
Learning: The LinkedUser entity in the Leenk-BE project has a unique constraint on the combination of feed_id and user_id columns (UniqueConstraint(columnNames = {"feed_id", "user_id"})), which requires explicit flush() calls after delete operations to prevent constraint violations during the delete-and-recreate pattern used in feed updates.

Applied to files:

  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
  • src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java
🧬 Code graph analysis (3)
src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java (1)
src/main/java/leets/leenk/domain/user/application/exception/UserNotFoundException.java (1)
  • UserNotFoundException (5-13)
src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt (1)
src/test/java/leets/leenk/config/MysqlTestConfig.java (2)
  • TestConfiguration (8-19)
  • Bean (13-18)
src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java (1)
src/main/java/leets/leenk/domain/feed/application/exception/FeedNotFoundException.java (1)
  • FeedNotFoundException (5-9)
⏰ 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 (12)
src/test/resources/application.yml (1)

1-3: LGTM!

테스트 프로파일을 활성화하는 표준적인 Spring Boot 설정입니다. application-test.yml의 설정이 올바르게 로드됩니다.

src/test/resources/application-test.yml (1)

59-67: 중복된 AWS 자격 증명 설정 확인 필요

spring.cloud.aws (라인 21-27)와 cloud.aws (라인 59-67)에 AWS 자격 증명이 중복 정의되어 있습니다. 이는 서로 다른 라이브러리(예: Spring Cloud AWS vs 커스텀 설정)를 위한 것일 수 있지만, 의도적인 설정인지 확인해 주세요.

src/main/java/leets/leenk/domain/feed/domain/service/FeedGetService.java (1)

29-36: LGTM!

비관적 락을 사용한 피드 조회 메서드가 기존 findById() 패턴과 일관되게 구현되었습니다. JavaDoc이 사용 목적을 명확히 설명하고 있어 유지보수에 도움이 됩니다.

src/main/java/leets/leenk/domain/user/domain/service/user/UserGetService.java (1)

26-33: LGTM!

FeedGetService.findByIdWithLock()과 동일한 패턴으로 구현되어 코드베이스 전체에서 일관성이 유지됩니다. UserRepository.findByIdWithPessimisticLock()FeedRepository와 동일한 PESSIMISTIC_WRITE 락과 2000ms 타임아웃으로 올바르게 설정되었으며, 비관적 락을 통한 동시성 제어가 Feed와 User 도메인에 일관되게 적용되었습니다.

src/main/java/leets/leenk/domain/feed/domain/repository/FeedRepository.java (1)

30-37: 비관적 락 구현이 적절하나 타임아웃 동작 검증 필요

PESSIMISTIC_WRITE 락과 JOIN FETCH f.user를 통한 동시성 제어는 적절합니다. 다만 jakarta.persistence.lock.timeout 힌트는 주의가 필요합니다.

현재 환경(dev/local에서 MySQLDialect 사용)에서는 Hibernate가 이 힌트를 MySQL 특정 문법으로 변환하지 않을 수 있으므로, 실제 타임아웃이 작동하지 않을 가능성이 있습니다. MySQL은 innodb_lock_wait_timeout 시스템 변수(기본값 50초)로 전역 제어되며, 쿼리 단위 타임아웃은 표준 지원하지 않습니다.

실제 동작 검증이 필요합니다. 필요시 다음 옵션을 검토하세요:

  • innodb_lock_wait_timeout 시스템 변수 설정 (세션 또는 연결 단위)
  • Hibernate 버전 확인 및 MySQL8Dialect 사용 고려
  • 통합 테스트 추가로 락 타임아웃 동작 확인
src/main/java/leets/leenk/domain/user/domain/repository/UserRepository.java (1)

21-28: 비관적 락 구현이 적절합니다.

락 타임아웃 힌트와 소프트 삭제 필터링이 올바르게 적용되었습니다. PESSIMISTIC_WRITE 모드와 2000ms 타임아웃은 동시성 제어에 적합한 선택입니다.

src/test/kotlin/leets/leenk/config/MongoTestConfig.kt (1)

10-17: LGTM!

MongoDB 테스트 컨테이너 설정이 깔끔하게 구현되었습니다. @ServiceConnection 어노테이션을 통해 자동으로 연결 속성이 주입됩니다.

src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt (1)

10-18: LGTM!

Java에서 Kotlin으로의 마이그레이션이 올바르게 수행되었습니다. 기존 Java 설정과 동일한 이미지 버전(mysql:8.0.41)과 데이터베이스 이름(testdb)을 유지합니다.

src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java (2)

213-227: 비관적 락 적용이 잘 구현되었습니다.

락 순서를 명시하고(피드 → 작성자) Javadoc에 관련 레포지토리 메서드를 참조한 점이 좋습니다. 공감하는 사용자(user)는 데이터가 수정되지 않으므로 락이 필요하지 않은 것이 맞습니다.


238-238: feedAuthor 사용이 올바릅니다.

락이 걸린 feedAuthor 엔티티를 사용하여 totalReactionCount를 업데이트함으로써 동시성 문제를 방지합니다. FeedUpdateService.updateTotalReaction 메서드는 User user 파라미터를 받으며, 메서드 내에서 feed, reaction, user 엔티티의 totalReactionCount를 모두 업데이트합니다.

src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt (2)

43-91: 동시성 테스트가 잘 구성되었습니다.

테스트 시나리오가 명확하고, afterEach에서 FK 제약조건을 고려한 올바른 순서로 데이터를 정리합니다. 여러 사용자의 동시 공감 시나리오를 효과적으로 검증합니다.


136-188: 데드락 방지 테스트가 PR 목표에 부합합니다.

동일 작성자의 여러 피드에 대한 동시 공감 시나리오를 테스트하여 totalReactionCount 업데이트 시 발생할 수 있는 데드락을 검증합니다. saveAndFlush 사용으로 테스트 데이터의 즉각적인 영속화를 보장합니다.

@hyxklee hyxklee marked this pull request as draft January 12, 2026 07:18
@hyxklee hyxklee marked this pull request as ready for review January 12, 2026 07:40
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: 0

🧹 Nitpick comments (2)
src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt (2)

30-31: 사용되지 않는 상수가 있습니다.

FEED_ID_100FEED_ID_200 상수가 정의되어 있지만 테스트 코드에서 사용되지 않습니다. 불필요한 경우 제거를 고려해 주세요.


198-225: latch.await()에 타임아웃 추가를 권장합니다.

현재 latch.await()가 무한정 대기할 수 있어, 예상치 못한 상황에서 테스트가 영구적으로 멈출 수 있습니다. 타임아웃을 추가하면 CI 환경에서 더 안정적으로 동작합니다.

♻️ 타임아웃 적용 제안
+import java.util.concurrent.TimeUnit
+
 private fun executeConcurrentReactions(
     threadCount: Int,
     reactions: List<() -> Unit>
 ): Pair<Int, Int> {
     val executor = Executors.newFixedThreadPool(threadCount)
     val latch = CountDownLatch(reactions.size)
     val successCount = AtomicInteger(0)
     val failureCount = AtomicInteger(0)

     reactions.forEachIndexed { index, reaction ->
         executor.submit {
             try {
                 reaction()
                 successCount.incrementAndGet()
             } catch (e: Exception) {
                 println("스레드 $index 실패: ${e.message}")
                 failureCount.incrementAndGet()
             } finally {
                 latch.countDown()
             }
         }
     }

-    latch.await()
+    val completed = latch.await(30, TimeUnit.SECONDS)
+    if (!completed) {
+        throw IllegalStateException("동시성 테스트 타임아웃: 30초 내에 완료되지 않음")
+    }
     executor.shutdown()

     return successCount.get() to failureCount.get()
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cac3f5d and b6ad07a.

📒 Files selected for processing (2)
  • src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt
  • src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt
⏰ 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 (4)
src/test/kotlin/leets/leenk/domain/feed/test/fixture/FeedTestFixture.kt (1)

6-19: LGTM! 간결하고 효과적인 테스트 픽스처입니다.

Companion object를 활용한 팩토리 메서드 패턴이 적절하며, 기본값을 제공해 테스트 작성 시 유연성을 확보했습니다.

src/test/kotlin/leets/leenk/domain/feed/application/usecase/FeedUsecaseTest.kt (3)

53-90: 여러 사용자 동시 공감 테스트 - LGTM!

비관적 락을 통한 동시성 제어를 검증하는 테스트가 잘 구성되어 있습니다. 모든 요청의 성공 여부, 리액션 개수, 그리고 집계 카운트까지 철저히 검증하고 있습니다.


97-133: 동일 사용자 동시 공감 테스트 - LGTM!

동일 사용자의 중복 요청(멀티 디바이스 시나리오)에서 유니크 제약 조건 위반 없이 reactionCount가 정확히 증가하는지 검증합니다. PR 목표에서 언급된 중복 삽입 문제를 잘 커버하고 있습니다.


140-187: 동일 작성자의 다른 피드 동시 공감 테스트 - LGTM!

작성자의 totalReactionCount 업데이트 시 발생할 수 있는 데드락을 검증하는 핵심 테스트입니다. saveAndFlush를 사용하여 동시성 테스트 전에 데이터가 확실히 영속화되도록 처리한 점이 좋습니다.

fun mongoContainer(): MongoDBContainer {
return MongoDBContainer(MONGO_IMAGE)
}
}

Choose a reason for hiding this comment

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

Leenk에서는 실제로 mongoDB를 사용하나요 ?
몽고디비 환경도 추가한 이유가 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

알림 기능을 위해서 몽고디비 사용하고 있습니당

Copy link
Collaborator

@jj0526 jj0526 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다! 피드랑 유저에 대해 비관적 락을 걸고, 테스트를 통해 락이 정상적으로 동작하는지까지 검증해주셨네요

Comment on lines +198 to +225
private fun executeConcurrentReactions(
threadCount: Int,
reactions: List<() -> Unit>
): Pair<Int, Int> {
val executor = Executors.newFixedThreadPool(threadCount)
val latch = CountDownLatch(reactions.size)
val successCount = AtomicInteger(0)
val failureCount = AtomicInteger(0)

reactions.forEachIndexed { index, reaction ->
executor.submit {
try {
reaction()
successCount.incrementAndGet()
} catch (e: Exception) {
println("스레드 $index 실패: ${e.message}")
failureCount.incrementAndGet()
} finally {
latch.countDown()
}
}
}

latch.await()
executor.shutdown()

return successCount.get() to failureCount.get()
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

스레드로 실행하는 부분 따로 빼놔서 좋네요!

Comment on lines +127 to +131
val updatedFeedAuthor = userRepository.findById(feedAuthor.id!!).get()
updatedFeedAuthor.totalReactionCount shouldBe ATTEMPT_COUNT.toLong()

val updatedFeed = feedRepository.findById(feed.id!!).get()
updatedFeed.totalReactionCount shouldBe ATTEMPT_COUNT.toLong()
Copy link
Collaborator

Choose a reason for hiding this comment

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

useCase를 검증하는게 목적이지만 이 경우에는 비관적 락을 테스트 하기 위해서 실제 Repository를 통해 검증한건가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

넵 해당 테스트들은 통합 테스트의 성격이라서 Repository까지 다 사용하도록 구현했습니당
단위, 통합 테스트를 구분할 방법도 한 번 생각을 해봐ㅏ야겠군요

@@ -0,0 +1,3 @@
spring:
profiles:
active: test No newline at end of file
Copy link
Collaborator

Choose a reason for hiding this comment

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

엔터가 하나 빠진거같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

환경변수 파일에도 개행이 필요할까요??

Copy link
Collaborator

Choose a reason for hiding this comment

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

필수는 아니긴 하지만 경고가 떠서 말씀드렸어요
충돌만 해결하시면 될거같아요~!

# Conflicts:
#	src/test/java/leets/leenk/config/MysqlTestConfig.java
#	src/test/java/leets/leenk/config/TestContainersTest.java
#	src/test/resources/application-test.yml
Copy link
Collaborator

@soo0711 soo0711 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다!!
원인 분석 -> 문제 해결 -> 테스트로 검증까지 꼼꼼하게 잘 되어있는 것 같습니다 👍

* 공감하기 등 동시 수정이 발생할 수 있는 경우 사용
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
Copy link
Collaborator

Choose a reason for hiding this comment

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

조회 쿼리에서 lock.timeout = 2000ms로 설정하신 기준이나 고려하신 부분이 있을지 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

보통의 경우 3초 정도로 설정하는 듯 한데, 공감하기의 경우는 너무 느린 응답속도로 반응하면 사용성이 떨어질 것 같아 약간 짧게 2초로 설정했습니다!!

@hyxklee
Copy link
Collaborator Author

hyxklee commented Jan 14, 2026

기존 @1winhyun 님께서 작성해주신 테스트와 성격이 다른 테스트라서 "FeedUsecaseIntegrationTest"로 명칭을 수정했습니당. 다음에도 통합 테스트를 구현하게 된다면 위 네이밍으로 작성해주세옴

Copy link
Member

@1winhyun 1winhyun left a comment

Choose a reason for hiding this comment

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

고생하셨습니다. 리뷰 하나 남겼으니 확인 부탁드려요!!

추가적으로 현재 락 타임아웃이 2초로 되어있는데, 이에 대한 예외를 별도로 처리하지 않는 것으로 보여 유저 입장에서는 500으로 보일 것으로 생각됩니다. 이를 따로 예외를 잡아 400번대 에러를 내는 것은 어떤가요?

Comment on lines +34 to +37
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
@Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id")
Optional<Feed> findByIdWithPessimisticLock(@Param("id") Long id);
Copy link
Member

Choose a reason for hiding this comment

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

지금 JOIN FETCH를 통해 USER를 묶고 있는데 이렇게 할 경우 USER까지 락에 걸릴 수 있지 않을까요?
해당 메서드가 구현된 이유는 락 순서를 고정해 데드락을 피하기 위해서입니다. 그런데 만약에 첫번째 쿼리에서 이미 USER가 잠겨버린다면 Feed 락-> User 락이 아니라 User 먼저 잠그고 Feed가 잠기는 현상이 일어날 수도 있을 것 같습니다.
또한 락 범위가 늘어나 불필요한 대기가 늘어날 수도 있을 것 같아요!!
개인적인 의견입니다..

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

3번 문제: 다른 피드더라도 작성자의 경우 “totalReactionCount” 필드를 동시에 업데이트 하기 때문에 데드락 발생
해당 문제 때문에 작성자에도 락을 걸어야하는 상황입니다!

테스트 해보니 페치 조인을 쓰는 경우 User 엔티티에도 락이 걸리는 것 맞네요! 다른 케이스라면 문제가 될 수 있지만 현재 요구사항으로는 딱 맞는 상황입니당. 주석에 해당 내용을 작성해둘테니 필요에 따라 구분해서 사용할 필요가 있어 보이네요!!

덕분에 해당 락이 중복이 되서 코드를 간략하게 만들 수 있겠네요 승현님 덕분에 하나 더 배웟습니당

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

그리고 커스텀 예외 하나 만들어서 잡아주겟습니다!

@hyxklee hyxklee merged commit f6816c6 into dev Jan 16, 2026
3 checks passed
@hyxklee hyxklee deleted the LNK-48-Leenk-공감하기-동시성-문제-해결 branch January 16, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants