
엘리스 부트캠프 수료 후 바로 취업 전선에 뛰어들려 했으나.. 어떻게 하다보니 AI 해커톤에 참여하게 되었고.. 이것만 하고 진짜 취준한다.. 했는데 원티드 프리온보딩 인턴십 프로그램을 보게 되었다. 커리큘럼이 너무나 마음에 들었다. 이 교육을 들으면 스킬업을 할 수 있고 코드 퀄리티를 높일 수 있을 것 같았다.
그래서 프리온보딩 인턴십 지원을 결심하고 사전과제를 살펴보았다.
사전과제는 투두리스트를 구현하는 것이였고, 이건 껌이네? 하는 오만방자한 생각이 들었다. 근데 내가 투두리스트 구현하고 뿌듯해서 회고글을 적게 될 줄 몰랐죠..
개발을 시작하면 응당 첫 앱은 투두리스트가 국룰인 것을.. 아니 전세계 룰이잖아요?! 지원자가 100명이라면 100명 다 기능 구현을 할 수 있을터인데, 그럼 이걸로 어떻게 평가하고 경쟁력을 가질 수 있다는 걸까요?!
코드 퀄리티를 높여야지!
그럼 코드 퀄리티는 어떻게 높여야하나요?
코드 퀄리티를 높이는 방법은 수만가지이지만,
나는 이번에 최근 관심을 갖고 있었던 리액트의 커스텀훅, ContextApi 등을 사용하여 관심사 분리를 하고, 유지보수성을 높이고 싶었다.

땡큐 지피티!
프론트엔드는 관심사가 너무 많다.. 인터페이스, 데이터베이스 접근, 비즈니스 로직 등등.. 이러한 관심사에 따라 코드를 구분하여 작성해 놓으면, 나중에 기능 수정한다고 이 파일 저 파일 배회하지 않고, 딱딱 필요한 로직만 찾아 수정할 수 있도록 할 수 있다는 것이다.
나는 이번 과제를 통해 지피티가 말해 준 관심사 분리의 특징 중 모듈화, 유지보수성, 확장성을 몸소 느낄 수 있었다.

"투두 리스트 추가 버튼을 클릭하면 버튼 이벤트를 감지하고, 새로운 투두 항목을 생성한 다음 돔요소를 추가해!"
라고 코드가 명령하는 것이 아니라,
"투두리스트 목록을 보여줘"
라고 요청하면 리액트 스스로 능동적인 결정을 할 수 있도록 하는 것이다.
관심사 분리를 위해서 이 개념이 중요하다고 생각한 이유는 코드의 관심사가 분리되면 주로 모듈화, 추상화된 함수를 많이 사용한다. 따라서 코드 실행에 대한 단계별 과정을 묘사하는 명령형 코드 보다는 원하는 결과를 명시하고 시스템이 그 결과를 달성하도록 하는 선언적 코드로 작성해야 하는 것이 근본이라 생각한다.
더불어 선언형 코드 작성시 주의해야할 점은 한 함수에는 작은 기능 단위로 기능을 분리하여 복잡성을 줄이고, 그 함수명이나 변수명 등으로 코드가 어떤 작업을 수행하는지 의도를 분명히 표현 해야한다. 리턴타입을 명시하는 것도 좋은 방법이다.
관심사를 분리할 수록 관련된 코드는 모듈화 된다지만 동시에 추상화되어, 함수가 애매한 동작을 하게되면 결국 개발자는 관련 모듈 코드를 다시 뜯어 봐야하고 그와 관련된 상태도 다시 보는 사태가 일어난다. 이러면 관심사 분리의 의의가 쇠퇴되겠죠?!
몇몇 아티클과 코드들을 찾아봤는데 리액트의 커스텀 훅을 사용해서 코드를 모듈화
하는 것이 큰 특징이었다.
나는 이전까지는 커스텀 훅과 유틸함수의 개념을 헷갈려 했었는데 이번 과제를 통해 어느정도 차이점을 깨닳은 것 같다.
내가 이해한 차이점은 다음과 같다.
커스텀 훅 - 비즈니스로직을 추상화하고 재사용성을 높이기 위해 사용
유틸 함수 - 아주 작은 기능 단위의 자주 쓰이는 함수 ex) 문자열 조작, 숫자 계산, 배열 처리 등
덧붙여 리액트에서 커스텀 훅은 use를 접두사로 가진 함수로 작성되고 관련된 컴포넌트에서 호출하여 사용할 수 있다. 이를 통해 컴포넌트 간 공통 로직을 공유하고, 코드의 중복을 줄일 수 있다는게 큰 특징이다.

