본문 바로가기
dev/프로젝트

[프로젝트] Redis & OAuth2 적용

by dev-everyday 2025. 1. 21.
반응형

1. Redis 캐시 동작 테스트

Redis 캐시가 제대로 동작하는지 확인하는 테스트를 작성해보자.

그리고 전체적으로 코드를 다 수정했기 때문에 잘 작동하는지 확인이 필요하다.

우선 redis-container와 mysql-container를 실행하자.

테스트 방법에는 단위 테스트, 통합 테스트, Redis  CLI를 활용한 확인 등 여러 가지가 있다.

 

1.1 단위 테스트 (JUnit + Spring Boot Test)

단위 테스트는 Spring Boot에서 Redis 캐시가 잘 동작하는지 확인하는 가장 기본적인 방법이다.

하나의 클래스 또는 메서드가 독립적으로 올바르게 동작하는지 검증하는 테스트로 외부 의존성 없이 실행하며 Mocking을 사용해 필요한 객체를 대체한다.

@Cacheable이 적용된 메서드가 redis에서 데이터를 불러오는지 확인하고 @CacheEvict 호출 시 캐시가 삭제되는지 검증하자.

 

problem > @Cacheable이 적용된 경우 Spring AOP가 작동하는데 Mockito 기반의 단위 테스트에서 확인할 수 없다.

@Mock으로 redisTemplate을 주입했지만 @Cacheable이 내부적으로 사용하는 Spring의 CacheManager와 연결되지 않아 실제 캐싱이 되지 않는다.

solve > 단위 테스트 안녕.. 계속 변경하면서 해보았는데 @Cacheable 어노테이션  단위 테스트는 AOP 내부 동작 때문에 힘들 거 같다.

1.2 Redis CLI로 캐시 데이터 확인

Redis 서버에서 직접 KEYS* 명령어로 캐싱된 데이터를 확인해보자.

우선 postman을 통해서 sign-up을 한다.

{
    "message": "중복된 이메일입니다.",
    "errors": [
        "중복된 이메일입니다."
    ]
}

두 번 보내면 custom error response를 받을 수 있다.

 

그리고 @PathVariable로 받던 userId를 전부 @RequestParam으로 변경하였다.

@GetMapping
public ResponseEntity<CardLogDto> getCardLog(@RequestParam("id") Long cardId) {
    return ResponseEntity.status(HttpStatus.OK).body(cardLogService.getCardLog(cardId));
}

@PathVariable이 맞아서 다시 복구했다.

@GetMapping("/{id}")
public ResponseEntity<CardLogDto> getCardLog(@PathVariable("id") Long cardId) {
    return ResponseEntity.status(HttpStatus.OK).body(cardLogService.getCardLog(cardId));
}

 

fix> Repository에서 PathVariable에 id로 해서 계속 인식을 못하는 문제가 있었는데 전부 수정하였다.

@GetMapping("/{userId}")
public ResponseEntity<UserDto> getUser(@PathVariable Long userId) {
    return ResponseEntity.status(HttpStatus.OK).body(userService.getUser(userId));
}

 

기존에 직렬화와 역직렬화가 에러가 발생해서 RedisConfig를 아래와 같이 수정하였다.

package com.fortune.app.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.activateDefaultTyping(
                objectMapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL
        );

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))  // 캐시 TTL 1시간
                .disableCachingNullValues()  // null 값 캐싱 안 함
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

activateDefaultTyping으로 type을 유지하도록 변경하였다.

 

실행 결과는 아래와 같다.

순서대로 회원가입 후 keys *, 조회 후 keys *, 전체 조회 후 keys * 이다.

회원 가입 후 같은 회원을 불러올 때 DB에서 select문을 하지 않고 list get시에도 한 번만 쿼리가 동작한다.

그리고 다시 회원 가입 시 userList 삭제 확인하면 잘 되는 것을 볼 수 있다.

Cacheable은 Query에서 사용하였고 CachePut은 Insert와 Update 때 사용하였다.

1.3 통합 테스트 (실제 Redis와 연동)

Spring  Boot Test 환경에서 Redis를 실행하고 실제 API 요청을 통해 캐시 동작을 검증하자.

통합 테스트는 여러 개의 모듈이 정상적으로 동작하는지(UserService, UserRepository, Redis 등) 검증하는 테스트로 SpringBoot + JPA + Redis +API 통합 테스트라고 생각하면 좋을 거 같다.

실제 DB(MySQL), Redis와 연결하여 실행한다.

 

통합 테스트는 Spring Boot 컨텍스트 로드(@SpringBootTest)가 필요하다.

기존에 UserRepositoryTest는 testFindUserByUserId만 있는 간단한 테스트였다.

