Spring Boot + Redis + TestContainers 연동

송영호·2025년 4월 3일

Spring Boot

목록 보기
1/8
post-thumbnail

개요

실무에서 개발하면서, 많은 개발자들은 성능 개선에 대해 고민해왔다.
특히, 데이터베이스 부하를 줄이고 빠른 응답 속도를 제공하는 것은 개발자에게 중요한 과제이다.
이를 해결하기 위한 대표적인 방법 중 하나가 캐싱(Caching) 이며, 그중에서도 Redis는 높은 성능과 유연한 데이터 구조를 제공하는 대표적인 인메모리 데이터 저장소이다.

이번 게시물에서는, Spring Boot에서 Redis를 연동하는 방법과 활용 예제(캐싱 적용, 데이터 저장 및 조회)뿐만 아니라, 테스트 컨테이너 및 Redis Service 테스트 시, 환경 분리까지 다뤄본다.

Redis란?

Redis(Remote Dictionary Server)는 Key-Value 형태의 데이터를 저장하는 인메모리 데이터 저장소로, 빠른 속도와 다양한 데이터 구조를 제공하는 NoSQL 데이터베이스이다.
일반적인 RDBMS와 달리 데이터를 메모리에 저장하기 때문에 매우 빠른 읽기/쓰기 성능을 제공하며, 캐싱뿐만 아니라 메시지 브로커, 세션 관리, 실시간 분석 등 다양한 분야에서 활용된다.

✅장점

1️⃣ 빠른 속도

  • 데이터를 메모리에 저장하여 읽기/쓰기 속도가 매우 빠름.
  • RDB와 비교했을 때, 수십 배 이상의 성능 차이를 보일 수 있음.

2️⃣ 다양한 데이터 구조

  • key-value 저장소뿐만 아니라, List, Set, Sorted Set, Hash, Bitmap, HyperLogLog 등 다양한 데이터 구조를 지원

3️⃣ 높은 확장성

  • Redis는 클러스터 모드를 지원하여 다중 노드로 확장 가능 ➡️ 많은 요청을 분산 처리할 수 있으며, 대용량 트래픽을 효과적으로 핸들링 (Elastic Cache 활용)

4️⃣ 설정 간편

  • 설치 및 설정이 간단하며, API도 직관적으로 제공되므로 개발자들이 쉽게 사용 가능

5️⃣ Persistence 지원

  • 메모리 기반의 저장소지만, 데이터를 디스크에 저장할 수 있는 스냅샷과 AOF(Append Only File) 방식의 영속성 옵션을 제공하여, 장애 발생 시 데이터 복구 가능

6️⃣ MessageBroker 기능

  • Pub/Sub 기능을 제공하여 메시지 큐 시스템처럼 활용 가능

❌단점

1️⃣ 메모리 의존성

  • 데이터를 메모리에 저장 ➡️ 대량의 데이터를 저장하려면 많은 메모리가 필요. 즉, RAM 비용이 증가하는 문제가 발생할 수 있음.

2️⃣ 데이터 일관성 문제

  • Redis는 속도를 우선시하는 구조이므로, 장애가 발생할 경우 일부 데이터 손실이 발생할 가능성 존재.
    특히, AOF 모드에서도 커밋 전에 장애가 발생하면 데이터 유실이 발생할 수 있음.

3️⃣ 복잡한 쿼리 지원 부족

  • RDB처럼 JOIN, GROUP BY, WHERE 조건과 같은 복잡한 쿼리 기능을 제공 ❌

4️⃣ 비효율적인 저장 방식

  • 데이터를 메모리에 저장하기 때문에, 같은 양의 데이터를 디스크 기반 저장소보다 더 많은 저장 공간을 차지

5️⃣ 클러스터 운영 난이도

  • Redis 클러스터를 구성하여 운영하는 것은 단순한 싱글 인스턴스 운영보다 복잡

구현

1️⃣ Redis 실행(Docker)

  • Redis 연동을 위해 Redis 서버를 로컬(또는 production) 환경에 구동한다.

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

  • --name redis-container : 컨테이너 이름을 redis-container로 지정
  • -d : 백그라운드 실행
  • -p 6379:6379 : 로컬 포트 6379와 컨테이너 포트 6379 연결
  • redis : 컨테이너 이미지

Production 배포 (필요 시)

  • docker-compose에 redis 추가 설정 (네트워크 설정 필요.)
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:

2️⃣ 프로젝트 설정

  • 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 # 테스트 환경 분리

3️⃣ RedisConfiguration 추가

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();
    }

}

4️⃣ Redis TestContainers 설정

  • test 시, 운영 환경의 redis와 분리 필요 ➡️ Redis용 TestContainers 환경
  • 기본 환경설정 및 설명은 앞선, 게시물 참고 ➡️ TestContainers 환경 구축

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📜

  • TestContainer 실행을 위해 상속
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@Transactional
public abstract class TestSupportWithRedis extends RedisConnectionSupport {
}

TestSupportWithOutRedis📜

  • Redis 없이 테스트할 경우(불필요한 docker container 실행)
@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;
}

5️⃣ 캐시 적용

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));
    }

6️⃣ 테스트 코드 작성

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();
    }
}
profile
BACKEND 개발자

0개의 댓글