IYAGI Backend Architecture III

업데이트:

IYAGI Backend Architecture III

지난 이야기(IYAGI Backend Architecture II)

중간 정리

푸시 알림 서비스는 어느 정도 완성됐고, 파일 저장 서비스도 얼개는 짜여진 것 같다. 또 넣어야 할 다른 구성요소들이 무엇이 있는지 잠시 고민 해볼 시간인 듯 하다.
지금 당장 떠오르는 것으로는, 유저의 인증 토큰을 관리할 서비스가 필요하고, 또 현재 접속 중인 유저의 세션을 관리해줄 세션 DB도 필요하다. 이것에 대해서 기초적인 아이디어는 있으나 Best Practices를 찾아 보는 게 좋을 것 같다. 그리고 채팅 서비스도 꾸려야 한다. 채팅 서비스는 가볍고 빨라야 하며 Core DB에 대한 접근도 어느 정도 가지고 있어야 한다. 다만 Core DB와 단단히 결합해버리면 용량은 작지만 엄청난 숫자의 데이터(예를 들면, 너무나 당연하게도 채팅 메세지)가 계속 들어가게 되고, Graph DB에 저런 데이터들을 저장하는 것은 바람직하지 않다. 그래서 Loose Coupling을 하되 Core DB에 접근 시에 지연 시간이 짧아지도록 해야 한다.

이렇게 중구난방으로 떠올리면 빠뜨리는 게 있을 수 있기 때문에 현재 거의 완성되어 있는 UI를 참고하여 유저의 흐름을 따라 가며 빠진 곳이 어디 있는지를 살펴보자.

  • 피드 화면 흐름
    첫 화면에서 피드가 보인다. 포스트들은 모두 Core DB에 있으니 문제가 없다. 댓글은 Node로서 좋아요는 Edge로서 CoreDB에 모두 포함되기 때문에 상관 없다. 재생목록 기능 또한 Core DB에서 충분히 처리가 가능할 것으로 보인다. 이런 물고 물리는 Relation들을 자연스럽게 표상할 수 있어서 Graph DB가 참 마음에 든다.
    필터링 기능도 막강한 Graph DB의 쿼리문으로 해결 가능할 것 같다. 검색 기능은 OpenSearch 서비스를 Neptune에 갖다 붙여주면 Neptune에서 데이터 변경이 있을 때마다 인덱싱이 자동으로 된다. 이 부분은 다이어그램에 지금 추가하도록 해야겠다. 따로 OpenSearch 서비스에 접근하는 게 아니라 Neptune 내에서 Gremlin 혹은 SPARQL로 바로 쿼리가 가능하기 때문에 참 편하게 되어 있다.
    추후에 추천 포스트 기능도 넣기 위해서 Machine Learning 기능을 넣는 것도 고려해봐야겠으나 지금 단계에서 생각할 문제는 아닌 것 같다.
    신고 기능이 있는데 각종 신고들을 수집할 DB도 필요하다. 신고 기능을 이야기 하니까 그것뿐만이 아니라 각종 불만사항들을 접수해서 분석할 수 있도록 하는 플로우도 필요하겠다.

  • 포스트 작성 흐름
    포스트 작성 흐름에서는 대체적으로 로직이 클라이언트 쪽에 있기는 하다. 서버 쪽과 통신하는 걸 생각해보면 태그나 유저 검색, 그리고 포스트를 최종적으로 업로드 할 때 정도일 것 같다. 검색은 위에서 고려되어있고 업로드도 CoreDB와 S3 버킷 흐름에 모두 연결되어 있어서 여기서는 추가적으로 고려할 부분이 없는 것 같다.

  • 마이 페이지 흐름
    마이페이지라는 것이 하나의 탭에 퉁쳐져 있기는 해도 여기서 생각보다 많은 기능들이 들어간다. 첫 번째로 이용권이나 다른 상품을 구매할 수 있는 페이지가 있는데 이 부분은 앱의 버전과 상관없이 모든 고객들이 같은 경험을 해야 하기 때문에 Server-side Rendering을 할 것이다. 이를 위해 Rendering Server를 하나 두어야 한다. 그리고 써드파티든 앱스토어든 플레이스토어든 고객이 결제를 하게 되면 해당 기록을 우리도 보관하고 있어야 하는데 한 번 기록되면 수정할 일이 없는 데이터이기 때문에 DynamoDB를 활용하면 좋을 것 같다. 그리고 알림 화면도 있는데 푸시 알림은 한 번 보내지고 나면 소비되어 없어지기 때문에 푸시 알림을 보냄과 동시에 해당 알림을 보관하는 DB도 필요하다. 이 알림 또한 한 번 기록되면 수정할 일이 전혀 없기 때문에 DynamoDB를 활용하면 좋을 것 같다. 알림 읽음 표시라든지 그런 부분에 대해서는 각론에서 고민해볼 문제인 것 같다.

