회사에서 Spring Boot를 주로 사용하고 있지만 이제는 업무 기반의 쿼리 개발이 많아지다보니 Spring 기술을 사용할 일이 많이 없어졌습니다. (쿼리 데이터 출력 -> 클라이언트로 서빙하는 작업의 반복..)
요즘은 스터디에서 진행 중이던 프로젝트도 종료가 되어 여유가 생겨 예전에 사용해봤던 캐싱 기능을 Redis에 접목시켜 보기로 했습니다.
현재 개발 환경은 다음과 같습니다.
- Spring Boot 2.7.5
- jdk11
- Gradle
- Mybatis
- MySQL 8
캐시란
캐시는 자주 사용되는 데이터나 값을 저장해 놓은 임시 장소를 말합니다. 캐시는 데이터 저장소 유형 중 하나로 반복되는 데이터를 돌려주는 상황에 효율적으로 사용할 수 있습니다. 캐시를 사용함으로써 반복되는 데이터의 접근을 피해 DB 서버로부터의 부하를 방지할 수 있는 것이죠.
아래는 최근 코드드림에서 개발한 교회 관리 서비스인데요. 관리자 서비스에서 교회 정보를 저장하고, 모바일앱에서는 api 서버를 통해 교회 정보를 조회하는 구조입니다. 이 때 교회 정보는 변경이 자주 일어나지 않기 때문에 캐싱 전략을 사용할 수 있었습니다.
이런 전략으로 하는 캐싱의 주요 대상은 다음과 같습니다.
- 반복적인 데이터 호출되는 경우
- 데이터 변경 주기가 오래 되는 경우
반면 데이터 변경이 실시간으로 이루어져야하는 경우는 적합하지 않을 수 있습니다.
그럼 이제 Spring Boot와 Redis를 이용해 캐싱을 구현해보겠습니다.
구현 시나리오는 위 화면에 보이는 메뉴를 캐싱해서 사용하게 되는 경우로 가정해보겠습니다.
서두에 언급했듯 본 포스팅은 MyBatis를 이용한 예제이나 JPA 방식에서도 큰 차이는 없습니다.
1. 의존성 추가
// Redis
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis'
// Embedded Redis
implementation group: 'com.github.kstyrc', name: 'embedded-redis', version: '0.6'
2. Redis 정보 설정
application.properties
spring.redis.host=레디스 서버 주소
spring.redis.password=레디스 접속 비밀번호
spring.redis.port=6379
3. 캐시 기능 활성화
Spring Boot에서는 캐시 기능을 추상화시켜 어노테이션을 추가하는 것만으로 캐시 기능을 사용할 수 있게 했습니다.
@EnableCaching 어노테이션을 main 메서드가 선언된 클래스(@SpringBootApplication이 선언된)에 추가합니다.
@EnableCaching
@SpringBootApplication
public class BasicFrameworkApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(BasicFrameworkApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(BasicFrameworkApplication.class, args);
}
}
4. Redis 설정
RedisConfig 파일을 생성하고 연결을 위한 기본 설정을 추가합니다.
4-1) LettuceConnectionFactory는 Redis client 중 하나로 동기,비동기 방식을 지원해 non-blocking을 지원한다는 특징이 있습니다. 이 설정으로 Redis와의 연결을 진행합니다.
4-2) CacheManager를 빈으로 등록합니다.
RedisCacheManager를 등록해주게 되는데 이를 통해 Spring에서는 캐시를 로컬 캐시가 아닌 Redis에 저장하게 됩니다.
이 때 설정을 통해 데이터의 저장 규칙을 정해주게 됩니다.
- serializeKeysWith : 데이터의 키를 직렬화할 때 사용할 규칙을 설정합니다.
- serializeValuesWith : 데이터의 값을 직렬화할 때 사용할 규칙을 설정합니다.
직렬화에 주로 사용되는 방법을 나열해보면 아래와 같은데요. (직렬화란 객체 데이터를 바이트 형태로 반환하는 기술을 말합니다)
- Jackson2JsonRedisSerializer : 클래스 타입을 지정해 JSON 형태로 저장합니다.
- StringRedisSerializer : String 값을 저장합니다.
- GenericJackson2JsonRedisSerializer : 클래스 지정없이 모든 클래스 타입을 JSON 형태로 저장합니다.
키를 직렬화 할때는 주로 SringRedisSerializer를 사용합니다. 본 예제에서는 단순히 Redis에 캐시값을 저장하는 것이 목표였기 때문에 값을 직렬화할 때 GenericJackson2JsonRedisSerializer를 사용했습니다. 여기서 내용을 다루지는 않겠지만 GenericJackson2JsonRedisSerializer에는 문제가 되는 부분이 있어 사용시 주의하셔야 합니다.(더보기 참고)
GenericJackson2JsonRedisSerializer 문제점
GenericJackson2JsonRedisSerializer를 사용하게 되면 클래스의 지정없이 모든 데이터를 직렬화하여 사용할 수 있는 장점이 있다. 하지만 @class라는 키로 클래스 타입이 포함되어 저장되는 구조를 갖고 있어 데이터를 가져올 때 동일한 타입의 DTO가 있어야 한다는 것입니다.
예를 들어 MSA 애플리케이션 구조의 환경이 있다고 하면 A _App과 B_App에서는 해당 데이터를 받기 위한 동일한 타입의 DTO가 같은 패키지 경로로 존재해야 합니다.
같은 문제에 대한 대안은 이 글에서 확인할 수 있었습니다.
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.port}")
private int port;
// 캐시 만료 시간
public static final int DEFAULT_EXPIRE_SEC = 60;
@Bean
public LettuceConnectionFactory lettuceConnectionFactory() {
final RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(
host, port);
redisStandaloneConfiguration.setPassword(RedisPassword.of(password));
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(DEFAULT_EXPIRE_SEC))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration).build();
}
}
5. Controller ~ Dao 생성
MenuDto
@Data
public class MenuDto implements Serializable {
private int menuSeq;
private String menuName;
}
HomeController
@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
private final HomeService homeService;
@RequestMapping(value = "/getMenuList", method = {RequestMethod.POST})
public @ResponseBody MsgEntity getMenuList() {
log.info(":: 메뉴 조회 ::");
List<MenuDto> menuList = homeService.getMenuList();
return MsgEntity.builder()
.message(StatusEnum.OK)
.result(menuList).build();
}
@RequestMapping(value = "/getMenuInfo", method = {RequestMethod.GET})
public @ResponseBody MsgEntity getMenuInfo(@RequestParam int menuSeq) {
log.info(":: 메뉴 조회 ::");
MenuDto menuInfo = homeService.getMenuInfo(menuSeq);
return MsgEntity.builder()
.message(StatusEnum.OK)
.result(menuInfo).build();
}
}
HomeService
public interface HomeService {
public List<MenuDto> getMenuList();
public MenuDto getMenuInfo(int menuSeq);
}
HomeService를 구현한 getMenuList() 와 getMenuInfo() 를 캐시 대상으로 정했습니다.
getMenuList()는 메뉴 리스트를 조회하고 getMenuInfo()는 특정 메뉴의 정보를 조회하는 메소드입니다. 해당 클래스에 선언된 @CacheConfig 어노테이션을 통해 캐시 관련 설정을 공유할 수 있습니다. HomeServiceImpl 클래스의 캐시명은 menu로 통일됩니다.
@Cacheable은 실제 캐시를 적용하는 어노테이션입니다. 이것을 통해 캐시된 데이터가 없는 경우 DB를 조회하고(캐시에 등록), 있다면 캐시 데이터를 사용하게 됩니다.
key는 캐시의 키를 정의합니다. getMenuList는 menu::all 키로 캐시가 생성되며, getMenuInfo는 menu::#menuSeq 라는 키로 캐시가 생성됩니다. 여기 #menuSeq는 파라미터값을 의미하기 때문에 값이 달라질 때마다 키값이 변경됩니다.
cacheManager는 앞서 Redis 설정에서 생성한 CacheManager를 지정합니다.
이외에도 value, allEntries, condition 등과 같은 속성이 존재합니다.
HomeServiceImpl
@Service
@CacheConfig(cacheNames = "menu")
@RequiredArgsConstructor
public class HomeServiceImpl implements HomeService {
private final HomeDao homeDao;
@Cacheable(key = "'all'", cacheManager = "cacheManager")
@Override
public List<MenuDto> getMenuList() {
return homeDao.getMenuList();
}
@Cacheable(key = "#menuSeq", cacheManager = "cacheManager")
@Override
public MenuDto getMenuInfo(int menuSeq) {
return homeDao.getMenuInfo(menuSeq);
}
}
HomeDao
public interface HomeDao {
public List<MenuDto> getMenuList();
public MenuDto getMenuInfo(int menuSeq);
}
homeMapper
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.app.basic.domain.home.dao.HomeDao">
<select id="getMenuList" resultType="com.app.basic.domain.home.dto.MenuDto">
<![CDATA[
SELECT
MENU_SEQ
, MENU_NAME
FROM MENU_INFO
]]>
</select>
<select id="getMenuInfo" parameterType="int" resultType="com.app.basic.domain.home.dto.MenuDto">
<![CDATA[
SELECT
MENU_SEQ
, MENU_NAME
FROM MENU_INFO
WHERE MENU_SEQ = #{menuSeq}
]]>
</select>
</mapper>
6. 테스트
이제 컨트롤러를 호출해보겠습니다.
최초 호출시에는 캐시 데이터가 없기 때문에 DB 조회후 데이터를 반환하게 됩니다. 하지만 이후부터는 DB를 거치지 않는 것이 확인됩니다.
그리고 캐시 만료시간인 60초가 지난 후에는 다시 DB를 조회해 반환하는 것을 알 수 있습니다.
Redis에 접속해 데이터가 저장된 것을 확인해보겠습니다.
캐시명 설정을 통해 menu::all 로 데이터가 저장이 되었고 조회시에는 직렬화된 메뉴 데이터가 확인됩니다. 데이터를 한글로 보고 싶은 경우 redis-cli --raw로 redis에 접속할 수 있습니다.
여기까지 Spring Boot와 Redis를 이용한 캐싱 구현을 테스트해봤습니다. 본 예제는 단순히 데이터를 조회하는 용도로만 사용했기에 내용이 간략합니다. 하지만 Spring cache를 통해 캐시를 갱신하고 삭제하고 조건에 따라 캐시를 적용할 수도 있는 등 다양한 활용이 가능합니다.
저희는 스터디를 통해 글을 기록하고 있습니다. 피드백은 언제나 환영입니다 :)
참고문서