Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
37 changes: 19 additions & 18 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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
80 changes: 78 additions & 2 deletions src/main/java/com/readyvery/readyverydemo/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> 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());
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -134,7 +134,7 @@ public WebSecurityCustomizer webSecurityCustomizer() {
@Bean
public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {
JwtAuthenticationProcessingFilter jwtAuthenticationFilter = new JwtAuthenticationProcessingFilter(jwtService,
userRepository, refreshTokenRepository);
userRepository, refreshTokenService);
return jwtAuthenticationFilter;
}

Expand Down
30 changes: 0 additions & 30 deletions src/main/java/com/readyvery/readyverydemo/domain/RefreshToken.java

This file was deleted.

20 changes: 4 additions & 16 deletions src/main/java/com/readyvery/readyverydemo/domain/UserInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public interface UserRepository extends JpaRepository<UserInfo, Long> {

Optional<UserInfo> findByEmail(String email);

Optional<UserInfo> findByRefreshToken(String refreshToken);

/**
* 소셜 타입과 소셜의 식별값으로 회원 찾는 메소드
* 정보 제공을 동의한 순간 DB에 저장해야하지만, 아직 추가 정보(사는 도시, 나이 등)를 입력받지 않았으므로
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

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

/**
Expand All @@ -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;
}

Expand Down
Loading
Loading