토이 프로젝트/ChatForYou.io 프로젝트

ChatForYou.io 실시간 화상채팅 프로젝트(1) Redis 활용해서 채팅방 다루기(feat. kubernetes 와 redis mater - slave)

TerianP 2024. 10. 12.
728x90

1. 시작하면서

벌써 지난 포스팅으로부터 2달이나 지났습니다. 시간이 정말 정말 빠르구나 라는걸 요즘 또 다시 느끼고 있습니다. 동시에 제게도 많은 변화가 있었습니다.

먼저 지난번에 잠깐 언급했던 ChatForYou 를 팀으로 만들어보는 새로운 프로젝트를 본격적으로 진행하게 되었습니다. 너무나 멋진 디자인을 만들어주시는 분과 그걸 또 기가막히게 만들어주시는 프론트 개발자분을 모셔서 6월부터는 정말 시간가는줄 모르고 다시 프로젝트를 만들었던 것 같네요.

새로운 프로젝트를 진행하면서 새롭게 서버 환경도 구성해보고, 서버 구성도도 그리고, 네트워크 구성도도 그리고 DB 설계도 해보고 뭔가 새로 시작하다는게 신기하면서도 굉장히 재미있게 진행중입니다.

 

그리고 드디어 1차로 목표로 했던 기능들이 드디어! 어느정도 정리되고 마무리되면서 chatforyou.io 와 관련된 첫번째 포스팅을 작성하게 되었습니다. 오늘 작성해볼 내용은 redis 를 사용하여 화상채팅과 관련된 데이터를 저장하고, 조회하고 수정하는 것에 관한 내용입니다.

 

2. Redis 너는 무엇이냐?

Redis는 메모리 기반의 NoSQL 데이터베이스야. 키-값(key-value) 구조로 데이터를 저장하고, 주로 빠른 속도가 필요한 캐싱(caching) 시스템이나 실시간 데이터 처리에 많이 사용된다. 메모리에 데이터를 저장하다 보니까 읽기/쓰기 성능이 매우 뛰어나. 그래서 짧은 응답 시간을 요구하는 서비스에서 Redis를 자주 쓰이고 있다.

 

1) 레디스의 장점

초고속 성능
- Redis는 메모리에 데이터를 저장하므로 읽기와 쓰기 속도가 매우 빠르다. 보통 밀리초 단위로 응답이 이루어지기 때문에 실시간 처리가 중요한 상황에 적합하다. 반면, 전통적인 RDB는 디스크 기반이기 때문에 디스크 I/O로 인해 성능이 상대적으로 느릴 수 있다.

데이터 구조 다양성
- Redis는 단순한 키-값 저장 외에도 리스트, 세트, 정렬된 세트, 해시 등 다양한 데이터 구조를 지원한다. 이를 통해 상황에 맞는 효율적인 데이터 처리가 가능하다. 반면, RDB는 주로 테이블과 열, 행 구조로 데이터를 관리하므로 유연성 면에서 Redis가 더 우수하다.

유연한 스케일링
- Redis는 클러스터링과 샤딩을 통해 쉽게 스케일 아웃(서버 수 늘리기)을 할 수 있다. 데이터 분산 처리도 쉽게 가능하므로 대용량 데이터를 처리하는 데 유리하다. RDB에서도 스케일링이 가능하지만, 복잡한 설정과 데이터베이스 설계가 필요하다.

비동기 데이터 복제
- Redis는 데이터를 비동기적으로 복제할 수 있어 성능 저하 없이 데이터의 고가용성을 보장한다. 반면, RDB는 주로 트랜잭션을 동기적으로 처리하므로 고가용성 구성 시 성능 저하가 발생할 수 있다.

단순한 구조
- Redis는 설정과 운영이 비교적 간단하여 빠르게 서비스를 도입하고 운영할 수 있다. 반면, RDB는 쿼리 최적화와 인덱스 설정 등 성능을 극대화하기 위해 신경 써야 할 부분이 많다.

 

2) 전통적인 RDB와의 차이점

데이터 일관성
- RDB는 트랜잭션을 통해 데이터의 ACID 속성을 보장한다. 복잡한 비즈니스 로직이나 트랜잭션 일관성이 중요한 곳에서는 RDB가 적합하다. 반면, Redis는 성능 위주로 설계되어 데이터 일관성을 완벽하게 보장하지는 않는다(단, Redis는 스냅샷과 AOF 기능을 통해 데이터의 지속성을 제공하지만, 이는 물론 추가 설정이 필요하다).

복잡한 쿼리 지원
- RDB는 SQL이라는 강력한 쿼리 언어를 지원하여 조인, 그룹화, 집계와 같은 복잡한 데이터를 다룰 때 유리하다. 하지만 Redis는 기본적으로 키-값 구조이므로 복잡한 쿼리보다는 단순한 데이터 접근에 적합하다.

