Redis 레플리케이션(Replication)
RECOMMEND POSTS BEFORE THIS
0. 들어가면서
레디스(redis) 같은 캐시 서비스를 사용할 때 고가용성(high availability)에 대한 고민이 필요하다. 단일 인스턴스로 구성된 캐시 서버에 잠깐이라도 장애가 발생하면 전체 시스템이 마비될 수 있다.
- 예기치 못한 서비스 인프라 장애가 발생하더라도 사용자에게 중단 없이 서비스를 제공할 수 있다.
- 특정 서비스의 장애가 시스템 전체로 전파되는 위험을 최소화한다.
1. Replication in Redis
레플리케이션(replication)은 레디스의 고가용성 구축을 위한 전략 중 하나다.
- 마스터(master) 인스턴스와 슬레이브(slave) 인스턴스들로 구성된다.
- 마스터는 여러 개의 슬레이브 인스턴스를 가질 수 있다.
- 슬레이브도 자신을 복제하기 위한 또 다른 슬레이브 인스턴스를 가질 수 있다.
- 클라이언트(client)는 마스터 인스턴스를 통해 읽기(read), 쓰기(write)가 가능하다.
- 마스터 인스턴스에 저장된 데이터는 슬레이브 인스턴스에 주기적으로 동기화(synchronize)된다.
- 마스터 인스턴스에 문제가 발생하는 경우 이를 슬레이브 인스턴스가 대체한다.
슬레이브 인스턴스는 읽기 연산에 대해서만 정상적인 동작을 보장한다. 레디스 버전 2.6부터 슬레이브 인스턴스는 기본적으로 읽기 전용이다. redis.conf 설정 파일의 replica-read-only 옵션을 통해 변경할 수 있다.
레디스는 마스터와 슬레이브 사이의 주기적인 데이터 동기화를 어떻게 수행할까? 레디스는 비동기적으로 동기화를 수행한다. 전체 동기화(full synchronization)는 다음과 같은 순서로 이뤄진다.
- 마스터는 자식 프로세스를 시작해 백그라운드(background)로 RDB 파일에 데이터를 저장한다.
- 데이터를 저장하는 동안 마스터에 새로 들어온 명령들은 처리 후 복제 버퍼에 저장된다.
- RDB 파일 저장이 완료되면 마스터는 파일을 복제 서버에 전송한다.
- 복제 서버는 파일을 받아 디스크에 저장하고, 메모리로 로드한다.
- 마스터는 복제 버퍼에 저장된 명령을 복제 서버에게 전송한다.
마스터와 슬레이브가 일정 시간 연결이 끊긴 경우 부분 동기화를 수행한다. 장시간 동기화 실패로 부분 동기화가 어려운 경우에는 전체 동기화를 수행한다.
2. Practice
간단한 시나리오를 바탕으로 애플리케이션을 구현하고 레디스 레플리케이션을 구축해 보겠다.
- 사용자가 애플리케이션 화면을 통해 간단한 메시지를 전송한다.
- 전송한 메시지는 레디스 마스터 인스턴스의 리스트(list)에 저장된다. 리스트는 두 종류가 있다.
- 읽지 않은 메시지를 저장하는 리스트
- 읽은 메시지를 저장하는 리스트
- 사용자는 메인 화면에서 읽지 않은 메시지가 몇 개인지 확인할 수 있다.
- 리스트별 메시지 현황을 볼 수 있는 화면에서 각 리스트에 담긴 메시지를 확인할 수 있다.
- 왼쪽은 읽지 않은 메시지 리스트이다.
- 오른쪽은 읽은 메시지 리스트이다.
- 해당 화면을 새로 고침하거나 메인 화면에서 다시 진입하면 읽은 메시지는 모두 오른쪽으로 이동한다.
시스템 구성도를 보면 다음과 같다.
읽기 기능은 리스트의 상태를 바꾸지 않는 연산이다. 반대로 쓰기 기능은 리스트의 상태를 바꾸는 연산이다. 앞서 말했듯 마스터 인스턴스는 리스트의 상태를 바꿀 수 있지만, 슬레이브는 리스트의 상태를 읽는 것만 가능하다. 우선 위 시나리오에서 읽기 연산과 쓰기 연산을 분리해서 살펴보자.
- 메인 화면에서 읽지 않은 메시지 개수를 조회하는 기능은
읽기연산이다. - 메인 화면에서 새로운 메시지를 작성하는 기능은
쓰기연산이다. - 메시지 리스트 현황 화면에서 읽지 않은 메시지와 읽은 메시지를 표시하는 작업은
읽기연산이다. - 메시지 리스트 현황 화면에서 읽지 않은 메시지를 모두 꺼내어(pop) 읽은 메시지 리스트로 이동하는
쓰기연산이 발생한다.
위 내용을 바탕으로 마스터 인스턴스를 중지시켰을 때 시스템이 어떻게 동작할까? 예상되는 동작을 정리해 보자.
- 메인 화면에서 새로 고침은 정상적으로 동작한다. (읽기)
- 메인 화면에서 새로운 메시지를 작성한 후 전송 버튼을 누르면 쓰기 연산이므로 정상 동작하지 않는다. (쓰기)
- 리스트 현황 페이지에서 리스트 현황을 보는 것은 정상적으로 동작한다. (읽기)
- 리스트 현황 페이지로 이동했을 때 읽지 않은 메시지를 읽은 메시지로 이동하는 동작은 정상적으로 수행되지 않는다. (쓰기)
슬레이브 인스턴스를 중지시켰을 때는 위와 다르게 모든 기능이 정상적으로 동작할 것으로 예상한다.
3. Implementation
사용자 화면은 타임리프(thymeleaf) 템플릿 엔진을 사용하였다. 지금부터 구현 코드와 설정을 살펴보자. 모든 클래스를 살펴보진 않고, 중요한 기능만 살펴보겠다. 프로젝트 패키지는 다음과 같다.
./
├── Dockerfile
├── conf
│ ├── redis-master.conf
│ ├── redis-slave-1.conf
│ ├── redis-slave-2.conf
│ └── redis.conf
├── docker-compose-replication.yml
├── mvnw
├── mvnw.cmd
├── pom.xml
├── shell
│ └── redis-replication.sh
└── src
├── main
│ ├── java
│ │ └── action
│ │ └── in
│ │ └── blog
│ │ ├── ActionInBlogApplication.java
│ │ ├── client
│ │ │ ├── MessageClient.java
│ │ │ └── RedisMessageClient.java
│ │ ├── config
│ │ │ ├── RedisConfiguration.java
│ │ │ └── RedisTemplateConfig.java
│ │ ├── controller
│ │ │ └── RedisController.java
│ │ └── domain
│ │ ├── Message.java
│ │ ├── MessageGroup.java
│ │ └── Queue.java
│ └── resources
│ ├── application.yml
│ └── templates
│ ├── index.html
│ └── messages.html
└── test
└── java
└── action
└── in
└── blog
└── ActionInBlogApplicationTests.java
pom.xml 파일에 레디스, 타임리프 관련 의존성을 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
application.yml 설정 파일에 마스터, 슬레이브 인스턴스 정보를 추가한다. 호스트 정보는 도커 컴포즈(docker compose) 파일에 정의된 호스트 이름을 사용한다.
spring:
mvc:
static-path-pattern: /static/**
thymeleaf:
prefix: classpath:templates/
check-template-location: true
suffix: .html
mode: HTML5
cache: false
redis:
master:
host: redis-master
port: 6379
slaves:
- host: redis-slave-1
port: 6379
- host: redis-slave-2
port: 6379
RedisConfiguration 클래스에 마스터, 슬레이브 설정값을 주입받는 빈(bean) 객체를 만든다.
package action.in.blog.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Getter
@Setter
class RedisInstance {
private String host;
private int port;
}
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "redis")
public class RedisConfiguration {
private RedisInstance master;
private List<RedisInstance> slaves;
}
RedisTemplateConfig 클래스에 application.yml 파일에 정의한 마스터, 슬레이브 설정값을 사용해 RedisConnectionFactory 빈을 생성하는 코드를 작성한다.
package action.in.blog.config;
import io.lettuce.core.ReadFrom;
import lombok.RequiredArgsConstructor;
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.RedisStaticMasterReplicaConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisTemplateConfig {
private final RedisConfiguration redisConfiguration;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
RedisStaticMasterReplicaConfiguration staticMasterReplicaConfiguration = new RedisStaticMasterReplicaConfiguration(
redisConfiguration.getMaster().getHost(),
redisConfiguration.getMaster().getPort()
);
redisConfiguration.getSlaves().forEach(slave -> staticMasterReplicaConfiguration.addNode(slave.getHost(), slave.getPort()));
return new LettuceConnectionFactory(staticMasterReplicaConfiguration, clientConfig);
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
RedisSerializer<Object> springSessionDefaultRedisSerializer
) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(springSessionDefaultRedisSerializer);
return redisTemplate;
}
}
RedisController 클래스에 아래와 같은 엔드포인트를 생성한다. 각 경로별 기능은 다음과 같다.
/경로- 기본 페이지를 반환한다.
- 현재 읽지 않은 메시지 리스트 크기를 모델에 담아 반환한다.
/message경로- 신규 메시지를 생성한다.
- 현재 읽지 않은 메시지 리스트 크기를 모델에 담아 반환한다.
/unread-list/size- 현재 읽지 않은 메시지 리스트 크기를 모델에 담아 반환한다.
/messages경로- 현재 두 리스트에 담긴 메시지를 보여준다.
/messages/flush경로- 읽지 않은 리스트에 담긴 메시지를 읽은 리스트로 옮긴다.
package action.in.blog.controller;
import action.in.blog.client.MessageClient;
import action.in.blog.domain.MessageGroup;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@RequiredArgsConstructor
@Controller
public class RedisController {
private final MessageClient messageClient;
@GetMapping(value = {"", "/"})
public String index(Model model) {
model.addAttribute("unreadListSize", messageClient.getUnreadMessagesSize());
return "index";
}
@PostMapping("/message")
public String createMessage(Model model, @ModelAttribute("message") String message) {
messageClient.pushMessage(message);
model.addAttribute("unreadListSize", messageClient.getUnreadMessagesSize());
return "index :: fragment";
}
@GetMapping("/unread-list/size")
public String getUnreadListSize(Model model) {
model.addAttribute("unreadListSize", messageClient.getUnreadMessagesSize());
return "index :: fragment";
}
@GetMapping("/messages")
public String messages(Model model) {
MessageGroup messageGroup = messageClient.readMessageGroup();
model.addAttribute("readMessages", messageGroup.getReadMessages());
model.addAttribute("unreadMessages", messageGroup.getUnreadMessages());
return "messages";
}
@PostMapping("/messages/flush")
@ResponseBody
public void flushMessages() {
messageClient.flushUnreadMessages();
}
}
RedisMessageClient 클래스에 아래와 같은 메서드 코드를 작성한다.
getUnreadMessagesSize메서드UNREAD리스트의 크기를 반환한다.
pushMessage메서드UNREAD리스트에 새로운 메시지를 추가한다.
readMessageGroup메서드UNREAD리스트와READ리스트에 담긴 메시지를 반환한다.
flushUnreadMessages메서드UNREAD리스트에 담긴 메시지를READ리스트로 옮긴다.
package action.in.blog.client;
import action.in.blog.domain.Message;
import action.in.blog.domain.MessageGroup;
import action.in.blog.domain.Queue;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CachePut;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.UUID;
@RequiredArgsConstructor
@Component
public class RedisMessageClient implements MessageClient {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public long getUnreadMessagesSize() {
return redisTemplate.opsForList().size(Queue.UNREAD.name());
}
@Override
public void pushMessage(String message) {
Message body = Message.builder()
.id(UUID.randomUUID().toString())
.message(message)
.build();
redisTemplate.opsForList().rightPush(Queue.UNREAD.name(), body);
}
@Override
public MessageGroup readMessageGroup() {
long unreadQueueSize = redisTemplate.opsForList().size(Queue.UNREAD.name());
List<Message> unreadMessages = (List) redisTemplate.opsForList().range(Queue.UNREAD.name(), 0, unreadQueueSize);
long readQueueSize = redisTemplate.opsForList().size(Queue.READ.name());
List<Message> readMessages = (List) redisTemplate.opsForList().range(Queue.READ.name(), 0, readQueueSize);
return MessageGroup.builder()
.unreadMessages(unreadMessages)
.readMessages(readMessages)
.build();
}
@Override
public void flushUnreadMessages() {
long unreadQueueSize = redisTemplate.opsForList().size(Queue.UNREAD.name());
List<Message> unreadMessages = (List) redisTemplate.opsForList().rightPop(Queue.UNREAD.name(), unreadQueueSize);
if (unreadMessages.size() != 0) {
redisTemplate.opsForList().rightPushAll(Queue.READ.name(), unreadMessages.toArray());
}
}
}
4. Test
도커 컴포즈로 테스트 환경을 구축했다. 도커 컴포즈 파일의 주요 설정을 살펴보자.
redis-master컨테이너- 볼륨을 사용해 프로젝트 폴더 내부에 레디스 설정 경로를 컨테이너 내부 설정 디렉토리로 연결한다.
- 마스터 인스턴스 설정을 사용해 레디스를 실행한다.
- 환경 변수를 사용해 복제 모드는 마스터, 비밀번호는 필요 없음으로 설정한다.
redis-slave-1컨테이너- 볼륨을 사용해 프로젝트 폴더 내부에 레디스 설정 경로를 컨테이너 내부 설정 디렉토리로 연결한다.
- 슬레이브 인스턴스 설정을 사용해 레디스를 실행한다.
- 환경 변수를 사용해 복제 모드는 슬레이브, 비밀번호는 필요 없음으로 설정한다.
redis-slave-2컨테이너도 동일한 방법으로 실행한다.
version: "3.9"
services:
redis-master:
hostname: redis-master
container_name: redis-master
image: redis
volumes:
- ./conf:/usr/local/etc/redis/
command: redis-server /usr/local/etc/redis/redis-master.conf
environment:
- REDIS_REPLICATION_MODE=master
- ALLOW_EMPTY_PASSWORD=yes
redis-slave-1:
hostname: redis-slave-1
container_name: redis-slave-1
image: redis
volumes:
- ./conf:/usr/local/etc/redis/
command: redis-server /usr/local/etc/redis/redis-slave-1.conf
environment:
- REDIS_REPLICATION_MODE=slave
- REDIS_MASTER_HOST=redis-master
- ALLOW_EMPTY_PASSWORD=yes
depends_on:
- redis-master
redis-slave-2:
hostname: redis-slave-2
container_name: redis-slave-2
image: redis
volumes:
- ./conf:/usr/local/etc/redis/
command: redis-server /usr/local/etc/redis/redis-slave-2.conf
environment:
- REDIS_REPLICATION_MODE=slave
- REDIS_MASTER_HOST=redis-master
- ALLOW_EMPTY_PASSWORD=yes
depends_on:
- redis-master
- redis-slave-1
backend:
build: .
ports:
- '8080:8080'
depends_on:
- redis-master
- redis-slave-1
- redis-slave-2
restart: on-failure
redis-master.conf 설정 파일에 포트 번호를 지정한다.
port 6379
슬레이브 인스턴스를 위한 설정 파일인 redis-slave-1.conf, redis-slave-2.conf에는 아래와 같이 복제할 마스터 인스턴스 정보를 추가한다.
- 4.X 버전까지는
slaveof였으며 5.X 버전부터replicaof로 변경되었다.
port 6379
replicaof redis-master 6379
다음 명령어로 컨테이너를 실행한다. 프로젝트에 미리 작성한 셸(shell) 스크립트를 실행한다.
$ sh shell/redis-replication.sh
...
[+] Running 5/4
⠿ Network action-in-blog_default Created 0.0s
⠿ Container redis-master Created 0.1s
⠿ Container redis-slave-1 Created 0.1s
⠿ Container redis-slave-2 Created 0.0s
⠿ Container action-in-blog-backend-1 Created 0.1s
컨테이너가 모두 실행되었다면 마스터 인스턴스가 멈췄을 때 어떻게 동작하는지 살펴보자. 도커 데스크톱(docker desktop)을 사용해 마스터 인스턴스를 실행 중지한다.
- 마스터 인스턴스를 중지시킨 후 읽기 연산은 정상적으로 동작한다.
- 새로 고침에 따라 리스트 크기 조회는 가능하다.
- 마스터 인스턴스를 중지시킨 후 쓰기 연산이 정상적으로 동작하지 않는다.
- 메시지 생성 불가능
- 읽지 않은 메시지 리스트 비우기 불가능
다음은 슬레이브 인스턴스가 멈췄을 때 어떻게 동작하는지 살펴보자. 마찬가지로 도커 데스크톱을 사용해 모든 슬레이브 인스턴스를 실행 중지한다.
- 모든 기능이 정상적으로 동작한다.
CLOSING
레디스의 레플리케이션만으로는 완벽한 고가용성 시스템을 구축하지 못한다. 마스터 인스턴스가 정지됨에 따라 시스템 대부분의 기능이 정상 동작하지 않았다. 더 나은 고가용성 시스템을 구축하기 위해 센티널(sentinel) 컴포넌트를 함께 사용한다. 센티널은 마스터, 슬레이브 인스턴스의 상태를 모니터링하면서 마스터 인스턴스가 죽었을 때 다른 슬레이브 인스턴스를 다시 마스터 인스턴스로 승격시킨다. 다음 포스트에서 센티널 적용과 관련된 내용을 정리할 예정이다.
댓글남기기