Dot Programming/Spring

[Spring] 어디서든 실행 가능한 Redis 통합 테스트 환경 구축하기 (TestContainers)

루지 2022. 4. 19. 19:00

    성능 향상을 위해 Redis 캐싱 기능을 도입하면서 이에 대한 테스트 코드도 작성하게 되었다. 기존에 미리 Jenkins으로 자동 테스트 및 배포 환경을 구축해놨었던지라 Redis도 이에 적응시키기 위해 어디서든 실행 가능한 Redis 통합 테스트 환경을 구축해보았다.

     

    다양한 Redis 테스트 환경 설정 방법

    테스트 DB 환경을 설정하는 방법이 다양한 것처럼 Cache 환경 설정 하는 방법도 여러가지가 있다.

     

    1. 내장된 인메모리 Cache 사용하기

    스프링 캐시는 아무 설정을 하지않으면 기본적으로 ConcurrentMapCache 캐시를 사용한다. 이는 Redis와 멱등성이 깨지기 때문에 사용하기 적절하지 않다. 

    • 환경에 따라 멱등성이 깨진다

     

    2. 로컬에 Redis 생성해서 사용하기

    Redis 로컬 6379, 데브 6380, 테스트 6399 이렇게 캐시 인스턴스를 나눠서 사용하는 방법이 있다. 그러나 이 방식은 다른 환경으로 이동할 때마다 매번 설치하고 설정해야 하기 때문에 번거롭다.

    • 매 번 직접 설치하고 설정해야 하므로 생산성이 떨어진다.

     

    3. 임베디드 Redis 라이브러리 사용하기

    이 방법은 제일 최선책이었지만 container기술 이후로 더 이상은 아니다. container에 비해 덜 경량화된 방식일 뿐더러 계속되는 버전 변경점에 취약하다는 단점이 있다. 실제로 mysql embedded 라이브러리에 들어가보면 deprecated 되었고 container 라이브러리인 testcontainers로 추천하고 있다.

     

    또한 redis embedded 라이브러리로는 kstyrc/embedded-redis, ozimov/embedded-redis가 있는데, kstycs는 마지막 커밋이 4년전으로 종료되었고 그나마 ozimov는 마지막 커밋이 2년전으로, 사실상 해당 오픈소스들은 활동이 중단되었다고 봐도 무방하다.

    • 컨테이너 기술 이후로 더 이상 사용하지 않는다.

     

    4. docker로 직접 Redis 띄워서 사용하기

    임베디드보다 더 가벼운 방식인 docker를 이용하면 더 원활한 테스트 환경 구축이 가능하다. redis 이미지를 docker로 띄워서 이용하면 된다. 컨테이너 이미지를 띄우는 방법은 docker-compose 파일이나 직접 이미지를 빌드하여 docker 컨테이너 명령어를 실행하는 방법이 있다. 자동으로 테스트 환경을 위한 redis 이미지를 띄우고 종료하는 것은 스크립트로 작성해도 되지만 여전히 조금 번거로움이 존재한다. 

    • docker로 띄우기 때문에 어느 환경에서든 쉽게 테스트를 동작할 수 있다. 하지만 자동화를 이루려면 스크립트 작성이나 docker-compose를 작성해줘야 하므로 아직도 테스트 실행에 약간 번거로움이 존재한다.

     

    5. TestContainers 사용하기

    사실 모든 건 이 라이브러리를 위한 빌드업이었다. TestContainers를 사용하면 위에서 말한 문제점을 다 해결할 수 있다. Testcontainers는 docker 컨테이너를 외부 설정 없이 Java 언어만으로 구축할 수 있는 오픈소스 라이브러리이다. 이를 사용하면 어떠한 사전 준비도 필요없이 단순 테스트 실행만으로 redis 컨테이너가 실행되어 테스트를 진행하고 모든 테스트가 끝나면 컨테이너도 자동으로 종료된다. 테스트 환경 이외에 다른 환경에 존재하는 Redis 캐싱 메모리와 충돌되는 문제도 없다. 또한 컨테이너로 동작하기 때문에 어느 환경에서든 바로 실행이 가능하다. 유일한 단점은 실행 시간이 좀 느리다는 것이다.

     

    어디서든 실행 가능한 Redis 통합 테스트 환경 구축하기 (TestContainers)

    TestContainers에서 Redis를 직접적으로 지원하지 않지만 GenericContainer 생성자에 특정 DockerImage를 띄우면 사용할 수 있다. 이를 이용해 Redis도 사용할 수 있다. 구글링 해보면 다양한 방법이 존재하지만 나는 기존 환경에 있는 설정 값을 오버라이딩하여 사용할 생각이다. 해당 방법을 순차적으로 정리하면 다음과 같다.

    1. testcontainers로 redis 이미지 실행
    2. 테스트환경에서 실행된 redis 컨테이너 host, port 값 추출 
    3. @DynamicPropertSource를 사용해 동적으로 application.yml 설정값 오버라이딩 (spring.redis.host, sping.redis.port)
    4. /config/RedisConfig.java 파일에서 오버라이딩된 값으로 RedisTemplate 빈 생성

     

    1. 스프링 프로젝트 시작하기 

    1. build.gradle 의존성 추가

    먼저 testcontainers와 redis 의존성을 추가해준다.

    // testcontainers
    testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
    testImplementation "org.testcontainers:junit-jupiter:1.16.3"
    
    // redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

     

    2. Dockerfile, docker-compose.yml 생성

    스프링 앱과 Redis를 간단하게 Docker로 띄워준다.

    Dockerfile 생성

    FROM openjdk:11
    VOLUME /tmp
    ARG JAR_FILE=./build/libs/*.jar
    COPY ${JAR_FILE} app.jar
    ENTRYPOINT ["java", "-jar", "app.jar"]

     

    docker-compose.yml 생성

    간단하게 redis와 spring에 대한 docker-compose.yml 파일을 생성해준다. 

    version: '3.1'
    
    services:
    
      redis:
        image: redis
        container_name: rediscache
        ports:
        - 6380:6379
        networks:
          - spring-net
    
      spring-testcontainers-app:
        image: spring-testcontainers-app
        container_name: spring-testcontainers-app
        build: .
        restart: always
        environment:
          REDIS_HOST: rediscache
          REDOS_PORT: 6379
        ports:
          - 8080:8080
        depends_on:
          - redis
        networks:
          - spring-net
    
    networks:
      spring-net:

     

    application.yml 설정

    • ${REDIS_~} 설정은 docker로 구동할 때 동작하는 설정이다. 
    • 로컬 Redis 디폴트 값은 localhost, 6379로 구동된다.
    spring:
      redis:
        host: ${REDIS_HOST:localhost}
        port: ${REDIS_PORT:6379}

     

    2. 장바구니 로직 간단하게 작성하기

    장바구니에 아이템을 저장하는 캐싱 로직을 간단하게 작성해보자. 먼저 RedisConfig 파일을 생성한다. 

     

    1. RedisConfig 클래스 생성

    redis host, port는 yml에서만 설정해도 정상적으로 동작한다. 해당 값을 @Value를 불러와 ConnectionFactory에 주입하는 이유는 TestContianers에서 실행된 Redis 컨테이너의 host와 post 값을 동적으로 오버라이딩하여 주입시킬 것이기 때문이다. 그리고 운영환경에서 ElasticCache 엔드포인트를 주입하기 위해서도 필요하다.

    @Configuration
    @EnableCaching
    public class RedisConfig {
    
    	@Value("${spring.redis.host}")
    	private String host;
    
    	@Value("${spring.redis.port}")
    	private int port;
    
    	@Bean
    	public RedisConnectionFactory redisConnectionFactory(){
    		// this(new RedisStandaloneConfiguration(host, port), new MutableLettuceClientConfiguration());
    		return new LettuceConnectionFactory(host, port);
    	}
    
    	@Bean
    	public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
    		GenericJackson2JsonRedisSerializer genericJackson2JsonSerializer = new GenericJackson2JsonRedisSerializer();
    		RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    		redisTemplate.setConnectionFactory(redisConnectionFactory);
    		// key, value
    		redisTemplate.setKeySerializer(new StringRedisSerializer());
    		redisTemplate.setValueSerializer(genericJackson2JsonSerializer);
            
    		// hash key, value 
    		redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    		redisTemplate.setHashValueSerializer(genericJackson2JsonSerializer);
            
    		return redisTemplate;
    	}
    
    }

     

    2. 장바구니 비즈니스 로직 생성

    아이템을 1분동안 Redis 캐시에 저장하는 비즈니스 로직을 생성한다.

    • ItemDto 클래스: name, price, quantity를 담는 데이터 클래스를 생성한다. Redis는 바이트코드로 저장되기 때문에 Serializable을 구현해줘야 한다.
    • CartDao 클래스: 장바구니를 담는 Repository 클래스를 생성한다. RedisConfig에 빈을 등록한 RedisTemplate을 사용하여 캐시에 저장하고 조회하는 로직을 만든다.
    • CartController 클래스: 비즈니스 로직을 실행하는 API(GET, POST "/{id}/cart")를 생성한다.
    // /dto/ItemDto.java
    @Data
    @NoArgsConstructor
    public class ItemDto implements Serializable {
    	private String name;
    	private int price;
    	private int quantity;
    
    	@Builder
    	public ItemDto(String name, int price, int quantity) {
    		this.name = name;
    		this.price = price;
    		this.quantity = quantity;
    	}
    }
    
    // /dao/CartDao.java
    @Repository
    @RequiredArgsConstructor
    public class CartDao {
    
    	private final RedisTemplate<String, Object> redisTemplate;
    
    	public void addItem(ItemDto itemDto, Long customerId){
    		String key = KeyGen.cartKeyGenerate(customerId); // "cart:{customerId}"
    
    		redisTemplate.opsForValue().set(key, itemDto);
    		redisTemplate.expire(key, 1, TimeUnit.MINUTES);
    	}
    
    	public ItemDto findById(Long customerId){
    		String key = KeyGen.cartKeyGenerate(customerId); // "cart:{customerId}"
    
    		return (ItemDto) redisTemplate.opsForValue().get(key);
    	}
    }
    
    // /controller/CartController.java
    @RestController
    @RequiredArgsConstructor
    @Slf4j
    public class CartController {
    	private final CartDao cartDao;
    
    
    	@PostMapping("/{id}/cart")
    	public String addItemToCart(@PathVariable Long id, @RequestBody ItemDto itemDto){
    		cartDao.addItem(itemDto, id);
    		log.info("customer {} - add item to cart  : {} ", id, itemDto);
    		return "success";
    	}
    
    	@GetMapping("/{id}/cart")
    	public ItemDto getCartById(@PathVariable Long id){
    		log.info("customer {} - find item from cart  ", id);
    		return cartDao.findById(id);
    	}
    
    }

     

    3. Testcontainers로 통합 테스트 환경 구축하기

    위에서 말한 바와 같이 TestContainers를 사용해 Reids 컨테이너를 띄운 다음 해당 Redis 포트 값을 현재 설정 파일에 오버라이딩하여 어디서든 실행 가능한 통합 테스트 환경을 구축할 것이다.

     

    1. AbstractContainerBaseTest 클래스 생성

    추상 클래스를 생성하여 Redis를 싱글톤 컨테이너로 생성한다. 그러면 하나의 인스턴스로 여러 통합테스트에 적용할 수 있다. 컨테이너를 실행조건으로 명시적으로 노출되는 6379 포트를 설정해주고 여러번 재사용할 수 있는 reuse 옵션을 true로 설정한 후 싱글톤 컨테이너를 생성한다.

    1. redis 버전에 맞게 이미지 값을 설정한다. "redis:6-alpine"
    2. withExposedPorts(6379): 각 포트는 명시적으로 노출되어야 한다.
    3. withReuse(true): 컨테이너를 재사용할 수 있도록 한다.

     

    @DynamicPropertySource - 동적으로 설정 값 매핑하기

    실행된 컨테이너의 host, port 값을 가져와 동적으로 application에 명시된 값을 오버라이딩한다.  @DynamicPropertySource 해당 로직을 정확히 작성하지 않으면 로컬에서 구동되고 있는 redis:6379와 매핑되니 주의해야 한다.

    1. getHost: 컨테이너 호스트 "localhost"를 반환한다. 
      • 현재 testcontainers 팀에서 해당 ip 주소를 반환하려면 몇 가지 더러운 핵을 해결해야 한다고 한다. (git issue) 그러나 지금 내 상황에서는 ip 값은 별로 중요치 않다. 어차피 localhost에 매핑되야 하는 것이 맞다.
    2. getMappedPort(6379): 무작위로 매핑된 포트는 컨테이너 시작 후 발생하므로 해당 메소드로 런타임 시 실제 포트를 검색할 수 있다.
    public abstract class AbstractContainerBaseTest {
    	static final String REDIS_IMAGE = "redis:6-alpine";
    	static final GenericContainer REDIS_CONTAINER;
    
    	static {
    		REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE)
    			.withExposedPorts(6379)
    			.withReuse(true);
    		REDIS_CONTAINER.start();
    	}
    
    	@DynamicPropertySource
    	public static void overrideProps(DynamicPropertyRegistry registry){
    		registry.add("spring.redis.host", REDIS_CONTAINER::getHost);
    		registry.add("spring.redis.port", () -> ""+REDIS_CONTAINER.getMappedPort(6379));
    	}
    }

     

    2. 동적으로 설정 값 매핑되는 과정 한 눈에 보기 

    동적으로 값이 오버라이딩되어 Redis에 적용되는 전체적인 과정을 요약하면 다음과 같다.

     

    동적으로 설정 값 매핑되는 과정

     

     

    4. 통합 테스트 실행하기

    실제로 잘 동작하는지 통합 테스틑 동작시켜보자. @SpringBootTest 애노테이션도 명시해주고 Redis 싱글톤 컨테이너를 들고 있는 AbstractContainerBaseTest 추상 클래스를 상속해준다.

    @SpringBootTest
    class CartControllerTest extends AbstractContainerBaseTest {
    
    	@Autowired
    	private CartDao cartDao;
    
    	@Test
    	void addCartItem(){
    		ItemDto item = ItemDto.builder()
    			.name("item")
    			.price(1000)
    			.quantity(2)
    			.build();
    
    		// when
    		cartDao.addItem(item, 1L);
    
    		ItemDto findItem = cartDao.findById(1L);
    		assertEquals(findItem.getName(), "item");
    		assertEquals(findItem.getPrice(), 1000);
    		assertEquals(findItem.getQuantity(), 2);
    	}
    
    }

     

    이를 동작시키면 Redis 컨테이너도 정상적으로 구동되고 테스트도 통과되는 것을 확인할 수 있다. 

     

    테스트 결과

     

    실습한 내용은 github에서 확인할 수 있습니다.

    참고

    https://www.testcontainers.org/quickstart/junit_5_quickstart/

    https://www.testcontainers.org/features/networking/

    https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/