From 52990b718a4a8ab642b1111343fb9fc1a737707b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 18 Aug 2026 20:29:03 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=ED=99=9C=EB=8F=99=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=84=B0(createdCourse=20=EB=93=B1)=EC=9D=98?= =?UTF-8?q?=20Lost=20Update=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunnectUser.createdCourse/createdRecord/createdScrap/createdPublicCourse가 순수 in-memory ++ 로만 구현돼 있어, 동시 요청이 같은 유저 row를 커밋 전에 읽으면 한쪽 증가분이 조용히 유실될 수 있었다(로컬 Postgres 대상 재현 테스트로 실증, 다음 커밋 참고). RunnectUser에 @Version 낙관적 락을 추가하고, 카운터 증가 + 스탬프 지급을 메인 트랜잭션(코스/기록/스크랩 생성)에서 분리된 REQUIRES_NEW 트랜잭션 (UserStampService.recordActivityAndAwardStamp)으로 격리했다 — 낙관적 락 충돌이 코스/기록/스크랩 생성 자체까지 롤백시키지 않게 하기 위함이다. 충돌 시에는 OptimisticLockRetrier가 지터를 두고 재시도한다. --- .../concurrency/OptimisticLockRetrier.java | 44 +++++++++++++++++++ .../server/course/service/CourseService.java | 7 ++- .../server/record/service/RecordService.java | 7 ++- .../server/scrap/service/ScrapService.java | 7 ++- .../server/user/entity/RunnectUser.java | 9 ++++ .../server/user/service/UserStampService.java | 40 +++++++++++++++++ .../course/service/CourseServiceTest.java | 14 ++++-- .../record/service/RecordServiceTest.java | 14 ++++-- .../scrap/service/ScrapServiceTest.java | 17 +++++-- .../user/service/UserStampServiceTest.java | 26 +++++++++++ 10 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java diff --git a/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java b/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java new file mode 100644 index 0000000..a54f555 --- /dev/null +++ b/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java @@ -0,0 +1,44 @@ +package org.runnect.server.common.module.concurrency; + +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; + +/** + * 낙관적 락(@Version) 충돌로 실패한 작업을 정해진 횟수만큼 재시도한다. + * action은 반드시 REQUIRES_NEW 등으로 독립된 트랜잭션 단위여야 한다 — 그래야 각 시도가 + * 완전히 새로운 트랜잭션에서 최신 상태를 다시 읽고 재시도할 수 있다. + * + * 재시도 사이에 짧은 지터를 둔다 — 충돌한 모든 스레드가 즉시 동시에 재시도하면 서로 + * 다시 충돌할 확률이 높아지는 thundering herd 현상을 완화하기 위함이다. + */ +@Component +public class OptimisticLockRetrier { + + private static final int MAX_ATTEMPTS = 8; + private static final int BASE_BACKOFF_MILLIS = 15; + + public void runWithRetry(Runnable action) { + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + action.run(); + return; + } catch (ObjectOptimisticLockingFailureException e) { + if (attempt == MAX_ATTEMPTS) { + throw e; + } + backoff(attempt); + } + } + } + + private void backoff(int attempt) { + int maxJitterMillis = BASE_BACKOFF_MILLIS * attempt; + long sleepMillis = ThreadLocalRandom.current().nextLong(maxJitterMillis + 1); + try { + Thread.sleep(sleepMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/org/runnect/server/course/service/CourseService.java b/src/main/java/org/runnect/server/course/service/CourseService.java index ecec62f..8f28d86 100644 --- a/src/main/java/org/runnect/server/course/service/CourseService.java +++ b/src/main/java/org/runnect/server/course/service/CourseService.java @@ -9,6 +9,7 @@ import org.locationtech.jts.geom.LineString; import org.runnect.server.common.dto.DepartureResponse; import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.common.module.convert.CoordinatePathConverter; import org.runnect.server.common.module.convert.DepartureConverter; import org.runnect.server.course.dto.request.CourseCreateRequestDto; @@ -39,6 +40,7 @@ public class CourseService { private final PublicCourseRepository publicCourseRepository; private final UserRepository userRepository; private final UserStampService userStampService; + private final OptimisticLockRetrier optimisticLockRetrier; @Transactional public CourseCreateResponseDto createCourse(Long userId, CourseCreateRequestDto requestDto, @@ -65,8 +67,9 @@ public CourseCreateResponseDto createCourse(Long userId, CourseCreateRequestDto .build(); Course saved = courseRepository.save(course); - user.updateCreatedCourse(); - userStampService.createStampByUser(user, StampType.c); + optimisticLockRetrier.runWithRetry( + () -> userStampService.recordActivityAndAwardStamp(user.getId(), StampType.c) + ); return CourseCreateResponseDto.of(saved.getId(), saved.getCreatedAt()); } diff --git a/src/main/java/org/runnect/server/record/service/RecordService.java b/src/main/java/org/runnect/server/record/service/RecordService.java index 2d50b47..762cd30 100644 --- a/src/main/java/org/runnect/server/record/service/RecordService.java +++ b/src/main/java/org/runnect/server/record/service/RecordService.java @@ -9,6 +9,7 @@ import org.runnect.server.common.constant.ErrorStatus; import org.runnect.server.common.exception.NotFoundException; import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.course.entity.Course; import org.runnect.server.course.repository.CourseRepository; import org.runnect.server.publicCourse.entity.PublicCourse; @@ -52,6 +53,7 @@ public class RecordService { private final UserStampService userStampService; private final RecordHealthDataRepository recordHealthDataRepository; private final RecordRankingService recordRankingService; + private final OptimisticLockRetrier optimisticLockRetrier; @Transactional public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto request) { @@ -91,8 +93,9 @@ public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto registerRankingUpdateAfterCommit(publicCourse.getId(), userId, record.getId(), time); } - user.updateCreatedRecord(); - userStampService.createStampByUser(user, StampType.r); + optimisticLockRetrier.runWithRetry( + () -> userStampService.recordActivityAndAwardStamp(userId, StampType.r) + ); CreateRecordDto recordDto = new CreateRecordDto(record.getId(), record.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"))); diff --git a/src/main/java/org/runnect/server/scrap/service/ScrapService.java b/src/main/java/org/runnect/server/scrap/service/ScrapService.java index 1afe4ba..2d2671a 100644 --- a/src/main/java/org/runnect/server/scrap/service/ScrapService.java +++ b/src/main/java/org/runnect/server/scrap/service/ScrapService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.runnect.server.common.constant.ErrorStatus; import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.publicCourse.entity.PublicCourse; import org.runnect.server.publicCourse.repository.PublicCourseRepository; import org.runnect.server.scrap.dto.request.CreateAndDeleteScrapRequestDto; @@ -27,6 +28,7 @@ public class ScrapService { private final UserRepository userRepository; private final PublicCourseRepository publicCourseRepository; private final UserStampService userStampService; + private final OptimisticLockRetrier optimisticLockRetrier; @Transactional public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateAndDeleteScrapRequestDto request) { @@ -44,8 +46,9 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA .runnectUser(user) .build(); - user.updateCreatedScrap(); - userStampService.createStampByUser(user, StampType.s); + optimisticLockRetrier.runWithRetry( + () -> userStampService.recordActivityAndAwardStamp(userId, StampType.s) + ); scrapRepository.save(newScrap); } else { diff --git a/src/main/java/org/runnect/server/user/entity/RunnectUser.java b/src/main/java/org/runnect/server/user/entity/RunnectUser.java index a37f7e3..9c29b9d 100644 --- a/src/main/java/org/runnect/server/user/entity/RunnectUser.java +++ b/src/main/java/org/runnect/server/user/entity/RunnectUser.java @@ -12,6 +12,7 @@ import javax.persistence.OneToMany; import javax.persistence.Table; import javax.persistence.UniqueConstraint; +import javax.persistence.Version; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -67,6 +68,14 @@ public class RunnectUser extends AuditingTimeEntity { @Column(nullable = false) private Long createdScrap; + /** + * 카운터 필드(createdCourse 등)를 다른 트랜잭션이 동시에 읽고 수정하면 Lost Update가 + * 발생할 수 있어 낙관적 락을 건다. 충돌 시 UserStampService.recordActivityAndAwardStamp가 + * 별도 트랜잭션에서 재시도한다. + */ + @Version + private Long version; + @OneToMany(mappedBy = "runnectUser") private List courses = new ArrayList<>(); diff --git a/src/main/java/org/runnect/server/user/service/UserStampService.java b/src/main/java/org/runnect/server/user/service/UserStampService.java index ca36220..7fc785f 100644 --- a/src/main/java/org/runnect/server/user/service/UserStampService.java +++ b/src/main/java/org/runnect/server/user/service/UserStampService.java @@ -11,6 +11,7 @@ import org.runnect.server.user.repository.UserRepository; import org.runnect.server.user.repository.UserStampRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -20,6 +21,45 @@ public class UserStampService { private final UserStampRepository userStampRepository; private final UserRepository userRepository; + /** + * 활동 카운터 증가 + 스탬프 지급을, 이 활동을 유발한 메인 트랜잭션(코스/기록/스크랩 생성)과 + * 분리된 별도 트랜잭션에서 수행한다. RunnectUser의 카운터 필드에는 낙관적 락(@Version)이 + * 걸려있어, 동시에 같은 유저의 카운터를 갱신하는 다른 요청과 충돌하면 + * ObjectOptimisticLockingFailureException이 발생한다 — 메인 트랜잭션(코스/기록/스크랩 생성 + * 자체)까지 롤백시키지 않기 위해 REQUIRES_NEW로 격리했다. 충돌 시 재시도는 호출부가 + * OptimisticLockRetrier로 감싸서 처리한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordActivityAndAwardStamp(Long userId, StampType stampType) { + RunnectUser user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundUserException(NOT_FOUND_USER_EXCEPTION, + NOT_FOUND_USER_EXCEPTION.getMessage())); + + incrementActivityCount(user, stampType); + createStampByUser(user, stampType); + + // 여기서 명시적으로 flush해서, 버전 충돌이 이 메서드 안에서 즉시 드러나게 한다 + // (그래야 REQUIRES_NEW 트랜잭션 경계를 벗어나기 전에 예외가 호출부로 전파된다). + userRepository.saveAndFlush(user); + } + + private void incrementActivityCount(RunnectUser user, StampType stampType) { + switch (stampType) { + case c: + user.updateCreatedCourse(); + break; + case s: + user.updateCreatedScrap(); + break; + case r: + user.updateCreatedRecord(); + break; + case u: + user.updateCreatedPublicCourse(); + break; + } + } + @Transactional public void createStampByUser(RunnectUser user, StampType stampType) { Long activityCount = getActivityCount(user, stampType); diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java index 75c9059..010204f 100644 --- a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -24,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.runnect.server.common.exception.BadRequestException; import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.common.module.convert.CoordinateDto; import org.runnect.server.common.module.convert.CoordinatePathConverter; import org.runnect.server.course.dto.request.CourseCreateRequestDto; @@ -55,13 +57,20 @@ class CourseServiceTest { private UserRepository userRepository; @Mock private UserStampService userStampService; + @Mock + private OptimisticLockRetrier optimisticLockRetrier; private CourseService courseService; @BeforeEach void setUp() { courseService = new CourseService(courseRepository, publicCourseRepository, userRepository, - userStampService); + userStampService, optimisticLockRetrier); + org.mockito.Mockito.lenient().doAnswer(invocation -> { + Runnable action = invocation.getArgument(0); + action.run(); + return null; + }).when(optimisticLockRetrier).runWithRetry(any()); } private RunnectUser buildUser(Long id) { @@ -130,8 +139,7 @@ class CreateCourse { assertThat(response.getId()).isEqualTo(100L); assertThat(response.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0)); - assertThat(user.getCreatedCourse()).isEqualTo(1L); - verify(userStampService).createStampByUser(user, StampType.c); + verify(userStampService).recordActivityAndAwardStamp(1L, StampType.c); ArgumentCaptor captor = ArgumentCaptor.forClass(Course.class); verify(courseRepository).save(captor.capture()); diff --git a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java index 13308ba..7a64612 100644 --- a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java +++ b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java @@ -24,6 +24,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.runnect.server.common.exception.NotFoundException; import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.course.entity.Course; import org.runnect.server.course.repository.CourseRepository; import org.runnect.server.health.entity.RecordHealthData; @@ -68,13 +69,21 @@ class RecordServiceTest { private RecordHealthDataRepository recordHealthDataRepository; @Mock private org.runnect.server.ranking.service.RecordRankingService recordRankingService; + @Mock + private OptimisticLockRetrier optimisticLockRetrier; private RecordService recordService; @BeforeEach void setUp() { recordService = new RecordService(recordRepository, userRepository, courseRepository, - publicCourseRepository, userStampService, recordHealthDataRepository, recordRankingService); + publicCourseRepository, userStampService, recordHealthDataRepository, recordRankingService, + optimisticLockRetrier); + org.mockito.Mockito.lenient().doAnswer(invocation -> { + Runnable action = invocation.getArgument(0); + action.run(); + return null; + }).when(optimisticLockRetrier).runWithRetry(any()); } private RunnectUser buildUser(Long id) { @@ -163,8 +172,7 @@ class CreateRecord { CreateRecordResponseDto response = recordService.createRecord(1L, request); assertThat(response.getRecord().getId()).isEqualTo(100L); - assertThat(user.getCreatedRecord()).isEqualTo(1L); - verify(userStampService).createStampByUser(user, StampType.r); + verify(userStampService).recordActivityAndAwardStamp(1L, StampType.r); verify(publicCourseRepository, never()).findById(any()); verify(recordRankingService, never()).updateBestRecord(anyLong(), anyLong(), anyLong(), any()); } diff --git a/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java index c42bdaa..4d95e04 100644 --- a/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java +++ b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,6 +22,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; import org.runnect.server.common.module.convert.CoordinateDto; import org.runnect.server.common.module.convert.CoordinatePathConverter; import org.runnect.server.course.entity.Course; @@ -50,13 +53,20 @@ class ScrapServiceTest { private PublicCourseRepository publicCourseRepository; @Mock private UserStampService userStampService; + @Mock + private OptimisticLockRetrier optimisticLockRetrier; private ScrapService scrapService; @BeforeEach void setUp() { scrapService = new ScrapService(scrapRepository, userRepository, publicCourseRepository, - userStampService); + userStampService, optimisticLockRetrier); + lenient().doAnswer(invocation -> { + Runnable action = invocation.getArgument(0); + action.run(); + return null; + }).when(optimisticLockRetrier).runWithRetry(any()); } private RunnectUser buildUser(Long id) { @@ -134,8 +144,7 @@ class CreateAndDeleteScrap { assertThat(response.getScrapCount()).isEqualTo(1L); assertThat(response.getScrapTF()).isTrue(); - assertThat(user.getCreatedScrap()).isEqualTo(1L); - verify(userStampService).createStampByUser(user, StampType.s); + verify(userStampService).recordActivityAndAwardStamp(1L, StampType.s); verify(scrapRepository).save(any(Scrap.class)); } @@ -155,7 +164,7 @@ class CreateAndDeleteScrap { assertThat(existingScrap.getScrapTF()).isTrue(); assertThat(user.getCreatedScrap()).isEqualTo(0L); verify(scrapRepository, never()).save(any()); - verify(userStampService, never()).createStampByUser(any(), any()); + verify(userStampService, never()).recordActivityAndAwardStamp(any(), any()); } @Test diff --git a/src/test/java/org/runnect/server/user/service/UserStampServiceTest.java b/src/test/java/org/runnect/server/user/service/UserStampServiceTest.java index eccac1e..fbdbc35 100644 --- a/src/test/java/org/runnect/server/user/service/UserStampServiceTest.java +++ b/src/test/java/org/runnect/server/user/service/UserStampServiceTest.java @@ -222,6 +222,32 @@ class CreateStampByUser { } } + @Nested + @DisplayName("recordActivityAndAwardStamp") + class RecordActivityAndAwardStamp { + + @Test + @DisplayName("userId로 유저를 다시 조회해서 카운터를 증가시키고 saveAndFlush로 저장한다") + void 정상_호출() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + userStampService.recordActivityAndAwardStamp(1L, StampType.c); + + assertThat(user.getCreatedCourse()).isEqualTo(1L); + verify(userRepository).saveAndFlush(user); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userStampService.recordActivityAndAwardStamp(1L, StampType.c)) + .isInstanceOf(NotFoundUserException.class); + } + } + @Nested @DisplayName("findUserStamps") class FindUserStamps { From a2ea2b28eea547f509fc0db888e02c0524171f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 18 Aug 2026 20:29:11 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=84=B0=20Lost=20Update=20=EC=9E=AC=ED=98=84/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 Postgres에 대해 실제 트랜잭션 두 개가 커밋 전 상태를 서로 보지 못하는 상황을 재현해, @Version 도입 전에는 조용한 데이터 손상 (assertion 실패)으로, 도입 후에는 명시적 충돌 감지 (ObjectOptimisticLockingFailureException)로 바뀌었음을 검증한다. 두 번째 테스트는 실제 프로덕션 경로(OptimisticLockRetrier + recordActivityAndAwardStamp)를 스레드 10개로 동시 호출해, 재시도로 충돌이 모두 해소되고 최종 카운트가 정확히 맞는지(호출부에는 예외가 전파되지 않는지) 검증한다. --- .../RunnectUserCounterConcurrencyTest.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/test/java/org/runnect/server/user/RunnectUserCounterConcurrencyTest.java diff --git a/src/test/java/org/runnect/server/user/RunnectUserCounterConcurrencyTest.java b/src/test/java/org/runnect/server/user/RunnectUserCounterConcurrencyTest.java new file mode 100644 index 0000000..f961d38 --- /dev/null +++ b/src/test/java/org/runnect/server/user/RunnectUserCounterConcurrencyTest.java @@ -0,0 +1,156 @@ +package org.runnect.server.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.module.concurrency.OptimisticLockRetrier; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.StampType; +import org.runnect.server.user.repository.UserRepository; +import org.runnect.server.user.service.UserStampService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * RunnectUser의 createdCourse 등 카운터 필드가 순수 in-memory ++ 로 구현되어 있어 + * Lost Update(갱신 유실)에 취약했던 문제를, 실제 로컬 Postgres에 대해 재현/검증한다. + * + * 1) {@link #버전_없이_직접_저장하면_충돌이_감지된다}: @Version을 추가하기 전에는 이 테스트가 + * "두 번 증가시켰는데 실제로는 1로 기록됨"이라는 assertion 실패로 버그를 실증했다 + * (커밋 로그 참고). @Version 추가 후에는 같은 재현 절차가 더 이상 조용히 데이터를 + * 잘못 기록하지 않고, ObjectOptimisticLockingFailureException을 던져 충돌을 명시적으로 + * 감지한다 — "조용한 데이터 손상"에서 "감지 가능한 실패"로 바뀐 것을 보여준다. + * 2) {@link #동시에_여러_요청이_같은_유저의_카운터를_증가시켜도_유실되지_않는다}: 실제 프로덕션 + * 경로(OptimisticLockRetrier + UserStampService.recordActivityAndAwardStamp)를 여러 + * 스레드에서 동시에 호출해도, 충돌은 자동으로 재시도되어 최종 카운트가 정확히 맞고 + * 호출부(CourseService 등)에는 예외가 전파되지 않음을 검증한다. + */ +@SpringBootTest +class RunnectUserCounterConcurrencyTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private UserStampService userStampService; + + @Autowired + private OptimisticLockRetrier optimisticLockRetrier; + + @PersistenceContext + private EntityManager entityManager; + + private Long testUserId; + + @AfterEach + void tearDown() { + if (testUserId == null) { + return; + } + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> { + // 카운터 증가 과정에서 함께 생긴 UserStamp 자식 row부터 지워야 FK 제약을 안 건드린다. + entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id = :userId") + .setParameter("userId", testUserId) + .executeUpdate(); + userRepository.deleteById(testUserId); + }); + } + + private Long createTestUser(String nickname) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + return tx.execute(status -> userRepository.save( + RunnectUser.builder() + .nickname(nickname) + .socialId("concurrency-test-social-id-" + nickname) + .email("concurrency-test-" + nickname + "@runnect.test") + .provider(SocialType.VISITOR) + .build() + ).getId()); + } + + @Test + void 버전_없이_직접_저장하면_충돌이_감지된다() { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + testUserId = createTestUser("cc-lost-update"); + + // 요청 A, 요청 B가 서로의 커밋 전 상태를 보지 못한 채 같은 초기값(createdCourse=0, version=0)을 읽는다. + RunnectUser userSeenByRequestA = tx.execute(status -> userRepository.findById(testUserId).orElseThrow()); + RunnectUser userSeenByRequestB = tx.execute(status -> userRepository.findById(testUserId).orElseThrow()); + + userSeenByRequestA.updateCreatedCourse(); + userSeenByRequestB.updateCreatedCourse(); + + // 요청 A 커밋 -> createdCourse=1, version이 올라감 + tx.executeWithoutResult(status -> userRepository.save(userSeenByRequestA)); + + // 요청 B는 자신이 읽은 시점(version=0)을 기준으로 저장을 시도한다. + // @Version이 없다면 이 저장은 조용히 성공해 요청 A의 증가분을 덮어썼을 것이다(Lost Update). + // @Version이 있으면 버전 불일치가 감지되어 예외가 발생한다 — 유실이 아니라 실패로 바뀐다. + assertThatThrownBy(() -> tx.executeWithoutResult(status -> userRepository.save(userSeenByRequestB))) + .isInstanceOf(ObjectOptimisticLockingFailureException.class); + } + + @Test + void 동시에_여러_요청이_같은_유저의_카운터를_증가시켜도_유실되지_않는다() throws InterruptedException { + testUserId = createTestUser("cc-fixed-path"); + int threadCount = 10; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger failureCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + // 실제 CourseService/RecordService/ScrapService가 호출하는 것과 동일한 경로. + optimisticLockRetrier.runWithRetry( + () -> userStampService.recordActivityAndAwardStamp(testUserId, StampType.c) + ); + } catch (Exception e) { + failureCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + boolean completed = doneLatch.await(15, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue(); + assertThat(failureCount.get()) + .withFailMessage("재시도로도 해결되지 않은 충돌이 %d건 발생함", failureCount.get()) + .isZero(); + + RunnectUser result = userRepository.findById(testUserId).orElseThrow(); + assertThat(result.getCreatedCourse()) + .withFailMessage( + "%d번 동시에 증가시켰는데 실제로는 %d로 기록됨 — 여전히 Lost Update 발생", + threadCount, result.getCreatedCourse() + ) + .isEqualTo((long) threadCount); + } +} From 4a8f9fa4b41971ad914254140c26e128a3409d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 22:00:46 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=8A=A4=ED=83=AC=ED=94=84/=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=84=B0=20=EA=B0=B1=EC=8B=A0=EC=9D=B4=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=80=EC=9E=A5=20=EB=A1=A4=EB=B0=B1=EA=B3=BC=20?= =?UTF-8?q?=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 리뷰 지적: recordActivityAndAwardStamp()가 REQUIRES_NEW라 호출 즉시 별도 트랜잭션으로 독립 커밋되는데, 이 호출이 메인 엔티티 저장보다 먼저(Scrap) 또는 저장 직후지만 같은 트랜잭션 커밋 전에(Course/Record) 실행되고 있었다. 메인 저장이 실패해 트랜잭션이 롤백돼도 이미 커밋된 스탬프/카운터는 되돌아가지 않는다. 실제로 재현: 같은 유저가 같은 코스를 동시에 스크랩하면 유니크 제약으로 한쪽만 저장에 성공하는데(#252), 수정 전에는 실패한 쪽도 스탬프/카운터가 반영돼 createdScrap이 실제 스크랩 개수(1)보다 많은 2로 기록됨을 테스트로 확인. OptimisticLockRetrier에 runAfterCommit()을 추가해, 메인 트랜잭션이 실제로 커밋된 뒤에만(TransactionSynchronization.afterCommit) 재시도 로직이 실행되게 했다 — 이 프로젝트에 이미 있던 랭킹(Redis) 갱신의 afterCommit 패턴과 동일한 구조. 재시도가 모두 소진되는 극단적인 경우엔 메인 작업 자체는 이미 성공했으므로 예외를 던지지 않고 로그로만 남긴다. --- .../concurrency/OptimisticLockRetrier.java | 34 +++++++++++++++++++ .../server/course/service/CourseService.java | 2 +- .../server/record/service/RecordService.java | 2 +- .../server/scrap/service/ScrapService.java | 8 ++--- .../course/service/CourseServiceTest.java | 2 +- .../record/service/RecordServiceTest.java | 2 +- .../server/scrap/ScrapConcurrencyTest.java | 12 +++++++ .../scrap/service/ScrapServiceTest.java | 2 +- 8 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java b/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java index a54f555..a798906 100644 --- a/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java +++ b/src/main/java/org/runnect/server/common/module/concurrency/OptimisticLockRetrier.java @@ -1,8 +1,11 @@ package org.runnect.server.common.module.concurrency; import java.util.concurrent.ThreadLocalRandom; +import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; /** * 낙관적 락(@Version) 충돌로 실패한 작업을 정해진 횟수만큼 재시도한다. @@ -12,6 +15,7 @@ * 재시도 사이에 짧은 지터를 둔다 — 충돌한 모든 스레드가 즉시 동시에 재시도하면 서로 * 다시 충돌할 확률이 높아지는 thundering herd 현상을 완화하기 위함이다. */ +@Slf4j @Component public class OptimisticLockRetrier { @@ -32,6 +36,36 @@ public void runWithRetry(Runnable action) { } } + /** + * action을 지금 실행하지 않고, 호출 시점에 진행 중인 트랜잭션이 실제로 커밋된 뒤에만 + * 실행되도록 예약한다. action 자체는 REQUIRES_NEW라 호출 시점(=원래의 runWithRetry)에 + * 즉시 별도 트랜잭션으로 독립 커밋되는데, 그 시점이 메인 트랜잭션의 저장보다 앞서면 + * 메인 저장이 나중에 실패해 롤백돼도 이 action은 이미 커밋돼 있어 되돌릴 수 없다. + * afterCommit으로 미루면 메인 트랜잭션이 실제로 성공했을 때만 실행된다. 진행 중인 + * 트랜잭션이 없는 채로 호출되면(동기화 비활성) 예약할 커밋 시점 자체가 없으므로 + * 그 자리에서 바로 실행한다. + * + * 재시도가 모두 소진되는 경우(극단적인 지속 경합)는 메인 작업 자체는 이미 성공한 + * 뒤이므로, 그 실패를 클라이언트 응답의 실패로 만들지 않고 로그로만 남긴다. + */ + public void runAfterCommit(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + runWithRetry(action); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + runWithRetry(action); + } catch (ObjectOptimisticLockingFailureException e) { + log.error("커밋 후 재시도가 모두 소진되어 활동 카운터/스탬프 반영에 실패함", e); + } + } + }); + } + private void backoff(int attempt) { int maxJitterMillis = BASE_BACKOFF_MILLIS * attempt; long sleepMillis = ThreadLocalRandom.current().nextLong(maxJitterMillis + 1); diff --git a/src/main/java/org/runnect/server/course/service/CourseService.java b/src/main/java/org/runnect/server/course/service/CourseService.java index 8f28d86..1bd46db 100644 --- a/src/main/java/org/runnect/server/course/service/CourseService.java +++ b/src/main/java/org/runnect/server/course/service/CourseService.java @@ -67,7 +67,7 @@ public CourseCreateResponseDto createCourse(Long userId, CourseCreateRequestDto .build(); Course saved = courseRepository.save(course); - optimisticLockRetrier.runWithRetry( + optimisticLockRetrier.runAfterCommit( () -> userStampService.recordActivityAndAwardStamp(user.getId(), StampType.c) ); diff --git a/src/main/java/org/runnect/server/record/service/RecordService.java b/src/main/java/org/runnect/server/record/service/RecordService.java index 762cd30..1dfa940 100644 --- a/src/main/java/org/runnect/server/record/service/RecordService.java +++ b/src/main/java/org/runnect/server/record/service/RecordService.java @@ -93,7 +93,7 @@ public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto registerRankingUpdateAfterCommit(publicCourse.getId(), userId, record.getId(), time); } - optimisticLockRetrier.runWithRetry( + optimisticLockRetrier.runAfterCommit( () -> userStampService.recordActivityAndAwardStamp(userId, StampType.r) ); diff --git a/src/main/java/org/runnect/server/scrap/service/ScrapService.java b/src/main/java/org/runnect/server/scrap/service/ScrapService.java index f333b54..8addcd6 100644 --- a/src/main/java/org/runnect/server/scrap/service/ScrapService.java +++ b/src/main/java/org/runnect/server/scrap/service/ScrapService.java @@ -48,10 +48,6 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA .runnectUser(user) .build(); - optimisticLockRetrier.runWithRetry( - () -> userStampService.recordActivityAndAwardStamp(userId, StampType.s) - ); - // 동시에 같은 코스를 스크랩하는 요청이 겹치면 둘 다 "기존 스크랩 없음"을 보고 // 각자 저장을 시도할 수 있다 — (user_id, public_course_id) 유니크 제약으로 DB가 // 하나는 거부하는데, 그 예외를 그대로 두면 500으로 샌다(HealthService의 기존 @@ -63,6 +59,10 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION, ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION.getMessage()); } + + optimisticLockRetrier.runAfterCommit( + () -> userStampService.recordActivityAndAwardStamp(userId, StampType.s) + ); } else { // 기존 스크랩한 내역이 있을 때 scrap.updateScrapTF(true); diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java index 010204f..58f8000 100644 --- a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -70,7 +70,7 @@ void setUp() { Runnable action = invocation.getArgument(0); action.run(); return null; - }).when(optimisticLockRetrier).runWithRetry(any()); + }).when(optimisticLockRetrier).runAfterCommit(any()); } private RunnectUser buildUser(Long id) { diff --git a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java index 7a64612..ba67836 100644 --- a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java +++ b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java @@ -83,7 +83,7 @@ void setUp() { Runnable action = invocation.getArgument(0); action.run(); return null; - }).when(optimisticLockRetrier).runWithRetry(any()); + }).when(optimisticLockRetrier).runAfterCommit(any()); } private RunnectUser buildUser(Long id) { diff --git a/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java index 74e09de..7824c2e 100644 --- a/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java +++ b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java @@ -150,5 +150,17 @@ private CreateAndDeleteScrapRequestDto scrapRequest(Long publicCourseId, boolean exception ) .isInstanceOf(org.runnect.server.common.exception.ConflictException.class); + + // 스크랩은 실제로 1건만 만들어졌는데(한쪽은 ConflictException으로 실패), 스탬프/카운터 + // 업데이트가 저장 성공 여부와 무관하게 REQUIRES_NEW로 즉시 커밋돼버리면 두 번 다 + // 반영돼 createdScrap이 2가 될 수 있다 — 실패한 시도까지 카운트되는 것이므로 그 자체가 버그. + RunnectUser user = userRepository.findById(testUserId).orElseThrow(); + assertThat(user.getCreatedScrap()) + .withFailMessage( + "실제로 생성된 스크랩은 1건인데 카운터가 %d로 기록됨 — 실패한 시도의 스탬프/카운터 갱신이 " + + "메인 저장 실패와 무관하게 커밋돼버린 것", + user.getCreatedScrap() + ) + .isEqualTo(1L); } } diff --git a/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java index 4d95e04..662bfbd 100644 --- a/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java +++ b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java @@ -66,7 +66,7 @@ void setUp() { Runnable action = invocation.getArgument(0); action.run(); return null; - }).when(optimisticLockRetrier).runWithRetry(any()); + }).when(optimisticLockRetrier).runAfterCommit(any()); } private RunnectUser buildUser(Long id) { From 6946f28212114cd29aa1811829117446b247a8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 23:27:36 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=8B=9C=EB=93=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=97=90=20=EC=9A=B0=EC=97=B0=ED=9E=88=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 테스트는 PublicCourseRepository를 목으로 대체하면서도 (user_id, public_course_id) FK 제약을 만족하려면 실제 course/public_course row가 DB에 있어야 한다는 걸 놓치고, 로컬 개발 DB에 우연히 남아있던 더미 데이터 (id=1)를 그대로 가정하고 작성했었다. PR #251 CI에서 재현: CI는 매번 새로 뜨는 빈 DB라 그 row가 아예 없어서, 두 스레드 모두 진짜 유니크 제약 위반이 아니라 FK 제약 위반으로 실패했다. 그런데 둘 다 같은 DataIntegrityViolationException → ConflictException 경로를 타기 때문에 겉보기엔 "정상 통과"처럼 보였다 — 실제로는 검증하려던 레이스와 무관한 이유로 우연히 같은 예외 타입이 나온 것뿐이었다. 진단 로그로 capturedException/스레드별 결과/실제 저장된 scrap 개수/public_course 실존 여부를 직접 찍어보고서야 확인했다. 이제 @BeforeEach에서 필요한 course/public_course row를 테스트가 직접 INSERT(네이티브 SQL)해서 어떤 환경에서 실행해도 동일하게 동작하도록 했다. (로컬 Postgres 컨테이너에 postgis 패키지가 아예 설치돼 있지 않았던 것도 같이 확인해 postgresql-15-postgis-3를 설치했다 — 기존에 문서화된 "PostGIS 공유 라이브러리 결함"의 실제 원인이었다.) --- .../server/scrap/ScrapConcurrencyTest.java | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java index 7824c2e..7b678fa 100644 --- a/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java +++ b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java @@ -38,17 +38,26 @@ * 확인한 뒤 커밋 로그에 남김). 수정 후에는 HealthService의 기존 처리 패턴과 동일하게 * ConflictException(409)으로 변환된다 — 이 테스트는 그 수정 후 동작을 검증한다. * + * 그 다음 단계로, 스탬프/카운터 갱신(recordActivityAndAwardStamp)이 메인 저장 성공 여부와 + * 무관하게 반영되지 않는지도 함께 검증한다 — 실패한 시도까지 카운트되면 안 되기 때문이다. + * * PublicCourseRepository는 @MockBean으로 대체한다 — 이 프로젝트의 로컬 Postgres/PostGIS * JDBC 드라이버 조합이 geometry(Course.path) 컬럼을 포함한 JOIN FETCH 결과를 추출할 때 * 알려진 결함이 있어(다른 통합 테스트에서도 동일 사유로 우회한 이력 있음), 이 테스트가 * 검증하려는 "스크랩 유니크 제약 경합"과 무관한 그 문제를 피하기 위함이다. Scrap/User * 리포지토리와 트랜잭션은 모두 실제 로컬 Postgres를 그대로 사용한다. + * + * 단, publicCourseRepository를 목으로 대체하는 것과 별개로 (user_id, public_course_id) + * FK 제약을 만족하려면 실제 course/public_course row가 DB에 존재해야 한다 — 처음에는 로컬에 + * 우연히 남아있던 더미 데이터(id=1)를 가정하고 작성했다가, CI(매번 새로 뜨는 빈 DB)에서는 + * 그 row가 아예 없어 두 스레드 모두 "유니크 제약 위반"이 아니라 "FK 제약 위반"으로 실패해버려 + * 테스트가 실제로 검증하려는 레이스와 무관하게 우연히 같은 예외 타입(ConflictException)으로 + * 통과해버리는 것을 CI 재현으로 확인했다. 이제는 이 테스트가 스스로 필요한 course/public_course + * row를 만들어 어떤 환경에서 실행해도 동일하게 동작하도록 한다. */ @SpringBootTest class ScrapConcurrencyTest { - private static final Long EXISTING_PUBLIC_COURSE_ID = 1L; - @Autowired private ScrapService scrapService; @@ -65,31 +74,57 @@ class ScrapConcurrencyTest { private EntityManager entityManager; private Long testUserId; + private Long testCourseId; + private Long testPublicCourseId; @BeforeEach void setUpPublicCourseStub() { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> { + testCourseId = ((Number) entityManager.createNativeQuery( + "INSERT INTO course " + + "(title, departure_region, departure_city, departure_town, distance, image, is_private, path, created_at, updated_at) " + + "VALUES ('동시성 테스트용 코스', '경기', '시흥시', '정왕동', 1.0, 'https://image.example/cc-scrap.png', " + + "false, ST_GeomFromText('LINESTRING(127.0 37.5, 127.1 37.6)', 4326), now(), now()) " + + "RETURNING id") + .getSingleResult()).longValue(); + + testPublicCourseId = ((Number) entityManager.createNativeQuery( + "INSERT INTO public_course (title, description, course_id, created_at, updated_at) " + + "VALUES ('스텁 공개 코스', '동시성 테스트용 스텁', " + testCourseId + ", now(), now()) " + + "RETURNING id") + .getSingleResult()).longValue(); + }); + PublicCourse publicCourse = PublicCourse.builder() .title("스텁 공개 코스") .description("동시성 테스트용 스텁") .build(); - ReflectionTestUtils.setField(publicCourse, "id", EXISTING_PUBLIC_COURSE_ID); - when(publicCourseRepository.findById(EXISTING_PUBLIC_COURSE_ID)).thenReturn(Optional.of(publicCourse)); + ReflectionTestUtils.setField(publicCourse, "id", testPublicCourseId); + when(publicCourseRepository.findById(testPublicCourseId)).thenReturn(Optional.of(publicCourse)); } @AfterEach void tearDown() { - if (testUserId == null) { - return; - } TransactionTemplate tx = new TransactionTemplate(transactionManager); tx.executeWithoutResult(status -> { - entityManager.createQuery("DELETE FROM Scrap s WHERE s.runnectUser.id = :userId") - .setParameter("userId", testUserId) - .executeUpdate(); - entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id = :userId") - .setParameter("userId", testUserId) - .executeUpdate(); - userRepository.deleteById(testUserId); + if (testUserId != null) { + entityManager.createQuery("DELETE FROM Scrap s WHERE s.runnectUser.id = :userId") + .setParameter("userId", testUserId) + .executeUpdate(); + entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id = :userId") + .setParameter("userId", testUserId) + .executeUpdate(); + userRepository.deleteById(testUserId); + } + if (testPublicCourseId != null) { + entityManager.createNativeQuery("DELETE FROM public_course WHERE id = " + testPublicCourseId) + .executeUpdate(); + } + if (testCourseId != null) { + entityManager.createNativeQuery("DELETE FROM course WHERE id = " + testCourseId) + .executeUpdate(); + } }); } @@ -124,7 +159,7 @@ private CreateAndDeleteScrapRequestDto scrapRequest(Long publicCourseId, boolean try { readyLatch.countDown(); startLatch.await(); - scrapService.createAndDeleteScrap(testUserId, scrapRequest(EXISTING_PUBLIC_COURSE_ID, true)); + scrapService.createAndDeleteScrap(testUserId, scrapRequest(testPublicCourseId, true)); } catch (Throwable e) { capturedException.compareAndSet(null, e); } finally { From b77702f8692ac8e4ce04351ffcf67fd6a648f789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 23:51:09 +0900 Subject: [PATCH 5/5] =?UTF-8?q?CI=20Postgres=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20PostGIS=20=ED=8F=AC=ED=95=A8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course.path가 PostGIS geometry 컬럼인데 CI는 순정 postgres:15를 써서 course 테이블 생성 자체가 매번 실패하고 있었다. Hibernate가 ddl-auto 실패를 조용히 로그만 남기고 넘어가는 바람에 지금까지 드러나지 않았을 뿐, CI에 course 테이블이 존재한 적이 없었다 — 실제 Course/PublicCourse row가 필요 없던 테스트만 우연히 계속 통과해온 것이었다. ScrapConcurrencyTest에 실제 course/public_course row를 직접 만들도록 고치는 과정에서 발견했다. prod/staging과 동일한 postgis/postgis 이미지로 바꿔 CI가 실제 스키마로 검증하도록 한다. --- .github/workflows/prod-ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml index 19d0461..6e2720c 100644 --- a/.github/workflows/prod-ci.yml +++ b/.github/workflows/prod-ci.yml @@ -14,9 +14,14 @@ jobs: # 테스트(contextLoads 등)는 실제 prod DB/Redis가 아니라 CI 안에서 띄우는 # 임시 컨테이너를 바라보게 한다 (원격 prod 환경에 직접 의존하지 않도록). + # Course.path가 PostGIS geometry 컬럼이라 순정 postgres 이미지로는 그 컬럼이 있는 + # 테이블(course) 생성 자체가 실패한다(Hibernate가 ddl-auto 실패를 조용히 무시해서 + # 지금까지 드러나지 않았을 뿐, course 테이블이 CI에 존재한 적이 없었다 — 실제 Course row가 + # 필요 없던 테스트만 우연히 계속 통과해온 것). prod/staging과 동일하게 PostGIS가 포함된 + # 이미지를 써서 CI가 실제 환경과 같은 스키마로 검증하도록 한다. services: postgres: - image: postgres:15 + image: postgis/postgis:15-3.4 env: POSTGRES_DB: runnect POSTGRES_USER: runnect