Repository: woowacourse-teams/2024-touroot
Pull request #662: [Fix] - 다중 환경에서 배치 작업이 여러번 실행되는 오류 수정
Author: login: Libienz
Labels: name: BE
Created: 2025-03-09T15:25:26Z | Merged: 2025-03-11T03:18:51Z

# ✅ 작업 내용

- [x] 다중 환경에서 @Scheduled 작업이 여러 서버에서 동시에 실행되는 현상 수정

# 🙈 참고 사항
좋아요 개수를 동기화하는 작업이 여러 서버 노드에서 중복으로 실행될 수 있습니다. 이 문제를 해결하기 위해 분산락을 활용했습니다.
방대한 데이터 크기를 다루는 만큼 spin lock 방식의 오버헤드를 고려해서 Redisson 클라이언트를 사용했습니다.
다만 여러 서버 노드가 모두 동기화할 책임이 있는 것은 아니므로 락을 획득하지 못한 경우 트랜잭션이 소멸되도록 분기 처리하였습니다.

https://github.com/woowacourse-teams/2024-touroot/issues/661



Diff:
diff --git a/backend/build.gradle b/backend/build.gradle
index 94242e98..ba6bf16e 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -30,6 +30,7 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-actuator'
     implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
     implementation 'com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4'
+    implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
     implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
     runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
     runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
