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 방식 변경
'dev > 프로젝트' 카테고리의 다른 글
[프로젝트] Docker-Compose & Docker 적용 (6) | 2025.01.21 |
---|---|
[프로젝트] On-Premise & Cloud (16) | 2025.01.21 |
[프로젝트] Custom Exception & Redis (4) | 2025.01.19 |
[프로젝트] Redis Image & JPQL 적용 (6) | 2025.01.16 |
[프로젝트] JPA 영속성 Context & AOP (2) | 2025.01.16 |