Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, "유효하지 않은 건강 데이터입니다"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
154 changes: 154 additions & 0 deletions src/test/java/org/runnect/server/scrap/ScrapConcurrencyTest.java
Original file line number Diff line number Diff line change
@@ -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<Throwable> 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);
}
}
Loading