데이터 저장 방식
- Redis는 기본적으로 메모리에 데이터를 저장하지만, RDB는 디스크에 데이터를 저장한다. 메모리는 휘발성이 있어서 Redis는 서버가 재시작되거나 장애가 발생하면 데이터를 잃을 가능성이 있다. 반면, RDB는 디스크 기반이므로 데이터를 안전하게 보존할 수 있다.
다만 Kubernetes 의 PV & PVC 기능을 활용하면 Redis가 메모리 기반임에도 불구하고 데이터를 안정적으로 보존할 수 있는 구조를 만들 수 있다

 

3. 이전 프로젝트와 차별성 : OpenVidu & Redis

이번 프로젝트와 이전 프로젝트의 가장 큰 차이점은 OpenVidu 를 사용한다는 것 이외에도 역시 전통적인 화상채팅 데이터를 저장하기 위해 전통적인 rdb 를 사용하거나 이전처럼 hashmap 에 저장하는 것 대신 redis 를 사용해서 데이터를 저장하고 가져온다는 점일 것이다. 여기서 오늘은 우선 Redis 에 대해서 먼저 이야기하려고 한다. 물론 곧 OpenVidu 를 사용하는 부분에 대해서도 포스팅을 할 예정이다!

 

왜 Redis 를 사용하는거야?

 

Redis 를 사용한다면 "왜?" redis 를 사용하는지부터 이야기해야할 것이다. 물론 이번에도 단순히 사용해보고 싶어서도 물론 있지만 명확한 성능 관점에서의 이유가 존재한다. 화상채팅의 가장 큰 특징은 지속적으로 방 생성, 조회, 삭제가 많이 발생한다는 점이다. 즉 채팅방에 대해서 혹은 화상채팅 전체 데이터에 대해서 엄청난 I/O 가 발생한다는 것이다.

 

이런 CRUD 로 대표되는 기능을 처음에는 자연스럽게 RDB 를 사용하려고 했었다. 심지어 이를 위해서 테이블 설계도 했고, JPA entity 도 만들었다. 그러다가 개발 초창기에 프론트 개발자분과 "이렇게 많은 I/O 가 발생하는데 과연 DB 만으로 적당할까? 또한 추후 부가적인 기능을 생각했을때 성능적으로 더 개선할 방법이 있을까?" 라는 이야기를 나누게 되었다. 

 

이런 의문은 개발을 하면 할수록 더 심해졌는데 단순히 내가 방을 생성하고 삭제하는 것뿐만 아니라 OpenVidu 에서 기본 설정으로 일정 시간마다 혹은 openvidu 데이터 상에서 참여하는 인원이 없다면 자체적으로 데이터를 지우고 있었기 때문이다. 이는 내가 내가 생각하는 것보다 "조회와 삭제" 가 더 많이 발생할 것이라는 점을 시사했다. 개발하는 도중이야 방 10개 많아봤자 50개씩 만들겠지만 '실제 서비스' 를 목표로하고 개발하는만큼 요청 수에 따른 자원관리는 내게 너무나도 중요하게 다가왔다.

 

그렇게 자연스럽게 Redis 로 눈을 돌리게 되었다. Redis는 내가 필요로 하는 성능적 요구 사항에 맞는 기능이었다. Redis는 메모리 기반으로 동작하기 때문에 데이터에 대한 조회, 생성, 삭제와 같은 I/O 작업을 매우 빠르게 처리할 수 있을 것이었고 동시에 기존 RDB에 비해 훨씬 가벼운 성능으로 방대한 양의 요청도 빠른 시간 내에 처리할 수 있다는 점이 특히 매력적이었다. 

 

즉 방의 상태나 참여자 목록을 빠르게 확인하고 업데이트하는 일이 빈번한 화상채팅 시스템에서 이러한 실시간 데이터 처리를 Redis가 더 효율적으로 처리 해줄 수 있을 것이라고 생각되었다. 그리고....

 

3. kubernetes 에 Redis 올리기(feat. master -slave)

Redis 를 사용하기로 정했으니 이제 "어디에 어떻게 설치하고, 어떻게 연결하며, 어떤 구조를 갖을 것인지" 생각해야했다. 처음에는 정말 단순하게 그냥 일반적으로 올리는 것처럼 쿠버네티스에 올리면 되지 뭐 라고 생각했었다. 그렇게 이것저것을 알아보다가 Redis 의 master - slave 구조를 보게 되었다. 사실 master - slave 구조는 이전 회사에서 시스템을 운영하면서 알게되었던건데 그때는 그게 어떤건지 자세하게 알지는 못하고  "master 를 메인으로 slave 는 백업을 위해 사용한다" 정도로만 알고있었다. 그래서 이번 기회에 내가 직접 master - slave 로 구조를 잡아보고, 설치하고 활용해보았다.

 

또한 Deployment 가 아닌 Statefulset 을 사용했다. 이는 상태가 있는 애플리케이션을 안정적으로 관리하고, 각 Pod의 고유한 ID데이터를 보존하기 위해서이다. 무엇보다 각 Pod가 PersistentVolume을 통해 고유한 저장소를 사용하며, 재시작이나 재배포 시에도 해당 Pod의 데이터를 유지할 수 있기 때문이다. 이를 통해 데이터 일관성을 유지할 수 있다는 장점이 있다.

 

1) Redis Master - Slave 

