Spring Boot에 Redis Cluster 적용기 (w. lettuce, redisson)

komment·2024년 7월 11일
11

2024 개발 일지

목록 보기
4/6
post-thumbnail

포스팅에 첨부된 코드는 예시 코드임을 명시합니다.

서론

  여느 날처럼 회사에서 평화롭게 키보드를 뚱땅대고 있는데 팀장님께서 자리로 오셨다.

👨🏻: 현석님, Redis 도입해서 이것저것 해주세요~

👶🏻: 넵!

  Redis를 활용할 수 있는 부분은 많았지만, 서비스 런칭이 얼마 남지 않았기에 분산락이나 간단한 캐싱 기능을 구현하는 데에 활용하였다. QA VPC와 Prod VPC 내부에 각각 Aws ElastiCache를 생성하여 환경을 구축하였다. 배포 성공 표시를 보며 그렇게 잘 마무리 된줄 알았다.

  그런데...

org.springframework.data.redis.RedisSystemException: Error in execution
    at org.springframework.data.redis.connection.lettuce.LettuceExceptionConverter.convert(LettuceExceptionConverter.java:52)
    at org.springframework.data.redis.connection.lettuce.LettuceExceptionConverter.convert(LettuceExceptionConverter.java:50)
    at org.springframework.data.redis.connection.lettuce.LettuceExceptionConverter.convert(LettuceExceptionConverter.java:41)

  위와 같은 에러가 발생했다는 Slack 알림이 울리기 시작했다. 이것뿐이 아니다.

org.redisson.client.RedisTimeoutException: 

Command still hasn't been written into connection! 
Check CPU usage of the JVM. Check that there are no blocking invocations in async/reactive/rx listeners or subscribeOnElements method. 
Check connection with Redis node: /* 생략 */

  Redisson Connection과 관련된 TimeOut 에러도 발생했다. 실제로 회원 앱의 경우, Redisson 연결 문제 때문에 간단한 동작도 오래 걸리거나 작동하지 않았다. 따라서 해당 이슈를 티켓을 끊고 우선순위를 Highest로 두어 삽질을 하기 시작했다.

왜 커넥션 에러가 났을까?

  문제는 간단했다.

io.lettuce.core.rediscommandexecutionexception: MOVED 8024 {IP}:{PORT}

  MOVED 에러는 Redis Cluster Client가 Cluster를 인식하지 못하고 기본 노드에 대한 리디렉션 요청을 처리할 수 없을 때 발생한다. ElastiCache를 클러스터 모드로 사용하고 있었는데, RedisConnectionFactory를Standalone 모드로 설정해서 발생한 것이다.

@EnableRedisRepositories
@Configuration
public class RedisConfig {

	@Value("${spring.data.redis.port}")
	private int port;
    @Value("${spring.data.redis.host}")
	private String host;
    @Value("${spring.data.redis.password}")
	private String password;

	@Bean
	public RedisConnectionFactory redisConnectionFactory() {
    	final RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);
		redisStandaloneConfiguration.setPassword(password);
        
		return new LettuceConnectionFactory(redisStandaloneConfiguration);
	}

	@Bean
	public RedissonClient redissonClient() {
		final Config config = new Config();
        final String address = "redis://%s:%d".formatted(host, port);
        
		config.useSingleServer().setAddress(address);

		return Redisson.create(config);
	}
}

  Redisson 또한 SingleServer를 사용했기에 커넥션 에러가 발생한 것이었다.

  그럼, Redis의 모드와 Cluster 모드에 대해 간단히 알아보고, Cluster 모드에 대한 설정에 대해 알아보자.

Redis Mode는 뭐가 있을까?

  Redis에는 Standalone, Sentinel, Cluster, 총 세 가지의 모드가 존재한다.

Standalone 모드

  하나의 Redis로 서비스 하는 모드이다.

