Spring Security + JWT + React - 02. 프론트엔드 - 1. 구조 / 상태관리

june·2022년 6월 7일
5
post-thumbnail

프론트엔드

구조

사용 스펙

언어

주요 언어로 TypeScript를 사용했다. 코드 작성 과정 중에서 넣고 반환하는 타입의 과정을 명확하게 설정하고, 재사용성을 향상시키기 위해 설정했다.

React

18.1.0 버전을 사용했다.

Router

React Router Dom을 사용했으며 6.3.0버전을 사용했다.

v6로 가면서 많은 문법이 바뀌었으나, 적용하는 데 크게 어려움이 없다고 느껴 v6로 사용했다.

전역 상태 관리

React 내부 기능인 Context API를 사용했다.

Redux와 같은 라이브러리를 많이 사용하지만, TypeScript를 완벽하게 다루지 못하는 입장에선 Redux에서 비동기 상태의 Aciton type관리가 힘들어서 좀 더 자유롭게 쓸 수 있는 Context API를 사용하게 되었다.

구현기능

  • 회원가입
  • 로그인
  • Token을 통한 회원 정보 API통신
  • nickname, password 변경
  • 로그아웃

회원가입

회원가입 컴포넌트를 통해 form으로 구현한 정보를 post로 넘긴다.

로그인

로그인 컴포넌트를 통해 form으로 구현한 정보를 post로 넘긴다.

로그인이 성공할 시, 메인 페이지로 리다이렉트 되며, api로 받은 토큰값은 localStorage와 전역상태로 저장되게 된다.

토큰의 만료시간 또한 파악하여 state에 저장하게 하고, 시간이 다 되었을 경우 로그아웃 로직을 실행하게 한다.

Token을 통한 회원정보 API통신

로그인이 성공할 경우, 네비게이션 바와 메인페이지에 회원의 닉네임을 표시하는 컴포넌트를 구현한다.

이 때 서버에게 회원의 정보를 넘겨달라는 요청을 보내게 되며, 그것을 받아 표시한다.

nickname, password 변경

각각 컴포넌트를 통해 form으로 구현한 정보를 토큰 헤더와 함께 post로 넘긴다.

단 비밀번호의 경우는, 바꾸기 전 비밀번호를 입력해야 하며, 변경이 성공적으로 이루어진다면 로그아웃이 된다.

로그아웃

localStorage에 저장되어 있는 토큰을 지우고, 전역상태에 있는 토큰값 또한 초기화 한다.

화면

로그아웃 상태

네비게이션 바에 홈, 로그인, 회원가입 링크가 떠 있으며, 홈화면에는 아무것도 없다

/login

/signup

각각 링크를 클릭하면 해당 컴포넌트가 나타남과 동시에 해당 url로 이동한다.

또한, 로그인을 했을 때 해당 url로 들어가려 하면 홈화면으로 반환된다.

로그인 상태

네비게이션 바에 홈, 마이프로필(member 닉네임), 로그아웃 버튼이 있으며, 홈 화면에 닉네임이 표시된다.

/profile

마찬가지로 로그아웃 상태에서는 해당 url로 들어가려 하면 홈화면으로 반환된다.

의존성

package.json

...
  "dependencies": {
    "@reduxjs/toolkit": "^1.8.1",
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^13.0.0",
    "@testing-library/user-event": "^13.2.1",
    "@types/jest": "^27.0.1",
    "@types/node": "^16.7.13",
    "@types/react": "^18.0.0",
    "@types/react-bootstrap-table-next": "^4.0.18",
    "@types/react-dom": "^18.0.0",
    "@types/react-router-dom": "^5.3.3",
    "axios": "^0.27.2",
    "bootstrap": "^5.1.3",
    "date-fns": "^2.28.0",
    "express": "^4.18.1",
    "http-proxy-middleware": "^2.0.6",
    "react": "^18.1.0",
    "react-bootstrap": "^2.4.0",
    "react-bootstrap-table-next": "^4.0.3",
    "react-dom": "^18.1.0",
    "react-query": "^3.39.1",
    "react-redux": "^8.0.2",
    "react-router-dom": "^6.3.0",
    "react-scripts": "5.0.1",
    "redux": "^4.2.0",
    "typescript": "^4.4.2",
    "web-vitals": "^2.1.0"
  }
...

상태 관리

Fetch 추상화

axios를 쓰고 에러캐치를 하는 구조가 같으며 많이 반복되어 있기 때문에, 따로 ts파일을 만들어서 추상화 했다.

