Skip to content

TodoList API 개발기

daeseong9388 edited this page Dec 11, 2022 · 7 revisions

일단 TodoList의 비즈니스 로직을 만들자!

데이터와 로직

다음과 같은 고민으로 시작해 함수로 구성할 것인지 클래스로 구성할 것인지에 대한 고민으로 그 범위를 줄였다.

  • 데이터와 로직을 어떻게 응집시킬 것인지
  • 단위 테스트한다고 했을 때 단위를 얼마나 어떻게 분리해야 할지
  • 의존성이 추가되는 것을 상정해 어떻게 확장성 있게 설계할 것인지
분류 \ 로직 모듈(함수) 클래스(메서드)
데이터 매개변수로 접근 this로 접근
인터페이스 분리 export private
Stateless field update { },Plain Object를 반환 new 키워드로 생성한 새로운 인스턴스 반환

추가적인 고민

  • 메모리
    • 구현할 TodoList는 일종의 싱글턴 형태이기 때문에 굳이 꼭 클래스(프로토타입)을 생성할 필요는 없다.
  • I/O, 의존성
    • 의존하는 객체를 주입하기(의존성 주입)
      • 객체에 정보를 저장하고 있다가(생성자 혹은 init 메서드), 메소드에서 참조하기
      • 메소드에 의존하는 객체를 인자로 넘기기
      • 핵심 로직을 테스트할 때 I/O interface를 mocking을 해야하는 번거로움이 존재함
    • 데이터를 주입하기
      • 핵심 코드를 순수하게 유지하고 모든 I/O를 가장자리(시작 / 종료점)으로 밀어넣기
      • I/O가 중간에 들어가지 않도록 설계하는 것이 쉽지 않음(언제 I/O와 동기화를 하는가에 대한 기준 잡기가 어려움)
  • 인터페이스, 타입, 형변환
    • READ와 CUD에 사용되는 인터페이스를 분리해야한다.
    • 누락 혹은 변형된 필드가 들어올 수 있기 때문에 기본값 설정 혹은 변환 과정이 필요하다.

결국, 생산성을 높이기 위해 클래스 문법을 사용하여 로직을 구현하기로 결정했고 다음과 같은 이유로 클래스 문법이 생산성이 높다고 판단했다.

  • 매번 매개변수로 입력을 받는 것보다 this 로 접근하는 것이 편하다.
  • 객체의 생성과 삭제가 centralized되어 관리하기 편하다.
  • this 를 반환하여 체이닝을 구현할 수 있다.
  • import문을 덜 사용할 수 있다.
  • 데이터와 로직이 좀 더 강하게 결합되어 있지만 로직의 호출을 더욱 쉽게 할 수 있다.
  • 함수형 프로그래밍이 익숙하지 않다.

데이터구조

먼저 각 데이터구조의 복잡도를 정리해보면 다음과 같다.

  • Array → 탐색 : O(N), 업데이트: O(N), 조회: O(1), 정렬 : O(NlogN)
    • 업데이트 때마다 정렬
  • Map → 탐색: O(1), 업데이트: O(1), 조회: O(nlogn), 정렬: O(NlogN)
    • 조회시마다 정렬
  • b-tree → 탐색: O(logN), 업데이트: O(logN), 조회: O(1) - active, O(logN) - 전체 리스트, 정렬:O(logN)
    • 조회시마다 정렬

전체적인 것을 고려했을 때 b-tree가 가장 적합했지만, 언어 단에서 지원하지 않아 배제했고 남은 둘은 어느정도 trade-off가 있어 Array를 이용하는 것이 좀 더 구현하기 쉽다고 판단해 Array를 이용하는 것으로 결정했다.

구현과 사용 예시

간단한 CRUD 메서드로들만 재구성했다.

알고리즘

// todo.ts
export type InputTodo = Partial<PlainTodo>;

export interface PlainTodo {
  id: string;
  label: string;
  done: boolean;
}

export interface Update {
  setDone: () => Todo;
  setReady: () => Todo;
  edit: (label: string) => Todo;
  toggleDone: () => Todo;
}

