From 4cff35f1ffc4ab11b0423c6d731702e2af0c7313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 21:05:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=9C=84=EB=B0=98=EC=9D=B4=20?= =?UTF-8?q?500=EC=9C=BC=EB=A1=9C=20=EC=83=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스크랩 생성이 "기존 스크랩 조회 → 없으면 저장" 순서로 동작해, 동시 요청(더블탭, 다중 기기)이 겹치면 둘 다 "스크랩 없음"을 보고 각자 저장을 시도할 수 있었다. (user_id, public_course_id) 유니크 제약이 이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스에서 잡지 않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어졌다. 실제 로컬 Postgres에 대해 두 스레드로 재현해 DataIntegrityViolationException이 그대로 새는 것을 먼저 확인한 뒤, HealthService에 이미 있던 처리 패턴(try/catch → ConflictException 409)을 동일하게 적용했다. 새로운 동시성 제어 기법을 도입한 게 아니라, 이미 DB가 보장하던 원자성의 결과를 애플리케이션이 우아하게 처리하도록 고친 것이다. --- .../server/common/constant/ErrorStatus.java | 1 + .../server/scrap/service/ScrapService.java | 14 +- .../server/scrap/ScrapConcurrencyTest.java | 154 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 42398bf..4da3538 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -29,6 +29,7 @@ public enum ErrorStatus { INVALID_PARAMETER_EXCEPTION(HttpStatus.BAD_REQUEST, "파라미터에 올바른 값이 입력되지 않았습니다."), NOT_FOUND_APPLE_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "appleAccessToken이 없습니다."), NOT_FOUND_SCRAP_EXCEPTION(HttpStatus.BAD_REQUEST, "스크랩한 코스가 없습니다."), + ALREADY_EXIST_SCRAP_EXCEPTION(HttpStatus.CONFLICT, "이미 처리된 스크랩 요청입니다."), NOT_FOUND_IMAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 이미지 파일입니다"), NOT_FOUND_PUBLICCOURSE_EXCEPTION(HttpStatus.BAD_REQUEST, "존재하지 않는 public course id입니다."), INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"), 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..05b7d89 100644 --- a/src/main/java/org/runnect/server/scrap/service/ScrapService.java +++ b/src/main/java/org/runnect/server/scrap/service/ScrapService.java @@ -4,6 +4,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.exception.ConflictException; import org.runnect.server.common.exception.NotFoundException; import org.runnect.server.publicCourse.entity.PublicCourse; import org.runnect.server.publicCourse.repository.PublicCourseRepository; @@ -16,6 +17,7 @@ import org.runnect.server.user.exception.userException.NotFoundUserException; import org.runnect.server.user.repository.UserRepository; import org.runnect.server.user.service.UserStampService; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,7 +49,17 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA user.updateCreatedScrap(); userStampService.createStampByUser(user, StampType.s); - scrapRepository.save(newScrap); + // 동시에 같은 코스를 스크랩하는 요청이 겹치면 둘 다 "기존 스크랩 없음"을 보고 + // 각자 저장을 시도할 수 있다 — (user_id, public_course_id) 유니크 제약으로 DB가 + // 하나는 거부하는데, 그 예외를 그대로 두면 500으로 샌다(HealthService의 기존 + // 처리 패턴과 동일하게 409로 변환). + try { + scrapRepository.save(newScrap); + } catch (DataIntegrityViolationException e) { + throw new ConflictException( + ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION, + ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION.getMessage()); + } } else { // 기존 스크랩한 내역이 있을 때 scrap.updateScrapTF(true); diff --git a/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java new file mode 100644 index 0000000..74e09de --- /dev/null +++ b/src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java @@ -0,0 +1,154 @@ +package org.runnect.server.scrap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Optional; +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.AtomicReference; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.scrap.dto.request.CreateAndDeleteScrapRequestDto; +import org.runnect.server.scrap.service.ScrapService; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 스크랩 생성 로직(ScrapService.createAndDeleteScrap)이 "기존 스크랩 조회 → 없으면 새로 저장" + * 순서로 동작하는데, 이 사이에 동시 요청(더블탭, 다중 기기)이 끼어들면 둘 다 "스크랩 없음"을 + * 보고 각자 INSERT를 시도할 수 있다. (user_id, public_course_id) 유니크 제약이 있어 DB가 + * 하나는 거부하는데, 수정 전에는 이 예외를 그대로 두어 500으로 샜다(실제 로컬 Postgres에 + * 대해 재현해 org.springframework.dao.DataIntegrityViolationException이 그대로 전파됨을 + * 확인한 뒤 커밋 로그에 남김). 수정 후에는 HealthService의 기존 처리 패턴과 동일하게 + * ConflictException(409)으로 변환된다 — 이 테스트는 그 수정 후 동작을 검증한다. + * + * PublicCourseRepository는 @MockBean으로 대체한다 — 이 프로젝트의 로컬 Postgres/PostGIS + * JDBC 드라이버 조합이 geometry(Course.path) 컬럼을 포함한 JOIN FETCH 결과를 추출할 때 + * 알려진 결함이 있어(다른 통합 테스트에서도 동일 사유로 우회한 이력 있음), 이 테스트가 + * 검증하려는 "스크랩 유니크 제약 경합"과 무관한 그 문제를 피하기 위함이다. Scrap/User + * 리포지토리와 트랜잭션은 모두 실제 로컬 Postgres를 그대로 사용한다. + */ +@SpringBootTest +class ScrapConcurrencyTest { + + private static final Long EXISTING_PUBLIC_COURSE_ID = 1L; + + @Autowired + private ScrapService scrapService; + + @Autowired + private UserRepository userRepository; + + @MockBean + private PublicCourseRepository publicCourseRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @PersistenceContext + private EntityManager entityManager; + + private Long testUserId; + + @BeforeEach + void setUpPublicCourseStub() { + 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)); + } + + @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); + }); + } + + private CreateAndDeleteScrapRequestDto scrapRequest(Long publicCourseId, boolean scrapTF) { + CreateAndDeleteScrapRequestDto dto = BeanUtils.instantiateClass(CreateAndDeleteScrapRequestDto.class); + ReflectionTestUtils.setField(dto, "publicCourseId", publicCourseId); + ReflectionTestUtils.setField(dto, "scrapTF", scrapTF); + return dto; + } + + @Test + void 동시에_같은_코스를_스크랩하면_한쪽은_ConflictException으로_처리된다() throws InterruptedException { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + testUserId = tx.execute(status -> userRepository.save( + RunnectUser.builder() + .nickname("cc-scrap-race") + .socialId("concurrency-test-social-id-scrap") + .email("concurrency-test-scrap@runnect.test") + .provider(SocialType.VISITOR) + .build() + ).getId()); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicReference capturedException = new AtomicReference<>(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + scrapService.createAndDeleteScrap(testUserId, scrapRequest(EXISTING_PUBLIC_COURSE_ID, true)); + } catch (Throwable e) { + capturedException.compareAndSet(null, e); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + boolean completed = doneLatch.await(15, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue(); + + Throwable exception = capturedException.get(); + assertThat(exception) + .withFailMessage("동시 스크랩 요청 중 하나가 실패할 것으로 예상했지만 둘 다 성공함") + .isNotNull(); + assertThat(exception) + .withFailMessage( + "수정 전에는 DataIntegrityViolationException이 그대로 샜음. 수정 후 예상: ConflictException(409). 실제: %s", + exception + ) + .isInstanceOf(org.runnect.server.common.exception.ConflictException.class); + } +}