정리를 해보면 내가 지금 고민해야 할 부분은 다음과 같다.

  • 신고 수집 DB, 결제 트랜잭션 DB, 알림 DB 및 분석 플로우 구성
  • SSR(Server-side Rendering) 서버 구성 및 CI/CD 구성
  • 채팅 서비스 구현
  • 유저 세션 관리 서비스

채팅 서비스 구현

매도 먼저 맞는 게 낫다고 가장 어려워 보이는 채팅 서비스 구상을 먼저 해볼 것이다.

채팅 서버 관련 글들을 쭈욱 살펴봤는데 일단 Sending Bird라는 만들어진 서비스가 있었다. 그런데 너무 비싸고 상용 써드 파티 서비스에 의존하고 싶지 않아서 일단 내가 직접 구성하는 걸로 결정했다. 그리고 발견한 한 빛과 소금 같은 블로그 포스트를 발견했다. WhatsApp에서 아키텍처를 공개한 적이 있나 보더라. 관련 포스트가 있어 이걸 일단 읽어보았다. 처음에는 ejabberd 서버라길래 무슨 이름이 오타가 났나 싶었는데 오픈 소스 챗 서버 솔루션이었다. 크으… 오픈소스뽕이 차오른다. 오픈소스 플랫폼을 하나 알았다고 해서 모든 게 해결되는 것은 아니니 차근 차근 공부를 해보기로 했다. 다음과 같은 사이트들을 참고했다.

이런 글들을 읽어보고 특히 오픈소스 XMPP 서버 중 하나인 ejabberd에 대해서 알아보았다. XMPP 서버 중 가장 많이 쓰인다고 하더라(거의 61퍼센트의 비중을 차지한다.). 차후에 여러가지 다른 기능들을 인스턴트 메시징에서 지원할 가능성이 높기 때문에 확장성도 좋고 여러 기능을 지원하지만 복잡하고, 생소한 언어를 쓰고, 문서가 불친절해서 한땀한땀 알아나가야 한다. 부족한 인력으로 이것까지 익혀 개발하기엔 너무 시간이 촉박할 것 같아 ejabberd를 굳이 쓰지 않고 간단한 채팅 서버를 직접 꾸리고 WebSocket 프로토콜을 이용해 실시간 채팅서비스를 구현해보려 한다. 일단 우리 챗 서버의 요구 사양을 고려해보자.

인스턴트 메시징 서비스 요구 사양

  • 1대1 메시징 서비스를 지원해야 한다.
  • 유저 세션이 있어 온라인/오프라인 유저를 구별할 수 있어야 한다.
  • 오프라인 유저에게 보낸 메세지는 Push Notification으로 알림이 전송되어야 한다.
  • 음성 메세지 전송이 가능해야 한다.
  • 이미지 전송이 가능해야 한다.
  • 메세지 읽음/읽지않음 표시를 지원해야 한다.
  • 성능이 좋아야 한다(?)

유저 세션 이슈