index 036bc2ad..eb723180 100644
--- a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
+++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
@@ -3,6 +3,8 @@
 import kr.touroot.travelogue.repository.TravelogueRepository;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -12,7 +14,10 @@
 @Slf4j
 public class TravelogueLikeSyncService {
 
+    private static final String LOCK_NAME = "syncLikeCountsWeekly";
+
     private final TravelogueRepository travelogueRepository;
+    private final RedissonClient redissonClient;
 
     /**
      * 매주 월요일 오전 3시에 좋아요 개수를 동기화
@@ -20,8 +25,15 @@ public class TravelogueLikeSyncService {
     @Scheduled(cron = "0 0 3 * * MON")
     @Transactional
     public void syncLikeCountsWeekly() {
-        log.info("Starting weekly like count sync...");
-        travelogueRepository.syncLikeCounts();
-        log.info("Weekly like count sync completed.");
+        RLock lock = redissonClient.getLock(LOCK_NAME);
+        if (lock.tryLock()) {
+            try {
+                log.info("Start sync like counts");
+                travelogueRepository.syncLikeCounts();
+                log.info("End sync like counts");
+            } finally {
+                lock.unlock();
+            }
+        }
     }
 }
diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeSyncServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeSyncServiceTest.java
new file mode 100644
index 00000000..cddb0a45
--- /dev/null
+++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeSyncServiceTest.java
@@ -0,0 +1,44 @@
+package kr.touroot.travelogue.service;
+
+import static org.awaitility.Awaitility.await;
+import static org.mockito.Mockito.times;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import kr.touroot.global.AbstractServiceIntegrationTest;
+import kr.touroot.travelogue.repository.TravelogueRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+
+@DisplayName("좋아요 개수 동기화 테스트")
+class TravelogueLikeSyncServiceTest extends AbstractServiceIntegrationTest {
+
+    @SpyBean
+    private TravelogueLikeSyncService travelogueLikeSyncService;
+    @MockBean
+    private TravelogueRepository travelogueRepository;
+
+    @DisplayName("여러 스레드가 동시에 동기화를 시도하는 경우 하나의 스레드만 성공한다")
+    @Test
+    void syncLikeCountsWeekly() {
+        // given & when
+        int threadCount = 10;
+        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+        CountDownLatch latch = new CountDownLatch(threadCount);
+        for (int i = 0; i < threadCount; i++) {
+            executorService.execute(() -> {
+                travelogueLikeSyncService.syncLikeCountsWeekly();
+                latch.countDown();
+            });
+        }
+
+        // then
+        await().atMost(Duration.ofSeconds(10)).until(() -> latch.getCount() == 0);
+        Mockito.verify(travelogueRepository, times(1)).syncLikeCounts();
+    }
+}


Reviews:
user: login: hangillee
body: 고생하셨습니다, 리비! 👍
Lettuce가 Spin-Lock을 사용했었군요. 하나 알아갑니다.

문제 될 만한 부분은 보이지 않아 approve 하겠습니다!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T02:01:01Z
user: login: eunjungL
body: 다중 노드 환경에서 중복 동작 방지하는 로직이 추가된거 확인했습니다.
분산락을 활용해보는 좋은 기회였네용

추가로 생각해볼만한 지점에 의견을 써두긴 했는데 구현하는 것까지는 과한 것 같아서 어떤 방법이 있을까? 정도만 생각해봐도 충분할 것 같아 approve 드립니다.

수고하셨어요 리비니쯔~ 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T15:58:41Z
user: login: Libienz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T01:26:54Z
user: login: nak-honest
body: 고생 많으셨습니다 리비!!

궁금한 점 하나가 있는데요!!
Lettuce 를 사용하지 않은 이유가 스핀 락의 오버헤드 때문이라고 하셨는데요!

저희 상황에서는 여러 다중 서버가 동시에 접근할 때 하나의 서버만 락을 획득하고, 나머지 서버는 락 획득을 단 한번만 시도하고 바로 트랜잭션을 소멸시키면 성능 상 더 좋은 것이 아닌가 궁금합니다!

즉 스핀락은 계속해서 락을 획득하려고 시도하는 것이 단점으로 알고 있는데, 한번만 시도하면 되지 않나 해서요!

그냥 저의 궁금증을 적은 것이고 구현에는 문제가 없어 보여 approve 드립니다!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T01:40:18Z

Review comments:
diff_hunk: @@ -12,16 +14,26 @@
 @Slf4j
 public class TravelogueLikeSyncService {
 
+    private static final String LOCK_NAME = "syncLikeCountsWeekly";
+
     private final TravelogueRepository travelogueRepository;
+    private final RedissonClient redissonClient;
 
     /**
      * 매주 월요일 오전 3시에 좋아요 개수를 동기화
      */
     @Scheduled(cron = "0 0 3 * * MON")
     @Transactional
     public void syncLikeCountsWeekly() {
-        log.info("Starting weekly like count sync...");
-        travelogueRepository.syncLikeCounts();
-        log.info("Weekly like count sync completed.");
+        RLock lock = redissonClient.getLock(LOCK_NAME);
path: backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
user: login: eunjungL
body: Redis를 사용해서 lock을 잡아두니 모든 애플리케이션에 global하게 lock이 잡히나보네요 😮 
신기한 방법이군요~ 👍 
created_at: 2025-03-10T15:52:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,16 +14,26 @@
 @Slf4j
 public class TravelogueLikeSyncService {
 
+    private static final String LOCK_NAME = "syncLikeCountsWeekly";
+
     private final TravelogueRepository travelogueRepository;
+    private final RedissonClient redissonClient;
 
     /**
      * 매주 월요일 오전 3시에 좋아요 개수를 동기화
      */
     @Scheduled(cron = "0 0 3 * * MON")
     @Transactional
     public void syncLikeCountsWeekly() {
-        log.info("Starting weekly like count sync...");
-        travelogueRepository.syncLikeCounts();
-        log.info("Weekly like count sync completed.");
+        RLock lock = redissonClient.getLock(LOCK_NAME);
+        if (lock.tryLock()) {
+            try {
+                log.info("Start sync like counts");
+                travelogueRepository.syncLikeCounts();
path: backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
user: login: eunjungL
body: 의견)
만약 여기서 로직이 실패해 트랜잭션의 롤백이 일어나거나 Redis에 오류가 발생해 락을 아무도 없지 못했다면 다른 노드는 시도하지 않을테니 별도의 failover가 없군요 🤔 
그 부분까지 구현하는건 오버 엔지니어링 같기도해서 어떤 방식으로 대처해볼 수 있을지 정도만 생각해봐도 좋을 것 같아용
단순 의견 하나 남깁니다 🙇 

created_at: 2025-03-10T15:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,16 +14,26 @@
 @Slf4j
 public class TravelogueLikeSyncService {
 
+    private static final String LOCK_NAME = "syncLikeCountsWeekly";
+
     private final TravelogueRepository travelogueRepository;
+    private final RedissonClient redissonClient;
 
     /**
      * 매주 월요일 오전 3시에 좋아요 개수를 동기화
      */
     @Scheduled(cron = "0 0 3 * * MON")
     @Transactional
     public void syncLikeCountsWeekly() {
-        log.info("Starting weekly like count sync...");
-        travelogueRepository.syncLikeCounts();
-        log.info("Weekly like count sync completed.");
+        RLock lock = redissonClient.getLock(LOCK_NAME);
+        if (lock.tryLock()) {
+            try {
+                log.info("Start sync like counts");
+                travelogueRepository.syncLikeCounts();
path: backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeSyncService.java
user: login: Libienz
body: 오~ 생각해보지 못한 부분이네요
다만 Repository Layer에서 예외가 발생했다면 다른 노드가 실행해도 마찬가지로 실패할 것 같아요.
다른 노드가 fail-over하는 해결책보다는 개발자가 대응해야 할 부분이 아닌가 싶어서 이대로 두겠습니다~
created_at: 2025-03-11T01:26:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+package kr.touroot.travelogue.service;
+
+import static org.awaitility.Awaitility.await;
+import static org.mockito.Mockito.times;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import kr.touroot.global.AbstractServiceIntegrationTest;
+import kr.touroot.travelogue.repository.TravelogueRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+
+@DisplayName("좋아요 개수 동기화 테스트")
+class TravelogueLikeSyncServiceTest extends AbstractServiceIntegrationTest {
+
+    @SpyBean
+    private TravelogueLikeSyncService travelogueLikeSyncService;
+    @MockBean
+    private TravelogueRepository travelogueRepository;
+
+    @DisplayName("여러 스레드가 동시에 동기화를 시도하는 경우 하나의 스레드만 성공한다")
+    @Test
+    void syncLikeCountsWeekly() {
+        // given & when
+        int threadCount = 10;
+        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
+        CountDownLatch latch = new CountDownLatch(threadCount);
+        for (int i = 0; i < threadCount; i++) {
+            executorService.execute(() -> {
+                travelogueLikeSyncService.syncLikeCountsWeekly();
+                latch.countDown();
+            });
+        }
+
+        // then
+        await().atMost(Duration.ofSeconds(10)).until(() -> latch.getCount() == 0);
+        Mockito.verify(travelogueRepository, times(1)).syncLikeCounts();
path: backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeSyncServiceTest.java
user: login: nak-honest
body: 꼼꼼한 테스트 코드 작성 너무 좋네요!!
created_at: 2025-03-11T01:34:51Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-09T15:29:17Z
body: ## Test Results
 34 files   34 suites   1m 10s ⏱️
316 tests 315 ✅ 1 💤 0 ❌
328 runs  327 ✅ 1 💤 0 ❌

Results for commit 70fdb36c.

[test-results]:data:application/gzip;base64,H4sIADqSz2cC/02MywrDIBQFfyW47uKqUdv+TLA+QJrE4mMV+u+9SKrZnZkDcxAfVpfJc+LzbSK5hjLI1qRLiDuyAmS8Sjup/NOSqzFNiaHe4YOKduF1WFH0xOJSiuk0qe6tye4n9CRT3Yxi40uw8bVn4raFgkAUePvi0oCkQD2IWcqHY5Z6HFoBt9xLI5Qi3x/ZFXy2BgEAAA==

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: Libienz
created_at: 2025-03-11T03:18:27Z
body: @hangillee @nak-honest 

> 저희 상황에서는 여러 다중 서버가 동시에 접근할 때 하나의 서버만 락을 획득하고, 나머지 서버는 락 획득을 단 한번만 시도하고 바로 트랜잭션을 소멸시키면 성능 상 더 좋은 것이 아닌가 궁금합니다!

현재 구현이 해당 구현과 동일합니다.

Redisson은 Pub/Sub 구조라고 하지만 Pub/Sub구조를 활용한 분산 락은 `lock.lock()`을 활용한 경우 활성화됩니다. 
현재의 tryLock은 락 획득을 시도하고 얻을 수 있을 때만 가져올 뿐 획득하지 못한 경우는 false를 return하고 대기 하지 않습니다.

```java
     // tryLock javadoc
     * <p>Acquires the lock if it is available and returns immediately
     * with the value {@code true}.
     * If the lock is not available then this method will return
     * immediately with the value {@code false}.
```

또 참고로 Lettuce는 애초에 분산락을 지원하지 않습니다.
분산락을 구현하려면 별도로 코드 작업을 진행해야 하는데요 이 과정에서 보통 스핀락 방식을 사용하게 됩니다.
lettuce가 스핀락을 사용하는 것처럼 표현해서 헷갈리셨을 수 있겠네요~ 본문의 내용 수정하겠습니다.
짚어주셔서 감사해요
author_association: CONTRIBUTOR