다시 한번 땡큐..
내 방식으로 정리해 보자면
Context는 상태를 관리하는 큰 문맥을 만드는 것
Dispatch는 상태를 변경하라고 명령하는 것
Reducer는 disptach 요청에 따라 상태를 업데이트 하는 것
Provider는 조작된 상태를 원하는 곳에 제공하는 것
이라고 말할 수 있겠다.
이번 과제에서는 Context API를 통해 전역 상태관리를 진행하였다.
useState로만 상태를 관리하게 되면 Props drilling이 불가피할 것 같았고, 이에 따라 컴포넌트 파일 안에서 데이터를 조작하는 코드를 작성하게 될 것 같았다. 또 추후 더 큰 프로젝트를 진행할 경우 상태관리를 어떻게 해야지 사이드이펙트를 줄일 수 있고 유지보수가 쉬워질지 감을 잡아보고 싶었기 때문이었다.
우선 코드를 짜기 전 다시 한 번 목표를 상기시켜보고 나만의 챌린지를 정해보았다.
근본적인 목표
유지보수하기 쉬운 클린코드를 짜는 것!
챌린지 1.
회원가입, 로그인 폼, 컴포넌트, 유효성 조건이 동일하기 때문에 동일한 컴포넌트, 비즈니스 로직을 재사용할 것
챌린지2.
투두리스트의 CRUD 로직을 명확하게 구분하고 데이터 패칭/가공, UI 컴포넌트 로직, 렌더링 로직을 명확히 모듈화 하고 사용할 것
챌린지3.
타입, 상수, 유틸을 따로 관리하여 따로 함수 내 코드를 보지 않고도 수정할 수 있게 할 것

(잘.. 안보이죠..? 이걸로 봐보세요.. 링크)
복잡해 보이기는 하지만 나중에 다른 프로젝트 설계할 때 참고하려고 최대한 구체적으로 그려보았다. 이런 구조로 설계하는 것이 정답이라고는 할 수 없지만, 몇 번의 리팩토링으로 개선한 결과 이 틀 안에서 짜잘한 함수들을 리팩토링하고 관리하는 것이 사이드 이펙트를 최소화 할 수 있었고, 내가 작성한 코드를 다시 읽기에도 편했다.
이 외에 api 클라이언트를 관리하는 모듈, 상수, 타입, 유틸, 스타일 관리 구조도 추가 되어야 하겠지만 일단 비즈니스 로직 관리 중점으로 앱구조를 그려보았다.
이걸 그리면서 생각한건데.. 설계하는 시점부터 그리면 더더더더더 유용할 것 같다. 특히 팀프로젝트 할 때.
좀 더 자세한 코드 구조를 위에서 부터 정리해 보겠다.
앞으로의 예시는 CRUD 기능이 모두 포함 된 투두리스트를 기준으로 작성해 보겠다..!

