0. 개요
안녕하세요. 지난 글에서 발생했던 문제에 대해서 얘기해보려고 합니다.
저번 글에 마지막 글에 보여드렸던 사진입니다. Spring Security를 적용해 본 분이라면 정말 익숙한 메소드죠? 메소드 명 그대로, 유저 정보를 로드하는 메소드입니다. 문제는 뒤에 있는 '병원'이 어떠한 요청을 할 때마다 2번의 조회쿼리가 발생한다는 거에요. 사실 2번의 단순한 조회쿼리가 성능에 큰 영향을 미칠까? 라고 한다면 크리티컬 한 부분은 아니라고 대답하겠지만, 사용자가 많은 서비스라면 이러한 부분에서도 성능 개선이 가능하다고 생각했습니다.
그럼, 실제로 모든 요청에 2번 조회쿼리가 나가는지 확인해볼까요? 병원으로 아무 요청이나 보내보겠습니다.
제가 위에서 구현한 코드 그대로 회원을 먼저 조회하고, 병원을 조회하는 모습입니다.
이를 어떻게 개선할 수 있을까 생각해보았는데 , 캐싱을 사용할 수 있겠다고 생각했어요. 회원들의 정보는 자주 바뀌지 않을 것이라 생각했고, 특히 병원에 대한 정보는 더더욱 그랬습니다.
무엇보다도, redis를 단순히 RefreshToken 저장소로만 쓰기는 너무 아깝잖아요?
1. Redis?
그럼, Redis가 뭔지부터 간단히 알아보고 넘어가겠습니다.
Redis는 본래 "Remote Dictionary Server"의 약자로, 메모리 기반의 데이터 저장소입니다. 디스크에 저장하는 전통적인 데이터베이스와 달리, Redis는 데이터를 RAM에 저장하기 때문에 빠른 속도를 자랑해요. 주로 캐시로 사용되지만, 키-값 쌍뿐만 아니라 리스트, 셋, 해시 등 다양한 데이터 구조를 지원해 다양한 활용이 가능합니다.
간단하게 말하자면, Redis는 빠르고 유연하게 데이터를 다룰 수 있는 메모리 저장소입니다.
설치와 관련된 부분에서는 잘 설명된 글들이 많으니 넘어가겠습니다. 다만, Window를 사용하시는 분이라면 현재의 최신 버전이 아닌 3.x 버전을 받아야 해요. '인파' 님의 티스토리에 너무 잘 설명되어 있으니 참고하시면 좋을 것 같습니다.
설치를 완료하셨다면요, 따로 설정을 변경하지 않으셨다면 C:\Program Files\Redis 주소에 redis-cli로 cmd창 실행이 가능해요.
실행하셔서요, 위와 같이 동작한다면 성공적으로 설치가 되셨습니다. 위의 사진을 보니 Redis는 기본 포트로 6379를 사용한다고 짐작할 수 있겠네요.
2. Redis 사용 설정하기
이제 스프링에서 사용을 해봐야겠죠? 우선 Build.gradle에 redis관련 의존성을 불러오겠다고 설정해볼게요.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
이제, Spring 설정파일에서 redis에 대한 정보를 입력해 볼게요. 우선 설정파일부터 작성해 보겠습니다.
제 yml파일 중 일부인데요, 따로 비밀번호를 걸지 않으셨다면 password부분은 생략하셔도 되겠습니다.
time-to-live는 말 그대로 data의 expired 시간을 정해주는 건데요, 초 단위라는 것을 인지하시고 본인 프로젝트에 맞게 적용하시면 되겠습니다.
이제 Redis 관련 설정파일만 코드로 작성해 주면 사용을 위한 준비가 끝날 것 같습니다.
아래부터는 혹시 복사하실 분들도 있을 것 같아서 코드블록으로 작성할게요.
우선, RedisConfig라는 이름의 파일을 하나 만들고 아래와 같이 작성하겠습니다.
@EnableRedisRepositories
@Configuration
public class RedisConfig {
@Value("${spring.cache.redis.host}")
private String host;
@Value("${spring.cache.redis.port}")
private int port;
@Value("${spring.cache.redis.password}")
private String password;
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration standaloneConfiguration = new RedisStandaloneConfiguration(host, port);
standaloneConfiguration.setPassword(password); // 저는 비밀번호가 있기에 작성했지만, 없다면 생략하세요
return new LettuceConnectionFactory(standaloneConfiguration);
}
@Bean
public RedisTemplate<String,String> redisTemplate(){
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer()); // 키-밸류 직렬화 방식은 프로젝트에 맞게 설정하세요
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
위에서부터 간략하게 설명해 보겠습니다.
@EnableRedisRepositories는 Spring Data Redis의 레포지토리를 활성화하는 역할을 합니다.
RedisConnectionFactory는 Redis와의 연결을 관리하는 빈입니다.
RedisStandaloneConfiguration는 RedisConnection을 설정하는 데 사용되는 클래스에요. 이를 통해 호스트, 포트, 패스워드 등 기본적인 설정을 할 수 있습니다.
Spring에서 사용할 수 있는 Redis의 Client는 크게 Jedis, Lettuce 2가지가 있는데요, 저희는 그중에서 Lettuce를 사용할게요. Jedis보다 성능면에서 뛰어나서 Lettuce가 기본 설정이라고 합니다.
다음으로, RedisTemplate은 Redis 연산을 위해서 필요한 빈인데요, 위에서 설정한 ConnectionFactory 빈을 주입받고, 직렬화 방식을 설정할 수 있습니다.
저는 RefreshToken을 삽입하고 삭제할 때 단순한 String 키-밸류를 사용하므로 StringRedisSerializer로 설정하였는데요, 이 부분도 프로젝트에 맞게 설정하시면 될 것 같습니다. 아래에 작성하겠지만, 이 글에서는 캐시를 위한 빈을 따로 설정할 것이므로 크게 중요한 부분은 아니에요.
레디스 다운받고 간단한 설정 하는데도 벌써 지치는 느낌이네요. 만약 RefreshToken관련된 DB 연산만 하실 거라면 여기까지만 작성하시면 충분합니다. 하지만, 저희는 Cache를 사용해 볼 거 기 때문에 조금 더 작성해 볼게요.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
//타입 안전성 검증
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator
.builder()
.allowIfSubType(Object.class) // Object 클래스의 하위 타입을 허용
.build();
// 객체와 JSON 간의 변환을 관리
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); // Time API 지원
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 알려지지 않은 프로퍼티(필드)가 있을 때 실패하지 않고 무시
objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); // 기본 타이핑 활성화 및 다형성 타입 검증 설정
objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); // enum을 문자열로 쓰기
objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); // 문자열을 enum으로 읽기
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); // 직렬화에 사용할 Serializer
// 캐시의 기본 설정
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues() // null 값은 캐싱X
.entryTtl(Duration.ofHours(1L)) // 캐시 유효 시간 설정(적절하게 바꿔서 사용하세요)
.computePrefixWith(CacheKeyPrefix.simple()) // 캐시 키의 접두어를 간단하게 계산 EX) UserCacheStore::Key 의 형태로 저장
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) // 키의 직렬화 방식 설정
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer)); // 값의 직렬화 방식 설정
// 캐시 이름 설정 담아주기(적절하게 바꿔서 사용하세요)
Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
redisCacheConfigurationMap.put("UserCacheStore", redisCacheConfiguration);
redisCacheConfigurationMap.put("HospitalInfo", redisCacheConfiguration);
redisCacheConfigurationMap.put("HospitalHoursInfo", redisCacheConfiguration);
redisCacheConfigurationMap.put("DoctorInfo", redisCacheConfiguration);
// RedisCacheManager 리턴
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory) // Redis 연결 설정
.cacheDefaults(redisCacheConfiguration) // 기본 캐시 설정
.withInitialCacheConfigurations(redisCacheConfigurationMap) // 초기 캐시 설정을 맵으로 전달
.build(); // RedisCacheManager 생성
}
글이 너무 길어져 지루하실 것 같아 주석으로 설명을 달아봤습니다. 하나만 짚고 넘어가자면
GenericJackson2JsonRedisSerializer인데요, 이 Serializer는 모든 객체를 직렬화해 줘요.
간단한 StringRedisSerializer와는 다르게, GenericJackson2JsonRedisSerializer는 객체의 메타데이터도 함께 저장합니다.
이렇게 하면 역직렬화할 때 원래의 객체 타입을 더 정확하게 복원할 수 있어요.
여기까지 정말 힘들게 왔는데요, 사용은 쉽게 하실 수 있어요. 그전에 정말 마지막으로!
스프링부트 실행부분에 @EnableCaching을 붙여서, 캐싱을 사용한다고 말해줍시다.
3. 캐싱해 보기
자, 이제 캐싱을 사용해 보겠습니다. 사용하는 건 정말 간단해요.
이제, 캐싱을 적용할 메소드에 @Cacheable만 붙여주면 끝이에요.
@Cacheable은 클래스에 사용할 수도 있지만, 주로 메소드에 적용되는 어노테이션으로, 메소드의 실행 결과를 캐시에 저장하는 역할을 합니다. 이후에 같은 입력값으로 해당 메소드를 호출하면 실제로 메소드를 실행하는 대신에 캐시에 저장된 결과를 반환해요.
다만, @Cacheable의 설정에서 key-value는 키-값 쌍으로 저장하겠다! 가 아닙니다.
value는 Redis에서 사용하는 캐시 그룹 즉, 제가 아까 RedisConfig에서 redisCacheConfigurationMap에 넣어주었던 이름을 의미하구요, key는 캐시 그룹에서 개별 데이터를 어떻게 식별할지에 대한 설정입니다. 그러니, key는 고유한 값을 넣어줘야겠죠?
레디스에 잘 저장이 되는지 볼까요? 요청을 보내게 되면요,
제가 설정한 prefix, key, value대로 잘 저장이 되어있네요. 설정에 비해 사용은 쉬웠습니다.
잘 저장이 되었다는 말은요, 캐시 히트가 발생했을 때?
회원 조회에 대한 쿼리는 발생하지 않았습니다. 훌륭하네요!
그런데, 회원이 정보를 수정하면 어떻게 될까요? DB에서 조회해 온 것이 아니라, Redis에 저장되어 있는 정보를 사용할 것이므로 큰 문제가 될거에요. 그래서, 정보 수정과 같은 이벤트가 발생했을 때는 캐시를 제거해줘야 합니다. 이 부분도 간단하게 적용해 볼게요.
@CacheEvict(value = "UserCacheStore", key = "#loginId")
@Transactional
public void modifyMember(String loginId, MemberInfoUpdateRequest memberInfoUpdateRequest, MultipartFile profileImg) {
Member member = findByLoginId(loginId);
member.update(memberInfoUpdateRequest);
// 기존에 등록 된 사진이 없고 새로 등록 시킬 때
if(Objects.isNull(member.getProfileImg()) && Objects.nonNull(profileImg)) {
String profileAddress = amazonS3Service.uploadFile(profileImg);
member.updateFileAddress(profileAddress);
return;
}
// 기존에 등록 된 사진이 있고 수정할 때
if(Objects.nonNull(member.getProfileImg()) && Objects.nonNull(profileImg)) {
String profileAddress = member.getAddress();
amazonS3Service.deleteFile(profileAddress);
String newProfileAddress = amazonS3Service.uploadFile(profileImg);
member.updateFileAddress(newProfileAddress);
}
}
제 코드의 회원 정보 수정 부분입니다. 로직적으로 수정한 부분은 없구요, @CacheEvict(value = "UserCacheStore", key = "#loginId")를 통해 어떤 캐시 그룹의, 키를 가진 값을 삭제할지를 알려주면 되겠습니다.
@CacheEvict도 클래스에 붙일 수 있지만, 주로 메소드에 적용되는 어노테이션으로 지정된 캐시의 항목을 제거하는 역할을 해요.
4. 마무리
이 글을 보시고 다른 부분에 대해서 캐싱을 해보시려고 하면, 수많은 직렬화와 역직렬화 문제에 당면하실 거예요. 그래서
제가 다른 정보들을 캐싱하면서 부딪힌 문제와 해결법에 대해서 작성하려 했었는데, 생각보다 글이 너무 길어져서 다음 글에 이어서 작성해야 할 것 같습니다. 글이 길어지니 지루해지고 글도 중구난방이 된 것 같네요.
글을 작성하다 보니 글 작성과 더불어서 실력적인 부분에서도 부족한 부분이 많이 드러나는 것 같습니다. 그래도 최대한 제가 아는 선에서 자세하게 쓰려고 노력했으니 이 글이 캐싱을 처음 시작해 보시려는 분들께 도움이 되었으면 좋겠습니다.
'SPRING' 카테고리의 다른 글
[SPRING + JPA + SECURITY] 너무 다른 2종류의 회원을 어떻게 설계할까? (2) | 2023.08.21 |
---|---|
[SPRING + JPA + Thymeleaf] 게시판에 해시태그 기능을 구현해보자 (7) | 2023.04.08 |