Skip to content

Commit 064aba5

Browse files
unam98alh0409
andauthored
스크랩 동시 생성 시 유니크 제약 위반이 500으로 새던 문제 수정 (#252)
스크랩 생성이 "기존 스크랩 조회 → 없으면 저장" 순서로 동작해, 동시 요청(더블탭, 다중 기기)이 겹치면 둘 다 "스크랩 없음"을 보고 각자 저장을 시도할 수 있었다. (user_id, public_course_id) 유니크 제약이 이미 DB 레벨에서 경합을 막고 있었지만, 그 예외를 서비스에서 잡지 않아 그대로 500 + 불필요한 Slack/Sentry 알림으로 이어졌다. 실제 로컬 Postgres에 대해 두 스레드로 재현해 DataIntegrityViolationException이 그대로 새는 것을 먼저 확인한 뒤, HealthService에 이미 있던 처리 패턴(try/catch → ConflictException 409)을 동일하게 적용했다. 새로운 동시성 제어 기법을 도입한 게 아니라, 이미 DB가 보장하던 원자성의 결과를 애플리케이션이 우아하게 처리하도록 고친 것이다. Co-authored-by: 나미 <dnska6657@gmail.com>
1 parent 0a8a26e commit 064aba5

3 files changed

Lines changed: 168 additions & 1 deletion

File tree

src/main/java/org/runnect/server/common/constant/ErrorStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public enum ErrorStatus {
2929
INVALID_PARAMETER_EXCEPTION(HttpStatus.BAD_REQUEST, "파라미터에 올바른 값이 입력되지 않았습니다."),
3030
NOT_FOUND_APPLE_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "appleAccessToken이 없습니다."),
3131
NOT_FOUND_SCRAP_EXCEPTION(HttpStatus.BAD_REQUEST, "스크랩한 코스가 없습니다."),
32+
ALREADY_EXIST_SCRAP_EXCEPTION(HttpStatus.CONFLICT, "이미 처리된 스크랩 요청입니다."),
3233
NOT_FOUND_IMAGE_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 이미지 파일입니다"),
3334
NOT_FOUND_PUBLICCOURSE_EXCEPTION(HttpStatus.BAD_REQUEST, "존재하지 않는 public course id입니다."),
3435
INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"),

src/main/java/org/runnect/server/scrap/service/ScrapService.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.util.stream.Collectors;
55
import lombok.RequiredArgsConstructor;
66
import org.runnect.server.common.constant.ErrorStatus;
7+
import org.runnect.server.common.exception.ConflictException;
78
import org.runnect.server.common.exception.NotFoundException;
89
import org.runnect.server.publicCourse.entity.PublicCourse;
910
import org.runnect.server.publicCourse.repository.PublicCourseRepository;
@@ -16,6 +17,7 @@
1617
import org.runnect.server.user.exception.userException.NotFoundUserException;
1718
import org.runnect.server.user.repository.UserRepository;
1819
import org.runnect.server.user.service.UserStampService;
20+
import org.springframework.dao.DataIntegrityViolationException;
1921
import org.springframework.stereotype.Service;
2022
import org.springframework.transaction.annotation.Transactional;
2123

@@ -47,7 +49,17 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA
4749
user.updateCreatedScrap();
4850
userStampService.createStampByUser(user, StampType.s);
4951

50-
scrapRepository.save(newScrap);
52+
// 동시에 같은 코스를 스크랩하는 요청이 겹치면 둘 다 "기존 스크랩 없음"을 보고
53+
// 각자 저장을 시도할 수 있다 — (user_id, public_course_id) 유니크 제약으로 DB가
54+
// 하나는 거부하는데, 그 예외를 그대로 두면 500으로 샌다(HealthService의 기존
55+
// 처리 패턴과 동일하게 409로 변환).
56+
try {
57+
scrapRepository.save(newScrap);
58+
} catch (DataIntegrityViolationException e) {
59+
throw new ConflictException(
60+
ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION,
61+
ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION.getMessage());
62+
}
5163
} else {
5264
// 기존 스크랩한 내역이 있을 때
5365
scrap.updateScrapTF(true);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package org.runnect.server.scrap;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.Mockito.when;
5+
6+
import java.util.Optional;
7+
import java.util.concurrent.CountDownLatch;
8+
import java.util.concurrent.ExecutorService;
9+
import java.util.concurrent.Executors;
10+
import java.util.concurrent.TimeUnit;
11+
import java.util.concurrent.atomic.AtomicReference;
12+
import javax.persistence.EntityManager;
13+
import javax.persistence.PersistenceContext;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
import org.runnect.server.publicCourse.entity.PublicCourse;
18+
import org.runnect.server.publicCourse.repository.PublicCourseRepository;
19+
import org.runnect.server.scrap.dto.request.CreateAndDeleteScrapRequestDto;
20+
import org.runnect.server.scrap.service.ScrapService;
21+
import org.runnect.server.user.entity.RunnectUser;
22+
import org.runnect.server.user.entity.SocialType;
23+
import org.runnect.server.user.repository.UserRepository;
24+
import org.springframework.beans.BeanUtils;
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.boot.test.context.SpringBootTest;
27+
import org.springframework.boot.test.mock.mockito.MockBean;
28+
import org.springframework.test.util.ReflectionTestUtils;
29+
import org.springframework.transaction.PlatformTransactionManager;
30+
import org.springframework.transaction.support.TransactionTemplate;
31+
32+
/**
33+
* 스크랩 생성 로직(ScrapService.createAndDeleteScrap)이 "기존 스크랩 조회 → 없으면 새로 저장"
34+
* 순서로 동작하는데, 이 사이에 동시 요청(더블탭, 다중 기기)이 끼어들면 둘 다 "스크랩 없음"을
35+
* 보고 각자 INSERT를 시도할 수 있다. (user_id, public_course_id) 유니크 제약이 있어 DB가
36+
* 하나는 거부하는데, 수정 전에는 이 예외를 그대로 두어 500으로 샜다(실제 로컬 Postgres에
37+
* 대해 재현해 org.springframework.dao.DataIntegrityViolationException이 그대로 전파됨을
38+
* 확인한 뒤 커밋 로그에 남김). 수정 후에는 HealthService의 기존 처리 패턴과 동일하게
39+
* ConflictException(409)으로 변환된다 — 이 테스트는 그 수정 후 동작을 검증한다.
40+
*
41+
* PublicCourseRepository는 @MockBean으로 대체한다 — 이 프로젝트의 로컬 Postgres/PostGIS
42+
* JDBC 드라이버 조합이 geometry(Course.path) 컬럼을 포함한 JOIN FETCH 결과를 추출할 때
43+
* 알려진 결함이 있어(다른 통합 테스트에서도 동일 사유로 우회한 이력 있음), 이 테스트가
44+
* 검증하려는 "스크랩 유니크 제약 경합"과 무관한 그 문제를 피하기 위함이다. Scrap/User
45+
* 리포지토리와 트랜잭션은 모두 실제 로컬 Postgres를 그대로 사용한다.
46+
*/
47+
@SpringBootTest
48+
class ScrapConcurrencyTest {
49+
50+
private static final Long EXISTING_PUBLIC_COURSE_ID = 1L;
51+
52+
@Autowired
53+
private ScrapService scrapService;
54+
55+
@Autowired
56+
private UserRepository userRepository;
57+
58+
@MockBean
59+
private PublicCourseRepository publicCourseRepository;
60+
61+
@Autowired
62+
private PlatformTransactionManager transactionManager;
63+
64+
@PersistenceContext
65+
private EntityManager entityManager;
66+
67+
private Long testUserId;
68+
69+
@BeforeEach
70+
void setUpPublicCourseStub() {
71+
PublicCourse publicCourse = PublicCourse.builder()
72+
.title("스텁 공개 코스")
73+
.description("동시성 테스트용 스텁")
74+
.build();
75+
ReflectionTestUtils.setField(publicCourse, "id", EXISTING_PUBLIC_COURSE_ID);
76+
when(publicCourseRepository.findById(EXISTING_PUBLIC_COURSE_ID)).thenReturn(Optional.of(publicCourse));
77+
}
78+
79+
@AfterEach
80+
void tearDown() {
81+
if (testUserId == null) {
82+
return;
83+
}
84+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
85+
tx.executeWithoutResult(status -> {
86+
entityManager.createQuery("DELETE FROM Scrap s WHERE s.runnectUser.id = :userId")
87+
.setParameter("userId", testUserId)
88+
.executeUpdate();
89+
entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id = :userId")
90+
.setParameter("userId", testUserId)
91+
.executeUpdate();
92+
userRepository.deleteById(testUserId);
93+
});
94+
}
95+
96+
private CreateAndDeleteScrapRequestDto scrapRequest(Long publicCourseId, boolean scrapTF) {
97+
CreateAndDeleteScrapRequestDto dto = BeanUtils.instantiateClass(CreateAndDeleteScrapRequestDto.class);
98+
ReflectionTestUtils.setField(dto, "publicCourseId", publicCourseId);
99+
ReflectionTestUtils.setField(dto, "scrapTF", scrapTF);
100+
return dto;
101+
}
102+
103+
@Test
104+
void 동시에_같은_코스를_스크랩하면_한쪽은_ConflictException으로_처리된다() throws InterruptedException {
105+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
106+
testUserId = tx.execute(status -> userRepository.save(
107+
RunnectUser.builder()
108+
.nickname("cc-scrap-race")
109+
.socialId("concurrency-test-social-id-scrap")
110+
.email("concurrency-test-scrap@runnect.test")
111+
.provider(SocialType.VISITOR)
112+
.build()
113+
).getId());
114+
115+
int threadCount = 2;
116+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
117+
CountDownLatch readyLatch = new CountDownLatch(threadCount);
118+
CountDownLatch startLatch = new CountDownLatch(1);
119+
CountDownLatch doneLatch = new CountDownLatch(threadCount);
120+
AtomicReference<Throwable> capturedException = new AtomicReference<>();
121+
122+
for (int i = 0; i < threadCount; i++) {
123+
executor.submit(() -> {
124+
try {
125+
readyLatch.countDown();
126+
startLatch.await();
127+
scrapService.createAndDeleteScrap(testUserId, scrapRequest(EXISTING_PUBLIC_COURSE_ID, true));
128+
} catch (Throwable e) {
129+
capturedException.compareAndSet(null, e);
130+
} finally {
131+
doneLatch.countDown();
132+
}
133+
});
134+
}
135+
136+
readyLatch.await();
137+
startLatch.countDown();
138+
boolean completed = doneLatch.await(15, TimeUnit.SECONDS);
139+
executor.shutdown();
140+
141+
assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue();
142+
143+
Throwable exception = capturedException.get();
144+
assertThat(exception)
145+
.withFailMessage("동시 스크랩 요청 중 하나가 실패할 것으로 예상했지만 둘 다 성공함")
146+
.isNotNull();
147+
assertThat(exception)
148+
.withFailMessage(
149+
"수정 전에는 DataIntegrityViolationException이 그대로 샜음. 수정 후 예상: ConflictException(409). 실제: %s",
150+
exception
151+
)
152+
.isInstanceOf(org.runnect.server.common.exception.ConflictException.class);
153+
}
154+
}

0 commit comments

Comments
 (0)