Master-Slave 구조는 데이터베이스나 캐시 시스템에서 흔히 사용되는 복제(Replication) 방식 중 하나입니다. 이 구조에서는 하나의 마스터 노드(Master)가 데이터를 쓰고, 하나 이상의 슬레이브 노드(Slave)가 마스터의 데이터를 복제하여 읽기 요청을 처리한다.

Master-Slave 구조의 정의

  • Master: 데이터를 쓰는 노드로 모든 쓰기 작업(데이터 삽입, 수정, 삭제)은 마스터 노드에서만 이루어진다. 마스터는 그 후 슬레이브 노드들에게 데이터를 전송하여 동기화한다.
  • Slave: 데이터를 복제받아 읽기 전용으로 사용되는 노드로 슬레이브 노드는 마스터에서 쓰기 작업이 발생할 때마다 데이터를 복제하여 최신 상태로 유지한다. 슬레이브 노드는 주로 읽기 작업을 분담하여 성능을 향상시킨다.

Redis Master-Slave 구조의 특징과 장점

특징

  1. 복제(Replication):
    • Redis에서 슬레이브 노드는 마스터 노드의 데이터를 비동기적으로 복제한다. 복제본은 마스터가 처리한 모든 쓰기 작업을 복제하여 최신 상태를 유지한다.
  2. 비동기 복제:
    • Redis는 기본적으로 비동기 방식으로 복제를 수행한다. 마스터에서 데이터 변경이 이루어진 후 슬레이브가 이를 복제하는데, 약간의 지연 시간이 있을 수 있습니다. 하지만 이는 대부분의 경우 문제가 되지 않는다.
  3. 읽기/쓰기 분리:
    • 마스터 노드는 쓰기 작업을 전담하고, 슬레이브 노드는 읽기 작업을 분담하여 성능을 최적화한다. 슬레이브 노드가 여러 개일 경우, 읽기 요청을 각각의 슬레이브 노드에 분산시킬 수 있다.
  4. 고가용성:
    • 마스터 노드가 장애가 발생해도 슬레이브 노드는 계속해서 읽기 요청을 처리할 수 있다. 이 구조는 읽기 가용성을 높여주며, 마스터가 복구되기 전까지 일시적으로 쓰기 작업만 제한된다.
  5. Failover:
    • Redis Sentinel과 같은 도구를 사용하면 마스터 노드에 장애가 발생했을 때 자동으로 슬레이브 노드가 새로운 마스터로 승격될 수 있습니다. 이를 통해 시스템이 지속적으로 동작할 수 있다 <= 얘는 결국 구현 못했다ㅠㅠ
    • 사실 여기에 대해서는 할 이야기가 정말 많은데...결론만 압축하자면 어려워서 못했다 라기보다는 sentinel 까지 올리기에는 redis 에 서버 버 리소스가 너무 많이 잡아들어가서 결국 포기했다.

2) Redis statefulset

  • master 와 salve 의 yaml !!
  • 사실 deployment 와 크게 차이점은 없다. 해봤자 slave 와 master 가 각각의 pv 와 pvc 를 갖는다는 정도?
  • 그것외에는 nodeport service 와 headless service 를 동시에 사용한다는 점이다. 이것은 특징이라기 보다는 개발환경의 특징에 맞춰서 만든 것이다.
  • 두가지를 모두 쓴 이유는 nodeport 를 통해서 외부에서 접근하고, headless service 를 통해서 내부에서 통신하도록 하기 위해서이다. 외부에서 접속은 나만 개발하는게 아니다보니 프론트 개발자분의 접근을 위해서 또 내가 외부에서 작업할때를 위해서 넣어두었고, headless service 는 실제 내부에서 chatforyou.io 서비스와 redis 가 통신하기 위해서 사용한다.
  • 특히 headless service 같은 경우 이번에 처음 사용해봤는데 굉장히 신기했다. 보통 service 에는 특정한 아이피가 할당되고(보통 10.x.x.x) 이를 통해 접근하는데 headless service 는 아이피가 할당되지도 않고, 각 파드별로 dns 가 할당된다. 그리고 쿠버네티스 내부에서는 dns 를 통해 접근하게 된다. 즉, 다른 내부 서비스(chatforyou.io)는 redis 와 통신할 때 고정된 DNS 이름으로 각 Pod에 직접 연결할 수 있다. 이를 통해 불필요한 네트워크 홉을 줄이고, 더 빠른 응답 시간을 보장할 수 있다. 실제로 속도도 평균 10~20ms 정도 개선되었다는 것을 확인했다.
  • 다만 headless service 만 있다면 외부와의 통신은 불가능하다. 사실 당연한 것인데 외부에서 "redis-master-headless.redis.svc.cluster.local" 요런식으로 주소를 잡으면...당연히 도착할 수 없기 때문이다. 그래서 결국 nodeport service 가 하나 튀어나왔다.
  • 하나 더 확인해야하는 점은 아래의 loadmodule 부분이다. 이게 정말정말 핵심이다. 왜냐하면 다음 포스팅 내용에 작성할 redis 에서의 검색기능은 이 모듈을 사용하고 있기 때문이다.
"--loadmodule" - "/usr/lib/redis/modules/redisearch.so" - "--loadmodule" - "/usr/lib/redis/modules/rejson.so"

