노션클론 리팩토링 (11) - 백엔드 아키텍쳐, Repository, test

김영현·2024년 12월 14일
0

아키텍쳐 도식화

현재 아키텍쳐는 다음과 같다.

  1. 클라이언트가 서버에게 요청을 보낸다.
  2. 서버는 해당 요청이 정적 요청(html, css, js)이라면, 클라이언트에게 바로 응답해준다.
    a. 만약 해당 요청이 db접근이 필요한 경우, 3번으로.
  3. 서버가 db서버에게 요청을 보낸다.
  4. db서버는 요청을 해석하여 결과로 나온 데이터를 서버에 응답한다.
  5. 서버db서버에서 받은 데이터를 특정하게 가공하여 클라이언트에게 응답한다.

토이프로젝트라 그런지 크게 신경쓸만한 내용이 없다.

여기서 큰 네모(클라이언트, 서버 db)내부의 아키텍쳐도 한 번 도식화 해보자.
당장은 서버만 도식화 해보겠다.

기존 서버 아키텍쳐

이렇게만 구성해도 서버는 잘 돌아간다. 하지만 백엔드를 잘 모르는 내게도 문제점이 꽤 보인다.

  • 서버 분리 : 예를들어 DB서버가 죽으면 사용자는 정적인 페이지라도 요청받아 볼 수 없게된다. 즉, 정적인 데이터와 동적인 데이터를 다루는 서버를 분리해야한다.
    => 간단하게 정적 웹서버, API서버를 분리해보겠다.
  • API와 DB사이 계층 추가 : API계층에서 바로 데이터베이스에 접근하여, 반환된 데이터를 곧바로 클라이언트에게 보내도 무방하다. 하지만 관심사를 분리하고 계층간 책임을 명확히 구분하면, 코드가 깔끔해지고 테스트하기 용이해진다. 또한 재사용하기 쉬워지며 확장성이 늘어난다. 다만 작은 프로젝트인데도 괜히 복잡하게 파일이 많이생기고...찮음은 덤이다.😣
    => Layered Architecture를 이용해 계층을 추가해보겠다.

Layered Architecture

Layered architecture(계층형 아키텍쳐)란, Multi-tier Architecture(다층구조)에서 영감을 받은 패턴이자 가장 널리쓰이는 패턴 중 하나다. 하나의 계층을 여러 계층으로 나누어, 관심사를 분리하여 책임을 줄이고 응집도를 높인다. 또한 재사용성과 유지보수성, 테스트를 용이하게 해준다.

  • OSI : OSI를 7계층으로 나누어 각 계층간 관심사를 분리 하였다.
  • OS : 하드웨어, 커널, 유틸리티, 사용자 계층으로 운영체제 내부를 나누었다.

이처럼 핵심적인 기능들은 대부분 계층형 아키텍쳐 구조로 이루어져있다.

웹 애플리케이션도 마찬가지로 계층형 아키텍쳐로 구성된사례가 대부분이다.


출처 : oreilly : software-architecture-patterns

웹 애플리케이션의 계층형 아키텍쳐

위 사진을 보면 알겠지만, 보통 4계층을 이용하여 계층을 나눈다. 각 계층은 하위 계층의 관심사를 알 필요가 없다.

  1. Presentation Layer(Controller) : 클라이언트의 요청과 응답을 담당한다.
  2. Business Layer(Service) : 비즈니스 로직을 수행한다. 규칙검증, 에러처리, 데이터 가공 등이 있다.
  3. Persistence Layer(Repository) : DB와 직접 상호작용한다. DB에 쿼리를 날리는 역할을 담당함.
  4. Database Layer : 실제 Database를 의미한다.

바로 이해가 안된다면 예시를 보자.

// 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 계층 (Repository)

Persistence계층은 db에 직접 접근하여 데이터를 반환하는 계층이다.
따라서 mongodb에 접근하여 데이터를 뽑아오자.

현재 필요한 데이터는 다음과 같다.

  • 문서 CRUD
  • 문서 리스트

이를 코드로 나타내보자.

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 테스트

먼저 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 모킹

현재 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 });
  });
});

전체 코드는 위와 같다. 테스트를 돌려보면 쉽게 통과한다.

profile
모르는 것을 모른다고 하기

0개의 댓글