본문 바로가기

카테고리 없음

[DevSession] '채널톡 채팅 서비스 10년, 메시지 테이블 설계 변천사' 요약

다음 글은 세션을 요약하며 정리한 글로, 원본 영상은 다음 링크에서 보실 수 있습니다.

https://youtu.be/rDvPVhfQY1A

 

 

0. 개요

발표 주제 : 채팅 서비스 10년, 메시지 테이블 설계 변천사

발표자 : 비누

 

1. 선정 계기

- 사내 DDB 이해도를 높이고자 하는 니즈

- 사내 메시지 테이블이 어떤 이유로 왜 지금과 같은 모습으로 자리잡았는지 이해

 

사실 발표 세션을 현장에서 들었었는데 꽤 많은 내용이 압축되어 빠른 속도로 전달되어 꼭 한번 정리해보고 싶었다.

 

2.  강의 이해에 필요한 사전 지식

더보기

2-1) PK, SK

 

DDB에는 두 가지 키 종류가 있습니다.

- PK(Partition Key) : 아이템(row)가 저장될 논리적 파티션을 정하는 키 - 필수

- SK(Sort Key) : 하나의 파티션에서 정렬 순서를 정하는 키

 

즉, 파티션 키는 '어떤 파티션에 저장되는가?'를 정렬 키는 '그 파티션에서 내 위치는 어디인가?'를 의미한다

파티션 키가 영화 관람관(C관)이라면 정렬키는 영화 좌석(K3) 같은 느낌이다.

 

예를 들어, 간단한 유저 테이블을 기준으로 하나의 PK=USER#42면 이런 느낌으로 하나의 파티션 내에서 B-Tree 형태로 각 item이 Sort Key 기준으로 구성되어 있음

 

2-2) Consistency : 강한 일관성 vs 최종적 일관성

- 강한 일관성 : 언제 어떤 노드를 읽어도 같은 읽기 결과를 반환(쓰기 작업에 대한 변경이 모든 노드에 일시 전파)

- 최종적 일관성 : 노드별 쓰기 지연으로 인해 최신 결과 반환 보장은 못하지만 시간이 흐르며 같은 상태 보장

 

2-3) DDB 내부 요청 흐름

- Request Router : 요청을 받아 파티션 대상 알아내고 라우팅 주도

- MetaData Service : 각 테이블의 해쉬 구간이 어떤 파티션에 매핑되는지 저장되어있는 해쉬 매핑 테이블

- Storage Node : 실제 데이터가 B-Tree 형태로 저장되는 파티션 단위

step1: 라우팅 파티션 식별  - Router 가 PK를 MetaDataService에 문의하여 라우팅 파티션 식별

step2 : 요청 전달  - 요청을 해당 파티션으로 보내고 응답 받음

 

2-4) GSI vs LSI

DDB는 기존 메인 테이블과 다양한 엑세스 패턴을 지원하기 위해 인덱스를 두가지 종류로 지원합니다.

- GSI(Global Secondary Index) : 파티션 키 자체를 바꿔 전혀 다른 접근 패턴을 여는 별도 테이블

- LSI(Local Secondary Index) :  같은 파티션 내에서 정렬 기준만 바꾸는 재정렬 인덱스

 

각 인덱스의 특징은 다음과 같습니다.

  LSI GSI
PK Main Table과 반드시 동일 아무 속성이나
SK Main Table과 다른 속성으로 자유롭게 지정
생성 시점 테이블 생성 시점에만 -> 추가 불가 언제든 추가- 삭제 가능
개수 제한 테이블 당 5개 테이블 당20개
저장 위치 Main Table 과 같은 파티션 안 별도 구조로 분리 저장
쓰기 반영 기본 쓰기와 함께 동기적 비동기 전파
크기 제한 파티션 키당 아이템 컬렉션 10GB 제한 제한 없음

 

특히, 여기서 쓰기 전파에 대한 부분을 이해할 필요가 있습니다. -> 희소 인덱스 이해에 중요함

 

 

3.  핵심 요약

 

3.1.1 초기 설계와 문제점 발생

1 ) 초기 설계는 Chat 단위 기반 메시지 저장이 다였음

- 2016년 PostgreSQL -> 10년후 45억건 아이템을 보유한 DDB 대형 테이블

- (PK : chatId, SK : id)로 메시지 아이템 저장

- UserChat 단위의 메시지를 최신순으로 정렬해 페이지네이션으로 반환 => chatId PK 기반으로 깔끔하게 핸들링 가능

 

2) 스레드 기능이 도입되어 설계 변경 필요성이 생김