우선 페이지 파일을 살펴보자
//pages/Todo.tsx
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import TodoForm from '../components/todo';
import { TodoProvider } from '../contexts/TodoContext';
import ROUTES from '../constants/routes';
import todoStyles from '../styles/Todo/todo.module.scss';
import Header from '../components/common/Header';
import useAuth from '../hooks/useAuth';
function Todo() {
const navigate = useNavigate();
const { getLoginState } = useAuth();
useEffect(() => {
if (!getLoginState()) {
navigate(ROUTES.SIGNIN);
}
});
return (
<main>
<Header />
{getLoginState() ? (
<div className={todoStyles.wrap}>
<h1 className={todoStyles.title}>✏️ Todo List</h1>
<TodoProvider>
<TodoForm />
</TodoProvider>
</div>
) : undefined}
</main>
);
}
export default Todo;
페이지에서는 리액트의 useNavigate 훅과 getLoginState() 커스텀 훅을 사용하여 로그인 여부에 따라 페이지를 렌더링하거나 로그인 페이지로 리다이렉트한다. (이 부분은 직접적으로 라우팅을 처리하는 App.tsx 파일에서 관리해보려 했으나.. 뭔가 잘 안됐다 ㅠㅠ 깜빡임이 심하다던지, 로그인 여부를 감지 못한다던지..)
모든 페이지의 공통 컴포넌트인 헤더 컴포넌트가 렌더링 된다.
해당 페이지의 메인 컴포넌트인 TodoForm 컴포넌트가 렌더링 된다.
Provider로 TodoForm 컴포넌트에 Todo Context를 제공한다.
이렇게 해서
페이지 파일에서는 로그인 여부에 따른 처리, 전 페이지의 공통 컴포넌트 삽입, Provider 삽입, 해당 페이지의 메인 컴포넌트 삽입의 코드가 작성될 것을 정의했다.
다음은 위의 3번에서 삽입한 메인 컴포넌트를 살펴보겠다.

//components/todo/index.tsx!
import React, { useEffect } from 'react';
import Create from './Create';
import TodoList from './List';
import useTodoList from '../../hooks/useTodoList';
import todoStyles from '../../styles/Todo/todo.module.scss';
function TodoForm() {
const { getTodos } = useTodoList();
useEffect(() => {
getTodos();
}, []);
return (
<div className={todoStyles.formContainer}>
<Create />
<TodoList />
</div>
);
}
export default TodoForm;
상위 컴포넌트 구조는 비교적 간단하다.
getTodos() 커스텀 훅을 사용하여 해당 컴포넌트가 렌더링 될 때 마다 get 패칭을 하고 전역 상태를 업데이트한다. Create를 렌더링한다.TodoList를 렌더링한다.이렇게 해서
메인 컴포넌트(index)에서는 하위 컴포넌트 렌더링, 사용될 데이터 패칭 작업을 하는 것으로 정의했다. 추가로 auth 관련 메인 컴포넌트에서는 전체 폼을 submit 하는 이벤트 로직이 추가된다.
다음은 이벤트 관련 로직이 포함된 컴포넌트 코드 구조이다.