import axios, { AxiosError,AxiosResponse }  from 'axios';

type ServerError = { errorMessage: string };
type LoginFailType = { status: number, error: string,};

interface FetchData {
  method: string,
  url: string,
  data? : {},
  header : {},
}

const fetchAuth = async (fetchData: FetchData) => {
  const method = fetchData.method;
  const url = fetchData.url;
  const data = fetchData.data;
  const header = fetchData.header;
  
  try {
    const response:AxiosResponse<any, any> | false =
    (method === 'get' && (await axios.get(url, header))) ||
    (method === 'post' && (await axios.post(url, data, header))) ||
    (method === 'put' && (await axios.put(url, data, header))) ||
    (method === 'delete' && (await axios.delete(url, header))
    );
    
    if(response && response.data.error) {
      console.log((response.data as LoginFailType).error);
      alert("Wrong ID or Password");
      return null;
    }

    if (!response) {
      alert("false!");
      return null;
    }

    return response;

  } catch(err) {
    
    if (axios.isAxiosError(err)) {
      const serverError = err as AxiosError<ServerError>;
      if (serverError && serverError.response) {
        console.log(serverError.response.data);
        alert("failed!");
        return null;
      }
    }

    console.log(err);
    alert("failed!");
    return null;
  }
  
}

const GET = ( url:string, header:{} ) => {
  const response = fetchAuth({ method: 'get', url, header });
  return response;
};

const POST = ( url:string, data: {}, header:{}) => {
  const response = fetchAuth({ method: 'post', url, data, header })
  return response;
};

const PUT = async ( url:string, data: {}, header:{}) => {
  const response = fetchAuth({ method: 'put', url, data, header });
  return response;
};

const DELETE = async ( url:string, header:{} ) => {
  const response = fetchAuth({ method: 'delete', url, header });
  return response;
};

export { GET, POST, PUT, DELETE }

Rest API에서 주로 쓰이는 GET, POST, PUT, DELETE를 각각 메소드로 분리했고,
에러를 catch하는 부분만 따로 추상화를 해서, 제시되는 메소드 변수에 따라 다른 로직이 구현되도록 했다.

또한 에러가 캐치되면 모두 null을 반환하게 했다.

따라서 각각의 메소드들은 response로 Promise<AxiosResponse<any, any> | null>을 반환하게 된다.

action 로직 분리.

이후 Context API에 모든 로직을 넣어도 되지만, Context 하나에 너무 많은 concerns를 부여하게 되면, 재사용성이 떨어지고, 유지보수가 힘들어지기 때문에,

Rest API 호출/응답, localStorage 토큰 저장과 같은 side effect를 불러일으킬 수 있는 action들을 따로 분리하여 ts파일로 만들었고 (react와 분리)

이후 action을 함수로 호출하여, Context API로 호출하여 전역 상태와 연결하는 식으로 로직을 구현했다.

/store/auth-action.ts

import { GET, POST }  from "./fetch-auth-action";

const createTokenHeader = (token:string) => {
  return {
    headers: {
      'Authorization': 'Bearer ' + token
    }
  }
}

const calculateRemainingTime = (expirationTime:number) => {
  const currentTime = new Date().getTime();
  const adjExpirationTime = new Date(expirationTime).getTime();
  const remainingDuration = adjExpirationTime - currentTime;
  return remainingDuration;
};

export const loginTokenHandler = (token:string, expirationTime:number) => {
  localStorage.setItem('token', token);
  localStorage.setItem('expirationTime', String(expirationTime));

  const remainingTime = calculateRemainingTime(expirationTime);
  return remainingTime;
}

export const retrieveStoredToken = () => {
  const storedToken = localStorage.getItem('token');
  const storedExpirationDate = localStorage.getItem('expirationTime') || '0';

  const remaingTime = calculateRemainingTime(+ storedExpirationDate);

  if(remaingTime <= 1000) {
    localStorage.removeItem('token');
    localStorage.removeItem('expirationTime');
    return null
  }

  return {
    token: storedToken,
    duration: remaingTime
  }
}

export const signupActionHandler = (email: string, password: string, nickname: string) => {
  const URL = '/auth/signup'
  const signupObject = { email, password, nickname };
  
  const response = POST(URL, signupObject, {});
  return response;
};

export const loginActionHandler = (email:string, password: string) => {
  const URL = '/auth/login';
  const loginObject = { email, password };
  const response = POST(URL, loginObject, {});

  return response;
};