-  스레드 : 하나의 메시지를 별도의 부모로 가지는 메시지 흐름

-  대화방 단위에서 보여지지 않지만 개념적으로 포함되어서 반환되어야 함 -> 인덱스가 필요하다

=> LSI 채택

 

3) LSI 선택 이유와 한계점

- 장점 : 강한 일관성

- 단점 : 테이블이 선언될 때 인덱스가 정의되어야 함

->  새로운 인덱스를 추가하거나 삭제가 어려웠음

-> 새로운 인덱스 추가하려면 새로운 테이블로 마이그레이션 필요(8개월 소요)

=> 어? 그럼 테이블을 생성할 때 앞으로의 변경 내용을 어떻게 다 알지?

 

 

 

 


3.1.2 LSI -> GSI(강한 일관성을 포기하고 확장성을 더 챙기는 형태)

 

1) DDB schema는 접근 패턴의 반영이다.

- 누가 읽는가? -> chat/person

- 어떤 경계로 읽는가? => partition

- 얼마나 최선이어야 하는가?  -> consistency

 

2) 채널톡 메시지에는 다양한 유형과 읽기 패턴이 존재한다.

- 매니저 : 고객과 상담을 하는 주체 -> 채널톡 입장에서는 고객사의 상담원

ex) 에이블리

- 고객(end user) : 채널톡을 활용해 상담을 신청한 가장 말단 고객 -> 채널톡 입장에서는 고객사의 고객

ex) 채널톡을 이용해 에이블리에 상담을 요청한 고객

 

3) 읽기 패턴이 다양해짐에 따라 GSI를 패턴마다 만듦

 

3-1 ) 내부 대화 : 주체에 따라 읽어와야 하는 메시지 성격이 다름

- 일반 메시지 : 고객과 매니저 모두 노출되는 public message

- 내부 대화 : 매니저끼리만 보이는 private message

-> 내부 대화는 매니저에게는 보이지만 고객에게는 보이지 않음

 

 

3-2) 스레드 기능

- 하나의 부모 메시지를 가지는 자식 메시지 흐름

- 브로드 캐스트 -> 일반 대화 방에서도 보여지고, 스레드에서도 보여져야 함

 

3-3) ALF : AI 상담사의 질문 정리-검색-검증도 메시지의 별도 흐름으로 읽혀야 함

- 하나의 메시지를 분모로 가지는 메시지 흐름으로 정리됨

- 상담사와 고객 모두 조회가 가능해야 함

 

 

-> 결론 : 다섯개의 GSI로 다양한 읽기 집합을 만듦

 

 

 


3.1.3 GSI 설계 상세 - 희소 인덱스 패턴

 

1) 희소 인덱스를 활용한 메시지 읽기 패턴에 따른 GSI 분류

- 희소 인덱스

    - DDB 보조인덱스는 인덱스 PK 속성을 가진 아이템만 표시

    -> 특정 컬럼을 가진 부분집합을 모아놓는 인덱스 패턴

 

 

- 채팅하나를 기준으로 메시지를 접근하는 키 설계는 유지 :  PK {ChatType}-{ChatId}

- 다만 성격에 따라 Message Attribute를 다르게 부여

- Main 메시지는 mainKey attribute를, thread Message는 threadKey attribute를 채움
- 만약 팀 멤버에게 보여야 하며, 고객에게도 보여야 한다면? -> mainKey와 frontKey 둘다 세팅
- 만약 고객에게만 보여야 한다면 -> frontKey만 세팅

 

=> GSI는 PK로 삼는 attribute가 지정되어 있지 않은 item은 GSI에 표기되지 않음

=> 즉, 각 읽기 성격에 맞는 Key Attribute를 세팅함으로써 읽기 패턴에 맞도록 자동으로 GSI 집합들에 반영되도록 합

- 이 메시지를 동시에 보여줄 수 있게 하거나 특정 스트림 메시지에서만 보여줄 수 있게 해줌

 

2) GSI 도입 장점 -> 용도에 맞게 접근 DB를 구분하여 무의미한 Query가 줄음

Before(LSI) : 사용자 정의 페이지를 만족할 때까지 필터링 쿼리 반복

- Front 메시지(고객에게 보여질 메시지)만 조회해야 했을 때 -> 일단 Base Table에 접근해서 Chat 단위로 필터링해 가져옴

- 필터 동작 방식 : Query가 Limit만큼 읽고 필터링 동작함 -> [ 4KB 페이지 가져옴 -> 필터링 -> 결과 반환 반복]