Master

apiVersion: v1
kind: Service
metadata:
  name: redis-master-service
  namespace: redis
spec:
  type: NodePort
  ports:
  - port: 6379
    targetPort: 6379
    nodePort: 32000  # 외부에서 접근할 수 있는 포트
  selector:
    app: redis
    role: master
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master-headless
  namespace: redis
spec:
  clusterIP: None  # Headless Service 설정
  selector:
    app: redis
    role: master
  ports:
  - port: 6379
    targetPort: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-master
  namespace: redis
spec:
  serviceName: "redis-master-headless"  # Headless Service로 접근 가능
  replicas: 1
  selector:
    matchLabels:
      app: redis
      role: master
  template:
    metadata:
      labels:
        app: redis
        role: master
    spec:
      securityContext:
        fsGroup: 65534
      containers:
      - name: redis
        image: redislabs/redismod:latest
        ports:
        - containerPort: 6379
        volumeMounts:
        - name: redis-storage
          mountPath: /data
        command:
        - redis-server
        - "--loadmodule"
        - "/usr/lib/redis/modules/redisearch.so"
        - "--loadmodule"
        - "/usr/lib/redis/modules/rejson.so"
        securityContext:
          runAsUser: 1000
          allowPrivilegeEscalation: false
      volumes:
      - name: redis-storage
        persistentVolumeClaim:
          claimName: redis-master-pvc  # 마스터용 PVC 사용

 

Slave

apiVersion: v1
kind: Service
metadata:
  name: redis-master-service
  namespace: redis
spec:
  type: NodePort
  ports:
  - port: 6379
    targetPort: 6379
    nodePort: 32000  # 외부에서 접근할 수 있는 포트
  selector:
    app: redis
    role: master
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master-headless
  namespace: redis
spec:
  clusterIP: None  # Headless Service 설정
  selector:
    app: redis
    role: master
  ports:
  - port: 6379
    targetPort: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis-master
  namespace: redis
spec:
  serviceName: "redis-master-headless"  # Headless Service로 접근 가능
  replicas: 1
  selector:
    matchLabels:
      app: redis
      role: master
  template:
    metadata:
      labels:
        app: redis
        role: master
    spec:
      securityContext:
        fsGroup: 65534
      containers:
      - name: redis
        image: redislabs/redismod:latest
        ports:
        - containerPort: 6379
        volumeMounts:
        - name: redis-storage
          mountPath: /data
        command:
        - redis-server
        - "--loadmodule"
        - "/usr/lib/redis/modules/redisearch.so"
        - "--loadmodule"
        - "/usr/lib/redis/modules/rejson.so"
        securityContext:
          runAsUser: 1000
          allowPrivilegeEscalation: false
      volumes:
      - name: redis-storage
        persistentVolumeClaim:
          claimName: redis-master-pvc  # 마스터용 PVC 사용

 

config

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-master-pvc  # 마스터용 PVC
  namespace: redis
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: redis-storage-class
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-slave-pvc  # 슬레이브용 PVC
  namespace: redis
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 3Gi
  storageClassName: redis-storage-class
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: redis-master-pv
  namespace: redis
spec:
  capacity:
    storage: 5Gi  # PVC에서 요청한 크기와 일치해야 함
  accessModes:
    - ReadWriteOnce  # PVC에서 요청한 접근 모드와 일치해야 함
  storageClassName: redis-storage-class  # PVC에서 지정한 StorageClass와 일치해야 함
  nfs:
    path: /nfs-data/redis-master
    server: 192.168.0.x  # NFS 서버 주소
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: redis-slave-pv
  namespace: redis
spec:
  capacity:
    storage: 3Gi  # PVC에서 요청한 크기와 일치해야 함
  accessModes:
    - ReadWriteOnce  # PVC에서 요청한 접근 모드와 일치해야 함
  storageClassName: redis-storage-class  # PVC에서 지정한 StorageClass와 일치해야 함
  nfs:
    path: /nfs-data/redis-slave
    server: 192.168.0.x  # NFS 서버 주소

4. Java - Redis 연결하기

Java 에서 Redis 를 사용하는 방법은 의외로 간단하다. 정말 의외로 정말 간단하다. 나는 사실 엄청 어렵고 복잡한 것을 생각했었는데 오히려 바로 연결되서 바로 되는것을 보면서 "이상하네...몬가 몬가 많이 이상하네" 하면서 두려움에 떨었을 정도니까 말이다.

 

설정해야하는 것도 많지는 않다. 다만 master - slave 구조 특성상 bean 도 master 와 slave 용으로 따로 등록해두었다. 이는 "쓰기, 삭제" 등의 작업은 master 에서하고, "읽기" 작업은 slave 에서 하기 위함이다. 재미있는건 slave 로 등록된 bean 으로 쓰기, 삭제 작업 요청을 하면 코드단에서 바로 에러가 난다는 점이다.

 

또한 redis 환경 변수 값은 모두 properties 를 통해 가져오게 하였다. 만약 직접 환경 변수를 넣어주는 경우 - 실제 서비스를 올릴때 kuberntes 에서 변수값을 넣는 경우 - 에는 그 값을 사용하되 일반적인 경우에는 properties 를 활용한다.

 

