diff --git a/build.gradle b/build.gradle index 547a881..8c026bb 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // kafka + implementation 'org.springframework.kafka:spring-kafka' + // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' diff --git a/compose.yaml b/compose.yaml index 1d09a62..6e1ddc6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,25 +1,26 @@ -version: '3' services: - zookeeper: - image: confluentinc/cp-zookeeper:latest - container_name: zookeeper - ports: - - "2181:2181" - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - kafka: - image: confluentinc/cp-kafka:latest + image: apache/kafka:3.7.0 container_name: kafka - depends_on: - - zookeeper ports: - "9092:9092" environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - # 클러스터 내부와 외부에서 접근할 listener 설정 (내부: kafka:29092, 외부: localhost:9092) - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_NODE_ID: 1 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT_HOST://localhost:9092,PLAINTEXT://kafka:29092 + KAFKA_LISTENERS: CONTROLLER://kafka:9093,PLAINTEXT://kafka:29092,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_PROCESS_ROLES: broker,controller KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + CLUSTER_ID: 4L6g3nShT-eMCtK--X86sw + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-data:/data diff --git a/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java b/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java index 5176331..467e5ed 100644 --- a/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java +++ b/src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java @@ -1,16 +1,26 @@ package com.readyvery.readyverydemo.config; +import java.time.Duration; + import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; import lombok.extern.slf4j.Slf4j; @Slf4j @Configuration -public class RedisConfig { +@EnableCaching +public class RedisConfig implements CachingConfigurer { @Value("${spring.data.redis.host}") private String host; @@ -20,8 +30,74 @@ public class RedisConfig { @Bean public RedisConnectionFactory redisConnectionFactory() { + // Redis Standalone 구성 설정 + org.springframework.data.redis.connection.RedisStandaloneConfiguration redisConfig = + new org.springframework.data.redis.connection.RedisStandaloneConfiguration(); + redisConfig.setHostName(host); + redisConfig.setPort(port); + + // Redis 연결 실패 시에도 애플리케이션이 계속 동작하도록 타임아웃 설정 + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(2)) // 짧은 타임아웃으로 빠른 실패 + .shutdownTimeout(Duration.ofMillis(100)) + .build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(redisConfig, clientConfig); + + // 연결 검증 비활성화로 Redis 다운 시에도 애플리케이션 시작 가능 + factory.setValidateConnection(false); + factory.setShareNativeConnection(false); + + log.info("Redis 연결팩토리 설정 완료: {}:{}", host, port); + return factory; + } - return new LettuceConnectionFactory(host, port); + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // String 직렬화 사용 + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setValueSerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + template.setHashValueSerializer(stringSerializer); + + // 연결 실패 시 예외를 던지지 않도록 설정 + template.setEnableDefaultSerializer(false); + template.setDefaultSerializer(stringSerializer); + + log.info("RedisTemplate 설정 완료 - Redis 다운 시 DB fallback 사용"); + return template; } + /** + * Redis 오류 발생 시 graceful 처리를 위한 CacheErrorHandler + * Redis가 다운되어도 애플리케이션이 계속 동작하도록 함 + */ + @Override + public CacheErrorHandler errorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("Redis 캐시 조회 실패 ({}): {} - DB에서 조회합니다", cache.getName(), exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("Redis 캐시 저장 실패 ({}): {} - 캐시 없이 계속 진행합니다", cache.getName(), exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("Redis 캐시 삭제 실패 ({}): {} - 캐시 없이 계속 진행합니다", cache.getName(), exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("Redis 캐시 클리어 실패 ({}): {} - 캐시 없이 계속 진행합니다", cache.getName(), exception.getMessage()); + } + }; + } } diff --git a/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java b/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java index fadaa46..30c01c6 100644 --- a/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java +++ b/src/main/java/com/readyvery/readyverydemo/config/SpringSecurityConfig.java @@ -22,7 +22,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.fasterxml.jackson.databind.ObjectMapper; -import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; import com.readyvery.readyverydemo.domain.repository.UserRepository; import com.readyvery.readyverydemo.security.exception.CustomAuthenticationEntryPoint; import com.readyvery.readyverydemo.security.jwt.filter.JwtAuthenticationProcessingFilter; @@ -31,6 +30,7 @@ import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginFailureHandler; import com.readyvery.readyverydemo.security.oauth2.handler.OAuth2LoginSuccessHandler; import com.readyvery.readyverydemo.security.oauth2.service.CustomOAuth2UserService; +import com.readyvery.readyverydemo.src.refreshtoken.RefreshTokenService; import lombok.RequiredArgsConstructor; @@ -43,7 +43,7 @@ public class SpringSecurityConfig { private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; private final CustomOAuth2UserService customOAuth2UserService; - private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenService refreshTokenService; private final OauthConfig oauthConfig; @Bean @@ -134,7 +134,7 @@ public WebSecurityCustomizer webSecurityCustomizer() { @Bean public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { JwtAuthenticationProcessingFilter jwtAuthenticationFilter = new JwtAuthenticationProcessingFilter(jwtService, - userRepository, refreshTokenRepository); + userRepository, refreshTokenService); return jwtAuthenticationFilter; } diff --git a/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java b/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java deleted file mode 100644 index b46cdb7..0000000 --- a/src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.readyvery.readyverydemo.domain; - -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; -import org.springframework.data.redis.core.index.Indexed; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@AllArgsConstructor -@Getter -@Builder -@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 30) -public class RefreshToken { - - @Id - private String id; - - @Indexed - private String refreshToken; - - // @Indexed - // private String accessToken; - - public void update(String refreshToken) { - this.refreshToken = refreshToken; - - } -} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java index e43dcd1..5783a00 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java @@ -4,18 +4,9 @@ import java.util.ArrayList; import java.util.List; +import jakarta.persistence.*; import org.hibernate.annotations.ColumnDefault; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,7 +22,6 @@ @Builder @Table(name = "USER", indexes = { @Index(name = "idx_email", columnList = "email"), - @Index(name = "idx_refresh_token", columnList = "refresh_token"), @Index(name = "idx_social_type_id", columnList = "social_type, social_id") }) @AllArgsConstructor @@ -73,21 +63,19 @@ public class UserInfo extends BaseTimeEntity { private Role role; // 소셜 로그인 타입 - @Column(nullable = false) + @Column(name = "social_type", nullable = false) @Enumerated(EnumType.STRING) private SocialType socialType; // KAKAO, NAVER, GOOGLE // 소셜 로그인 타입의 식별자 값 (일반 로그인인 경우 null) - @Column(nullable = false) + @Column(name = "social_id", nullable = false) private String socialId; // 로그인한 소셜 타입의 식별자 값 (일반 로그인인 경우 null) // 유저 상태 @Column(nullable = false, columnDefinition = "BOOLEAN default true") private boolean status; - // 리프레시 토큰 - @Column(columnDefinition = "TEXT") - private String refreshToken; + // 계정 삭제 요청일 @Column diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java deleted file mode 100644 index 1b8d365..0000000 --- a/src/main/java/com/readyvery/readyverydemo/domain/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.readyvery.readyverydemo.domain.repository; - -import java.util.Optional; - -import org.springframework.data.repository.CrudRepository; -import org.springframework.stereotype.Repository; - -import com.readyvery.readyverydemo.domain.RefreshToken; - -@Repository -public interface RefreshTokenRepository extends CrudRepository { - - Optional findByRefreshToken(String refreshToken); - -} diff --git a/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java b/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java index 2dd5423..9bf2c76 100644 --- a/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java +++ b/src/main/java/com/readyvery/readyverydemo/domain/repository/UserRepository.java @@ -16,8 +16,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - Optional findByRefreshToken(String refreshToken); - /** * 소셜 타입과 소셜의 식별값으로 회원 찾는 메소드 * 정보 제공을 동의한 순간 DB에 저장해야하지만, 아직 추가 정보(사는 도시, 나이 등)를 입력받지 않았으므로 diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java index 1e3a70b..7adb34a 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -11,12 +11,11 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import com.readyvery.readyverydemo.domain.RefreshToken; import com.readyvery.readyverydemo.domain.UserInfo; -import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; import com.readyvery.readyverydemo.domain.repository.UserRepository; import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails; import com.readyvery.readyverydemo.security.jwt.service.JwtService; +import com.readyvery.readyverydemo.src.refreshtoken.RefreshTokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -33,7 +32,7 @@ public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenService refreshTokenService; private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); @@ -78,14 +77,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse * 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기 */ public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { - refreshTokenRepository.findByRefreshToken(refreshToken) - .map(RefreshToken::getId) // RefreshToken 객체에서 ID를 추출합니다. - .flatMap(userRepository::findByEmail) // 추출된 ID를 이용하여 user를 조회합니다. - .ifPresent(user -> { - String reIssuedRefreshToken = reIssueRefreshToken(user); - jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()), - reIssuedRefreshToken, user.getRole()); - }); + // RefreshToken으로 이메일을 찾고, 해당 이메일로 사용자 조회 + String email = refreshTokenService.findEmailByRefreshToken(refreshToken); + if (email != null) { + userRepository.findByEmail(email) + .ifPresent(user -> { + String reIssuedRefreshToken = reIssueRefreshToken(user); + jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()), + reIssuedRefreshToken, user.getRole()); + }); + } } /** @@ -95,22 +96,7 @@ public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, */ private String reIssueRefreshToken(UserInfo userInfo) { String reIssuedRefreshToken = jwtService.createRefreshToken(); - - RefreshToken refreshToken = refreshTokenRepository.findById(userInfo.getEmail()) - .map(token -> { - // 이미 존재하는 토큰이 있으면, 새로 발급받은 리프레시 토큰으로 업데이트 - token.update(reIssuedRefreshToken); - return token; - }) - .orElseGet(() -> { - // 새로운 토큰 생성 - return RefreshToken.builder() - .id(userInfo.getEmail()) - .refreshToken(reIssuedRefreshToken) - .build(); - }); - - refreshTokenRepository.save(refreshToken); + refreshTokenService.saveRefreshTokenInRedis(userInfo.getEmail(), reIssuedRefreshToken); return reIssuedRefreshToken; } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java index ce1416e..900c193 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/JwtServiceImpl.java @@ -99,23 +99,48 @@ public Optional extractAccessToken(HttpServletRequest request) { */ @Override public Optional extractEmail(String accessToken) { + // 토큰이 null이거나 빈 문자열인 경우 사전 검사 + if (accessToken == null || accessToken.trim().isEmpty()) { + log.debug("AccessToken이 비어있습니다."); + return Optional.empty(); + } + + // JWT 형식 기본 검사 + String[] tokenParts = accessToken.split("\\."); + if (tokenParts.length != 3) { + log.debug("잘못된 AccessToken 형식입니다. 예상 부분: 3, 실제 부분: {}", tokenParts.length); + return Optional.empty(); + } + try { // 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환 - return jwtTokenizer.verifyAccessToken(accessToken); } catch (Exception e) { - + log.debug("AccessToken에서 이메일 추출 실패: {}", e.getMessage()); return Optional.empty(); } } @Override public boolean isTokenValid(String token) { + // 토큰이 null이거나 빈 문자열인 경우 사전 검사 + if (token == null || token.trim().isEmpty()) { + log.debug("토큰이 비어있습니다."); + return false; + } + + // JWT 형식 기본 검사 (3개 부분으로 구성되어야 함) + String[] tokenParts = token.split("\\."); + if (tokenParts.length != 3) { + log.debug("잘못된 JWT 토큰 형식입니다. 예상 부분: 3, 실제 부분: {}", tokenParts.length); + return false; + } + try { JWT.require(jwtConfig.getAlgorithm()).build().verify(token); return true; } catch (Exception e) { - log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + log.debug("JWT 토큰 검증 실패: {}", e.getMessage()); return false; } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java index a66cc3e..ccd6887 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/extract/ExtractToken.java @@ -19,7 +19,8 @@ public Optional extractTokenCookie(HttpServletRequest request, String to return Arrays.stream(cookies) .filter(cookie -> tokenName.equals(cookie.getName())) .findFirst() - .map(Cookie::getValue); + .map(Cookie::getValue) + .filter(token -> token != null && !token.trim().isEmpty()); // 빈 토큰 필터링 추가 } return Optional.empty(); } @@ -27,7 +28,8 @@ public Optional extractTokenCookie(HttpServletRequest request, String to public Optional extractTokenHeader(HttpServletRequest request, String tokenName) { return Optional.ofNullable(request.getHeader(tokenName)) .filter(verifyToken -> verifyToken.startsWith(BEARER)) - .map(verifyToken -> verifyToken.replace(BEARER, "")); + .map(verifyToken -> verifyToken.replace(BEARER, "")) + .filter(token -> token != null && !token.trim().isEmpty()); // 빈 토큰 필터링 추가 } } diff --git a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java index 284db7a..7d3fe7e 100644 --- a/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java +++ b/src/main/java/com/readyvery/readyverydemo/security/jwt/service/sendmanger/JwtTokenizer.java @@ -45,10 +45,28 @@ public void addRefreshTokenCookie(HttpServletResponse response, String refreshTo // } public Optional verifyAccessToken(String accessToken) { - return Optional.ofNullable(JWT.require(jwtConfig.getAlgorithm()) - .build() - .verify(accessToken) - .getClaim(EMAIL_CLAIM) - .asString()); + // 토큰이 null이거나 빈 문자열인 경우 사전 검사 + if (accessToken == null || accessToken.trim().isEmpty()) { + log.debug("검증할 AccessToken이 비어있습니다."); + return Optional.empty(); + } + + // JWT 형식 기본 검사 + String[] tokenParts = accessToken.split("\\."); + if (tokenParts.length != 3) { + log.debug("AccessToken 검증 실패 - 잘못된 형식입니다. 예상 부분: 3, 실제 부분: {}", tokenParts.length); + return Optional.empty(); + } + + try { + return Optional.ofNullable(JWT.require(jwtConfig.getAlgorithm()) + .build() + .verify(accessToken) + .getClaim(EMAIL_CLAIM) + .asString()); + } catch (Exception e) { + log.debug("AccessToken 검증 중 오류 발생: {}", e.getMessage()); + return Optional.empty(); + } } } diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java index 9f7f43b..438ae6c 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenService.java @@ -5,4 +5,8 @@ public interface RefreshTokenService { void removeRefreshTokenInRedis(String email); void saveRefreshTokenInRedis(String email, String refreshToken); + + String getRefreshToken(String email); + + String findEmailByRefreshToken(String refreshToken); } diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java index 6770335..d631be7 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/RefreshTokenServiceImpl.java @@ -1,16 +1,12 @@ package com.readyvery.readyverydemo.src.refreshtoken; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; - -import com.readyvery.readyverydemo.domain.RefreshToken; -import com.readyvery.readyverydemo.domain.repository.RefreshTokenRepository; +import org.springframework.data.redis.core.RedisTemplate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import java.util.concurrent.TimeUnit; import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; import com.readyvery.readyverydemo.src.refreshtoken.fallback.RefreshTokenFallback; import com.readyvery.readyverydemo.src.refreshtoken.fallback.RefreshTokenFallbackRepository; @@ -22,39 +18,132 @@ public class RefreshTokenServiceImpl implements RefreshTokenService { private final RedisTemplate redisTemplate; private final RefreshTokenFallbackRepository fallbackRepository; private static final String REFRESH_TOKEN_PREFIX = "refreshToken:"; - private static final long REFRESH_TOKEN_TTL = 60L * 60 * 24 * 7; + private static final long REFRESH_TOKEN_TTL = 60L * 60 * 24 * 7; @Override public void removeRefreshTokenInRedis(String email) { try { redisTemplate.delete(REFRESH_TOKEN_PREFIX + email); - } catch (Exception e) { - log.error("Redis 삭제 실패, DB fallback 사용: {}", e.getMessage()); + log.info("Redis에서 리프레시 토큰 삭제 완료: {}", email); + // Redis 성공 시 DB에서도 삭제 (있다면) fallbackRepository.deleteById(email); + } catch (Exception e) { + log.warn("Redis 삭제 실패, DB fallback 사용: {}", e.getMessage()); + // Redis 실패 시에만 DB에서 삭제 + try { + fallbackRepository.deleteById(email); + log.info("DB에서 리프레시 토큰 삭제 완료: {}", email); + } catch (Exception dbException) { + log.error("DB에서도 토큰 삭제 실패: {}", dbException.getMessage()); + } } } @Override public void saveRefreshTokenInRedis(String email, String refreshToken) { + boolean redisSuccess = false; + try { - redisTemplate.opsForValue().set(REFRESH_TOKEN_PREFIX + email, refreshToken, REFRESH_TOKEN_TTL, TimeUnit.SECONDS); - } catch (Exception e) { - log.error("Redis 저장 실패, DB fallback 사용: {}", e.getMessage()); - fallbackRepository.save( - new RefreshTokenFallback(email, refreshToken, LocalDateTime.now().plusSeconds(REFRESH_TOKEN_TTL)) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + email, + refreshToken, + REFRESH_TOKEN_TTL, + TimeUnit.SECONDS ); + redisSuccess = true; + log.info("Redis에 리프레시 토큰 저장 완료: {}", email); + } catch (Exception e) { + log.warn("Redis 저장 실패, DB fallback 사용: {}", e.getMessage()); + } + + if (redisSuccess) { + // Redis 저장 성공 시 DB에 있는 기존 토큰 삭제 (있다면) + try { + fallbackRepository.deleteById(email); + } catch (Exception e) { + log.debug("DB 토큰 삭제 중 오류 (무시 가능): {}", e.getMessage()); + } + } else { + // Redis 실패 시에만 DB에 저장 + try { + fallbackRepository.save( + new RefreshTokenFallback(email, refreshToken, LocalDateTime.now().plusSeconds(REFRESH_TOKEN_TTL)) + ); + log.info("DB에 리프레시 토큰 저장 완료 (fallback): {}", email); + } catch (Exception dbException) { + log.error("Redis와 DB 모두에서 토큰 저장 실패: {}", dbException.getMessage()); + } } } + @Override public String getRefreshToken(String email) { try { - return redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + email); + String redisToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + email); + if (redisToken != null) { + log.debug("Redis에서 리프레시 토큰 조회 완료: {}", email); + return redisToken; + } + log.debug("Redis에 리프레시 토큰이 없음, DB fallback 사용: {}", email); } catch (Exception e) { - log.error("Redis 조회 실패, DB fallback 사용: {}", e.getMessage()); - return fallbackRepository.findById(email) + log.warn("Redis 조회 실패, DB fallback 사용: {}", e.getMessage()); + } + + // Redis에 없거나 실패한 경우 DB에서 조회 + try { + String dbToken = fallbackRepository.findById(email) .filter(token -> token.getExpiresAt().isAfter(LocalDateTime.now())) .map(RefreshTokenFallback::getRefreshToken) .orElse(null); + + if (dbToken != null) { + log.debug("DB에서 리프레시 토큰 조회 완료 (fallback): {}", email); + } + + return dbToken; + } catch (Exception dbException) { + log.error("DB에서도 토큰 조회 실패: {}", dbException.getMessage()); + return null; + } + } + + @Override + public String findEmailByRefreshToken(String refreshToken) { + try { + // Redis에서 모든 refreshToken: 패턴의 키를 검색 + var keys = redisTemplate.keys(REFRESH_TOKEN_PREFIX + "*"); + if (keys != null) { + for (String key : keys) { + try { + String storedToken = redisTemplate.opsForValue().get(key); + if (refreshToken.equals(storedToken)) { + String email = key.substring(REFRESH_TOKEN_PREFIX.length()); + log.debug("Redis에서 이메일 조회 완료: {}", email); + return email; + } + } catch (Exception keyException) { + log.debug("Redis 개별 키 조회 실패: {}", keyException.getMessage()); + } + } + } + log.debug("Redis에서 해당 리프레시 토큰을 찾을 수 없음, DB fallback 사용"); + } catch (Exception e) { + log.warn("Redis에서 이메일 조회 실패, DB fallback 사용: {}", e.getMessage()); + } + + // Redis에서 찾지 못한 경우 DB에서 효율적으로 조회 + try { + String email = fallbackRepository.findUserIdByRefreshTokenAndNotExpired(refreshToken, LocalDateTime.now()) + .orElse(null); + + if (email != null) { + log.debug("DB에서 이메일 조회 완료 (fallback): {}", email); + } + + return email; + } catch (Exception dbException) { + log.error("DB에서도 이메일 조회 실패: {}", dbException.getMessage()); + return null; } } } diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallback.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallback.java index 5a8cfbf..381caca 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallback.java +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallback.java @@ -2,6 +2,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Index; import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,6 +11,10 @@ import lombok.Setter; @Entity +@Table(name = "refresh_token", indexes = { + @Index(name = "idx_refresh_token", columnList = "refreshToken"), + @Index(name = "idx_expires_at", columnList = "expiresAt") +}) @Getter @Setter @NoArgsConstructor @@ -18,4 +24,4 @@ public class RefreshTokenFallback { private String userId; private String refreshToken; private LocalDateTime expiresAt; -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallbackRepository.java b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallbackRepository.java index a47408e..acee917 100644 --- a/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallbackRepository.java +++ b/src/main/java/com/readyvery/readyverydemo/src/refreshtoken/fallback/RefreshTokenFallbackRepository.java @@ -1,6 +1,13 @@ package com.readyvery.readyverydemo.src.refreshtoken.fallback; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.Optional; public interface RefreshTokenFallbackRepository extends JpaRepository { -} \ No newline at end of file + + @Query("SELECT r.userId FROM RefreshTokenFallback r WHERE r.refreshToken = :refreshToken AND r.expiresAt > :now") + Optional findUserIdByRefreshTokenAndNotExpired(@Param("refreshToken") String refreshToken, @Param("now") LocalDateTime now); +} \ No newline at end of file