Skip to content

의존성 주입으로 DB를 바꿔보자

n-ryu edited this page Dec 11, 2022 · 10 revisions

지난 이야기

OaO의 근본은 Todo 앱! 이를 위해서 Todo 데이터를 관리하는 시스템을 개발해야 했는데, 현재까지의 상황은 아래와 같았다!

  • 어떤 DB를 써야할지 정해지지 않은 상황

    • 멘토님의 조언에 따라 일단 FE에 집중하여 구현을 진행하기로 했다.
    • FE에 집중하여 구현을 진행하기 위해, 우선 데이터는 로컬에 저장하는 방향으로 진행하기로 했다.
    • 일단 로컬에 저장하는 것일 뿐, 언제든 서버 DB와의 연동이 가능하게끔 계획 중이다! 또, 서비스 특성상 팀원들 중 아무도 사용해보지 않은 graphDB를 도입해야 할 수도 있다!
  • Todo 데이터 관리 시스템 인터페이스를 먼저 정의함

    • 팀원 모두가 FE 개발에 집중하고, 2명은 컴포넌트 구현에, 2명은 Todo 데이터 관리 시스템 구현에 투입되었다.
    • 원활한 병렬작업을 위해서 Todo 데이터 관리시스템(TodoList API)의 인터페이스를 먼저 확실하게 정의하고 구현을 시작해서 컴포넌트 팀이 TodoList API의 내부 로직 구현 정도에 독립적으로 구현을 진행할 수 있게끔 했다!
    • DB를 어디에 구축하든, DB와의 상호작용은 비동기적으로 하는 것이 더 유연한 구현을 가능하게 할 것이라 판단해서 모든 데이터 관리 로직(CRUD 로직)을 비동기 인터페이스로 미리 정의했다.
  • 일단은 메모리를 사용하도록 TodoList API 구현체 개발

    • API 정의가 되어 있으니, 내부 구조는 어떻게 되든 상관이 없다고 판단했다!
    • 알고리즘이 복잡하고, 순수성 등 고려할 점이 많다고 생각해서 일단 별도의 DB 연결 없이 메모리에서 모든 로직이 돌아가게끔 TodoList API의 구현체를 개발했다.
    • 구현 후, 메모리에서 동작하는 로직만으로 컴포넌트 팀의 설계와 잘 융합되어 동작하는 것을 확인했다.

TodoList API에서 DB를 분리해보자

일단 메모리를 사용하는 TodoList API는 온전하게 잘 동작하는 것을 확인했으니, 이제 실제 확장, 아니 적어도 확장성을 고려한 형태로 구조를 변경해야할 차례가 왔다.

현재의 구조는 모든 데이터 관리가 TodoList 클래스에서 일어나는데, 일반적으로 DB가 수행하는 업무만을 별도의 인터페이스로 정의하고 분리해서,나중에 DB의 종류를 바꾸거나 서로 다른 DB를 사용하고 싶을 때 의존성 주입 형태로 DB를 바꾸어가며 사용할 수 있도록 구현하기로 했다!

구조 변경에 앞서서, 아래 그림과 같이 여러가지 구조를 고민해 보았다.

image

  1. 현재구조 : 구현된 TodoList API는 메모리를 이용하는 클래스 인스턴스 형태이다. React 컴포넌트는 이를 전역 상태 형태로 보관하고 필요한 정보가 있거나, 변경 사항이 생기면 TodoList의 메서드를 호출한다. 변경 요청 메서드들은 대부분 변경된 이후의 새 TodoList를 반환한다.

  2. DB를 아예 분리하고, 데이터는 필요할때마다 fetching 함수로 가져온다면 : 가장 일반적인 백엔드-프론트엔드의 관계인 것 같다. 하지만 복잡한 연결관계를 가지고 있는 우리 서비스 특성상, 모든 데이터 관련 로직이 DB를 통해 직접 이루어진다면 느리고 불편하리라는 생각이 들었다. 또, 이미 만들어 놓은 API 구조가 클래스 구조를 따르고 있으므로, fetching 함수 형태로 구현한다면 API 구조를 다 변경해야 해서 미리 인터페이스를 설계해서 얻었던 이득이 없어지리라는 생각이 들었다.

  3. TodoList와 DB만 분리한다면 : TodoList에서 DB에 의존하는 부분만 분리하여 사용하는 방법. 지금까지의 TodoList API의 구조를 바꾸지 않아 호환성을 지키면서도, DB 인터페이스를 별도로 설계하고 이를 TodoList에 주입해서 사용하도록 한다. DB인터페이스는 기초적인 CRUD만 담당하고, 추가되거나 복잡한 비즈니스 로직을 요구하는 부분은 TodoList가 전담하므로, 관심사의 분리도 잘 이루어질 것이다!

  4. Read는 TodoList에서 직접, CUD만 DB로 향하게 한다면 : 2번 방식에서 데이터 조회도 항상 DB에서 직접한다면 비효율적일 것이라는 생각이 들었다. 따라서 Read 계열의 요청들은 모두 TodoList의 메모리에서 바로 꺼내서 주고, CUD만 DB로 향한 뒤, DB에서 CUD 성공 응답이 오면 바로 TodoList와의 싱크로를 맞춰주는 방식으로 구현하면 좋으리라는 판단이 들었다.