export const logoutActionHandler = () => {
  localStorage.removeItem('token');
  localStorage.removeItem('expirationTime');
};

export const getUserActionHandler = (token:string) => {
  const URL = '/member/me';
  const response = GET(URL, createTokenHeader(token));
  return response;
}

export const changeNicknameActionHandler = ( nickname:string, token: string) => {
  const URL = '/member/nickname';
  const changeNicknameObj = { nickname };
  const response = POST(URL, changeNicknameObj, createTokenHeader(token));

  return response;
}

export const changePasswordActionHandler = (
  exPassword: string,
  newPassword: string,
  token: string
) => {
  const URL = '/member/password';
  const changePasswordObj = { exPassword, newPassword }
  const response = POST(URL, changePasswordObj, createTokenHeader(token));
  return response;
}

함수 하나하나를 설명해보자.

createTokenHeader

const createTokenHeader = (token:string) => {
  return {
    headers: {
      'Authorization': 'Bearer ' + token
    }
  }
}

토큰을 만드는 함수이며, auth-action.ts내부에서만 사용한다.

calculateRemainingTime

const calculateRemainingTime = (expirationTime:number) => {
  const currentTime = new Date().getTime();
  const adjExpirationTime = new Date(expirationTime).getTime();
  const remainingDuration = adjExpirationTime - currentTime;
  return remainingDuration;
};

토큰의 만료시간을 계산하는 함수이며, auth-action.ts내부에서만 사용한다.

loginTokenHandler

export const loginTokenHandler = (token:string, expirationTime:number) => {
  localStorage.setItem('token', token);
  localStorage.setItem('expirationTime', String(expirationTime));

  const remainingTime = calculateRemainingTime(expirationTime);
  return remainingTime;
}

토큰값과 만료시간을 부여받으면 그것을 localStorage내부에 저장해주는 함수다.

남은 시간을 반환해준다.

retrieveStoredToken

export const retrieveStoredToken = () => {
  const storedToken = localStorage.getItem('token');
  const storedExpirationDate = localStorage.getItem('expirationTime') || '0';

  const remaingTime = calculateRemainingTime(+ storedExpirationDate);

  if(remaingTime <= 1000) {
    localStorage.removeItem('token');
    localStorage.removeItem('expirationTime');
    return null
  }

  return {
    token: storedToken,
    duration: remaingTime
  }
}

localStorage내부에 토큰이 존재하는지 검색하는 함수다.

만약 존재한다면, 만료까지 남은 시간과 토큰값을 같이 객체로 반환한다.

또한 만약 시간이 1초 아래로 남았으면 자동으로 토큰을 삭제해준다

signupActionHandler

export const signupActionHandler = (email: string, password: string, nickname: string) => {
  const URL = '/auth/signup'
  const signupObject = { email, password, nickname };
  
  const response = POST(URL, signupObject, {});
  return response;
};

회원가입 URL로 POST 방식으로 호출하는 함수다.

통신으로 반환된 response를 반환한다.

앞서 말했듯이 반환 타입은 Promise<AxiosResponse<any, any> | null>다.

loginActionHandler

export const loginActionHandler = (email:string, password: string) => {
  const URL = '/auth/login';
  const loginObject = { email, password };
  const response = POST(URL, loginObject, {});

  return response;
};

마찬가지로 로그인 URL을 POST방식으로 호출하는 함수다.

logoutActionHandler

export const logoutActionHandler = () => {
  localStorage.removeItem('token');
  localStorage.removeItem('expirationTime');
};

로그아웃을 해주는 함수다.

localStorage에 저장된 토큰과 만료시간을 삭제한다.

getUserActionHandler

export const getUserActionHandler = (token:string) => {
  const URL = '/member/me';
  const response = GET(URL, createTokenHeader(token));
  return response;
}

유저의 정보를 GET방식으로 호출하는 함수다.

토큰값을 헤더에 넣고 호출한다.

마찬가지로 Promise객체인 response를 반환한다.

changeNicknameActionHandler, changePasswordActionHandler

export const changeNicknameActionHandler = ( nickname:string, token: string) => {
  const URL = '/member/nickname';
  const changeNicknameObj = { nickname };
  const response = POST(URL, changeNicknameObj, createTokenHeader(token));

  return response;
}

export const changePasswordActionHandler = (
  exPassword: string,
  newPassword: string,
  token: string
) => {
  const URL = '/member/password';
  const changePasswordObj = { exPassword, newPassword }
  const response = POST(URL, changePasswordObj, createTokenHeader(token));
  return response;
}

