전 편에서는 간단한 Repository 함수들을 테스트했었다. 이제 실제 db로직을 도입하여 테스트할 시간이다.
먼저 문서 목록이 어떻게 들어오는지 생각해보자.
const documentsData = [
{
id:1,
title:"title!",
documents:[...],
},
{
id:1,
title:"title!",
documents:[...],
}
]
위와같이 documents
필드에 재귀적으로 문서들이 담겨온다. 이는 문서 데이터의 구조가 트리를 띄고있어서 그렇다.
그렇다면 트리 데이터 구조를 어떻게 nosql인 mongodb에 저장하면 될까?
전에 한번 살펴보긴했다
포스팅 당시에는 자식참조를 택했으나, 실제 코드 작성시는 부모참조부터 하나씩 살펴보았다.
처음엔 부모참조로 데이터를 저장했다.
{ "_id": 1, "name": "Root", "parentId": null }
{ "_id": 2, "name": "Child 1", "parentId": 1 }
{ "_id": 3, "name": "Child 2", "parentId": 1 }
구현이 간단해서 쉽게 할 수 있었다. 기존에 있던 백엔드(부트캠프에서 지원해주셨던)도 문서 생성시 parent
를 파라미터로 넘겨줘야해서 이 방법이 맞다고 여겼다.
그런데 쿼리를 진행하니 문제점이 생겼다.
문서 리스트를 받아오려면 어쩔 수 없이 재귀쿼리를 해야하는 것이 아닌가?
본인은 db조작 능력이 미천하여 재귀쿼리같은 고급기술을 사용할 수 없었다.
아 이거 안되겠다 싶어 자식참조로 한 번 넘어(회피)간다.
{ "_id": 1, "name": "Root", "children": [2, 3] }
{ "_id": 2, "name": "Child 1", "children": [] }
{ "_id": 3, "name": "Child 2", "children": [] }
오 바로 이거다! 자식들을 저장하고있으니, 배열내부 원소(자식의 _id)를 순회하며 문서를 가져오면 되겠구나?
그러나 자식참조 또한 문제가 있었다.
문서 전체를 받아와야해서 트리 전체를 탐색하려는데, 똑같이 재귀쿼리가 필요한 것이 아닌가?
더군다나 최상단 문서(루트)를 찾는 게 여간 일이 아니었다.
자식이 없는 문서가 루트 문서인가? => x
다른 자식 배열에 포함되지 않은 문서가 루트 문서인가? => o
그러므로 문서목록을 루트문서 기준으로 넘겨줄때 귀찮은 작업이 더 추가된다.
이 뿐만이 아니다. 위 예제 기준 id = 2
인 문서를 삭제해보자.
{ "_id": 1, "name": "Root", "children": [3] }
{ "_id": 3, "name": "Child 2", "children": [] }
삭제하면 아마 위처럼 될것이다. 즉, id
가 2
인 문서를 자식으로갖고있는 문서를 찾아 지워주어야한다.
그러므로 자식참조는 포기하기로 했다.
요약 : 수정시 비효율적, 그러나 부모노드기준 자식 탐색은 빠르다.
{ "_id": 1, "name": "Root", "path": null }
{ "_id": 2, "name": "Child 1", "path": ",1," }
{ "_id": 3, "name": "Sub Child", "path": ",1,2," }
세번째 방법인 구체화된 경로를 이용해보기로 했다. 다시 문서를 쿼리한다.
어라? 이 방법을 사용하니 재귀쿼리가 필요없었다.하지만 간과한점이있다. 내가 원하는 데이터구조는 재귀구조다. 문서가 문서를 포함한 형태다.
//db에 저장되는 실제 데이터
const storedDoc = {
_id,
id,
title,
content,
createdAt,
updatedAt,
}
//클라이언트가 받을 데이터
const documents = {
id,
title,
documents:[...(documnets가 재귀적으로 존재함)],
}
따라서 재귀쿼리를 하지 않아도 서버측에서 넘겨줄때 재귀 함수를 이용해 데이터셋을 구성해야한다. 결국 애플리케이션 서버에서 재귀를 이용하냐, DB에서 재귀를 이용하냐 차이일 뿐이다.
생각해보니 당연하다. 트리구조 데이터다. 데이터베이스라고해서 트리의 정의가 바뀌진 않는다. 탐색을 위해선 DFS,BFS둘 중 하나를 택해야 한다. 이 사실을 깨닫고는 머리를 쳤다. 왜 데이터 구조와 데이터베이스를 나눠서 생각했을까?
데이터 구조는 그저 data라는 정보를 저장하기위한 아키텍쳐인데 말이다...
아무튼 돌아와서!
구체화된 경로를 이용해 쿼리후 서버측에서 재귀연산하여 원하는 데이터를 만들어냈다.
const buildTree = (documents) => {
const idToNodeMap = {};
const rootNodes = [];
for (const doc of documents) {
const { _id, content, updatedAt, createdAt, ...rest } = doc;
idToNodeMap[doc._id] = { ...rest, documents: [] };
}
for (const doc of documents) {
const { _id, path } = doc;
if (!path) {
rootNodes.push(idToNodeMap[_id]);
} else {
const parentIds = path.split(",").filter(Boolean);
const parentId = parentIds[parentIds.length - 1];
if (idToNodeMap[parentId]) {
idToNodeMap[parentId].documents.push(idToNodeMap[_id]);
}
}
}
return rootNodes;
};
클라이언트에서 BreadCrumb구현시 필요했던 path도 구체화된 경로에 이미 들어있으니 그대로 넘겨주었다.
추후 더 나은방법이 떠오르면 그때가서 바꿔보겠음!
방법 | 장점 | 단점 | 사용 사례 |
---|---|---|---|
부모참조 | 구현이 쉽고 유연하다 | 재귀 쿼리, 읽기 비용 | 파일 시스템, 간단한 트리 구조 |
자식참조 | 자식 노드 탐색 속도, 단일 쿼리 | 업데이트 비용 | 특정 노드 자식만 빠르게 조회 |
구체화된 경로 | 전체 트리 탐색, 빠른 쿼리 | 업데이트 시 경로 재작성, 길이 제한 | 카테고리 트리, URL 구조 |
이외에도 nested set, embeded(원본 데이터 그대로 하위필드로 추가)등이 있으나 세가지 방법만 일단 알아보았다!
직역하자면 자동증가. DB에서 흔히 사용되는 기법이다.
예를들어 문서를 생성할때 id(key)를 1씩 증가시키고싶으면, auto-increment를 사용한다.
SQL에는 내장되어있는 기능인데 Nosql인 mongodb에는 기본으로 지원되지는 않는다.
따라서 사용자가 구현해서 적용해야한다. 공식문서를 바탕으로 구현해보았다.
async function getNextSequenceValue(collectionName) {
const db = await connectDB();
const result = await db.collection("counters").findOneAndUpdate(
{ _id: collectionName },
{ $inc: { sequence_value: 1 } },
{
returnDocument: "after",
returnOriginal: false,
upsert: true,
projection: { sequence_value: 1 },
}
);
return result.sequence_value;
}
요약하자면 이렇다.
counter
컬렉션에 원하는 컬렉션 이름을 _id
필드로 갖는 문서를 생성한다.sequence_value
라는 필드를 추가하고, upsert
옵션을 이용하여 값이 없다면 1부터 시작한다.$inc
연산자를 이용해서 sequence_value
를 1씩 증가시킨다.returnDocument: "after"
를 이용해서 업데이트된 문서를 반환한다.result.sequence_value
를 이용해서 증가된 sequence_value
를 반환받는다.컬렉션을 이용한 트릭으로 구현 자체는 매우 쉽다. 하지만 초당 수많은 문서가 생성되는 경우는 어떡할까?
아마 race condition이 발생해 같은 id를 갖게되지 않을까?
물론 mongodb에서는 이러한 경쟁조건을 해소하기위해 atomic update를 한다.
atomic update란, 해당 작업이 끝날때까지 다른 연산에 의해 필드가 수정될 수 없음을 의미한다. 이는 race condition을 해소하기위한 방법 중 하나인 lock개념과 유사하다!
하지만 실제로 lock을 걸지는 않는 다던데...추후 자세히 알아봐야겠다.
일단 어느정도 보장된다는 것만 알면 됐음!
Service 계층에서는 비즈니스 로직을 처리한다. 현재 프로젝트의 비즈니스 로직은 어떤게 있을까?
재귀적으로 데이터셋을 처리하던 buildTree
함수를 예로 들어보자.
Repository계층은 데이터베이스와의 상호작용만 담당한다. 즉, 이 데이터를 가지고 무엇을 할건지, 어떤 데이터셋으로 보여줘야 할지 몰라도 된다.
그렇다고 DB에서 전체 데이터를 받아와 Service계층에서 데이터를 가공해야할까?
그건 또 아니다. 적절하게 쿼리를 수행하고, 쿼리로 불가능한(할줄 모르는ㅎㅎ;) 데이터 가공을 서비스 계층에서 담당하면 된다.
어떤 데이터에 의존하냐 차이다
이렇게 이해했는데, 틀린점이 있다면 댓글로 부탁드립니다!
이전 Repository에 있던 buildTree
함수를 유틸함수로 빼내고, 서비스 계층에서 사용해보자.
async function getDocumentsService() {
const documents = await getDocuments();
return buildTreeFromMaterializedPath(documents);
}
생각보다 간단한데?
라고 생각했던 나를 반성하며...
현재 Repository계층에서 문서를 지우는 로직은 다음과 같다.
1. db에 연결해서 컬렉션을 가져온다.
2. 삭제할 문서를 찾고, 문서가 없으면 에러처리.
3. 삭제할 문서가 있으면 삭제할 문서의 자식들을 가져온다.
4. 재귀적으로 자식 문서들의 `path`필드를 업데이트한다(삭제될 문서의 id를 path에서 제거)
5. 문서를 삭제한다.
이중 어떤 부분을 서비스 계층으로 빼내야할까?
1번, 2번은 DB와 관련된 로직이므로 당연히 Repository 계층.
3번은 삭제할 문서의 자식을 가져오는 로직인데, 여기부터 좀 헷갈린다.
문서가 삭제된다 => 이건 데이터베이스 관련 로직
문서 내 필드인 경로를 제거한다 => 이건 데이터베이스 관련 로직일까?
그러니까, 경로는 트리구조의 데이터를 위해 존재한다.
트리구조의 데이터는 폴더구조처럼 사용자에게 문서를 보여주기위해 존재한다.
그렇다면 이는 비즈니스(도메인)인가?
말장난일 수도 있겠지만...아주 헷갈린다!😫
엄연히 따지자면 Repository계층의 함수들은 db 드라이버를 한단계 추상화한 함수다.
따라서 문서 필드 내 경로 제거 또한 db드라이버를 이용하니, Repository에서 호출하는 게 맞을 수도 있다.
하지만 그렇게 추상화된 함수를 Service계층에서 호출한다.😱
이 부분때문에 사실 어제부터 계속 고민이 많았다. 답이 나오지 않는 고민이라, 일단 Service 계층을 추가하지 않고 진행해보겠다.
Controller => Repository => DB이렇게 3계층으로. 사실 3계층이면 Repository가 아니라 Service여야되지만 네이밍 바꾸기 귀찮으니 일단....
요약: Service계층 없이 Repository에 로직을 모아넣고 진행해보겠음
mongodb에서 _id
는 인덱스필드다. 문서를 고유하게 구별하는 값인데, 사용자가 임의로 넣지 않으면 ObjectId
타입인 12바이트 길이 문자열이 들어간다. 공식문서
문서를 생성하면 insertedId
라는 프로퍼티값을 얻어올 수 있다. 이 값은 _id
값을 받아오는데, 자동생성 됐다면 당연히 ObjectId
타입이 들어온다.
그런데 이 값을 그대로 사용하면 에러가 난다.
따라서 mongodb
노드 드라이버에서 제공하는 ObjectId
로 감싸서 사용해주어야한다.
const response = createDocument();
ObjectId(response.insertedId)
만약 문자열로 사용하고 싶다면, 템플릿 리터럴이나 .toString()
을 호출해서 사용해야한다.