코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
React (3)
Virtual DOM 가장 쉽게 이해하기

선언형 프로그래밍

버추얼돔에 대해서 공부하기 이전에 우리는 선언형 프로그래밍에과 명령형 프로그래밍에 대해 알고 갈 필요가 있다.

선언형 프로그래밍이란 ?

한 정의에 따르면, 프로그램이 어떤 방법으로 해야 하는지를 나타내기보다 무엇과 같은지를 설명하는 경우에 "선언형"이라고 한다.

선언형 프로그래밍은 무엇(What)을 원하는지를 명시하고, 시스템이 어떻게(How) 처리해야 하는지는 추상화된 레벨에서 결정하는 방식이다.

쉽게 말해 리액트에서 UI를 선언적으로 작성하면 개발자는 UI의 구조와 렌더링 결과물을 설명하고, 리액트가 이를 실제 DOM 조작으로 변환하고 최적화한다.

그럼 명령형 프로그래밍이란 ?

프로그래밍의 상태와 상태를 변경시키는 구문의 관점에서 연산을 설명하는 프로그래밍 패러다임의 일종이다.

어떻게(How) 작업을 수행해야 하는지 단계별로 명시하고, 변경 가능한 상태를 직접 조작하여 프로그램의 흐름을 제어하는 방식이다.

개발자의 의도대로 하나부터 열까지 동작해야 하는 상황에서는 좋을 수 있지만, 그게 아니라면 오히려 복잡한 작업순서와 예상치못한 결과를 초래할 수 있다.

DOM (Document Object Model)

DOM은 Document Object Model의 약자로 HTML 문서 의 모든 노드를 구조적으로 표현한 것입니다.

쉽게 말해 DOM은 응용 프로그램의 UI를 나타낸다고 할 수 있다.

만약 당신이 1부터 5까지의 값이 담긴 배열에서 중간의 3을 4로 바꾸고 싶다면 어떻게 할것인가?

const arr = [1,2,3,4,5] // 변경 전
const arr = [1,2,4,4,5] // 변경 후
 

작은 규모라면, 단순히 하나의 값을 변경한 새 배열을 선언해 대체할 수 있지만, 만약 규모가 작지 않다면 해당 방법은 비효율적일 수 있다.

이렇게 실제 DOM은 하나의 변경점이 발생해도 모든 노드를 업데이트 하게 된다. 변경점이 발생하지 않았던 노드도 리렌더링을 하게 된다면 얼마나 큰 비용이 소모되는지 알 수 있다.

Virtual DOM

버추얼 돔은 실제 돔의 추상화된 모델이라 볼 수 있다. 그러기 떄문에 추상화의 추상화이다. 경량 복사본이라는 점을 제외하면 실제 DOM 개체와 동일하다.

버추얼돔은 돔의 동작방식을 보완하여 속성이 변경되면 전체 트리가 아닌 해당 노드만 업데이트된다.

리액트에서 버추얼돔은 어떻게 업데이트에 관여할까?

  1. ReactDOM.render() 는 실제 돔과 버추얼 돔트리를 생성하여 첫 번째 로드시 화면의 요소를 렌더링한다.
  2. 요소에 대한 변경 사항 혹은 상태 변경에 대한 알림을 버추얼 노드로 전송한다. 마찬가지로 노드의 속성이 변경되면 자체적으로 업데이트 된다.
  3. 리액트는 업데이트된 버추얼돔을 실제 돔과 비교한다. 그리고 실제 돔을 업데이트 하며 이 과정을 재조정이라고 한다. 재조정은 Diffing Algorithm 으로 알려진 휴리스틱 알고리즘을 사용하여 수행된다.

그렇게 버추얼돔은 변경값이 존재하는 속성만 업데이트하여 렌더링함과 동시에

모든 변경점을 하나하나 리렌더링을 트리거하며 업데이트하지 않고, 필요한 부분만 업데이트하여 한번만 리렌더링을 트리거한다.

div.innerHTML='change text'
div.style.cssText='width:100px;height:100px'
 

예를 들어 이러한 변경점이 발생할 때 , 실제 돔은 변경점 하나하나마다 리렌더링을 한다고 하면, 버추얼 돔은 이 변경점을 모두 반영하여 한번의 리렌더링을 발생시킨다.

→ 실제 돔은 이렇게 개발자가 원하는 방향을 하나하나 일일히 명령하여 렌더링과 성능최적화를 직접 신경써야 한다면

