이번에 어떤 이유로 특정 사용자들에 대해 푸시 알림을 보내야 하는 일이 있었다.
이 과정에서 Redis를 사용했고, Redis를 사용하면서 고려 & 만난 여러가지 이슈들을 정리해보고자 한다.
1. 자료구조의 선택
자료구조를 선택하는 과정에서 여러가지 고민이 있었다.
String
우선 String은 배재했다. String을 사용하면 scan을 이용해서 특정 prefix로 key를 가져와서 보낼 수 있겠지만 푸시알람을 보낼 대상을 같은 key로 두고 해당 key에 list나 set 형태로 쓰는 게 더 데이터 관리 측면에서 간단해보였다. 또 어떤 상황이 올지 모르니 마음대로 expire를 지정할 수 도 없는 노릇이고, push가 성공적으로 나가면 key들을 전부 삭제해야 하는데 이 과정이 조금 귀찮기도 했다. (그런데 지금 생각해보면 굳이 안쓸 이유도 없어보임)
list
다음으로는 list 자료구조를 고려했다. 하지만 우려되는 점은 데이터가 몇 건이 들어갈지는 동향을 보았을 때 대충 예상이 갔으나 확신할 수 없기 때문에 특정 key에 데이터가 너무 많이 들어가 해당 bucket의 조회 속도가 느려진다거나, rehash가 일어난다거나 하는 일이 있을 것 같았다.
하지만 redis의 list는 listNode를 연결 리스트 형태로 갖고 있게 되고 각 node가 redisObject를 참조하는 포인터로 구성되어 있어 크기에 의한 성능 저하, rehash는 없겠다고 판단할 수 있었다. 그러나 이제 연결 리스트로 인한 문제가 생길 수 있겠다고 판단했다. range로 조회할 경우 O(S+N)의 성능으로 데이터가 많고, 뒤로 갈 수록 조회 성능이 많이 나빠지고, 이는 다른 redis 명령에 큰 영향을 줄 수 도 있겠다고 판단했다.
사실 이 때 pop을 고려하지 않았던 이유는 데이터를 1회성으로 밖에 사용할 수 없다는 부분에서였다. 푸시가 정상 작동 못해버리면, 사용자 정보가 모두 날라가서 대응이 어려웠다. 참고로 pop은 성능이 나쁘지 않다.
또한 list는 중복 제거를 보장하지 못하기 때문에, push를 할 때 {exist - push} 과정의 atomic 함을 보장해주어야 했다. 따라서 추가적으로 Lock을 잡거나, lua script를 통한 원자성 보장이 필요했다. (multi-exec은 exist 응답값을 사용할 수 없어 선택지가 되지 못한다.)
set
그래서 최종적으로 set을 사용하게 됐다. 중복 방지도 보장해주고 sscan
명령어를 통해 데이터가 없어지지 않아야 한다는 것, 어느정도의 성능을 유지한다는 점을 보장할 수 있었기 때문이다. set 또한 해시 테이블 구조에서 각 redis Object를 여러개의 bucket에 놓고 이를 참조하고 있다는 측면에서 하나의 hash 버킷에 엄청나게 많은 데이터가 들어가지 않겠다라는 생각을 할 수 있었다.
우려할 수 있는 부분은 푸시의 실패지점 파악 및 sscan을 적절하게 활용하기 위해 배치 시점에는 데이터가 들어오지 않음을 보장해야 한다는 것이었다. 하지만 이는 확실하게 보장할 수 있었기에 고려대상으로 선정하지 않을 수 있었다.
2. 명령의 선택
명령어를 선택하는 과정에서 sscan
과 spop
을 다시 고려하게 됐다.
sscan vs spop
처음에는 푸시에 회원 정보를 담을 예정이었어서 json 문자열이 value로 들어가서 sscan을 사용하는데 문제가 크게 없었다. 작은 문제가 있다면 scan의 커서를 지정하는 과정에서 특정 bucket에 여러 개의 데이터가 들어있다면 count가 정확함을 보장할 수 가 없었다. (5개를 원했는데 6개가 내려온다거나 뭐 그런) 하지만 이런 경우는 크게 문제가 되지는 않았기 떄문에 넘어갔다.
그런데 value에 userId만 받게끔 변경하면서 문제가 생겼다. sscan
명령어를 실행할 때 count를 지정해도 데이터를 전부 가져오는 것이었다. 당시에는 어디가 문제인지 결국 찾지 못하고 위험성이 있더라도 spop
을 사용하고 userId를 로그로 남기자라는 의사결정을 했다. 시간 / 동향을 고려했을 때 데이터가 많이 들어오지 않겠다는 것에 그나마 힘을 실을 수 있었기 때문에 이런 결정이 가능했다.
따라서 pop
명령어를 이용해 지정한 chunk size 만큼만 데이터를 조회하여 다른 명령어에 영향이 가지 않게끔 하였다.
나중에 알아보니 정수형 value만 512(default)개 이하 일 경우 redis는 intset을 사용하게 되고 intset은 정수형 배열에 모든 데이터를 담아 메모리를 최적화 한다고 한다. cursor를 지정하여 데이터를 조회하는 sscan으로써는 아무리 count를 지정해도 같은 버킷의 모든 데이터를 가져올 수 밖에 없는 것이었다.
추가적으로 고민 가능한 부분
하나의 key에 데이터가 많이 사용된다면, redis cluster를 적절하게 사용할 수 없다. 하지만 key:{hashtag} 형태로 데이터를 저장한다면 하나의 key이더라도 여러 클러스터에 분산하여 데이터를 저장할 수 있다는 장점이 있다. 하지만 다른 클러스터에 저장된다면 중복은 방지할 수 없기 때문에 (내가 잘못 실험한 거일수도) 중복 방지를 위해 userId의 범위에 따라 cluster를 지정하는 hashTag를 설정하여 저장한다는 추가적 로직이 필요해보였다.
여러가지 고민들을 통해 해당 내용을 배포할 수 있었고, 결과적으로 데이터를 저장하고 push를 발송하는 과정에서는 전혀 문제가 없었다.
'새로운 학습' 카테고리의 다른 글
[Hibnernate] Query Cache Plan (1) | 2022.12.24 |
---|