현재 아키텍쳐는 다음과 같다.
토이프로젝트라 그런지 크게 신경쓸만한 내용이 없다.
여기서 큰 네모(클라이언트, 서버 db)내부의 아키텍쳐도 한 번 도식화 해보자.
당장은 서버만 도식화 해보겠다.
이렇게만 구성해도 서버는 잘 돌아간다. 하지만 백엔드를 잘 모르는 내게도 문제점이 꽤 보인다.
Layered architecture(계층형 아키텍쳐)란, Multi-tier Architecture(다층구조)에서 영감을 받은 패턴이자 가장 널리쓰이는 패턴 중 하나다. 하나의 계층을 여러 계층으로 나누어, 관심사를 분리하여 책임을 줄이고 응집도를 높인다. 또한 재사용성과 유지보수성, 테스트를 용이하게 해준다.
이처럼 핵심적인 기능들은 대부분 계층형 아키텍쳐 구조로 이루어져있다.
웹 애플리케이션도 마찬가지로 계층형 아키텍쳐로 구성된사례가 대부분이다.
출처 : oreilly : software-architecture-patterns
위 사진을 보면 알겠지만, 보통 4계층을 이용하여 계층을 나눈다. 각 계층은 하위 계층의 관심사를 알 필요가 없다.
바로 이해가 안된다면 예시를 보자.
// Presentation Layer(Controller)
const getUser = async (req, res) => {
const user = await userService.getUserById(req.params.id);
res.json(user);
};
// Business Layer(Service)
const getUserById = async (id) => {
const user = await userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
};
// Persistence Layer(Repository)
const findById = async (id) => {
const user = await db.collection('users').findOne({ _id: id });
return user;
};
각 계층은 하위 계층이 무슨일을 하는지 알 필요가 없다. 그저 하위 계층에서 구현된 함수를 호출해 값을 꺼내와 반환한다.
함수간 책임이 명확하고 분리가 잘 되어있기때문에 이전에 말한 장점들이 다 살아있다.
하지만 모든 기술이 그렇듯 장점만 있는 건 아니다.
최하위계층은 데이터베이스다. 따라서 모든 로직은 데이터베이스의 영향을 받는다. 이는 개발의 의미와 살짝 동떨어진다.
개발이란 무엇인가? 실존하는 문제를 해결하기위해 나온 기술이 개발이다.
그러나 계층형 아키텍쳐는 문제(도메인)주도가아닌 데이터베이스주도 설계(개발)을 진행하게된다. 하지만 비즈니스적 관점에서는 전혀 맞지 않는다. 매출을 위해선 사용자들이 원하는 문제를 해결해야한다.
또한 위 예제를 계층형 아키텍쳐가 아닌 예시로 바꿔보겠다.
const getUser = async (req, res) => {
try{
const id = req.params.id;
const user = await db.collection('users').findOne({ _id: id });
if (!user) {
throw new Error('User not found');
}
res.json(user);
}catch(error){
res.json(error);
}
};
세 계층을 합치니 간단해진다. 이처럼 아주 간단한 작업이라도 계층을 나눠야해서 오버헤드가 증가한다.
이뿐만이 아니라 계층 건너뜀, 하위계층의 비대등이 있으나 당장은 계층형 아키텍쳐를 깊게 배우는 시간이 아니라 생략하겠다.
널리 쓰인다니까 일단 써보겠다. 단, 만들고나서 단점을 어떻게 보완하면 좋을지 생각해보자.
일단 아는 선에서 계층을 분리해보았다. 여기서 더 깊게들어가면 로드밸런싱, 캐싱, 보안 등이 있겠지만...기본적 인것 부터 챙기고 나서 해도 늦지 않다.
수정된 아키텍쳐대로 백엔드를 구성해보자!
폴더 구조를 어떻게 해야할지 고민이 됐는데, 일단 Co-location방식을 채용하기로 했다.
Controller, Service, Repository
폴더를 구분해놓을 수 도 있으나, 관련파일이 서로 가까운 위치에 있는게 보기가 더 편하지 않을까 싶었다.
/
|-- api-server/
| |-- documents/
| |-- routes/
| |-- models/
| |-- index.js
| |-- config/
| | |-- db.js
| |-- .env
|-- web-server/
| |-- public/
| |-- routes/
| |-- index.js
위처럼 구성해보았다. 루트경로에있는 패키지는 concurrently
만 설치되어있다. 서버 2개를 동시실행하기위해!
이제 API서버측 코드를 작성해보자.
Persistence계층은 db에 직접 접근하여 데이터를 반환하는 계층이다.
따라서 mongodb
에 접근하여 데이터를 뽑아오자.
현재 필요한 데이터는 다음과 같다.
이를 코드로 나타내보자.
const connectDB = require("../models/db");
const COLLECTION_NAME = "documents";
async function getDocuments(query = {}) {
const db = await connectDB();
return await db.collection(COLLECTION_NAME).find(query).toArray();
}
async function getDocumentById(id) {
const db = await connectDB();
return await db.collection(COLLECTION_NAME).findOne({ _id: id });
}
async function createDocument(document) {
const db = await connectDB();
return await db.collection(COLLECTION_NAME).insertOne(document);
}
async function updateDocument({ id, newDocument }) {
const db = await connectDB();
return await db
.collection(COLLECTION_NAME)
.updateOne({ _id: id }, { $set: newDocument });
}
async function deleteDocument(id) {
const db = await connectDB();
return await db.collection(COLLECTION_NAME).deleteOne({ _id: id });
}
module.exports = {
getDocuments,
getDocumentById,
createDocument,
updateDocument,
deleteDocument,
};
간단한 함수들이 생성되었다. 실제 요구사항하고 다른점이 눈에 띈다.
예를들어 문서를 생성할땐 부모가 될 문서의 id가 필요하다.
또한 문서 리스트를 받아올땐, 각 문서를 재귀적으로 담고있는 documents
필드가 있어야한다.
마지막으로 해당 함수들이 잘 작동하는지는 어떻게 판단할까?
직접 데이터베이스에 접근해서 CRUD를 진행해도 좋지만, 기왕이면 테스트를 이용해보자.
먼저 Jest와 Mongodb테스트용 프리셋을 같이 설치해준다.
npm i -d jest @shelf/jest-mongodb
그다음 jest설정파일에 해당 옵션을 삽입해준다.
// jest.config.json
{
"preset": "@shelf/jest-mongodb"
}
이후 xxx.test.js
파일을 만들고, 아래 예제를 붙여넣기하고 실행해보자.
const {MongoClient} = require('mongodb');
describe('insert', () => {
let connection;
let db;
beforeAll(async () => {
connection = await MongoClient.connect(globalThis.__MONGO_URI__, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
db = await connection.db(globalThis.__MONGO_DB_NAME__);
});
afterAll(async () => {
await connection.close();
});
it('should insert a doc into collection', async () => {
const users = db.collection('users');
const mockUser = {_id: 'some-user-id', name: 'John'};
await users.insertOne(mockUser);
const insertedUser = await users.findOne({_id: 'some-user-id'});
expect(insertedUser).toEqual(mockUser);
});
});
정상적으로 실행된 것을 볼수있다!
mongodb 테스트를 위한 프리셋의 자세한 내용은 여기다.
출처 : https://jestjs.io/docs/mongodb (공식문서)
이제 만든 Repository를 테스트해볼건데, 조금 문제가있다.
현재 db가져오는 로직은 아래와 같다.
//models/db.js
const { MongoClient } = require("mongodb");
const uri = "mongodb://localhost:27017"; // MongoDB URI
const client = new MongoClient(uri);
const dbName = "notion_clone";
let db;
const connectDB = async () => {
if (!db) {
await client.connect();
db = client.db(dbName);
}
return db;
};
module.exports = connectDB;
//Repository...
async function getDocumentById(id) {
const db = await connectDB();
return await db.collection(COLLECTION_NAME).findOne({ _id: id });
}
즉 db를 받아서 쓰는게 아니라, connectDB
모듈을 가져와서 함수 실행마다 db인스턴스를 가져와 쓰고있다. 이렇게 사용되는 db를 테스트함수에서 주입해주기 위해선 Repository의 인자로 db를 넘겨야하는데, 인자로 받는다면 Service계층에서도 주입해야하니 본말전도다.
따라서 models/db.js
에서 내보내는 모듈의 로직을 조금 변경해주자.
module.exports = {
connectDB,
_internal: {
setDb: (newDb) => {
db = newDb;
},
},
};
이렇게 private하게 언더바를 붙여 내보내주고, 테스트 함수에서는 아래와 같이 사용한다.
beforeAll(async () => {
connection = await MongoClient.connect(globalThis.__MONGO_URI__, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
db = connection.db(globalThis.__MONGO_DB_NAME__);
_internal.setDb(db);
});
기존 db인스턴스를 테스트 함수 프리셋에서 만든 인스턴스로 변경한다.
그러면 해결!
const { MongoClient } = require("mongodb");
const {
getDocuments,
getDocumentById,
createDocument,
updateDocument,
deleteDocument,
} = require("./documentsRepository.js");
const { connectDB, _internal } = require("../models/db");
describe("insert", () => {
let connection;
let db;
beforeAll(async () => {
connection = await MongoClient.connect(globalThis.__MONGO_URI__, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
db = connection.db(globalThis.__MONGO_DB_NAME__);
_internal.setDb(db);
await db
.collection("counters")
.insertOne({ _id: "documents", sequence_value: 0 });
});
afterAll(async () => {
await connection.close();
});
it("should insert a doc into collection", async () => {
const mockDocument = {
title: "제목 인데용?",
content: "내용 인데용?",
createdAt: "2024-12-15-19:52:00",
updatedAt: "2024-12-15-19:52:00",
};
const data = await db
.collection("counters")
.find({ _id: "documents" })
.toArray();
await createDocument({ document: mockDocument });
const insertedUser = await getDocumentById(1);
expect(insertedUser).toEqual({ ...mockDocument, id: 1, _id: 1 });
});
});
전체 코드는 위와 같다. 테스트를 돌려보면 쉽게 통과한다.