→ 버추얼 돔은 개발자가 원하는 방향과 처리방법을 선언하면 리액트가 디핑 알고리즘을 통해 변경점을 캐치 후 렌더링한다.

이것이 선언형 / 명령형 프로그래밍 방식의 차이라고도 볼 수 있다.

그렇다면 버추얼돔과 실제돔 사이 비교는 어떻게 이뤄질까?

한 트리를 다른 트리로 변환하기 위하여 최소한의 연산을 제공하는 알고리즘은 O(n3)의 시간 복잡도를 가진다고 한다. → 1000개의 요소를 표시하는데 10억번의 비교가 필요하다는 소리이다.

리액트는 이러한 재조정 과정에서 Diffing 알고리즘 휴리스틱 알고리즘을 사용하여 성능을 최적화하며 시간 복잡도를 낮췄다.

휴리스틱 알고리즘

→ 휴리스틱이란 불충분한 시간이나 정보로 인하여 합리적인 판단을 할 수 없거나 체계적이면서 합리적 판단이 필요하지 않은 상황에서 사람들이 빠르게 사용할 수 있도록 구성된 간편 추론 방법이다.

단순히 중요한 것들만 고려해서 최선의 값을 찾아내는 방법이라고 할 수 있다.

  • 다른 타입의 두 요소는 서로 다른 트리를 생성한다.
  • 개발자는 Key prop을 이용해 다른 렌더링 사이에서의 자식 요소에 대한 힌트를 얻을 수 있다.

리액트는 위 두가지 가정에 따른 휴리스틱 알고리즘을 채택하여 시간복잡도를 O(n)으로 낮췄다.

따라서 휴리스틱 알고리즘이 기반하고 있는 가정에 부합하지 않는 경우에 성능이 나빠질 수 있다.

재조정 과정

  • 기본적으로 두개의 트리를 비교할 때 ,리액트는 비교를 위한 새 버추얼돔을 생성 후 루트 엘리먼트부터 비교한다.
    • 돔 요소 타입이 다른경우 리액트는 이전 트리를 버리고 새로운 트리를 구축하며 새로운 돔 노드들이 돔에 삽입되며 연관된 모든 state 는 사라지며 새로 다시 마운트된다.
    • 같은 경우에는 변경된 속성들만 갱신한다.
  • 돔노드의 처리가 끝나면, 리액트는 해당 노드의 자식들을 재귀적으로 처리한다.
    • 더 이상 처리할 자식이 없을때까지 처리한다는 뜻
  • 이러한 상황에서의 Key 프로퍼티가 성능적인 측면에서 중요한 역할을 하게된다.
    • 자식들이 key를 갖고있다면, 리액트는 key를 통해 기존트리와 이후 트리의 자식들이 일치한지 확인하여 자식 요소들을 순회하지 않고 key 속성으로 쉽게 판단할 수 있게 된다.

→ key 값으로 인덱스를 사용하면 어떻게 될까?

  • 어떠한 상황에서 단순히 key 값으로 인덱스를 사용하는 경우가 존재한다.
  • 재배열되는 경우에는 항목의 순서가 바뀔 때 key 또한 바뀌기 때문에 식별자로서의 역할을 하지 못한다.

주의점

  • 매우 비슷한 결과물을 출력하는 두 컴포넌트를 교체하고 있다면, 그 둘을 같은 타입으로 만드는 것이 더 나을수도 있다.
  • 위와 마찬가지로 key 는 변하지 않고 예상이 가능하며 유일해야한다. 변하는 키를 사용하면 자식 컴포넌트의 state 가 유실되거나 성능이 나빠질 수 있다.

레퍼런스

ReactJS Reconciliation - GeeksforGeeks

Difference between Virtual DOM and Real DOM - GeeksforGeeks

What is the difference between virtual and real DOM (React)?

[ React ] 재조정(Reconciliation)과 Key 사용 이유

재조정 (Reconciliation) – React

  Comments,     Trackbacks
원티드 프리온보딩 인턴십 1주차 개인&팀과제 회고록

프로젝트 주제

원티드 프리온보딩 인턴십 교육 (11차) 사전과제 주제는 투두리스트 만들기였습니다.
간단한 투두리스트 라고 생각할 수 있지만, 뭐든지 간단한만큼 신경쓸게 더 많다는 사실.. 방심하지 않고 핵심 기능들을 최대한 깔끔하게 구현해보자 라는 생각으로 임해보았습니다.

여담이지만 .. 왜 저는 항상 팀장이 되는걸까요 ..? 흑..

