1. 작업 배경
- 현재 회사에서 주문의 배송처리 서비스 를 담당하고 있다.
- 배송처리 서비스는 여러 가지 일을 맡고 있는데, 그중 주문의 배송완료 처리 도 맡고 있다.
- 배송완료 처리를 수행하면 최종적으로 주문의 배송완료 정보가 생성되고, 이 정보는 여러 도메인(예, 적립금, 회원 도메인) 에 전달된다. 요약하면 아래 플로우 대로 작업이 이루어진다.
작업 플로우
- 배송처리 서비스에서 배송완료 처리 -> 배송정보 제공 서비스 API 호출 -> 카프카 배송완료 메시지 발행
- 배송완료 정보에 기반해 고객에게 적립금을 지급하거나 첫 구매 혜택을 적용할 수 있기 때문에 타 도메인에서는 발행된 메시지를 구독한다.
- 이 작업에는 한 가지 요구사항이 있었는데, 배송완료 정보를 주문당 한 번씩만 발행해야 한다는 것이었다. (적립금이 두 번 적립되거나 할 수 있기 때문에 그렇다).
- 물론 적립금 서비스에는 방어 로직이 있었다.
- 하지만 메시지를 한 번만 발행하여 정확한 정보를 제공하는 것은, 서비스의 담당자인 내가 책임져야 할 일이라고 생각했다.
- 이 요구사항을 지키기 위해 두 가지 문제를 해결해야 했다. (위 구조 참고)
- (1) 배송처리 서비스에서 배송정보 제공 서비스의 API 를 중복 호출하는 문제
- (2) 배송정보 제공 서비스에서 배송완료 메시지를 중복 발행하는 문제
- (2)의 문제는 생각보다 간단히 해결할 수 있었지만 (참고), (1)은 고민이 필요했다.
1.1. 이른 결론
- 얼마간 시간을 두고 고민했는데, 결국 내가 선택한 건 Redis 였다. (대단한 방법을 사용할 것 같았지만 그렇지 않았다).
- 사실 처음부터 Redis 를 생각했지만 이번에는 다른 방법에 도전해보고 싶었다 (Kafka Streams 등)
- 하지만 다른 방법은 사용하기에 무거웠고, 팀 내부 사정을 고려했을 때 (팀에서 사용 중인 레디스가 많이 여유로웠다.) 결국 Redis 사용하는 것으로 결론지었다.
- 아래와 같은 간단한 플로우를 추가하니 메시지를 한 번만 발행해야 한다는 요구사항을 만족할 수 있었다.
간단한 작업 플로우
- API 를 호출할 때마다 레디스에 주문번호를 저장한다.
- 레디스에 이미 주문번호가 저장되어 있으면 메시지를 발행하지 않는다.
- 저장되어 있지 않으면 주문번호를 저장하고 메시지를 발행한다.
2. Spring Redis
Redis 를 사용하는 데 엄청난 진입장벽이 있는 것은 아니지만 앞으로 두고 두고 사용할 일이 많을 것 같아 사용법을 가볍게 정리하였다.
2.1. Dependency
dependencies {
...
implementation("org.springframework.boot:spring-boot-starter-data-redis")
...
}
2.2. Configuration
- @EnableAutoConfiguration이 반드시 필요한 것은 아니다. Redis Config 를 어디에 선언하냐에 따라 다르다.
- 나는 Redis 용 모듈을 새로 생성하였고 @EnableAutoConfiguration 을 선언해주는 곳이 따로 없어서 사용하였다.
@Configuration
@EnableAutoConfiguration
public class RedisConfiguration {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port:6379}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
}
아래처럼 Cluster Configuration 을 사용해 Redis 를 구성할 수도 있다.
@Configuration
@EnableAutoConfiguration
public class RedisConfiguration {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port:6379}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration();
redisClusterConfiguration.clusterNode(host, port);
return new LettuceConnectionFactory(redisClusterConfiguration);
}
}
다만 이 구성은 테스트시 Connection이 제대로 되지 않는 문제가 발생할 수 있다. (아래 메시지 참고). 이때는 Test용 RedisConfiguration을 따로 구성하면 된다.
2.3. RedisService
- Redis 기능을 사용하는 방법은 여러 가지다. RedisTemplate 을 사용할 수도 있고, RedisRepository 를 사용할 수도 있다.
- 여기에서는 심플하게 Redis 라이브러리에서 제공하는 StringRedisTemplate 을 사용하였다.
@Component
public class RedisService {
private final StringRedisTemplate redisTemplate;
public RedisService(final StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean put(String key, String value) {
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
return Boolean.TRUE.equals(valueOperations.setIfAbsent(key, value, 1L, TimeUnit.SECONDS));
}
public String getBy(final String key) {
return redisTemplate.opsForValue().get(key);
}
}
3. Test by Testcontainers
- 기능을 만들었다면 테스트 해봐야 한다.
- Embedded Redis 를 구성하여 테스트해도 되지만 여기에서는 Testcontainers를 사용하였다.
- Testcontainers를 사용한 이유는 Embedded Redis의 port 제약 때문이었다.
- 동일한 임베디드 레디스 구성을 사용하면 port 충돌이 있어 테스트를 병렬적으로 진행하기 어렵다. (해결법은 있지만 적용하기 귀찮다).
- 실제로 로컬에서 레디스를 사용하면 늘 port 충돌을 신경써야 한다.
- host로 주로 사용하는 localhost가 CI 환경에서 작동하지 않을 수 있다.
- 이에 반해 Testcontainers는 랜덤 포트를 레디스 포트(주로 6379)로 포워딩할 수 있고, 도커 컨테이너의 주소를 호스트로 사용할 수 있어 환경에 독립적인 이점이 있다.
- 사용법은 아래와 같다.
3.1. Dependency
dependencies {
...
testImplementation "org.testcontainers:junit-jupiter:1.17.3"
testImplementation "org.testcontainers:testcontainers:1.17.3"
...
}
3.2. RedisServiceTest
Testcontainers가 실행한 Redis 컨테이너의 주소와 포트로 Redis 를 구성하기 위해 아래 코드가 필요햐다.
System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost());
System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString());
위 내용을 아래 반영하면
@Testcontainers
@SpringBootTest(
classes = {
RedisService.class,
RedisConfiguration.class
}
)
class RedisServiceTest {
private static final String REDIS_IMAGE = "redis:6.2.7-alpine";
private static final String REDIS_KEY_PREFIX = "test:order-no";
private static final String INITIAL_KEY = "212341234";
private static final String REDIS_VALUE = "REQUESTED";
@Autowired
private RedisService redisService;
@Container
public static GenericContainer<?> REDIS_CONTAINER;
static {
REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_IMAGE))
.withExposedPorts(6379)
.withReuse(true);
REDIS_CONTAINER.start();
System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost());
System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString());
}
@Nested
@DisplayName("put 메서드는")
class DescribePut {
boolean subject(final String key, final String value) {
return redisService.put(key, value);
}
@Nested
@DisplayName("저장되지 않은 키가 주어지면")
class ContextWithInitialKey {
@Test
@DisplayName("키와 값을 저장하고 true 를 반환한다.")
void itReturns() {
boolean result = subject(REDIS_KEY_PREFIX + INITIAL_KEY, REDIS_VALUE);
assertThat(result).isTrue();
assertThat(redisService.getBy(REDIS_KEY_PREFIX + INITIAL_KEY)).isEqualTo(REDIS_VALUE);
}
}
@Nested
@DisplayName("이미 저장된 키가 주어지면")
class ContextWithDuplicateKey {
@Test
@DisplayName("false 를 반환한다.")
void itReturns() {
// given
String duplicateKey = REDIS_KEY_PREFIX + "123";
subject(duplicateKey, "TEMP_VALUE_1"); // 같은 키로 미리 저장
// when
boolean result = subject(duplicateKey, "TEMP_VALUE_2");
// then
assertThat(result).isFalse();
}
}
}
@Nested
@DisplayName("getBy 메서드는")
class DescribeGetBy {
String subject(final String key) {
return redisService.getBy(key);
}
@Nested
@DisplayName("저장된 키가 주어지면")
class ContextWithExistentKey {
@Test
@DisplayName("저장된 값을 반환한다.")
void itReturns() {
// given
String redisKey = REDIS_KEY_PREFIX + "124";
redisService.put(redisKey, REDIS_VALUE);
// when
String result = subject(redisKey);
// then
assertThat(result).isEqualTo(REDIS_VALUE);
}
}
@Nested
@DisplayName("저장되지 않은 키가 주어지면")
class ContextWithNonExistentKey {
@Test
@DisplayName("null 을 반환한다.")
void itReturns() {
String result = subject("non-existent-key");
assertThat(result).isNull();
}
}
}
}
3.3. Test Result
RedisServiceTest 를 실행하면 모든 테스트가 통과하는 것을 확인할 수 있다.