Redisson과 ReaciveRedissonClient 이슈

조갱·2023년 12월 10일
0

이슈 해결

목록 보기
13/14

분산락 (distributed lock)

서버의 1개의 공유자원에 N개의 작업자가 동시에 접근할 때, 원자성을 보장하기 위한 방법

쇼핑몰을 개발하다보면 위와 같이 원자성을 보장하기 위한 작업이 필수적으로 들어간다.

예를 들면

  • 한정된 재고에 대해 여러명이 동시에 결제를 진행
  • 주문의 상태가 변경을 동시에 요청 (쇼핑몰 관리자가 배송을 시작함과 동시에 고객이 주문 취소신청)
  • 쇼핑몰 설정을 여러 운영자가 동시에 변경
    ... 등등 수많은 사례가 발생한다.

내가 있는 주문파트에서는 기존에 Redis 를 활용해 분산락을 직접 구현하여 사용했다.
하지만 종종 분산락이 뚫리고 동시에 요청되는 경우가 간혹 생기곤 했다.

Redisson은 이러한 분산락 기능을 제공해주는 라이브러리이다.
위 이슈를 Redisson을 활용해 수정하면서 발생했던 Connection 이슈에 대해 소개하고자 한다.

배경지식

이번 이슈가 발생하기 위해서는 특정 조건이 필요하다.

  • RedissonReactiveClient 만 사용한다. (RedissonClient는 사용 X)
  • redis 접속 계정 정보를 properties가 아니라 configuration 으로 관리한다.
  • Redis Sentinel을 사용한다(?)

RedissonClient

Redisson 을 사용하기 위해서는 RedissonClient를 활용하면 된다.

> Build.gradle (.kts)

implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'

사실 얘만 사용하면 아무 이슈가 없다.

@Configuration(proxyBeanMethods = false)
class CacheConfiguration(
    ... // Spring Bean Autowired
) {
    @Bean
    fun redissonClient(): RedissonClient {
        return createRedissonClient()
    }

    private fun createRedissonClient(): RedissonClient {
        return Redisson.create(
            Config().also { config ->
                ... // 중략
                config.useSentinelServers()
                    .setUsername(USERNAME) // properties가 아닌 동적으로 할당
                    .setPassword(PASSWORD) // properties가 아닌 동적으로 할당
                    ... // 중략
            }
        )
    }
}

> 어플리케이션 시작 로그

16:40:56.840 [main] INFO  o.s.b.w.e.netty.NettyWebServer - Netty started on port 0000
16:40:56.933 [main] INFO  c.n.c.HidePrjNameApplicationKt - Started AdminApplicationKt in 31.092 seconds (JVM running for 29.599)

보다시피 잘 실행된다.

RedissonReactiveClient

우리는 Spring Webflux를 사용하기 때문에, Reactive한 통신 (Non-Blocking IO) 을 해야한다.
그러니까, 위에 Redisson도 Reactive로 변환해서 사용해보자. (짱쉽다.)

RedissonClient를 생성하고, 뒤에 .reactive() 만 붙여주면 끝이다.

@Configuration(proxyBeanMethods = false)
class CacheConfiguration(
    ... // Spring Bean Autowired
) {
    @Bean
    fun redissonReactiveClient(): RedissonReactiveClient {
        return createRedissonClient().reactive()
    }

    private fun createRedissonClient(): RedissonClient {
        // 이전 예제코드랑 동일하니까 지움
    }
}

그리고 어플리케이션을 실행해보면,,,?

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'redissonConnectionFactory' defined in class path resource
...
Unable to connect to Redis server: {IP 가림}/{IP 가림}:{PORT 가림}

Caused by: org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'redisson' defined in class path resource
...
Unable to connect to Redis server: {IP 가림}/{IP 가림}:{PORT 가림}

Caused by: org.springframework.beans.BeanInstantiationException:
Failed to instantiate [org.redisson.api.RedissonClient]
...
Unable to connect to Redis server: {IP 가림}/{IP 가림}:{PORT 가림}

Caused by: org.redisson.client.RedisAuthRequiredException: NOAUTH Authentication required...

뭔가 Bean을 못만드는것 같은데... 에러메시지를 보다보면
Unable to connect to Redis server
NOAUTH Authentication
이런 구문이 있다.

뭐가 문제지?

ACL 문제인가?

Unable to connect to Redis server라는 메시지를 보니, ACL문제인가? 싶었는데
ACL 문제는 아닌것 같다. 그 이유는,

  • RedissonClient 는 동일한 IP/Port 에도 접속이 잘됐다.
  • Redis 인스턴스로 Ping / telnet 둘 다 잘 된다.

계정 정보에 이슈가 있는것 같은데,,

위에 에러메시지에서, 마지막 에러메시지로 NOAUTH Authentication이 있었다.
결국 인증이 안돼서 -> 연결을 못해서 -> Unable to connect.. 로 예상했다.

Redisson을 뜯어보자