- 즉, Front의 서비스 페이지 단위를 만족하는 메시지가 찰 때까지 계속 반복 쿼리가 발생함

 

After(GSI) : 용도에 맞는 데이터 베이스에서 메시지 읽음

- Write 단계에서 attribute에 FrontKey를 포함해서 Write

- Front Stream 테이블에 해당 메시지가 GSI 에 포함됨

- 고객에게 공개될 메시지? -> Front Stream GSI Query를 통해 이미 '고객에게 공개되어야 할 메시지만' 조회

 

 


3.1.4 강한 일관성 포기와 대안

 

1) 메시지 조회 방식 2가지

방안1) REST API 기반 메시지 리스트 조회

- 실제 작성된 메시지를 페이지 기반으로 GSI에서 쿼리해옴

 

방안2) Socket 기반 실시간 메시지 렌더링

- 클라이언트가 소켓 룸에 조인 후 메시지 이벤트를 통해 실시간으로 수신

- 소켓 이벤트 조인을 놓치더라도 레스트 API로 조회 가능

 

2) GSI 최종적 일관성의 안전성 검증

- 소켓 룸 join -> REST API로 기존 메시지 그리기 -> join 이후에 실시간으로 오는 메시지는 socket 이벤트로 받기

 

 

만약 Socket event를 놓치고 이후에 소켓 룸에 들어간다면? => REST API로 회수

 

[누락 가능한 조건 2가지]

- 1요건 : Socket 이벤트가 Socket Room Join 전에 발생 - Socket emit < room join

- 2요건 : REST API로 회수하기까지 아직 GSI 반영이 되지 않음

=> 아직 관찰되지 않아서 운영상에 충분히 가능하다고 판단

 

 


 

3-2. 인덱스 테이블을 활용한 개인 관점의 Access Pattern 커버링

 

3.2.1. 기존 키(UserChat 단위) 설계로 읽지 못하는 Access Pattern 2가지

나를 멘션한/ 내가 작성한 메시지에 리액션한 메시지 저장된 메시지

 

-> 메시지 item의 읽기 기준이 하나의 대화(UserChat)이 아니다보니 기존 테이블 인덱스를 가지고는 풀수 없는 문제

 

3.2.2.  IndexedMessage Table을 통해 개인 관점의 메시지 관리

- 각 개인을 PK로 잡아 조회의 기준이 되도록 하고, SK로 메시지 식별자(messageId) 만 보유

- 변동 사안은 따로 Event Stream을 받아 애플리케이션 레벨에서 반영해줌(GSI가 하는 역할을 애플리케이션에서)

- 개인 기준 조회가 발생했을 때 IndexedMessage Table에 조회해야할 MessageId List를 조회해온 이후에 실제 Message Table에 BatchGetItems로 읽기 요청

 

ex) Alice가 멘션된 메시지 흐름 모아보기

=> Message 테이블 자체는 개인 기준 메시지 관리에 대한 관심사로부터 자유로울 수 있게 됨

 


 

3-3. SK 설계를 통한 수직 Partitioning

3.3.1 요구사항

- 어떤 대화 흐름으로도 보여지지 않지만 필요에 따라 대화 흐름에 가끔 조회되는 케이스가 있음

- History : 특정 대화의 수정 내역을 조회할 때

- Translation : 자동번역된 대화의 경우, 매번 번역 API를 호출하면 비용문제로 DB에 같이 캐싱해두고 조회할 필요

 

3.3.2 하나의 파티션에 SK 설계만 다르게 하여 item을 넣음

- 관계형 DB라면? : 아예 편집이력 테이블, 번역 테이블을 따로 만들고 join 했을 것

- DDB에서는, 한 파티션에 다 몰아넣돼 SK prefix를 구분하여 구분함 

PK SK 내용
ROOM#42 MESSAGE#000b "잘 지내세요?" (현재 메시지)
ROOM#42 HISTORY#000b#1f3a "잘지내세요?" (수정 전 버전)
ROOM#42 TRANSLATION#en#000b#1f3a "How are you?" (영어 번역)
ROOM#42 TRANSLATION#ja#000b#1f3a "お元気ですか?" (일본어 번역)

 

-> 특정 메시지 X의 편집이력 조회 시, begins_with(SK, "HISTORY#X#")를 통해 조회

-> 특정 메시지에 대한 번역에 대한 조회

begins_with(SK, "TRANSLATION#") → 모든 번역
begins_with(SK, "TRANSLATION#en#") → 영어 번역 전부
begins_with(SK, "TRANSLATION#en#X#") → 메시지 X의 영어 번역

 