또한 특히 중요한 부분이 있다. 바로  public RediSearchClient redisSearchClient() { ~~~ } 부분이다. 사실 이 친구는 redis 만 사용한다면 전혀 필요없는 친구이다. 다만 나처럼 redis 를 통해서 검색, 정렬 기능들을 활용하기 위해서는 꼭! 필수적으로 짚고 넘어가야한다. 스키마는 마치 rdb 처럼 redis 의 field 에 인덱싱을 거는 것이다. 이를 통해 redis 안에서 검색기능을 활용할 수 있다. 반대로 검색을 하려면 스키마가 필수이다.

 

 

Build.gradle

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

// redisSearch
implementation group: 'io.github.dengliming.redismodule', name: 'redisearch', version: '2.0.3'

 

RedisConfig

/**
 * redis 를 master - slave 구조로 사용
 * master : 쓰기 작업 수행
 * slave : 읽기 작업 수행
 */
@Configuration
@EnableCaching
@Slf4j
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.master.port}")
    private String masterPort;

    @Value("${spring.data.redis.slave.port}")
    private String slavePort;

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

    private RediSearchClient redisSearchClient;

    // redis 의 설정값을 세팅하기 위한 postConstruct
    // 환경변수로 url 과 port 가 들어오면 해당 값을 을 사용하고, 아니면 properties 에 정의 된 값을 사용
    @PostConstruct
    private void initRedis(){
        String envRedisUrl = System.getenv("REDIS_URL");
        if(!StringUtil.isNullOrEmpty(envRedisUrl)){
            host = envRedisUrl;
        }

        String envRedisMasterPort = System.getenv("REDIS_MASTER_PORT");
        if(!StringUtil.isNullOrEmpty(envRedisMasterPort)){
            masterPort = envRedisMasterPort;
        }

        String envRedisSlavePort = System.getenv("REDIS_SLAVE_PORT");
        if(!StringUtil.isNullOrEmpty(envRedisSlavePort)){
            slavePort = envRedisSlavePort;
        }

        String envRedisPassword = System.getenv("REDIS_PASSWORD");
        if(!StringUtil.isNullOrEmpty(envRedisPassword)){
            password = envRedisPassword;
        }
    }

    @Primary
    @Bean("masterRedisConnectionFactory")
    public RedisConnectionFactory masterRedisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, Integer.parseInt(masterPort));
        return new LettuceConnectionFactory(config);
    }

    @Bean("slaveRedisConnectionFactory")
    public RedisConnectionFactory slaveRedisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, Integer.parseInt(slavePort));
        return new LettuceConnectionFactory(config);
    }

    @Primary
    @Bean(name = "masterRedisTemplate")
    public RedisTemplate<?, ?> masterRedisTemplate(@Qualifier("masterRedisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        // 직렬화 및 역직렬화 설정
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer); // 이 부분이 데이터를 JSON 형식으로 직렬화
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }

    @Bean(name = "slaveRedisTemplate")
    public RedisTemplate<?, ?> slaveRedisTemplate(@Qualifier("slaveRedisConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        // 직렬화 및 역직렬화 설정
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer); // 이 부분이 데이터를 JSON 형식으로 직렬화
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }


    @Bean
    public RediSearchClient redisSearchClient() {

        Config config = new Config();
        config.useMasterSlaveServers()
                .setMasterAddress("redis://" + host + ":" + masterPort)
                .addSlaveAddress("redis://" + host + ":" + slavePort);

        // RediSearchClient 생성
        redisSearchClient = new RediSearchClient(config);

        // 인덱스 스키마 정의
        Schema schema = new Schema()
                .addField(new TextField("sessionId"))
                .addField(new TextField("creator").noStem())
                .addField(new TextField("roomName").noStem())
                .addField(new Field("currentTime", FieldType.NUMERIC));

        // 인덱스 생성 (존재하지 않을 경우에만 생성)
        try {
            redisSearchClient.getRediSearch("chatRoomIndex").createIndex(schema);
            log.info("인덱스가 성공적으로 생성되었습니다.");
        } catch (Exception e) {
            log.error("인덱스가 이미 존재하거나 생성 중 오류가 발생했습니다.");
            log.error("Exception: {} :: {}", e.getMessage(), e);

        }

        return redisSearchClient;
    }

}

 


5. Redis 활용하기 : 저장하고 가져오기

사실 저장하고 가져오는 모든 코드를 작성하기에는 너무...많다ㅠㅠ 따라서 여기에는 저장하는 로직과 그것을 가져오는 로직을 일부 적어두도록 하겠다. 모든 코드는 당연히 git 에서 확인가능!!

 

Redis 에 정보를 저장하기 위해 사용하는 방법은 정말 다양하다. 가장 간단한 방법으로는 key 와 value 로 그냥 저장하는 방법도 있고, hash 를 통해 저장하는 방법과 set 으로 저장하는 방법등 정말 다양하게 존재한다.

 

