hyeseong-dev 2024. 10. 7. 22:43

이미지에서 보여지는 구조를 바탕으로 분석한 결과, 이 시스템은 Spring WebFlux를 이용해 비동기적으로 API 서버 및 RDBMS와 통신하는 구조를 나타내고 있습니다. 주요 통신 경로를 살펴보면:

  1. Reactor-Netty (Async): 클라이언트와 Spring WebFlux 간의 통신은 Reactor-Netty를 통해 비동기적으로 이루어집니다. 이는 WebFlux의 기본 비동기 처리 방식입니다.
  2. WebClient (Async): Spring WebFlux는 API 서버와 WebClient를 이용해 비동기 통신을 하고 있습니다. 이 역시 논블로킹 방식으로 처리됩니다.
  3. R2DBC (Async): 관계형 데이터베이스(RDB)와의 통신은 R2DBC를 통해 비동기적으로 처리됩니다. 이를 통해 많은 양의 데이터도 효율적으로 처리할 수 있습니다.
  4. Redis (Sync): 문제점으로 지적될 수 있는 부분은 Redis와의 통신이 **동기적(sync)**으로 이루어지고 있다는 것입니다. 이 부분이 전체 비동기 구조에서 병목 현상을 일으킬 가능성이 있습니다.
  5. 응답 반환: 최종적으로 Spring WebFlux는 클라이언트에 비동기적으로 응답을 반환하는 구조입니다.

병목 현상 해결 방안: Reactive Redis 적용

Redis와의 동기 통신이 전체 비동기적 아키텍처의 흐름을 방해할 수 있기 때문에, 이를 Reactive Redis로 전환하여 성능을 최적화할 수 있습니다. Spring Data Redis의 리액티브 지원을 통해 Redis와의 통신을 논블로킹 방식으로 전환하는 것이 중요합니다.

Reactive Redis는 Spring Data Redis와 Spring WebFlux를 결합하여 Redis와 비동기, 논블로킹(non-blocking) 방식으로 상호작용할 수 있도록 지원하는 기술입니다. Redis는 일반적으로 빠른 메모리 기반 데이터 저장소로 사용되지만, Spring WebFlux의 리액티브 모델과 결합하여 더 높은 성능과 확장성을 제공합니다. Reactive Redis는 Spring WebFlux와 함께 비동기 I/O 처리의 장점을 활용할 수 있도록 하며, 이를 통해 더 많은 요청을 효율적으로 처리할 수 있습니다.

주요 특징

  1. 비동기 I/O:
    • Reactive Redis는 I/O 작업을 비동기적으로 처리하여 블로킹 없이 데이터 입출력이 가능합니다. 따라서 성능 저하 없이 많은 요청을 동시에 처리할 수 있습니다.
  2. 리액티브 프로그래밍 지원:
    • FluxMono를 기반으로 Redis와 상호작용하는 리액티브 타입을 사용합니다. 이로 인해 데이터를 스트리밍 방식으로 처리하거나, 단일 결과를 처리할 때 유연하게 대응할 수 있습니다.
  3. Reactive Streams 기반:
    • Reactive Redis는 Reactive Streams 표준을 준수하므로, 다른 리액티브 라이브러리(Spring WebFlux, Reactor 등)와 쉽게 통합할 수 있습니다.
  4. 레디스의 다양한 데이터 타입 지원:
    • Redis의 기본 데이터 타입인 string, list, set, hash, sorted set 등을 비동기적으로 다룰 수 있으며, Pub/Sub과 같은 기능도 리액티브 방식으로 처리할 수 있습니다.
  5. Pub/Sub 지원:
    • Redis의 Pub/Sub 기능을 리액티브 방식으로 사용할 수 있습니다. 메시지 브로커로서의 역할을 하며, 여러 서비스 간 비동기 메시징을 손쉽게 처리할 수 있습니다.

Reactive Redis 사용을 위한 주요 의존성