네 가지 큰 그림중에서, 다른 팀원과의 병렬 작업을 해치지 않으면서도, 성능도 고려할 수 있는 4번으로 골라 구현하게 되었다!

DB 인터페이스를 먼저 정의해보자

어떤 구조를 택할 것인지 정했으니, DB 인터페이스를 먼저 설계했다.

기존의 TodoList API 로직 중에서, 가장 기본적이고 범용적인 CRUD 메서드만을 설계했으며, update와 read 관련 메서드는 다수에 동시에 접근하는 경우가 꽤 있을 것이라 생각해 한번에 여러 데이터에 접근하는 메서드도 별도로 정의했다.

export interface ITodoListDataBase {
  get: (id: string) => Promise<PlainTodo | undefined>;
  getAll: () => Promise<PlainTodo[]>;
  add: (todo: InputTodo) => Promise<PlainTodo[]>;
  edit: (id: string, todo: InputTodo) => Promise<PlainTodo[]>;
  editMany: (inputArr: Array<{ id: string; todo: InputTodo }>) => Promise<PlainTodo[]>;
  remove: (id: string) => Promise<PlainTodo[]>;
}

메모리를 사용하던 구조를 DB 인터페이스의 구현체 형태로 분리해보자

다음으로 기존에 TodoList Class에 정의해 놓았던 로직들을 DB 쪽으로 분리했다. 이참에 기존에 TodoList에서 Array를 사용하고 있어 비효율적이었던 구조를 Map을 사용하는 구조로 바꾸어 성능 또한 높일 수 있었다! 이처럼 여러 겹의 추상화 구조를 가지게 되니, 비효율적이던 기존 구조를 리팩터링하거나 수정하는데에 매우 편리하다는 것을 다시 한번 느꼈다!

이렇게 DB 인터페이스의 Memory 버전 구현체를 만들고 나서, 반대로 TodoList Class로 돌아가 기존의 로직들이 DB를 주입받고, DB의 메서드를 사용하게끔 수정하였다.

export class MemoryDB implements ITodoListDataBase {
  private readonly todoList: Map<string, Todo>;
  constructor(todoList?: InputTodo[]) {
    const newTodoList = todoList ?? [];
    this.todoList = new Map(
      newTodoList.map((el) => {
        const newTodo = new Todo(el);
        return [newTodo.id, newTodo];
      }),
    );
  }

  async get(id: string): Promise<PlainTodo | undefined> {
    return this.todoList.get(id)?.toPlain();
  }

  async getAll(): Promise<PlainTodo[]> {
    return [...this.todoList.values()].map((el) => el.toPlain());
  }

  async add(todo: InputTodo): Promise<PlainTodo[]> {
    const newTodo = new Todo(todo);
    this.todoList.set(newTodo.id, newTodo);
    return [...this.todoList.values()].map((el) => el.toPlain());
  }

  async edit(id: string, todo: InputTodo): Promise<PlainTodo[]> {
    if (!this.todoList.has(id)) throw new Error('ERROR: 수정하려는 ID의 Todo가 없습니다.');
    const oldTodo = (await this.get(id)) as PlainTodo;
    const newTodo = new Todo({ ...oldTodo, ...todo, id: oldTodo.id });
    this.todoList.set(id, newTodo);
    return [...this.todoList.values()].map((el) => el.toPlain());
  }

