Document Database vs RDBMS: 차이점과 스키마 설계 방법

Document Database 를 사용하기로 결정 했다면, 대부분의 개발자들은 “내 프로젝트의 스키마를 어떻게 설계해야 할까?”라는 고민을 했을거라고 생각한다.
하지만 이 질문에 대한 답은 상황에 따라 다르기 때문에 명확한 답이 없다.
Document Database vs Relational Database: 스키마 설계 접근방식
Document Database의 스키마를 설계할 때, 대부분의 개발자들은 Relational Database에서와 마찬가지로 깔끔하게 데이터 구조를 설계하고 싶을 것이다.
이 마음가짐은 이상한게 아니다. 하지만, 스키마를 Relational Database와 동일하게 설계한다면 Document Database의 장점을 대부분 이용할 수 없게 된다.
그렇다면, Document Database의 스키마는 어떻게 설계해야 할까?
예제를 통해 Relational Database와 Document Database를 비교해가며 스키마를 어떻게 설계해야 할지를 알아보자.
Relational Database
Relational Database 사용자는 스키마와 상관없이 데이터를 모델링한다. “내가 가지고 있는 데이터는 무엇인가?”라는 질문에 답을 해 나가며 데이터의 구조를 정의하는 것이다. 이어서 3차 정규화를 통해 데이터를 분할하여 중복되지 않도록 개선해 나간다.