일단 break point를 걸어서, 실행되는 흐름들을 살펴보자.

  1. Redisson.java
public static RedissonClient create(Config config) {
    return new Redisson(config);
}
  1. Redisson.java
protected Redisson(Config config) {
    this.config = config;
    Config configCopy = new Config(config);

    connectionManager = ConfigSupport.createConnectionManager(configCopy);
    RedissonObjectBuilder objectBuilder = null;
    if (config.isReferenceEnabled()) {
        objectBuilder = new RedissonObjectBuilder(this);
    }
    commandExecutor = new CommandSyncService(connectionManager, objectBuilder);
    evictionScheduler = new EvictionScheduler(commandExecutor);
    writeBehindService = new WriteBehindService(commandExecutor);
}
  1. Redisson.java
@Override
public RedissonReactiveClient reactive() {
    return new RedissonReactive(connectionManager, evictionScheduler, writeBehindService, responses);
}
  1. Redisson.java
protected RedissonReactive(ConnectionManager connectionManager, EvictionScheduler evictionScheduler, WriteBehindService writeBehindService, ConcurrentMap<String, ResponseEntry> responses) {
    this.connectionManager = connectionManager;
    RedissonObjectBuilder objectBuilder = null;
    if (connectionManager.getCfg().isReferenceEnabled()) {
        objectBuilder = new RedissonObjectBuilder(this);
    }
    commandExecutor = new CommandReactiveService(connectionManager, objectBuilder);
    this.evictionScheduler = evictionScheduler;
    this.writeBehindService = writeBehindService;
    this.responses = responses;
}
  1. RedisAutoConfiguration.java
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean(RedissonClient.class)
    public RedissonClient redisson() throws IOException {
    Config config;
    Method clusterMethod = ReflectionUtils.findMethod(RedisProperties.class, "getCluster");
    Method usernameMethod = ReflectionUtils.findMethod(RedisProperties.class, "getUsername");
    Method timeoutMethod = ReflectionUtils.findMethod(RedisProperties.class, "getTimeout");
    Object timeoutValue = ReflectionUtils.invokeMethod(timeoutMethod, redisProperties);
    int timeout;
    if(null == timeoutValue){
        timeout = 10000;
    }else if (!(timeoutValue instanceof Integer)) {
        Method millisMethod = ReflectionUtils.findMethod(timeoutValue.getClass(), "toMillis");
        timeout = ((Long) ReflectionUtils.invokeMethod(millisMethod, timeoutValue)).intValue();
    } else {
        timeout = (Integer)timeoutValue;
    }

    String username = null;
    if (usernameMethod != null) {
        username = (String) ReflectionUtils.invokeMethod(usernameMethod, redisProperties);
    }

    if (redissonProperties.getConfig() != null) {
        try {
            config = Config.fromYAML(redissonProperties.getConfig());
        } catch (IOException e) {
            try {
                config = Config.fromJSON(redissonProperties.getConfig());
            } catch (IOException e1) {
                e1.addSuppressed(e);
                throw new IllegalArgumentException("Can't parse config", e1);
            }
        }
    } else if (redissonProperties.getFile() != null) {
        try {
            InputStream is = getConfigStream();
            config = Config.fromYAML(is);
        } catch (IOException e) {
            // trying next format
            try {
                InputStream is = getConfigStream();
                config = Config.fromJSON(is);
            } catch (IOException e1) {
                e1.addSuppressed(e);
                throw new IllegalArgumentException("Can't parse config", e1);
            }
        }
    } else if (redisProperties.getSentinel() != null) {
        Method nodesMethod = ReflectionUtils.findMethod(Sentinel.class, "getNodes");
        Object nodesValue = ReflectionUtils.invokeMethod(nodesMethod, redisProperties.getSentinel());

        String[] nodes;
        if (nodesValue instanceof String) {
            nodes = convert(Arrays.asList(((String)nodesValue).split(",")));
        } else {
            nodes = convert((List<String>)nodesValue);
        }

        config = new Config();
        config.useSentinelServers()
            .setMasterName(redisProperties.getSentinel().getMaster())
            .addSentinelAddress(nodes)
            .setDatabase(redisProperties.getDatabase())
            .setConnectTimeout(timeout)
            .setUsername(username)
            .setPassword(redisProperties.getPassword());
    } else if (clusterMethod != null && ReflectionUtils.invokeMethod(clusterMethod, redisProperties) != null) {
        Object clusterObject = ReflectionUtils.invokeMethod(clusterMethod, redisProperties);
        Method nodesMethod = ReflectionUtils.findMethod(clusterObject.getClass(), "getNodes");
        List<String> nodesObject = (List) ReflectionUtils.invokeMethod(nodesMethod, clusterObject);

        String[] nodes = convert(nodesObject);

        config = new Config();
        config.useClusterServers()
            .addNodeAddress(nodes)
            .setConnectTimeout(timeout)
            .setUsername(username)
            .setPassword(redisProperties.getPassword());
    } else {
        config = new Config();
        String prefix = REDIS_PROTOCOL_PREFIX;
        Method method = ReflectionUtils.findMethod(RedisProperties.class, "isSsl");
        if (method != null && (Boolean)ReflectionUtils.invokeMethod(method, redisProperties)) {
            prefix = REDISS_PROTOCOL_PREFIX;
        }

        config.useSingleServer()
            .setAddress(prefix + redisProperties.getHost() + ":" + redisProperties.getPort())
            .setConnectTimeout(timeout)
            .setDatabase(redisProperties.getDatabase())
            .setUsername(username)
            .setPassword(redisProperties.getPassword());
    }
    if (redissonAutoConfigurationCustomizers != null) {
        for (RedissonAutoConfigurationCustomizer customizer : redissonAutoConfigurationCustomizers) {
            customizer.customize(config);
        }
    }
    return Redisson.create(config);
}
  1. Redisson.java
