Skip to content

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

n-ryu edited this page Dec 10, 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[]>;
}

고민 1

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

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

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

메모리를 사용하던 구조를 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 초기화를 쉽게 해보자.

남아있는 숙제

💊 비타500

📌 프로젝트

🐾 개발 일지

🥑 그룹활동

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

Clone this wiki locally