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
16 changes: 8 additions & 8 deletions src/main/java/org/runnect/server/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
12 changes: 12 additions & 0 deletions src/main/java/org/runnect/server/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Throwable> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Throwable> 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);
}
}
Loading