diff --git a/src/main/java/com/techfork/domain/auth/service/AuthService.java b/src/main/java/com/techfork/domain/auth/service/AuthService.java index 7833dd62..a4cb36e6 100644 --- a/src/main/java/com/techfork/domain/auth/service/AuthService.java +++ b/src/main/java/com/techfork/domain/auth/service/AuthService.java @@ -12,6 +12,7 @@ import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; import com.techfork.global.security.auth.service.RefreshTokenService; +import com.techfork.global.security.auth.service.UserAuthCacheService; import com.techfork.global.security.jwt.JwtDTO; import com.techfork.global.security.jwt.JwtProperties; import com.techfork.global.security.jwt.JwtUtil; @@ -37,6 +38,7 @@ public class AuthService { private final JwtProperties jwtProperties; private final AuthConverter authConverter; private final KakaoOAuthService kakaoOAuthService; + private final UserAuthCacheService userAuthCacheService; @Value("${server.domain}") private String domain; @@ -54,7 +56,9 @@ public TokenRefreshResponse refreshToken(String refreshToken, HttpServletRespons long expiration = jwtProperties.getRefreshTokenExpiration(); saveAndSetRefreshToken(response, userId, newTokens.refreshToken(), expiration); - log.info("Token refreshed for userId: {}", userId); + userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration()); + + log.info("Token refreshed"); return TokenRefreshResponse.builder() .accessToken(newTokens.accessToken()) diff --git a/src/main/java/com/techfork/domain/user/service/UserCommandService.java b/src/main/java/com/techfork/domain/user/service/UserCommandService.java index 8e7507d2..21efdabb 100644 --- a/src/main/java/com/techfork/domain/user/service/UserCommandService.java +++ b/src/main/java/com/techfork/domain/user/service/UserCommandService.java @@ -7,6 +7,7 @@ import com.techfork.domain.user.exception.UserErrorCode; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; +import com.techfork.global.security.auth.service.UserAuthCacheService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +22,7 @@ public class UserCommandService { private final InterestCommandService interestCommandService; private final UserRepository userRepository; + private final UserAuthCacheService userAuthCacheService; public void completeOnboarding(Long userId, @Valid OnboardingRequest request) { User user = userRepository.findByIdWithInterestCategories(userId) @@ -29,6 +31,8 @@ public void completeOnboarding(Long userId, @Valid OnboardingRequest request) { user.updateUser(request.nickname(), request.email(), request.description()); interestCommandService.saveUserInterests(user, new SaveInterestRequest(request.interests())); + + userAuthCacheService.evict(userId); } public void updateUserProfile(Long userId, UpdateUserProfileRequest request) { @@ -52,7 +56,8 @@ public void withdrawUser(Long userId) { } user.withdraw(); + userAuthCacheService.evict(userId); - log.info("User withdrawn - userId: {}, status changed to WITHDRAWN and personal data anonymized", userId); + log.info("User withdrawn - status changed to WITHDRAWN and personal data anonymized"); } } diff --git a/src/main/java/com/techfork/global/constant/RedisKey.java b/src/main/java/com/techfork/global/constant/RedisKey.java index 3bf2442b..238c5e44 100644 --- a/src/main/java/com/techfork/global/constant/RedisKey.java +++ b/src/main/java/com/techfork/global/constant/RedisKey.java @@ -4,5 +4,6 @@ public final class RedisKey { private RedisKey() {} public static final String REFRESH_TOKEN_PREFIX = "refreshToken:"; + public static final String USER_AUTH_PREFIX = "user:auth:"; public static final String CRAWLING_LOCK_KEY = "rss-crawling"; } diff --git a/src/main/java/com/techfork/global/security/auth/service/UserAuthCacheService.java b/src/main/java/com/techfork/global/security/auth/service/UserAuthCacheService.java new file mode 100644 index 00000000..ae418785 --- /dev/null +++ b/src/main/java/com/techfork/global/security/auth/service/UserAuthCacheService.java @@ -0,0 +1,71 @@ +package com.techfork.global.security.auth.service; + +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.Role; +import com.techfork.domain.user.enums.UserStatus; +import com.techfork.global.constant.RedisKey; +import com.techfork.global.security.oauth.UserPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserAuthCacheService { + + private static final String DELIMITER = "|"; + private static final int FIELD_COUNT = 4; + + private final StringRedisTemplate redisTemplate; + + public UserPrincipal get(Long userId) { + String key = buildKey(userId); + String cached = redisTemplate.opsForValue().get(key); + if (cached == null) { + return null; + } + return deserialize(cached); + } + + public void put(Long userId, User user, long ttlMillis) { + String key = buildKey(userId); + String value = serialize(user); + redisTemplate.opsForValue().set(key, value, ttlMillis, TimeUnit.MILLISECONDS); + log.debug("User auth cached for userId: {}", userId); + } + + public void evict(Long userId) { + String key = buildKey(userId); + redisTemplate.delete(key); + log.debug("User auth cache evicted for userId: {}", userId); + } + + private String buildKey(Long userId) { + return RedisKey.USER_AUTH_PREFIX + userId; + } + + private String serialize(User user) { + return user.getId() + + DELIMITER + user.getRole().name() + + DELIMITER + user.getStatus().name() + + DELIMITER + (user.getEmail() != null ? user.getEmail() : ""); + } + + private UserPrincipal deserialize(String value) { + String[] parts = value.split("\\" + DELIMITER, FIELD_COUNT); + if (parts.length != FIELD_COUNT) { + log.warn("Invalid user auth cache format: {}", value); + return null; + } + return UserPrincipal.builder() + .id(Long.parseLong(parts[0])) + .role(Role.valueOf(parts[1])) + .status(UserStatus.valueOf(parts[2])) + .email(parts[3].isEmpty() ? null : parts[3]) + .build(); + } +} diff --git a/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java index b5780c9b..bbf09ddd 100644 --- a/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java @@ -2,10 +2,13 @@ import com.techfork.domain.auth.exception.AuthErrorCode; import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.UserStatus; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.constant.Constants; import com.techfork.global.constant.MdcKey; import com.techfork.global.exception.GeneralException; +import com.techfork.global.security.auth.service.UserAuthCacheService; +import com.techfork.global.security.jwt.JwtProperties; import com.techfork.global.security.jwt.JwtUtil; import com.techfork.global.security.oauth.UserPrincipal; import com.techfork.global.util.HeaderUtil; @@ -40,6 +43,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserRepository userRepository; + private final UserAuthCacheService userAuthCacheService; + private final JwtProperties jwtProperties; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -54,14 +59,23 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse jwtUtil.validateTokenType(jwt, TOKEN_TYPE_ACCESS); Long userId = jwtUtil.getUserIdFromToken(jwt); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND)); + UserPrincipal userPrincipal = userAuthCacheService.get(userId); - if (user.isWithdrawn()) { + if (userPrincipal == null) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND)); + + userPrincipal = UserPrincipal.buildUserPrincipal(user); + + if (userPrincipal.getStatus() == UserStatus.WITHDRAWN) { + throw new GeneralException(AuthErrorCode.WITHDRAWN_USER); + } + + userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration()); + } else if (userPrincipal.getStatus() == UserStatus.WITHDRAWN) { throw new GeneralException(AuthErrorCode.WITHDRAWN_USER); } - UserPrincipal userPrincipal = UserPrincipal.buildUserPrincipal(user); UsernamePasswordAuthenticationToken authentication = createAuthentication(userPrincipal, request); SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); diff --git a/src/test/java/com/techfork/domain/auth/service/AuthServiceTest.java b/src/test/java/com/techfork/domain/auth/service/AuthServiceTest.java index 038e5d96..2d92cb67 100644 --- a/src/test/java/com/techfork/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/techfork/domain/auth/service/AuthServiceTest.java @@ -13,6 +13,7 @@ import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; import com.techfork.global.security.auth.service.RefreshTokenService; +import com.techfork.global.security.auth.service.UserAuthCacheService; import com.techfork.global.security.jwt.JwtDTO; import com.techfork.global.security.jwt.JwtProperties; import com.techfork.global.security.jwt.JwtUtil; @@ -60,6 +61,9 @@ class AuthServiceTest { @Mock private KakaoOAuthService kakaoOAuthService; + @Mock + private UserAuthCacheService userAuthCacheService; + @InjectMocks private AuthService authService; @@ -96,6 +100,7 @@ void refreshToken_Success() { given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(jwtUtil.generateTokens(userId, Role.USER)).willReturn(newTokens); given(jwtProperties.getRefreshTokenExpiration()).willReturn(900000L); + given(jwtProperties.getAccessTokenExpiration()).willReturn(180000L); // When TokenRefreshResponse result = authService.refreshToken(validRefreshToken, response); @@ -105,6 +110,7 @@ void refreshToken_Success() { verify(jwtUtil).isValidToken(validRefreshToken); verify(jwtUtil).validateTokenType(validRefreshToken, TOKEN_TYPE_REFRESH); verify(refreshTokenService).saveRefreshToken(eq(userId), eq(newRefreshToken), anyLong()); + verify(userAuthCacheService).put(eq(userId), eq(user), eq(180000L)); verify(response).addHeader(eq("Set-Cookie"), anyString()); } diff --git a/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java index 03853151..8802ec86 100644 --- a/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java @@ -9,6 +9,7 @@ import com.techfork.domain.user.exception.UserErrorCode; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; +import com.techfork.global.security.auth.service.UserAuthCacheService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -40,6 +41,9 @@ class UserCommandServiceTest { @Mock private UserRepository userRepository; + @Mock + private UserAuthCacheService userAuthCacheService; + @InjectMocks private UserCommandService userCommandService; @@ -81,6 +85,7 @@ void completeOnboarding_Success() { verify(userRepository, times(1)).findByIdWithInterestCategories(userId); verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + verify(userAuthCacheService).evict(userId); } @Test @@ -142,6 +147,7 @@ void completeOnboarding_NullDescription_Success() { assertThat(mockUser.getDescription()).isNull(); verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + verify(userAuthCacheService).evict(userId); } @Test @@ -182,6 +188,7 @@ void completeOnboarding_MultipleCategories_Success() { // Then assertThat(mockUser.getNickName()).isEqualTo("풀스택개발자"); verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + verify(userAuthCacheService).evict(userId); } // ===== 프로필 수정 테스트 ===== @@ -308,6 +315,7 @@ void withdrawUser_Success() { assertThat(testUser.getSocialId()).isEqualTo(originalSocialId); verify(userRepository).findById(userId); + verify(userAuthCacheService).evict(userId); } @Test diff --git a/src/test/java/com/techfork/global/security/auth/service/UserAuthCacheServiceTest.java b/src/test/java/com/techfork/global/security/auth/service/UserAuthCacheServiceTest.java new file mode 100644 index 00000000..391b56b4 --- /dev/null +++ b/src/test/java/com/techfork/global/security/auth/service/UserAuthCacheServiceTest.java @@ -0,0 +1,149 @@ +package com.techfork.global.security.auth.service; + +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.Role; +import com.techfork.domain.user.enums.SocialType; +import com.techfork.domain.user.enums.UserStatus; +import com.techfork.global.security.oauth.UserPrincipal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserAuthCacheServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private UserAuthCacheService userAuthCacheService; + + private static final Long USER_ID = 1L; + private static final String CACHE_KEY = "user:auth:" + USER_ID; + + // ===== get 테스트 ===== + + @Test + @DisplayName("get - 캐시 미스: null 반환") + void get_CacheMiss_ReturnsNull() { + // Given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(CACHE_KEY)).willReturn(null); + + // When + UserPrincipal result = userAuthCacheService.get(USER_ID); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("get - 캐시 히트: 역직렬화 후 UserPrincipal 반환") + void get_CacheHit_ReturnsDeserializedPrincipal() { + // Given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(CACHE_KEY)).willReturn("1|USER|ACTIVE|test@example.com"); + + // When + UserPrincipal result = userAuthCacheService.get(USER_ID); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getRole()).isEqualTo(Role.USER); + assertThat(result.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("get - 캐시 히트: email이 빈 문자열이면 null로 역직렬화") + void get_CacheHit_EmptyEmail_ReturnsNullEmail() { + // Given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(CACHE_KEY)).willReturn("1|USER|WITHDRAWN|"); + + // When + UserPrincipal result = userAuthCacheService.get(USER_ID); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(UserStatus.WITHDRAWN); + assertThat(result.getEmail()).isNull(); + } + + @Test + @DisplayName("get - 잘못된 포맷: null 반환") + void get_InvalidFormat_ReturnsNull() { + // Given + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(CACHE_KEY)).willReturn("invalid-format"); + + // When + UserPrincipal result = userAuthCacheService.get(USER_ID); + + // Then + assertThat(result).isNull(); + } + + // ===== put 테스트 ===== + + @Test + @DisplayName("put - email이 있는 유저 직렬화 후 저장") + void put_WithEmail_SerializesAndSaves() { + // Given + User user = User.createSocialUser(SocialType.KAKAO, "socialId", "test@example.com", null); + ReflectionTestUtils.setField(user, "id", USER_ID); + ReflectionTestUtils.setField(user, "status", UserStatus.ACTIVE); + + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // When + userAuthCacheService.put(USER_ID, user, 180000L); + + // Then + verify(valueOperations).set(CACHE_KEY, "1|USER|ACTIVE|test@example.com", 180000L, TimeUnit.MILLISECONDS); + } + + @Test + @DisplayName("put - email이 null인 유저 직렬화 후 저장") + void put_WithNullEmail_SerializesEmptyEmail() { + // Given + User user = User.createSocialUser(SocialType.KAKAO, "socialId", null, null); + ReflectionTestUtils.setField(user, "id", USER_ID); + + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // When + userAuthCacheService.put(USER_ID, user, 180000L); + + // Then + verify(valueOperations).set(CACHE_KEY, "1|USER|PENDING|", 180000L, TimeUnit.MILLISECONDS); + } + + // ===== evict 테스트 ===== + + @Test + @DisplayName("evict - 캐시 키 삭제") + void evict_DeletesCacheKey() { + // When + userAuthCacheService.evict(USER_ID); + + // Then + verify(redisTemplate).delete(CACHE_KEY); + } +} diff --git a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java index 938f8098..f246d4fd 100644 --- a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java @@ -5,6 +5,8 @@ import com.techfork.domain.user.enums.SocialType; import com.techfork.domain.user.enums.UserStatus; import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.security.auth.service.UserAuthCacheService; +import com.techfork.global.security.jwt.JwtProperties; import com.techfork.global.security.jwt.JwtUtil; import com.techfork.global.security.oauth.UserPrincipal; import jakarta.servlet.FilterChain; @@ -36,6 +38,12 @@ class JwtAuthenticationFilterTest { @Mock private UserRepository userRepository; + @Mock + private UserAuthCacheService userAuthCacheService; + + @Mock + private JwtProperties jwtProperties; + @Mock private HttpServletRequest request; @@ -66,13 +74,15 @@ void setUp() { // ===== 인증 성공 테스트 ===== @Test - @DisplayName("JWT 인증 성공 - 유효한 액세스 토큰으로 SecurityContext 설정") - void doFilterInternal_Success_WithValidAccessToken() throws Exception { + @DisplayName("JWT 인증 성공 - 캐시 미스: DB 조회 후 캐시 저장") + void doFilterInternal_Success_CacheMiss() throws Exception { // Given given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); willDoNothing().given(jwtUtil).validateToken(validAccessToken); given(jwtUtil.getUserIdFromToken(validAccessToken)).willReturn(userId); + given(userAuthCacheService.get(userId)).willReturn(null); given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(jwtProperties.getAccessTokenExpiration()).willReturn(180000L); // When jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -86,11 +96,38 @@ void doFilterInternal_Success_WithValidAccessToken() throws Exception { assertThat(principal.getId()).isEqualTo(userId); assertThat(principal.getRole()).isEqualTo(Role.USER); assertThat(principal.getStatus()).isEqualTo(UserStatus.PENDING); - assertThat(principal.getUsername()).isEqualTo(String.valueOf(userId)); - verify(jwtUtil).validateToken(validAccessToken); - verify(jwtUtil).validateTokenType(validAccessToken, TOKEN_TYPE_ACCESS); verify(userRepository).findById(userId); + verify(userAuthCacheService).put(eq(userId), eq(testUser), eq(180000L)); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("JWT 인증 성공 - 캐시 히트: DB 조회 없이 인증") + void doFilterInternal_Success_CacheHit() throws Exception { + // Given + UserPrincipal cachedPrincipal = UserPrincipal.builder() + .id(userId) + .role(Role.USER) + .status(UserStatus.ACTIVE) + .email("test@example.com") + .build(); + + given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); + willDoNothing().given(jwtUtil).validateToken(validAccessToken); + given(jwtUtil.getUserIdFromToken(validAccessToken)).willReturn(userId); + given(userAuthCacheService.get(userId)).willReturn(cachedPrincipal); + + // When + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // Then + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isEqualTo(cachedPrincipal); + + verify(userRepository, never()).findById(anyLong()); + verify(userAuthCacheService, never()).put(anyLong(), any(), anyLong()); verify(filterChain).doFilter(request, response); } @@ -200,18 +237,19 @@ void doFilterInternal_Fail_UserNotFound() throws Exception { } @Test - @DisplayName("JWT 인증 실패 - 탈퇴한 회원 (WITHDRAWN 상태)") - void doFilterInternal_Fail_WithdrawnUser() throws Exception { + @DisplayName("JWT 인증 실패 - 탈퇴한 회원 (캐시 미스 후 DB 조회)") + void doFilterInternal_Fail_WithdrawnUser_CacheMiss() throws Exception { // Given User withdrawnUser = User.createSocialUser(SocialType.KAKAO, "withdrawnSocialId", "withdrawn@example.com", null); withdrawnUser.updateUser("탈퇴유저", "withdrawn@example.com", "개발자였습니다."); - withdrawnUser.withdraw(); // 탈퇴 처리 + withdrawnUser.withdraw(); ReflectionTestUtils.setField(withdrawnUser, "id", userId); given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); willDoNothing().given(jwtUtil).validateToken(validAccessToken); willDoNothing().given(jwtUtil).validateTokenType(validAccessToken, TOKEN_TYPE_ACCESS); given(jwtUtil.getUserIdFromToken(validAccessToken)).willReturn(userId); + given(userAuthCacheService.get(userId)).willReturn(null); given(userRepository.findById(userId)).willReturn(Optional.of(withdrawnUser)); // When @@ -219,30 +257,29 @@ void doFilterInternal_Fail_WithdrawnUser() throws Exception { // Then Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - assertThat(authentication).isNull(); // 인증 실패 + assertThat(authentication).isNull(); - // 탈퇴한 회원은 SecurityContext에 설정되지 않음 - verify(jwtUtil).validateToken(validAccessToken); - verify(jwtUtil).validateTokenType(validAccessToken, TOKEN_TYPE_ACCESS); verify(userRepository).findById(userId); + verify(userAuthCacheService, never()).put(anyLong(), any(), anyLong()); // 탈퇴 유저는 캐시 저장 안 함 verify(filterChain).doFilter(request, response); } @Test - @DisplayName("JWT 인증 실패 - 탈퇴 회원 토큰으로 API 접근 시도") - void doFilterInternal_Fail_WithdrawnUserCannotAccessAPI() throws Exception { + @DisplayName("JWT 인증 실패 - 탈퇴한 회원 (캐시 히트)") + void doFilterInternal_Fail_WithdrawnUser_CacheHit() throws Exception { // Given - User withdrawnUser = User.createSocialUser(SocialType.KAKAO, "kakaoUser123", "user@gmail.com", "profile.jpg"); - withdrawnUser.updateUser("카카오유저", "user@gmail.com", "백엔드 개발자"); - withdrawnUser.withdraw(); // 탈퇴 - ReflectionTestUtils.setField(withdrawnUser, "id", 2L); - - String withdrawnUserToken = "withdrawn.user.access.token"; - given(request.getHeader("Authorization")).willReturn("Bearer " + withdrawnUserToken); - willDoNothing().given(jwtUtil).validateToken(withdrawnUserToken); - willDoNothing().given(jwtUtil).validateTokenType(withdrawnUserToken, TOKEN_TYPE_ACCESS); - given(jwtUtil.getUserIdFromToken(withdrawnUserToken)).willReturn(2L); - given(userRepository.findById(2L)).willReturn(Optional.of(withdrawnUser)); + UserPrincipal withdrawnPrincipal = UserPrincipal.builder() + .id(userId) + .role(Role.USER) + .status(UserStatus.WITHDRAWN) + .email(null) + .build(); + + given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); + willDoNothing().given(jwtUtil).validateToken(validAccessToken); + willDoNothing().given(jwtUtil).validateTokenType(validAccessToken, TOKEN_TYPE_ACCESS); + given(jwtUtil.getUserIdFromToken(validAccessToken)).willReturn(userId); + given(userAuthCacheService.get(userId)).willReturn(withdrawnPrincipal); // When jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -251,13 +288,7 @@ void doFilterInternal_Fail_WithdrawnUserCannotAccessAPI() throws Exception { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNull(); - // 탈퇴 회원 상태 확인 - assertThat(withdrawnUser.isWithdrawn()).isTrue(); - assertThat(withdrawnUser.getStatus()).isEqualTo(UserStatus.WITHDRAWN); - assertThat(withdrawnUser.getNickName()).isNull(); // 익명화됨 - assertThat(withdrawnUser.getEmail()).isNull(); // 익명화됨 - - verify(userRepository).findById(2L); + verify(userRepository, never()).findById(anyLong()); // 캐시 히트이므로 DB 조회 없음 verify(filterChain).doFilter(request, response); } }