사전 개인과제 요구사항

  • 기능구현에 직접적으로 연관된 라이브러리 사용은 허용되지 않습니다.(React-Query 등)
  • README.md 작성은 필수입니다. 아래의 사항은 반드시 포함되도록 해주세요
  • 페이지별로 요구되는 경로는 도메인 URL뒤에 바로 이어지도록 설정해주세요

기본적으로 진입 루트가 회원가입 -> 로그인 -> 투두리스트 였고, 기능 구현에 연관된 라이브러리는 사용하지 않는것이 기본 규칙이었습니다.

1주차 팀 과제 요구사항

팀빌딩이 이뤄지고 진행되는 1주차 팀 과제는 BestPractice 를 선정하여 사전과제를 리팩토링 하는것이었습니다.

Best Practice란 모범사례라는 말로서, 특정 문제를 효과적으로 해결하기 위한 가장 성공적인 해결책 또는 방법론을 의미합니다.

쉽게말해 사전과제의 핵심 기능을 파트별로 나눠, 가장 잘 작성된 코드를 선정해 선정된것들로만 이뤄진 프로젝트를 완성해 제출하는 것이 1주차 과제였습니다.

1주차 세션에서 진행했던 협업을 위한 툴들을 적용시키기 위해 ES Lint 와 Prettier, 그리고 husky를 사용하여 포멧팅을 자동화 하도록 진행했고,

너무 세분화하여 진행하지 않고 큰 부분들만 나눠서 불필요한 딜레이를 줄이는것을 목표로 진행되었기 때문에 고민사항은 크게 3가지로 분류하여 BestPractice 를 선정했습니다.
(진행은 팀별 디스코드 채널,노션을 생성해 회의와 회의내용을 정리하여 기록하며 진행했습니다.)

  • 파일트리
    • 전체적으로 팀원들이 프로젝트의 디테일한 폴더 구조에 대한 고민이 많았기 때문에 여러부분을 참고하여 진행했지만 실무를 겪어보지 못했기 때문에 프로젝트의 규모별로 괜찮은 파일트리를 선정하기 어려웠다는 의견들이 많았습니다.
  • 인증/인가 로직
    • 인증에 대한 로직을 어디서 구현해야 할지, 공통 로직을 분리할 수 있을지 , 그리고 그것을 어떻게 구현해야 할지에 대한 고민이 많았습니다.
  • 컴포넌트 분리
    • 컴포넌트를 어떻게 어떠한 기준으로 분리하여 유지보수에 최적화된 컴포넌트를 구현할 수 있을지에 대한 고민이 공통적으로 존재했습니다.

과제 진행중 고민되었던 부분

개인 : 파일트리

개인으로 진행했던 사전과제의 경우에는 파일트리를 프로젝트 전체 깊이가 깊어지더라도 세분화하여 폴더를 생성하여 각 폴더별로 메인이 될 수 있는 컴포넌트를 index.tsx 로 생성하여 관리하는 구조를 택했습니다.

해당 구조가 개인적으로 각각의 컴포넌트에 대한 작명만 잘한다면 도서관의 원하는 도서 찾기처럼 크게 불편함이 없는 상태로 세분화를 할 수 있겠다는 생각이 들었습니다.

이전에 진행했던 개인 프로젝트는 아무래도 하나하나를 세분화 하다보니 전체적으로 깊이가 너무 깊어져서 오히려 가독성을 더욱 해칠 수 있겠다는 생각이 들었기 때문에 이번 프로젝트는 진행하면서 자식 컴포넌트 (HeaderMenu 의 Menu 같은)는 따로 폴더를 생성하지 않고 해당 컴포넌트의 이름으로 네이밍을 하는 방식으로 진행하였습니다.

팀 : 파일트리

팀과제로 진행된 프로젝트의 경우에는 일단 컴포넌트 구현에 앞서서 정확한 파일트리와 인증로직 구현을 진행 후에 컴포넌트를 구현하는게 맞다고 생각했기 때문에 파일트리를 먼저 정하게 되었습니다.

전체적으로 저는 백엔드를 학습하다가 프론트엔드를 시작했었던지라, 백엔드 프로젝트를 진행할 땐 항상 확장에 대한 가능성을 열어둔 채로 프로젝트를 진행했기 때문에 최대한 쪼갤 수 있는 부분은 쪼개고 각각의 모듈에 대해서 관심사를 분리하는것을 메인으로 삼고 진행했으나,