@SpringBootTest
@Transactional
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserService userService;

    @PersistenceContext
    private EntityManager entityManager;

    private UserDto savedUserDto;

    @BeforeEach
    void setUp() {
        UserRequestDto userRequestDto = UserRequestDto.builder()
                .name("사용자")
                .birth(new Date(2025, 1, 1))
                .nickname("testUser")
                .email("testEmail@test.com")
                .provider("kakao")
                .providerUid("1234567890")
                .accessToken("accessToken")
                .refreshToken("refreshToken")
                .build();

        savedUserDto = userService.signUp(userRequestDto);

        entityManager.flush();
        entityManager.clear();
    }

    @Test
    public void testFindUserByUserId() {
        Optional<User> foundUser = userRepository.findByUserId(savedUserDto.getUserId());

        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getEmail()).isEqualTo(savedUserDto.getEmail());
        assertThat(foundUser.get().getName()).isEqualTo(savedUserDto.getName());
        assertThat(foundUser.get().getBirth()).isEqualTo(savedUserDto.getBirth());
        assertThat(foundUser.get().getNickname()).isEqualTo(savedUserDto.getNickname());
    }
}

 

우선 테스트 DB와 개발 DB를 분리하자.

MySQL에 테스트 DB fortune-app-test를 만들었다.

CREATE DATABASE `fortune-app-test`;

Redis 역시 test용으로 하나 생성한다. 포트는 6380으로 설정하였다.

docker run --name redis-test-container -d -p 6380:6379 redis

내부적으로는 6379 포트를 이용하고 나는 6380으로 접근하도록 하였고 두 개는 다른 컨테이너라 같은 포트를 사용해도 상관 없다.

 

application-test.properties를 아래와 같이 추가하였다.

spring.application.name=fortune-app-backend-test

spring.datasource.url=jdbc:mysql://localhost:3306/fortune-app-test?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.defer-datasource-initialization=true

spring.data.redis.database=0
spring.data.redis.host=localhost
spring.data.redis.port=6380 
spring.cache.redis.use-key-prefix=true
spring.cache.redis.key-prefix=test-everyday:
spring.cache.redis.cache-null-values=false
spring.cache.redis.time-to-live=600000 

logging.level.org.springframework=DEBUG

 

또한 RedisTestConfig를 생성하는데 이 부분에서 RedisConfig와 자꾸 redisTemplate이 겹쳐서 Bean 주입에 문제가 발생하였다.

@Primary로 테스트 우선으로 표시하고 아래 코드로 TestConfig를 사용하도록 지정하였다.

@ActiveProfiles("test")
@Import(RedisTestConfig.class)

또한 port를 6380으로 지정하였다.

@Bean
@Primary
public RedisConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory("localhost", 6380);
}

 

아래는 서비스 테스트 코드이다.

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = FortuneAppBackendApplication.class)
@EnableCaching
@ActiveProfiles("test")
@Import(RedisTestConfig.class)
@Transactional
public class UserServiceTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private CacheManager cacheManager;

    @Autowired
    private RedisTemplate<Object, Object> redisTemplate;

    private User savedUser;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
        redisTemplate.getConnectionFactory().getConnection().flushAll();

        savedUser = userRepository.save(
                User.builder()
                        .name("John Doe")
                        .birth(new Date(2000, 1, 1))
                        .email("john.doe@example.com")
                        .nickname("johnny")
                        .provider("Google")
                        .providerUid("1234")
                        .accessToken("access_token")
                        .refreshToken("refresh_token")
                        .build()
        );
    }

    // 회원 가입 테스트
    @Test
    void testSignUp_CachePut() {
        UserRequestDto requestDto = UserRequestDto.builder()
                .name("Jane Doe")
                .birth(new Date(2000, 1, 1))
                .email("jane.doe@example.com")
                .nickname("jane")
                .provider("Google")
                .providerUid("5678")
                .accessToken("access_token")
                .refreshToken("refresh_token")
                .build();

        UserDto userDto = userService.signUp(requestDto);

        assertThat(userDto).isNotNull();
        assertThat(userDto.getEmail()).isEqualTo("jane.doe@example.com");

        Object cachedUser = redisTemplate.opsForValue().get("user::UserService_" + userDto.getUserId());
        assertThat(cachedUser).isNotNull();
    }

    // 회원 조회 테스트
    @Test
    void testGetUser_Cacheable() {
        UserDto userDto1 = userService.getUser(savedUser.getUserId());
        assertThat(userDto1).isNotNull();

        Object cachedUser = redisTemplate.opsForValue().get("user::UserService_" + savedUser.getUserId());
        assertThat(cachedUser).isNotNull();

        UserDto userDto2 = userService.getUser(savedUser.getUserId());
        assertThat(userDto2.getUserId()).isEqualTo(userDto1.getUserId());
        assertThat(userDto2.getEmail()).isEqualTo(userDto1.getEmail());
    }

    // 회원 수정 테스트
    @Test
    void testUpdateUser_CachePut() {
        UserRequestDto updateDto = UserRequestDto.builder()
                .name("John Updated")
                .build();

        UserDto updatedUser = userService.updateUser(savedUser.getUserId(), updateDto);

        assertThat(updatedUser).isNotNull();
        assertThat(updatedUser.getName()).isEqualTo("John Updated");

        Object cachedUser = redisTemplate.opsForValue().get("user::UserService_" + savedUser.getUserId());
        assertThat(cachedUser).isNotNull();
    }

    // 회원 탈퇴 테스트
    @Test
    void testDeleteUser_CacheEvict() {
        userService.deleteUser(savedUser.getUserId());

        Optional<User> deletedUser = userRepository.findById(savedUser.getUserId());
        assertThat(deletedUser).isEmpty();

        Object cachedUser = redisTemplate.opsForValue().get("user::UserService_" + savedUser.getUserId());
        assertThat(cachedUser).isNull();
    }
}

 

