Redis는 in-memory 데이터 저장을 지원하는 Key-Value Nosql 데이터베이스이다. 메모리에 데이터를 저장하고 구조화하기 때문에 Input/Output latency를 보장하고 많은 처리를 할수 있게 한다. Reactive Redis란 Redis Database에 비동기,Non-blocking 요청을 할수 있는 Library를 말한다. 비동기 어플리케이션 절차에 동기 처리가 포함되면 병목이 발생하고 전체 성능이 저하된다. 따라서 RDB는 R2DBC가 Reactive Stream과 연동하여 Non-blocking 쿼리 요청을 지원함으로써 전체 요청,응답 과정에 비동기 처리가 가능하였다. 만약 Redis를 함께 사용하는 환경에서 동기 방식으로 구현한다면 병목이 발생한다. Reactive Redis는 이러한 문제를 해결할수 있는 Async를 지원하는 Library이다.
Lettuce는 동기, 비동기 및 반응형(Reactive) 사용을 위한 확장 가능하고 스레드 안전한 Redis 클라이언트(Spring Framework에서 Redis 접속을 위한)이다. 여러 스레드는 BLPOP 및 MULTI/EXEC와 같은 블로킹 및 트랜잭션 작업을 피하면 하나의 연결을 공유할 수 있다. Lettuce는 Netty를 기반으로 구축되었으며 Sentinel, 클러스터, 파이프라이닝, 자동 재연결 및 Redis 데이터 모델과 같은 고급 Redis 기능을 지원한다.
ReactiveRedisTemplate은 Spring Data Redis에서 제공하는 고수준 API로 Reactive Streams 기반의 비동기 프로그래밍 모델을 사용하여 Redis와 상호작용할 수 있도록 설계된 템플릿이다. 다양한 데이터 타입에 대한 Operator를 제공하고 Serialization,Connection을 관리한다.
대용량 트래픽이 init되는 환경에 Spring webflux가 유용하며 빠른 Read/Write를 위해 Redis를 함께 사용하는 경우가 많다. 따라서 Spring Data Reactive Redis를 활용하는 방법에 대해 알아두는것은 중요하다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
application.yaml
spring:
r2dbc:
url: r2dbc:mysql://localhost:3306/fastsns
username: root
password: fastcampus
data:
redis:
host: 127.0.0.1
port: 6379
Docker Redis 컨테이너를 실행하고 다음 의존성을 추가하고 application.yaml 파일에 Redis 정보를 추가하였다.
@Configuration
@RequiredArgsConstructor
@Slf4j
public class RedisConfig implements ApplicationListener<ApplicationReadyEvent> {
private final ReactiveRedisTemplate<String,String> reactiveRedisTemplate;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
reactiveRedisTemplate.opsForValue().get("1")
.doOnSuccess(i -> log.info("Initialize to redis connection"))
.doOnError((err) -> log.error("Failed to initialize redis connection: {}", err.getMessage()))
.subscribe();
}
}
애플리케이션 시작 시점에서 Redis와의 연결 상태를 확인하고, 애플리케이션 초기화 완료하기 위한 config 파일을 생성하였다.
기존 R2DBC에서 구현하였던 id를 통한 사용자 조회를 Redis Cache 기능을 사용하여 구현해보자. findById 메서드는 반환 타입이 Mono<User>이기 때문에 필드에 다음값을 설정하면
private final ReactiveRedisTemplate<String,User> reactiveRedisTemplate;
ReactiveRedisTemplate<String,String>와 같은 기본 타입이 아니라서 오류가 발생한다. 따라서 RedisConfig에 다음과 같은 객체를 설정한다.
@Bean
public ReactiveRedisTemplate<String, User> reactiveRedisUserTemplate(ReactiveRedisConnectionFactory connectionFactory) {
var objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);
Jackson2JsonRedisSerializer<User> jsonSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, User.class);
RedisSerializationContext<String, User> serializationContext = RedisSerializationContext
.<String, User>newSerializationContext()
.key(RedisSerializer.string())
.value(jsonSerializer)
.hashKey(RedisSerializer.string())
.hashValue(jsonSerializer)
.build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
}
해당 코드는 Redis에 User 객체를 JSON 형식으로 저장하거나 읽을 때 사용되는 템플릿이다.
주요 로직
(1) JSON 직렬화/역직렬화를 담당하는 Jackson 라이브러리의 핵심 클래스 ObjectMapper를 User에 customazation 시킨다.
(2) Redis 값 데이터를 JSON 형식으로 직렬화/역직렬화하는 데 사용하는 Jackson2JsonRedisSerializer를 생성한다.
(3) 최종으로 Redis에 저장되는 데이터의 직렬화 방식 RedisSerializationContext을 정의 한다.
(4) 연결을 관리하는 ReactiveRedisTemplate 생성 후 RedisSerializationContext를 주입하여 반환하면 Custom Template가 Bean으로 등록된다.
UserService
private final ReactiveRedisTemplate<String,User> reactiveRedisTemplate;
public Mono<User> findById(Long id) {
//1.redis 조회
//2.값이 존재하면 응답
//3.없으면 DB에 질의하고 그 결과를 redis에 저장
return reactiveRedisTemplate.opsForValue()
.get(getUserCacheKey(id))
.switchIfEmpty(userR2dbcRepository.findById(id)
.flatMap(u -> reactiveRedisTemplate.opsForValue()
.set(getUserCacheKey(id), u, Duration.ofSeconds(30))
.then(Mono.just(u)))
);
}
flatMap을 사용하는 이유: reactiveRedisTemplate.opsForValue().set(...)은
Mono<Void>을 반환한다. 또한 then(Mono.just(u))로 저장 후 조회 결과를 반환하면 새로운Mono<User>를 반환한다. -> map 사용시Mono<Mono<User>>가 반환된다.
결과
http://localhost:8080/users/4 에 Get요청(findById)을 보낼시 처음에 Redis 질의 후 SET을 진행하고 이후 요청시 Redis GET만 진행된다.(캐시)
Redis와 Mysql을 Docker Container로 구동하여 동일한 환경을 구성하고 MVC,Webflux 각각의 성능을 Apache JMeter를 통해 테스트 해보자.
MVC Server application.yaml
server:
port: 9000
tomcat:
max-connections: 10000
accept-count: 1000
threads:
max: 3000
min-spare: 1000
spring:
r2dbc:
url: r2dbc:mysql://localhost:3306/fastsns
username: root
password: fastcampus
data:
redis:
host: 127.0.0.1
port: 6379
MVC application
public class MvcApplication implements ApplicationListener<ApplicationReadyEvent> {
private final RedisTemplate<String, String> redisTemplate;
private final UserRepository userRepository;
public static void main(String[] args) {
SpringApplication.run(MvcApplication.class, args);
}
@GetMapping("/health")
public Map<String, String> heatlh() {
return Map.of("health", "ok");
}
@GetMapping("/users/1/cache")
public Map<String, String> getCachedUser() {
var name = redisTemplate.opsForValue().get("users:1:name");
var email = redisTemplate.opsForValue().get("users:1:email");
return Map.of("name", name == null ? "" : name, "email", email == null ? "" : email);
}
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElse(new User());
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
redisTemplate.opsForValue().set("users:1:name", "greg");
redisTemplate.opsForValue().set("users:1:email", "greg@fastcampus.co.kr");
Optional<User> user = userRepository.findById(1L);
if (user.isEmpty()) {
userRepository.save(User.builder().name("greg").email("greg@fastcampus.co.kr").build());
}
}
}
MVC 서버에서 위 코드와 같이 서버에서 응답하는 heatlh(), Redis에서 응답하는 getCachedUser(),Mysql에서 응답하는 getUser()를 정의하였고
users:1의 key에 각각 greg이라는 초기 값을 주입하였다.
Webflux application
public class WebfluxApplication {
private final ReactiveRedisTemplate<String, String> reactiveRedisTemplate;
private final UserRepository userRepository;
public static void main(String[] args) {
SpringApplication.run(WebfluxApplication.class, args);
}
@GetMapping("/health")
public Mono<Map<String, String>> health() {
return Mono.just(Map.of("health", "ok"));
}
@GetMapping("/users/1/cache")
public Mono<Map<String, String>> getCachedUser() {
var name = reactiveRedisTemplate.opsForValue().get("users:1:name");
var email = reactiveRedisTemplate.opsForValue().get("users:1:email");
return Mono.zip(name, email)
.map(i -> Map.of("name", i.getT1(), "email", i.getT2()));
}
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id).defaultIfEmpty(new User());
}
}
마찬가지로 Template,Repository를 달리하여 동일한 코드를 Webflux에서도 준비하였다.
이후 각 어플리케이션을 ./gradlew build를 통해 빌드, java -jar build/libs/webflux-0.0.1-SNAPSHOT.jar 로 실행하였다.
jmeter를 통해 200명의 사용자가 infinite하게 요청을 보내는 Thread groups을 MVC,Webflux별로 만들었고 결과 시각화를 위한 플러그인 설치와설정을 진행하였다.
Response Times Over time
응답 시간에서 Webflux는 안정적이고 빠른 응답 속도(60초)를 보이는 반면, MVC는 편차가 있고 느린 응답 속도(250초)를 보인다.
Transaction Per Second
TPS:초당 처리되는 트랜잭션의 수
MVC의 TPS는 가장 높은 수치가 1000대 인 방면, Webflux는 평균 3300의 트랜잭션을 처리하며 높은 성능을 보인다.
Summary
Webflux가 MVC보다 Throughput에서 3배 차이,응답 속도에서 3배 차이를 보이며 대용량 트래픽 환경에서 동기 방식인 MVC 보다 Webflux가 성능이 훨씬 나은것을 확인할수 있다.
Webflux는 MVC에 비해 3배 이상의 Throughput과 빠르고 안정적인 응답 시간을 제공하며 대규모 트래픽을 처리해야 하는 환경에서 더 나은 성능을 발휘한다.