Sentinel 모드

  Sentinel이 존재하는 모드다. Sentinel은 직역하면 감시자라는 뜻으로, Redis의 Master/Slave를 모니터링 하는 서버다. Master 노드가 비정상적으로 종료됐을 때, Slave 노드를 Master 노드로 승격시키기 위해 자동 Failover를 진행한다.

  각 Sentinel 인스턴스는 Redis의 모든 노드와 연결돼 있고, 감시한다. Sentinel 노드 중 과반수가 찬성해야 Failover가 진행되는데, 정상적인 기능을 위해 최소 3개의 Sentinel 인스턴스가 필요하고, 홀수개로 구성해야 한다.

Cluster 모드

  Redis Cluster는 여러 노드에 데이터를 자동 분산 하고, 일부 노드에 결함이 있어도 계속 운영되는 가용성을 제공한다. 또한 고성능을 보장하면서도 선형 확장성을 제공한다.

왜 클러스터 모드를 사용할까?

  먼저 스케일 업스케일 아웃에 대해 이해할 필요가 있다. 트래픽이 많아지면, 특히 키의 이빅션(eviction)이 자주 발생한다면 용량 및 성능을 늘리기 위한 시스템 확장을 고려해야 하는데, 스케일 업은 하드웨어의 허용 범위까지만 확장이 가능하다. 특히 단일 스레드로 동작하는 Redis는 병렬 처리가 불가능 하기에 결함이 발생하기 쉽다. 만약 Standalone 모드의 Redis 인스턴스에 결함이 발생할 경우 운영 환경 전체에 장애로 다가올 수 있다.

  Redis 클러스터 모드는 아키텍처 변경 없이 Redis 인스턴스 간 수평 확장이 가능하게 해준다. 또, 데이터의 분산 처리, 복제, 자동 Failover 기능을 제공한다.

  그럼, Redis 클러스터의 동작 방법에 대해 간단히 알아보자.

Redis 클러스터의 동작 방법

해시슬롯을 이용한 데이터 샤딩

  클러스터 구조에서 모든 데이터는 해시슬롯에 저장된다. Redis는 총 16,384개의 해시슬롯을 가지고, 마스터 노드는 이 해시슬롯을 나눠 갖는다. 만약 3대의 Master 노드로 클러스터를 구성했을 때 해시슬롯을 아래와 같다.

  Redis에 입력된 모든 키는 CRC16으로 암호화 후 하나의 해시슬롯에 매핑되는데, 아래는 해시함수다.

HASH_SLOT = CRC16(key) mod 16384

  해시슬롯은 마스터 노드 내에서 자유롭게 옮겨질 수 있고, 옮겨지는 중에도 데이터에 대해 정상적인 접근이 가능하다. 때문에 클러스터 내 마스터 노드의 추가 및 삭제는 굉장히 간단하게 처리된다.

해시태그

  클러스터를 사용할 때는 MGET과 같은 다중키 커맨드를 사용할 수 없다. 클러스터는 키를 통해 커맨드를 처리할 Master로 클라이언트의 연결을 Redirect 하기 때문이다.

  이 때 해시태그 기능을 활용하면 이러한 문제를 해결할 수 있다. 키에 대괄호를 사용하면 전체 키가 아닌 대괄호 사이에 있는 값을 이용해 해시할 수 있는데, 이 기능을 해시태그라고 한다. 해시태그를 통해 다중 키 커맨드를 사용할 수는 있지만, 너무 많은 키가 같은 해시태그를 갖는다면 하나의 해시슬롯에 데이터가 몰릴 수 있기 때문에 경우에 따라 키의 분배에 대한 모니터링이 필요할 수 있다.