export class Todo implements PlainTodo, Update {
	id: string;
  label: string;
  done: boolean;
  constructor(label: string) {
    this.id = uuidv4();
    this.label = label;
    this.done = false;
  }
	...
}
  • 각 프로퍼티의 기본값 설정 및 추후 업데이트를 위해 InputTodo type을 정의했다.
    • 테스트 혹은 컴포넌트 단에서 어떠한 입력값이 들어올지 모르기 때문이다.
  • Todo는 Update는 결국 TodoList 클래스에서 하기 때문에 Update 관련 메서드는 자기 자신을 반환하는 체이닝을 통해 편의성을 높였다.
  • 사용하는 데이터는 PlainTodo를 형태로 반환해야한다.
// todoList.ts
import { InputTodo, PlainTodo, Todo } from './todo';

export interface CUD {
  add: (label: string) => TodoList;
  delete: (id: string) => TodoList;
  edit: (id: string, label: string) => TodoList;
  setDone: (id: string) => TodoList;
  toggleDone: (id: string) => TodoList;
  clearDone: () => TodoList;
}

export type FilterType = 'ALL' | 'DONE' | 'READY';

export interface READ {
  length: number;
  numReadyTodos: number;
  isAllCompleted: boolean;
  filter: (type: FilterType) => PlainTodo[];
}

export class TodoList implements CUD, READ {
	private readonly todoList: Todo[];
	constructor(todoList?: InputTodo[]) {
    this.todoList = todoList?.map(el => new Todo(el)) ?? [];
  }

  private static toPlain(todoList: Todo[]) : PlainTodo[] {
    return todoList.map(el => el.toPlain());
  }

  add(label: string): TodoList {
    return new TodoList(TodoList.toPlain([new Todo({label}), ...this.todoList]));
  }
	...
}
  • 마찬가지로 입력/생성은 InputTodo[] 인자로 받고, 출력은 PlainTodo[]를 반환하며, 업데이트는 PlainTodo[]를 입력받아 새로운 인스턴스를 반환한다!
    • 입력을 InputTodo[]로 받을 수 있게 해놓은 것은 한번에 Raw Todo의 배열을 입력받을 수도 있기 때문이다.
    • ex) JSON 혹은 string으로 밖에 저장되지 않는 DB, 테스트 케이스
  • 위와 마찬가지로 Read와 CUD를 분리해 사용성을 높였다!

컴포넌트

export function Todos() {
  const [todos, setTodos] = useState<TodoList>(new TodoList([]));

  const filteredTodos = todos.filter(type);

	return (... 
						<TodoItem
              key={todo.id}
              todo={todo}
              type={type}
              todos={todos}
              setTodos={setTodos}
            />
					...)
}
export const TodoItem = ({
  todo,
  type,
  todos,
  setTodos,
}: {
  todo: PlainTodo;
  type: FilterType;
  todos: TodoList;
  setTodos: React.Dispatch<React.SetStateAction<TodoList>>;
}) => {
  const completed =
    (todo.done && type === 'READY') || (!todo.done && type === 'DONE');

  const handleToggle = () => setTodos(todos.toggleDone(todo.id));
  const handleDestroy = () => setTodos(todos.delete(todo.id));
  const handleChange = (event: any) =>
    setTodos(todos.edit(todo.id, event.target.value));

  const [editing, setEditing] = useState(false);

  const handleEditEnd = () => {
    setEditing(false);
    setTodos(todos.edit(todo.id, todo.label.trim()));
  };

	return (...);
}
  • TodoList를 상태로 사용하고 있고 이를 업데이트 해주기 위해 setTodos 를 props로 내려준다.
  • setTodos(todos.delete(todo.id)) 와 같이 업데이트 메서드는 새로운 인스턴스를 반환해주고 이를 이용해 상태를 업데이트 할 수 있다!

내부 로직과 인터페이스를 명확히 구분해보자

비즈니스 로직을 구현함에 있어서 내부적으로 필요한 helper 함수들은 실제 API로 사용되는 함수들과는 다르게 접근을 막아야한다. 따라서 인터페이스를 정의하고 private 키워드를 사용해 이 둘을 분리했다.

