티스토리 뷰

컴포넌트 기본 구조

컴포넌트 명의 폴더 
├─ index.tsx # 화면에 보여질 컴포넌트
├─ styles.tsx # emotion(CSS in JS)으로 만들어진 UI 파일

프로젝트 폴더 구조 및 파일별 기능 설명

👩‍🦰 폴더 설명

  • components - 페이지를 구성하는 작은 단위의 컴포넌트를 모아놓은 폴더(기능 별 구분)
  • contexts - 전역으로 공유되는 값들을 담은 context폴더
    • 투두리스트 컨텍스트, 권한 컨텍스트 
  • hooks - 커스텀 훅을 모아놓은 폴더
  • pages - 라우팅의 기준점이 되는 페이지 컴포넌트를 모아놓은 폴더
  • typings - 공유 타입들 정의 폴더
  • utils - 독립적인 기능을 모아놓은 폴더 
    • axios 인스턴스, 유효성 검사 로직

👩‍🦰  구현 기능

라우팅

  • URL별 페이지 생성
  • 제공되는 URL이 아닌 다른 페이지로 접근할 경우 (404 페이지)
  • 비즈니스 로직의 오류가 발생할 경우 (500 페이지)
  • 유저 정보(토큰)가 브라우저에 없을 경우 로그인 페이지로 라우팅
  • 유저 정보가 브라우저에 있을 경우 todo list 페이지로 라우팅

로그인

  • 유효성 검사
  • api 요청

회원 가입

  • 유효성 검사
  • api 요청

투두 리스트

  • todo 입력 및 추가 (post)
  • todo list 출력 (get)
  • todo 수정 (put)
  • todo 삭제 (delete)

👩‍🦰  상세 설명 ▼

my-todo-list
├─ .gitignore
├─ API.md # API 관련 docs
├─ craco.config.js # 절대경로 설정 craco 세팅 파일
├─ package.json 
├─ public 
├─ README.md # 프로젝트 설명 docs 
├─ src
│  ├─ App.css
│  ├─ App.test.tsx
│  ├─ App.tsx #별다른 기능X, index.tsx에 있는 router 컴포넌트들의 공통 레이아웃 컴포넌트
│  ├─ components # 페이지를 구성하는 컴포넌트를 모아놓은 폴더
│  │  ├─ AddTodo # 투두 추가 기능 + form UI
│  │  │  └─ index.tsx 
│  │  ├─ Header # Home 페이지에 들어가는 Header 컴포넌트
│  │  │  ├─ index.tsx 
│  │  │  └─ styles.tsx
│  │  ├─ Todo # 투두 삭제, 수정 기능 + 개별 투두 UI 
│  │  │  ├─ index.tsx 
│  │  │  └─ styles.tsx
│  │  └─ TodoList # 투두 리스트 불러오기 기능 + 투두리스트 UI
│  │     ├─ index.tsx
│  │     └─ styles.tsx
│  ├─ contexts #전역으로 공유되는 값들을 담은 context폴더
│  │  ├─ auth
│  │  │  └─ AuthContext.tsx # 권한 context => auth 상태값과 setAuth함수  
│  │  └─ todo
│  │     ├─ TodoContext.tsx # 투두 관련 2개의 컨텍스트(todo리스트 컨텍스트와 todo리스트 상태를 업데이트하는 액션을 수행하는 dispatch 컨텍스트)
│  │     └─ todoReducer.ts # todo 리스트값과 todo리스트의 상태를 변경하는 액션을 관리하기 위한 리듀서 
│  ├─ hooks # 커스텀 훅을 모아놓은 폴더
│  │  ├─ useAuth.ts
│  │  ├─ useDispatch.ts # context에서 제공된 todo리스트의 state를 변경하는 dispatch 함수를 반환하는 hook
│  │  ├─ useInput.ts # input의 state와 state를 변경하는 함수, target 핸들러를 반환하는 hook
│  │  ├─ useRouter.ts # 현재 경로와 라우팅함수를 담은 객체를 반환하는 hook
│  │  └─ useTodoState.ts #공유된 context의 todo리스트 state를 반환하는 hook
│  ├─ index.css #reset css일부와 글로벌 font
│  ├─ index.tsx
│  ├─ layouts
│  ├─ logo.svg
│  ├─ pages
│  │  ├─ Content # todoList를 보여주는 컨텐츠 페이지
│  │  │  └─ index.tsx
│  │  ├─ Home # 맨 처음 보이는 홈 페이지
│  │  │  └─ index.tsx
│  │  ├─ NotFound # 없는 경로에 접근했을 때 보이는 페이지
│  │  │  └─ index.tsx
│  │  ├─ SignIn # 로그인 페이지
│  │  │  ├─ index.tsx #로그인 기능,및 로그인 정보 유효성 검사
│  │  │  └─ styles.tsx
│  │  └─ SignUp # 회원가입 페이지
│  │     └─ index.tsx #회원가입 기능 및 회원가입 정보 유효성 검사
│  ├─ react-app-env.d.ts
│  ├─ reportWebVitals.ts
│  ├─ router.tsx # 경로별 컴포넌트가 정의되어 있는 라우터 파일
│  ├─ setupTests.ts
│  ├─ typings # 여러 컴포넌트에서 공유되는 타입들이 정의된 폴더
│  │  ├─ todo.ts
│  │  └─ user.ts 
│  └─ utils #독립적인 기능을 모아놓은 폴더
│     ├─ api 
│     │  └─ customAxios.ts # 전역에 사용될 커스텀 axios를 정의해놓은 파일
│     └─ validation # 유효성 검사 관련 기능을 모아놓은 폴더
│        └─ userInfoRegex.ts # 로그인, 회원가입에 사용되는 유저정보 유효성 검사 정규표현식
├─ tsconfig.json
├─ tsconfig.paths.json # 절대경로 설정 관련 파일
└─ yarn.lock

 