사실 redis 를 사용할때 처음에는 "그냥 key 와 value 만 저장하는 방법으로 opsForValue 를 사용하면 되겠지" 라고 생각했었다. 그리고 물론 이렇게 사용해도 '동작' 하는데 크게 문제는 없었다. 다만 성능에는 정말정말 안좋았다. 오히려 mysql 을 사용해서 저장하고 가져오는것보다 느릴 정도였다.

 

결국 여러가지로 방법을 시도하기 시작했다. 단순히 key,value 로 저장하고 가져오는 대신 hash 를 사용해서 저장하고 가져오기도해보고 set 을 사용해서 저장하고 가져오는 방법으로 바꿔보기도했다. 여러번의 시도끝에 조회와 저장, 삭제 등의 속도 개선에 (나름) 엄청난 향상이 있었다!!

 

1) redisUtils

  • redisUtils 클래스!! 여기서는 masterTemplate 과 slaveTemplate 모두 초기화해둔다. 특히 redisSearchClient 의 경우 추후 redis 검색 시 사용되는 객체이다.
@Component
@Slf4j
public class RedisUtils {

    private final RedisTemplate<String, Object> masterTemplate;
    private final RedisTemplate<String, Object> slaveTemplate;
    private final ObjectMapper objectMapper;
    private final RediSearchClient rediSearchClient;

    public RedisUtils(
            @Qualifier("masterRedisTemplate") RedisTemplate<String, Object> masterTemplate,
            @Qualifier("slaveRedisTemplate") RedisTemplate<String, Object> slaveTemplate,
            ObjectMapper objectMapper, RediSearchClient rediSearchClient) {
        this.masterTemplate = masterTemplate;
        this.slaveTemplate = slaveTemplate;
        this.objectMapper = objectMapper;
        this.rediSearchClient = rediSearchClient;
    }

2) chatRoom 생성 및 저장

  • chatRoom 을 redis 에 저장하기 위한 코드이다. 저장에는 당연히 master 노드를 사용해야하기에 template 도 masterTemplate 을 사용한다.
  • hash 를 사용해서 저장한다. 이때 hash 에는 총 3개의 파라미터가 들어간다.
key(첫번째 파라미터) : Key는 Redis에서 데이터를 저장할 때 사용하는 고유 식별자로 모든 데이터를 키로 식별하므로, Key는 데이터에 접근하는 기본적인 경로 역할을 한다. Redis에서 여러 개의 채팅방 정보를 저장한다고 가정할 때, 각 채팅방은 고유한 Key를 가진다.

field(두번째 파라미터) : Field는 Redis의 Hash 데이터 타입에서 사용되는 개념으로 하나의 Key에 여러 필드가 있을 수 있고, 각 필드는 고유한 이름을 가져야 합니다. 즉 필드는 하나의 Key 안에서 다양한 정보를 구분하는 역할을 한다.

value(세번째 파라미터) : value 는 필드에 대응하는 실제 데이터이다. Redis의 Hash에서 Field는 고유하지만, Value는 그 필드에 저장되는 정보로 단순한 문자열, 숫자 또는 복잡한 객체를 표현할 수 있다.
public void createChatRoomJob(String sessionId, ChatRoomInVo chatRoomInVo, OpenViduDto openViduDto){
    String redisKey = "sessionId:"+sessionId;
    // 채팅방 객체 저장
    masterTemplate.opsForHash().put(redisKey, DataType.CHATROOM.getType(), chatRoomInVo);
    masterTemplate.opsForHash().put(redisKey, "sessionId", sessionId);
    masterTemplate.opsForHash().put(redisKey, "creator", chatRoomInVo.getCreator());
    masterTemplate.opsForHash().put(redisKey, "roomName", chatRoomInVo.getRoomName());
    masterTemplate.opsForHash().put(redisKey, "currentTime", new Date().getTime());
    // OpenVidu 객체 저장
    masterTemplate.opsForHash().put(redisKey, DataType.OPENVIDU.getType(), openViduDto);
}

 

만약 redisKey 를 sessionId:1234 로 둘때 redis 에 저장되는 구조는 아래오 같다. 이렇게 저장할때의 가장 큰 장점은 추후 레디스에서 redisKey 인 "sessionId:1234" 로만 조회했을때 관련된 전체 필드를 모두 한꺼번에 조회할 수 있다는 점이다. 이는 채팅방 리스트 조회나 개별 방을 조회시 redis 를 여러번 조회하는 것이 아닌 1번 조회하는 것에서 끝날 수 있고, 이는 곧 성능 향상에 큰 도움이 된다.

Key: "sessionId:1234"
|
|--> Field: "roomName"    Value: "My Chatroom"
|--> Field: "creator"     Value: "User123"
|--> Field: "currentTime" Value: 1697052600 (Unix 타임스탬프)
|--> Field: "chatRoom"    Value: (채팅방 객체 정보, 예: JSON 형식으로 직렬화된 정보)
|--> Field: "openVidu"    Value: (OpenVidu 관련 정보)

 

