diff --git a/src/main/java/com/afternote/domain/auth/service/AuthService.java b/src/main/java/com/afternote/domain/auth/service/AuthService.java index 1978f04..893ec5f 100644 --- a/src/main/java/com/afternote/domain/auth/service/AuthService.java +++ b/src/main/java/com/afternote/domain/auth/service/AuthService.java @@ -76,7 +76,14 @@ public LoginResponse login(LoginRequest request) { public LoginResponse issueTokens(User user) { activityTouchService.touch(user.getId()); + return createSessionTokens(user); + } + /** + * JWT·refresh만 발급한다. 신규 가입 INSERT가 커밋되기 전에 + * {@link ActivityTouchService#touch}의 REQUIRES_NEW UPDATE가 같은 행을 기다리지 않게 한다. + */ + private LoginResponse createSessionTokens(User user) { String accessToken = jwtTokenProvider.generateAccessToken(user.getId()); String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId()); tokenService.saveToken(refreshToken, user.getId()); @@ -275,7 +282,8 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) { User user = userRepository.findByEmail(socialUserInfo.getEmail()) .orElse(null); boolean isNewUser = false; - + LoginResponse tokens; + if (user == null) { withdrawalCooldownService.assertNotInCooldown(socialUserInfo.getEmail()); user = User.builder() @@ -286,9 +294,11 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) { .build(); user = saveNewUser(user); isNewUser = true; + tokens = createSessionTokens(user); + } else { + tokens = issueTokens(user); } - LoginResponse tokens = issueTokens(user); return SocialLoginResponse.builder() .accessToken(tokens.getAccessToken()) .refreshToken(tokens.getRefreshToken()) diff --git a/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java b/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java new file mode 100644 index 0000000..6874918 --- /dev/null +++ b/src/test/java/com/afternote/domain/auth/integration/SocialLoginSignupLockMySqlTest.java @@ -0,0 +1,280 @@ +package com.afternote.domain.auth.integration; + +import com.afternote.domain.auth.dto.SocialLoginRequest; +import com.afternote.domain.auth.dto.SocialLoginResponse; +import com.afternote.domain.auth.dto.SocialUserInfo; +import com.afternote.domain.auth.service.AuthService; +import com.afternote.domain.auth.service.EmailService; +import com.afternote.domain.auth.service.TokenService; +import com.afternote.domain.auth.service.social.SocialLoginFactory; +import com.afternote.domain.auth.service.social.SocialLoginService; +import com.afternote.domain.user.model.AuthProvider; +import com.afternote.domain.user.model.User; +import com.afternote.domain.user.model.UserStatus; +import com.afternote.domain.user.repository.UserRepository; +import com.afternote.domain.user.service.ActivityTouchService; +import com.afternote.domain.user.service.WithdrawalCooldownService; +import com.afternote.global.jwt.JwtTokenProvider; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.MySQLContainer; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.Properties; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * 신규 소셜 가입 INSERT와 {@code REQUIRES_NEW} 활동 UPDATE가 같은 행에서 + * lock wait 나지 않는지 실제 MySQL 트랜잭션으로 확인한다. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("소셜 로그인 신규 가입 MySQL 잠금") +class SocialLoginSignupLockMySqlTest { + + private MySQLContainer mysql; + private AnnotationConfigApplicationContext context; + private AuthService authService; + private UserRepository userRepository; + private JdbcTemplate jdbcTemplate; + private SocialLoginFactory socialLoginFactory; + private JwtTokenProvider jwtTokenProvider; + + @BeforeAll + void connectToMysql() { + DataSource dataSource; + String externalUrl = System.getenv("AFTERNOTE_MYSQL_TEST_URL"); + if (externalUrl != null && !externalUrl.isBlank()) { + String username = System.getenv().getOrDefault("AFTERNOTE_MYSQL_TEST_USERNAME", "root"); + String password = System.getenv().getOrDefault("AFTERNOTE_MYSQL_TEST_PASSWORD", ""); + dataSource = hikari(externalUrl, username, password); + } else { + assumeTrue(dockerAvailable(), + "Docker 또는 AFTERNOTE_MYSQL_TEST_URL이 있어야 MySQL 회귀 테스트를 실행할 수 있습니다."); + mysql = new MySQLContainer<>("mysql:8.0"); + mysql.start(); + dataSource = hikari(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword()); + } + + context = new AnnotationConfigApplicationContext(); + context.registerBean(DataSource.class, () -> dataSource); + context.register(JpaTestConfiguration.class); + context.refresh(); + + authService = context.getBean(AuthService.class); + userRepository = context.getBean(UserRepository.class); + jdbcTemplate = new JdbcTemplate(dataSource); + socialLoginFactory = context.getBean(SocialLoginFactory.class); + jwtTokenProvider = context.getBean(JwtTokenProvider.class); + } + + @AfterAll + void stopMysql() { + if (context != null) { + context.close(); + } + if (mysql != null) { + mysql.stop(); + } + } + + @BeforeEach + void stubTokens() { + given(jwtTokenProvider.generateAccessToken(anyLong())).willReturn("access"); + given(jwtTokenProvider.generateRefreshToken(anyLong())).willReturn("refresh"); + given(jwtTokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); + } + + @Test + @DisplayName("신규 카카오 가입은 lock wait 없이 커밋되고 lastActiveAt은 INSERT 값이다") + void newKakaoSignup_CommitsWithoutLockWait() { + String email = email(); + stubKakao(email); + long waitsBefore = rowLockWaits(); + LocalDateTime started = LocalDateTime.now().minusSeconds(1); + + SocialLoginResponse response = authService.socialLogin(new SocialLoginRequest("KAKAO", "kakao-token")); + + assertThat(response.isNewUser()).isTrue(); + assertThat(response.getAccessToken()).isEqualTo("access"); + assertThat(response.getRefreshToken()).isEqualTo("refresh"); + assertThat(rowLockWaits()).isEqualTo(waitsBefore); + + User saved = userRepository.findByEmail(email).orElseThrow(); + assertThat(saved.getLastActiveAt()).isNotNull(); + assertThat(saved.getLastActiveAt()).isAfterOrEqualTo(started); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE email=?", Long.class, email + )).isEqualTo(1L); + } + + @Test + @DisplayName("기존 카카오 재로그인은 last_active_at을 갱신한다") + void existingKakaoLogin_TouchesActivity() { + String email = email(); + User existing = userRepository.saveAndFlush(User.builder() + .email(email) + .name("기존") + .status(UserStatus.ACTIVE) + .provider(AuthProvider.KAKAO) + .build()); + LocalDateTime stale = LocalDateTime.now().minusDays(100); + jdbcTemplate.update("UPDATE users SET last_active_at=? WHERE id=?", stale, existing.getId()); + stubKakao(email); + + SocialLoginResponse response = authService.socialLogin(new SocialLoginRequest("KAKAO", "kakao-token")); + + assertThat(response.isNewUser()).isFalse(); + LocalDateTime active = jdbcTemplate.queryForObject( + "SELECT last_active_at FROM users WHERE id=?", LocalDateTime.class, existing.getId() + ); + assertThat(active).isAfter(stale); + } + + private void stubKakao(String email) { + SocialLoginService kakao = mock(SocialLoginService.class); + given(socialLoginFactory.getService("KAKAO")).willReturn(kakao); + given(kakao.getUserInfo(any())).willReturn(SocialUserInfo.builder() + .provider(AuthProvider.KAKAO) + .providerId("kakao-1") + .email(email) + .name("kakao") + .build()); + } + + private long rowLockWaits() { + Long waits = jdbcTemplate.queryForObject( + "SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits'", + (rs, rowNum) -> Long.parseLong(rs.getString("Value")) + ); + return waits == null ? 0L : waits; + } + + private static String email() { + return UUID.randomUUID() + "@example.test"; + } + + private static DataSource hikari(String jdbcUrl, String username, String password) { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(jdbcUrl); + config.setUsername(username); + config.setPassword(password); + config.setConnectionInitSql("SET SESSION innodb_lock_wait_timeout=2"); + return new HikariDataSource(config); + } + + private static boolean dockerAvailable() { + try { + return DockerClientFactory.instance().isDockerAvailable(); + } catch (RuntimeException ignored) { + return false; + } + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement(proxyTargetClass = true) + @EnableJpaAuditing + @EnableJpaRepositories(basePackageClasses = UserRepository.class) + static class JpaTestConfiguration { + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + LocalContainerEntityManagerFactoryBean factory = + new LocalContainerEntityManagerFactoryBean(); + factory.setDataSource(dataSource); + factory.setPackagesToScan("com.afternote.domain"); + HibernateJpaVendorAdapter vendor = new HibernateJpaVendorAdapter(); + vendor.setGenerateDdl(true); + factory.setJpaVendorAdapter(vendor); + Properties properties = new Properties(); + properties.setProperty("hibernate.hbm2ddl.auto", "create-drop"); + factory.setJpaProperties(properties); + return factory; + } + + @Bean + PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + ActivityTouchService activityTouchService(UserRepository userRepository) { + return new ActivityTouchService(userRepository); + } + + @Bean + SocialLoginFactory socialLoginFactory() { + return mock(SocialLoginFactory.class); + } + + @Bean + TokenService tokenService() { + return mock(TokenService.class); + } + + @Bean + JwtTokenProvider jwtTokenProvider() { + return mock(JwtTokenProvider.class); + } + + @Bean + EmailService emailService() { + return mock(EmailService.class); + } + + @Bean + WithdrawalCooldownService withdrawalCooldownService() { + return mock(WithdrawalCooldownService.class); + } + + @Bean + PasswordEncoder passwordEncoder() { + return mock(PasswordEncoder.class); + } + + @Bean + AuthService authService( + UserRepository userRepository, + PasswordEncoder passwordEncoder, + JwtTokenProvider jwtTokenProvider, + TokenService tokenService, + EmailService emailService, + WithdrawalCooldownService withdrawalCooldownService, + SocialLoginFactory socialLoginFactory, + ActivityTouchService activityTouchService + ) { + return new AuthService( + userRepository, + passwordEncoder, + jwtTokenProvider, + tokenService, + emailService, + withdrawalCooldownService, + socialLoginFactory, + activityTouchService + ); + } + } +} diff --git a/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java b/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java index 635ead0..e83c1e6 100644 --- a/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/afternote/domain/auth/service/AuthServiceTest.java @@ -15,13 +15,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.util.ReflectionTestUtils; -import java.time.LocalDateTime; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -372,7 +372,7 @@ void emailVerify_Success() { } @Test - @DisplayName("소셜 로그인 성공 - 신규 유저 생성, 활동 시각 갱신") + @DisplayName("소셜 로그인 성공 - 신규 유저 생성, INSERT 커밋 전 touch 생략") void socialLogin_NewUser_Success() { SocialLoginRequest request = org.mockito.Mockito.mock(SocialLoginRequest.class); given(request.getProvider()).willReturn("KAKAO"); @@ -401,8 +401,13 @@ void socialLogin_NewUser_Success() { assertThat(response.isNewUser()).isTrue(); assertThat(response.getAccessToken()).isEqualTo("social-access"); + assertThat(response.getRefreshToken()).isEqualTo("social-refresh"); assertThat(response.getExpiresIn()).isEqualTo(3600L); - verify(activityTouchService).touch(77L); + + ArgumentCaptor saved = ArgumentCaptor.forClass(User.class); + verify(userRepository).saveAndFlush(saved.capture()); + assertThat(saved.getValue().getLastActiveAt()).isNotNull(); + verify(activityTouchService, never()).touch(any()); verify(tokenService).saveToken("social-refresh", 77L); } @@ -442,7 +447,8 @@ void socialLogin_ExistingUser_Success() { assertThat(response.getAccessToken()).isEqualTo("existing-access"); assertThat(response.getRefreshToken()).isEqualTo("existing-refresh"); assertThat(response.getExpiresIn()).isEqualTo(3600L); - verify(userRepository, org.mockito.Mockito.never()).save(any(User.class)); + verify(userRepository, never()).save(any(User.class)); + verify(userRepository, never()).saveAndFlush(any(User.class)); verify(activityTouchService).touch(88L); verify(tokenService).saveToken("existing-refresh", 88L); }