-
MongoDB aggregation: $lookup, $unwind, $project개발/백엔드 2022. 9. 29. 21:04반응형
1. 문제상황
가령 블로그 사이트를 만든다고 생각한다.
셀 수 없이 수많은 요소들이 필요하지만.. 유저, 본문, 댓글 정보로 한정해서 다루어보겠다.
MongoDB와 같은 NoSQL기반 데이터베이스에는 다음과 같이 저장이 될 것이다.
일단은 '유저'라는 하나의 컬렉션 안에 모두 표현하고자 시도했다.
- '유저' 컬렉션 -
[ { "_id": ObjectId("..."), "이름": "최재휘", "별명": "jaytsol", "국가": "대한민국", "이메일": "jaytsol@naver.com", "글": [ { "id": "1", "제목": "JavaScript 입문", "본문내용": "JavaScript는 어쩌구저쩌구.....", "생성일시": "2022-09-08 07:13:25.914Z", "수정일시": "2022-09-08 08:16:96.317Z", "댓글": [ { "id": "1", "이름": "홍길동", "댓글내용": "최악의 강의", "생성일시": "2022-09-09 07:06:43.829Z", "수정일시": "2022-09-10 01:20:06.896Z" }, { "id": "2", "이름": "아무개", "댓글내용": "1111", "생성일시": "2022-09-12 01:07:16.785Z", "수정일시": "2022-09-12 01:20:06.896Z" } ] }, { "id": "2", "제목": "JavaScript 심화", "본문내용": "JavaScript는 불라불라.....", "생성일시": "2022-09-25 16:13:25.914Z", "수정일시": "2022-09-25 16:24:84.068Z", "댓글": [ { "id": "1", "이름": "홍길동", "댓글내용": "최고의 강의", "생성일시": "2022-09-26 02:13:08.056Z", "수정일시": "2022-09-26 02:13:08.056Z" }, { "id": "2", "이름": "아무개", "댓글내용": "2222", "생성일시": "2022-09-27 01:07:16.785Z", "수정일시": "2022-09-27 01:07:16.785Z" } ] } ] }, { "_id": ObjectId("..."), "이름": "홍길동", ... } ]
json 형식에 어긋나는 부분이 있을 수 있다. 그냥 예시로 보면 되겠다..
아주 적은 수의 필드(별명, 국가, 이메일, 본문내용, 댓글내용..), 단 한두개의 유저 정보만을 표현했을 뿐인데 저장할 것이 너무나 많다.
실제 서비스에 위와 같이 하나의 컬렉션에 모조리 저장한다면 난리가 날 것이다.
그래서 이번에는 여러 개의 컬렉션을 만들어 데이터를 따로 저장하여 보기 좋게 만들 것이다.
본 예시에서는 다음과 같이 3개의 컬렉션 : 유저, 본문, 댓글로 나누어 데이터를 저장하기로 한다.
- '유저' 컬렉션 (수정본) -
[ { "_id": ObjectId("..."), "이름": "최재휘", "별명": "jaytsol", "국가": "대한민국", "이메일": "jaytsol@naver.com" }, { "_id": ObjectId("..."), "이름": "홍길동", "별명": "hgd_123", "국가": "대한민국", "이메일": "hgd_123@google.com" }, { ... }, ... ]
- '본문' 컬렉션 -
[ { "id": ObjectId("..."), "유저id": ObjectId("..."), "제목": "JavaScript 입문", "본문내용": "JavaScript는 어쩌구저쩌구.....", "생성일시": "2022-09-08 07:13:25.914Z", "수정일시": "2022-09-08 08:16:96.317Z" }, { "id": ObjectId("..."), "유저id": ObjectId("..."), "제목": "JavaScript 심화", "본문내용": "JavaScript는 불라불라.....", "생성일시": "2022-09-25 16:13:25.914Z", "수정일시": "2022-09-25 16:24:84.068Z" } ]
- '댓글' 컬렉션 -
[ { "id": ObjectId("..."), "유저id": ObjectId("..."), "본문id": ObjectId("..."), "댓글내용": "최악의 강의", "생성일시": "2022-09-09 07:06:43.829Z", "수정일시": "2022-09-10 01:20:06.896Z" }, { "id": ObjectId("..."), "유저id": ObjectId("..."), "본문id": ObjectId("..."), "댓글내용": "1111", "생성일시": "2022-09-12 01:07:16.785Z", "수정일시": "2022-09-12 01:20:06.896Z" }, { "id": ObjectId("..."), "유저id": ObjectId("..."), "본문id": ObjectId("..."), "댓글내용": "최고의 강의", "생성일시": "2022-09-26 02:13:08.056Z", "수정일시": "2022-09-26 02:13:08.056Z" }, { "id": ObjectId("..."), "유저id": ObjectId("..."), "본문id": ObjectId("..."), "댓글내용": "2222", "생성일시": "2022-09-27 01:07:16.785Z", "수정일시": "2022-09-27 01:07:16.785Z" } ]
보기에 훨씬 깔끔해졌다.
2. 문제인식
다만 이렇게 컬렉션을 나누면 문제가 발생한다.
예를 들어 댓글창을 구현할 때, 댓글 왼쪽에 댓글 작성자의 이름을 표현하고싶다.
컬렉션을 분리하기 전에는 쉽게 가능했다.
해당 댓글 데이터의 상위 레벨인 유저의 이름을 가져오기만 하면 되니까.
분리하고 나서는? 댓글의 내용과 작성자의 이름을 한 컬렉션 안에서 동시에 가져오는 것이 불가능하다.
데이터를 저장할 때 댓글 컬렉션에 작성자 이름을 같이 넣으면 되지 않느냐? 할 수 있는데,
이는 매우 비효율적인 방법이다. 그 이유는
1. 보내야하는 필드가 많아지면 컴퓨팅 자원이 낭비된다.
2. 댓글 컬렉션 뿐만 아니라 본문에도 작성자 이름을 표시해야하고,
그 이외 수많은 경우마다 같은 데이터를 반복적으로 보내야한다.3. 2의 이유로 인해 수많은 컬렉션에 작성자 이름이 저장되어있으면,
작성자가 프로필을 업데이트하여 이름이 바뀐다면 또다시 수많은 컬렉션을 모조리 수정해야한다.
3. 해결안
때문에 MongoDB에서는 여러 컬렉션의 필드를 동시에 사용할 수 있게 하는 $lookup이라는 Join 기능을 제공한다.
사실 Join은 SQL 진영에서 더 강력하게 쓰이고 MongoDB에서는 보조적으로만 쓰인다고 알고 있는데,
일단 내가 MongoDB를 쓰니까.. 이것만 알아보자
4. 사용법
- 전체코드 -
const matchPipelines = []; matchPipelines.push( { $lookup: { from: collectionReference(ModelName.유저), localField: '유저id', foreignField: '_id', as: '유저' } }, { $unwind: '$유저' }, { $project: { 유저id: 1, 본문id: 1, 댓글내용: '$댓글내용', 유저이름: '$유저.이름' } } ); const aggregation = await this.댓글Model.aggregate(pipeline).exec();
위 코드의 결과만 생각하면 일단,
aggregation =
[ { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "최악의 강의", "유저이름": "홍길동" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "1111", "유저이름": "아무개" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "최고의 강의", "유저이름": "홍길동" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "2222", "유저이름": "아무개" } ]
와 같이 나온다.
댓글 컬렉션 하나만으로는 가져올 수 없던 유저의 이름을 '유저' 컬렉션으로부터 가져온 것이다.
이제 $lookup, $unwind, $project 가 어떻게 작동한 것인지 알아보자.
먼저 $lookup부터.
{ $lookup: { from: collectionReference(ModelName.유저), localField: '유저id', foreignField: '_id', as: '유저' } }
먼저 생각해두어야할 것은 위 - 전체코드 - 의 마지막 줄에 있는
const aggregation = await this.댓글Model.aggregate(pipeline).exec();
여기에서부터, 야구로 비유하면 댓글 컬렉션이 '홈팀'이라고 생각하고,
$lookup pipeline 안에 있는
from: collectionReference(ModelName.유저)
에서부터 유저 컬렉션이 '원정팀'이라고 생각하면 된다.
때문에 localField는 댓글 컬렉션 안에 있는 유저id를 확인하고
이 유저id를 이용해서 foreignField (유저 컬렉션)을 검색해서 해당하는 유저를 찾아낸다.
as의 기능은, 찾아낸 원정팀의 유저에 해당하는 모든 정보를 유저 컬렉션으로부터 뽑아내서 객체 형태로,
그리고 '유저'라는 이름으로 저장해서 마치 변수처럼 이용할 수 있게 한다.
다음으로 $unwind.
{ $unwind: '$유저', }
이는 별거없다.
$lookup으로부터 뽑아낸 $유저 변수는 array '[]' 로 감싸져있기에
이 array를 한꺼풀 벗겨내는 거라고 생각하면 된다.
다음, $project.
{ $project: { 유저id: 1, 본문id: 1, 댓글내용: '$댓글내용', 유저이름: '$유저.이름' } }
위의 aggregation 결과를 잘 관찰했다면 알 수 있겠지만,
걸러낸 데이터 중 필요한 필드만 뽑아내서 깔끔한 데이터로 정제하는 기능을 한다.
여기서 '$댓글내용'은 홈팀(댓글 컬렉션)에서 댓글내용 필드를 가져온다.
그리고 $lookup 설명란에서 '유저'라는 이름으로 변수처럼 이용할 수 있다고 했는데,
바로 여기서 $유저.이름, 즉 원정팀, 유저 컬렉션에 존재하는 유저의 이름을 가져오는 것이다.
자~~ 그리하여 다음과 같은 결과가 나오는 것이다.
[ { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "최악의 강의", "유저이름": "홍길동" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "1111", "유저이름": "아무개" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "최고의 강의", "유저이름": "홍길동" }, { "유저id": Object("..."), "본문id": Object("..."), "댓글내용": "2222", "유저이름": "아무개" } ]
aggregation 처음 배울때는 정말 머리가 터져버릴뻔 했으나
어느정도 감이 잡히고 나니까 정말 강력한 툴이라는 것을 알 수 있었다.
그럼 안녕~
반응형'개발 > 백엔드' 카테고리의 다른 글
MongoDB 데이터를 백업하거나 덮어씌우는 방법 (dump, restore) (0) 2024.01.24 JavaScript에서의 객체 순서 보장법 (1) 2022.09.26 Param, Query, Body (0) 2022.08.17 Kafka - Acks 옵션 (0) 2022.07.26 Kafka produce시 key-value값에 대해 (0) 2022.07.25