3) JoinChatRoom

  • chatroom 에 유저가 join 할때 사용한다. 사실 전반적인 내용은 일반적인 rdb 를 사용할때의 로직과 다를게 없다. chatRoom 을 조회한 후 해당 데이터(객체)에 유저를 추가하듯이 여기도 마찬가지이다.
  • 특징이라면 유저가 추가됨에 따라 변화한 openviduDto 를 redis 에 새로 저장하고, 유저수와 userList 에 참여하는 유저를 추가한다.
  • 이때 유저는 set 으로 저장하는데 이는 특정 유저가 중복되서 들어올 일이 없기에 list 보다는 조회가 빠른 set 으로 저장한다.
public void joinUserJob(String sessionId, UserOutVo user, OpenViduDto openViduDto){
    String redisKey = "sessionId:"+sessionId;
    // OpenVidu 객체 저장
    masterTemplate.opsForHash().put(redisKey, DataType.OPENVIDU.getType(), openViduDto);
    // 유저 수 증가
    this.incrementUserCount(redisKey, DataType.USER_COUNT.getType(), 1);
    // userList 에 추가
    String userListKey = redisKey + ":userList";
    // Set에 유저 추가
    masterTemplate.opsForSet().add(userListKey, user);
}

 

4) redis 에서 정보 조회 : redisKey 로 모든 필드 조회

  • rediskey 로 해당 key 에 해당하는 모든 field 를 조회한다. 이는 주로 해당 채방에 관한 모든 정보를 조회하기 위함이다.
  • slave 노드에서 조회하며 key 를 기준으로 모든 field 의 value 를 가져온다. 이때 결과는 Map 으로 받는다. 특정 필드를 하나씩 조회하는 대신 모든 필드를 한 번에 가져오는 방식은 통해 네트워크 왕복(RTT)을 최소화하고, 데이터 조회에 소요되는 응답 시간을 줄일 수 있다
public Map<Object, Object> getAllChatRoomData(String sessionId){
    String redisKey = this.makeRedisKey(sessionId);
    return slaveTemplate.opsForHash().entries(redisKey);
}

 

 

5) redis 에서 정보 조회 : redisKey 로 특정 필드 혹은 값 조회

  • 위처럼 한번에 조회하는 방식은 당연히 좋지만 특정 상황에서는 단순히 chatroom 에 대한 내용만 조회하거나 user_list 만 조회해야하는 경우가 있다.
  • 이를 위해 sessionId 를 기준으로 특정 필드만 조회할 수 있도록 만들었다. 이때 clazz 는 제네릭타입으로 받아서 하나의 function 으로 여러 타입의 객체를 가져올 수 있도록 하였다.
  • 다만 userList 의 경우 hash 가 아닌 set 으로 저장하기에 기존에 redisKey 에 ":userList" 를 붙여서 값을 가져오고 동시에 결과를 list 로 출력하여볼 수 있도록 했다.
public <T> T getRedisDataByDataType(String sessionId, DataType dataType, Class<T> clazz) throws BadRequestException {
    String redisKey = makeRedisKey(sessionId);
    switch (dataType){
        case CHATROOM :
            return clazz.cast(slaveTemplate.opsForHash().get(redisKey, DataType.CHATROOM.getType()));
        case OPENVIDU:
            return clazz.cast(slaveTemplate.opsForHash().get(redisKey, DataType.OPENVIDU.getType()));
        case USER_LIST:
            String userListKey = redisKey + ":userList";
            return clazz.cast(slaveTemplate.opsForSet().members(userListKey).stream().toList());
        default:
            throw new BadRequestException("Dose Not Exist DataType");
    }
}

 

 

6) redisKey 로 조회해서 전체 데이터 삭제

  • 안타깝게도 나의 서버의 사양때문에 userList 가 0 인 즉, 방에 아무도 없는 chatRoom 은 자동으로 삭제되어야 한다. 
  • 이를 위해 간단한 batch 를 만들어서 특정 시간마다 userList 가 0 인 방들을 확인 후 해당방들을 삭제하는 작업을 해야한다.
  • 이때 redis 의 데이터들도 모두 삭제하기 위해서 scan 을 사용한다. 사실 redis 에서 keys 라는 명령어? 기능도 있는데 이것보다는 scan 이 성능상 훨씬 좋다고 한다.
  • 사실 나머지 코드는 굉장히 간단한데 slaveTemplate 에서 rediskey 로 userCount 가 0 인 방은 sessionList 에 담고 아닌 방들은 다음으로 넘긴다. 그리고 최종적으로 sessionList 를 return 하여 하나하나 모든 방을 삭제하고, 동시에 redis 에 저장된 데이터를 삭제하게 된다.
    @Scheduled(cron = "0 0,30 * * * *", zone = "Asia/Seoul")
//    @Scheduled(cron = "*/10 * * * * *", zone = "Asia/Seoul")
    public void chatRoomScheduledJob() throws OpenViduJavaClientException, OpenViduHttpException, BadRequestException {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
        log.info("=== ChatRoom Batch Job Start :: {} ==== ", sdf.format(new Date().getTime()));
        List<String> sessionList = redisUtils.getSessionListForDelete();
        for (String sessionId : sessionList) {
            chatRoomService.deleteChatRoom(sessionId);
        }
        log.info("=== ChatRoom Batch Job End :: {} ==== ", sdf.format(new Date().getTime()));
    }