투두 리스트의 하위 컴포넌트 중 List로 살펴보겠다.
// components/todo/List.tsx
import React, { useEffect, useState } from 'react';
import * as TodoType from '../../interface/Todo';
import Item from './Item';
import todoStyles from '../../styles/Todo/todo.module.scss';
import { useTodoState } from '../../contexts/TodoContext';
function TodoList() {
const todoState = useTodoState();
const [todoList, setTodoList] = useState<TodoType.Item[]>(todoState);
useEffect(() => {
setTodoList(todoState);
}, [todoState]);
const handleSetTodoList = (id: number, value: string) => {
setTodoList((prev) => prev.map((item) => (item.id === id ? { ...item, todo: value } : item)));
};
return (
<ul className={todoStyles.ulContainer}>
{todoList.map((todoItem: TodoType.Item) => {
const { id } = todoItem;
return (
<li className={todoStyles.listContainer} key={`todo-${id}`}>
<Item setTodoList={handleSetTodoList} item={todoItem} />
</li>
);
})}
</ul>
);
}
export default TodoList;
이 컴포넌트 부터 본격적으로 컴포넌트 다운(?) 컴포넌트를 그리고 있다(?)
1. 전역적으로 관리되는 todoState를 불러오고 지역적으로 관리 될 todoList 상태를 업데이트한다.
여기서 todoList를 다시 지역 관리하는 이유는 각각 수정되는 투두 아이템들을 관리하기 위해서이다. 여기서 직접적으로 디스패치를 사용해서 전역 상태를 업데이트할 수 있겠으나, 데이터 무결성을 위해 전역 상태 업데이트는 무조건 커스텀 훅을 통해서 데이터 패칭 상태와 동일하게 관리되도록 하고 싶었다.
handleSetTodoList로 수정 되는 투두 항목을 세팅한다.todoList 상태를 맵핑하여 돔요소와 아이템 컴포넌트를 동적 렌더링한다.다음은 리스트의 하위 컴포넌트인 Item 컴포넌트이다.
// components/todo/Item.tsx
import React, { useState } from 'react';
import useTodoList from '../../hooks/useTodoList';
import * as TodoType from '../../interface/Todo';
interface ItemProps {
setTodoList: (id: number, value: string) => void;
item: TodoType.Item;
}
export default function Item({ setTodoList, item }: ItemProps) {
const { id, todo, isCompleted } = item;
const { updateTodo, deleteTodo } = useTodoList();
const [isEditMode, setIsEditMode] = useState(false);
const [editedValue, setEditedValue] = useState(item.todo);
const handleIsCompletedChange = () => {
updateTodo(id, todo, !isCompleted);
};
const handleEditClick = () => {
setIsEditMode(!isEditMode);
};
const handleRemoveClick = () => {
deleteTodo(id);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setEditedValue(value);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editedValue !== '') {
updateTodo(id, editedValue, isCompleted);
setTodoList(id, editedValue);
}
setIsEditMode(!isEditMode);
};
const handleCancelClick = () => {
setIsEditMode(!isEditMode);
setEditedValue(item.todo);
};
return (
<label htmlFor={id.toString()}>
{!isEditMode ? (
<div>
<div>
<input
id={id.toString()}
type="checkbox"
checked={isCompleted}
onChange={handleIsCompletedChange}
/>
<span>{todo}</span>
</div>
<div>
<button
type="button"
data-testid="modify-button"
onClick={handleEditClick}
>
수정
</button>
<button
type="button"
data-testid="delete-button"
onClick={handleRemoveClick}
>
삭제
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit}>
<input
id={id.toString()}
type="text"
data-testid="modify-input"
value={editedValue}
onChange={handleInputChange}
/>
<div>
<button
type="submit"
data-testid="submit-button"
>
제출
</button>
<button
type="button"
data-testid="cancel-button"
onClick={handleCancelClick}
>
취소
</button>
</div>
</form>
)}
</label>
);
}
이 파일에는 수정모드일 때와, 기본 리스트 모드일 때 돔 요소 렌더링 코드가 그려져 있어 코드가 길다..
그래도 생각보다 구조는 복잡하지 않다...!
위 함수들을 간략하게 정리해 보자면 다음과 같다.
item prop으로 데이터를 렌더링한다.이벤트 핸들러 함수는 useTodoList() 커스텀 훅을 사용하여 데이터를 패칭하고 전역 상태를 업데이트 하거나, 수정 관련 지역 상태를 변경하고 상위 컴포넌트에서 관리되는 지역 상태인 todoList를 업데이트한다.수정 관련 지역 상태에 따라 알맞게 돔 요소를 렌더링한다.마지막으로
하위 컴포넌트파일의 역할을 정리하자면, 컴포넌트 렌더링 조건에 따라 지역 상태 관리, 이벤트 핸들러 관리, 렌더링 되는 작은 컴포넌트 코드를 관리하는 것으로 정의할 수 있겠다.
여기서 작은 포인트는 이벤트 핸들러 함수를 직접 삽입 (
onChange={()=>{}})하지 않고 상위에 따로const로 선언해서 관리하는 것이다.
이렇게 하면 렌더링 되는 돔요소를 확인하지 않고도 상단의 코드만 보고 로직을 예측할 수 있고, 핸들러 내 코드가 길어질 때 손쉽게 추가할 수 있으며, 지역 상태 혹은 이벤트 핸들러간의 상관관계도 확인하기 쉽다.
주의해야할 점은 개발자는 추상화에 대한 이해가 필요하다. updateTodo(), deleteTodo() 커스텀 훅이 내부에서 어떤 동작을 하는지 (여기서는 데이터를 패칭하고 전역 상태를 업데이트한다.) 명확하게 이해하고 있어야 적재적소에 해당 훅을 사용할 수 있다.
관심사 분리의 핵심인 커스텀 훅 코드를 살펴보겠다!