자동 재구성

  Sentinel 모드와 마찬가지로 Cluster 구조에서도 복제와 Failover를 통해 고가용성을 보장한다. Cluster 구조에서는 Sentinel 인스턴스 대신 일반 Redis 노드가 Cluster Bus를 통해 통신하고 서로를 감시하며, 인스턴스에 문제가 생겼을 때 자동으로 구조를 재구성 한다.

  Redis Cluster의 재구성은 크게 두 가지가 있다. 먼저 자동 페일오버(Auto Failover)다. 위의 그림처럼 Master 1에 장애가 생겼을 때 Replica 1이 다른 Master 노드들에게 Failover를 시도해도 될지 투표 요청을 보낸다. 그리고 과반수 이상 투표를 받을 시 Replica 1이 Master로 승격된다.

  두번째는 자동 복제본 마이그레이션(Auto Replica Migration)이다. Master 1이 장애가 발생하며 Replica 1이 Auto Failover를 통해 Master로 승격되었다. 이 때 복제본이 없는 현상이 벌어지는데, Master 3가 열견된 2개의 복제본 중 Replica 4를 Replica-1의 복제본이 되게 이동 시킨다. 이를 통해 모든 Master가 적어도 1개 이상의 복제본에 의해 복제되는 것을 보장하며, 클러스 전체의 안정성을 향상 시킨다. 그리고 이 때 FAIL 상태가 아닌 복제본 중 노드 ID가 가장 작은 복제본이 이동될 노드로 선택된다.

Cluster 모드는 항상 옳을까?

  위와 같은 많은 장점을 가지고 있지만 당연히 클러스터 모드를 사용했을 때 데이터 사용량이 많은 만큼 비용도 어마어마 하다. 따라서 서비스의 규모나 트래픽 수치 등 여러 가지를 고려하여 도입 및 설정, 관리해야 한다.

클러스터 모드를 설정해보자.

RedisClusterProperties

  먼저 아래와 같이 yaml 파일을 구성한다.

spring:
	data:
        redis:
            password: ${PASSWORD}
            cluster:
                max-redirects: ${MAX_REDIRECTS}
                nodes:
					${NODE1_IP}:${NODE1_PORT},
					${NODE2_IP}:${NODE2_PORT},
                    . . .

  Redis 클러스터는 데이터를 각 노드에 분산 저장하는데, 찾으려는 값이 없을 경우 Redirect를 통해 다른 노드로 이동한다. 따라서 max-redirects 값을 지정해주었고, 기본값은 3이다. 설정값이 너무 높을 경우, 무한 Redirect가 되어 성능에 크리티컬한 영향을 미칠 수 있기 때문에 잘 설정해줘야 한다.

@Profile(value = {"prod", "qa"})
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.data.redis.cluster")
public class RedisClusterProperties {

	private String password;
	private int maxRedirects;
	private List<String> nodes;
}

  설정한 값들을 RedisClusterProperties에 담아주었다. 이 때, @Profile 어노테이션을 통해 원하는 환경을 지정해주었다.

RedisConnectionFactory 및 Lettuce 설정

	. . .
	@Bean
	public RedisConnectionFactory redisConnectionFactory() {
		RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port);
		redisStandaloneConfiguration.setPassword("password");
		return new LettuceConnectionFactory(host, port);
	}
    . . .

  위의 코드는 Standalone 모드의 Redis 설정이다. 정말 간단하게 설정을 끝낼 수 있다. 하지만 Cluster 모드에서는 다양한 기능을 제공해주는 만큼, 이것저것 신경 쓸 요소가 많다. 아래는 Cluster 모드의 Redis 설정이다.

@Profile(value = {"prod", "qa"})
@EnableRedisRepositories
@RequiredArgsConstructor
@Configuration
public class RedisConfig {

	private final RedisClusterProperties redisClusterProperties;