public List<String> getSessionListForDelete() throws BadRequestException {
    String pattern = "*sessionId:"+"*";
    List<String> sessionList = new ArrayList<>();
    ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();

    Cursor<byte[]> cursor = slaveTemplate.getConnectionFactory().getConnection().scan(options);

    while (cursor.hasNext()) {
        String key = new String(cursor.next()).replace("\"", "").replace("sessionId:", "");
        if (key.contains("userList")) {
            continue;
        }
        int userCount = this.getUserCount(key);
        if (0 == userCount) {
            sessionList.add(key);
            continue;
        }
        List userList = this.getRedisDataByDataType(key, DataType.USER_LIST, List.class);
        if (CollectionUtils.isEmpty(userList)) {
            sessionList.add(key);
        }

    }

    return sessionList;
}

 

 

6. Redis 로 얼마나 속도가 향상되었을까?

레디스를 사용할때와 아닐때의 속도를 비교해보았다. 아래의 그래프는 ChatRoomList 조회 에 대해 총 12회 실행한 후 결과를 테스트한 것에 대해 속도를 측정한 결과이다.

생성은 총 3가지 방법에 대해서 테스트를 진행했다. RDB 만 사용했을때, RDB 와 redis 를 사용했을때 Redis 만 사용했을 때이다. 그래프를 그리는 것은 우리의 ChatGPT 선생님께서 도와주셨다(감사합니다 AI 님 충성충성)

 

그래프만 보더라도 전반적으로 성능이 향상되었음을 알 수 있다. 약 2~3배 정도 속도가 향상되었다. 물론 중간중간 속도가 확 튀는 경우가 있는데 이는 네트워크의 문제가 아닌가...생각된다. 솔직히 나의 생각보다도 속도가 훨씬 많이 향상되었다. 특히 opsForValue 로 했을때와 opsForHash 로 했을때 정말 속도차이가 정말정말 심했다. 솔직히 맨 처음에 opsForValue 로만 개발했을때는 기존의 RDB 와 거의 동일한 속도였는데 이후 opsForHash 로 조정한 후 속도를 봤을때는 스스로도 놀랐을정도니까 말이다.

 

이번 개발은 나름의 뿌듯함도 있었는데 기존의 것에서 속도 향상을 이뤄내서 개발 결과를 공유했을때 같은 프론트 팀원분께서 "와 엄청 빨라졌어요!
" 라고 이야기 들었을때 너무나 뿌듯했다. 앞으로 좀 더 조정해서 50ms 까지 줄이는 것을 목표로 하고 있다!!


7. 다음에는...?

사실 이번에 안쓴 내용들이 굉장히 많다. 비동기로 저장하는 부분이나 특히 redisSearch 를 사용하는 부분에 대해서는 사실상 하나도 작성하지 못하였다ㅠㅠ

이 내용들은 다소 길어질듯해서 나중에 설명하는 것으로 일단 빼고, 오늘은 여기까지만 작성하도록 하겠다. 이것만해도 나름 이것저것 알차게 작성했지 않나 싶다ㅋㅋㅋ

 

한동한 블로그에 포스팅하는게 뜸햇는데 이제는 조금 더 열심히 글을 써보려고한다. 고로 앞으로도 계속 놀러와주세요!! 화이팅ㅋㅋ


Reference

https://developer-nyong.tistory.com/21#article-4--%EB%A0%88%EB%94%94%EC%8A%A4-%EC%BA%90%EC%8B%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

https://khdscor.tistory.com/99

 

Springboot: redis를 통해 캐시 기능 간단 적용

들어가기 이 글에서는 간단하게 Springboot에 Redis를 적용하여 캐시 기능을 구현하는 과정을 설명할 것이다. Redis에 대한 기본적인 내용은 https://khdscor.tistory.com/98를 참고하길 바란다. springboot with red

khdscor.tistory.com

 

https://pinggoopark.tistory.com/281

 

[redis] 레디스에서 전체 key 삭제 하는 방법

key : key1, value : value1 key : key2, value : value2 키값 입력하기 set key1 value1 set key2 value2 전체 키 확인 keys * 전체 키 삭제 flushall 전체 키 확인 keys *

pinggoopark.tistory.com

 

https://tjdrnr05571.tistory.com/11

 

[#5] 성능을 위해 Redis keys 대신 scan 이용하기

Redis scan을 사용하는 이유 레디스의 명령어중 keys를 이용하면 모든 키값들을 가져올 수 있지만 이키값들을 가져오는동안 다른 명령어를 수행하지 못합니다. 따라서 성능에 영향을 줄 수 있어 레

tjdrnr05571.tistory.com

 

https://dev-monkey-dugi.tistory.com/148

 

Spring Data Redis에서 O(N) 명령어인 Keys를 Scan으로 대체하기

아직 Redis에 대해서는 잘모르지만, 싱글 스레드로 동작하기 때문에 O(N)은 피해야 한다는 것을 알게 되었다. redis-cli가 아닌 Spring Data Redis의 RedisTemplate에서 어떻게 대체할 수 있는지 어떻게 대체

dev-monkey-dugi.tistory.com

 

 

댓글