클라이언트(유저)의 플로우를 따라 가면서 어떻게 구현할지 생각해보자. 일단 유저는 로그인을 한다. 이 부분은 AWS Cognito에서 해결해줄 것이다. 그리고 로그인을 하게 되면 먼저 현재 이 유저가 온라인이라는 걸 알려주기 위해 세션 DB에 유저 세션을 생성해야 한다. 이는 API Gateway에서 WebSocket 프로토콜을 사용하여 연결 이벤트 발생 시 세션 DB에 유저 세션이 기록되고 연결 끊김 이벤트 발생 시 세션 DB에서 해당 유저의 세션을 삭제하도록 하면 될 것 같다.
이 유저 세션의 목적을 생각해보면 타 유저에게 내 접속 상황을 알려주는 목적도 있지만 서버 입장에서 이 유저가 대화방에 접속해있지 않을 때 푸시 메세지를 보내고, 그렇지 않을 때에는 푸시를 하지 않아야 한다. 푸시 메세지를 언제나 보내고 클라이언트 단에서 해당 대화방 내에 있을 때에는 무시하도록 해도 될 것 같지만 푸시 메시징 비용을 생각한다면 전자가 훨씬 경제적일 듯 하다. 이를 어떻게 구현할까? 내가 생각해본 바는 다음과 같다. 일단 한 디바이스에서 한 유저가 활성화할 수 있는 챗방은 하나라는 점을 생각해볼 때 글로벌 유저 세션이 하나인 만큼 유저의 대화방 세션 또한 하나밖에 안 되므로 둘을 통합해도 될 것 같다. 그렇다면 다른 시나리오는 없는가? 추후에 여러 플랫폼을 지원할 가능성이 높다. Flutter를 사용하면, 조금 과장을 보태서 코드 하나로 현존하는 모든 대중적인 플랫폼에 대응하여 배포할 수 있다. 그렇다면 글로벌 유저 세션이 하나가 아니게 될 가능성도 높고 디바이스마다 활성화된 대화방이 다를 가능성도 높다.
빈번하게 쓰기/읽기가 일어날 것을 생각하면 in-memory DB를 쓰는 게 좋아보인다. Elastic Cache에서 Redis를 쓴다고 해보자. Redis Key-value 형태의 NoSQL DB이다. 빈번한 쓰기/읽기가 이루어지는 세션 데이터는 key로 바로 접근가능해야지 따로 쿼리가 들어가 탐색 시간이 O(1)을 넘어가서는 안 된다. 글로벌 세션 데이터는 uid를 키로 하고 value에는 set 형태의 오브젝트를 사용해 디바이스 고유값을 넣어주면 바로바로 확인이 가능할 것 같다. 대화방 세션 데이터는 어떨까? 한 유저가 여러 대화방에 참여할 수 있기 때문에 uid를 key로 사용해서는 안 된다. 그렇다면 구분자로 이어진 uid, 대화방 id는 어떨까? 만약에 글로벌 세션 데이터에서 uid, 디바이스 고유값으로 이루어진 key를 사용한다면 매번 탐색이 이루어져야 한다. 왜냐하면 클라이언트 단에서 타 유저가 무슨 디바이스를 쓰는지를 알 길이 없기 때문에 쿼리를 사용해야 하기 때문이다. 그러나 대화방을 생각해보면 채팅 서버에서는 이 유저가 ‘이 대화방’에 있는지 없는지를 체크하는 것이기 때문에 key를 바로 사용할 수 있다. 이렇게 하면 대화방 세션도 고성능으로 읽고 쓸 수 있을 것 같다.
또 한 가지 문제가 있는데, 지금은 세션 데이터가 서버에 있는지 여부로 유저의 접속 상태를 판단한다. 다시 말해 유저는 접속할 때 세션 데이터를 쓰고, 종료하면서 세션 데이터를 삭제할 것이다. WebSocket의 connect, disconnect 이벤트를 이용하면 되지만 문제는 disconnect 이벤트는 전달 여부가 100퍼센트 보장되지 않는다. 연결을 우아하게 끊는 경우가 대부분이겠지만 예기치 않은 네트워크 연결 끊김이 발생한다든가 하는 이유로 disconnect 이벤트가 전달되지 않는 경우도 대비해야 하기 때문에 서버 단에서 ping-pong 메서드를 구현해서 연결 체크를 하려 한다.
플로우는 다음과 같을 것이다. connect 라우팅 실행 -> lambda 트리거 및 세션 작성 -> 클라이언트로 ping 보냄 -> 클라이언트에서 pong 받음 -> …
만약 정해진 timeout 내에 pong이 안 오거나 disconnect 라우팅이 실행되면 lambda에서는 세션을 삭제한다.
세션 중복에 대해서 생각도 해야 한다. 글로벌 세션 같은 경우에는 각 디바이스 별 세션을 마련해줄 필요가 있다. 차후에 등록된 디바이스 개수 제한을 한다든지 해야 할 수도 있기 때문이다. 그러나 대화방 세션은 어떤 디바이스에서든지 최소 하나만 접속해있는지만 알면 되기 때문에 lambda에서 대화방 세션을 작성할 때에는 데이터가 있는지 여부를 먼저 살핀 뒤에 작성하고, 이미 다른 디바이스에서 세션이 돌고 있으면 굳이 작성하지 않는다. 다만 ping-pong은 계속 오간다.