개인적인 Best  Practice

useReducer 와 context api 조합으로 전역 상태 관리( 투두리스트 )

contexts > todo > TodoContext.tsx

import React, { createContext, useReducer, Dispatch } from "react";
import { todoReducer } from "./todoReducer";
import { TodoProps } from "@typings/todo";
import { State } from "./todoReducer";
import { ActionType } from "./todoReducer";

type SampleDispatch = Dispatch<ActionType>;

export const dispatchContext = createContext<SampleDispatch | null>(null);
export const todoStateContext = createContext<State | null>(null);

const initTodos: TodoProps[] = [];

export default function TodoContextWrapper({
  children,
}: {
  children: React.ReactNode;
}) {
  const [todos, dispatch] = useReducer(todoReducer, initTodos);

  return (
    <todoStateContext.Provider value={todos}>
      <dispatchContext.Provider value={dispatch}>
        {children}
      </dispatchContext.Provider>
    </todoStateContext.Provider>
  );
}
  • Context API로 todoList 상태값과 dispatch함수를 자식 컴포넌트에서도 접근이 가능하게 구성했다.
  • TodoContextWrapper 컴포넌트는 useReducer에서 반환된 state값과 dispatch 함수를 하위 컴포넌트에 공유한다.

contexts > todo > todoReducer.ts

import { TodoProps } from "@typings/todo";

export type ActionType =
  | { type: "INIT"; initTodos: TodoProps[] }
  | { type: "ADD"; todo: TodoProps }
  | { type: "DELETE"; id: number }
  | { type: "EDIT"; todo: TodoProps };

export type State = TodoProps[];

//action에 {type이랑 payload}
export const todoReducer = (state: State, action: ActionType): State => {
  switch (action.type) {
    case "INIT":
      return action.initTodos || state;
    case "ADD":
      return [...state, action.todo];
    case "DELETE":
      return state.filter((aTodo) => aTodo.id !== action.id);
    case "EDIT":
      return state.map((aTodo) =>
        aTodo.id === action.todo.id ? { ...aTodo, ...action.todo } : aTodo
      );
    default:
      throw new Error("unhandled action!");
  }
};
  • useReducer를 활용하여 상태 관리 로직을 구조화
  • 리덕스처럼 액션에 따라 상태를 업데이트하는 로직을 한 곳에 모아 관리하기 쉽게 만들었다. 

+ Context api를 사용하면 Context 값이 변경되었을 때, 해당 값을 구독하는 컴포넌트들이 불필요하게 리렌더링되기 때문에 하위에 위치한 Todo.tsx 컴포넌트를 React.memo()로 묶어주어 불필요한 렌더링을 방지해주었다. 

 

다시 코드를 보니 Context API와 useReducer를 남용하면 안되겠다는 생각이 듦, 컴포넌트끼리 응집도가 높은 느낌 

 

자주 사용되는 로직들은 커스텀 훅으로 따로 분류

hooks > useTodoState.ts

import { useContext } from "react";
import { todoStateContext } from "@contexts/todo/TodoContext";

// context에서 제공된 전역 todoState를 return 하는 hook
export default function useTodoState() {
  const todoState = useContext(todoStateContext);
  if (!todoState) throw new Error("cannot find Provider");
  return todoState;
}

hooks > useDispatch.ts

import { useContext } from "react";
import { dispatchContext } from "@contexts/todo/TodoContext";

// context에서 제공된 todo의 state를 변경하는 dispatch 함수를 return 하는 hook
export default function useDispatch() {
  const dispatch = useContext(dispatchContext);
  if (!dispatch) throw new Error("cannot find provider");
  return dispatch;
}