  async editMany(inputArr: Array<{ id: string; todo: InputTodo }>): Promise<PlainTodo[]> {
    for (const el of inputArr) {
      await this.edit(el.id, el.todo);
    }
    return [...this.todoList.values()].map((el) => el.toPlain());
  }

  async remove(id: string): Promise<PlainTodo[]> {
    if (!this.todoList.has(id)) throw new Error('ERROR: 삭제하려는 ID의 Todo가 없습니다.');
    this.todoList.delete(id);
    return [...this.todoList.values()].map((el) => el.toPlain());
  }
}

Indexed DB를 사용하는 구현체를 추가로 구현해보자

이제 첫번째 기능 확장을 할 차례이다. 메모리만 사용할 수 있던 기존의 구조를 로컬에 데이터를 저장할 수 있는 웹 브라우저 표준 인터페이스인 Indexed DB를 사용하는 구조로 바꾸어 보았다!

이 부분을 구현하면서 굉장히 고무적이었던 부분은, 잘 추상화된 구조 덕에 정말 새로운 DB를 도입하는데에 아무런 어려움도, 큰 오류나 버그도 없었다는 점이었다. 이리저리 수정하고 코드를 꼬아댈 필요 없이, 새로 구현하는 구현체에만 집중하니 매우 효율적이었다. 실제로 4시간여가 걸릴 것이라고 예상했던 구현 시간도, Indexed DB 자체를 조사한 초반 시간을 제외하면 거의 1시간 정도로 매우 빠른 시간 안에 구현할 수 있었다.

이렇게 의존성을 분리하고, 실제 구현체도 둘이나 만들어 놓고 나니, 다른 DB를 쓰거나, 서버를 구축해 서버 DB를 추가로 활용하게 되더라도 FE 쪽에서의 대응이 전혀 어렵지 않으리라는 자신이 들었다.

class IndexedDB implements ITodoListDataBase {
  private readonly db: IDBPDatabase;
  constructor(db: IDBPDatabase) {
    this.db = db;
  }

  async get(id: string): Promise<PlainTodo | undefined> {
    const result = await this.db.get(TABLE_NAME, id);
    return result as PlainTodo | undefined;
  }

  async getAll(): Promise<PlainTodo[]> {
    const result = await this.db.getAll(TABLE_NAME);
    return result as PlainTodo[];
  }

  async add(todo: InputTodo): Promise<PlainTodo[]> {
    const newTodo = new Todo(todo).toPlain();
    await this.db.add(TABLE_NAME, newTodo);
    return await this.getAll();
  }

  async edit(id: string, todo: InputTodo): Promise<PlainTodo[]> {
    const oldTodo = (await this.get(id)) as PlainTodo;
    const newTodo = new Todo({ ...oldTodo, ...todo, id: oldTodo.id }).toPlain();
    await this.db.put(TABLE_NAME, newTodo);
    return await this.getAll();
  }

  async editMany(inputArr: Array<{ id: string; todo: InputTodo }>): Promise<PlainTodo[]> {
    for (const el of inputArr) {
      await this.edit(el.id, el.todo);
    }
    return await this.getAll();
  }

  async remove(id: string): Promise<PlainTodo[]> {
    await this.db.delete(TABLE_NAME, id);
    return await this.getAll();
  }
}

TodoList와 DB 초기화를 쉽게 해보자

여기까지 구현은 좋지만, DB의 생성은 일반적으로 비동기적으로 일어나기 때문에, 의존성 주입 과정이 번거롭다.

일단 JavaScript class constructor 안에서는 비동기 처리를 기다리지 못하기 때문에 초기화를 담당해줄 별도의 TodoList.init() 메서드를 구현했다. 다음으로, 별도의 팩토리 함수를 따로 구현해 해당 함수를 호출하면 DB 생성을 기다리고, 이를 주입한 TodoList를 반환받을 수 있게끔 하면 기존에 생성자를 사용하는 것처럼 편안하게 초기화가 완료된 TodoList를 만들 수 있다!

export const createTodoList = async (dbType: 'MemoryDB' | 'IndexedDB', todos?: InputTodo[]): Promise<TodoList> => {
  if (dbType === 'MemoryDB') {
    const mdb = new MemoryDB(todos);
    const todoList = new TodoList(mdb);
    return await todoList.init();
  }
  if (dbType === 'IndexedDB') {
    const idbFactory = new IndexedDBFactory();
    const idb = await idbFactory.createDB(todos);
    const todoList = new TodoList(idb);
    return await todoList.init();
  }
  throw new Error('ERROR: invalid DB type for TodoList');
};