닉네임과 패스워드를 바꿔주는 함수들.

둘다 token값을 헤더에 붙여줘서 POST방식으로 호출하나

닉네임에는 바꿀 닉네임만 값으로 보내주면 되지만, 패스워드는 전의 패스워드와 현재의 패스워드 둘다 보내줘야한다.

Promise객체인 response를 반환한다.

Context

이제 로그인에 관련된 사이드 이펙트에 관련된 액션을 분리했으니, 그 액션들을 함수로 불러와서 전역상태 / useEffect와 같은 리액트의 로직과 결합을 시켜보자.

/store/auth-context.tsx

import React, { useState, useEffect, useCallback } from "react";
import * as authAction from './auth-action'; 

let logoutTimer: NodeJS.Timeout;

type Props = { children?: React.ReactNode }
type UserInfo = { email: string, nickname: string};
type LoginToken = { 
  grantType: string,
  accessToken: string,
  tokenExpiresIn: number
}

const AuthContext = React.createContext({
  token: '',
  userObj: { email: '', nickname: '' },
  isLoggedIn: false,
  isSuccess: false,
  isGetSuccess: false,
  signup: (email: string, password: string, nickname:string) =>  {},
  login: (email:string, password: string) => {},
  logout: () => {},
  getUser: () => {},
  changeNickname: (nickname:string) => {},
  changePassword: (exPassword: string, newPassword: string) => {}
});


export const AuthContextProvider:React.FC<Props> = (props) => {

  const tokenData = authAction.retrieveStoredToken();

  let initialToken:any;
  if (tokenData) {
    initialToken = tokenData.token!;
  }

  const [token, setToken] = useState(initialToken);
  const [userObj, setUserObj] = useState({
    email: '',
    nickname: ''
  });
  
  const [isSuccess, setIsSuccess] = useState<boolean>(false);
  const [isGetSuccess, setIsGetSuccess ] = useState<boolean>(false);

  const userIsLoggedIn = !!token;


  
  const signupHandler = (email:string, password: string, nickname: string) => {
    setIsSuccess(false);
    const response = authAction.signupActionHandler(email, password, nickname);
    response.then((result) => {
      if (result !== null) {
        setIsSuccess(true);
      }
    });
  }

  const loginHandler = (email:string, password: string) => {
    setIsSuccess(false);
    console.log(isSuccess);
    
    const data = authAction.loginActionHandler(email, password);
    data.then((result) => {
      if (result !== null) {
        const loginData:LoginToken = result.data;
        setToken(loginData.accessToken);
        logoutTimer = setTimeout(
          logoutHandler,
          authAction.loginTokenHandler(loginData.accessToken, loginData.tokenExpiresIn)
        );
        setIsSuccess(true);
        console.log(isSuccess);
      }
    })
  };

  const logoutHandler = useCallback(() => {
    setToken('');
    authAction.logoutActionHandler();
    if (logoutTimer) {
      clearTimeout(logoutTimer);
    }
  }, []);

  const getUserHandler = () => {
    setIsGetSuccess(false);
    const data = authAction.getUserActionHandler(token);
    data.then((result) => {
      if (result !== null) {
        console.log('get user start!');
        const userData:UserInfo = result.data;
        setUserObj(userData);
        setIsGetSuccess(true);
      }
    })    
    
  };

  const changeNicknameHandler = (nickname:string) => {
    setIsSuccess(false);

    const data = authAction.changeNicknameActionHandler(nickname, token);
    data.then((result) => {
      if (result !== null) {
        const userData:UserInfo = result.data;
        setUserObj(userData);
        setIsSuccess(true);
      }
    })
  };

  const changePaswordHandler = (exPassword:string, newPassword: string) => {
    setIsSuccess(false);
    const data = authAction.changePasswordActionHandler(exPassword, newPassword, token);
    data.then((result) => {
      if (result !== null) {
        setIsSuccess(true);
        logoutHandler();
      }
    });
  };

  useEffect(() => {
    if(tokenData) {
      console.log(tokenData.duration);
      logoutTimer = setTimeout(logoutHandler, tokenData.duration);
    }
  }, [tokenData, logoutHandler]);


  const contextValue = {
    token,
    userObj,
    isLoggedIn: userIsLoggedIn,
    isSuccess,
    isGetSuccess,
    signup: signupHandler,
    login: loginHandler,
    logout: logoutHandler,
    getUser: getUserHandler,
    changeNickname: changeNicknameHandler,
    changePassword: changePaswordHandler
  }
  
  return(
    <AuthContext.Provider value={contextValue}>
      {props.children}
    </AuthContext.Provider>
  )
}