-> 즉, 현재 메시지, 수정 이력, 번역 결과를 동일 테이블에 싱글 테이블로 저장하고 접근 패턴에 따라서만 다른 조건문으로 필터링

-> 삭제를 할 때도 UserChat 단위의 같은 PK를 공유하므로 같이 삭제 및 조회가 가능한 구조

 

그런데 여기까지 읽고 한가지 의문이 들었던 것은 그럼 왜 이런 SK설계를 "수직 파티셔닝"으로 명명하느냐? 였습니다.

 

찾아보니 이렇더군요.

수직 파티셔닝은 주로 열 단위로 테이블에 세로선을 그어 자주 쓰는 컬럼들은 A테이블, 가끔쓰는 무거운 컬럼은 B테이블로 분리하는 걸 의미하는데 이러한 SK설계 기반에도 자주 사용되는 메시지와 가끔 조회되는 메시지 성격을 SK prefix로 나눈다는 점이 수직 분할과 개념적으로 겹치기 때문이라고 합니다.

ROOM#42
 ├─ MESSAGE#000b          ← 항상 쓰는 부분
 ├─ HISTORY#000b#1f3a     ← 가끔 쓰는 부분
 ├─ TRANSLATION#en#...    ← 가끔 쓰는 부분
 └─ TRANSLATION#ja#...    ← 가끔 쓰는 부분

 

 


3-4. 결론

- 메시지 접근 패턴에 따라 GSI, Indexed Table, SK 설계로 나눈 접근

   - GSI : 같은 Chat 단위 접근이지만 성격에 따른 필터링 필요

   - Indexed Table : 개인 단위 기준으로 아예 Base table과 다른 접근 및 N:N 관계

   - SK 설계 : 잘 접근되지 않는 영역에 대한 필터링 키 설계 

- 새로운 액세스 패턴이 필요해지면 새로운 구조를 고민하는 습관이 필요함

 


4.  느낀 점

 

DDB 쓰는 이유, 다 이유가 있었다~

솔직히 MySQL, Redis에 익숙해져 있던 입장에서 DDB를 처음 접했을 때의 인식은 좋지 않았습니다. 신경써주어야 하는 제약도 많았고 무엇보다 낙관적락 기반 핸들링이나 인덱스 테이블 부하가 메인 테이블에 영향을 주는 핫 파티션 문제까지 신경써주어야 하는게 한두개가 아니었기 때문이었습니다.

 

다만 세션을 통해 DDB는 가장 비즈니스 드리븐할 수 있는 DB라는 인식을 가지게 되었습니다. 비즈니스 적으로 시스템과의 소통과정에 필요한 접근 패턴을 정의하고 그 패턴이 그대로 GSI와 같은 스키마로 정의되는 것이 가장 강력한 힘이라 생각했습니다.

 

특히 요구사항이 변경됨에 따라 기존 테이블과 분리된 별도 테이블로 join을 많이 하지 않고 단일쿼리 패턴과 애플리케이션 변동을 최소화할 수 있게끔 새로운 인덱스 설계가 가능한 것이 매력적인 것 같습니다.

 

돈을 주고 관리비용을 사는 것의 큰 장점

 

무엇보다 채널톡이 DDB를 선택할 수 밖에 없던 이유는 트래픽의 예측 불가능성에 있다고 생각했습니다. 채널톡은 B2B2C 서비스로 고객사의 고객 트래픽을 감당해야할 의무도 가집니다. 그런데 고객사가 언제 어떤 이벤트를 할지, 언제 고객사의 상담 유입이 스파이크를 가질지 예측하기가 쉽지 않은 상황입니다. 특정 이벤트를 기점으로 상담이 많이 잡힐 수 있고, 대용량 푸쉬로 인한 유입이 많아질 수도 있습니다. 

 

이러한 상황에서 직접 사내에서 관리 시스템을 구축하는 것보다 몇십억건의 메시지를 안정적으로 관리하면서, 예측가능한 지연시간을 보장하는 NoSQL DB는 비즈니스 특성상 당연히 매력적으로 느껴질 수 밖에 없었던 흐름이 아닐까 생각했습니다.

 

 

비누에게 궁금해진 두가지

- GSI 지연으로 인한 영향이 없음을 어떻게 실험했는지, 그리고 지금은 어떻게 모니터링하고 있는지

- Indexed Table에 Base Table 변동 사안을 어떻게 애플리케이션이 스트림으로 받아 전파를 하고 있는지, 쓰기/읽기 제한에 걸리면?