ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ShMarket - Redis 캐싱 전략과 장애 대비
    ShMarket 2021. 7. 31. 20:22

    ShMarket 프로젝트에서는 Redis를 사용하고 있습니다.

    그로 인해 발생할 수 있는 문제점들과 해결 방법을 생각해보는 포스팅입니다.

     

    Redis를 도입한 이유

    1. ShMarket에서 커뮤니티 게시글, 상품 게시글을 조회하기 위해 현재 사용자의 주변 동네 리스트 조회가 필수지만 

    가장 요청이 많은 기능으로 주변 동네를 구하는 것은 전국 행정동 DB를 계속해서 조회해야 하기 때문에 속도 저하가 발생할 수 있을 거라 생각해 Redis 캐시에 저장해서 사용하기 위해 선택

     

    2. String뿐만 아닌 다양한 데이터 타입을 사용하기 때문


    Redis가 관여하고 있는 기능

    1. 사용자의 주변 동네 저장

    2. 사용자의 정보 저장 ( FCM 토큰, 사용자 ID )

    3. 사용자의 최근 검색 ( 구현 예정 )


    Redis 캐싱 전략 

    1. Look Aside 

     

    Look Aside 구조는 캐시 (Redis)를 옆에 두고 캐시가 필요할 때에만 데이터를 캐시에 로드하는 전략입니다.

    어플리케이션과 데이터베이스 사이에 캐시를 배치하고, 단순 Key- Value 형태를 저장합니다. 

    클라이언트로부터 요청이 와 데이터를 조회하는 경우 요청 데이터가 캐시에 존재한다면 해당 데이터를 바로 반환합니다. 데이터가 캐시에 존재하지 않다면 어플리케이션에서 데이터베이스로 데이터를 요청하고 어플리케이션은 해당 데이터를 레디스에 저장하는 프로세스를 가지고 있습니다.

     

    Look Aside

     

    Look Aside 장점

    1. 실제로 사용되는 데이터만 캐시

    2. Redis의 장애가 서비스에 치명적인 영향을 주지 않을 수 있음

    3. 요청 데이터가 캐시에 존재할 때 속도가 보다 빠름

     

    Look Aside 단점

    1. 캐시 데이터가 최신 상태임을 보장하지 못함

    2. 요청 데이터가 캐시에 존재하지 않을 때 캐시에 다시 저장해야하기에 DB 조회만 하는 것보다 더 오랜 시간이 걸림

     

    2. Write-Through

     

    Write-Through 구조는 데이터베이스에 데이터를 작성할 때마다 캐시에 데이터를 추가하거나 업데이트 합니다.

    이 구조를 통해 1번 방법인 Look Aside와 달리 캐시의 데이터가 최신 상태임을 보장할 수 있습니다. 

    하지만 데이터를 입력할 때 두 번의 과정을 거쳐야 하기에 지연 시간이 증가하고, 사용하지 않아도 되는 데이터도 캐시에 저장하기 때문에 자원 낭비가 발생하게 됩니다.

    Write Through

     

    Write-Through 장점

    1. 캐시 데이터가 최신 상태임을 보장함

     

    Write-Through 단점

    1. 과정의 추가로 지연 시간 증가

    2. 실제로 사용되지 않는 데이터도 캐시 하기 때문에 자원 낭비 발생 ( TTL을 설정해 데이터 삭제 권장 )


    현재 시나리오

    주요 서비스 레디스 저장 코드

           Area getArea = areaRepository.findByAddress(joinRequestDto.getArea());
    
            List<String> nearArea = new ArrayList<>();
            list.forEach(l -> {
                double distanceMeter =
                        distance(Double.parseDouble(getArea.getLat()), Double.parseDouble(getArea.getLng()), Double.parseDouble(l.getLat()), Double.parseDouble(l.getLng()), "meter");
                if (distanceMeter <= 3000) {
                    nearArea.add(l.getDong());
                }
            });
            ListOperations<String, String> vo = redisTemplate.opsForList();
            if (!vo.getOperations().hasKey(getArea.getAddress() + "::List")) {
                vo.leftPushAll(getArea.getAddress() + "::List", nearArea);
            }

    회원가입 시 저장한 거주지 기준으로 

    행정동 DB 데이터의 위도, 경도로 거리를 계산(distance 함수)해 3000미터 이내의 행정동을 Redis에 List 형태로 저장합니다.

     

    문제점

    1. TTL을 지정하지 않고 Redis를 영구적 저장소로 사용하고 있음

    - 데이터를 파일로 보관하기 위한 persistence 기능 (RDB, AOF)로 인한 장애 발생 가능성이 높아지고, 파일이 생성되지 않으면 Redis에 write를 할 수 없는 등의 장애가 발생할 수 있기 때문입니다.

     

    2. Redis 프로세스 다운되었을 때 

    2.1. 많은 사용자들의 상품, 커뮤니티 리스트를 조회하기 위한 요청 ( 레디스 캐시를 이용하는 요청  )

    2.2. 주변 동네 리스트를 가져오지 못한다면 사용자는 아무런 게시글을 조회할 수 없는 상황이 발생할 수 있음

    2.3. 레디스 데이터를 가져올 수 없으니 DB에 조회 요청 ( 전국 행정동 DB 스캔 )

     

    3. Redis 장애가 발생한다면 FCM 토큰 또한 Redis에 저장해 보관하기 때문에 푸시 알림 서비스도 장애가 발생함

     

    이처럼 성능이 저하되고, 큰 문제들이 발생할 여지가 존재하기에 이를 조금이라도 회피하는 방법을 고민해보았습니다.


    문제점 1의 해결 방법 

    Redis를 영구 저장소로 사용하지 않도록 TTL을 넣어 주기적 데이터 삭제와 추가하도록 변경

     

    Look Aside 전략으로 캐싱할 예정

    캐시 데이터의 최신 상태임을 보장하지 않다고 된다고 생각함

    1. 저장될 데이터인 주변 동네 리스트는 공식 행정동이기에 쉽게 변경되지 않기 때문에 

    2. FCM 토큰 또한 하나의 클라이언트에서 하나의 토큰만 발급되어 쉽게 변경되지 않기 때문에 ( 변경된다 하더라도 로그인 로직에서 처리 )

     

    주변 동네 리스트를 사용해야 할 때

    1. Redis에 존재하는 경우 -> 그대로 사용 

    2. Redis에 존재하지 않는 경우-> 재탐색 후 캐시에 저장


    문제점 2,3의 해결 방법

    Redis High Availability - Redis Replication 사용 

    Redis를 단일 구성인 Stand-alone으로 사용할 때 레디스 노드에 문제가 생길 경우 앞서 작성한 문제점 2,3번이 발생할 수 있다고 생각하기에 Replication을 적용해보고자 했습니다.

     

    Redis Replication이란

    레디스는 Master - Replica 형태의 복제를 제공합니다.

    Replication이 되어있는 동안 Master 노드의 데이터는 실시간으로 Replica 노드에 복사됩니다.

    그렇기에 서비스를 제공하던 Master 노드에 문제가 생겼을 때 Replica 노드를 어플리케이션에 재 연결함으로 서비스를 이어나갈 수 있습니다.

    Replication 하나의 Master에 2개의 Replica 노드

    위 사진과 같이 하나의 Master에 복수의 Replica 노드를 생성할 수 있으며, Replica 노드에 다른 Replica 노드를 연결하는 것도 가능합니다.

    주의할 점은 다중 Master를 생성할 수 없고 하나의 그룹에서는 하나의 Master 노드만 존재할 수 있습니다.

    연결된 상태

    데이터 복제는 비동기 방식으로 이루어지게 됩니다. Master에 데이터가 추가되면 Master는 어플리케이션에 신호를 보내게 되고 그 후 Replica 노드에 데이터를 전달합니다. 

     

    그렇기에 Master 까지만 데이터가 추가된 후 Master 노드에 문제가 발생한다면 Replica 노드에 데이터가 복제되지 않아 데이터 유실이 발생할 가능성이 있습니다.

     

     

    Replication  테스트

     

    마스터 노드로 선택한 testRedis의 데이터가 없는 초기 모습

    $ redis-cli -h testRedis
    testRedis:6379> keys *
    (empty array)

    복제 노드인 testRedis1 , testRedis2의 데이터가 없는 초기 모습

    $ redis-cli -h testRedis1
    testRedis1:6379> keys *
    (empty array)
    $ redis-cli -h testRedis2
    testRedis2:6379> keys *
    (empty array)

    마스터 노드로 선택한 testRedis에 key : "test", value: "testRedis" 데이터를 추가

    $ redis-cli -h testRedis
    testRedis:6379> set test testRedis
    OK

    복제 노드인 testRedis1, testRedis2에 마스터 노드인  testRedis의 값이 복제된 모습을 확인할 수 있었습니다.

    $ redis-cli -h testRedis1
    testRedis1:6379> keys *
    1) "test"
    testRedis1:6379> get test
    "testRedis"
    $ redis-cli -h testRedis2
    testRedis2:6379> keys *
    1) "test"
    testRedis2:6379> get test
    "testRedis"

     

    Redis Sentinel

     

    Redis Replication을 적용해 어플리케이션과 마스터 노드를 연결한 상태에서 

    레디스 프로세스가 다운된다면 

    1. 복제 노드와 마스터 노드의 연결 해제

    2. 어플리케이션과 레디스 연결 설정 변경 ( 마스터 노드 -> 복제 노드로 변경 )

    위와 같은 과정을 직접 수행해야 합니다.

     

    그렇기에 실제 운영되는 서비스에서 이를 처리하기까지 데이터가 유실될 가능성이 있습니다.

     

    이런 상황을 회피할 수 있게 도움을 주는 것을 찾다 Redis Sentinel을 알게 되었습니다.

     

    Redis Sentinel은 설정된 마스터 노드에 장애가 발생해 종료되었을 때 자동으로 복제 노드 중 하나를 선택해 마스터 노드로 승격시키는 역할 ( Failover )을 합니다. 

     

    Redis Sentinel은 다음과 같은 동작을 지원합니다

    - 마스터 및 복제 노드가 제대로 동작하는지 지속적으로 ( 선택 ) 점검

    - 마스터 노드가 제대로 동작하지 않는 경우가 발생한다면 복제 노드 중 하나를 선택해 마스터 노드로 승격시키고 새롭게 선택된 마스터 노드에 대한 새 엔드포인트를 제공

     

    주의해야 할 점

    Sentinel은 마스터 노드가 죽었을 경우 각 Sentinel 당 장애인지, 정상인지 판단하게 되는데,

    장애 판단이 과반수가 넘을 경우 Failover를 진행하게 됩니다. 하지만 Sentinel을 짝수로 설정하는 경우 

    장애 판단 2, 정상 판단 2와 같이 과반수(Quorum)가 정해지지 않는다면 Failover를 결정할 수 없는 문제가 발생할 수 있습니다.

    그렇기에 Sentinel은 적어도 3개가 필요하고, 홀수로 배치하는 것이 좋은 것 같습니다.

    그리고 어플리케이션은 Redis의 Master, Replica 노드가 아닌 Sentinel에 접근해주어야 정상적으로 사용이 가능함을 확인할 수 있었습니다. 

     

    Failover 과정

    위에서 구성된 Replication과 같이 1개의 Master, 2개의 Replica, 3개의 Sentinel 존재 

    1. Master 노드에 문제 발생

    2. Master 노드를 감시하고 있던 Sentinel이 감지

    3. Master 노드에 연결할 수 없는지에 대한 투표 시작

    4. 과반수 이상이 문제가 생긴 상황이라고 판단하면 Failover 시작

    5. Replica 노드 중 하나를 Master 노드로 승격

    6. Master 노드가 복구되었다면 승격된 새로운 Master 노드의 Replica 노드로 연결

     

    Sentinel 테스트

     

    테스트를 해보기 위한 docker-compose 설정입니다.

    docker-compose.yml

    version: "3"
    
    networks:
      app-tier:
        driver: bridge
    
    services:
      redis:
        image: 'bitnami/redis:latest'
        container_name: testRedis
        environment:
          - REDIS_REPLICATION_MODE=master
          - ALLOW_EMPTY_PASSWORD=yes
        ports:
          - 6379:6379
        networks:
          - app-tier
      redis-slave-1:
        image: 'bitnami/redis:latest'
        container_name: testRedis1
        environment:
          - REDIS_REPLICATION_MODE=slave
          - REDIS_MASTER_HOST=redis
          - ALLOW_EMPTY_PASSWORD=yes
        ports:
          - 6378:6379
        networks:
          - app-tier
        depends_on:
          - redis
    
      redis-slave-2:
        image: 'bitnami/redis:latest'
        container_name: testRedis2
        environment:
          - REDIS_REPLICATION_MODE=slave
          - REDIS_MASTER_HOST=redis
          - ALLOW_EMPTY_PASSWORD=yes
        ports:
          - 6377:6379
    
        networks:
          - app-tier
        depends_on:
          - redis
    
      redis-sentinel:
        image: 'bitnami/redis-sentinel:latest'
        environment:
          - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000
          - REDIS_MASTER_HOST=testRedis
          - REDIS_MASTER_PORT_NUMBER=6379
          - REDIS_MASTER_SET=sentinelMaster
          - REDIS_SENTINEL_QUORUM=2
        depends_on:
          - redis
          - redis-slave-1
          - redis-slave-2
        ports:
        - '26379:26379'
        networks:
          - app-tier
    
      redis-sentinel-2:
        image: 'bitnami/redis-sentinel:latest'
        environment:
          - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000
          - REDIS_MASTER_HOST=testRedis
          - REDIS_MASTER_PORT_NUMBER=6379
          - REDIS_MASTER_SET=sentinelMaster
          - REDIS_SENTINEL_QUORUM=2
        depends_on:
          - redis
          - redis-slave-1
          - redis-slave-2
        ports:
          - '26380:26379'
        networks:
          - app-tier
    
      redis-sentinel-3:
        image: 'bitnami/redis-sentinel:latest'
        environment:
          - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000
          - REDIS_MASTER_HOST=testRedis
          - REDIS_MASTER_PORT_NUMBER=6379
          - REDIS_MASTER_SET=sentinelMaster
          - REDIS_SENTINEL_QUORUM=2
        depends_on:
          - redis
          - redis-slave-1
          - redis-slave-2
        ports:
          - '26381:26379'
        networks:
          - app-tier
    
    
      spring-app:
        restart: always
        links:
          - redis
        build:
          context: ""
          dockerfile: Dockerfile
        container_name: testBoot
        ports:
          - "8080:8080"
        expose:
          - "8080"
        depends_on:
          - redis-sentinel
        networks:
          - app-tier
    docker-compose up

    다음은 센티넬의 포트로 접근해 info 명령어를 작성한 결과입니다.

    $ redis-cli -p 26379
    127.0.0.1:26379> info
    # Sentinel
    sentinel_masters:1
    sentinel_tilt:0
    sentinel_running_scripts:0
    sentinel_scripts_queue_length:0
    sentinel_simulate_failure_flags:0
    master0:name=sentinelMaster,status=ok,address=172.19.0.2:6379,slaves=2,sentinels=3
    127.0.0.1:26379> sentinel master sentinelMaster
     1) "name"
     2) "sentinelMaster"
     3) "ip"
     4) "172.19.0.2"
     5) "port"
     6) "6379"
     7) "runid"
     8) "56184b09e79e6ffcfa98d582211838355e0617a1"
     9) "flags"
    10) "master"
    11) "link-pending-commands"
    12) "0"
    13) "link-refcount"
    14) "1"
    15) "last-ping-sent"
    16) "0"
    17) "last-ok-ping-reply"
    18) "115"
    19) "last-ping-reply"
    20) "115"
    21) "down-after-milliseconds"
    22) "3000"
    23) "info-refresh"
    24) "3635"
    25) "role-reported"
    26) "master"
    27) "role-reported-time"
    28) "355198"
    29) "config-epoch"
    30) "0"
    31) "num-slaves"
    32) "2"
    33) "num-other-sentinels"
    34) "2"
    35) "quorum"
    36) "2"
    37) "failover-timeout"
    38) "180000"
    39) "parallel-syncs"
    40) "1"

    Master의 name은 sentinelMaster,

    Master의 port는 6379,

    Master의 slave는 2개,

    Master의 sentinel은 3개로 compose 파일에서 설정한 값들이 정상임을 확인할 수 있었습니다.

     

    이렇게 구성된 Redis 노드는 다음과 같습니다.

    현재 구조도 ( Application은 Sentinel을 바라보아야함 )

     

     

     

    현재 마스터 노드의 정보를 확인해보면 다음과 같이 나타나게 됩니다.

    1. Master Host

    2. Master Port

    127.0.0.1:26379> sentinel get-master-addr-by-name sentinelMaster
    1) "172.19.0.2"
    2) "6379"

     

    Failover 테스트 

    redis-cli -h testRedis debug sleep 30

    현재 마스터 노드인 testRedis를 30초간 멈추었을 때 

    마스터가 정지상태임을 확인하고 

    설정된 Quorum인 센티넬이 2개 이상으로 과반수를 넘어섰기 때문에

    페일오버를 시도하는 로그를 확인할 수 있었습니다.

     

    redis-sentinel_3  | 1:X 31 Jul 2021 09:55:11.938 # +sdown master sentinelMaster 172.19.0.2 6379
    redis-sentinel_1  | 1:X 31 Jul 2021 09:55:12.063 # +sdown master sentinelMaster 172.19.0.2 6379
    redis-sentinel_1  | 1:X 31 Jul 2021 09:55:12.163 # +odown master sentinelMaster 172.19.0.2 6379 #quorum 2/2
    redis-sentinel_1  | 1:X 31 Jul 2021 09:55:12.163 # +new-epoch 1
    redis-sentinel_1  | 1:X 31 Jul 2021 09:55:12.163 # +try-failover master sentinelMaster 172.19.0.2 6379

    복제 노드였던 노드가 마스터 노드로 승격됨을 확인할 수 있습니다.

    172.19.0.2 -> 172.19.0.4

    redis-sentinel_1  | 1:X 31 Jul 2021 09:55:14.349 # +switch-master sentinelMaster 172.19.0.2 6379 172.19.0.4 637

    30초간 정지시켰던 마스터 노드였던 testRedis가

    복제 노드에서 마스터 노드로 승격된 testRedis2 노드의 Replica로 연결을 시도함을 확인할 수 있습니다.

    testRedis         | 1:S 31 Jul 2021 09:55:48.463 * Connecting to MASTER 172.19.0.4:6379
    testRedis         | 1:S 31 Jul 2021 09:55:48.463 * MASTER <-> REPLICA sync started

     

    현재 마스터 노드의 재확인 결과로 마스터 노드를 sleep 시키기 전과 다른 노드가 마스터 노드임을 확인할 수 있습니다.

    기존 - 172.19.0.2

    현재 - 172.19.0.4

    $ redis-cli -p 26379
    127.0.0.1:26379> sentinel get-master-addr-by-name sentinelMaster
    1) "172.19.0.4"
    2) "6379"

     

    승격 후 기존 마스터 노드였던 testRedis 호스트에 데이터를 추가하려고 할 때 

    마스터 노드가 아니기 때문에 데이터를 직접적으로 추가할 수 없는 모습입니다. 

    (복제 노드에 데이터를 추가할 수 있다면 데이터 무결성에 문제가 될 수 있기 때문일까요 ?)

    testRedis:6379> set testSentinel test!
    (error) READONLY You can't write against a read only replica.

     

    마스터 노드로 승격된 testRedis2에 데이터를 추가한 결과로

    데이터가 잘 들어감을 확인할 수 있습니다.

    $ redis-cli -h testRedis2
    testRedis2:6379> set testSentinel test!
    OK
    testRedis2:6379> get testSentinel
    "test!"

     

    위와 같이 새 마스터 노드에 데이터를 추가하고 

    복제 노드로 변경된 testRedis 노드에 마스터 노드의 데이터가 잘 복제되었는지 테스트한 결과로

    Key= "testSentinel", Value = "test!"가 잘 들어가 있음을 확인할 수 있었습니다.

    testRedis:6379> get testSentinel
    "test!"

    추가 설정

     

    ShMarket에서의 Redis는 캐시 용도로만 사용하기로 했습니다.

    그렇기에 RDB 파일을 저장하는 Redis의 옵션인  save""로 설정했습니다.

    만약 save 옵션이 yes일 때 RDB 파일 저장에 실패한다면 Redis의 write가 불가능해지기 때문입니다.

     

    또한 AOF파일을 저장하는 옵션인 appendonlyno로 설정해주었습니다.


    결론

    지금까지 Redis의 장애를 대비해 학습하고 ShMarket 프로젝트에 적용해봄으로

    서비스 중 Redis 장애로 인한 오류 발생에 대비할 수 있도록 더욱 학습해 다음 프로젝트를 진행할 때 더 견고하게 서버를 구성할 수 있을 것 같습니다.

     


    참고 문서

     

    https://meetup.toast.com/posts/226

    https://redis.io/topics/sentinel

     

    댓글

Designed by Tistory.