서론
위 당근 서비스의 채팅서버에 대한 발표를 정리한 글이다.
당근에서 설계된 채팅 아키텍쳐와 전략 등을 살펴보고, 내 생각을 따로 정리하고자 이렇게 작성했다.
모든 그림은 발표에서 사용된 그림을 가져왔다.
본론
채팅서버의 동작방식 설명
상황
User A, B, C가 존재하고 각각은 메세지 발행자, 메세지 수신자(Online), 메세지 수신자(Offline) 상태이다.
서버의 종류는 Chat-Server, Push-Server, 데이터를 저장하는 DB가 있다.
채팅서버의 간단한 동작방식
시나리오: A가 B와 C에게 메세지를 전달한다!
1. A가 채팅서버와 연결되면, 채팅서버의 로컬 메모리에는 유저의 ID값과 Session값을 저장해둠.(B도 동일하게 수행된다.) 이를 통해서 사용자가 온라인인지, 오프라인인지 구분하게 된다.
-> 메모리에는 A.userid: session A, B.userid: session B가 저장되어있음.
2. A가 메세지를 보낸다.
3. 메세지는 DB에 저장하게 되고,
1) 메모리에 B의 정보를 확인했다. B에 해당하는 세션정보를 불러와서 B에게 전달한다.
2) 메모리에 C의 정보는 없다. 이를 푸시서버에게 전달하고 푸시서버에서 C에게 메세지를 푸시한다.
이전 채팅서버 구현시, 오프라인인 사용자에 대한 시나리오 및 구현을 고려하지 않았다.
단순히 접속한 사용자에 대해서 생각해보았기에 고민해볼 수 있는 사항이라고 생각했다.
채팅서버 N대의 동작방식
만약 채팅서버가 2대라면?
채팅서버가 2대라면, 서버마다 로컬 메모리에 유저의 세션정보를 저장하고있을 것이다.
A와 B가 서로 다른 채팅서버에 연결했다면, A와 연결된 서버는 메모리에 B의 정보가 없기에 B가 온라인 상태가 아니라고 판단하게 된다.
문제를 어떻게 해결할까? 2가지 해결법을 제시한다.
💡 해결방법을 알아보자 💡
1. 공유 메모리, 저장소를 사용한다. 이는 유저가 접속한 서버의 IP를 관리한다. (당근 Pick!🥕)
2. pub/sub 기능을 통해 유저아이디 기반의 이벤트 발행 뒤, 구독중인 유저(subscriber)에게 메세지를 전달한다.
1번의 선택이유
-> 그룹채팅방에서 발생하는 비효율성때문이었다. 아래 글에서 이유를 더 자세히 작성한다.
📌📌📌
과연 pub/sub에서는 그룹채팅과 같은 문제를 어떻게 해결할 수 있을까? 과연 해결방법이 비효율적이기만 할까?
이를 고민해보는 것이 매우 좋을 것 같다.
Key-value Store
모든 채팅서버는 Key-value를 저장하고 관리하는 Key-value store를 바라보도록 한다.
즉, 채팅서버에 접속하게 되면 로컬에서는 유저의 세션정보를, DB에서는 유저의 서버 IP를 저장한다.(앱 종료시에는 이를 삭제)
서버는 접속중인 유저확인을 위해서 Key-value store를 확인하고 이를 전달한다.(릴레이 요청)
이때, 위 방법으로 서버가 N대를 사용한다면 네트워크 복잡도가 매우 크게 증가해서 확장이 불리해진다.
-> Message Queue + Consumer를 통해 해결! (서버간 통신 비동기 처리)
Message Queue + Consumer
서버에서 메세지 대상을 Message Queue에 넣어두면 Consumer가 Key-value store를 확인해서 알맞는 서버에 요청을 보낸다!
(이때, Consumer는 비동기로 동작)
그러면 유저가 오프라인일 경우는 어떻게 처리할까? 2가지 시나리오가 존재한다.
1. 접속하지 않은 유저
Key-value store에 데이터가 없는 것을 Consumer가 확인한다면, 푸시서버에 요청을 보낸다.
2. 잘못된 접속정보의 유저
Key-value store에 데이터가 있고 Consumer에서 해당 채팅서버IP로 요청을 보낸다.
이때, 해당 유저는 오프라인이기에 채팅서버는 로컬 메모리에서 유저를 찾지 못하고, 채팅서버 -> 푸시서버로 요청을 보낸다!
Key-value store를 완전히 믿으면 안된다.(비정상적인 동작이 빈번히 발생)
그룹채팅에서의 동작방식
상황
채팅문의하기 서비스를 가정한다.
당근 사장님, 매니저, 고객 3개의 역할이 존재하고, 고객의 채팅과정을 사장님과 매니저가 모두 볼 수 있어야 한다.
시나리오
1. 고객이 채팅요청을 보낸다.
2. 비즈니스 로직이 수행되고, 사장님과 매니저에 대한 요청이 Message Queue에 쌓인다.
3. 두 사람 접속중인지 확인 후, 각 서버에 Consumer가 요청보낸다.
그룹톡방의 특징
큐에서는 사람 수만큼 큐를 쌓고, 또 사람 수만큼 Consumer가 큐를 받아서 Key-value 스토어에서 조회하고 서버에 전송한다.
10명의 그룹이라면 위 과정이 10번씩 수행되어야 한다. 벌써 비효율의 냄시가...😅 만약 1만명의 그룹톡방이면 각 과정을 1만번씩 수행해야 한다.
-> 유저의 아이디를 리스트로 묶어서 요청하자!🥕
위를 통해서 10번, 1000번, 10000번 수행할 요청이 1번으로 줄어들게 되고, 각 요청은 유저 아이디 리스트로 묶여있다.
만약 Pub/sub을 쓴다면 유저를 묶어서 처리하는 방식을 적용하기 어려웠을것이다.
한번에 많은 사람을 처리하는게 어렵지 않을까? -> UserID를 chunk단위로 나눈다! 위에서는 chunk를 200으로 설정했다.🥕
❓pub/sub에서는 유저를 처리하는 방식이 어떻게 해서 비효율적이라고 말하는걸까?
채팅서버 동작방식에 대한 특징
1. 확장성 -> Message Queue + Consumer를 통해, 비동기 처리 도입의 수평적 확장 가능하다.
2. 신뢰성 -> 메세지 전송 후 문제가 발생한다면, 푸시알림을 통해 유저가 메세지를 유실하지 않고 전달받을 수 있다.
3. 효율성 -> 많은 사용자에게 메세지를 전달하면, list, chunk단위로 전달하면 효율적인 메세지 전달이 가능해진다.
위 동작과정은 안정성과 효율성을 동시에 챙긴 구조라는 생각이 든다.
이때, pub/sub에서도 위와 비슷한 구조를 설정할 수 있는지 궁금해진다.
채팅서버 데이터 저장방식
특징, 히스토리
저장되는 메세지 양은 매우 많고 다양하다.
초기에는 당근🥕의 도메인 모두가 하나의 RDB에 접근하도록 했다.
이때 서비스가 급격하게 발전하면서, 데이터의 양 또한 폭발적으로 많아졌기에 RDB의 최적화가 물리적 한계를 맞이한다.
가장 중요한 점이, scale up할 사양이 얼마 없었다고 한다.(2~3개 정도?) + 이중 채팅 데이터가 60%정도를 차지
결국 RDB 1개 -> 메인 DB, 채팅 DB로 분리한다! + 실시간성을 위한 API 최적화도 진행!
DB 설계하기!
상황
위는 채팅에서 수행하는 간단한 테이블을 짜둔 모습이다.
1. Message(채팅 내용): PK, 내용, 채팅방id(FK)
2. Chat(채팅방): PK, 채팅방 이름, 썸네일id(FK)
3. UserChat(채팅방에 소속된 사람 및 설정): 채팅방id(PK, FK), 유저 id(PK, FK), 음소거, 최신 채팅 index(최신 채팅방 정렬을 위해)
샤딩(Sharding)
물리적으로 분리된 DB인스턴스를 샤딩이라고 한다.
이 샤딩 기법을 통해서 분리된 데이터베이스를 모두 접근하는게 아니라, 최대한 적은 수의 DB에서 데이터를 가져와야한다.
만약 Mesaage를 샤딩한다고 하면, 수많은 Message의 데이터가 여러 DB에 분리되어 저장된다.
샤드 키(Shard Key)
DB에 최대한 적게 접근해서 수행하기 위해서는 Shard Key를 패턴에 맞게 설정해야한다.
Shard Key란, 데이터를 어떤 Shard에 위치시킬지 기준이 되는 필드이다.
상황
채팅방에 진입하게 되면 클라이언트는 채팅방의 Message 리스트를 요청한다. 이때 Shard Key로 채팅방 아이디를 사용한다면, 채팅방별로 메세지를 동일한 Shard에 저장할 수 있게된다. 같은 채팅방의 메세지는 같은 Chat id로 저장하고, 유저는 User id를 쓴다!
샤딩을 구현할 때, 샤딩을 지원하지 않는 DB를 사용하면 아래 과정을 직접 구현해야한다.
그래서 당근은 뭐쓰는데?
결국 채팅은 현재 AWS DynamoDB🥕를 선정한다.
확장성 및 적은 인원으로 관리가능해야했고, DynamoDB는 Partition key(= Shard key)를 설정하면 내부적으로 자동 샤딩을 수행해준다. 또 DB관리비용 자체도 배재할 수 있게된다.
-> 그 결과, 트래픽에 관련되는 DB장애는 거의 없다고 한다.
채팅 실사간성을 위한 API
최신 채팅방 정렬하기!
채팅은 새로운 메세지가 있는 채팅이 위로 올라간다. 또 채팅방이 정렬되는 순서가 존재한다.
채팅방에서 읽지않은, 최신에 왔던 메세지를 정렬시켜주는 과정에 대해서 비동기적으로 처리하도록 한다.
UserChat table에 있는 display-idx를 message에 새로운 로우가 추가될때마다 업데이트시켜주는 전략을 수행한다.
메세지 읽기
위는 메세지에 대한 읽기 과정에 대한 동작과정이다. 만약 메세지를 읽었다면,
1. 유저1이 데이터를 읽었기에 읽음 api호출
2. DB는 읽음 API에 대한 로직 수행후,
3. 참여중인 유저를 조회해서
4. 서버가 이벤트 전달을 통해서
5. 이벤트를 전달해준다.
6. 모든 과정 끝난 뒤 응답을 유저1에게 보낸다.
-> 너무 시나리오가 길고 처리하는 시간이 오래걸리는 것을 볼 수 있다. 비동기 처리하자!
해당 부분을 비동기 처리를 적극활용함. (읽음처리)비동기 처리를 동기적으로 write해야하는 데이터만 캐시에 적재 후, 사용자에게 따로 백그라운드에서 DB,Cache에 수행. 고루틴이 위험하다면 카프카를 사용함. 요청시 처리할 테스크를 카프카 이벤트로 발행 후, 응답을 내려줘서 이벤트를 컨슈머로 다시 이용해서 비동기방식으로 처리하도록 함. 카프카는 이뿐 아니라 다른 도메인의 과정도 수행함.
위 노란색 부분은 아직 완벽하게 이해하지 못한 상태이다.
발표외에 채팅에서 생각해야하는 과정을 정리한 것이다.
내 생각 정리
채팅서버 동작
생방송에서 진행하는 라이브 채팅보다는, 당근에서 사용자끼리 수행하는 채팅을 구현하는 방식으로 하자.
서버의 동작방식 측면에서는 크게
1) 사용자가 온라인이냐, 오프라인이냐에 따른 고민 -> 푸시알람 처리, 실시간성 메세지 보내기
2) 그룹사용자에 대한 고민 -> 효율적인 처리를 위한 고안
2가지를 고민한 것 같다. 특히 2)번때문에 key-value store방식을 선택한 것 같은데... 앞으로 다른 것들을 정리하면서 pub/sub에서는 정말 그룹채팅에 대한 처리가 비효율적일 수 밖에 없는지 글로 정리해보자.
⭐️그리고 pub/sub측면에서의 서버구현을 수행하면서 위처럼 간단한 구현방식을 정리하면 좋을 것 같다.⭐️
왜 Go로 구현했을까?
go가 사실 로우레벨에 더 적합한 언어이기에 아마 java, kotlin을 활용한 springboot보다 비동기처리는 더 좋을 것이다.
하지만 그 이유를 명확히 아는것은 아니니, 당근의 블로그나 유튜브를 더 찾아보자.
채팅저장 방식
많은 대기업, 유니콘 기업에서 활용하는 카프카를 통해서 동기화를 수행했고, 샤딩을 활용해서 DB를 분리시켰다.
음, 당연히 카프카를 쓰는건 안정적인 환경과 분산DB를 동기화하기에 적합하지만, 이보다 더 나은 기술이 있는지 궁금하다.
우리나라에서 성숙했기에 계속 사용하는 기술인건지, 아니면 최근 동향으로도 정말 최적의 기술인건지 궁금하다.
발표 외 채팅구현 시 설계 및 최적화
채팅은 정말 신경써야하는 부분이 많은 분야인 듯 하다. 발표에서의 2가지만 봐도 많다고 생각했는데 위 그림처럼 아직 저렇게나 많다니... 아직 한참 공부가 필요한 것 같다.
'고민 + 기타' 카테고리의 다른 글
프로젝트에서 유용하게 쓸 수 있는 기술들 모음집. (0) | 2025.03.17 |
---|