마치면서

여기까지 성공적으로 구현이 완료되었고, 컴포넌트를 설계하는 팀원들과의 코드 연계도 테스트해 본 결과 매우 정상적으로, 아무 문제 없이 잘 동작하는 것을 확인할 수 있었다. 특히 Indexed DB 구현체까지 구현한 후에 연결 테스트를 했는데, 한번에 Indexed DB와의 연계도 잘 진행되어 매우 기뻤다!!!

하지만 그와 동시에 두 가지 고민이 생겼고, 앞으로도 프로젝트를 진행하며 계속 고민을 해야하리라고 생각한다.

고민 1

첫번째 고민은, 결국 인터페이스는 모든 DB에서 동일한 형상을 가지고 있어야 하는데, 특정 DB에서 특별히 더 빠른 특수한 요청 케이스가 있다면 어떻게 해야할까 하는 점이다.

예를 들어, 우리 서비스에서는 Todo의 선후관계에 순환참조가 없는지 테스트가 필요하다. 만약 rDB를 사용한다면 단순히 전체 목록을 조회해서 별도의 로직으로 가공하겠지만, graphDB를 사용한다면 graph 형식에 맞게 더 효율적인 접근법이 DB 자체에서 제공될지도 모른다.

이경우 같은 메서드를 사용할 수 없고, DB 인터페이스를 사용하는 쪽에서도 구분하여 사용할 수 없다. 이를 해결하는, 인터페이스는 동일하지만 각자의 개성을 살릴 수 있는 방법에는 무엇이 있을까?

고민 2

두번째 고민은, 현재의 구조는 데이터와 로직의 분리가 이루어지지 않고 있다는 점이다. 아무래도 작성한 코드가 FE에서의 코드이니 만큼, 데이터는 데이터로서 관리를 하고, 이를 다루는 비즈니스 로직들은 별도로 분리되어야 하는데, 지금은 클래스 구조 안에 두가지가 동시에 존재한다.

이를 해결하려면 클래스 내부에 데이터와 변경 로직, DB를 함께 보관할 것이 아니라 이들을 분리해야 한다는 생각이 들었다. 멘토님의 멘토링을 통해서 데이터와 DB는 별도의 객체 형태로, 각종 로직은 함수의 형태로 만들어 로직 함수에서 데이터와 DB를 인자로 받아 결과 데이터를 반환하게끔 하면 좋을 것이라는 조언을 받았다. 당장은 해당 방식으로 리팩터링 하기에 프로젝트 진행이 급해 우선순위를 미루게 되었지만, 언젠가는 더 나은 형태로 정리하는 시간을 가지고 싶다.

그리고 DB는 이미 분리했다 쳐도, 데이터의 경우 어디까지를 로직과의 경계선으로 가져야 하는가? 하는 고민도 생겨났다. TodoList에서 최우선순위 Todo를 가져오는 로직은 TodoList와 밀접하여 그냥 그대로 클래스 형태로 유지해서 사용하는 것이 좋을 것 같다. 하지만 데이터를 한번 더 가공해서 데이터 시각화를 한다고 치면, 여전히 DB는 별도로 쓰지 않더라도 별도의 비즈니스 로직으로 분리하는 것이 더 보기 좋으리라는 생각이 든다. 이런 기준을 어디로 잡아야 하는 것인지 더 깊은 생각이 필요하다!

아무튼 이렇게 데이터 관리 구조를 DB 의존성을 주입하는 형태로 리팩터링하는 작업은 성공적으로 마무리 되었다. 조금 독특한 Todo의 관리를 하는 기능일 뿐인데, 실제 구현을 하면서 복합적으로 확장가능한 구조에 대해서 정말 심도있게 고민해볼 수 있었다. 또, 의존성 주입 형태를 만들고 직접 구현체를 둘이나 구현하고 나니, 의존성 주입 구조가 가지는 장점도 직접 피부로 느낄 수 있는 정말 소중한 경험이었다.

💊 비타500

📌 프로젝트

🐾 개발 일지

🥑 그룹활동

🌴 멘토링
🥕 데일리 스크럼
🍒 데일리 개인 회고
🐥 주간 회고
👯 발표 자료
Clone this wiki locally