Axios 인스턴스와 인터셉터 사용

utils > api > customAxios.ts

import axios from "axios";

interface CustomError {
  status: number;
  message: string;
}

const token = localStorage.getItem("access_token");
const BASE_URL = "https://www.~~서버주소~~~shop/";

export const api = axios.create({
  baseURL: BASE_URL,
  timeout: 5000,
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
});

//요청 API
api.interceptors.request.use(
  (config) => {

    if (token && config.headers) {
      config.headers["Authorization"] = `Bearer ${token}`;
    }
    return config;
  },
  (err) => {
    if (err.response) {
      const { status, data } = err.response;
      const errResponse: CustomError = {
        status,
        message: data.message,
      };
      return Promise.reject(errResponse);
    }
    return Promise.reject(err);
  }
);

//응답 API
api.interceptors.response.use(
  (response) => {
    return response;
  },
  (err) => {
    if (err.response) {
      const { status, data } = err.response;
      const errResponse: CustomError = {
        status,
        message: data.message,
      };
      return Promise.reject(errResponse);
    }
    return Promise.reject(err);
  }
);

export default api;
  • create를 통해 인스턴스를 생성하여 중복 코드(e.g. baseURL)를 줄였다.
  • interceptor를 통해 요청값에 config를 원하는 형식으로 설정해주고 요청, 응답 과정에서 에러가 났을 경우, 커스텀 에러를 리턴하도록 만들었다. 

로그인  & 회원가입 페이지 구현

로그인과 회원가입 두 페이지는 로직과 뷰 자체를 분리하지 못하고 한 곳에 몰아넣었기 때문에 좋은 코드는 아니라고 생각한다.
  • 유효성 검사 로직은 utils > validation 에 따로 분리하였다. 

components >  SignUp > index.tsx

import React, { useState, useCallback } from "react";
import { useRouter } from "@hooks/useRouter";
import api from "@utils/api/customAxios";
import { check_email, check_password } from "@utils/validation/userInfoRegex";
import { BtnWrapper, Error, FormCont, Input } from "@pages/SignIn/styles";
import useAuth from "@hooks/useAuth";
import { Navigate, Link } from "react-router-dom";

// 회원가입
const SignUp = () => {
  const { auth } = useAuth();
  const { routeTo } = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const [emailErrorMsg, setEmailErrorMsg] = useState<string | null>("");
  const [passwordErrorMsg, setPasswordErrorMsg] = useState<string | null>("");

  const [signInErorrMsg, setSignInErrorMsg] = useState("");

  const onChangeEmail = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const { value } = e.target;
      setEmail(value);
      setEmailErrorMsg(
        check_email(value) ? null : "유효한 이메일을 입력해주세요."
      );
    },
    []
  );
  const onChangePassword = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const { value } = e.target;
      setPassword(value);
      setPasswordErrorMsg(
        check_password(value) ? null : "비밀번호는 8자 이상이어야 합니다."
      );
    },
    []
  );

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const trimmedEmail = email.trim();
    const trimmedPassword = password.trim();

    setSignInErrorMsg("");
    api
      .post("auth/signup", { email: trimmedEmail, password: trimmedPassword })
      .then((response) => {
        if (response.status === 201) {
          alert("회원가입 성공!");
          routeTo("/signin");
        }
      })
      .catch((error) => {
        setSignInErrorMsg(error.message);
        console.log(error);
      });
  };

  const isButtonDisabled = emailErrorMsg !== null || passwordErrorMsg !== null;

  return auth ? (
    <Navigate to="/todo" replace />
  ) : (
    <>
      <h2>회원가입 페이지</h2>
      <FormCont onSubmit={handleSubmit}>
        <label htmlFor="email">Username:</label>
        <Input
          data-testid="email-input"
          type="email"
          id="email"
          value={email}
          onChange={onChangeEmail}
          placeholder="abc@email.com"
        />
        {emailErrorMsg && <Error>{emailErrorMsg}</Error>}

        <label htmlFor="password">Password:</label>
        <Input
          data-testid="password-input"
          type="password"
          id="password"
          value={password}
          onChange={onChangePassword}
          placeholder="8자리 이상 입력해주세요."
          minLength={8}
        />
        {passwordErrorMsg && <Error>{passwordErrorMsg}</Error>}

        <BtnWrapper
          type="submit"
          disabled={!!isButtonDisabled}
          data-testid="signup-button">
          회원가입
        </BtnWrapper>
        {signInErorrMsg && <Error>{signInErorrMsg}</Error>}
      </FormCont>
      <Link to="/signin">로그인 하러가기</Link>
    </>
  );
};

export default SignUp;
댓글