-
Notifications
You must be signed in to change notification settings - Fork 1
TodoList API 개발기
다음과 같은 고민으로 시작해 함수로 구성할 것인지 클래스로 구성할 것인지에 대한 고민으로 그 범위를 줄였다.
- 데이터와 로직을 어떻게 응집시킬 것인지
- 단위 테스트한다고 했을 때 단위를 얼마나 어떻게 분리해야 할지
- 의존성이 추가되는 것을 상정해 어떻게 확장성 있게 설계할 것인지
분류 \ 로직 | 모듈(함수) | 클래스(메서드) |
---|---|---|
데이터 | 매개변수로 접근 | 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가 초기화 되어 있다.
- 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가 그 자체로 작은 백엔드가 되버렸다고 할 수 있다.
앞으로의 방향
- TodoList API 전체를 백엔드로 옮기기
- DB interface와 비즈니스 로직 interface를 아예 분리하기
- OaO 환경설정 A to Z
- CRLF 너가 뭔데 날 힘들게 해?
- Github Issue 똑똑하게 사용하기
- OAO! CI CD 적용기 with release 자동화
- 매번 다른 import문
- 못생긴 상대경로에서 간zlzl존 절대경로로😎
- TodoList API 개발기
- 의존성 주입으로 DB를 바꿔보자
- 렌더링 최적화 서막: useNavigate를 추가한 순간 리렌더 범위가 확장된 건에 대하여
- 렌더링 최적화 1탄: 렌더링 범위에 대하여 (by 최적화무새)
- 렌더링 최적화 2탄: 잘못된 custom hook 사용,, 전체 리렌더링을 부르다,,
- 렌더링 최적화 3탄: Todo 상세 좀 봤다고 테이블 전체가 재렌더링 되는건을 고치기😌
- 렌더링 최적화 4탄: 다이어그램 편
- 🐁 마우스 상대위치 계산은 이상해
- React 컴포넌트에 애니메이션을 적용해보자 🏃🏻💨
- 컴포넌트 재사용성을 높여보자: Modal 분리기 🌹
- 선후관계를 자동완성으로 추가해보자 🔎