원티드 프리온보딩 인턴십 교육 (11차) 사전과제 주제는 투두리스트 만들기였습니다.
간단한 투두리스트 라고 생각할 수 있지만, 뭐든지 간단한만큼 신경쓸게 더 많다는 사실.. 방심하지 않고 핵심 기능들을 최대한 깔끔하게 구현해보자 라는 생각으로 임해보았습니다.
여담이지만 .. 왜 저는 항상 팀장이 되는걸까요 ..? 흑..
기본적으로 진입 루트가 회원가입 -> 로그인 -> 투두리스트 였고, 기능 구현에 연관된 라이브러리는 사용하지 않는것이 기본 규칙이었습니다.
팀빌딩이 이뤄지고 진행되는 1주차 팀 과제는 BestPractice 를 선정하여 사전과제를 리팩토링 하는것이었습니다.
Best Practice란 모범사례라는 말로서, 특정 문제를 효과적으로 해결하기 위한 가장 성공적인 해결책 또는 방법론을 의미합니다.
쉽게말해 사전과제의 핵심 기능을 파트별로 나눠, 가장 잘 작성된 코드를 선정해 선정된것들로만 이뤄진 프로젝트를 완성해 제출하는 것이 1주차 과제였습니다.
1주차 세션에서 진행했던 협업을 위한 툴들을 적용시키기 위해 ES Lint 와 Prettier, 그리고 husky를 사용하여 포멧팅을 자동화 하도록 진행했고,
너무 세분화하여 진행하지 않고 큰 부분들만 나눠서 불필요한 딜레이를 줄이는것을 목표로 진행되었기 때문에 고민사항은 크게 3가지로 분류하여 BestPractice 를 선정했습니다.
(진행은 팀별 디스코드 채널,노션을 생성해 회의와 회의내용을 정리하여 기록하며 진행했습니다.)
개인으로 진행했던 사전과제의 경우에는 파일트리를 프로젝트 전체 깊이가 깊어지더라도 세분화하여 폴더를 생성하여 각 폴더별로 메인이 될 수 있는 컴포넌트를 index.tsx 로 생성하여 관리하는 구조를 택했습니다.
해당 구조가 개인적으로 각각의 컴포넌트에 대한 작명만 잘한다면 도서관의 원하는 도서 찾기처럼 크게 불편함이 없는 상태로 세분화를 할 수 있겠다는 생각이 들었습니다.
이전에 진행했던 개인 프로젝트는 아무래도 하나하나를 세분화 하다보니 전체적으로 깊이가 너무 깊어져서 오히려 가독성을 더욱 해칠 수 있겠다는 생각이 들었기 때문에 이번 프로젝트는 진행하면서 자식 컴포넌트 (HeaderMenu 의 Menu 같은)는 따로 폴더를 생성하지 않고 해당 컴포넌트의 이름으로 네이밍을 하는 방식으로 진행하였습니다.
팀과제로 진행된 프로젝트의 경우에는 일단 컴포넌트 구현에 앞서서 정확한 파일트리와 인증로직 구현을 진행 후에 컴포넌트를 구현하는게 맞다고 생각했기 때문에 파일트리를 먼저 정하게 되었습니다.
전체적으로 저는 백엔드를 학습하다가 프론트엔드를 시작했었던지라, 백엔드 프로젝트를 진행할 땐 항상 확장에 대한 가능성을 열어둔 채로 프로젝트를 진행했기 때문에 최대한 쪼갤 수 있는 부분은 쪼개고 각각의 모듈에 대해서 관심사를 분리하는것을 메인으로 삼고 진행했으나,
공통적으로 프론트엔드 개발자분들은 대부분 현재 진행하는 프로젝트의 규모에 따라 방식을 정하는듯 하셨습니다.
저희 팀의 경우 현재 진행해야 하는 투두리스트 또한 규모 자체가 크지 않았기 때문에 과한 세분화는 지양하고 최대한 커뮤니케이션에 문제가 없게끔 파일트리를 정의하여 깔끔한 프로젝트를 진행하기로 결정되었습니다.
여기에 api로직에 대한 부분들은 저의 방식을 BestPractice 로 선정해 apis 폴더에 각 컴포넌트 별로 비즈니스로직을 분리하여 선언하도록 구현했습니다.
해당 부분에 있어서는 기본적으로 큰 부분 (컴포넌트들,api들, 페이지들 등) 으로 나누어 폴더를 생성 후 각 부분에 맞춰서 필요시에 폴더를 생성하고, 큰 이유가 없다면 파일만 생성하여 관리하는 구조를 택했고, 전체적으로 파일 트리는 아래와 같이 정의되었습니다.
인증 로직의 경우에는 기본적으로 요청시에 헤더에 토큰을 포함시키고, 토큰이 만료되거나 비밀번호가 옳지 않을때는 공통적으로 401에러를 반환하기 때문에 axios interceptor 로 분리하여 로직을 구현할 수 있겠다는 생각이 들었습니다.
이에 대해서 들었던 생각은 두가지였습니다.
이번 과제의 경우에는 메인 기능을 구현하는데에 있어서 관련된 라이브러리를 설치하여 사용하는것이 금지되었기 때문에 ContextAPI 를 사용하여 구현하는것을 생각했습니다.
하지만 Axios Interceptor 의 경우에는 해당 로직 내부에서 hook사용이 불가능했기 때문에 구현에 있어서 문제가 발생했습니다.
따라서 Axios Interceptor 에서는 매 요청시마다 로컬스토리지에 저장된 값을 확인이 가능했기 때문에 로컬스토리지에서 토큰을 찾아서 같이 포함시켰고, Context API는 인증 여부에 따른 라우팅을 담당하는데에 사용하였습니다.
단순히 라우팅시에도 localStorage 에서 값을 찾아오면, 해당 변수에 저장된 boolean 값이 계속 업데이트 되는것이 아니기때문에 제대로된 구현이 어려웠습니다.
+@ 로 useEffect를 사용하여 navigate시에는 블링크 현상이 발생했기 때문에 신경쓸 부분이 많았습니다. (예: 토큰이 없는 상황에서 Todo로 라우팅시에 SignIn 컴포넌트로 네비게이트)
하나의 기능에 대한 구현을 여러 방법으로 구현하는것 처럼 보일 수 있었지만, 이렇게 지속적으로 인증 상태가 변경되는 부분을 감시해야하는 상황에서는 Context API 를 사용했고, Interceptor 에서는 LocalStorage 의 토큰 유무를 매 요청마다 확인하여 반영할 수 있었기 때문에 단순히 getItem 으로 유무를 확인 후 포함하도록 구현했습니다.
export function setInterceptors(instance: AxiosInstance) {
// 요청 인터셉터 추가
instance.interceptors.request.use(
(config) => {
config.headers["Content-Type"] = "application/json";
if (getTokenFromLocalStorage) {
config.headers["Authorization"] = `Bearer ${getTokenFromLocalStorage()}`;
}
return config;
},
(error) => {
interceptorErrorHandler(error);
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
interceptorErrorHandler(error);
return Promise.reject(error);
}
);
return instance;
}
function App() {
const tokenState = useTokenState();
return (
<StyledContainer>
<Header/>
<Routes>
<Route path={"/"} element={tokenState.accessToken ? <Navigate to={"/todo"}/> : <Navigate to={"/signin"}/>}/>
<Route path={"/signup"}
element={tokenState.accessToken ? <Navigate to={"/todo"}/> : <SignUp/>}/>
<Route path={"/signin"}
element={tokenState.accessToken ? <Navigate to={"/todo"}/> : <SignIn/>}/>
<Route path={"/todo"}
element={tokenState.accessToken ? <Todo/> : <Navigate to={"/signin"}/>}/>
<Route path={"/signout"}
element={tokenState.accessToken ? <SignOut/> : <Navigate to={"/signin"}/>}/>
<Route path={"*"} element={<NotFound/>}/>
</Routes>
</StyledContainer>
);
}
저희 팀원들 같은 경우에는 리액트 라우터의 createBrowserRouter를 사용하여 구현하셨고, ContextAPI를 사용하시지 않고 단순히 localStorage 에서 토큰의 유무를 확인하고 요청에 포함시키도록 구현하셨었고,
감사하게도 제 구현 방법이 BestPractice 로 선정되어 진행되었습니다.
하지만 이 부분에 있어서 ContextAPI를 사용하지 않고 구현했기 때문에 기본적인 깜빡임 (접근하면 안되는 페이지가 접근이 되었다가 순식간에 구현했던 로직대로 다른 페이지로 이동되는 현상) 이 존재했기때문에 아쉬웠습니다.
시간상 ContextAPI를 다시 적용시킬 여유가 되지 않았기때문에, 디테일한 구현보다는 기본적인 요구사항에만 초점을 두고 진행되었습니다.
//인터셉터
const onRequest = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
};
const onResponse = (response: AxiosResponse): AxiosResponse => {
return response;
};
const onErrorResponse = (error: AxiosError): Promise<AxiosError> => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
localStorage.removeItem('token');
}
}
return Promise.reject(error);
};
export { onRequest, onResponse, onErrorResponse };
//라우팅
type TCustomRouteObjectParams = {
path?: string;
name?: string;
element?: ReactElement;
};
type TCustomIndexRouteObject = IndexRouteObject & TCustomRouteObjectParams;
type TCustomNonIndexRouteObject = Omit<NonIndexRouteObject, 'children'> &
TCustomRouteObjectParams & {
children?: (TCustomIndexRouteObject | TCustomNonIndexRouteObject)[];
};
type TCustomRouteConfig = TCustomIndexRouteObject | TCustomNonIndexRouteObject;
const routeConfig: TCustomRouteConfig[] = [
{
path: '/',
element: <Home />,
errorElement: <div>404 Not Found</div>,
name: '홈',
},
{
path: '/signup',
element: <SignUp />,
name: '회원가입',
},
{
path: '/signin',
element: <SignIn />,
name: '로그인',
},
{
path: '/todo',
element: <Todo />,
name: '투두',
},
];
아무래도 프론트엔드는 독학으로 공부해왔기 때문에, 어느 프로젝트를 진행하더라도 항상 고민되었던 부분은 컴포넌트를 어떻게 분리해야할까? 에 대한게 가장 컸습니다.
회원가입 , 로그인 부분은 사전과제 요구사항에 공통적으로 요구되는 것이 , 1차적인 검증을 진행하고 검증이 실패하면 요청을 보내는 버튼이 비활성화 되어야 했습니다.
해당 검증은 공통적으로 이메일에는 '@' 이 포함되어야 했고, 비밀번호는 8자 이상으로 입력이 되어야 했습니다.
해당 요구사항으로 인해 로그인, 회원가입에 기본적으로 사용되는 input 컴포넌트는 동일하게 구현해도 되겠다는 생각이 들었습니다.
따라서 ValidationInput 이라는 컴포넌트를 생성해 기본적으로 검증을 진행하는 시각적 효과를 가진 스타일링 컴포넌트를 구현했고, 이에 필요한 공통 로직은 useFormControl 훅을 생성해 로직을 분리했습니다.
이 외에 디테일한 에러핸들링 또한 반영하여 구현하였고 최대한 사용자 입장에서 모르는 에러가 은밀하게 스쳐 지나가지 않도록 구현하는것을 목표로 진행하였습니다.
// useFormControl
export function useFormControl(options: {
regex: RegExp;
initialValue?: string;
}): [React.ChangeEventHandler<HTMLInputElement>, string,React.Dispatch<React.SetStateAction<string>>, boolean, React.Dispatch<React.SetStateAction<boolean>>,] {
const { regex } = options || {};
const [value, setValue] = useState(options.initialValue || "");
const [validation, setValidation] = useState(false);
const validateValue = (value: string) => {
const isValid = regex.test(value);
setValidation(isValid);
};
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const value = e.target.value;
setValue(value);
validateValue(value);
};
return [handleChange, value,setValue, validation, setValidation ,];
}
기본적으로 요청을 담당하는 로직들은 Page 를 구성하는 컴포넌트에 선언해 처리를 진행했습니다.
// SignIn.tsx
...
return (
<StyledFormControl onSubmit={onSubmit}>
로그인
<StyledInputBox>
<ValidationInput {...emailInputProps}/>
</StyledInputBox>
<StyledInputBox>
<ValidationInput {...passwordInputProps}/>
</StyledInputBox>
<StyledSignInButton type={"submit"} disabled={!emailValidation || !passwordValidation}
variant={"contained"}
data-testid={"signin-button"}>
로그인
</StyledSignInButton>
</StyledFormControl>
);
투두리스트를 구현하면서 가장 고민되었던 컴포넌트 분리 부분은 아무래도 요구사항 중 투두를 수정할 때 기존의 text가 사라지면서 input 으로 변경 된 후 '수정', '삭제' 버튼 또한 '제출','취소' 버튼으로 변경이 되도록 구현하는 것이었는데,
해당 부분을 수정모드일때의 컴포넌트를 구현하여 반영을 할지, 혹은 하나의 컴포넌트에 반영을 할지 고민이 많이 되었고, 주변의 어느분께 헬프를 요청하여 아이디어를 얻게 되었습니다.
아무래도 현업에서 개발을 하고 계시는 프론트엔드 개발자 분들도 공통적으로 고민하는 부분이 컴포넌트 분리에 대한 고민이라고 하시면서, 본인은 각 컴포넌트를 역할별로 분리한다고 하셨고, 해당 투두에대한 수정과 삭제는 해당 투두(항목) 밖에선 일어날 일이 없으므로 굳이 분리할 필요가 없다고 하셨고 , 해당 의견을 참고하여 구현하였습니다.
기본적으로 큰 페이지는 투두 입력창 / .map() 을 활용한 투두 로 구성이 되도록 하였으며 투두컨텐츠라는 컴포넌트에 수정/삭제 로직을 포함시켜 구현하였습니다.
TodoInput 또한 마찬가지로 useFormControl 훅을 재사용해 핵심 로직을 분리시켜서 가독성에 신경썼습니다.
TodoContent 는 과제 명세에 포함된 내용을 참고하여 디테일한 부분을 살리려 노력했습니다. (수정중에 체크박스를 클릭해도 업데이트가 진행된다던지 하는 부분들)
<StyledBox>
<TodoInput getTodoList={getTodoList}/>
<StyledTodoList>
{isLoading ? <Typography variant={"h6"}>로딩중..</Typography> : (
data && data.length > 0 ?
data.map((todo)=>{
return <TodoContent key={todo.id} data={todo} getTodoList={getTodoList} />
}) : <Typography variant={"h6"}>할 일이 없습니다.</Typography>
)}
</StyledTodoList>
</StyledBox>
저같은 경우에는 팀원 중 기본적으로 구현이 되어야하는 컴포넌트 (예 : input, button 등) 을 먼저 구현 후 각 큰 컴포넌트 별로 다시 스타일링 및 구현을 진행한 후 페이지를 완성한 팀원분의 코드를 보고 구조적으로 깔끔하다 생각되어 해당 팀원분을 선정하였습니다.
스타일링 또한 기본적으로 코딩 컨벤션을 지켜가면서 기능 / 스타일링을 철저히 분리해 가독성을 살리셨고, 다른 팀원분들 또한 마찬가지로 의견이 거의 동일하게 해당 팀원분을 선정하여 진행됐습니다.
여기서 제가 진행했던 부분은 TodoInput 을 구현하는 것이었는데, 구현하는김에 제가 개인적으로 구현했던 useFormControl 훅을 재사용해 useInput 이라는 커스텀 훅을 구현했고,
해당 훅을 구현시에 고민했던 부분 (불필요한 리턴값에 대한 핸들링) 을 반영하여 배열 타입으로 반환하지 않고 객체 타입으로 반환하도록 구현했습니다.
여기에 BestPractice 로 선정된 팀원분의 의견 (현업에선 UI/UX디자이너 분들께서 MUI , Chakra같은 스타일링 라이브러리를 사용하는것을 안좋아한다)을 참고하여 직접 common 컴포넌트를 구성해 구현하도록 했기때문에, Input 컴포넌트를 직접 구현하게 되었습니다.
interface IUseInputReturn<T> {
onChange: React.ChangeEventHandler<HTMLInputElement>;
value: T;
setValue: React.Dispatch<React.SetStateAction<T>>;
isValidated: boolean;
setIsValidated: React.Dispatch<React.SetStateAction<boolean>>;
setFocus: () => void;
setBlur: () => void;
}
const useInput = <T>(options: {
regex?: RegExp;
ref?: RefObject<HTMLInputElement>;
initialValue?: T;
}): IUseInputReturn<T> => {
const { regex } = options || {};
const [value, setValue] = useState<T>((options.initialValue as T) ?? ('' as unknown as T));
const [isValidated, setIsValidated] = useState<boolean>(false);
const validateValue = (value: T) => {
if (typeof value === 'string' && regex) {
const isValid = regex.test(value);
setIsValidated(isValid);
} else {
setIsValidated(false);
}
};
const setFocus = () => {
if (options.ref) {
options.ref.current?.focus();
}
};
const setBlur = () => {
if (options.ref) {
options.ref.current?.blur();
}
};
const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const newValue = e.target.value as unknown as T;
if (newValue !== value) {
setValue(newValue);
validateValue(newValue);
}
};
// 기본적으로 훅 사용할때 <type> 형식으로 타입 지정 해주시거나 (객체도 가능) 초기화값 지정해주시면 타입 자동으로 들어갈겁니다.
/* 폼 제출시 유효성 검사 초기화, 값 초기화를 위해 setter 까지 반환하도록 했습니다.
전체적으로 빈 값이 들어가는 경우는 없기때문에 정규표현식으로 관리하도록 구현했습니다.*/
/* const { data: data1, isLoading: isLoading1 } = useCustomHook(params1);
const { data: data2, isLoading: isLoading2 } = useCustomHook(params2);*/
/* 여러번 선언해야할 경우 위와 같이 사용하면 됩니다. (여러번 사용하지 않더라도 변수명 헷갈리지 않게 하기 위해 이렇게 사용하시는걸 추천드립니다.) */
return { onChange, value, setValue, isValidated, setIsValidated, setFocus, setBlur };
};
export default useInput;
개인과제로 진행했던 hook은 아무 생각 없이 단순 string 만을 핸들링하도록 구현했고, 이를 반성하며 리팩토링을 통해 제네릭 타입으로 checkbox 등에도 사용할 수 있도록 변경하였습니다.
여기에 refObject 를 전달받아서 focus나 blur 같은 이벤트도 구현할 수 있도록 전체적으로 react-hook-form 의 기능을 모방하여 사용할 수 있도록 구현하였습니다.
export interface IInputProps extends Omit<HTMLProps<HTMLInputElement>, 'ref'> {
helperText?: string;
error?: boolean;
errorText?: string;
dataTestId?: string;
width?: string;
height?: string;
}
//TODO: 부모 컴포넌트에서 ref 받아오도록 구현해야함
const Input = forwardRef((props: IInputProps, ref: ForwardedRef<HTMLInputElement>) => {
const { helperText, error, errorText, dataTestId, width, height, ...inputProps } = props;
return (
<S.InputWrap>
<S.Input {...inputProps} ref={ref} data-testid={props.dataTestId} width={width} height={height} />
<S.HelperText error={error} color={error ? 'red' : 'grey'}>
{error ? errorText : helperText}
</S.HelperText>
</S.InputWrap>
);
});
export default Input;
input 의 경우에는 기본적으로 input 태그가 갖고있는 attribute를 다 받아올 수 있도록 인터페이스 선언 시에 HTMLProps를 상속받아 사용하도록 구현했고, ref 같은 경우에는 따로 전달하는 값이 있기때문에 Omit 을 사용하여 해당 인터페이스중 ref 항목을 제외하고 상속받도록 구현했습니다.
이번 과제에는 존재하지 않았지만, 개인적으로 Jest를 이용한 테스트코드 작성에 대한 흐름이 매번 궁금했었고, 전체적으로 프로젝트 규모가 크지 않으니 이번 기회에 배워서 작성해볼 수 있겠다 ! 라는 생각을 갖고 테스트코드 작성을 시도해보았습니다.
테스트는 기본적으로 각 Page별로 진행했고, 상황별로 mocking 을 통해 api를 호출했을때의 상황, routing 유무 등을 테스트하였습니다.
it("이메일과 비밀번호가 일치할 때 contex에 토큰 저장 후 TODO 로 이동", async () => {
const postSignIn = jest.spyOn(apiMethods, "postSignin").mockResolvedValue({
access_token : 'token'
});
const mockedSetToken = jest.spyOn(context, "setToken");
const token = 'token';
customRender(<SignIn/>);
const signInButton = screen.getByTestId("signin-button");
const emailInput = screen.getByTestId("email-input") as HTMLInputElement;
const passwordInput = screen.getByTestId("password-input") as HTMLInputElement;
fireEvent.change(emailInput, {target: {value: "mytestemail@email.email"}});
fireEvent.change(passwordInput, {target: {value: "password"}});
fireEvent.click(signInButton);
await waitFor(() => {
expect(postSignIn).toBeCalledTimes(1);
});
await waitFor(()=>{
expect(postSignIn).toBeCalledWith({
email: emailInput.value,
password: passwordInput.value
});
})
expect(mockedSetToken).toBeCalledTimes(1);
expect(mockedSetToken).toBeCalledWith(mockedDispatch, token);
expect(mockedUsedNavigate).toBeCalledWith('/todo');
});
Test Suites: 3 passed, 3 total
Tests: 28 passed, 28 total
Snapshots: 3 passed, 3 total
Time: 4.639 s
Ran all test suites related to changed files.
이런식으로 총 28개의 단위테스트를 3개의 페이지별로 진행했고, 모두 통과하였습니다.
다행히 스프링부트로 개발을 하면서 단위테스트를 진행했던 경험이 있어서 테스팅 라이브러리를 익히는데에 큰 문제는 없었으나
어려웠던건 스프링부트때와 똑같이 mocking 에 대한 부분이었습니다.
스프링부트는 내가 직접 생성하고 작성한 클래스나 api들을 mocking 해서 단위 테스트를 진행했으나, jest를 이용할때에는 내가 직접 작성한 비즈니스 로직 외에도 npm 으로 설치한 라이브러리까지 mocking 하여 상황별로 given 을 지정해야했기 때문에 .. 적응하는데 시간이 꽤 걸렸습니다.
아무래도 해당 부분은 리액트의 동작원리를 파악한다면 더 쉽게 이해할 수 있지 않았을까 하는 생각이 들었습니다.
처음으로 합류해봤던 프론트엔드 교육이라 떨리는 마음으로 임하고 있지만, 1주차 과제를 어찌저찌 끝내고 드는 생각은 아무래도 '시간이 지나고 보면 다 별거 아닌 일들이다' 인 것 같습니다.
2주차 과제는 1주차 과제에서 미흡했던 점, 아쉬웠던 점을 보완하여 욕심 부리지 않고 깔끔하게 마무리 하는 방향으로 가려고 합니다.
오랜만에 작성하는 회고록인만큼 열심히 깔끔하게 작성하려 노력했는데 잘 모르겠네요.. 읽어주셔서 감사합니다!
앞으로도 화이팅~!