From df2543e083b81db20a5b98e6e689201004bffcf5 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:23:24 +0900 Subject: [PATCH 01/10] =?UTF-8?q?improve:=20JWT=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20userId=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84(miss=20=EC=8B=9C=20DB=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../techfork/global/constant/RedisKey.java | 1 + .../auth/service/UserAuthCacheService.java | 71 +++++++++++++++++++ .../filter/JwtAuthenticationFilter.java | 19 +++-- 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/techfork/global/security/auth/service/UserAuthCacheService.java 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..3103f1d4 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,20 @@ 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); + userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration()); + } + + 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(); From 4aa375fe68d7ea2598ffe9ec2ed6767b0f4027b1 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:28:09 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test:=20JWT=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=BA=90=EC=8B=9C=20=EB=AF=B8=EC=8A=A4=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=84=B1=EA=B3=B5=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilterTest.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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..0ebbe823 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,9 @@ 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); } From 5f64aa318383fe832542c7ee10ab769c452c47a6 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:29:38 +0900 Subject: [PATCH 03/10] =?UTF-8?q?test:=20JWT=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=20DB=20=EC=A1=B0=ED=9A=8C=20=EC=97=86=EC=9D=B4=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilterTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 0ebbe823..cdce43ff 100644 --- a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java @@ -102,6 +102,35 @@ void doFilterInternal_Success_CacheMiss() throws Exception { 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); + } + // ===== 인증 실패 테스트 ===== @Test From ab6d3e758187abfb1e3183a7e5c5b4b69e372356 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:37:23 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20JWT=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=83=88=ED=87=B4=ED=95=9C=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(=EC=BA=90=EC=8B=9C=20=EB=AF=B8=EC=8A=A4=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilterTest.java | 47 +++---------------- 1 file changed, 7 insertions(+), 40 deletions(-) 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 cdce43ff..b5e36f01 100644 --- a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java @@ -237,49 +237,21 @@ 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 - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // Then - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - assertThat(authentication).isNull(); // 인증 실패 - - // 탈퇴한 회원은 SecurityContext에 설정되지 않음 - verify(jwtUtil).validateToken(validAccessToken); - verify(jwtUtil).validateTokenType(validAccessToken, TOKEN_TYPE_ACCESS); - verify(userRepository).findById(userId); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("JWT 인증 실패 - 탈퇴 회원 토큰으로 API 접근 시도") - void doFilterInternal_Fail_WithdrawnUserCannotAccessAPI() 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)); + given(jwtProperties.getAccessTokenExpiration()).willReturn(180000L); // When jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -288,13 +260,8 @@ 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).findById(userId); + verify(userAuthCacheService, never()).put(anyLong(), any(), anyLong()); // 탈퇴 유저는 캐시 저장 안 함 verify(filterChain).doFilter(request, response); } } From 3e53dea731ecc014435bb0995356a91d5f96390e Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:38:28 +0900 Subject: [PATCH 05/10] =?UTF-8?q?test:=20JWT=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=83=88=ED=87=B4=ED=95=9C=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilterTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 b5e36f01..fd1d76f7 100644 --- a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java @@ -264,4 +264,32 @@ void doFilterInternal_Fail_WithdrawnUser_CacheMiss() throws Exception { verify(userAuthCacheService, never()).put(anyLong(), any(), anyLong()); // 탈퇴 유저는 캐시 저장 안 함 verify(filterChain).doFilter(request, response); } + + @Test + @DisplayName("JWT 인증 실패 - 탈퇴한 회원 (캐시 히트)") + void doFilterInternal_Fail_WithdrawnUser_CacheHit() throws Exception { + // Given + 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); + + // Then + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNull(); + + verify(userRepository, never()).findById(anyLong()); // 캐시 히트이므로 DB 조회 없음 + verify(filterChain).doFilter(request, response); + } } From 3f99893fc26914b7381ccc2768acb0b5ffa8978b Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:43:06 +0900 Subject: [PATCH 06/10] =?UTF-8?q?improve:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/techfork/domain/user/service/UserCommandService.java | 5 ++++- .../techfork/domain/user/service/UserCommandServiceTest.java | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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..c17f1a34 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) @@ -52,7 +54,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/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java index 03853151..8f7c77f8 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; @@ -308,6 +312,7 @@ void withdrawUser_Success() { assertThat(testUser.getSocialId()).isEqualTo(originalSocialId); verify(userRepository).findById(userId); + verify(userAuthCacheService).evict(userId); } @Test From e7c770b21111ae1952dd28ade68d561adf8a167f Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:45:09 +0900 Subject: [PATCH 07/10] =?UTF-8?q?improve:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20status=EA=B0=80=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=98=EB=AF=80=EB=A1=9C=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/techfork/domain/user/service/UserCommandService.java | 2 ++ .../techfork/domain/user/service/UserCommandServiceTest.java | 3 +++ 2 files changed, 5 insertions(+) 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 c17f1a34..21efdabb 100644 --- a/src/main/java/com/techfork/domain/user/service/UserCommandService.java +++ b/src/main/java/com/techfork/domain/user/service/UserCommandService.java @@ -31,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) { 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 8f7c77f8..8802ec86 100644 --- a/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java @@ -85,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 @@ -146,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 @@ -186,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); } // ===== 프로필 수정 테스트 ===== From 6b8616c8a1d7fe9ef7a70420304490334f328e69 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 21:48:47 +0900 Subject: [PATCH 08/10] =?UTF-8?q?improve:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=9C=20=EC=BA=90=EC=8B=B1=20TTL=EB=8F=84=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/techfork/domain/auth/service/AuthService.java | 6 +++++- .../com/techfork/domain/auth/service/AuthServiceTest.java | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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/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()); } From 1bf611c759bbfc7e66766cac361c4edce2424e91 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 22:56:28 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20UserAuthCacheService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserAuthCacheServiceTest.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/test/java/com/techfork/global/security/auth/service/UserAuthCacheServiceTest.java 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); + } +} From 4746c17eaa7a51513d05350bc6d37829d3ff7c23 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Feb 2026 23:25:49 +0900 Subject: [PATCH 10/10] =?UTF-8?q?improve:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=83=81=ED=83=9C=EB=A9=B4=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20X?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/filter/JwtAuthenticationFilter.java | 9 ++++++--- .../security/filter/JwtAuthenticationFilterTest.java | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) 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 3103f1d4..bbf09ddd 100644 --- a/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/techfork/global/security/filter/JwtAuthenticationFilter.java @@ -66,10 +66,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND)); userPrincipal = UserPrincipal.buildUserPrincipal(user); - userAuthCacheService.put(userId, user, jwtProperties.getAccessTokenExpiration()); - } - if (userPrincipal.getStatus() == UserStatus.WITHDRAWN) { + 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); } 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 fd1d76f7..f246d4fd 100644 --- a/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/techfork/global/security/filter/JwtAuthenticationFilterTest.java @@ -251,7 +251,6 @@ void doFilterInternal_Fail_WithdrawnUser_CacheMiss() throws Exception { given(jwtUtil.getUserIdFromToken(validAccessToken)).willReturn(userId); given(userAuthCacheService.get(userId)).willReturn(null); given(userRepository.findById(userId)).willReturn(Optional.of(withdrawnUser)); - given(jwtProperties.getAccessTokenExpiration()).willReturn(180000L); // When jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);