	@Bean
	public RedisConnectionFactory redisConnectionFactory() {
    	final List<String> nodes = redisClusterProperties.getNodes();
        final String password = redisClusterProperties.getPassword();
    	final int maxRedirects = redisClusterProperties.getMaxRedirects();     
		final List<RedisNode> redisNodes = nodes.stream()
			.map(node -> new RedisNode(node.split(":")[0], Integer.parseInt(node.split(":")[1])))
			.toList();
		
        // (1) Redis Cluster 설정
		RedisClusterConfiguration clusterConfiguration = new RedisClusterConfiguration();
		clusterConfiguration.setClusterNodes(redisNodes);
		clusterConfiguration.setMaxRedirects(maxRedirects);
        clusterConfig.setPassword(password);
        
        // (2) Socket 옵션
        SocketOptions socketOptions = SocketOptions.builder()
			.connectTimeout(Duration.ofMillis(100L))
			.keepAlive(true)
			.build();

		// (3) Cluster topology refresh 옵션
		ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
        	.dynamicRefreshSources(true)
			.enableAllAdaptiveRefreshTriggers()
			.enablePeriodicRefresh(Duration.ofMinutes(30L))
			.build();

		// (4) Cluster Client 옵션
		ClientOptions clientOptions = ClusterClientOptions.builder()
			.topologyRefreshOptions(clusterTopologyRefreshOptions)
			.socketOptions(socketOptions)
			.build();

		// (5) Lettuce Client 옵션
		LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder()
			.clientOptions(clientOptions)
			.commandTimeout(Duration.ofMillis(3000L))
            .build();

		return new LettuceConnectionFactory(clusterConfiguration, clientConfiguration);
	}
}

  Redis에 연결하기 위해선 RedisConnectionFactory가 필요하다. RedisConnectionFactory가 Bean으로 생성되는 코드를 보면 우리가 설정할 수 있는 부분은 크게 Redis Cluster, Socket 옵션, Cluster topology refresh 옵션, 그리고 Cluster Client 옵션이 있다.

(1) Redis Cluster 설정

RedisClusterConfiguration clusterConfiguration = new RedisClusterConfiguration();
clusterConfiguration.setClusterNodes(redisNodes);
clusterConfiguration.setMaxRedirects(maxRedirects);
clusterConfig.setPassword(password);

  먼저 기본적인 Redis Cluster 설정을 해줘야 한다. RedisClusterConfiguration 인스턴스를 생성하여 Redis 노드들을 등록해주고, max-redirects를 설정해준다. 또, 보안을 위해 password를 설정해주었다.

(2) Socket 옵션

SocketOptions socketOptions = SocketOptions.builder()
	.connectTimeout(Duration.ofMillis(100L))
	.keepAlive(true)
	.build();

  Lettuce는 Redis 인스턴스와 통신하기 위해 Socket을 활용한다. 이 때 Keep Alive와 connecention Time을 설정해주는 것이 좋다.

  • connectTimeout(Duration.ofMillis(100L))
    • 소켓 연결 시간 초과 설정
    • 네트워크 환경에 문제가 생긴 경우, 빠른 포기를 통해 연쇄적인 장애를 막을 수 있음
  • keepAlive(true)
    • 소켓 연결이 일정 시간 동안 사용되지 않더라도 TCP Connection 유지
    • 주기적으로 패킷을 보내 Ack(Acknowledgment) 수신
    • Ack를 일정 시간 안에 받지 못하면 종료

(3) Cluster topology refresh 옵션