Reactive Redis를 사용하려면 spring-boot-starter-data-redis-reactive를 의존성에 추가해야 합니다. Maven에서는 아래와 같이 추가할 수 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

기본적인 Reactive Redis 사용 예시

  1. Redis 구성 설정:
@Configuration
public class RedisConfig {

    @Bean
    public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() {
        return new LettuceConnectionFactory("localhost", 6379);  // Lettuce 클라이언트를 사용
    }

    @Bean
    public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory) {
        RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext
                .<String, Object>newSerializationContext(new StringRedisSerializer())
                .value(new GenericJackson2JsonRedisSerializer())
                .build();

        return new ReactiveRedisTemplate<>(factory, serializationContext);
    }
}
  • LettuceConnectionFactory는 Lettuce 클라이언트를 통해 Redis에 연결하는 설정입니다.
  • ReactiveRedisTemplate는 비동기 방식으로 Redis 작업을 처리할 수 있는 템플릿입니다.
  1. 기본적인 CRUD 작업 예시:
@Service
public class RedisService {

    private final ReactiveRedisTemplate<String, Object> redisTemplate;

    public RedisService(ReactiveRedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Mono<Boolean> saveValue(String key, String value) {
        return redisTemplate.opsForValue().set(key, value);
    }

    public Mono<String> getValue(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public Mono<Boolean> deleteValue(String key) {
        return redisTemplate.opsForValue().delete(key);
    }
}
  • saveValue: 주어진 keyvalue를 Redis에 저장합니다. Mono<Boolean>을 반환하여 성공 여부를 알려줍니다.
  • getValue: 주어진 key에 대한 값을 Redis에서 조회합니다. Mono<String>을 반환합니다.
  • deleteValue: 주어진 key에 대한 값을 Redis에서 삭제합니다.
  1. Pub/Sub 예시:
@Service
public class RedisPubSubService {

    private final ReactiveRedisTemplate<String, String> redisTemplate;

    public RedisPubSubService(ReactiveRedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Mono<Long> publishMessage(String channel, String message) {
        return redisTemplate.convertAndSend(channel, message); // 메시지 전송
    }

    public Flux<String> subscribeToChannel(String channel) {
        return redisTemplate.listenToChannel(channel) // 채널 구독
                .map(message -> message.getMessage());
    }
}
  • publishMessage: 지정된 채널에 메시지를 보냅니다.
  • subscribeToChannel: 지정된 채널을 구독하여 들어오는 메시지를 리액티브 방식으로 처리합니다.

Reactive Redis의 장점

  • 확장성: 비동기 논블로킹 방식으로 더 많은 연결과 요청을 처리할 수 있어 고성능 애플리케이션에 적합합니다.
  • 경량화된 리소스 사용: 비동기 I/O 처리를 통해 리소스를 효율적으로 사용합니다.
  • 리액티브 시스템과의 통합: Spring WebFlux와 같은 리액티브 시스템에서 Redis를 사용할 때 더 높은 성능을 기대할 수 있습니다.

Reactive Redis를 사용할 때의 주의사항

  • 블로킹 연산 회피: Redis 작업 자체는 비동기적으로 처리되지만, 비동기 메서드 내부에서 블로킹 연산을 사용하면 시스템 성능이 저하될 수 있습니다.
  • 적절한 Redis 클라이언트 사용: LettuceReactor 기반의 Redis 클라이언트를 사용하는 것이 중요합니다. 이는 비동기 프로그래밍을 자연스럽게 지원하는 클라이언트입니다.

Requirements

도커를 이용하여 redis 서버 구성 및 설치

docker run -it -p 6379:6379 redis:6.2
1:C 07 Oct 2024 10:03:18.400 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 07 Oct 2024 10:03:18.400 # Redis version=6.2.14, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 07 Oct 2024 10:03:18.400 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1:M 07 Oct 2024 10:03:18.400 * monotonic clock: POSIX clock_gettime
                _._
           _.-``__ ''-._
      _.-``    `.  `_.  ''-._           Redis 6.2.14 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._
 (    '      ,       .-`  | `,    )     Running in standalone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 1
  `-._    `-._  `-./  _.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |           https://redis.io
  `-._    `-._`-.__.-'_.-'    _.-'
 |`-._`-._    `-.__.-'    _.-'_.-'|
 |    `-._`-._        _.-'_.-'    |
  `-._    `-._`-.__.-'_.-'    _.-'
      `-._    `-.__.-'    _.-'
          `-._        _.-'
              `-.__.-'

1:M 07 Oct 2024 10:03:18.401 # Server initialized
1:M 07 Oct 2024 10:03:18.403 * Ready to accept connections
  • 별도의 터미널을 하나 더 생성하여 레디스의 로그를 볼 수 있도록 monitor를 이용합니다.
docker ps
CONTAINER ID   IMAGE       COMMAND                  CREATED         STATUS         PORTS                    NAMES
b29f75309e64   redis:6.2   "docker-entrypoint.s…"   7 seconds ago   Up 6 seconds   0.0.0.0:6379->6379/tcp   happy_proskuriakova
docker exec -it happy_proskuriakova redis-cli PING
PONG
docker exec -it happy_proskuriakova redis-cli monitor
OK

redis-reactive 의존성 라이브러리 설치

implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'

환경변수 등록

application.yaml로 이동하여 작성합니다.

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

스프링 서버가 정상 기동 되는지 기동하여 확인하기~

RedisConfig 설정


import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.web.reactive.config.WebFluxConfigurer;

import java.time.Duration;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class RedisConfig implements WebFluxConfigurer {

    private final ReactiveRedisTemplate<String, String> reactiveRedisTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady() {
        // Redis에 PING을 보내서 연결 상태 확인
        reactiveRedisTemplate.opsForValue().get("1")
                .doOnSuccess(i -> log.info("성공"))
                .doOnError((err) -> log.error("실패: {}", err.getMessage()))
                .subscribe();
        reactiveRedisTemplate.opsForList().leftPush("list1", "hello").subscribe();
        reactiveRedisTemplate.opsForValue().set("sampleKey1", "sample" , Duration.ofSeconds(10)).subscribe();
    }

}
2024-10-07T21:06:05.412+09:00  INFO 20174 --- [webflux1] [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port 8080
2024-10-07T21:06:05.417+09:00  INFO 20174 --- [webflux1] [           main] c.example.webflux1.Webflux1Application   : Started Webflux1Application in 1.374 seconds (process running for 1.594)
2024-10-07T21:06:05.584+09:00  INFO 20174 --- [webflux1] [ioEventLoop-5-1] c.example.webflux1.config.RedisConfig    : 성공

redis-cli monitor

위 코드를 작성하고 서버를 다시 기동하여 봅니다. 그리고 redis 컨테이너에서 아래와 같이 redis-cli monitor 로그 기록을 확인 할 수 있습니다.

docker exec -it happy_proskuriakova redis-cli monitor
OK
1728296619.868656 [0 172.17.0.1:59602] "HELLO" "3"
1728296619.881989 [0 172.17.0.1:59602] "GET" "1"
1728302530.128949 [0 172.17.0.1:59604] "HELLO" "3"
1728302530.152002 [0 172.17.0.1:59604] "GET" "1"
1728302732.946106 [0 172.17.0.1:59606] "HELLO" "3"
1728302732.959581 [0 172.17.0.1:59606] "GET" "1"
1728302732.963350 [0 172.17.0.1:59606] "LPUSH" "list1" "hello"
1728302765.573177 [0 172.17.0.1:59608] "HELLO" "3"
1728302765.585918 [0 172.17.0.1:59608] "GET" "1"
1728302765.589436 [0 172.17.0.1:59608] "LPUSH" "list1" "hello"
1728302765.592404 [0 172.17.0.1:59608] "SET" "sampleKey1" "sample" "EX" "10"

도커를 이용하여 MySQL 서버 설치 및 구성과 데이터베이스 구성

  1. Docker MySQL 컨테이너 실행:결과:컨테이너 상태 확인:결과:
  2. docker ps
  3. docker run --name mysql-r2dbc -e MYSQL_ROOT_PASSWORD=r2dbc -e MYSQL_DATABASE=r2dbc -p 3306:3306 -d mysql:8.0
  4. CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 31f05f8bf1bd mysql:8.0 "docker-entrypoint.s…" 6 seconds ago Up 5 seconds 0.0.0.0:3306->3306/tcp, 33060/tcp mysql-r2dbc b29f75309e64 redis:6.2 "docker-entrypoint.s…" 2 hours ago Up 2 hours 0.0.0.0:6379->6379/tcp happy_proskuriakova
  5. 명령어:
  6. 31f05f8bf1bda2b07077a1a4992e7dd2c44707f51198af7a4e7879538cf588ad
  7. 명령어:
  8. MySQL 접속:MySQL 로그인 시 비밀번호:로그인 후 MySQL 콘솔에 성공적으로 접속되었음을 확인:
  9. docker exec -it mysql-r2dbc mysql -p
  10. Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 8 Server version: 8.0.39 MySQL Community Server - GPL
  11. Enter password: r2dbc
  12. 명령어:
  13. 데이터베이스 목록 확인:결과:r2dbc 데이터베이스가 존재함을 확인.
  14. show databases;
  15. +--------------------+ | Database | +--------------------+ | information_schema | | mysql | | performance_schema | | r2dbc | | sys | +--------------------+ 5 rows in set (0.00 sec)
  16. 명령어:
  17. scema.sql 파일 생성 및 정의
    src/main/resources 경로에 scema.sql 파일을 생성하고 아래와 같이 정의합니다.
CREATE TABLE IF NOT EXISTS users (
     id BIGINT PRIMARY KEY AUTO_INCREMENT,
     name VARCHAR(100),
    email VARCHAR(100),
    created_at TIMESTAMP,
    updated_at TIMESTAMP
    );

CREATE TABLE IF NOT EXISTS posts (
     id BIGINT PRIMARY KEY AUTO_INCREMENT,
     user_id BIGINT,
     title VARCHAR(255),
    content TEXT,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    CONSTRAINT fk_user
    FOREIGN KEY (user_id)
    REFERENCES users(id)
    ON DELETE CASCADE
    );

그리고 application.yaml 파일에 scema.sql 파일이 읽혀지도록 수정해줍니다.

spring:
  sql:
    init:
      mode: always  # 서버가 시작될 때 schema.sql을 실행
  1. 데이터베이스 전환:결과:
  2. use r2dbc;
  3. Database changed
  4. 명령어:
  5. 테이블 확인 (처음에 테이블이 없었음):결과:
  6. show tables;
  7. Empty set (0.00 sec)
  8. 명령어:
  9. 테이블 재확인 (테이블 생성 후):결과:
  10. show tables;
  11. +-----------------+ | Tables_in_r2dbc | +-----------------+ | posts | | users | +-----------------+ 2 rows in set (0.00 sec)
  12. 명령어:
  13. posts 테이블 스키마 확인:결과:
  14. desc posts;
  15. +------------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------+--------------+------+-----+---------+----------------+ | id | bigint | NO | PRI | NULL | auto_increment | | user_id | bigint | YES | MUL | NULL | | | title | varchar(255) | YES | | NULL | | | content | text | YES | | NULL | | | created_at | timestamp | YES | | NULL | | | updated_at | timestamp | YES | | NULL | | +------------+--------------+------+-----+---------+----------------+ 6 rows in set (0.02 sec)
  16. 명령어:
  17. users 테이블 스키마 확인:결과:
  18. desc users;
  19. +------------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +------------+--------------+------+-----+---------+----------------+ | id | bigint | NO | PRI | NULL | auto_increment | | name | varchar(100) | YES | | NULL | | | email | varchar(100) | YES | | NULL | | | created_at | timestamp | YES | | NULL | | | updated_at | timestamp | YES | | NULL | | +------------+--------------+------+-----+---------+----------------+ 5 rows in set (0.00 sec)
  20. 명령어:

요약:

  • Docker로 실행한 MySQL 컨테이너에 접속하여 r2dbc 데이터베이스가 생성되었음을 확인.
  • 테이블이 존재 하지 않으므로 scema.sql파일을 구성하고 기동시 생성 하도록 환경변수를 등록. 이후 postsusers 테이블이 성공적으로 생성되었음을 확인.
  • 각 테이블의 스키마는 auto_increment로 설정된 id 필드를 포함하며, posts 테이블은 user_id를 외래키로 참조하고 있습니다.

RedisConfig 수정

reactiveRedisUserTemplate을 커스텀하게 만들어서 사용해보겠습니다.


@Slf4j
@Configuration
@RequiredArgsConstructor
public class RedisConfig implements WebFluxConfigurer {

    private final ReactiveRedisTemplate<String, String> reactiveRedisTemplate;


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

}

1. 메서드 정의:

@Bean
public ReactiveRedisTemplate<String, User> reactiveRedisUserTemplate(ReactiveRedisConnectionFactory connectionFactory) { ... }
  • 이 메서드는 ReactiveRedisTemplate<String, User> 타입의 빈을 생성합니다. 이 템플릿은 Redis에 저장될 String 타입의 키User 객체를 처리하는 데 사용됩니다.
  • @Bean 어노테이션은 Spring 컨테이너에서 이 메서드의 반환값을 빈으로 등록한다는 의미입니다.
  • ReactiveRedisConnectionFactory는 Redis 연결을 관리하는 팩토리 객체로, 이 템플릿에서 사용됩니다.

2. ObjectMapper 설정:

var objectMapper = new ObjectMapper()
    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
    .registerModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS);
  • ObjectMapper는 JSON 직렬화 및 역직렬화를 담당하는 Jackson 라이브러리의 핵심 객체입니다.
  • DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false: JSON 역직렬화 시, 매핑되지 않은 추가 필드가 있어도 오류를 발생시키지 않도록 설정합니다.
  • registerModule(new JavaTimeModule()): Java 8의 LocalDateTime 등 시간 관련 클래스의 직렬화 및 역직렬화를 지원하기 위해 JavaTimeModule을 등록합니다.
  • disable(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS): 날짜와 시간을 직렬화할 때 타임스탬프 형식이 아닌 ISO 8601 형식(예: 2024-01-01T10:00:00)으로 직렬화하도록 설정합니다.

3. Jackson2JsonRedisSerializer 설정:

Jackson2JsonRedisSerializer<User> jsonSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, User.class);
  • Jackson2JsonRedisSerializer<User>User 객체를 JSON 형식으로 직렬화/역직렬화하기 위한 도구입니다. ObjectMapper를 사용해 User 객체를 Redis에 저장할 때 JSON으로 변환하고, 읽어올 때 JSON을 User 객체로 변환합니다.

4. RedisSerializationContext 설정:

RedisSerializationContext<String, User> serializationContext = RedisSerializationContext
    .<String, User>newSerializationContext()
    .key(RedisSerializer.string())
    .value(jsonSerializer)
    .hashKey(RedisSerializer.string())
    .hashValue(jsonSerializer)
    .build();
  • RedisSerializationContext는 Redis에서 데이터를 직렬화/역직렬화할 때 사용하는 방식과 규칙을 정의합니다.
  • .key(RedisSerializer.string()): Redis에 저장될 키를 String 형식으로 직렬화합니다.
  • .value(jsonSerializer): Redis에 저장될 값을 Jackson2JsonRedisSerializer<User>로 직렬화합니다. 즉, User 객체는 JSON 형식으로 저장됩니다.
  • .hashKey(RedisSerializer.string()): Redis의 해시 자료구조에서 해시의 키도 String 형식으로 직렬화합니다.
  • .hashValue(jsonSerializer): 해시의 값은 User 객체를 JSON으로 직렬화합니다.

5. ReactiveRedisTemplate 생성:

return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
  • ReactiveRedisTemplate<String, User>는 Redis에 데이터를 읽고 쓰기 위한 템플릿입니다. 이 템플릿은 String 타입의 키와 User 객체의 값을 처리할 수 있습니다.
  • Redis와 비동기적으로 통신하며, 리액티브 프로그래밍을 지원합니다. 즉, Redis에서 데이터를 비동기적으로 처리할 수 있는 기능을 제공합니다.

요약:

  • 이 코드는 User 객체를 Redis에 JSON 형식으로 저장하고 불러오기 위한 리액티브 템플릿을 설정합니다.
  • ReactiveRedisTemplate<String, User>는 키를 String으로, 값을 User로 처리할 수 있도록 설정되었고, 이를 통해 Redis에서 비동기적으로 데이터를 저장하고 조회할 수 있습니다.
  • 이 템플릿은 Jackson2JsonRedisSerializer를 사용해 User 객체를 JSON으로 직렬화하고 역직렬화합니다.

UserService 캐시 설정

findbyId

기존 findById 메서드를 리팩토링합니다.
캐시로 붙여서 사용해봅시다.

  1. Redis에서 getUserCacheKey(id)로 값을 조회.
  2. 값이 있으면 즉시 반환.
  3. 값이 없으면 MySQL에서 조회한 후, 그 값을 Redis에 저장하고 반환.

private static String getUserCacheKey(Long id) {
        return "users:%d".formatted(id);
}

public Mono<User> findById(Long id){
    return reactiveRedisTemplate.opsForValue()
            .get(getUserCacheKey(id))  // 1. Redis 조회
            .switchIfEmpty(  // 3. 값이 없으면 MySQL DB에서 조회 후 Redis에 저장
                userR2dbcRepository.findById(id)
                    .flatMap(u -> reactiveRedisTemplate.opsForValue()
                            .set(getUserCacheKey(id), u)
                            .then(Mono.just(u))  // 4. 저장 후 해당 유저 객체 반환
                    )
            );
}

정상적으로 코드 작성이 되었다면 클라이언트 툴로 테스트 해봅니다. 그렇게 되면 redis-cli monitor에서는 아래와 같이 결과가 출력됩니다.
동일한 쿼리를 여러번 하게 되면 GET 요청을 계속 하게 됩니다.

1728307107.566492 [0 172.17.0.1:59786] "GET" "users:3"
1728307107.616743 [0 172.17.0.1:59786] "SET" "users:3" "{\"id\":3,\"name\":\"greg100\",\"email\":\"greg100@fastcampus.co.kr\",\"createdAt\":[2023,7,8,0,45,9],\"updatedAt\":[2023,7,8,16,47,19]}"
1728307462.544276 [0 172.17.0.1:59786] "GET" "users:3"
1728307463.757065 [0 172.17.0.1:59786] "GET" "users:3"
1728307464.366049 [0 172.17.0.1:59786] "GET" "users:3"
1728307464.737965 [0 172.17.0.1:59786] "GET" "users:3"

update 메소드

수정 전 코드

public Mono<User> update(Long id, String name, String email){
    // 유저를 찾고 데이터를 변경하고 저장
    return userR2dbcRepository.findById(id)
            .flatMap(user -> {
                user.setName(name);
                user.setEmail(email);
                return userR2dbcRepository.save(user); // 저장 후 업데이트된 유저 반환
            })
            .switchIfEmpty(Mono.empty()); // 유저를 찾지 못한 경우 Mono.empty() 반환
}

수정 후 코드

public Mono<User> update(Long id, String name, String email){
    // 유저를 찾고 데이터를 변경하고 저장
    return userR2dbcRepository.findById(id)
            .flatMap(user -> {
                user.setName(name);
                user.setEmail(email);
                return userR2dbcRepository.save(user)
                        .flatMap(updatedUser ->{
                            return reactiveRedisTemplate
                                    .unlink(getUserCacheKey(id))
                                    .thenReturn(updatedUser); // 저장 후 업데이트된 유저 반환
                        });
            })
            .switchIfEmpty(Mono.empty()); // 유저를 찾지 못한 경우 Mono.empty() 반환
}

주요 차이점 및 설명

  1. 캐시 삭제 로직 추가 (unlink)
    • 수정 후 코드는 DB에서 유저 정보를 업데이트한 후, Redis 캐시에서 해당 유저 정보를 삭제하는 로직이 추가되었습니다.
    • reactiveRedisTemplate.unlink(getUserCacheKey(id))을 통해, Redis에서 해당 유저에 대한 캐시 데이터를 비동기적으로 삭제합니다.
    • unlink: Redis의 비동기 삭제 명령어로, 캐시 키를 즉시 삭제하는 대신, 비동기적으로 백그라운드에서 제거됩니다. 성능에 더 유리할 수 있습니다.
  2. thenReturn 사용
    • unlink 작업이 끝나면, thenReturn(updatedUser)을 통해 삭제 작업이 완료된 후에 업데이트된 유저 객체를 반환합니다.
    • thenReturn은 리액티브 스트림에서 어떤 작업(여기서는 unlink)이 끝난 후, 그 결과 대신 특정 값을 반환하게 해주는 역할을 합니다. 이 경우 unlink 작업의 결과는 무시하고, updatedUser 객체를 반환합니다.

생소한 문법 설명

1. MonoflatMap

  • Mono는 Spring WebFlux나 Reactor에서 사용되는 리액티브 프로그래밍의 기본 단위로, 하나의 비동기적인 값을 처리할 때 사용됩니다.
    • 예: Mono<User>는 비동기적으로 하나의 User 객체를 처리합니다.
  • flatMap은 비동기 작업을 순차적으로 연결하는 데 사용됩니다. 첫 번째 작업이 끝난 후, 두 번째 비동기 작업을 처리할 때 사용합니다.
    • 예: findById(id)로 유저를 비동기적으로 찾은 후, 그 유저 정보를 기반으로 다른 비동기 작업을 하고 싶을 때 flatMap을 사용합니다.

2. switchIfEmpty

  • switchIfEmptyMonoFlux가 빈 값일 경우 대체 동작을 정의할 때 사용됩니다.
  • 예를 들어, findById(id)에서 유저를 찾지 못하면 Mono.empty()로 빈 응답을 반환하도록 설정한 부분입니다.

3. unlink

  • unlink는 Redis에서 키를 삭제하는 명령어입니다. Redis에서 특정 키에 대한 캐시를 비동기적으로 삭제하는 역할을 합니다.
  • 기존의 del과 비슷하지만, unlink는 비동기적으로 키 삭제 작업을 처리하기 때문에 성능에 더 유리할 수 있습니다.

4. thenReturn

  • thenReturn(T value)는 이전 비동기 작업의 결과는 무시하고, value 값을 반환하게 해줍니다. 즉, 캐시 삭제가 끝난 후, 유저 객체를 반환하게 하는 데 사용됩니다.
  • 예를 들어, unlink 작업이 끝난 후, 삭제 결과와 상관없이 updatedUser를 반환합니다.

요약

  • Mono, flatMap: 비동기적으로 값을 처리하고, 작업을 연결하는 리액티브 스트림의 기본 문법입니다.
  • switchIfEmpty: 값이 없을 때 대체 작업을 정의하는데 사용됩니다.
  • unlink: Redis에서 키를 비동기적으로 삭제하는 명령어입니다.
  • thenReturn: 이전 작업의 결과를 무시하고, 원하는 값을 반환할 때 사용됩니다.

deleteById 메서드

    public Mono<?> deleteById(Long id){
        return userR2dbcRepository.deleteById(id)
                .then(reactiveRedisTemplate.unlink(getUserCacheKey(id)))
                .then(Mono.empty());
    }

코드 설명

  1. deleteById (유저 삭제)
    • userR2dbcRepository.deleteById(id)는 데이터베이스에서 해당 id에 해당하는 유저를 삭제하는 비동기 작업을 수행합니다.
    • 이 작업은 Mono<Void>를 반환하는데, 작업이 성공적으로 완료되면 이후의 작업을 이어서 실행합니다.
  2. unlink (Redis 캐시 삭제)
    • .then(reactiveRedisTemplate.unlink(getUserCacheKey(id)))는 첫 번째 작업(DB에서 유저 삭제)이 완료되면, 이어서 Redis 캐시에서 해당 유저의 데이터를 삭제하는 비동기 작업을 수행합니다.
    • unlink는 Redis에서 키를 비동기적으로 삭제하는 명령어로, 주어진 getUserCacheKey(id)를 사용하여 캐시된 유저 데이터를 삭제합니다.
  3. Mono.empty() 반환
    • 마지막 .then(Mono.empty())는 최종적으로 빈 응답(Mono.empty())을 반환하여, 작업이 완료되었음을 나타냅니다.
    • 이는 응답으로 특별한 데이터를 보내지 않고, 단지 작업이 성공적으로 완료되었다는 신호를 전달합니다.

전체 흐름

  1. DB에서 유저 삭제: userR2dbcRepository.deleteById(id)로 MySQL이나 R2DBC 기반 데이터베이스에서 해당 유저 데이터를 삭제.
  2. Redis 캐시 삭제: unlink(getUserCacheKey(id))로 해당 유저의 캐시 데이터를 Redis에서 삭제.
  3. 빈 응답 반환: 모든 작업이 끝나면 Mono.empty()를 반환해, 작업이 정상적으로 완료되었음을 나타냄.

간단 요약:

  • DB에서 유저 삭제Redis 캐시 삭제작업 완료 응답(Mono.empty()).
  • 이 코드는 유저와 관련된 데이터를 DB와 캐시 둘 다에서 삭제하는 흐름을 비동기적으로 처리하는 방식입니다.

findById 수정

Duration.ofSeconds(30) 코드를 추가하였습니다. 30초동안 캐시를 보관하고 이후는 캐시를 삭제하도록 설정했습니다.

    public Mono<User> findById(Long id){
        return reactiveRedisTemplate.opsForValue()
                .get(getUserCacheKey(id))  // 1. Redis 조회
                .switchIfEmpty(  // 3. 값이 없으면 MySQL DB에서 조회 후 Redis에 저장
                        userR2dbcRepository.findById(id)
                                .flatMap(u -> reactiveRedisTemplate.opsForValue()
                                        .set(getUserCacheKey(id), u, Duration.ofSeconds(30))
                                        .then(Mono.just(u))  // 4. 저장 후 해당 유저 객체 반환
                                )
                );
    }

client tool로 query하게 된다면 redis-cli monitor에서는 아래와 같이 나타나게 될 것입니다.
최초 캐시를 30초동안 생성한다는 query가 보입니다. 30초동안 캐시를 유지하다가 30초 이후 삭제되고 다시 요청하면 캐시를 생성하게 되는 흐름입니다.

1728308468.970243 [0 172.17.0.1:59808] "SET" "users:3" "{\"id\":3,\"name\":\"greg100\",\"email\":\"greg100@fastcampus.co.kr\",\"createdAt\":[2023,7,8,0,45,9],\"updatedAt\":[2023,7,8,16,47,19]}" "EX" "30"
1728308482.126222 [0 172.17.0.1:59808] "GET" "users:3"
1728308482.700058 [0 172.17.0.1:59808] "GET" "users:3"
1728308484.383023 [0 172.17.0.1:59808] "GET" "users:3"
1728308524.138225 [0 172.17.0.1:59808] "GET" "users:3"
1728308524.164335 [0 172.17.0.1:59808] "SET" "users:3" "{\"id\":3,\"name\":\"greg100\",\"email\":\"greg100@fastcampus.co.kr\",\"createdAt\":[2023,7,8,0,45,9],\"updatedAt\":[2023,7,8,16,47,19]}" "EX" "30"