이 예제를 보면, user_id를 기준으로 Profession, Cars 테이블과 조인할 수 있는 구조로 되어있다. 이 구조를 동일하게 Document Database에서 어떻게 설계할 수 있는지 알아보자.
Document Database
Document Database는 Relational Database의 스키마와 많이 다르다. 공식적인 절차도 없고, 알고리즘도 없고, 규칙도 없다!
하지만, Document Database의 스키마를 설계할 때, 가장 중요한 기준이 있다. 바로, 내 애플리케이션에 잘 작동하는가? 이다.
스키마를 설계할 때 고려해야하는 세부사항은 다음과 같다.
- 데이터 저장
- 우수한 쿼리 성능
- 합리적인 하드웨어 크기
Relational Database에서 정의한 스키마를 Document 데이터구조로 설계하면 다음과 같은 구조를 가진다.
{
"name": "Jisung",
"profession": ["banking", "finance", "trader"],
"cars": [
{
"model": "Bentley",
},
{
"model": "Rolls Royce",
}
]
}
데이터를 별도의 컬렉션이나 문서로 분할하는 대신 문서 기반 디자인을 활용하여 데이터를 User 객체 내의 배열과 객체에 포함시키는 것을 볼 수 있다.
이렇게 하면 간단한 하나의 쿼리로 애플리케이션에 대한 모든 데이터를 가져올 수 있다. 이처럼 객체 내의 배열에 객체를 포함시켜서 데이터를 표현하는 형태를 임베딩이라고 한다.
Document Database가 데이터를 구성하는 방법은 임베딩과 레퍼런싱 두 가지 뿐이다.
Embedding vs Referencing: 데이터의 분할 방법
임베딩 (Embedding)
장점
- 단일 쿼리로 모든 정보를 검색할 수 있다.
- 코드상에서 조인하는 방식을 피할 수 있다.
주의할 점
- 여러 작업을 일괄적으로 하는 경우에 트랜잭션 API를 사용할 수 있지만, 지나치게 의존하는 것은 안티패턴이다.
제한사항
- 큰 문서는 대부분의 필드가 관련이 없을 경우 더 많은 오버헤드를 의미한다. 각 쿼리에서 전송하는 문서의 크기를 제한함으로써 쿼리 성능을 향상시킬 수 있다.
- Document Database의 문서 크기 제한을 넘을 수 있기 때문에, 하나의 문서에 너무 많은 데이터를 포함하면 이 제한에 도달할 수 있으므로 조심해야 한다.
레퍼런싱 (Referencing)
스키마 설계의 또 다른 옵션은 문서의 고유 객체 ID를 사용하여 다른 문서를 참조하고 이를 연결하는 것이다. MongoDB의 경우에는 lookup 이라는 기능을 제공하고 있고, Firestore에서는 제공하고있지 않다.
따라서, Firestore를 사용하는 경우에는 Relational Database와 마찬가지로 FK를 활용해서 검색하는 것이 더 효과적이다.
레퍼런싱은 SQL 쿼리에서의 JOIN 연산자와 유사하게 작동한다. 이는 데이터를 분할하여 더 효율적이고 확장 가능한 쿼리를 가능하게 하며, 데이터 간의 관계를 유지한다.
장점
- 데이터를 분할하면 더 작은 사이즈의 Document를 만들 수 있다.
- 한 문서당 크기 제한을 넘을 확률이 줄어든다.
- 자주 엑세스하지 않는 정보를 불필요하게 조회하지 않아도 된다.
- 데이터의 중복을 줄일 수 있다.
주의할 점
- 데이터의 중복을 줄이기 위해 더 나은 스키마 형태를 피할 필요는 없다!
제한사항
- 참조 문서의 모든 데이터를 검색하려면 최소 두 개의 쿼리또는 조인 함수가 필요합니다. (Firestore는 2개의 쿼리만로만 가능하다.)
Document Database: 스키마의 관계 유형
위에서는 스키마를 설계할 때, 데이터를 분리하는 방법에 대해서 설명했다. 이번 주제에서는 스키마와 스키마 사이의 관계에 따라서 어떤 형태로 데이터를 분리해야 하는지를 구체적인 사례를 통해 알아보자.
Case 1) One-to-One
위에서 정의한 사용자(Users) 문서를 살펴보자. 내가 만드는 애플리케이션에서는, 한 사용자가 하나의 이름만 가질 수 있다고 가정해보자. 이는 일대일 관계의 대표적인 예로 볼 수 있다.
모든 일대일 데이터는 아래의 데이터 구조와 같이, 키-값 쌍으로 설계할 수 있다.
{
_id: 1,
name: "Jisung",
}
Case 2) One-to-Few
이제 사용자의 작은 데이터 시퀀스를 다룬다고 가정해보겠다. 예를 들어, 주어진 사용자와 관련된 여러 주소를 저장해야 할 수 있다.
하지만, 내가 만드는 애플리케이션에서 사용자가 두 개 이상의 다른 주소를 가질 가능성은 적다고 가정해보자.
이러한 관계에서는 이를 일대소수(One-to-Few) 관계로 정의할 수 있다.
{
"id": 1,
"name": "Jisung",
"addresses": [
{ "street": "1 Street", "city": "My City" },
{ "street": "2 Street", "city": "My City" }
]
}
이처럼 일반적으로 데이터를 문서 내에 임베딩하는 것이 기본 동작이며, 필요할 때만 데이터를 빼내어 참조한다.
하지만, 데이터에 개별적으로 접근해야 하거나, 너무 크거나, 거의 필요하지 않거나, 다른 이유가 있을 때 참조방식을 사용한다.
Rule 1. One-to-Few 관계에서는 임베딩을 선호한다.
Case 3) One-to-Many
전자상거래 웹사이트의 제품 페이지를 구축한다고 가정해보자. 우리는 각 제품에 대한 정보를 보여줄 수 있는 스키마를 설계해야 한다.
우리 시스템에서는 각 제품을 수리하기 위한 많은 구성 부품에 대한 정보를 저장하고 있다. 이러한 모든 구성 부품 데이터를 저장하면서, 제품 페이지를 성능 좋게 만들 수 있는 스키마를 어떻게 설계할 수 있을까?
각 제품이 여러 부품으로 구성되기 띠문에, 일대다(One-to-Many) 스키마를 고려할 수 있다.
수천 개의 하위 부품을 저장할 수 있는 스키마를 가진 경우, 모든 요청마다 부품에 대한 모든 데이터를 가질 필요는 없지만, 이 관계를 스키마에서 유지하는 것은 중요하다.
따라서 우리의 전자상거래 스토어에서 각 제품에 대한 데이터를 가지고 있는 Products 컬렉션을 두고, 이 부품 데이터를 연결된 상태로 유지하려면, 부품에 대한 정보를 담고 있는 문서의 ID 배열을 구성할 수 있다.
이러한 부품은 동일한 컬렉션 또는 별도의 컬렉션에 저장될 수 있다. 이를 어떻게 구현할 수 있는지 살펴보자.
제품
{
"name": "left-handed smoke shifter",
"parts": [0, 10, 25]
}
구성부품
{ "id": 0, "partno": "100-aff-000" },
{ "id": 10, "partno": "100-aff-010" },
{ "id": 25, "partno": "100-aff-025" }
Rule 2. 객체에 개별적으로 접근이 필요하다면, 임베딩을 피해야 한다.
Rule 3. 가능하면 조인은 피해야 한다. 하지만, 더 나은 스키마 설계를 제공할 수 있다면 피하지 마라.
Case 4) One-to-squillions
잠재적으로 수백만 개 이상의 하위 문서를 가질 수 있는 스키마가 있는 경우에는 어떻게 해야 할까?
서버 로그 애플리케이션을 만들라는 요청을 받았다고 가정해보자. 각 서버는 로깅의 자세한 정도와 서버 로그를 저장하는 기간에 따라 엄청난 양의 데이터를 저장할 수 있다.
데이터베이스에서 제한된 크기 이상의 배열을 다루는 것을 허용하지 않기 때문에, 이러한 상황은 위험하다.
따라서 이 관계를 추적하는 방법을 다시 생각해야 한다.
호스트 문서에서 호스트와 로그 메시지 간의 관계를 추적하는 대신, 각 로그 메시지에 해당 메시지가 연관된 호스트를 저장하도록 하자.
로그에 데이터를 저장하면 무한 배열이 애플리케이션에 영향을 미칠 염려가 없어진다. 이를 어떻게 구현할 수 있는지 살펴보자.
호스트
{
"id": 1,
"name": "zucchini.example.com",
"ipaddr": "127.0.0.1"
}
로그
{
"time": ISODate("2014-03-28T09:42:41.382Z"),
"message": "CPU 과부하",
"host": 1
}
Rule 4: 배열이 무한정으로 커지지 않도록 해야한다. 많은 쪽에 수백 개 이상의 문서가 있으면 임베딩을 피해야 하고, 수천 개 이상의 문서가 있다면 ID참조 배열을 사용하지 말아야 한다. 즉, 크기가 너무 큰 배열은 임베딩 해서는 안된다.
Case 5) Many-to-Many
이 포스트에서 다룰 마지막 스키마 설계 패턴은 다대다 관계(Many-to-Many)이다. 이 패턴은 Relational Database와 Document Database 스키마 설계에서 자주 보게 되는 또 다른 일반적인 패턴이다.
이 패턴을 위해, 할 일 애플리케이션을 만든다고 가정해보자. 우리의 앱에서는 한 사용자가 여러 작업을 가질 수 있고, 한 작업에 여러 사용자가 할당될 수 있다.
사용자와 작업 간의 이러한 관계를 유지하기 위해, 한 사용자가 여러 작업을 참조하고, 한 작업이 여러 사용자를 참조하는 참조가 필요하다. 할 일 애플리케이션에서 이를 어떻게 구현할 수 있는지 살펴보자.
사용자
{
"id": 1,
"name": "Jisung",
"tasks": [10, 20, 30]
}
할 일
{
"id": 10,
"description": "Write blog post",
"due_date": ISODate("2025-01-01"),
"owners": [1, 2]
}
위의 예제에서 각 사용자는 연결된 작업(tasks)의 하위 배열을 가지고 있으며, 각 작업(tasks)은 할 일 애플리케이션에서 각 항목에 대한 소유자(owners)의 하위 배열을 가지고 있다.
요약
보시다시피, 데이터 스키마 설계에는 다양한 방법이 있으며, SQL에서 익숙한 데이터 정규화를 넘어서는 방법으로 데이터를 표현할 수 있다.
문서 내에 데이터를 임베딩하거나 조인
연산자를 사용하여 문서를 참조하여 애플리케이션에 딱 맞는 강력하고 확장 가능하며 효율적인 데이터베이스 쿼리를 만들 수 있다.
마지막으로 Document Database 스키마 설계의 가장 중요한 규칙이다.
규칙 5: Document Database에서는 데이터 모델링이 전적으로 애플리케이션의 데이터 액세스 패턴에 따라 달라진다. 즉, 데이터 구조는 애플리케이션이 데이터를 쿼리하고 업데이트하는 방식에 맞추어야 한다.
각 애플리케이션은 고유한 요구 사항을 가지고 있으므로 스키마 설계는 해당 애플리케이션의 요구 사항을 반영해야 한다. 이 포스트의 예제를 애플리케이션을 위한 출발점으로 사용해보자.
그리고 무엇을 해야 하는지, 그리고 스키마를 어떻게 사용하여 그 목표를 달성할 수 있는지에 대해 고민해보자.
Document Database Referencing: MongoDB vs Firestore
Document Database Referencing – MongoDB
// 스키마 - 사용자
{
"_id": ObjectId("userId1"),
"name": "Kate Monster",
"tasks": [ObjectId("taskId1"), ObjectId("taskId2"), ObjectId("taskId3")]
}
// 스키마 - 작업
{
"_id": ObjectId("taskId1"),
"description": "Write blog post about MongoDB schema design",
"due_date": ISODate("2024-04-01"),
"owners": [ObjectId("userId1"), ObjectId("userId2")]
}
// 특정 사용자가 가진 모든 작업을 가져오기
const userId = "userId1";
const user = await db.collection("Users").findOne({ _id: ObjectId(userId) });
if (user) {
const tasks = await db.collection("Tasks").find({ _id: { $in: user.tasks } }).toArray();
console.log(tasks);
}
// 특정 작업에 할당된 모든 사용자 가져오기
const taskId = "taskId1";
const task = await db.collection("Tasks").findOne({ _id: ObjectId(taskId) });
if (task) {
const users = await db.collection("Users").find({ _id: { $in: task.owners } }).toArray();
console.log(users);
}
Document Database Referencing – Firestore
// 스키마 - 사용자
{
"name": "Kate Monster",
"tasks": ["taskId1", "taskId2", "taskId3"]
}
// 스키마 - 작업
{
"description": "Write blog post about Firestore schema design",
"due_date": "2024-04-01",
"owners": ["userId1", "userId2"]
}
// 특정 사용자가 가진 모든 작업을 가져오기
const userId = "userId1";
const userRef = db.collection("Users").doc(userId);
userRef.get().then((userDoc) => {
if (userDoc.exists) {
const taskIds = userDoc.data().tasks;
const tasksPromises = taskIds.map((taskId) => db.collection("Tasks").doc(taskId).get());
Promise.all(tasksPromises).then((tasks) => {
tasks.forEach((taskDoc) => {
if (taskDoc.exists) {
console.log(taskDoc.data());
}
});
});
}
});
// 특정 작업에 할당된 모든 사용자 가져오기
const taskId = "taskId1";
const taskRef = db.collection("Tasks").doc(taskId);
taskRef.get().then((taskDoc) => {
if (taskDoc.exists) {
const userIds = taskDoc.data().owners;
const usersPromises = userIds.map((userId) => db.collection("Users").doc(userId).get());
Promise.all(usersPromises).then((users) => {
users.forEach((userDoc) => {
if (userDoc.exists) {
console.log(userDoc.data());
}
});
});
}
});