메시징 DB

DB 스키마를 구상하기 위해서는 우리 인스턴트 메시징이 사용하는 데이터가 어떤 것이 있는지 먼저 나열해 봐야 한다.

  • 대화 메시지
  • 대화방
  • 대화방 참여자

유저가 우리 인스턴트 챗 서비스에서 할 수 있는 것은 메세지 보내고 받는 것이 끝이다. 여러가지 메세지 포맷을 지원하지만 이것은 대화 메세지의 컨텐츠를 JSON 타입으로 전송하면 될 문제 같다. 그리고 메세지 읽음 여부는 페이스북의 방법을 사용해볼까 한다. 메세지 데이터 자체에 읽음여부를 저장하는 것이 아니라 특정 유저가 방에 접속하면 읽었다는 일종의 마커 메세지를 써서 표시하는 것이다. 이렇게 하면 안 읽은 메세지 개수를 세기도 편하다. 가장 최근의 마커 메세지 이후에 쓰여진 메세지의 개수를 세기만 하면 끝이기 때문이다. 그 외에 페이스북 메신저에서는 메세지에 좋아요 표시를 한다든가 하는 것도 이러한 마커 메세지를 이용한다고 한다.
자, 어쨌거나 저쨌거나 우리가 필요로 하는 데이터가 어떤 것인지를 알았으니 디테일한 스키마를 짜보도록 하자. 먼저 대화 메세지 테이블을 살펴 보자.
Message

  • (pk)id UUID - 메시지 자체의 id이다.
  • user Character - 메시지 작성자의 id이다.
  • content JSON - 메시지의 내용이다.
  • created_at Temporal - 메시지의 작성 시간이다.
  • type Character - 메시지의 타입이다. (메시지, 읽음 마커, 기타 마커 등등…)
  • (fk)chat_room UUID - 이 메세지가 속해있는 대화방의 id이다.

Chatroom

  • (pk)id UUID - 대화방의 id이다.
  • title Character - 대화방의 제목이다.
  • type Character - 대화방의 타입이다.
  • created_at Temporal - 대화방의 생성 시간이다.

ChatroomUser

  • (pk)id UUID - 이 데이터의 id이다.
  • user Character - 유저의 id이다.
  • (fk)chat_room UUID - 해당 유저가 참여하고 있는 대화방의 id이다.
  • created_at Temporal - 유저가 대화방에 처음 참여한 시간이다.

이렇게 매우 간단한 스키마로 인스턴트 메시징 DB를 구현할 수 있을 것 같다. 나중에 구현하면서 혹시 더 필요한 게 생기면 추가할 수도 있겠지만 지금은 이 정도면 충분한 것 같다.

마무리

어떻게 하다 보니 인스턴트 메시징 이슈에 대해 구상하다가 유저 세션 문제까지 엮어서 처리하게 된 것 같다. 원래 이 문서를 11월 16일에 작성을 끝마치려고 했는데 ejabberd의 엄청나게 불친절한 문서를 탐독하고 인스턴트 메시징 서비스에서 필요한 요소들에 대해서 고민하다 보니 이렇게 시간이 길어지고 말았다 ㅜㅜ. 역시 우리 프로젝트가 작은 프로젝트가 아니었다. 이 인스턴트 메시징만 해도 작지 않은 프로젝트 그 자체라 할 수 있을 것 같은데, 어쨌든 이제 남은 것은 신고 수집 DB, 결제 트랜잭션 DB, 알림 DB 및 분석 플로우 구성과, SSR(Server-side Rendering) 서버 구성 및 CI/CD 구성이다. 전자는 고려할 것이 좀 많아 보여서 후자 먼저 해봐야겠다. 몇 안 되는 이슈로 긴 여정이었다…

댓글남기기