공통적으로 프론트엔드 개발자분들은 대부분 현재 진행하는 프로젝트의 규모에 따라 방식을 정하는듯 하셨습니다.

저희 팀의 경우 현재 진행해야 하는 투두리스트 또한 규모 자체가 크지 않았기 때문에 과한 세분화는 지양하고 최대한 커뮤니케이션에 문제가 없게끔 파일트리를 정의하여 깔끔한 프로젝트를 진행하기로 결정되었습니다.

여기에 api로직에 대한 부분들은 저의 방식을 BestPractice 로 선정해 apis 폴더에 각 컴포넌트 별로 비즈니스로직을 분리하여 선언하도록 구현했습니다.

해당 부분에 있어서는 기본적으로 큰 부분 (컴포넌트들,api들, 페이지들 등) 으로 나누어 폴더를 생성 후 각 부분에 맞춰서 필요시에 폴더를 생성하고, 큰 이유가 없다면 파일만 생성하여 관리하는 구조를 택했고, 전체적으로 파일 트리는 아래와 같이 정의되었습니다.

팀 진행으로 인해 얻어간것들 (파일트리)

  • 어떻게 보면 제가 개인으로 정의했던 파일트리는 예를 들었을 때
    쓸데없이 즉섭밥을 데워먹어야하는 상황임에도 직접 지은 밥을 고수하는 듯한 느낌을 줄 수도 있겠구나 .. 라는 생각을 하게 되었습니다.
  • 다른분들의 의견을 통해 상황별로 해당 구조가 필요한 경우가 있겠지만 너무 과하게 멀리보고 진행하기보다는 확실한 규모를 정하고 프로젝트를 진행하는게 좋겠다는 생각을 다시 한번 하게 되었습니다. 개발은 아무래도 나 혼자 하는것이 아니기 때문에 ..

개인 : 인증 로직 구현

인증 로직의 경우에는 기본적으로 요청시에 헤더에 토큰을 포함시키고, 토큰이 만료되거나 비밀번호가 옳지 않을때는 공통적으로 401에러를 반환하기 때문에 axios interceptor 로 분리하여 로직을 구현할 수 있겠다는 생각이 들었습니다.

이에 대해서 들었던 생각은 두가지였습니다.

  • ContextAPI 를 사용하여 인증 상태값 저장하기
  • 단순히 LocalStorage 에서 토큰값을 가져와서 헤더에 포함시키기

이번 과제의 경우에는 메인 기능을 구현하는데에 있어서 관련된 라이브러리를 설치하여 사용하는것이 금지되었기 때문에 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: '투두',
  },
];

팀 진행으로 인해 얻어간 것들 (인증 로직 구현)

  • 해당 부분에 대해선 개인적으로 아쉬웠던게 많았습니다.
  • 아무래도 요구 사항에 무조건적으로 맞춰서 불필요한 부분은 제외해두고 개발한다고 해도 최적의 사용자 경험도 무시할 수 없다고 생각했기때문에, 블링크 현상이라던가, 상황별로 헤더에 나타나는 메뉴를 컨트롤 할 수 없었던 부분이 많이 아쉬웠습니다.
  • 2주차부터는 자바스크립트의 기본 동작 원리에 대해 더 공부해서 기초 설계 시에 디테일한 부분까지 신경써서 진행해야겠다는 생각이 들었습니다 :)

개인 :컴포넌트 분리

아무래도 프론트엔드는 독학으로 공부해왔기 때문에, 어느 프로젝트를 진행하더라도 항상 고민되었던 부분은 컴포넌트를 어떻게 분리해야할까? 에 대한게 가장 컸습니다.

  • 제가 선택한 방식은 아무래도 가장 보편화된 방식으로 기능별로 분리를 하는 방식을 선택했습니다.
    • 공통된 기능을 가질 수 있겠다 싶은 부분들을 선정해 작은 컴포넌트를 만들어 하나의 큰 모듈을 완성하는 것으로 목표로 작은것부터 차근차근 개발에 임했습니다.
  • 페이지는 크게봤을 때 회원가입 / 로그인 / 투두리스트 로 분리를 할 수 있었고, 각 페이지별로 공통된 부분을 살펴 쪼갠 후 가장 작은 부분부터 구현을 진행하였습니다.

회원가입 및 로그인

회원가입 , 로그인 부분은 사전과제 요구사항에 공통적으로 요구되는 것이 , 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 항목을 제외하고 상속받도록 구현했습니다.