여러 번의 삽질 끝에 겨우 받은 passed, 통합 테스트 완료!

2. Oauth 구현하기

먼저 Google Oauth를 붙이려고 한다.

Google Cloud Console에 접속하여 프로젝트를 생성하였다.

왼쪽 탭에서 API 및 서비스/OAuth 동의 화면으로 이동하자.

외부 선택 후 만들기를 클릭하였다.

사용자 인증 정보에서 OAuth 클라이언트 ID 만들기를 클릭하고 웹 애플리케이션을 골라준다.

승인된 리디렉션 URI에는 http://localhost:8080/login/oauth2/code/google 를 입력하였다.

 

그리고 build.gradle에 아래 implementation을 추가한다. (Spring Security)

 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

또한 생성된 Oauth 관련 id와 pw를 application.properties에 추가한다.

profile과 email을 가져오도록 설정하였는데 구글에서는 필수 입력 사항은 아니다.

# Oauth
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope = profile, email
# Thymeleaf
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.enabled=true
spring.thymeleaf.cache=false

 

코드 짜기 전 내가 헷갈려서 먼저 OAuth 로그인 순서를 정리해보았다.

1) 사용자가 /login 페이지 접근  
2) SecurityConfig에서 OAuth2 로그인 설정 (`oauth2Login()` 사용)  
3) 사용자가 Google OAuth2 버튼 클릭 → Google OAuth2 인증 페이지 이동  
4) 인증 완료 후 Redirect URI로 이동 (`http://localhost:8080/login/oauth2/code/google`)  
5) Spring Security가 `CustomOAuth2UserService.loadUser()` 호출  
6) DB에 사용자 정보 저장 후 `/auth/signup` 페이지로 이동  
7) 기존 회원이면 `/dashboard`로 리다이렉트, 신규 회원이면 추가 정보 입력 (`signup.html`) 후 회원가입 진행 

 

 

로그인 순서에 따른 코드는 아래와 같다.

1) 사용자가 /login 페이지 접근  

localhost:8080/login으로 접속한다.

2) SecurityConfig에서 OAuth2 로그인 설정 (`oauth2Login()` 사용)  

SecurityConfig는 애플리케이션의 보안 관련 정책과 규칙을 정의하는 설정이다.

config 폴더에 추가하였고 인증과 인가, 네트워크 보안, 데이터 보안 등을 포함한다.

Spring Security의 기본 폼 로그인 및 OAuth2 인증을 사용하였는데 세션을 기반으로 동작하고 있어서 이후에 csrf 부분을 수정해야할 거 같다.

REST API + JWT 로 변경해야할 거 같긴한데 현재 세션으로 개발하고 있어서 서버 부하가 커질 거 같다.

지금은 로컬에서 테스트 중이라 괜찮지만 이후에 배포를 하고 실 사용자가 생기면 REST API 방식으로 해야할 거 같다.