export interface ITodoList {
  // TEST 전용
  updateAll: (date?: Date) => Promise<ITodoList>; // test용
  getSortedRTL: (today?: Date) => Promise<PlainTodo[]>; // test용
  getTL: () => PlainTodo[]; // test용
  // READ
  getActiveTodo: () => Promise<PlainTodo>;
  getSortedList: (type: 'READY' | 'WAIT' | 'DONE', compareArr: string[]) => Promise<PlainTodo[]>;
  getSummary: () => any;
  getTodoById: (id: string) => Promise<PlainTodo | undefined>;
  // CREATE, UPDATE, DELETE
  postponeTemporally: () => Promise<ITodoList>;
  postponeDeadline: () => Promise<ITodoList>;
  postponeForToday: () => Promise<ITodoList>;
  lowerImportance: () => Promise<ITodoList>;
  setDone: () => Promise<ITodoList>;
  updateElapsedTime: (elapsedTime: number) => Promise<ITodoList>;
  add: (todo: InputTodo) => Promise<ITodoList>;
  edit: (id: string, todo: InputTodo) => Promise<ITodoList>;
  remove: (id: string) => Promise<ITodoList>;
}
import { ITodoList } from '@core/todo/todoList.interface';

const topologySort = async (
  todoList: ITodoList,
  filter?: (todo: PlainTodo) => boolean,
): Promise<Map<string, DiagramTodo>> => {
  • 실제 사용할 때는 인터페이스를 타입으로 받는다!

왜 비동기여야 하나

자바스크립트는 싱글 스레드 언어이다.

  • 익히들 알고 있는 사실이다. 그래서 만약 웹에서 모든 코드들이 동기적으로만 일을 하게 된다면 최상단에 있는 것부터 로딩이 시작될 것이며, 중간에 끊지도 못해 다른 명령(ex. 마우스 클릭)도 실행하지 못하게 된다.

이전에는 I/O 혹은 network 관련 로직을 처리할 때 이외에는 전부 동기적으로 구현했었다. 즉, 비즈니스 로직을 비동기로 구현해본 경험이 없었다.

굳이 I/O, network와 관련이 없는 것들까지 비동기로 작성해야할까?라는 고민에 대해 팀원들과 함께 의견을 나눴고 다음과 같은 이유로 인터페이스로 제공되는 함수 전체를 비동기적으로 구현하기로 결정했다.

  • 확장성
    • 추후 비즈니스 로직에 DB와 같은 I/O 관련 로직이 추가될 수 있다.
    • 비동기 → 동기로의 변경은 자원이 적게 소모되지만 동기 → 비동기로의 변경은 자원이 많이 소모된다. 따라서 어차피 일어날 일이기에 미리 비동기로 구현을 하는 것이 이득이다.
  • 메모리 + 가독성 vs 동시성
    • 추후 데이터가 늘어났을 때 복잡도가 있는 비즈니스 로직을 실행시킨다는 가정하에 동시성을 달성할 필요가 있다.

위에 있는 인터페이스를 보면 전부 Promise 인스턴스를 반환하는 것을 알 수 있다.

구현과 사용 예시

export interface CUD {
  add: (label: string) => Promise<TodoList>;
  delete: (id: string) => Promise<TodoList>;
  edit: (id: string, label: string) => Promise<TodoList>;
  setDone: (id: string) => Promise<TodoList>;
  toggleDone: (id: string) => Promise<TodoList>;
  clearDone: () => Promise<TodoList>;
}
export const TodoItem = ({
  todo,
  type,
  todos,
  setTodos,
}: {
  todo: PlainTodo;
  type: FilterType;
  todos: TodoList;
  setTodos: React.Dispatch<React.SetStateAction<TodoList>>;
}) => {
  
	...

  const handleToggle = () => todos.toggleDone(todo.id).then(setTodos)
  const handleDestroy = () => todos.delete(todo.id).then(setTodos)
  const handleChange = (event: any) =>
    todos.edit(todo.id, event.target.value).then(setTodos)
	
	...

	return ...
}
  • 비동기 프로그래밍이 낯설게 느껴지지만 실제로 READ를 제외한 CUD에 대해서는 애초에 비동기적인 이벤트를 처리하는 핸들러단에서 사용되기 때문에 사용하는데 큰 문제는 없다.
  • 다만, READ 혹은 초기화 처리는 추가적인 상태와 useEffect 를 사용해야 한다.

참고. 비동기 처리

  • 콜백
    • 비동기 처리 결과를 외부에 반환하지 못함
    • 비동기 처리 결과를 상위 스코프의 변수에 할당하지 못함
    • 여러개의 비동기 처리를 하는 경우, 순서가 보장되지 않는 문제
    • 콜백함수를 다른 함수로 전달하는 순간 그 함수에 대한 제어권을 잃게되는 문제
  • Promise
    • Promise는 비동기 상황을 일급 객체로 다룬다. 즉, 값으로 다루어진다는 뜻이고 변수에 할당 혹은 함수로 전달할 수 있다.

    • Promise 가 중첩되어도 한 번의 then 을 통해 결과물을 꺼낼 수 있다

      • 타이밍을 조절할 수 있다. resolve 가 호출 된 순간을 포착하는 것.

      • then 안에서 또 return한다면 다시 Promise 인스턴스를 반환한다(체이닝).

        Promise.resolve(1).then(console.log) 
        // 1을 출력하고 undefined를 값으로 가지고 있는 pending promise 반환
    • 요약

      • 콜백의 호출 시점은 then 으로 등록된 순서대로 실행
      • 콜백이 호출되지 않은 경우 → Promise.race
      • 콜백의 제어권을 잃어도 값만큼은 지킨다

DB와 연결해볼까?

DB와 연결하기 전 다음과 같은 고민들이 있었다.

  • DB와의 동기화는 컴포넌트, 비즈니스 로직 둘 중 어디에서 이루어져야 하는가?
  • 확장성
    • 백엔드까지와의 확장성을 고려해야한다!
    • 그러나, 백엔드와의 다른 점이 존재하는데 이를 어떻게 추상화해야할까?
      • 백엔드는 DB가 초기화 되어 있다.
      • localStorage를 사용한다면 문제 없겠지만 메모리 혹은 IndexedDB는 초기화를 시켜줘야한다.
    • 의존성 주입이 가능하도록 해야한다. configurable 해야한다
      • 이에 관한 이야기는 다른 개발일지에서 다루도록 하겠다.

그리고 다음과 같은 결론을 내렸다.

  • 초기화를 해야한다는 점과 동기화를 해야하는 시점을 정하는 것이 어렵다는 점으로부터 초기화된 DB 인스턴스를 생성 후 TodoList에 주입해주는 팩토리 패턴을 사용하고 비즈니스 로직 안에 DB의 동기화 로직도 포함시키자
  • 의존성 주입을 위한 DB의 인터페이스를 정의하자

다음은 구현한 코드의 일부분이다.

import { PlainTodo, InputTodo } from '@todo/todo.type';

export interface ITodoListDataBase {
  // Basic
  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의 인터페이스다!
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');
};
  • 팩토리 패턴을 이용해 생성 부분을 클라이언트에게 노출시키지 않을 수 있다!
export class TodoList {
  private readonly db: ITodoListDataBase;
  private readonly todoList: Todo[];
	constructor(db: ITodoListDataBase, todoList?: InputTodo[]) {
    this.db = db;
    this.todoList = todoList?.map((el) => new Todo(el)) ?? [];
  }

	...
	
	async add(todo: InputTodo): Promise<TodoList> {
    const newTodo = new Todo(todo);
		
		...

    await this.db.add(newTodo.toPlain());
    const newTodoList = await this.db.editMany([...changedTodoSet].map((el) => ({ id: el.id, todo: el.toPlain() })));
    return new TodoList(this.db, newTodoList);
  }
  • 비즈니스 로직안에 DB와의 동기화 로직이 포함되어 있는 것을 확인할 수 있다.

요약

  • 빠른 MVP 구현, 생산성을 고려해 클래스로 비즈니스 로직을 구현했다.
  • 확장성 및 사용자 경험을 위해 비동기 인터페이스를 구현했다.
  • 백엔드까지의 확장을 고려한 DB 인터페이스를 구현했다.

해결해야할 문제

  • 의존성 주입은 객체의 생성 방식을 별도의 configuration 파일에서 지정할 수 있도록 사용되는 테크닉이다. 추후 백엔드의 DB 인스턴스를 갈아끼워넣기 위해 이와 같은 방법을 사용했지만 문제와 해결이 정확히 대응되지 않는다. 프론트엔드에서 어떤 DB를 사용할지 지정하기 위해 의존성 주입을 사용한 것이 아니기 때문이다.
  • MVP를 구현하면서 추후 연결할 백엔드까지 고려한 인터페이스 설계였지만, 구현 방식과 실제로 사용한 indexedDB가 그 자체로 작은 백엔드가 되버렸다고 할 수 있다.

앞으로의 방향

  1. TodoList API 전체를 백엔드로 옮기기
  2. DB interface와 비즈니스 로직 interface를 아예 분리하기

💊 비타500

📌 프로젝트

🐾 개발 일지

🥑 그룹활동

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