From 4f9e0c379d62efcda83d701fab0ea5d114f6fb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 21:27:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EB=B3=80=EA=B2=BD=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 서로 다른 두 유저가 동시에 같은 새 닉네임으로 변경을 요청하면 중복 조회에서 둘 다 통과해 변경을 시도할 수 있다. 더티 체킹이라 트랜잭션 커밋 시점에야 DataIntegrityViolationException이 터져 그대로 500으로 샜음을 재현 테스트로 확인한 뒤, saveAndFlush로 메서드 안에서 직접 잡아 DuplicateNicknameException (409)으로 변환했다. --- .../server/user/service/UserService.java | 12 ++ .../service/UserNicknameConcurrencyTest.java | 129 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/test/java/org/runnect/server/user/service/UserNicknameConcurrencyTest.java diff --git a/src/main/java/org/runnect/server/user/service/UserService.java b/src/main/java/org/runnect/server/user/service/UserService.java index 79df0eb..d93b42e 100644 --- a/src/main/java/org/runnect/server/user/service/UserService.java +++ b/src/main/java/org/runnect/server/user/service/UserService.java @@ -21,6 +21,7 @@ import org.runnect.server.user.exception.userException.DuplicateNicknameException; import org.runnect.server.user.exception.userException.NotFoundUserException; import org.runnect.server.user.repository.UserRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,6 +60,17 @@ public UpdateUserNicknameResponseDto updateUserNickname( user.updateUserNickname(newNickname); + // 서로 다른 두 유저가 동시에 같은 새 닉네임으로 변경을 요청하면 위의 existsByNickname + // 조회에서 둘 다 "중복 없음"을 보고 여기까지 도달할 수 있다. 변경 자체는 더티 체킹이라 + // 여기서 바로 flush하지 않으면 unique 제약 위반이 트랜잭션 커밋 시점에야 터져 이 메서드 + // 밖에서 DataIntegrityViolationException으로 그대로 샌다. saveAndFlush로 여기서 직접 + // 터뜨려 잡고, 이미 존재하는 다른 예외(DuplicateNicknameException)로 변환한다. + try { + userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage()); + } + return UpdateUserNicknameResponseDto.of(user, calculateUserLevelPercent(user)); } diff --git a/src/test/java/org/runnect/server/user/service/UserNicknameConcurrencyTest.java b/src/test/java/org/runnect/server/user/service/UserNicknameConcurrencyTest.java new file mode 100644 index 0000000..31655a0 --- /dev/null +++ b/src/test/java/org/runnect/server/user/service/UserNicknameConcurrencyTest.java @@ -0,0 +1,129 @@ +package org.runnect.server.user.service; + +import static org.assertj.core.api.Assertions.assertThat; + +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.Test; +import org.runnect.server.user.dto.request.UpdateUserNicknameRequestDto; +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.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * updateUserNickname()은 "닉네임 중복 조회 -> 없으면 변경" 순서로 동작하는데, 서로 다른 두 유저가 + * 동시에 같은 새 닉네임으로 변경을 요청하면 둘 다 "중복 없음"을 보고 각자 변경을 시도할 수 있다. + * nickname 컬럼의 unique 제약으로 DB가 하나는 거부하는데, 수정 전에는 엔티티 필드만 바꾸는 + * 더티 체킹이라 이 메서드 안에서 flush를 강제하지 않으면 위반 여부가 트랜잭션 커밋 시점에야 + * DataIntegrityViolationException으로 그대로 샜다(실제 로컬 Postgres에 대해 재현해 확인한 뒤 + * 커밋 로그에 남김). 수정 후에는 saveAndFlush로 메서드 안에서 직접 터뜨려 잡고 + * DuplicateNicknameException(409)으로 변환한다 — 이 테스트는 그 수정 후 동작을 검증한다. + */ +@SpringBootTest +class UserNicknameConcurrencyTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @PersistenceContext + private EntityManager entityManager; + + private Long userId1; + private Long userId2; + + @AfterEach + void tearDown() { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> { + entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id IN :ids") + .setParameter("ids", java.util.List.of(userId1, userId2)) + .executeUpdate(); + if (userId1 != null) { + userRepository.deleteById(userId1); + } + if (userId2 != null) { + userRepository.deleteById(userId2); + } + }); + } + + @Test + void 동시에_서로_다른_유저가_같은_닉네임으로_변경하면_한쪽만_성공한다() throws InterruptedException { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + userId1 = tx.execute(status -> userRepository.save( + RunnectUser.builder() + .nickname("cc-nick-race-1") + .socialId("concurrency-test-social-id-nick-1") + .email("concurrency-test-nick-1@runnect.test") + .provider(SocialType.VISITOR) + .build() + ).getId()); + userId2 = tx.execute(status -> userRepository.save( + RunnectUser.builder() + .nickname("cc-nick-race-2") + .socialId("concurrency-test-social-id-nick-2") + .email("concurrency-test-nick-2@runnect.test") + .provider(SocialType.VISITOR) + .build() + ).getId()); + + String targetNickname = "레이스닉네임"; + 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<>(); + + Long[] userIds = {userId1, userId2}; + for (int i = 0; i < threadCount; i++) { + Long targetUserId = userIds[i]; + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + userService.updateUserNickname(targetUserId, + new UpdateUserNicknameRequestDto(targetNickname)); + } 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이 그대로 샜음. 수정 후 예상: DuplicateNicknameException(409). 실제: %s", + exception + ) + .isInstanceOf(org.runnect.server.user.exception.userException.DuplicateNicknameException.class); + } +} From 03d8a6c76cd1bd7fc9cca8d33e4bacbbd3f70f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Sun, 23 Aug 2026 21:27:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=8F=99=EC=8B=9C=20=EC=B2=AB=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20500=20=EB=82=98=EB=8D=98=20?= =?UTF-8?q?=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 같은 소셜 계정으로 첫 로그인 요청이 동시에 들어오면(더블탭, 타임아웃 재시도 등) 가입 여부 확인에서 둘 다 미가입으로 보고 각자 저장을 시도할 수 있다. (email, provider) 유니크 제약으로 DB가 하나는 거부하는데, 이 예외가 그대로 새서 실제로는 가입에 성공한 요청까지 500으로 실패했음을 재현 테스트로 확인했다. 이 레이스는 스크랩 레이스와 달리 두 요청 모두 "같은 계정으로 로그인 성공"을 원하므로 409 대신 실패한 저장 시도만 무시하고 둘 다 로그인에 성공시켜야 한다. Postgres는 트랜잭션 안에서 한 번 실패한 문장이 있으면 이후 모든 명령을 거부하므로, 저장을 REQUIRES_NEW로 분리된 트랜잭션(SocialSignUpRegistrar)에서 실행해 실패해도 로그인 트랜잭션의 후속 조회가 정상 동작하게 했다. --- .../server/auth/service/AuthService.java | 16 +-- .../auth/service/SocialSignUpRegistrar.java | 41 ++++++ .../AuthServiceSignInConcurrencyTest.java | 121 ++++++++++++++++++ .../server/auth/service/AuthServiceTest.java | 9 +- 4 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/runnect/server/auth/service/SocialSignUpRegistrar.java create mode 100644 src/test/java/org/runnect/server/auth/service/AuthServiceSignInConcurrencyTest.java diff --git a/src/main/java/org/runnect/server/auth/service/AuthService.java b/src/main/java/org/runnect/server/auth/service/AuthService.java index 61ee426..474c751 100644 --- a/src/main/java/org/runnect/server/auth/service/AuthService.java +++ b/src/main/java/org/runnect/server/auth/service/AuthService.java @@ -16,6 +16,7 @@ import org.runnect.server.user.exception.authException.TimeExpiredRefreshTokenException; import org.runnect.server.user.exception.userException.NotFoundUserException; import org.runnect.server.user.repository.UserRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +30,7 @@ public class AuthService { private final KakaoSignInService kakaoSignInService; private final JwtService jwtService; private final RedisService redisService; + private final SocialSignUpRegistrar socialSignUpRegistrar; public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToken) { //? 토큰 에러 분기 처리(reissueToken) @@ -88,14 +90,12 @@ public AuthResponseDto signIn(SignInRequestDto signInRequestDto) { boolean isRegistered = userRepository.existsByEmailAndProvider(socialInfo.getEmail(), socialType); if (!isRegistered) { - RunnectUser newUser = RunnectUser.builder() - .nickname(generateTemporaryNickname()) - .email(socialInfo.getEmail()) - .socialId(socialInfo.getSocialId()) - .provider(socialType) - .build(); - - userRepository.save(newUser); + try { + socialSignUpRegistrar.register( + generateTemporaryNickname(), socialInfo.getEmail(), socialInfo.getSocialId(), socialType); + } catch (DataIntegrityViolationException e) { + // 동시 요청이 먼저 저장을 마쳤다 — 아래 조회가 그 값을 그대로 읽는다. + } } RunnectUser user = userRepository.findByEmailAndProvider(socialInfo.getEmail(), socialType) diff --git a/src/main/java/org/runnect/server/auth/service/SocialSignUpRegistrar.java b/src/main/java/org/runnect/server/auth/service/SocialSignUpRegistrar.java new file mode 100644 index 0000000..b6aee7a --- /dev/null +++ b/src/main/java/org/runnect/server/auth/service/SocialSignUpRegistrar.java @@ -0,0 +1,41 @@ +package org.runnect.server.auth.service; + +import lombok.RequiredArgsConstructor; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 같은 소셜 계정으로 첫 로그인 요청이 동시에(더블탭, 타임아웃 후 재시도 등) 들어오면 AuthService.signIn()의 + * "가입 여부 확인 -> 없으면 저장" 사이에서 둘 다 "미가입"으로 보고 각자 저장을 시도할 수 있다. + * (email, provider) 유니크 제약으로 DB가 하나는 거부하는데, 이 두 요청은 스크랩처럼 서로 다른 + * 사용자 의도가 충돌하는 게 아니라 "같은 계정으로 로그인 성공"이라는 동일한 결과를 원하므로, + * 실패한 쪽도 조회로 넘어가 그대로 로그인에 성공시키는 게 맞다(signIn()에서 처리). + * + * 이 저장은 REQUIRES_NEW로 signIn()과 분리된 트랜잭션에서 실행한다. 저장이 실패하면(유니크 제약 + * 위반) 예외를 여기서 삼키지 않고 그대로 던져 이 트랜잭션이 정상적으로 롤백되게 한다 — JPA는 + * flush 실패 이후의 영속성 컨텍스트를 커밋 가능한 상태로 되돌릴 수 없어("current transaction is + * aborted"), 캐치 후 그대로 커밋을 시도하면 UnexpectedRollbackException으로 다시 실패한다(실제 + * REQUIRES_NEW 안에서 캐치하고 커밋을 시도했다가 이 예외로 재현/확인한 뒤 지금 구조로 바꿈). + * 대신 이 트랜잭션은 실패 시 롤백으로 깔끔히 끝내고, 예외 자체는 signIn()의 트랜잭션(이 실패와 + * 무관하게 살아있는)으로 전파시켜 거기서 잡아 무시한다. + */ +@Component +@RequiredArgsConstructor +public class SocialSignUpRegistrar { + + private final UserRepository userRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void register(String nickname, String email, String socialId, SocialType provider) { + userRepository.save(RunnectUser.builder() + .nickname(nickname) + .email(email) + .socialId(socialId) + .provider(provider) + .build()); + } +} diff --git a/src/test/java/org/runnect/server/auth/service/AuthServiceSignInConcurrencyTest.java b/src/test/java/org/runnect/server/auth/service/AuthServiceSignInConcurrencyTest.java new file mode 100644 index 0000000..bf5c89d --- /dev/null +++ b/src/test/java/org/runnect/server/auth/service/AuthServiceSignInConcurrencyTest.java @@ -0,0 +1,121 @@ +package org.runnect.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +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 java.util.concurrent.atomic.AtomicReference; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.auth.dto.request.SignInRequestDto; +import org.runnect.server.auth.dto.response.SocialInfoResponseDto; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.repository.UserRepository; +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.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * AuthService.signIn()은 "(email, provider)로 가입 여부 확인 -> 없으면 신규 저장" 순서로 동작한다. + * 같은 소셜 계정으로 첫 로그인 요청이 동시에(더블탭, 타임아웃 후 재시도 등) 들어오면 둘 다 + * "미가입"으로 보고 각자 저장을 시도할 수 있다. (email, provider) 유니크 제약이 있어 DB가 하나는 + * 거부하는데(RunnectUser는 IDENTITY 전략이라 save() 호출 시점에 즉시 INSERT가 나가 그 자리에서 + * DataIntegrityViolationException이 던져진다), 수정 전에는 이 예외를 그대로 두어 실제로는 + * 가입에 성공한 요청까지 500으로 실패했다. + * + * 이 레이스는 스크랩 레이스와 성격이 다르다 — 두 요청 모두 "같은 계정으로 로그인 성공"이라는 + * 동일한 결과를 원하므로, 한쪽에 409를 주는 대신 실패한 저장 시도만 조용히 무시하고 둘 다 + * 로그인에 성공시키는 것이 맞다. Postgres는 트랜잭션 안에서 한 번 실패한 문장이 있으면 그 뒤 + * 모든 명령을 거부하므로, 저장 시도를 signIn()과 분리된 트랜잭션(REQUIRES_NEW)에서 실행해야 + * 실패해도 뒤이은 조회가 정상 동작한다 — 이 테스트는 수정 후 그 동작을 검증한다. + */ +@SpringBootTest +class AuthServiceSignInConcurrencyTest { + + private static final String TEST_EMAIL = "concurrency-signin-race@runnect.test"; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @MockBean + private KakaoSignInService kakaoSignInService; + + @MockBean + private JwtService jwtService; + + @Autowired + private PlatformTransactionManager transactionManager; + + @PersistenceContext + private EntityManager entityManager; + + @AfterEach + void tearDown() { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> + entityManager.createQuery("DELETE FROM RunnectUser u WHERE u.email = :email") + .setParameter("email", TEST_EMAIL) + .executeUpdate()); + } + + @Test + void 동시에_같은_소셜계정으로_첫_로그인하면_둘_다_로그인에_성공한다() throws InterruptedException { + when(kakaoSignInService.getSocialInfo("race-token")) + .thenReturn(SocialInfoResponseDto.of(TEST_EMAIL, "race-social-id")); + when(jwtService.issuedAccessToken(any())).thenReturn("access-token"); + when(jwtService.issuedRefreshToken(any())).thenReturn("refresh-token"); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicReference capturedException = new AtomicReference<>(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + authService.signIn(new SignInRequestDto("race-token", "KAKAO")); + successCount.incrementAndGet(); + } 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(); + assertThat(capturedException.get()) + .withFailMessage( + "수정 전에는 DataIntegrityViolationException이 그대로 새서 한쪽 로그인이 실패했음. 실제: %s", + capturedException.get() + ) + .isNull(); + assertThat(successCount.get()) + .withFailMessage("두 로그인 요청 모두 성공해야 한다") + .isEqualTo(2); + assertThat(userRepository.findByEmailAndProvider(TEST_EMAIL, SocialType.KAKAO)).isPresent(); + } +} diff --git a/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java b/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java index 9a34148..0604b72 100644 --- a/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java +++ b/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java @@ -49,13 +49,15 @@ class AuthServiceTest { private JwtService jwtService; @Mock private RedisService redisService; + @Mock + private SocialSignUpRegistrar socialSignUpRegistrar; private AuthService authService; @BeforeEach void setUp() { authService = new AuthService(userRepository, googleSignInService, appleSignInService, - kakaoSignInService, jwtService, redisService); + kakaoSignInService, jwtService, redisService, socialSignUpRegistrar); } private RunnectUser buildUser(Long id, String email, SocialType provider) { @@ -191,7 +193,8 @@ class SignIn { AuthResponseDto response = authService.signIn(new SignInRequestDto("kakao-token", "KAKAO")); assertThat(response).isInstanceOf(SignUpResponseDto.class); - verify(userRepository).save(any(RunnectUser.class)); + verify(socialSignUpRegistrar).register(any(), org.mockito.ArgumentMatchers.eq("new@runnect.io"), + org.mockito.ArgumentMatchers.eq("social-1"), org.mockito.ArgumentMatchers.eq(SocialType.KAKAO)); } @Test @@ -209,7 +212,7 @@ class SignIn { AuthResponseDto response = authService.signIn(new SignInRequestDto("google-token", "GOOGLE")); assertThat(response).isInstanceOf(SignInResponseDto.class); - verify(userRepository, never()).save(any()); + verify(socialSignUpRegistrar, never()).register(any(), any(), any(), any()); } @Test