개발 과정 중 OAuth2.0의 Id Token을 사용하기로 했고, 재생 방지 공격(Replay-Attack)을 방지하기 위해 Nonce를 TTL=5분으로 Redis에 저장해야 하는 상황이 생겼다.
Redis를 선택한 이유?
Docker에 올려서 CLI를 통해서 개발 과정 중 확인할 수도 있고, Redis는 별도 설정 없이 TTL이 쉽게 가능하니 알아서 삭제되는 걸 원했기 때문. DB도 가능한데 DB는 별도의 설정이 필요하고 난 그런 걸 제일 싫어한다! + 개발 과정 중 mySQL 안 쓰고 H2 In-memory DataBase로 개발하고 있어서 최대한 DB는 심플하게
아무튼! NonceRepository를 어떻게 테스트해야 할까?
위와 같이 세 가지의 선택지가 있었다.
- RedisTemplate bean을 모킹한다.
나는 RedisTemplate이 실제로 잘 작동하고 연결이 되는 걸 보고 싶은 건데 RedisTemplate bean을 모킹하면 NonceRepository를 테스트하는 의미가 없는데?- 테스트 시, Embedded Redis를 쓴다.
마지막 업데이트가 너무 이전이고 버전 문제, 라이브러리 충돌 문제(검색하면 나온다) 등 간단하게 쓰겠다는 목적에 비해서 위험부담이 크다.- Docker에서 Redis 컨테이너를 켜고 테스트한다.
테스트가 너무 종속적이다. 테스트를 돌리는 사람이 모두 같은 설정의 docker container(Redis container)를 미리 세팅해야 한다.- Test Containers를 도입한다.
선택지가 얘밖에 없음. 도입하자!
위와 같은 이유로 Test Containers를 통해 Redis 관련 로직을 테스트하게 되었다.
Test Containers 공식 사이트
Test containers 설명하려면 먼저 도커를 설명해야 한다.
도커란 쉽게 말해서,
위와 같이 각각의 컨테이너(App A, B, C, ...)가 있고 Docker에서 이러한 컨테이너를 관리해준다. Host OS(진짜 OS)의 자원을 Docker가 각각의 컨테이너에 나눠주는 역할을 한다. 꼭 Docker가 아니어도 된다. Docker는 단지 컨테이너를 관리하는 오픈소스이고 다른 회사가 만든 오픈소스도 있을 것이다.
하지만 대부분 도커를 이용하고 있고 거의 표준으로 자리잡은 것 같다. Test Containers도 도커를 기반으로 돌아간다.
그래서 🚨Docker를 꼭 설치해야 한다. 그리고 테스트 전에 도커를 켜놔야 한다!🚨
결론적으로! 테스트 전에 도커를 켜 놓으면,
어플리케이션 내부의 코드를 통해 코드에 명시된 container를 런타임에 만들고 테스트가 종료되면 해당 컨테이너를 삭제한다.
이게 다임.
나는 Spring Context가 구성되기 전에 Redis Container를 만들어야 했다.
왜냐하면 Redis Template을 통해 redis와 소통을 했고 이러한 RedisTemplate은 @Configuration이 붙은 설정 클래스 내부에서 @Bean으로 생성됐기 때문이다.
그래서 BeforeAllCallback이라는 Test Extension을 구현한 TestRedisConfig를 만들었다. 해당 Extension은 Spring context가 구성되기 전에 실행되기 때문에 위에서 언급한 Redis Template이 생성되는 데에 영향을 끼치지 않는다. (관련 정보를 못 찾았고 gpt한테 물어봤는데도 자기도 모른다고 했었는데, 실제로 디버깅했을 때 spring context 구성 이전에 먼저 test 환경을 만든다고 먼저 실행된다.)
public class TestRedisConfig implements BeforeAllCallback {
private final String dockerfileName = "redis:6-alpine";
private final int port = 6379;
@Override
public void beforeAll(ExtensionContext context) throws Exception {
GenericContainer redis = new GenericContainer(DockerImageName.parse(dockerfileName))
.withExposedPorts(port);
redis.start();
System.setProperty("spring.data.redis.host", redis.getHost());
System.setProperty("spring.data.redis.port", redis.getMappedPort(port).toString());
}
}
Redis는 별도의 Container 구현체가 없어서 GenericContainer로 만들어줘야 한다. 그래서 dockerfile 이름을 넣어준다.
Redis docker file 종류
해당 공식 문서에서 확인 가능하다.
GenericContainer redis = new GenericContainer(DockerImageName.parse(dockerfileName))
.withExposedPorts(port);
해당 코드에서 Exposed port를 명명한다.
해당 메소드는 컨테이너가 리슨할 포트를 명명해주는 거라고 하는데, 이게 뭔지 좀 헷갈렸다. (그리고 블로그마다 말이 다 달라서... 내부 포트를 설정하는 거다 / 외부 포트를 설정하는 거다 갈려서 헷갈렸다.)
그림으로 간단히 나타내면,
컨테이너도 포트가 있고 host(local host, 그냥 현재 로컬 컴퓨터)도 포트가 있다. 컨테이너도 tcp/ip 통신을 한다는 말이다!
우리가 테스트 컨테이너를 쓰는 이유는, 종속적이지 않은 테스트를 하기 위함이다.
사진으로 만들어봤는데, 결론적으로는 host의 포트는 아무 포트나 줘도 되고 사실 container에서 올바른 포트로 접근해야 되기 때문에 container의 포트만 mysql이면 3306, Redis면 6379 이렇게 접속하면 된다.
결론적으로 container의 포트를 withExposedPorts로 정의하고 해당 내부 포트에 매핑된 host 포트번호를 getMappedPort(int innerPort)로 가져오면 된다.
public class TestRedisConfig implements BeforeAllCallback {
private final String dockerfileName = "redis:6-alpine";
private final int port = 6379;
@Override
public void beforeAll(ExtensionContext context) throws Exception {
GenericContainer redis = new GenericContainer(DockerImageName.parse(dockerfileName))
.withExposedPorts(port);
redis.start();
System.setProperty("spring.data.redis.host", redis.getHost());
System.setProperty("spring.data.redis.port", redis.getMappedPort(port).toString());
}
}
BeforeAllCallback 구현체
System.setProperty로 현재 시스템의 spring.data.redis.host/port property들을 설정한다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password}")
private String password;
@Bean
RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(host, port);
redisConfiguration.setPassword(password);
RedisConnectionFactory redisConnectionFactory = new LettuceConnectionFactory(redisConfiguration);
return redisConnectionFactory;
}
}
Redis template bean 생성하는 config 코드
여기서 application.yml에 기재된 spring.data.redis.host/port property 말고 BeforeAllCallback 구현체에서 설정한 property가 @Value 어노테이션으로 들어가게 된다.
이유는 우선순위가 높아서라고 함
System.setProperties(name, value)로 설정하는 값이 yml 파일에 정의하는 것보다 우선순위가 높음!
공식문서 에서 확인할 수 있다.
결과적으로 테스트 코드는
import com.yoojkim.cupdiary.config.TestRedisConfig;
import com.yoojkim.cupdiary.auth.repository.NonceRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@ExtendWith(TestRedisConfig.class)
@SpringBootTest
public class NonceRepositoryTest {
@Autowired
NonceRepository nonceRepository;
@Test
void nonce_없음() {
boolean doesExist = nonceRepository.existsNonce("test");
Assertions.assertFalse(doesExist);
}
@Test
void nonce_값_저장() {
nonceRepository.saveNonce("test");
}
@Test
void nonce_확인하는지_테스트() {
String nonce = "testt";
nonceRepository.saveNonce(nonce);
Assertions.assertTrue(nonceRepository.existsNonce(nonce));
}
@Test
void nonce_삭제_되는지_테스트() {
String nonce = "delete";
nonceRepository.saveNonce(nonce);
nonceRepository.deleteNonce(nonce);
Assertions.assertFalse(nonceRepository.existsNonce(nonce));
}
}
위에서 구현한 BeforeAllCallback 구현체 Extension을 사용한다고 @Extendwith 어노테이션을 통해 명명해주고, Spring Context를 통해 NonceRepository를 만들기 때문에, @SpringBootTest 어노테이션도 명명해줬다.
혹시 몰라서 Nonce Repository도 첨부
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class NonceRepository {
private final RedisTemplate redisTemplate;
public void saveNonce(String nonce) {
redisTemplate.opsForValue().set(nonce, "1", 5, TimeUnit.MINUTES);
}
public boolean existsNonce(String nonce) {
Object storedNonce = redisTemplate.opsForValue().get(nonce);
return (storedNonce == null) ? false : true;
}
public void deleteNonce(String nonce) {
if (!existsNonce(nonce)) {
return;
}
redisTemplate.opsForValue().getAndDelete(nonce);
}
}
모든 테스트가 성공하는 결과가 나온다.
Redis 테스트 하나에도 너무 많은 개념들이 있고, 이를 공부하느라 시간을 많이 썼다. 하지만 새롭게 알게된 개념들과 docker에 대한 이해도를 높일 수 있었다. 공부란 이런 것 같다. 컴퓨터 공부를 하면서 느끼는 거지만 예상치 못한 부분에서 공부를 하게 되는 것 같다.