ClusterTopologyRefreshOptions clusterTopologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
    .dynamicRefreshSources(true)
	.enableAllAdaptiveRefreshTriggers()
	.enablePeriodicRefresh(Duration.ofMinutes(30L))
	.build();

  Redis Cluster는 여러 노드로 구성되고, 노드에 대한 추가 및 삭제, Failover 등의 이벤트가 발생한다. 이 때 포톨로지가 변경되는데, Redis와 연결되는 클라이언트 또한 이 정보를 알기 위해 동기화 해야 한다. ClusterTopologyRefreshOptions는 Redis Cluster의 토폴로지 갱신을 제어하기 위한 설정을 제공한다. Cluster 토폴로지는 노드 구성, 슬롯 할당, 노드 상태 등의 정보를 포함하고, 이를 정기적으로 또는 이벤트 기반으로 갱신하여 클러스터의 최신 상태를 유지한다.

  • dynamicRefreshSources(true)
    • 클러스터의 동적 소스 갱신 활성화
    • 클러스터 노드가 갱신될 때, 새로운 노드가 자동으로 갱신 소스 목록에 추가되며, 삭제된 노드는 목록에서 제거
    • false일 경우 Redis 클라이언트가 seed 노드에만 질의하여 새로운 노드를 찾음
    • 대규모 Redis 클러스터에서는 false 추천
  • enableAllAdaptiveRefreshTriggers()
    • 적응형 갱신 트리거 모두 활성화
    • 트리거는 다음 목록을 포함할 수 있음
      • MOVED_REDIRECT
      • ASK_REDIRECT
      • PERSISTENT_RECONNECTS
      • UNCOVERED_SLOT
      • UNKNOWN_NODE
  • enablePeriodicRefresh(Duration.ofMinutes(30L))
    • 30분 마다 Cluster 토폴로지를 업데이트
    • 비활성화 시, 명령을 실행하고 오류가 발생할 때만 업데이트
    • 너무 짧은 주기는 Redis Cluster 전체에 부하를 줄 수 있음

(4) Cluster Client 옵션

ClientOptions clientOptions = ClusterClientOptions.builder()
	.topologyRefreshOptions(clusterTopologyRefreshOptions)
	.socketOptions(socketOptions)
	.build();

  (2)와 (3)에서 설정한 옵션들을 통해 Client 옵션을 설정해주었다.

(5) Lettuce Client 옵션

LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder()
	.clientOptions(clientOptions)
	.commandTimeout(Duration.ofMillis(3000L))
    .build();

  (4)에서 설정한 Client 설정을 활용하여 Lettuce Client 옵션을 설정해주었다. Lettuce 라이브러리는 지연 연결을 사용하고 있다. 따라서 Command Timeout 값을 Connection Timeout 값보다 크게 설정해줘야 한다.

Redisson 설정

  Redisson 또한 Cluster 모드를 설정해줘야 한다.

@Profile(value = {"prod", "qa"})
@RequiredArgsConstructor
@Configuration
public class RedissonConfig {

	private final RedisClusterProperties redisClusterProperties;

	@Bean
	public RedissonClient redissonClient() {
		final Config config = new Config();

		ClusterServersConfig csc = config.useClusterServers()
            .setScanInterval(2000)
            .setConnectTimeout(100)
   			.setTimeout(3000)
			.setRetryAttempts(3)
			.setRetryInterval(1500);

		nodes.forEach(node -> csc.addNodeAddress(REDISSON_PREFIX + node));

		return Redisson.create(config);
	}
}
  • setScanInterval(2000)
    • Cluster의 토폴로지 스캔 간격을 설정
  • setConnectTimeout(100)
    • Connection 연결에 대한 타임아웃 설정
  • setTimeout(3000)
    • Command에 대한 타임아웃 설정
  • setRetryAttempts(3)
    • 명령 재시도 횟수 설정
  • setRetryInterval(1500)
    • 명령 재시도 간격 설정

  이렇게 설정을 마치고 배포하고 나서야 잘 작동되는 것을 확인할 수 있었다.

회고

  우습게도 나는 Redis에 대한 클라이언트 구성에 대해 자신이 있었다. 사이드 프로젝트에 많이 사용해봤었기 때문인데, 단지 Standalone 모드만을 사용해보고 가졌던 자신감이었다. 이번 이슈를 해결하면서 Redis Client Application 측면뿐 아니라 Redis와 Redis Cluster에 대한 학습도 많이 이루어진 것 같다. (실제로 많은 레퍼런스를 찾아보고, 도서까지 구매하여 읽었다.)

  쉽진 않았지만, 성장에 꼭 있어야 할 경험이었고, 이후에도 Redis에 대해 더 깊이 학습 해봐야겠다.


Ref

profile
안녕하세요. 서버 개발자 komment 입니다.

0개의 댓글