// hooks/useTodoList.ts
import { useTodoDispatch } from '../contexts/TodoContext';
import * as todoFetcher from '../api/todoFetcher';
const useTodoList = () => {
const todoDispatch = useTodoDispatch();
const getTodos = async () => {
try {
const res = await todoFetcher.getTodos();
todoDispatch({ type: 'GET', payload: res.data });
} catch (err: any) {
alert(err.message);
}
};
const createTodo = async (value: string) => {
try {
const res = await todoFetcher.createTodo({ todo: value });
todoDispatch({ type: 'CREATE', payload: res.data });
} catch (err: any) {
alert(err.message);
}
};
const updateTodo = async (id: number, value: string, isCompleted: boolean) => {
try {
const req = { todo: value, isCompleted };
const res = await todoFetcher.updateTodo(id, req);
todoDispatch({ type: 'UPDATE', payload: res.data });
} catch (err: any) {
alert(err.message);
}
};
const deleteTodo = async (id: number) => {
try {
await todoFetcher.deleteTodo(id);
todoDispatch({ type: 'DELETE', payload: id });
} catch (err: any) {
alert(err.message);
}
};
return {
getTodos,
createTodo,
updateTodo,
deleteTodo,
};
};
export default useTodoList;
CRUD 기능에 따라 데이터 상태가 명확한 투두 커스텀 훅 코드는 비교적 단순 명확하다. (회원폼 관련 커스텀 훅은 회원 폼의 구분에 따라 상태 관리도 해야하고, 필드의 구분에 따라 유효성 검사 결과가 포함되어 더욱 복잡하다.)
각 함수는 다음의 역할을 한다.
fetcher 함수를 통해 비동기 통신의 결과 값을 받는다.dispatch로 서버로 부터 전달 받은 결과 값과, 인자로 받은 부가 정보를 페이로드로 전달해 전역 상태를 업데이트 한다.
Custom Hook에서는 데이터 패칭, 데이터 패칭에 따라 디스패치 호출, 상태 변경에 따라 변경되는 비즈니스 로직 관련 상태 리턴을 하는 함수들이 관리된다고 정의할 수 있다.
마지막으로 전역 상태를 직접적으로 관리하는 context를 살펴보겠다!