export default AuthContext;

마찬가지로 하나하나 보자.

AuthContext - createContext

const AuthContext = React.createContext({
  token: '',
  userObj: { email: '', nickname: '' },
  isLoggedIn: false,
  isSuccess: false,
  isGetSuccess: false,
  signup: (email: string, password: string, nickname:string) =>  {},
  login: (email:string, password: string) => {},
  logout: () => {},
  getUser: () => {},
  changeNickname: (nickname:string) => {},
  changePassword: (exPassword: string, newPassword: string) => {}
});

createContext는 각각의 컴포넌트에 포함되는 객체를 만드는 로직이다.

객체안에는 state와 state를 컨트롤 하는 함수를 넣는다.

이후 이 state와 함수들은 인스턴스로 불러오게 된다.

AuthContextProvider

export const AuthContextProvider:React.FC<Props> = (props) => {
	...
  const contextValue = {
    token,
    userObj,
    ...
  }
  
  return(
    <AuthContext.Provider value={contextValue}>
      {props.children}
    </AuthContext.Provider>
  )
}

Context의 Provider 역할, 즉 Context의 변화를 알리는 Provider 컴포넌트를 반환하는 함수다.

Provider의 value로는 생성하거나 로직을 구현한 state와 함수들을 넣어주고, props.children을 통해 wrapping될 모든 컴포넌트에게 적용되게 한다.

여기서는 index.tsx를 wrapping할 예정이므로 모든 tsx에게 적용이 된다.

const tokenData = authAction.retrieveStoredToken();

let initialToken:any;
if (tokenData) {
  initialToken = tokenData.token!;
}

const [token, setToken] = useState(initialToken);
const [userObj, setUserObj] = useState({
  email: '',
  nickname: ''
});

const [isSuccess, setIsSuccess] = useState<boolean>(false);
const [isGetSuccess, setIsGetSuccess ] = useState<boolean>(false);

let userIsLoggedIn = !!token;

tokenData는 authAction.retrieveStoredToken을 통해 token을 확인하는 함수를 실행하여 안의 값을 넣어준다.

만약 존재하게 된다면 initialToken의 값은 tokenDatatoken값이 된다.

여기서 다시 tokentoken이라는 상태에 넣어준다.

userObj는 사용자의 정보를 담기 위한 객체이며, isSuccessisGetsuccess는 정확히 데이터가 나왔는지, 비동기 시스템에서의 처리를 위한 상태이다.

userIsLoggedIn은 반환하는 boolean값이며, token이 존재하냐 안하냐에 따라 값이 변한다.

signupHandler

  const signupHandler = (email:string, password: string, nickname: string) => {
    setIsSuccess(false);
    const response = authAction.signupActionHandler(email, password, nickname);
    response.then((result) => {
      if (result !== null) {
        setIsSuccess(true);
      }
    });
  }

회원가입을 하는 함수다.

form에서 받은 email, password, nickname을 받아서 auth-action.tssignupActionHandler에 넣어준다.

이후 받은 Promise객체인 response를 비동기처리를 통해 Promise내부의 result가 null이 아닐경우, 즉 error가 없을 경우 isSuccess의 상태를 변화시켜서 성공했음을 나타낸다.

loginHandler

  const loginHandler = (email:string, password: string) => {
    setIsSuccess(false);

    const data = authAction.loginActionHandler(email, password);
    data.then((result) => {
      if (result !== null) {
        const loginData:LoginToken = result.data;
        setToken(loginData.accessToken);
        logoutTimer = setTimeout(
          logoutHandler,
          authAction.loginTokenHandler(loginData.accessToken, loginData.tokenExpiresIn)
        );
        setIsSuccess(true);
      }
    })
  };

로그인도 이와 비슷한 로직이지만, auth-action.tsloginActionHandler에서 받아온 데이터에서 토큰을 추출해내서

전역상태에 token의 값을 설정하고, logoutTimersetTimeout을 통해 만료 시간이 지나면 logoutHandler를 통해 로그아웃을 실행하게 만든다.

그리고 그 만료시간은 auth-action.tsloginTokenHandler에 토큰과 토큰 만료일을 넣고 반환된 값을 기준으로 삼는다.