public static RedissonClient create(Config config) {
    return new Redisson(config);
}
  1. Redisson.java
protected Redisson(Config config) {
    this.config = config;
    Config configCopy = new Config(config);

    connectionManager = ConfigSupport.createConnectionManager(configCopy);
    RedissonObjectBuilder objectBuilder = null;
    if (config.isReferenceEnabled()) {
        objectBuilder = new RedissonObjectBuilder(this);
    }
    commandExecutor = new CommandSyncService(connectionManager, objectBuilder);
    evictionScheduler = new EvictionScheduler(commandExecutor);
    writeBehindService = new WriteBehindService(commandExecutor);
}
  1. Exception이 터짐

분석 결과,,,

1,2,3,4 : RedissonClient를 만들고, 그 결과로 RedissonReactiveClient를 만든다.
5,6,7 : 갑자기 RedissonClient 설정과 객체를 다시 만든다,,?
8 : Exception이 터짐

여기서, 추가적인 디버깅 정보를 풀어보자면
2번에서 connectionManager 변수에는 username과 password가 들어있던 반면,
7번의 connectionManager 변수에는 username과 password가 없었다.

원인 공개

5번 과정을 보면 중간에 이런 부분이 있다.

else if (redisProperties.getSentinel() != null) {
        Method nodesMethod = ReflectionUtils.findMethod(Sentinel.class, "getNodes");
        Object nodesValue = ReflectionUtils.invokeMethod(nodesMethod, redisProperties.getSentinel());

        String[] nodes;
        if (nodesValue instanceof String) {
            nodes = convert(Arrays.asList(((String)nodesValue).split(",")));
        } else {
            nodes = convert((List<String>)nodesValue);
        }

        config = new Config();
        config.useSentinelServers()
            .setMasterName(redisProperties.getSentinel().getMaster())
            .addSentinelAddress(nodes)
            .setDatabase(redisProperties.getDatabase())
            .setConnectTimeout(timeout)
            .setUsername(username)
            .setPassword(redisProperties.getPassword());
    }

그렇다,, username과 password를 configuration에서 설정한 값이 아니라
redisProperties 에서 가져오고 있던것이다.
그래서 username, password가 없었고 NOAUTH ... 익셉션이 발생했다.

저게 왜 실행됐지?

그러면, 1~4번 과정에서 RedissonReactiveClient를 이미 잘 만들었는데
5~7번 과정에서 RedissonClient를 왜 한번 더 만들었을까?

5번의 위에서 2번째 줄을 보면 아래와 같은 어노테이션을 볼 수 있다.
@ConditionalOnMissingBean(RedissonClient.class)
-> RedissonClient 클래스의 빈이 없다면, 새로 만들어라.

그렇다. 우리는 RedissonReactiveClient 빈만 정의하고, RedissonClient는 별도로 만들지 않아서 AutoConfiguration에 의해 원치 않는 새로운 Bean이 만들어졌다.

해결 방법

간단하게,, 그냥 RedissonClient 빈도 하나 만들어줬다.

@Configuration(proxyBeanMethods = false)
class CacheConfiguration(
    ... // Spring Bean Autowired
) {
    @Bean
    fun redissonClient(): RedissonClient {
        return createRedissonClient()
    }

	@Bean
    fun redissonReactiveClient(): RedissonReactiveClient {
        return createRedissonClient().reactive()
    }

    private fun createRedissonClient(): RedissonClient {
        // 이전 예제코드랑 동일하니까 지움
    }
}

> 실행 결과

16:40:56.840 [main] INFO  o.s.b.w.e.netty.NettyWebServer - Netty started on port 0000
16:40:56.933 [main] INFO  c.n.c.HidePrjNameApplicationKt - Started AdminApplicationKt in 28.143 seconds (JVM running for 27.558)

분산락과 Redisson에 대해서는 검색해보면 잘 설명되어있는 블로그가 많아요~
다음에 시간이 된다면 저도 정리해볼게요~

profile
A fast learner.

0개의 댓글