// contexts/TodoContext.tsx
import React, { Dispatch, ReactNode, createContext, useContext, useReducer } from 'react';
import * as TodoType from '../interface/Todo';
type TodoAction =
| { type: 'GET'; payload: TodoType.Item[] }
| { type: 'CREATE'; payload: TodoType.Item }
| { type: 'DELETE'; payload: number }
| { type: 'UPDATE'; payload: TodoType.Item };
interface TodoProviderProps {
children: ReactNode;
}
const initialState: TodoType.Item[] | [] = [];
const TodoStateContext = createContext<TodoType.Item[] | undefined>(undefined);
type TodosDispatch = Dispatch<TodoAction>;
const TodosDispatchContext = createContext<TodosDispatch | undefined>(undefined);
const todoReducer = (todos: TodoType.Item[], action: TodoAction): TodoType.Item[] => {
switch (action.type) {
case 'GET':
return action.payload;
case 'CREATE':
return [...todos, action.payload];
case 'DELETE':
return todos.filter((todo) => todo.id !== action.payload);
case 'UPDATE':
return todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, todo: action.payload.todo, isCompleted: action.payload.isCompleted }
: todo
);
default:
return todos;
}
};
export function TodoProvider({ children }: TodoProviderProps) {
const [todoState, dispatch] = useReducer(todoReducer, initialState);
return (
<TodosDispatchContext.Provider value={dispatch}>
<TodoStateContext.Provider value={todoState}>{children}</TodoStateContext.Provider>
</TodosDispatchContext.Provider>
);
}
export function useTodoState() {
const state = useContext(TodoStateContext);
if (!state) throw new Error('TodosProvider not found');
return state;
}
export function useTodoDispatch() {
const dispatch = useContext(TodosDispatchContext);
if (!dispatch) throw new Error('TodosProvider not found');
return dispatch;
}
ContextApi를 직접 사용해본건 처음이라 보일러플레이트를 이해하는데 시간이 좀 많이 걸렸다🥹
그래도 한 번 세팅해 놓으니 코드를 수정하기에는 정말 쉽다.
여기서는 Reducer에서 action.type과 전달받은 payload에 따라 todo의 상태를 변경하는 것이 전부이다.
추가로 Provider를 구현할 때 일반적으로는 state와 dispatch를 하나의 provider로 전달하는데, 여기서는 dispatch.provider로 한 번 더 감싸고 따로 value를 전달해서 추후 코드 수정이나 임포트하기 편하게 구현했다.
정말 아쉬운 점은 여기서 부터 타입을 모듈화하는 것이 쉽지 않았다. Action 관련 타입이나, Props 타입은 어떻게 정리하는거예요!!
결론은 context 파일에서는 ContextAPI 관련 보일러플레이트를 작성하고 Reducer에서 Action.type에 맞는 상태 변경 로직을 구현하고 관리한다.
관심사 분리를 하며 코드 추상화 하는 것이 핵심이다 보니 추상화 된 코드의 파악이 중요하고, 코드를 자아아알 추상화하고 데이터타입을 명확하게 명시하는게 정말 중요할 것 같다. 또한 모듈화한 함수들을 내가 아닌 다른 개발자들도 잘 사용하고, 사용하고 싶게 만드는 것, 모듈화한 근거를 갖는 것이 중요하다는 생각도 들었다.
팀 프로젝트를 할 때는 설계단계 부터 어떻게 관심사를 분리해 나갈건지 의논 하고 추상화 단계를 정하면서 같은 페이지에서 시작할 수 있게 하는 것이 핵심일 것 같다.
그리고 삽질하지 말고 모든 Form에는 유효성 검사 결과 또한 포함 되어야 한다는 것을 유념하고 상태 관리 로직을 짜자.. (설계가 부족한 상태로 코드를 짰더니 처음엔 회원 폼 관련 로직 짤 때 인풋에 따라 유효성 검사 상태가 따로 놀았었음☠️)
관심사 분리라는 개념을 접하고 구현하면서 처음으로 커스텀훅을 구현해보고, ContextAPI 사용해 봤다. 이전까지는 이런 개념들을 알고 있었으나, 얼마나, 왜 중요한지 와닿지도 않았었고 코드가 너무 복잡해 보여서 지레 겁먹고 원래 하던 방법으로만 코드를 작성했었다. 근데 이번 기회로 직접 관심사 분리를 해보고, 수십번 리팩토링을 하면서 관심사 분리, 클린코드, 선언형 프로그래밍의 장점과 개념을 몸소 깨닳으니 지금까지 기능 구현에만 급급해서 코드를 짜왔던 지난 멍청한 날을 반성하게 된다.😇
진짜 x100000로 이런 개념을 알고있고, 직접 고민하고 코드를 작성해 봤다는 것이 정말 큰 자산이 될 것 같다.
처음엔 원티드 프리온보딩 인턴십 프로그램에 참여하고 싶어서 시작한 과제이지만 솔직히 선발되지 않더라도 이번 과제를 통해 많은 개념을 체화하고 스스로 성장한 느낌이라 과제를 한 것 만으로도 유의미한 시간을 보냈다고 생각한다. 근데 커리큘럼을 보니 진짜 이것 까지 수료하면 미친듯이 성장할 것 같아 너무 욕심난다 ㅋㅋㅋ
그리고 나름 쉴틈없이 고민하며 리팩토링했지만 지금 이 회고글을 작성하면서도 여전히 부족한 점이 보인다. 클린코드의 길은 멀고도 험하구나..!
찾아보니 더 작은 단위로도 커스텀 훅을 만들기도하고 리액트 쿼리처럼 데이터 패칭 관련 커스텀 훅을 만들기도 하는 것 같은데 이것도 꼬옥.. 꼬옥 해볼거야.. 몸이 두개 였으면 ... 하루가 48시간이였으면 좋겠다.. ㅎㅎ
개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.