이번 글에서는 채팅 서비스에서의 몽고DB 데이터 모델링한 과정에 대해 알아보겠습니다.
그래서 RDBM 안쓰고 왜 MongoDB 쓰는데?
NoSQL/MongoDB 이름만 들어본 분들을 위해 특징 및 사용목적을 간단하게만 짚고 넘어가는 게 좋을 것 같습니다.
인터넷 서비스가 점점 많은 곳에 보급되고 데이터를 전송하는 device의 수가 증가하게 되면서, 전통적인 RDB로는 취급하기 어려운 방대한 양의 비정형 데이터들을 적재하고 처리하기 위해 새로운 data storage가 필요했습니다. 즉, RDBMS의 한계를 극복하고자 함이 NoSQL의 등장 이유입니다. 여기서 말하는 RDB의 한계란 1) 비정형 데이터를 처리하기가 힘들며, 2) 확장성이 떨어지고, 3) 상대적으로 속도가 느린 것을 들 수 있습니다.
데이터가 감당할 수 없이 쏟아진다면...?
여기저기서 데이터들이 마구 쏟아져 들어오는 상황에서 1) 데이터 포맷은 크게 신경 쓰지 않고 일단 때려 담을 수 있으며, DB 서버 용량이 넘칠 것 같으면 2) 쉽게 새로운 서버를 옆에 팍팍 붙여서 확장할 수 있고, 3) 엄청난 빈도로 읽기/쓰기 연산을 해도 performance에 큰 문제가 없어야 하는 경우에 MongoDB를 사용합니다.
분당 수만 건씩 쌓이는 사용자 로그를 RDB에 실시간으로 적재할 수 있을까요? 사용자가 클릭하고, 주문하고, 결제하는 복잡한 과정을 모두 RDB 스키마에 맞추어 실시간으로 정형화하여 유실 없이 처리할 수 있을까요? 아마 매우 어려울 것입니다.
위에서는 NoSQL의 등장 이유 그리고 MongoDB를 사용하는 이유에 대해 알아봤습니다.
그렇다면 FitTrip 프로젝트에서는 MongoDB를 사용하는게 적절할까요?
1. 저장될 데이터 유형들로는 이모지, 파일, 채팅들이 존재합니다. 파일 같은 경우에는 채팅에 List 타입으로 담겨서 저장이 됩니다. 또한 채팅들 유형들로는 서버 채팅, 포럼 채팅, DM 채팅들이 있습니다. 다양한 데이터 포맷들이 존재하는 상황입니다. 그래서 데이터 포맷은 크게 신경 쓰지 않고 저장할 수 있는 MongoDB가 적합하다고 생각했습니다.
2. 채팅은 클라와 서버가 웹소켓으로 연결이 된 후 엄청난 빈도로 쓰기 연산이 수행됩니다. 쓰기 연산뿐만 아니라 채팅 목록들을 읽는 읽기 연산도 빈번히 발생합니다. 엄청난 빈도로 읽기/쓰기 연산을 해도 performance에 큰 문제가 없는 MongoDB가 적합하다고 생각했습니다.
위 2가지 이유로 MongoDB가 적합하다고 생각합니다.
MongoDB 스키마 설계를 위한 고려사항
The best approach to design is to represent the data the way your application sees it.
"당신의 어플리케이션이 바라보는 관점에서 설계하는 것이 가장 좋은 접근(설계) 방법이다."
-Kristina Chodorow, (2019) MongoDB: the Definitive Guide: O'Reily
RDB에서의 스키마 설계는 (application이나 query와는 무관하게) entity를 정의하고 정규화를 통해 중복을 없애는 비교적 정형화된 프로세스를 따릅니다. 이에 비해, MongoDB는 application 관점에서 수행되는 query의 성능을 고려하여 유연한 설계를 필요로 합니다. 본격적인 설계에 앞서 우리가 고려해야 할 사항을 살펴보겠습니다.
Access Pattern
Application이 데이터에 접근하는 패턴을 파악하여 collection을 정의할 수 있습니다.
우리는 이 과정에서 아래와 같은 질문들을 던질 수 있습니다.
- Application이 어떤 query들을 수행하는가?
- 어떤 query를 가장 빈번하게 수행하는가?
- Application은 주로 DB에서 데이터를 읽는가? 아니면 쓰는가?
위의 질문들을 통해 우리는 아래와 같은 과정을 통해 collection을 정의합니다.
- 함께 조회되는 경우가 빈번한 데이터들은 같은 collection에 담아, query의 횟수를 줄일 수 있습니다.
- 주로 읽기만 하는 데이터와 자주 업데이트하는 데이터는 별개의 collection에 담습니다.
Relation
MongoDB에서 스키마 디자인을 할 때 고려할 수 있는 디자인 방식은 Embedding or Referencing 방식입니다.
즉 collection 간에 reference 할지, embed 할지 결정해야 한다는 소리 입니다.
여기서, reference란 collection 간 참조할 수 있도록 id를 저장하는 것이고, embed는 관계된 document를 통째로 저장하는 것입니다.
reference 방식
참조형 같은 경우 각각의 Document들이 가지고 있는 특별한 id 인 object id와 $lookup을 이용해 데이터를 참조할 수 있습니다. 이는 관계형 데이터베이스에서의 JOIN과 흡사하게 동작하며, 이러한 구조는 데이터를 효율적이고 관리하기 쉽게 나누면서도 데이터 간의 관계를 유지할 수 있습니다.
장점
- Document를 쪼개어 더욱 작은 단위의 Document를 가질 수 있으며, 이를 통해 16mb 크기 제한을 피할 수 있습니다.
- 필요하지 않은 정보에 대한 접근을 줄일 수 있습니다.
- Document를 참조하여 데이터를 가져오기에 Embedding 방식보다 데이터의 중복을 줄일 수 있습니다.
한계점
- 하나의 Document 안에 다수의 데이터가 참조형이라면 다수의 쿼리, $lookup, populate 가 필요합니다.
embed 방식
장점
- 하나의 쿼리문으로 필요한 모든 데이터를 가져올 수 있습니다.
- $lookup과 같은 Join 동작을 수행하지 않고 데이터를 가져올 수 있습니다.
한계점
- Document가 커지면 오버헤드 또한 함께 커진다 (문서의 크기를 제한하여 쿼리 성능도 챙길 수 있습니다.)
- Document는 16mb의 크기 제한을 갖고 있어서 Embedding방식을 계속 사용한다면 언젠간 한계에 도달할 수도 있습니다.
우리는 어떤 기준을 가지고 두 방식을 고민해야 할까요? 이는 application의 성격에 따라 달라집니다.
예를 들어, 채팅에 파일 정보가 함께 보인다면 두 정보는 대부분 함께 조회된다고 봐야 할 것입니다.
따라서 query 한 번에 모두 가져올 수 있도록 embed 하는 것이 바람직한 선택입니다.
반면에, 파일 정보가 끊임없이 변경되는 상황이라면 어떨까요?
Embed 방식의 경우, 해당 파일의 모든 채팅 document를 찾아서 일일이 embed 된 파일 정보를 수정해야 합니다.
반면, reference 방식의 경우 별도로 관리되는 파일 collection에서 하나의 document만 찾아 수정하면 됩니다.
이렇게 잦은 수정이 예상되는 경우, reference 방식이 더 바람직하다고 볼 수 있습니다.
Reference는 데이터를 정규화하고, embed는 데이터를 비정규화합니다.
일반적으로 최대한 정규화하여 중복을 제거하는 것이 바람직하다고 여겨지는 RDM와 달리, MongoDB는 적절한 수준의 비정규화가 필요한 경우가 많습니다. NoSQL의 경우 RDB처럼 복잡한 Join 연산이 불가능하다는 것을 염두에 두어야 합니다. 만약 가능한 수준까지 정규화하여 entity별 collection으로 모두 쪼개어 놓았더니, join 연산을 통해 application에서 필요한 복잡한 데이터로 재구성하는 것이 어려울 수도 있습니다.
일반적으로 reference(정규화)는 쓰기를 빠르게 하고, embed(비정규화)는 읽기를 빠르게 합니다.
분당 수천~수만 번씩 access 되는 웹페이지에서 이용되는 정보라면 가능한 하나의 document에 모아두는 것이 필요하겠죠. 하지만, 이런 설계에 정답은 없습니다. 사용자들이 인내심을 갖고 이용하는 사내 시스템이라면 읽기 performance에 크게 신경 쓰지 않고 정규화하는 것도 나쁘지 않아 보입니다.
Cardinality
Cardinality는 관계를 "얼마나" 맺을 것인가에 대한 설명입니다.
다음 세 가지 방법으로 관계를 작성할 수 있습니다.
- One to Few 하나당 적은 수
- One to Many 하나당 여럿
- One to Squillions 하나 당 무지 많은 수
각각 방법은 장단점을 갖고 있어서 상황에 맞는 방법을 활용해야 하는데 One-to-N에서 N이 어느 정도 규모/농도 되는지 잘 판단해야 합니다.
One-to-Few
// person
{
name: "Edward Kim",
hometown: "Jeju",
addresses: [
{ street: 'Samdoil-Dong', city: 'Jeju', cc: 'KOR' },
{ street: 'Albert Rd', city: 'South Melbourne', cc: 'AUS' }
]
}
하나 당 적은 수의 관계가 필요하다면 위 같은 방법을 쓸 수 있습니다.
쿼리 한 번에 모든 정보를 가질 수 있다는 장점이 있지만, 내포된 엔티티만 독자적으로 불러올 수 없다는 단점도 있습니다.
One-to-Many
// 편의상 ObjectID는 2-byte로 작성, 실제는 12-byte
// parts
{
_id: ObjectID('AAAA'),
partno: '123-aff-456',
name: 'Awesometel 100Ghz CPU',
qty: 102,
cost: 1.21,
price: 3.99
}
// products
{
name: 'Weird Computer WC-3020',
manufacturer: 'Haruair Eng.',
catalog_number: 1234,
parts: [
ObjectID('AAAA'),
ObjectID('DEFO'),
ObjectID('EJFW')
]
}
부모가 되는 문서에 배열로 자식 문서의 ObjectID를 저장하는 방식으로 구현합니다.
이 경우에는 DB 레벨이 아닌 애플리케이션 레벨 join으로 두 문서를 연결해 사용해야 합니다.
// category_number를 기준으로 product를 찾음
> product = db.products.findOne({catalog_number: 1234});
// product의 parts 배열에 담긴 모든 parts를 찾음
> product_parts = db.parts.find({_id: { $in : product.parts } } ).toArray() ;
각각의 문서를 독자적으로 다룰 수 있어 쉽게 추가, 갱신 및 삭제가 가능한 장점이 있지만 여러 번 호출해야 하는 단점이 있습니다. join이 애플리케이션 레벨에서 처리되기 때문에 N-to-N도 쉽게 구현할 수 있습니다.
One-to-Squillions
이벤트 로그와 같이 엄청나게 많은 데이터가 필요한 경우, 단일 문서의 크기는 16MB를 넘지 못하는 제한이 있어서 앞서와 같은 방식으로 접근할 수 없습니다. 그래서 부모 참조(parent-referencing) 방식을 활용해야 합니다.
// host
{
_id : ObjectID('AAAB'),
name : 'goofy.example.com',
ipaddr : '127.66.66.66'
}
// logmsg
{
time : ISODate("2015-09-02T09:10:09.032Z"),
message : 'cpu is on fire!',
host: ObjectID('AAAB') // Host 문서를 참조
}
다음과 같이 Join 합니다.
// 부모 host 문서를 검색
> host = db.hosts.findOne({ipaddr : '127.66.66.66'}); // 유일한 index로 가정
// 최근 5000개의 로그를 부모 host의 ObjectID를 이용해 검색
> last_5k_msg = db.logmsg.find({host: host._id}).sort({time : -1}).limit(5000).toArray()
MongoDB 스키마 설계 FitTrip Example
채팅과 댓글
One-to-Many 관계에서 Embedding 구조를 사용하면, 채팅에 달린 댓글 데이터가 몇백 개가 넘어갔을 때 Document의 16mb 제한을 초과해 버립니다. 더불어 Reference 구조를 사용한다 하더라도 ObjectID 가 몇 천 개 쌓인다면 똑같이 용량이 초과될 것입니다.
따라서 One-to-Squillions 관계에서 Reference 구조를 사용한다면 채팅의 ObjectID로 해당 채팅에 달린 댓글들을 가져올 수 있고 용량 제한을 피하면서도 채팅과 댓글의 관계를 유지할 수 있습니다.
채팅과 이모지
채팅에 많은 이모지가 달릴 수 있기 때문에 채팅과 이모지도 One-to-Squillions 관계와 Reference 구조를 사용합니다.
채팅과 파일
하나의 채팅에는 최대 10개의 파일이 존재하도록 제한을 했습니다.
채팅에 파일이 달릴 경우 채팅 조회 시 파일도 조회해야 합니다.
채팅 같은 경우 수시로 조회를 하므로 파일을 채팅에 embeded 해서 채팅 조회 시 파일도 같이 조회하는 방식으로 결정했습니다. 그래서 One-to-Few 관계와 Embedding 구조로 채팅과 파일 간의 관계를 설계했습니다.
그동안 MySQL만 사용해 봤는데 처음 몽고 DB를 사용하니 처음에는 굉장히 막막했습니다.
MySQL 같은 경우 테이블을 만들고 컬럼을 만들고 테이블 간의 관계를 맺고 했지만 몽고DB는 어떻게 스키마를 설계할지 조인을 할 수 없으면 어떻게 데이터를 가져올지 등 많은 고민을 했던 시간이었던 거 같습니다. 사용하다 보니 몽고DB도 굉장한 매력이 있는 데이터베이스인 거 같습니다. 복잡하면 하나의 Collection에 데이터를 다 때려 박는(?) 점을 보면서 서비스에 맞는 DB라면 사용하기 굉장히 편할 것 같다는 생각도 드네요
참고
https://gamguma.dev/post/2022/04/mongodb_schema_design
https://meetup.nhncloud.com/posts/276
https://edykim.com/ko/post/summary-of-six-rules-for-designing-a-mongodb-schema/
'프로젝트 > FitTrip' 카테고리의 다른 글
트러블 슈팅 - 웹소켓 연결 요청 JWT 검증 문제 (0) | 2024.06.30 |
---|---|
개발 기록 - MongoDB 트랜잭션 도입 (0) | 2024.06.29 |
트러블 슈팅 - 채팅 서비스 scalue out 문제 (0) | 2024.06.27 |
개발 기록 - 개발 언어 및 기반 기술 조사 (0) | 2024.06.27 |
개발 기록 - 프로젝트 공통 작업 설정 (0) | 2024.06.27 |