데이터 분할로 친구 관계의 연쇄 작용 제어: 실시간 친구 추천 시스템 구축기
1. 기존 방식 및 문제점
1-1. 기존 친구 추천 시스템 개요
기존의 친구 추천시스템은 조회용 추천친구 테이블을 별개로 두고 1시간 마다 배치로 친구 테이블에서 추천친구를 계산하여 추천친구 테이블에 저장하였습니다.
아래 표를 기준으로 회원마다 점수를 측정하여 상위 10명을 추천 했습니다.
| 2촌 | 3촌 | 글 추천 | 댓글 추천 | 댓글 작성 | 공통 2촌 추가 점수 | 공통 3촌 추가 점수 | |
| 점수 | 50점 | 20점 | 0.5점 | 0.5점 | 0.5점 | 2점 | 0.5점 |
| 점수 제한 | 최대 50점 | 최대 20점 | 최대 10점 | 최대 20점 |
최대 5점 | ||
1-2. 기존 방식의 문제점
문제점
1. 실시간성 불가.
2. 추천 친구 테이블을 1시간마다 제거하는 비용.
3. 배치를 최적화 시키기 위한 복잡한 조건.
4. 만든사람도 알아보기 힘든 스파게티 코드.
1. 실시간의 부재
가장 심각한건 실시간의 부재입니다.
"나 - 친구A - 친구B - 친구C" 에서 나와 친구C는 3촌관계 입니다. 그런데 제가 친구B를 새로운 친구로 맞이했다고하면 친구C는 3촌에서 2촌이 됩니다. 하지만 여전히 추천친구 테이블에 친구C는 3촌으로 저장되어 있습니다. 사용자가 과거의 정보를 보는 시간이 1시간으로 깁니다. SNS의 특성상 이런 지연은 서비스에 치명적입니다.
2. 추천 친구 테이블을 1시간마다 제거하는 비용.
현재 추천 친구는 상위 10명을 반환하기 때문에 회원 1명당 10개 레코드를 가집니다. 하지만 추천 친구의 표시 인원수는 언제든지 달라질 수 있습니다. 만약 상위 20명으로 변경하거나 페이징으로 100명을 반환하겠다하면 추천 친구 테이블의 데이터는 2배, 10배로 커집니다. 회원 수가 적을때는 괜찮으나 미래에 회원이 많아지면 큰 I/O 부하가 발생할 것 입니다.
3. 배치로직을 최적화 하기 위한 복잡한 조건
배치로직이 시스템에게 줄 부하를 염려하여 최적화 시키기위해 복잡한 절차를 거쳐 개발자를 혼란스럽게합니다.
1. 1시간 마다 배치로 추천친구 테이블의 모든 데이터를 제거
2. 2촌계산 : 각 멤버의 2촌을 조회하고 10명이 이상이면 4으로간다. 되지않으면 3로간다. 아무도 없으면 5로 간다.
3. 3촌계산 : 각 멤버이 3촌을 조회하고 3으로 간다.
4. 공통친구 점수 계산 : 결과를 대상으로 유니온파인드를 실행하여 관계파악 후 점수를 부여 후 5로 간다.
5. 상호작용 점수 계산 (10명 이상) : 결과를 대상으로 상호작용 계산을 시행하여 점수를 부여하고 상위 10명을 잘라 저장한다.
5-2. 상호작용 계산 (10명 미만) : 전체 테이블 대상으로 상호작용 계산을 시행하여 점수를 부여하고 6으로 간다.
6. 멤버 채우기 : 1최근가입자순으로 추천친구를 저장한다.
4. 만든사람도 알아보기 힘든 스파게티 코드.
3번의 이유로 모든 멤버를 대상으로 친구테이블에서 셀프조인, 복잡한 자료구조, 여러 도메인으로 추가정보 요청 및 복잡한 조건을 거쳐 추천친구테이블에 저장하며 코드가 불어나게 되고 한눈에 알아보기 힘듭니다.
1-3. 실시간 조회를 구축하기 위해 반드시 해결해야 할 개념
실시간 정보를 전달하기에는 관계의 연쇄작용이 가장 큰 문제였습니다.
1. 관계가 바뀌었을 때 그 관계만 업데이트되는 것이 아니고 파장에 따라 친구네트워크가 동적으로 바뀜
2. 추천이 상호간에 발생했을때, 댓글이 상호간에 바뀌었을때도 점수가 달라짐
3. 상태가 바뀔때마다 추천친구 테이블을 업데이트 하기는 사실상 불가능했습니다.
나 - A - B - C 로 이뤄지는 관계에서 내가 B와 친구가 되면 B는 2촌에서 1촌(친구)로 C는 3촌에서 2촌이 됩니다.
많은 친구들이 있고 친구들끼리 연결되어있다면 파장은 가늠할 수 없습니다.
조회용 테이블은 정적이여야하며 캐시의 성격을 지니고있습니다.
친구관계라는 동적인 개념을 어떻게 정적인 것에 적용할 수 있을지가 중점이었습니다.
1-4. 결과 요약
새로운 방식
데이터를 정적, 동적, 성격 별로 구분하였습니다.
| 구분 | 데이터 |
| 동적이자 연쇄 작용에 영향받는 데이터 | 촌 수, 공통 친구 |
| 비교적 정적이자 연쇄 작용의 근원인 데이터 | 친구관계 |
| 정적인 데이터이자 미리 산정할 수 있는 점수 | 상호관계 점수 |
1. 3번 데이터 상호작용 점수를 Redis에 저장 이벤트 발행으로 미리 계산하여 성능과 스파게티 코드를 개선했습니다.
2. 2번 친구관계를 Redis에 저장하여 DB의 친구관계와 동일하게 동작하도록 하였습니다.
3. Redis에서 친구관계를 조회하여 BFS를 통해 촌 수와 공통 친구 수를 계산하고 3번의 점수를 더하여 실시간으로 추천친구를 반환했습니다. 연쇄 작용의 변동성이 심한 부분을 애플리케이션 부분으로 가져와 문제를 해결하였습니다.
성과
1. 실시간 친구 추천 확보 : 실시간 구현의 키포인트는 관계의 연쇄 작용을 제어하는 것에 있었습니다.
2. 관계의 연쇄 작용 제어 : 관계 연결이 많은 데이터와 적은 데이터를 구분하여 관리하여 연쇄 작용을 경량화 시켰습니다.
3. 서버 리소스 증가 불필요 : 서버를 증설하지 않고 리소스를 최소한으로 소모하게 하여 비용을 절감했습니다.
4. 미래 지향적 시스템 : 향후 회원이 늘어나도 견딜 수 있는 로직을 구축했습니다.
| 테스트 (회원 1000명) | 평균 응답 시간 | 최소 응답 시간 | 최대 응답 시간 |
| 기존 추천 친구 조회 | 49.6ms | 20ms | 230ms |
| 실시간 추천 친구 조회 | 53.9ms | 39ms | 81ms |
| 테스트 (회원 1000명) | 실행시간 |
| 기존 추천 친구 배치 저장 | 37.85초 |
| 실시간 추천 친구 저장 | 0초 (저장 로직 없음) |
2. 설계 탐색 및 아키텍처 고려
2-1. 목표
회원이 30명인 제 서비스는 현재 시스템으로도 충분하지만 미래를 대비하여 최소 1000명의 회원에서 원할하게 작동하는 시스템을 구축하고 싶습니다.
| 분야 | 목표 |
| 실시간 | 친구추천의 실시간 시스템을 구축해야합니다. |
| 응답속도 | 제1목표는 100ms 이하입니다. 200ms는 절대 넘으면 안됩니다. |
| 동시성 | 친구추천과 관련된 API중 어느것이라도 사용자가 인지할 만큼 스레드 블로킹이 되면 안됩니다 |
| 안정성 | 어떠한 방법으로 설계를 하더라도 데이터를 복구할 기반 데이터가 있어야합니다. |
| 개발 생산성 | 지나치게 복잡한 로직, 관리해야할 요소 증가로 개발 생산성이 저하되면안됩니다. |
| 한계 회원 | 1000명 입니다. 회원이 더 늘어나더라도 부하가 기하급수적으로 증가하지 않고 선형적으로 증가해야합니다. |
| 서버 리소스 | 스케일 아웃이나 스케일 업, DB의 추가 증설등 서버비가 증가하면 안됩니다. . |
| 계산 과정의 리소스 | 추천친구 테이블에 저장을 하던 조회시에 동적으로 계산하던 메모리와 CPU의 리소스 소모가 적어야합니다. |
2-2. 외부 사례 조사
다른 기업들은 이 부분을 어떻게 해결하였는지 조사하였습니다.
| 기업 | 해결법 |
| 메타 | PYMK시스템 + TAO아키텍처 |
| 링크드인 | Liquid + GAIA |
| 하이퍼커넥트 | 카프카 스트림 + ksqlDB |
위의 사례를 적용하는 것은 제 프로젝트에서 오버 엔지니어링이라고 생각합니다.
2-3. 고민한 아키텍처의 도입 가능성 판단 요약
결론 : Redis 테이블 분할 + 계산 로직 Redis 내부 or 외부 (후보1, 후보2)
| 방법 | 실시간 | 응답 속도 | 조회/저장시 블로킹 | 안정성 | 계산 리소스 소모 | 서버 리소스 | 개발 생산성 |
| 기존 방식 | 불가 | 느림 | API 사용불가 | 매우 높음 | 높음 | 없음 | 높음 |
| MongoDB | 불가 | 보통 | API 사용불가 | 높음 | 높음 | 매우 증가 | 매우 저하 |
| Redis 배치 | 불가 | 빠름 | API 사용불가 | 보통 | 높음 | 매우 증가 | 저하 |
| Redis 클러스터 | 가능 | 빠름 | 없음 | 높음 | 높음 | 매우 증가 | 높음 |
| Redis에 모든관계 삽입 | 가능 | 빠름 | 없음 | 보통 | 없음 | 매우 증가 | 보통 |
| 샤딩 + 배치 | 불가 | 보통 | 없음 | 매우 높음 | 높음 | 매우 증가 | 매우 저하 |
| 파티셔닝 + 분할업데이트 | 가능 | 보통 | 없음 | 매우 높음 | 보통 | 없음 | 매우 저하 |
| Redis 테이블 분할 + 계산 로직 Redis 내부 | 가능 | 매우 빠름 | API 사용불가 | 매우 높음 | 낮음 | 없음 | 높음 |
| Redis 테이블 분할 + 계산 로직 Redis 외부 | 가능 | 매우 빠름 | 없음 | 매우 높음 | 낮음 | 없음 | 높음 |
2-4. 아키텍처의 고려 사고 흐름
1. MongoDB 도입
가장 먼저 생각난 것은 Nosql이었습니다. 동적인 친구 관계를 저장하기엔 Rdb보다 적합하다고 생각했습니다. 그러나 신경써야할게 하나 늘어난다는 문제가 있습니다. 제 서비스의 수많은 로직 중 배치로직 하나를 위해 DB를 하나 더 도입한다는 건 후에 Trade-off가 크다고 생각합니다. 작은 시스템도 아니고 데이터베이스입니다. 배치로직 하나를 위해 DB를 더 도입하는 것은 이후의 개발 생산성을 저하시킨다고 생각합니다.
2. Redis 배치 방식
그렇게 고민중인 찰나에 전 이미 Redis를 쓰고 있다는 것을 떠올렸습니다. Redis도 Nosql기반이고 굳이 MongoDB같은 것을 도입할 필요없이 Nosql이 필요하면 기존의 Redis를 쓰면 되지 않냐는 생각입니다.
그러나 Redis의 치명적인 단점은 메모리기반이라 영속성이 없다는 것입니다. 물론 AOF를 통해 영구 저장과 복구가 가능하지만 그것 또한 개발자에게 새로 신경써야하는 요소가 하나 더 생기는 겁니다. 또 하나의 치명적인 단점은 싱글 스레드 기반이라 배치 로직이 돌아가는 동안 다른 사용자가 조회를 할 수 없다는 것입니다. 레디스에서 배치가 돌아가면 Rdb보다는 매우 빠르기 때문에 회원이 1000명이라고 하면 수 초내에 끝날 수도 있습니다. 그렇게하면 쓸 만은 하겠지만 결국 실시간성이 되지 못합니다. Redis를 빠른속도를 위해 사용하는 상황이며 메모리 사용량이 증가하여 서버가 터질 위험성이 있습니다. 또한 만약 1000명은 커버한다고 해도 만약 제 서비스가 커져서 5000명, 만명이 된다면? 그러면 추천친구는 기하급수적으로 증가하기 때문에 그 방식 또한 불가능합니다.
3. Redis 클러스터 + 실시간 조회
그리고 실시간 조회를 적용시키겠다고하면 누군가가 추천친구를 조회할때마다 레디스에서 배치로직이 작동되어 병목이 될 것입니다. 이 방법에서 레디스 클러스터를 통해 배치 로직이 돌아갈 때도 조회를 가능하게 할 수 있습니다. 하지만 Redis 클러스터도 결국 실제 Redis를 여러개 올리는 구조입니다. 그만큼 리소스를 소모하고 1000명 기준 스케일 아웃이나 스케일 업을 피할 수 없다고 생각합니다. 그리고 이건 근본적인 데이터의 흐름을 제어하지 못하고 시스템의 리소스를 늘린다는 것과 다름이 없습니다.
4. Redis에 모든 회원끼리의 점수를 넣어두기
만약 Redis에 모든멤버에 대한 각각의 관계를 미리넣어둔다면? 그렇게 모든 연결점에서 이벤트 발행시 수치로 증가하고 추천멤버를 조회했을 때 상위권만 반환한다면? 즉 관계의 연쇄 작용을 방지하기 위해 미리 모든 관계를 펼쳐놓는 방법입니다. 이 방법은 실시간 조회가 가능하고 조회도 빠릅니다. 그래서 싱글 스레드 기반으로 동작해도 다른 회원도 접근할 수 있습니다. 하지만 멤버가 1000명이면 그 모든관계는 제곱인 100만개입니다. 메모리 사용량이 엄청나게 증가합니다. 그리고 멤버가 늘어날수록 기하급수적으로 레코드와 메모리 사용량이 증가합니다. 회원이 1만명이라면 1억개 입니다. 하나의 자료구조에 많은 데이터를 넣어두는 것도 바람직하지 않고 회원이 늘어날수록 기하급수적으로 조회가 느려지며 한계가 명확한 시스템이라고 생각합니다.
5. Mysql 샤딩
사실 Nosql은 Rdb보다 유연하여 관계의 연쇄작용을 감당하는 것에 유리하다고 생각하여 고민을 했지만 DB설계로 해결을 할 수 있지 않을까 라는 생각에 Rdb에서 해결할 방법을 고민했습니다.
샤딩 - 과합니다. 데이터베이스를 하나 더 추가하는 것으로 최악의 리소스 낭비입니다.
6. Mysql 파티셔닝 + 분할 업데이트
파티셔닝? - 친구 네트워크를 몇개의 거대 네트워크로 분할하여 분할 업데이트한다. 좋은 방법입니다. 하지만 친구 네트워크에서 거대 네트워크들은 어떻게 식별할건지? 식별한다고해도 분할의 범위를 어떻게 잡을 것인가? 친구관계의 변동이 일어날 때 하나의 파티션에서만 일어난다는 보장이 있는가? 각각의 거대 네트워크의 특정 노드들은 약하게라도 다른 네트워크와 이어지는데 그때는 어떻게 할 것인가? 신경 쓸 것이 너무 많았습니다.
그때 중요한 것을 깨달았습니다. Rdb든 Nosql이든 각자의 자료구조로 디스크든, 메모리든 어딘가에 저장하는 데이터베이스입니다. 결국엔 정적인 데이터처리에 가장 유리합니다. Nosql이 유연하긴해도 동적으로 변화하는 친구 네트워크를 즉각적으로 표현하기 어렵습니다. 그렇다면 정적인 것은 DB에 저장하고 동적인 것은 애플리케이션 로직에서 즉각처리하면 안될까? 그럼 DB를 변화시키는 리소스 낭비도 없고 동적인 것을 즉각 처리하기 때문에 실시간성도 확보됩니다. 동적 데이터 처리를 하는 동안 특정 사용자가 DB에 접근하지 못하는 이슈도 없습니다. 하지만 동적인 것을 애플리케이션에서 처리할 때 메모리 사용량이 치솟지 않을지 속도가 지연되지 않을지 해결해야합니다.
친구 관계 네트워크를 Rdb든 Nosql이든 어디에도 저장할 필요가 없습니다. 누군가가 하늘에서 친구관계 연결그래프를 전체적으로 보고 있는 것이 아니기 때문입니다.
3. 아키텍처 설계 시작
3-1. 설계 원칙
그렇다면 결론은
1. 정적인 요소만 DB에 저장할 것 (DB의 업데이트 최소화, 다른 스레드의 DB접근이 가능)
2. 애플리케이션에서 동적 요소의 계산을 빠르게 하기 위해 계산에 필요한 자료 구조만 빠르게 가져올 것(레이턴시 감소)
3. 메모리 기반 DB에서 데이터가 사라져도 복구할 수 있는 데이터가 존재할 것(안정성 증가)
1번에 따르면 추천 친구 테이블은 존재하지 않아야합니다. DB의 업데이트를 최소화 해야하기 때문입니다.
2번에 따르면 Redis를 통해 정적 요소를 가져오는 것이 가장 좋은 방법입니다. 또한 각 상황에 적절한 자료구조가 존재하는 장점이 있습니다.
3번에 따르면 RDB에 친구관계 데이터를 두고 Redis에도 친구관계 데이터를 두어야 합니다. 중복처럼 보이지만 정합성을 지킬 수 있는 좋은 방법입니다.
3-2. 구현 방향 및 로직 분리
응답속도를 빠르게 하려면 추천 친구를 계산하는 로직을 최소화해야합니다. 그리고 추천 친구를 계산할 때 관계의 연쇄 작용이 강한 부분과 약한 부분이 분리되어있다는 것을 깨달았습니다. 그리고 기존 로직에서 실시간 인기 롤링페이퍼를 계산할 때 Sorted Set을 이용해 점수화해서 즉각적으로 조회 했던 것을 떠올렸습니다. 만약 Redis에서 데이터를 가져와서 추천 친구를 계산할 때 일정부분 이미 완료가 되어있다면? 추천 친구를 계산하는 시간을 절약할 수 있습니다.
관계의 연쇄 작용의 해결법은 데이터의 분리였습니다. 변경 되었을 때 다른 요소들에 강한 연쇄 작용을 주는 데이터와 약한 연쇄 작용을 주는 데이터를 분리해서 약한 연쇄 작용을 유발하는 데이터들은 충분히 미리 저장할 수 있습니다. 친구 네트워크의 변동성은 친구의 연결, 해제에서 나옵니다. 제 추천 친구 계산로직의 상호작용 점수(글, 댓글 추천, 댓글 작성 등)은 변동성에 큰 영향을 주지 않습니다. 한마디로 이 상호작용 점수에 필요한 요소들은 충분히 정적인 성격의 자료들이고 미리 계산할 수 있습니다
그렇다면 상호작용 점수는 상호작용이 일어날 때 마다 이벤트를 발행하여 미리 계산해두면 됩니다. 혹여나 데이터가 날아가도 RDB조회를 통해 복구가 가능합니다.
상호작용 점수 (Sorted Set)
상호작용 점수는 상호간에 댓글 작성, 글 추천, 댓글 추천에서 이벤트로 실시간으로 레디스에 점수를 올린다.
이때 interaction:1의 member:2의 값과 interaction:2의 member:1의 값은 같다
```
interaction:1
member:2 = 10
member:3 = 5
member:4 = 8
interaction:2
member:1 = 10
member:3 = 7
member:4 = 3
```
그렇다면 친구관계테이블에서 촌수를 계산하여 점수를 부여하고 공통친구를 계산하여 점수를 부여해서 기존에 계산되어있던 상호작용점수에 더하기만 하면 끝입니다. 글 테이블, 댓글 테이블 등의 Rdb 조회의 필요성이 사라진 것입니다. 추천 친구 계산에 필요한 것은 오로지 점수이기 때문입니다.
그렇다면 이제 촌수를 계산하고 공통친구를 계산해야합니다. 그 계산을 Redis안에서 하는 것은 1번에 위배됩니다.싱글 스레드 이기 때문에 계산을 하는동안 다른 사용자는 Redis에 접근을 못 할 것입니다. 계산 로직은 공통 재산인 Redis가 아닌 각각의 스레드에서 하는 것이 맞습니다.
친구관계 그래프 (set)
```
friend:1 → [2,3,4]
friend:2 → [1,5,7]
friend:3 → [1,9,10]
```
Redis의 친구관계 그래프는 Set을 활용합니다. 중복 친구가 등록되지 않게 하였습니다. 이 때 촌수 계산은 BFS로 하고 공통친구는 유니온파인드를 활용합니다. 친구관계 그래프도 유실될경우에 얼마든지 Rdb에서 복구가 가능하고 친구가 추가되거나 삭제될경우 이벤트로 즉각 관리할 수 있습니다.
관계의 연쇄 작용을 극복하고 실시간성을 확보하고 응답시간을 저하하지 않으며 서버의 리소스 낭비도 최소로 하였고 안정성있는 시스템을 만들었습니다. 회원 수가 늘어나거나 회원 당 보여줘야할 추천친구의 수가 늘어나면 RDB에서 배치로 저장하는 로직은 기하급수적으로 증가할 것이고 증가하는 추천친구의 테이블의 레코드만큼 조회도 느려질 것입니다.
반면 새로운 방식은 보여줘야할 추천친구의 수가 늘어나도 BFS의 타겟목표가 10명에서 더 보여줘야 하는 인원 수가 될 뿐 추가적으로 저장하지 않아 메모리 소모가 크지 않습니다. 회원수가 늘어나도 기존 방식은 추천친구가 10명일 시 1명이 늘어나면 10개의 레코드가 추천친구 테이블에 추가되지만 새로운 방식은 단 하나의 Set과 Sorted Set의 키를 추가하기만 하면 됩니다. 요구사항이 변경되거나 회원수가 증가하는 등의 미래의 변화에 좀 더 유연하다고 판단합니다.
4. 테스트
4-1. 테스트 전제
회원의 상호작용이 활발한 상황을 가정하여 추천 친구 조회 API 성능 테스트를 하였습니다.
회원 : 1000명
각 회원의 평균 보유 친구 : 15명
각 회원의 평균 글 수 : 1개
각 회원의 평균 글 추천 수 : 1개
각 회원의 평균 댓글 수 : 5개
각 회원의 평균 댓글 추천 수 : 10개
기존 방식은 약 1만 레코드가 추천친구 테이블에 등록되게 됩니다.
4-2 테스트 결과
| 테스트 | 실행시간 |
| 기존 DB 추천 친구 배치 저장 | 37.85초 |
| 기존 DB 추천 친구 배치 저장 (도메인 무시 Join) | 17.96초 |
| Redis 실시간 조회 | 0초 (저장 로직 없음) |
기존 방식의 배치 저장이 약 37.85초가 걸렸는데 1시간 기준으로 약 1.05%의 시간이 배치로 돌아갑니다. 배치를 조금 더 빠르게 하는 것은 불가능할 점유율입니다.
그래서 각 도메인에 정보 요청을 보내지않고 직접 Join하여 최적화를 시켰더니 절반으로 줄어들었습니다. 이정도면 배치를 30분 단위로 해도 될 것 같지만 실시간인 새로운 방법에 비하면 모든 측면에서 안 좋습니다.
위 테스트와 동일한 상황에서 추천친구조회 10명을 동시에 실행했습니다.
| 테스트 | 평균 응답 시간 | 최소 응답 시간 | 최대 응답 시간 |
| 기존 추천 친구 조회 | 49.6ms | 20ms | 230ms |
| 실시간 추천 친구 조회 | 53.9ms | 39ms | 81ms |
평균 응답 시간이 4ms 증가 했지만, 디스크 I/O의 특징인 최소, 최대 응답시간이 큰 차이가 나는 현상이 개선되어 최대 응답 시간이 3배 가까이 줄어들었고 배치가 불 필요하여 37.85초의 배치 시간이 완전히 사라졌습니다. 실시간 계산 오버헤드가 추가되었지만, 실시간 목표를 달성하였고 최대 응답시간의 불안정성을 제거하으며 배치 부하를 0으로 만들었기 때문에 감수할 수 있는 Trade-off입니다. 그리고 회원 수가 많아질수록 디스크를 탐색하는 기존 방식보다 Redis에서 전달받는 새로운 방식이 성능이 좋아질 것 입니다.