Skip to content

Commit ff0a1ab

Browse files
unam98alh0409
andauthored
닉네임 변경/소셜 첫 로그인 동시성 레이스 컨디션 수정 (#253)
* 닉네임 동시 변경 시 유니크 제약 위반이 500으로 새던 문제 수정 서로 다른 두 유저가 동시에 같은 새 닉네임으로 변경을 요청하면 중복 조회에서 둘 다 통과해 변경을 시도할 수 있다. 더티 체킹이라 트랜잭션 커밋 시점에야 DataIntegrityViolationException이 터져 그대로 500으로 샜음을 재현 테스트로 확인한 뒤, saveAndFlush로 메서드 안에서 직접 잡아 DuplicateNicknameException (409)으로 변환했다. * 같은 소셜 계정 동시 첫 로그인 시 500 나던 문제 수정 같은 소셜 계정으로 첫 로그인 요청이 동시에 들어오면(더블탭, 타임아웃 재시도 등) 가입 여부 확인에서 둘 다 미가입으로 보고 각자 저장을 시도할 수 있다. (email, provider) 유니크 제약으로 DB가 하나는 거부하는데, 이 예외가 그대로 새서 실제로는 가입에 성공한 요청까지 500으로 실패했음을 재현 테스트로 확인했다. 이 레이스는 스크랩 레이스와 달리 두 요청 모두 "같은 계정으로 로그인 성공"을 원하므로 409 대신 실패한 저장 시도만 무시하고 둘 다 로그인에 성공시켜야 한다. Postgres는 트랜잭션 안에서 한 번 실패한 문장이 있으면 이후 모든 명령을 거부하므로, 저장을 REQUIRES_NEW로 분리된 트랜잭션(SocialSignUpRegistrar)에서 실행해 실패해도 로그인 트랜잭션의 후속 조회가 정상 동작하게 했다. --------- Co-authored-by: 나미 <dnska6657@gmail.com>
1 parent 064aba5 commit ff0a1ab

6 files changed

Lines changed: 317 additions & 11 deletions

File tree

src/main/java/org/runnect/server/auth/service/AuthService.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.runnect.server.user.exception.authException.TimeExpiredRefreshTokenException;
1717
import org.runnect.server.user.exception.userException.NotFoundUserException;
1818
import org.runnect.server.user.repository.UserRepository;
19+
import org.springframework.dao.DataIntegrityViolationException;
1920
import org.springframework.stereotype.Service;
2021
import org.springframework.transaction.annotation.Transactional;
2122

@@ -29,6 +30,7 @@ public class AuthService {
2930
private final KakaoSignInService kakaoSignInService;
3031
private final JwtService jwtService;
3132
private final RedisService redisService;
33+
private final SocialSignUpRegistrar socialSignUpRegistrar;
3234

3335
public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToken) {
3436
//? 토큰 에러 분기 처리(reissueToken)
@@ -88,14 +90,12 @@ public AuthResponseDto signIn(SignInRequestDto signInRequestDto) {
8890
boolean isRegistered = userRepository.existsByEmailAndProvider(socialInfo.getEmail(), socialType);
8991

9092
if (!isRegistered) {
91-
RunnectUser newUser = RunnectUser.builder()
92-
.nickname(generateTemporaryNickname())
93-
.email(socialInfo.getEmail())
94-
.socialId(socialInfo.getSocialId())
95-
.provider(socialType)
96-
.build();
97-
98-
userRepository.save(newUser);
93+
try {
94+
socialSignUpRegistrar.register(
95+
generateTemporaryNickname(), socialInfo.getEmail(), socialInfo.getSocialId(), socialType);
96+
} catch (DataIntegrityViolationException e) {
97+
// 동시 요청이 먼저 저장을 마쳤다 — 아래 조회가 그 값을 그대로 읽는다.
98+
}
9999
}
100100

101101
RunnectUser user = userRepository.findByEmailAndProvider(socialInfo.getEmail(), socialType)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package org.runnect.server.auth.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.runnect.server.user.entity.RunnectUser;
5+
import org.runnect.server.user.entity.SocialType;
6+
import org.runnect.server.user.repository.UserRepository;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.annotation.Propagation;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
/**
12+
* 같은 소셜 계정으로 첫 로그인 요청이 동시에(더블탭, 타임아웃 후 재시도 등) 들어오면 AuthService.signIn()의
13+
* "가입 여부 확인 -> 없으면 저장" 사이에서 둘 다 "미가입"으로 보고 각자 저장을 시도할 수 있다.
14+
* (email, provider) 유니크 제약으로 DB가 하나는 거부하는데, 이 두 요청은 스크랩처럼 서로 다른
15+
* 사용자 의도가 충돌하는 게 아니라 "같은 계정으로 로그인 성공"이라는 동일한 결과를 원하므로,
16+
* 실패한 쪽도 조회로 넘어가 그대로 로그인에 성공시키는 게 맞다(signIn()에서 처리).
17+
*
18+
* 이 저장은 REQUIRES_NEW로 signIn()과 분리된 트랜잭션에서 실행한다. 저장이 실패하면(유니크 제약
19+
* 위반) 예외를 여기서 삼키지 않고 그대로 던져 이 트랜잭션이 정상적으로 롤백되게 한다 — JPA는
20+
* flush 실패 이후의 영속성 컨텍스트를 커밋 가능한 상태로 되돌릴 수 없어("current transaction is
21+
* aborted"), 캐치 후 그대로 커밋을 시도하면 UnexpectedRollbackException으로 다시 실패한다(실제
22+
* REQUIRES_NEW 안에서 캐치하고 커밋을 시도했다가 이 예외로 재현/확인한 뒤 지금 구조로 바꿈).
23+
* 대신 이 트랜잭션은 실패 시 롤백으로 깔끔히 끝내고, 예외 자체는 signIn()의 트랜잭션(이 실패와
24+
* 무관하게 살아있는)으로 전파시켜 거기서 잡아 무시한다.
25+
*/
26+
@Component
27+
@RequiredArgsConstructor
28+
public class SocialSignUpRegistrar {
29+
30+
private final UserRepository userRepository;
31+
32+
@Transactional(propagation = Propagation.REQUIRES_NEW)
33+
public void register(String nickname, String email, String socialId, SocialType provider) {
34+
userRepository.save(RunnectUser.builder()
35+
.nickname(nickname)
36+
.email(email)
37+
.socialId(socialId)
38+
.provider(provider)
39+
.build());
40+
}
41+
}

src/main/java/org/runnect/server/user/service/UserService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.runnect.server.user.exception.userException.DuplicateNicknameException;
2222
import org.runnect.server.user.exception.userException.NotFoundUserException;
2323
import org.runnect.server.user.repository.UserRepository;
24+
import org.springframework.dao.DataIntegrityViolationException;
2425
import org.springframework.stereotype.Service;
2526
import org.springframework.transaction.annotation.Transactional;
2627

@@ -59,6 +60,17 @@ public UpdateUserNicknameResponseDto updateUserNickname(
5960

6061
user.updateUserNickname(newNickname);
6162

63+
// 서로 다른 두 유저가 동시에 같은 새 닉네임으로 변경을 요청하면 위의 existsByNickname
64+
// 조회에서 둘 다 "중복 없음"을 보고 여기까지 도달할 수 있다. 변경 자체는 더티 체킹이라
65+
// 여기서 바로 flush하지 않으면 unique 제약 위반이 트랜잭션 커밋 시점에야 터져 이 메서드
66+
// 밖에서 DataIntegrityViolationException으로 그대로 샌다. saveAndFlush로 여기서 직접
67+
// 터뜨려 잡고, 이미 존재하는 다른 예외(DuplicateNicknameException)로 변환한다.
68+
try {
69+
userRepository.saveAndFlush(user);
70+
} catch (DataIntegrityViolationException e) {
71+
throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage());
72+
}
73+
6274
return UpdateUserNicknameResponseDto.of(user, calculateUserLevelPercent(user));
6375
}
6476

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package org.runnect.server.auth.service;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.when;
6+
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.AtomicInteger;
12+
import java.util.concurrent.atomic.AtomicReference;
13+
import javax.persistence.EntityManager;
14+
import javax.persistence.PersistenceContext;
15+
import org.junit.jupiter.api.AfterEach;
16+
import org.junit.jupiter.api.Test;
17+
import org.runnect.server.auth.dto.request.SignInRequestDto;
18+
import org.runnect.server.auth.dto.response.SocialInfoResponseDto;
19+
import org.runnect.server.config.jwt.JwtService;
20+
import org.runnect.server.user.entity.SocialType;
21+
import org.runnect.server.user.repository.UserRepository;
22+
import org.springframework.beans.factory.annotation.Autowired;
23+
import org.springframework.boot.test.context.SpringBootTest;
24+
import org.springframework.boot.test.mock.mockito.MockBean;
25+
import org.springframework.transaction.PlatformTransactionManager;
26+
import org.springframework.transaction.support.TransactionTemplate;
27+
28+
/**
29+
* AuthService.signIn()은 "(email, provider)로 가입 여부 확인 -> 없으면 신규 저장" 순서로 동작한다.
30+
* 같은 소셜 계정으로 첫 로그인 요청이 동시에(더블탭, 타임아웃 후 재시도 등) 들어오면 둘 다
31+
* "미가입"으로 보고 각자 저장을 시도할 수 있다. (email, provider) 유니크 제약이 있어 DB가 하나는
32+
* 거부하는데(RunnectUser는 IDENTITY 전략이라 save() 호출 시점에 즉시 INSERT가 나가 그 자리에서
33+
* DataIntegrityViolationException이 던져진다), 수정 전에는 이 예외를 그대로 두어 실제로는
34+
* 가입에 성공한 요청까지 500으로 실패했다.
35+
*
36+
* 이 레이스는 스크랩 레이스와 성격이 다르다 — 두 요청 모두 "같은 계정으로 로그인 성공"이라는
37+
* 동일한 결과를 원하므로, 한쪽에 409를 주는 대신 실패한 저장 시도만 조용히 무시하고 둘 다
38+
* 로그인에 성공시키는 것이 맞다. Postgres는 트랜잭션 안에서 한 번 실패한 문장이 있으면 그 뒤
39+
* 모든 명령을 거부하므로, 저장 시도를 signIn()과 분리된 트랜잭션(REQUIRES_NEW)에서 실행해야
40+
* 실패해도 뒤이은 조회가 정상 동작한다 — 이 테스트는 수정 후 그 동작을 검증한다.
41+
*/
42+
@SpringBootTest
43+
class AuthServiceSignInConcurrencyTest {
44+
45+
private static final String TEST_EMAIL = "concurrency-signin-race@runnect.test";
46+
47+
@Autowired
48+
private AuthService authService;
49+
50+
@Autowired
51+
private UserRepository userRepository;
52+
53+
@MockBean
54+
private KakaoSignInService kakaoSignInService;
55+
56+
@MockBean
57+
private JwtService jwtService;
58+
59+
@Autowired
60+
private PlatformTransactionManager transactionManager;
61+
62+
@PersistenceContext
63+
private EntityManager entityManager;
64+
65+
@AfterEach
66+
void tearDown() {
67+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
68+
tx.executeWithoutResult(status ->
69+
entityManager.createQuery("DELETE FROM RunnectUser u WHERE u.email = :email")
70+
.setParameter("email", TEST_EMAIL)
71+
.executeUpdate());
72+
}
73+
74+
@Test
75+
void 동시에_같은_소셜계정으로_첫_로그인하면_둘_다_로그인에_성공한다() throws InterruptedException {
76+
when(kakaoSignInService.getSocialInfo("race-token"))
77+
.thenReturn(SocialInfoResponseDto.of(TEST_EMAIL, "race-social-id"));
78+
when(jwtService.issuedAccessToken(any())).thenReturn("access-token");
79+
when(jwtService.issuedRefreshToken(any())).thenReturn("refresh-token");
80+
81+
int threadCount = 2;
82+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
83+
CountDownLatch readyLatch = new CountDownLatch(threadCount);
84+
CountDownLatch startLatch = new CountDownLatch(1);
85+
CountDownLatch doneLatch = new CountDownLatch(threadCount);
86+
AtomicInteger successCount = new AtomicInteger();
87+
AtomicReference<Throwable> capturedException = new AtomicReference<>();
88+
89+
for (int i = 0; i < threadCount; i++) {
90+
executor.submit(() -> {
91+
try {
92+
readyLatch.countDown();
93+
startLatch.await();
94+
authService.signIn(new SignInRequestDto("race-token", "KAKAO"));
95+
successCount.incrementAndGet();
96+
} catch (Throwable e) {
97+
capturedException.compareAndSet(null, e);
98+
} finally {
99+
doneLatch.countDown();
100+
}
101+
});
102+
}
103+
104+
readyLatch.await();
105+
startLatch.countDown();
106+
boolean completed = doneLatch.await(15, TimeUnit.SECONDS);
107+
executor.shutdown();
108+
109+
assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue();
110+
assertThat(capturedException.get())
111+
.withFailMessage(
112+
"수정 전에는 DataIntegrityViolationException이 그대로 새서 한쪽 로그인이 실패했음. 실제: %s",
113+
capturedException.get()
114+
)
115+
.isNull();
116+
assertThat(successCount.get())
117+
.withFailMessage("두 로그인 요청 모두 성공해야 한다")
118+
.isEqualTo(2);
119+
assertThat(userRepository.findByEmailAndProvider(TEST_EMAIL, SocialType.KAKAO)).isPresent();
120+
}
121+
}

src/test/java/org/runnect/server/auth/service/AuthServiceTest.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ class AuthServiceTest {
4949
private JwtService jwtService;
5050
@Mock
5151
private RedisService redisService;
52+
@Mock
53+
private SocialSignUpRegistrar socialSignUpRegistrar;
5254

5355
private AuthService authService;
5456

5557
@BeforeEach
5658
void setUp() {
5759
authService = new AuthService(userRepository, googleSignInService, appleSignInService,
58-
kakaoSignInService, jwtService, redisService);
60+
kakaoSignInService, jwtService, redisService, socialSignUpRegistrar);
5961
}
6062

6163
private RunnectUser buildUser(Long id, String email, SocialType provider) {
@@ -191,7 +193,8 @@ class SignIn {
191193
AuthResponseDto response = authService.signIn(new SignInRequestDto("kakao-token", "KAKAO"));
192194

193195
assertThat(response).isInstanceOf(SignUpResponseDto.class);
194-
verify(userRepository).save(any(RunnectUser.class));
196+
verify(socialSignUpRegistrar).register(any(), org.mockito.ArgumentMatchers.eq("new@runnect.io"),
197+
org.mockito.ArgumentMatchers.eq("social-1"), org.mockito.ArgumentMatchers.eq(SocialType.KAKAO));
195198
}
196199

197200
@Test
@@ -209,7 +212,7 @@ class SignIn {
209212
AuthResponseDto response = authService.signIn(new SignInRequestDto("google-token", "GOOGLE"));
210213

211214
assertThat(response).isInstanceOf(SignInResponseDto.class);
212-
verify(userRepository, never()).save(any());
215+
verify(socialSignUpRegistrar, never()).register(any(), any(), any(), any());
213216
}
214217

215218
@Test
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package org.runnect.server.user.service;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.concurrent.CountDownLatch;
6+
import java.util.concurrent.ExecutorService;
7+
import java.util.concurrent.Executors;
8+
import java.util.concurrent.TimeUnit;
9+
import java.util.concurrent.atomic.AtomicReference;
10+
import javax.persistence.EntityManager;
11+
import javax.persistence.PersistenceContext;
12+
import org.junit.jupiter.api.AfterEach;
13+
import org.junit.jupiter.api.Test;
14+
import org.runnect.server.user.dto.request.UpdateUserNicknameRequestDto;
15+
import org.runnect.server.user.entity.RunnectUser;
16+
import org.runnect.server.user.entity.SocialType;
17+
import org.runnect.server.user.repository.UserRepository;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.boot.test.context.SpringBootTest;
20+
import org.springframework.transaction.PlatformTransactionManager;
21+
import org.springframework.transaction.support.TransactionTemplate;
22+
23+
/**
24+
* updateUserNickname()은 "닉네임 중복 조회 -> 없으면 변경" 순서로 동작하는데, 서로 다른 두 유저가
25+
* 동시에 같은 새 닉네임으로 변경을 요청하면 둘 다 "중복 없음"을 보고 각자 변경을 시도할 수 있다.
26+
* nickname 컬럼의 unique 제약으로 DB가 하나는 거부하는데, 수정 전에는 엔티티 필드만 바꾸는
27+
* 더티 체킹이라 이 메서드 안에서 flush를 강제하지 않으면 위반 여부가 트랜잭션 커밋 시점에야
28+
* DataIntegrityViolationException으로 그대로 샜다(실제 로컬 Postgres에 대해 재현해 확인한 뒤
29+
* 커밋 로그에 남김). 수정 후에는 saveAndFlush로 메서드 안에서 직접 터뜨려 잡고
30+
* DuplicateNicknameException(409)으로 변환한다 — 이 테스트는 그 수정 후 동작을 검증한다.
31+
*/
32+
@SpringBootTest
33+
class UserNicknameConcurrencyTest {
34+
35+
@Autowired
36+
private UserService userService;
37+
38+
@Autowired
39+
private UserRepository userRepository;
40+
41+
@Autowired
42+
private PlatformTransactionManager transactionManager;
43+
44+
@PersistenceContext
45+
private EntityManager entityManager;
46+
47+
private Long userId1;
48+
private Long userId2;
49+
50+
@AfterEach
51+
void tearDown() {
52+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
53+
tx.executeWithoutResult(status -> {
54+
entityManager.createQuery("DELETE FROM UserStamp s WHERE s.runnectUser.id IN :ids")
55+
.setParameter("ids", java.util.List.of(userId1, userId2))
56+
.executeUpdate();
57+
if (userId1 != null) {
58+
userRepository.deleteById(userId1);
59+
}
60+
if (userId2 != null) {
61+
userRepository.deleteById(userId2);
62+
}
63+
});
64+
}
65+
66+
@Test
67+
void 동시에_서로_다른_유저가_같은_닉네임으로_변경하면_한쪽만_성공한다() throws InterruptedException {
68+
TransactionTemplate tx = new TransactionTemplate(transactionManager);
69+
userId1 = tx.execute(status -> userRepository.save(
70+
RunnectUser.builder()
71+
.nickname("cc-nick-race-1")
72+
.socialId("concurrency-test-social-id-nick-1")
73+
.email("concurrency-test-nick-1@runnect.test")
74+
.provider(SocialType.VISITOR)
75+
.build()
76+
).getId());
77+
userId2 = tx.execute(status -> userRepository.save(
78+
RunnectUser.builder()
79+
.nickname("cc-nick-race-2")
80+
.socialId("concurrency-test-social-id-nick-2")
81+
.email("concurrency-test-nick-2@runnect.test")
82+
.provider(SocialType.VISITOR)
83+
.build()
84+
).getId());
85+
86+
String targetNickname = "레이스닉네임";
87+
int threadCount = 2;
88+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
89+
CountDownLatch readyLatch = new CountDownLatch(threadCount);
90+
CountDownLatch startLatch = new CountDownLatch(1);
91+
CountDownLatch doneLatch = new CountDownLatch(threadCount);
92+
AtomicReference<Throwable> capturedException = new AtomicReference<>();
93+
94+
Long[] userIds = {userId1, userId2};
95+
for (int i = 0; i < threadCount; i++) {
96+
Long targetUserId = userIds[i];
97+
executor.submit(() -> {
98+
try {
99+
readyLatch.countDown();
100+
startLatch.await();
101+
userService.updateUserNickname(targetUserId,
102+
new UpdateUserNicknameRequestDto(targetNickname));
103+
} catch (Throwable e) {
104+
capturedException.compareAndSet(null, e);
105+
} finally {
106+
doneLatch.countDown();
107+
}
108+
});
109+
}
110+
111+
readyLatch.await();
112+
startLatch.countDown();
113+
boolean completed = doneLatch.await(15, TimeUnit.SECONDS);
114+
executor.shutdown();
115+
116+
assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue();
117+
118+
Throwable exception = capturedException.get();
119+
assertThat(exception)
120+
.withFailMessage("동시 닉네임 변경 요청 중 하나가 실패할 것으로 예상했지만 둘 다 성공함")
121+
.isNotNull();
122+
assertThat(exception)
123+
.withFailMessage(
124+
"수정 전에는 DataIntegrityViolationException이 그대로 샜음. 수정 후 예상: DuplicateNicknameException(409). 실제: %s",
125+
exception
126+
)
127+
.isInstanceOf(org.runnect.server.user.exception.userException.DuplicateNicknameException.class);
128+
}
129+
}

0 commit comments

Comments
 (0)