팀 진행으로 인해 얻어간 것들 (컴포넌트 분리)

  • Omit과 HTMLProps 라는 새로운 것을 알게되었습니다.
    • common component 를 구현할 때 쓸데없이 interface 에 property를 와바박 쓸 일이 없어졌습니다..
  • 아무래도 컴포넌트 구현을 저 혼자 하는게 아니었기 때문에 협업을 통해 리팩토링에 신경쓰게 되었고, useInput 을 구현할 때 제네릭 타입을 처음 사용하는 등의 경험이 소중하게 느껴졌습니다.
    • 마찬가지로 주석처리에 신경쓰게 되었고, 커뮤니케이션의 대부분이 컴포넌트 구현 파트에서 이뤄졌기 때문에 실무를 정말 간접적으로 경험할 수 있었습니다.
  • 오히려 좋았던점은 커스텀훅을 사용하는 법이 익숙치 않았던 팀원분께서 계셨고, 이로 인해서 주석처리나 가독성 좋은 코드를 작성하려 많이 노력했던 부분이 오히려 실력상승으로 이어지게 되었습니다.

개인 : 테스팅

이번 과제에는 존재하지 않았지만, 개인적으로 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주차 과제에서 미흡했던 점, 아쉬웠던 점을 보완하여 욕심 부리지 않고 깔끔하게 마무리 하는 방향으로 가려고 합니다.

오랜만에 작성하는 회고록인만큼 열심히 깔끔하게 작성하려 노력했는데 잘 모르겠네요.. 읽어주셔서 감사합니다!

앞으로도 화이팅~!

'React' 카테고리의 다른 글

Virtual DOM 가장 쉽게 이해하기  (0) 2023.08.24
재사용 가능한 컴포넌트 만들기  (0) 2023.03.11
  Comments,     Trackbacks
재사용 가능한 컴포넌트 만들기

컴포넌트?

프론트엔드 공부를 시작하기 이전에, 나는 자바를 공부하고 있었고, 어쩌다가 접하던 프론트엔드 글에는 무조건 들어가는 단어가 있었다.

바로 컴포넌트 였다.

그러면 리액트에서의 컴포넌트란 무엇일까?

리액트에서는 기존의 html 태그를 이용하는 것처럼 함수를 선언해 태그처럼 사용할 수 있다.

함수 속에 함수를 선언하거나 외부에서 함수를 불러와서 사용 할 수도 있다.

말그대로 함수형 컴포넌트는 이러한 함수를 컴포넌트로 사용하는것 이라고 생각했다.

컴포넌트가 될 수 있는 부분을 살펴보기

  • 백엔드던 프론트엔드던 가장 좋은 코드는 유지보수가 가능하고 쉬운 코드라고 생각한다.
    • 개인적으로 항상 내가 코딩할 할때 두는 목표는 확장,수정에 힘든 부분이 없도록 설계하는것이다.
  • 서버 개발을 진행할때도 대부분 최대한 한 객체는 하나의 역할만 갖고, 하나의 메소드또한 마찬가지로 하나의 작은 역할을 갖도록 진행했었다.
  • 해당 부분을 단순히 프론트엔드에서 끌어와서 생각해보면 그 하나의 역할만 갖도록 만드는것이 재사용 가능한 컴포넌트를 만드는 작업이라 생각이 되었다.
    • 예를들면 많이 쓰일 수 있는 부분들 (검색창 , 테이블, 버튼, 텍스트 입력창) 같은 부분을 최대한 내부의 코드를 바꿀 필요 없이 요구하는 재료들만 갖다주면 알아서 뿅~ 하고 나오도록 만든다면? 그리고 내부의 코드를 바꿀때도 복잡하게 하나를 변경하면 다른 연결된 무언가를 싹 다 바꿀 필요 없도록 만든다면?
  • 어떠한 홈페이지의 메인 페이지를 구성하다보니, 재사용이 가능한 부분들이 몇개가 있었다.
    • 본인같은 경우에는 메인 페이지에 게임 캐릭터의 랭킹을 표현 할 수 있는 테이블과, 최근 게시글을 출력할 수 있는 테이블 (벌써 이 두개도 하나의 컴포넌트로 재사용이 가능 하겠다는 생각이 들었다.🤓) , 검색창과 헤더, 모바일 메뉴, 로그인창 정도가 있었다.
      • 해당 구성중에 검색창과 테이블, 그리고 로그인창 등에 들어가는 텍스트 필드가 재사용이 가능하겠다는 생각이 들었다.
      • 하나로 예를 든다면, 카테고리와 검색어를 입력하면 검색 결과로 이동하게 해주는 검색창을 만들어버리면 다른 화면에서 매번 새로 커스텀해서 사용할 필요 없이 단순히 스타일만 변경해서 사용한다면 개발 시간이 굉장히 단축된다는 소리이다.