JWT 토큰을 이용하면 세션을 사용하지 않고 클라이언트가 토큰을 보관하고 서버는 검증하는 방식인데 Redis를 활용하는 것이 로그아웃에 좋을 거 같다.

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/auth/signup", "/css/**", "/js/**").permitAll()
                        .anyRequest().authenticated()
                )
                .csrf(AbstractHttpConfigurer::disable)
                .oauth2Login(oauth2 -> oauth2
                        .defaultSuccessUrl("/auth/signup", true)
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/") // 로그아웃 후 메인 페이지로 이동
                        .invalidateHttpSession(true)
                        .clearAuthentication(true)
                        .deleteCookies("JSESSIONID", "SESSION")
                        .addLogoutHandler((request, response, authentication) -> {
                            request.getSession().invalidate();
                        })
                        .logoutSuccessHandler((request, response, authentication) -> {
                            response.sendRedirect("/login"); // 로그아웃 성공 시 리다이렉트
                        })
                );
        return http.build();
    }
}


3) 사용자가 Google OAuth2 버튼 클릭 → Google OAuth2 인증 페이지 이동  

버튼 클릭 시 구글 클라우드 콘솔에서 등록한 내용을 기반으로 인증 페이지로 이동한다.


4) 인증 완료 후 Redirect URI로 이동 (`http://localhost:8080/login/oauth2/code/google`)  

승인된 redirection으로 등록한 URI로 이동한다.

 

5) Spring Security가 `CustomOAuth2UserService.loadUser()` 호출  

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    public CustomOAuth2UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getRegistrationId();
        String providerUid = oAuth2User.getAttribute("sub");
        String email = oAuth2User.getAttribute("email");
        String accessToken = userRequest.getAccessToken().getTokenValue();
        String name = oAuth2User.getAttribute("name");

        Optional<User> existingUser = userRepository.findByEmail(email);

        User user;
        if (existingUser.isPresent()) {
            user = existingUser.get();
            user.setAccessToken(accessToken);
        } else {
            user = User.builder()
                    .email(email)
                    .name(name)
                    .provider(provider)
                    .providerUid(providerUid)
                    .accessToken(accessToken)
                    .isRegistered(false)
                    .build();
        }

        userRepository.save(user);
        return oAuth2User;
    }
}

loadUser에서는 기본적으로 OAuth로 전달 받는 정보들을 기본으로 기존에 존재한다면 accessToken을 다시 설정하였고 새로 로그인하는 거라면 user 테이블에 저장되도록 하였다.


6) DB에 사용자 정보 저장 후 `/auth/signup` 페이지로 이동  

따로 OAuthController를 만들었다.

@GetMapping("/signup")
public String signupPage(@AuthenticationPrincipal OAuth2User oAuth2User, Model model) {
    String email = oAuth2User.getAttribute("email");

    Optional<User> userOptional = userRepository.findByEmail(email);
    if (userOptional.isPresent() && userOptional.get().getIsRegistered()) {
        return "redirect:/dashboard";
    }

    model.addAttribute("userId", userOptional.get().getUserId());
    model.addAttribute("email", email);
    return "signup";
}

/auth/signup으로 이동하고 만약 해당 이메일 기반의 유저가 등록되지 않은 상태라면 signup 화면으로 이동하게 하였다.

이미 등록된 유저면 dashboard에 redirect하게 하였다.


7) 기존 회원이면 `/dashboard`로 리다이렉트, 신규 회원이면 추가 정보 입력 (`signup.html`) 후 회원가입 진행 

현재는 프론트가 아니라 html로 작성해서 보기에 좀 별로긴 하지만 기능에 집중하자.

signup.html에서 닉네임과 생년월일을 입력받고 hidden으로 userId를 전달해서 /user/signup으로 redirect한다.

가입 된 후 dashboard로 이동된다.

유저가 존재하지 않는다면 CustomException을 throw하게 하였고 아니라면 나머지 입력사항을 추가한다.

@MethodTimeCheck
@PostMapping("/sign-up")
public ResponseEntity<UserDto> completeSignup(@Validated(SignUpGroup.class) @RequestBody UserRequestDto dto) {
    return ResponseEntity.status(HttpStatus.CREATED).body(userService.completeRegistration(dto));
}
@Caching(put = {
        @CachePut(value = "user", key = "#root.targetClass.simpleName + '_' + #result.userId")
}, evict = {
        @CacheEvict(value = "userList", key = "#root.targetClass.simpleName")}
)
public UserDto completeRegistration(UserRequestDto dto) {
    Optional<User> oauthUser = userRepository.findById(dto.getUserId());
    if (!oauthUser.isPresent()) {
        throw new CustomException(ErrorCode.USER_NOT_FOUND);
    }

    User user = oauthUser.get();
    user.completeRegistration(dto.getNickname(), dto.getBirth());
    userRepository.save(user);

    return UserDto.mapToDto(user);
}

3. GitHub Repository 정리

우선 기존 Repository를 삭제하고 다시 생성하였다.

우선은 feat로 기능 추가와 chore로 부가 기능들을 추가하였고 test로 테스트 코드를 추가하였다.

4. TODO

- 코드 리팩토링- OAuth 방식 변경

반응형