스키마 모델링이란?
MongoDB는 스키마리스(Schema-less) 데이터베이스로 잘 알려져 있지만, 데이터 모델링은 여전히 중요하다.
애플리케이션 요구사항과 데이터베이스 성능 간의 균형을 맞추는 것이 핵심 과제이다.
이번 포스팅에서는 스키마 설계를 통해 성능을 최적화하면서도 확장 가능한 구조를 만드는 방법에 대해 다룬다.
Embedding vs Referencing
MongoDB 스키마 모델링에서 가장 먼저 마주하는 문제는 임베딩(Embedding)과 레퍼런싱(Referencing)이다.
MongoDB의 기본 철학은 역정규화이지만, RDB를 주로 사용해왔던 입장에서는 낯선 방법이다. 아래 장단점을 통해 간단히 알아보자.
Embedding (중첩, 내장)
- 장점
- 데이터가 한 문서 안에 있어 **조회 속도(read)**가 빠르다.
- 조인이 필요 없으므로 응답 시간이 짧다.
- 단점
- 중복된 데이터가 발생하기 쉽다.
- 중복된 데이터 변경 시 여러 문서를 수정해야 하므로 쓰기 성능(write)이 저하된다.
Referencing (참조)
- 장점
- 데이터 중복이 없으므로 저장 공간을 효율적으로 사용할 수 있다.
- 변경 작업이 적어 쓰기 성능이 안정적이다.
- 단점
- 조인을 수행해야 하므로 조회 성능이 느려질 수 있다.
MongoDB 3.2부터 레퍼런싱 조인 지원
MongoDB는 3.2 버전 이후 $lookup 연산자를 통해 레퍼런싱 방식으로도 조인을 지원한다.
앞서 말했듯이 MongoDB의 철학은 임베딩을 기본으로 한다는 것이다.
그러나 아래와 같은 경우에는 레퍼런싱을 고려해야 한다.
- 데이터 중복을 피해야 할 때
- Document 크기가 MongoDB 한계(16MB)를 초과할 때
- 데이터 간의 관계가 복잡해질 때
반대로 임베딩을 허용할 때는 다음을 고려하면 좋다.
- 데이터 중복을 얼마나 허용할지?
- 중복된 데이터를 갱신해야 할 경우 얼마나 자주 변경이 일어나는지?
다양한 시나리오와 사례 분석
회원이 많은 OLTP 카페 서비스 스키마 모델링
시나리오 개요
회원이 많은 OLTP 성격의 카페 서비스에서는 CRUD 작업이 빈번하게 발생한다.
카페 데이터와 회원 데이터를 모델링하는 방식에 따라 성능과 유지보수 효율성이 크게 달라질 수 있다.
우선, 정말 간단하게 카페 컬렉션에 회원 데이터를 임베딩하여 접근해 보자
[
{
_id: 1,
name: "IT",
description: "IT 자랑",
createdAt: "2023-01-01",
lastArticleAt: "2024-12-04",
level: 5,
members: [
{ id: 1, name: "김길동" },
{ id: 2, name: "홍길동" },
...
]
},
...
]
카페 컬렉션이 있고 있고 카페 회원을 카페 도큐먼트 내장으로 포함시켰다.
Embedding 철학을 지켜 설계했지만, 다음과 같은 문제가 발생한다
- 회원 수가 많아지면 members 배열이 커져 도큐먼트 크기 제한(16MB)에 도달할 위험이 있다.
- 카페에 가입된 특정 회원 정보를 수정하려면 대규모 배열에서 데이터를 찾아야 하므로 쓰기 성능이 저하된다.
- 한 회원이 여러 카페에 가입할 수 있으므로 회원 데이터 중복이 발생하여 관리가 어려워진다.
결론적으로, 카페 당 회원 수가 많거나 회원 데이터가 자주 수정되는 경우에는 적합하지 않은 구조로 볼 수 있다.
문제가 있었으니 이번에는 반대로 카페 컬렉션에 회원 데이터를 임베딩하는 방법으로 접근해보자
[
{
id: 1,
name: "김길동",
joined_cafes: [
{ id: 1000, name: "중고나라", lastArticleAt: "2024-12-04", ... },
{ id: 2000, name: "A카페", lastArticleAt: "2024-12-04", ... },
...
]
},
{
id: 2,
name: "홍길동",
joined_cafes: [
{ id: 1000, name: "중고나라", lastArticleAt: "2024-12-04", ... },
...
]
},
...
]
회원 데이터와 가입한 카페 데이터를 한 번의 쿼리로 조회가 가능해졌고, 자주 접근하는 데이터는 빠르게 처리할 수 있게되었다.
그러나, 단점이 또 발생한다.
- 카페 데이터를 업데이트할 때 성능 문제 발생
- 특정 카페의 마지막 게시글 날짜를 변경하려면 모든 회원 문서를 수정해야 함.
- 회원 수가 많을수록 선형적으로 성능이 저하됨.
- 카페 데이터 중복으로 인한 유지보수 부담 증가
따라서, 이 방법도 자주 변경되는 데이터가 카페 정보에 포함된다면 적합하지 않은 구조이다.
임베딩은 모두 문제가 있었다.
이제 임베딩 방식이 아닌 카페 컬렉션과 회원 컬렉션 분리 카페와 회원 데이터를 분리하고, 관계는 레퍼런싱 방식으로 표현하기로 하자.
// 회원 컬렉션
[
{
id: 1,
name: "홍길동",
joined_cafes: [1000, 2000]
},
{
id: 2,
name: "성준혁",
joined_cafes: [1000]
},
...
]
// 카페 컬렉션
[
{ id: 1000, name: "중고나라", lastArticleAt: "2023-01-01" },
{ id: 2000, name: "카페", lastArticleAt: "2023-01-02" },
...
]
이것만으로 해결이 될까?
데이터 중복이 없어 저장 공간을 효율적으로 사용하게 되었고, 카페 데이터가 변경될 때 카페만 수정하면 되므로 수정 범위가 줄어든다.
그런데 조회 시 조인(Aggregate $lookup)이 필요하고, 대량의 회원 데이터와 조인이 일어나면 성능 저하가 발생할 수 있다.
그렇다면 결국 방법이 없을까?
생각을 바꿔서
자주 변경되지 않는 데이터는 임베딩하고, 변경이 잦은 데이터는 레퍼런싱으로 관리하면 어떨까?
이런 방법은 Extended Reference Pattern이라고 한다.
[
{
id: 1,
name: "김",
joinedCafes: [
{ id: 1000, name: "중고나라", createdAt: "2023-01-01" },
{ id: 2000, name: "카페", createdAt: "2023-01-02" }
]
},
{
id: 2,
name: "박",
joinedCafes: [
{ id: 1000, name: "중고나라", createdAt: "2023-01-01" },
...
]
}
]
마지막 게시글 날짜는 카페 컬렉션에서 관리하고, 멤버가 가진 카페는 위 처럼 관리시킨다.
- 조회 빈도가 높은 데이터를 멤버 컬렉션에 포함시켜 빠른 접근이 가능
- 자주 변경되는 값은 여전히 카페 컬렉션에서 참조하므로 데이터 일관성을 유지
여전히 원본 값이 변경되면 관련 도큐먼트를 모두 수정해야하는 단점이 남아있지만,
위 데이터로만 봤을 때 생성일은 불변이고, 이름은 자주 변경되지 않기에 적합하다고 볼 수 있다.