
실무에서 개발하면서, 많은 개발자들은 성능 개선에 대해 고민해왔다.
특히, 데이터베이스 부하를 줄이고 빠른 응답 속도를 제공하는 것은 개발자에게 중요한 과제이다.
이를 해결하기 위한 대표적인 방법 중 하나가 캐싱(Caching) 이며, 그중에서도 Redis는 높은 성능과 유연한 데이터 구조를 제공하는 대표적인 인메모리 데이터 저장소이다.
이번 게시물에서는, Spring Boot에서 Redis를 연동하는 방법과 활용 예제(캐싱 적용, 데이터 저장 및 조회)뿐만 아니라, 테스트 컨테이너 및 Redis Service 테스트 시, 환경 분리까지 다뤄본다.
Redis(Remote Dictionary Server)는 Key-Value 형태의 데이터를 저장하는 인메모리 데이터 저장소로, 빠른 속도와 다양한 데이터 구조를 제공하는 NoSQL 데이터베이스이다.
일반적인 RDBMS와 달리 데이터를 메모리에 저장하기 때문에 매우 빠른 읽기/쓰기 성능을 제공하며, 캐싱뿐만 아니라 메시지 브로커, 세션 관리, 실시간 분석 등 다양한 분야에서 활용된다.
docker run --name redis-container -d -p 6379:6379 redis
--name redis-container : 컨테이너 이름을 redis-container로 지정-d : 백그라운드 실행-p 6379:6379 : 로컬 포트 6379와 컨테이너 포트 6379 연결redis : 컨테이너 이미지Production 배포 (필요 시)
version: '3.8'
services:
db:
image: mysql:8.0
container_name: beauty-care-db-container
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: db
volumes:
- db_data:/var/lib/mysql
networks:
- beauty_care_network
ports:
- 3306:3306
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
retries: 5
timeout: 5s
redis:
image: redis:latest // redis 최신 이미지
container_name: beauty-care-redis-container
restart: always
ports:
- "6379:6379" // local 6379 -> container 6379
command: [ "redis-server", "--appendonly", "yes" ]
volumes:
- redis_data:/data // redis 볼륨 설정 (불필요 시, 제거)
networks:
- beauty_care_network
app:
build:
context: .
dockerfile: Dockerfile
image: beauty-care-app
container_name: beauty-care-app-container
environment:
- SPRING_PROFILES_ACTIVE=prod
depends_on:
db:
condition: service_healthy
networks:
- beauty_care_network
ports:
- 8080:8080
networks:
beauty_care_network:
external: true
name: beauty_care_network
driver: bridge
volumes:
db_data:
redis_data:
build.gradle에 redis 관련 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.4.4'
application.yml에 redis 설정 추가
host : redis 호스트 (production 환경 배포 시, docker container name ➡️ docker network 설정 필요)
port : redis 포트 (default 6379)
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis # 테스트 환경 분리
RedisConfig📜
@EnableRedisRepositories : RedisRepository 활성화@EnableCaching : 캐싱 기능 활성화@ConditionalOnProperty : spring.cache.type=redis가 application.yml에 있을 때만 Redis 설정이 활성화 (테스트 환경 분리를 위함)RedisConnectionFactory : Redis와 연결을 관리하는 빈RedisTemplate : Redis에 데이터를 저장하고 조회.CacheManager : Spring의 캐싱 기능을 Redis를 통해 사용할 수 있도록 설정@Configuration
@EnableRedisRepositories
@EnableCaching
@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis", matchIfMissing = true) // 조건부 connect
public class RedisConfig {
// yml에서 redis 호스트와 포트를 주입받음.
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private Integer redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory(RedisStandaloneConfiguration redisStandaloneConfiguration, LettuceClientConfiguration lettuceClientConfiguration) {
return new LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration);
}
@Bean
RedisStandaloneConfiguration redisStandaloneConfiguration() {
// Standalone 모드 (단일 인스턴스 복제 클러스터 x)
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
// redis host, port 설정
redisStandaloneConfiguration.setHostName(this.redisHost);
redisStandaloneConfiguration.setPort(this.redisPort);
return redisStandaloneConfiguration;
}
@Bean
LettuceClientConfiguration lettuceClientConfiguration() {
final LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder();
return builder.build();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// connectionFactory를 활용하여, redis와 연결
redisTemplate.setConnectionFactory(redisConnectionFactory);
// key를 String 타입으로 직렬화
redisTemplate.setKeySerializer(new StringRedisSerializer());
// value Serializer
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory cf) {
// entryTtl : 캐시 만료 시간 -> TTL 지나면 자동 삭제
// serializeKeysWith(new StringRedisSerializer()) -> 캐시 키를 문자열(String)로 직렬화
// serializeValuesWith(new JdkSerializationRedisSerializer()) -> 캐시 값을 JSON 직렬화
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(60L)); // 캐시 유지 -> 1시간
return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
RedisConnectionSupport📜
@Testcontainers // TestContainers 선언
public abstract class RedisConnectionSupport extends DataBaseConnectionSupport {
protected static final GenericContainer<?> REDIS_CONTAINER;
// redis port
private static final int REDIS_PORT = 6379;
static {
REDIS_CONTAINER = new GenericContainer<>("redis:latest") // redis docker image
.withExposedPorts(REDIS_PORT) // 컨테이너에서 노출할 포트
.withReuse(Boolean.TRUE) // 컨테이너 재사용 여부
.waitingFor(Wait.forListeningPort()); // redis 실행될 때 까지 대기
REDIS_CONTAINER.start(); // 테스트 실행 시 redis 컨테이너 자동 시작
}
// 동적으로 redis property 설정
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {
// add to property Host & Port
dynamicPropertyRegistry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
dynamicPropertyRegistry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT).toString());
}
}
TestSupportWithRedis📜
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@Transactional
public abstract class TestSupportWithRedis extends RedisConnectionSupport {
}
TestSupportWithOutRedis📜
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@TestPropertySource(properties = {
"spring.cache.type=none", // 캐시 : none -> RedisConfig 로드 안함
"spring.data.redis.host=",
"spring.data.redis.port="
})
public abstract class TestSupportWithOutRedis extends DataBaseConnectionSupport {
// redis 관련 빈 mock 처리
@MockitoBean
private RedisTemplate<String, Object> redisTemplate;
@MockitoBean
private CacheManager cacheManager;
}
CodeService📜
key = "#p0" - cacheKey를 첫번째 인자(codeId)로 지정@CacheEvict - 메서드 실행 시, 정의된 캐시 삭제@Cacheable(value = RedisCacheKey.CODE, key = "#p0", cacheManager = "redisCacheManager")
@Transactional(readOnly = true)
public AdminCodeResponse findCodeById(String codeId) {
Code entity = findById(codeId);
return CodeMapper.INSTANCE.toDto(entity);
}
@CacheEvict(value = RedisCacheKey.CODE, key = "#request.codeId", cacheManager = "redisCacheManager")
public AdminCodeResponse createCode(AdminCodeCreateRequest request) {
Code parent = null;
checkExistsId(request.getCodeId());
if (ObjectUtils.isNotEmpty(request.getParentId()))
parent = repository.findById(request.getParentId())
.orElseThrow(() -> new EntityNotFoundException(Errors.INTERNAL_SERVER_ERROR));
Code entity = Code.builder()
.id(request.getCodeId())
.name(request.getName())
.sortNumber(request.getSortNumber())
.description(request.getDescription())
.isUse(request.getIsUse())
.parent(parent)
.build();
return CodeMapper.INSTANCE.toDto(repository.save(entity));
}
RedisConfigTest📜
class RedisConfigTest extends TestSupportWithRedis {
@Autowired
private RedisConnectionFactory connectionFactory;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@DisplayName("redis ping test")
@Test
void connectRedis() {
assertDoesNotThrow(() -> {
RedisConnection connection = connectionFactory.getConnection();
connection.ping();
connection.close();
}, "redis 연결 실패");
}
@DisplayName("redis put & delete")
@Test
void putAndDelete() {
// given
final String KEY = "key";
final String VALUE = "value";
putObjectSingle(KEY, VALUE);
Object objectByRedis = getSingleObjectByKey(KEY);
// value 값 일치하는지 확인
assertThat(objectByRedis).isEqualTo(VALUE);
// when
// key 삭제
redisTemplate.delete(KEY);
Object deletedObjectByRedis = getSingleObjectByKey(KEY);
// then
assertThat(deletedObjectByRedis).isNull();
}
@DisplayName("key 만료 시, null을 리턴한다.")
@Test
void whenGetExpiredKeyReturnNull() {
// given
final String KEY = "key";
final String VALUE = "value";
putObjectSingle(KEY, VALUE);
Object objectByRedis = getSingleObjectByKey(KEY);
assertThat(objectByRedis).isEqualTo(VALUE);
// when
redisTemplate.expire(KEY, Duration.ZERO);
await().atMost(Duration.ofSeconds(1))
.until(() -> ObjectUtils.isEmpty(redisTemplate.opsForValue().get(KEY)));
// then
Object expiredObject = redisTemplate.opsForValue().get(KEY);
assertThat(expiredObject).isNull();
}
@DisplayName("reids key clear")
@Test
void clearAllRedis() {
// given
final List<String> keyList = List.of("key1", "key2");
final List<String> valueList = List.of("value1", "value2");
putObjectMulti(keyList, valueList);
List<Object> objectByRedis = getMultiObjectByKey(keyList);
for (int i = 0; i < objectByRedis.size(); i++) {
assertThat(objectByRedis.get(i)).isEqualTo(valueList.get(i));
}
clearAll();
// when
Set<String> keySet = redisTemplate.keys("*");
// then
assertThat(keySet).isEmpty();
}
private void putObjectMulti(List<String> key, List<String> value) {
for (int i = 0; i < key.size(); i++) {
redisTemplate.opsForValue().set(key.get(i), value.get(i));
}
}
private List<Object> getMultiObjectByKey(List<String> keyList) {
return redisTemplate.opsForValue().multiGet(keyList);
}
private void putObjectSingle(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
private Object getSingleObjectByKey(String key) {
return redisTemplate.opsForValue().get(key);
}
private void clearAll() {
ScanOptions scanOptions = ScanOptions.scanOptions().match("*").count(100).build();
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection().scan(scanOptions)) {
while (cursor.hasNext()) {
redisTemplate.delete(new String(cursor.next()));
}
}
}
}
CacheManagerTest📜
class CacheManagerTest extends TestSupportWithRedis {
@Autowired
private CacheManager cacheManager;
@Autowired
private CodeService codeService;
@MockitoBean
private CodeRepository codeRepository;
@DisplayName("cache test")
@Test
void findCodeWithCache() {
final String key = "all";
Code code
= buildCode("sys", "system", 1, new ArrayList<>(), "", Boolean.TRUE);
Mockito.doReturn(Optional.of(code)).when(codeRepository).findByParentIsNull();
// 최초 호출 -> DB 접근 (캐시 저장)
codeService.findAllCode();
verify(codeRepository, times(1)).findByParentIsNull();
// 캐시 저장 여부 확인
Cache cache = cacheManager.getCache(RedisCacheKey.CODE);
assertThat(cache).isNotNull();
Object cachedValue = cache.get(key, Object.class);
assertThat(cachedValue).isNotNull();
// 캐시에서 조회
codeService.findAllCode();
verify(codeRepository, times(1)).findByParentIsNull(); // repository 접근 count 그대로 1
assertAll(
() -> assertThat(cache.get(key)).isNotNull(),
() -> cache.evict(key),
() -> assertThat(cache.get(key)).isNull()
);
// 캐시 삭제 후 호출 -> count 증가
codeService.findAllCode();
verify(codeRepository, times(2)).findByParentIsNull();
}
@DisplayName("코드 수정 => 관련 캐시 삭제")
@Test
void whenUpdateCodeThenClearCache() {
// given
final String codeId = "sys";
final String key = "all";
Code code
= buildCode(codeId, "system", 1, new ArrayList<>(), "", Boolean.TRUE);
doReturn(Optional.of(code)).when(codeRepository).findByParentIsNull();
doReturn(Optional.of(code)).when(codeRepository).findById(codeId);
// 캐시 저장
codeService.findAllCode();
codeService.findCodeById(codeId);
Cache cache = cacheManager.getCache(RedisCacheKey.CODE);
// check cache
assertThat(cache).isNotNull();
assertThat(cache.get(codeId)).isNotNull();
assertThat(cache.get(key)).isNotNull();
// when
codeService.updateCode(codeId, AdminCodeUpdateRequest.builder().isUse(Boolean.TRUE).build());
// then : update -> 캐시 삭제
assertThat(cache.get(codeId)).isNull();
assertThat(cache.get(key)).isNull();
}
private Code buildCode(String id,
String name,
Integer sortNumber,
List<Code> children,
String description,
Boolean isUse) {
return Code.builder()
.id(id)
.name(name)
.sortNumber(sortNumber)
.children(children)
.description(description)
.isUse(isUse)
.build();
}
}