재사용이 애매한 컴포넌트를 재사용이 가능하도록 만들어보기

  • 위에 적어뒀듯이 본인이 진행할 프로젝트에 가장 많이 사용 될 수 있는 부분부터 생각해보면 좋을 것 같다.
    • 본인같은 경우에는 진행하던 프로젝트가 어떠한 게임 랭킹 조회 사이트 + 커뮤니티 사이트 클론코딩 이었기 때문에 랭킹 테이블이 가장 많이 노출될 것 같았다.
  • 여기서도 생각해볼 수 있는게, 어떠한 것을 재사용이 가능한 컴포넌트로 만들것인지가 중요한것같다.
    • 랭킹 테이블 자체를 만들것인가?
    • 혹은 기본적인 테이블을 만들어서 각 상황에서 커스텀이 가능하도록 꾸밀것인가?
  • 나는 둘다를 선택했다.
    • 왜냐하면 테이블 자체가 여러곳에서 사용이 가능하다. (위에도 언급했듯이 본인의 프로젝트에선 최근 게시판을 출력할때도 사용이 가능했고, 랭킹용으로도 사용이 가능했다. 더 나아가선 마이페이지에서도 사용이 가능할 것 같았다.
    • 랭킹테이블 자체 또한 여러 상황에서 사용이 가능했기 때문에 기본적인 테이블 틀을 만들어 둔 후에, 랭킹용으로 사용 할 수 있는 컴포넌트를 한번 더 만들었다.

처음 스타일은 아니지만, 이런식으로 재사용이 가능하도록 세부 카테고리를 갖고 있는 테이블 컴포넌트를 만들어주었다.
이런식으로 기본적인 카테고리를 갖고있는 테이블 컴포넌트를 생성하고, 바디에는 또다른 내용이 들어가도록 자식노드를 넣어주었다.
만들어둔 테이블컴포넌트를 이용하여 최근 게시글 테이블을 만들었다.
마찬가지로 만들어둔 테이블에 바디부분만 새로 꾸며서 랭킹 테이블을 만들었다.
그러면 전체 틀은 테이블 컴포넌트로 꾸며주고, 바디부분만 그리드를 활용해서 꾸며주는 방식으로 2차 활용이 가능하다.

  • 이런식으로 export 되는 컴포넌트들을 하드코딩 하지 않고, 건내주는 props 들을 잘 활용한다면 말그대로 옷입히기 하는것처럼 웹사이트를 완성시킬 수 있다.
    • 굉장히 좋은점은 이 글을 작성하는 지금도, 생각해보니 화살표 버튼을 만들었지만, 해당 버튼을 클릭할때 행동을 정의해두지 않았다.
    • 단순히 건내주는 props 에 boolean 값을 받는 useArrow 와 함수를 받는 onClickArrow 를 추가하여 동작하도록 단 한줄의 코드만 추가하면 끝이었다.

이런식으로 상위 컴포넌트에 옵션을 추가해주도록 선언하면 끝이다.

 

그러면 전체 틀은 테이블 컴포넌트로 꾸며주고, 바디부분만 그리드를 활용해서 꾸며주는 방식으로 2차 활용이 가능하다. (수치는 수정할 예정이라 보지마세용.)

  • 이 사진만 보더라도 내가 단독으로 고칠 수 있는 부분이 벌써 10개정도 된다.
    • 테이블의 타이틀과 스타일링
    • 화살표의 유무와 행동 방식
    • 카테고리를 사용할지 말지와 카테고리 버튼의 행동과 글씨 스타일링
    • 테이블 내부 콘텐츠 스타일링
    • 순위 아이콘 스타일링
    • 캐릭터 이미지 출력 스타일링
    • 캐릭터 이름/ 모험단 이름/ 서버이름 스타일링
    • 랭킹 수치 스타일링
  • 이정도만 해도 9가지이고, 더 나아가서 스타일링 또한 상위 컴포넌트에서 단독적으로 다루도록 할 수 있게 만든다면 금상첨화인것
  • 이렇게 랭킹 테이블을 만들어두면 여러 랭킹 출력 화면에서 사용이 가능하다.

 

  Comments,     Trackbacks