logoutHandler

  const logoutHandler = useCallback(() => {
    setToken('');
    authAction.logoutActionHandler();
    if (logoutTimer) {
      clearTimeout(logoutTimer);
    }
  }, []);

먼저 이 함수는 이후 useEffect를 통해 토큰이 없어지면 자동으로 로그아웃을 실행하게 할 것이므로, 무한루프를 막기 위해 useCallback으로 감싸준다.

이후 token상태를 빈값으로 만들어주고, auth-action.tslogoutActionHandlerlocalStorage의 토큰값을 지우게 만든다음, logoutTimer가 존재한다면 Timer또한 지워준다.

getUserHandler

  const getUserHandler = () => {
    setIsGetSuccess(false);
    const data = authAction.getUserActionHandler(token);
    data.then((result) => {
      if (result !== null) {
        const userData:UserInfo = result.data;
        setUserObj(userData);
        setIsGetSuccess(true);
      }
    })    
  };

auth-action.tsgetUserActionHandler에 전역 상태에 있는 token의 값을 넣어주고 Promise객체인 data를 받는다.

이후 data가 null이 아닐 경우 안의 객체를 뽑아내, userObj 상태에 객체를 넣는다.

changeNicknameHandler

  const changeNicknameHandler = (nickname:string) => {
    setIsSuccess(false);

    const data = authAction.changeNicknameActionHandler(nickname, token);
    data.then((result) => {
      if (result !== null) {
        const userData:UserInfo = result.data;
        setUserObj(userData);
        setIsSuccess(true);
      }
    })
  };

함수 자체에서 받은 변수인 nickname과 전역상태 tokenauth-action.tschangeNicknameActionHandler에 넣고 Promise객체인 data를 받는다.

이후 data가 null이 아닐 경우 안의 객체를 뽑아내, userObj 상태에 객체를 넣는다.

changePaswordHandler

  const changePaswordHandler = (exPassword:string, newPassword: string) => {
    setIsSuccess(false);
    const data = authAction.changePasswordActionHandler(exPassword, newPassword, token);
    data.then((result) => {
      if (result !== null) {
        setIsSuccess(true);
        logoutHandler();
      }
    });
  };

changeNicknameHandler와 유사하지만, 다만 이것은 제대로 에러 없이 실행될 경우 logoutHandler를 실행시킨다.

useEffect

  useEffect(() => {
    if(tokenData) {
      console.log(tokenData.duration);
      logoutTimer = setTimeout(logoutHandler, tokenData.duration);
    }
  }, [tokenData, logoutHandler]);

retrieveStoredToken로 받은 token값과, logoutHandler를 종속변수로 삼는 useEffect훅이다.

이를 통해 만료시간이 될 경우 자동으로 logoutHandler를 실행시킨다.


이제 Context 부분, 즉 실제 실행에 관련된 부분은 거의 끝났다. 이제 이것을 실제 컴포넌트에 적용해보자.

profile
초보 개발자

9개의 댓글

comment-user-thumbnail
2022년 7월 11일

안녕하세요! 글 잘봤습니다
혹시 타입스크립트 리액트와 스프링부트 연동을 어떻게 했는지 알 수 있을까요?

1개의 답글
comment-user-thumbnail
2022년 8월 2일

깃허브 링크좀 알 수 있을가요?

1개의 답글
comment-user-thumbnail
2023년 1월 1일

안녕하세요 선생님,
글보고 열심히 따라가보려고 합니다.
혹시 리액트 프로젝트 안에서 추가하신 라이브러리들을 알 수 있을까요?
npm install 하신것들요 ㅎㅎ

아 그리고 .tsx 를 붙히지 않으신 파일은 모두 .js 파일 맞나요?
제가 타입스크립트를 처음 공부하는거라서...

1개의 답글
comment-user-thumbnail
2023년 2월 3일

센세 글 보고 잘 정리하고 있습니다.
제가 이전에 했던 거에서 더하고 있는데
파일 형식 차이가 있어 문의를 드립니다.
저같은 경우엔 로그인,회원가입 파일을 전부 다 js로 작업했는데
그러다보니 tsx로 옮기는 데에 약간 에러가 있습니다.
혹시 js타입으로 옮긴다면 어떻게 수정해야하는지 여쭤봐도 될까요??

1개의 답글
comment-user-thumbnail
2023년 10월 18일

안녕하세요! 글 잘보고 있습니다.
혹시 보안문제 수정이 끝나셨으면 깃허브 링크 알 수 있을까용?

답글 달기