💽 Server
MongoDB 는 어떻게 설계할까?
몽고와 관련된 시리즈 중 첫번째 글로, 디자인 가이드 목적으로 씁니다.
RDB에서 테이블 설계만 해보신 분들이 Mongo에서 콜렉션 설계는 어떻게 하는 건지, 참고하셨으면 좋겠습니다.
RDB 와 무엇이 다를까?
스키마 디자인에 있어서 관계형 데이터베이스와는 정말 많이 다릅니다.
예를 들어 다음과 같은 테이블이 있다고 합시다.
- Users
ID | name | cell | city | location |
1 | paul | 39273646 | London | 45.123, 47.232 |
2 | jack | 472966210 | Tokyo | null |
- Professions
ID | user_id | profession |
9 | 1 | banker |
10 | 2 | programmer |
- Cars
ID | user_id | model | year |
430 | 1 | Bentley | 1973 |
237 | 2 | Porsche | 2003 |
유저 관련 데이터를 관계형 데이터베이스에 적용한다고 하면, 위처럼 ‘정규화’하여 저장하게 됩니다.
user_id라는 Foreign key로 Join할 수 있고, 덕분에 duplicate key를 피할 수 있어요.
→ 이제 몽고의 입장에서 보자.
크게 3가지를 기억해야 합니다.
- 공식적인 프로세스는 없다. (No formal process)
- 알고리즘은 없다. (No algorithms)
- 규칙은 없다. (No rules)
몽고디비 스키마를 디자인한다고 했을 때, 중요하게 여겨야 할 것은
당신의 어플리케이션에서 잘 동작할 것인가?
입니다.
당장은 이 말이 크게 와닿지 않죠. 천천히 읽다보면 어느덧 이해가 될 겁니다.
만약 서로 다른 어플리케이션에서 정확히 같은 데이터를 사용한다고 할 때 완전히 다른 스키마를 통해 다른 방식으로 사용될 수 있기 때문입니다.
구체적으로 다음 3가지를 고려해야 합니다.
- 데이터를 저장한다. (Store the data)
- 좋은 쿼리 성능을 제공한다. (Provide good query performance)
- 합리적인 하드웨어 수를 요구한다. (Require reasonable amount of hardware)
즉, 데이터 중복이 어떻고 하면서 정규화 관련 생각을 할 시간에 내 서비스에서 어떻게 사용하는 것이 좋을지를 떠올리는 겁니다. 관점이 아예 달라집니다.
이제 실제로 유저 모델을 어떻게 디자인하는지 봅시다.
{
"first_name": "Paul",
"surname": "Miller",
"cell": "447557505611",
"city": "London",
"location": [45.123, 47.232],
"profession": ["banking", "finance", "trader"],
"cars": [
{
"model": "Bentley",
"year": 1973
},
{
"model": "Rolls Royce",
"year": 1965
}
]
}
데이터를 여러 콜렉션이나 도큐멘트로 나누는 대신,
몽고 디비의 ‘도큐멘트 기반 디자인’이라는 강점을 살려 임베드 데이터의 형식으로써 어레이로 만든 뒤 유저 오브젝트에 담았습니다.
이제 하나의 간단한 쿼리로 모든 데이터를 어플리케이션으로 가져올 수 있습니다.
RDB와 비교하여 테이블은 콜렉션, 로우는 도큐멘트라고 표현한다.
Document oriented database로, field:value 형식이며, BSON 구조를 주로 활용한다. (샤딩에 유리)
Embedding vs. Referencing
몽고에서 스키마 디자인은 사실상 2가지로 귀결됩니다.
데이터를 임베드 하거나, 다른 데이터를 참조하는 것입니다.
이 때 참조에는 $lookup 연산자가 쓰입니다. RDB에서 JOIN과 비슷한 개념입니다.
위처럼 lookup 연산자는 지양해야한다는 이야기가 있습니다.
그러나 최근 몽고 디비 디자인 가이드들을 보면 반드시 그래야 한다는 주장도 있으니, 어플리케이션 서버 성능에 맞춰서 적절히 사용하시면 될 것 같아요.
저는 lookup 을 쓸 일이 생긴다면 그냥 RDB 를 쓰면 되지 않을까 라는 의견입니다.
임베딩의 장점!
- 단 하나(a single)의 쿼리로 관련된 모든 정보를 회수할 수 있습니다.
- 어플리케이션 레벨에서 코드를 통한 조인 연산 또는 $lookup을 활용한 조인 구현을 피할 수 있습니다.
- 단 하나의 최소 연산(atomic operation)으로 관련 데이터를 업데이트할 수 있습니다.
- 디폴트로 한 도큐멘트에 대한 CRUD 연산은 ACID를 준수합니다.
- 그러나 여러 연산(multiple operations)에 대해 트랜잭션이 필요하다면 트랜잭션 연산자를 사용할 수 있습니다.
- 이러한 트랜잭션은 4.0부터 출발했지만 안티-패턴으로 규정하고 사용하기를 지양합니다.
임베딩의 단점!
- 한번에 많은 데이터를 담고 있다는 것은 동시에 관련 없는 데이터 필드를 사용한다면 오버헤드를 유발합니다.
- 물론, 쿼리를 쪼개거나 도큐멘트 사이즈를 제한함으로써 쿼리 성능을 높일 수 있습니다.
- 몽고디비는 도큐멘트 사이즈를 16MB로 제한하고 있습니다.
레퍼렌싱은 도큐멘트의 오브젝트 아이디(유니크)를 사용하여 도큐멘트 간의 연결을 통해 마치 SQL의 JOIN처럼 데이터를 나눔으로써 더 효율적인 쿼리를 돕는 방법입니다. $lookup 연산자를 통해 제공되는 방법입니다.
참조의 장점!
- 데이터를 스플릿함으로써 더 작은 도큐멘트를 가질 수 있습니다.
- 도큐멘트 크기 제한에 도달할 가능성이 적어집니다.
- 모든 쿼리가 필요하지 않은 정보를 자주 들추지 않게 됩니다.
- 중복 데이터를 줄일 수 있습니다.
- 그러나 데이터 중복을 피하는 것이 반드시 좋은 것은 아닙니다. 중요한 것은 결과에 더 적합한 스키마라면 중복이 있어도 괜찮다는 겁니다.
참조의 단점!
- 레퍼렌스 도큐멘트로 되어있는 모든 데이터를 회수하기 위해서는 최소한 2개의 쿼리가 필요하거나 $lookup 연산자를 활용해야만 합니다.
몽고의 연관관계
- One-to-One
{
"_id": "ObjectId('AAA')",
"name": "Joe Karlsson",
"company": "MongoDB",
"twitter": "@JoeKarlsson1",
"twitch": "joe_karlsson",
"tiktok": "joekarlsson",
"website": "joekarlsson.com"
}
만약 유저는 반드시 하나의 이름만 가질 수 있다는 정책이 있다면,
이러한 특성을 통해 원-투-원 관계에 적합하다는 사실을 파악할 수 있게 됩니다.
예를 들어 직원은 한 회사에만 속할 수 있을 때에도 사용할 수 있겠죠.
도큐멘트안에 임베디드 하는 key-value 쌍의 구조를 선호하는 관계입니다.
- One-to-Few
{
"_id": "ObjectId('AAA')",
"name": "Joe Karlsson",
"company": "MongoDB",
"twitter": "@JoeKarlsson1",
"twitch": "joe_karlsson",
"tiktok": "joekarlsson",
"website": "joekarlsson.com",
"addresses": [
{ "street": "123 Sesame St", "city": "Anytown", "cc": "USA" },
{ "street": "123 Avenue Q", "city": "New York", "cc": "USA" }
]
}
유저 정보 중에 주소와 같은 정보는 여러개가 입력될 수도 있습니다.
이럴 때는 어레이를 활용해서 임베드 도큐멘트형태를 유지합니다.
이러한 관계를 One-to-Few라고 하며, 가능한 이렇게 임베드 형태를 유지하는 것이 좋습니다.
임베드를 최우선으로 고려해야한다.
- One-to-Many
// product
{
"name": "left-handed smoke shifter",
"manufacturer": "Acme Corp",
"catalog_number": "1234",
"parts": ["ObjectID('AAAA')", "ObjectID('BBBB')", "ObjectID('CCCC')"]
}
// parts
{
"_id" : "ObjectID('AAAA')",
"partno" : "123-aff-456",
"name" : "#4 grommet",
"qty": "94",
"cost": "0.94",
"price":" 3.99"
}
이커머스와 같이 큰 규모의 웹사이트를 구현한다면 원-투-매니 방식을 활용하는 것이 좋을 겁니다.
수백 수천개의 서브 파트로 이루어진 하나의 스키마를 저장할 때 생각해볼 만한 것은, 그 모든 데이터가 한 쿼리에서 관리될 필요는 없을겁니다.
이제 한 파트를 나타내는 오브젝트 아이디를 가지고 같은 콜렉션이나 분리된 콜렉션으로 저장할 수 있습니다.
임베드 하지 말아야 할 분명한 이유가 생길 때 고려할 수 있는 방법입니다. lookup을 쓴다고 해서 무조건 피해야하는 건 아닙니다. 필요하다면 써야합니다.
- One-to-Squillions
// hosts
{
"_id": ObjectID("AAAB"),
"name": "goofy.example.com",
"ipaddr": "127.66.66.66"
}
// log message
{
"time": ISODate("2014-03-28T09:42:41.382Z"),
"message": "cpu is on fire!",
"host": ObjectID("AAAB")
}
현실의 수 많은 데이터를 담기 위해서는 오브젝트 아이디를 활용한다 하더라도 16MB 제한을 지키기 힘듭니다.
예를 들어 로그 시스템을 개발한다고 했을 때, One-to-Many를 사용한다면 호스트에서 각 로그의 오브젝트 아이디를 어레이에 담아야 할거에요.
그러나 로그는 수백만을 넘어갈 정도로 커지기도 하죠.
이때는 어레이 크기 만으로도 16MB를 넘어가기 때문에 관리할 수 없게 되고, 이를 해결하기 위해 생각을 전환했다고 합니다.
호스트가 아닌 로그가 직접 본인의 호스트 정보를 가져가는 겁니다.
즉, 오브젝트 아이디를 갖는 주체를 전환시킨 겁니다.
배열은 절대로 제한 없이 커져선 안됩니다. 배열 요소가 정말 많이 필요할 때는 임베드 할 수 없고 이 방법을 써야 합니다.
- Many-to-Many
여기서는 투두 리스트 기능을 하는 어플리케이션 개발을 한다고 가정합시다.
요구사항으로는, 한 유저가 여러 테스크를 가질 수 있고 한 테스크도 여러 유저에게 할당될 수 있어요.
// users
{
"_id": ObjectID("AAF1"),
"name": "Kate Monster",
"tasks": [ObjectID("ADF9"), ObjectID("AE02"), ObjectID("AE73")]
}
// tasks
{
"_id": ObjectID("ADF9"),
"description": "Write blog post about MongoDB schema design",
"due_date": ISODate("2014-04-01"),
"owners": [ObjectID("AAF1"), ObjectID("BB3G")]
}
서로가 각각의 오브젝트 아이디를 배열에 담아 참조하고 있는 구조입니다.
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
Recap
몽고의 도큐먼트에는 16MB 의 제한이 있다는 걸 반드시 기억해야 합니다.
때문에 이를 넘어가야 할 때는 참조 방식으로 스플릿 하는 걸 고려해야 해요.
그러나 디폴트는 임베드 방식이 좋습니다.
NoSQL 자체가 말 그대로 찍어내는 개념으로 동작하기 때문에 실제로 임베드 방식이 더 성능이 좋기 때문이에요.
마지막으로 객체 관점에서 생각하고 지금 만드는 서비스의 성능 관점에서 고려해야 합니다.
정규화하는 프로세스를 거칠 필요가 없어요!
이후에는 몽고의 특징과 함께 스프링부트에서 사용한 경험에 대해 떠들어볼게요 😄