주요 언어로 TypeScript
를 사용했다. 코드 작성 과정 중에서 넣고 반환하는 타입의 과정을 명확하게 설정하고, 재사용성을 향상시키기 위해 설정했다.
18.1.0 버전을 사용했다.
React Router Dom
을 사용했으며 6.3.0버전을 사용했다.
v6로 가면서 많은 문법이 바뀌었으나, 적용하는 데 크게 어려움이 없다고 느껴 v6로 사용했다.
React 내부 기능인 Context API를 사용했다.
Redux와 같은 라이브러리를 많이 사용하지만, TypeScript
를 완벽하게 다루지 못하는 입장에선 Redux에서 비동기 상태의 Aciton type관리가 힘들어서 좀 더 자유롭게 쓸 수 있는 Context API를 사용하게 되었다.
회원가입 컴포넌트를 통해 form으로 구현한 정보를 post로 넘긴다.
로그인 컴포넌트를 통해 form으로 구현한 정보를 post로 넘긴다.
로그인이 성공할 시, 메인 페이지로 리다이렉트 되며, api로 받은 토큰값은 localStorage
와 전역상태로 저장되게 된다.
토큰의 만료시간 또한 파악하여 state에 저장하게 하고, 시간이 다 되었을 경우 로그아웃 로직을 실행하게 한다.
로그인이 성공할 경우, 네비게이션 바와 메인페이지에 회원의 닉네임을 표시하는 컴포넌트를 구현한다.
이 때 서버에게 회원의 정보를 넘겨달라는 요청을 보내게 되며, 그것을 받아 표시한다.
각각 컴포넌트를 통해 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"
}
...
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>
을 반환하게 된다.
이후 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;
}
함수 하나하나를 설명해보자.
const createTokenHeader = (token:string) => {
return {
headers: {
'Authorization': 'Bearer ' + token
}
}
}
토큰을 만드는 함수이며, auth-action.ts
내부에서만 사용한다.
const calculateRemainingTime = (expirationTime:number) => {
const currentTime = new Date().getTime();
const adjExpirationTime = new Date(expirationTime).getTime();
const remainingDuration = adjExpirationTime - currentTime;
return remainingDuration;
};
토큰의 만료시간을 계산하는 함수이며, auth-action.ts
내부에서만 사용한다.
export const loginTokenHandler = (token:string, expirationTime:number) => {
localStorage.setItem('token', token);
localStorage.setItem('expirationTime', String(expirationTime));
const remainingTime = calculateRemainingTime(expirationTime);
return remainingTime;
}
토큰값과 만료시간을 부여받으면 그것을 localStorage
내부에 저장해주는 함수다.
남은 시간을 반환해준다.
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초 아래로 남았으면 자동으로 토큰을 삭제해준다
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>
다.
export const loginActionHandler = (email:string, password: string) => {
const URL = '/auth/login';
const loginObject = { email, password };
const response = POST(URL, loginObject, {});
return response;
};
마찬가지로 로그인 URL을 POST방식으로 호출하는 함수다.
export const logoutActionHandler = () => {
localStorage.removeItem('token');
localStorage.removeItem('expirationTime');
};
로그아웃을 해주는 함수다.
localStorage
에 저장된 토큰과 만료시간을 삭제한다.
export const getUserActionHandler = (token:string) => {
const URL = '/member/me';
const response = GET(URL, createTokenHeader(token));
return response;
}
유저의 정보를 GET방식으로 호출하는 함수다.
토큰값을 헤더에 넣고 호출한다.
마찬가지로 Promise객체인 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;
}
닉네임과 패스워드를 바꿔주는 함수들.
둘다 token값을 헤더에 붙여줘서 POST방식으로 호출하나
닉네임에는 바꿀 닉네임만 값으로 보내주면 되지만, 패스워드는 전의 패스워드와 현재의 패스워드 둘다 보내줘야한다.
Promise객체인 response를 반환한다.
이제 로그인에 관련된 사이드 이펙트에 관련된 액션을 분리했으니, 그 액션들을 함수로 불러와서 전역상태 / 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;
마찬가지로 하나하나 보자.
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와 함수들은 인스턴스로 불러오게 된다.
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
의 값은 tokenData
의 token
값이 된다.
여기서 다시 token
을 token
이라는 상태에 넣어준다.
userObj
는 사용자의 정보를 담기 위한 객체이며, isSuccess
와 isGetsuccess
는 정확히 데이터가 나왔는지, 비동기 시스템에서의 처리를 위한 상태이다.
userIsLoggedIn
은 반환하는 boolean값이며, 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);
}
});
}
회원가입을 하는 함수다.
form에서 받은 email, password, nickname을 받아서 auth-action.ts
의 signupActionHandler
에 넣어준다.
이후 받은 Promise객체인 response를 비동기처리를 통해 Promise내부의 result가 null이 아닐경우, 즉 error가 없을 경우 isSuccess
의 상태를 변화시켜서 성공했음을 나타낸다.
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.ts
의loginActionHandler
에서 받아온 데이터에서 토큰을 추출해내서
전역상태에 token의 값을 설정하고, logoutTimer
에 setTimeout
을 통해 만료 시간이 지나면 logoutHandler
를 통해 로그아웃을 실행하게 만든다.
그리고 그 만료시간은 auth-action.ts
의 loginTokenHandler
에 토큰과 토큰 만료일을 넣고 반환된 값을 기준으로 삼는다.
const logoutHandler = useCallback(() => {
setToken('');
authAction.logoutActionHandler();
if (logoutTimer) {
clearTimeout(logoutTimer);
}
}, []);
먼저 이 함수는 이후 useEffect
를 통해 토큰이 없어지면 자동으로 로그아웃을 실행하게 할 것이므로, 무한루프를 막기 위해 useCallback
으로 감싸준다.
이후 token
상태를 빈값으로 만들어주고, auth-action.ts
의 logoutActionHandler
로 localStorage
의 토큰값을 지우게 만든다음, logoutTimer
가 존재한다면 Timer
또한 지워준다.
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.ts
의 getUserActionHandler
에 전역 상태에 있는 token
의 값을 넣어주고 Promise객체인 data를 받는다.
이후 data
가 null이 아닐 경우 안의 객체를 뽑아내, userObj
상태에 객체를 넣는다.
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
과 전역상태 token
을 auth-action.ts
의 changeNicknameActionHandler
에 넣고 Promise객체인 data를 받는다.
이후 data
가 null이 아닐 경우 안의 객체를 뽑아내, userObj
상태에 객체를 넣는다.
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(() => {
if(tokenData) {
console.log(tokenData.duration);
logoutTimer = setTimeout(logoutHandler, tokenData.duration);
}
}, [tokenData, logoutHandler]);
retrieveStoredToken
로 받은 token값과, logoutHandler
를 종속변수로 삼는 useEffect
훅이다.
이를 통해 만료시간이 될 경우 자동으로 logoutHandler
를 실행시킨다.
이제 Context 부분, 즉 실제 실행에 관련된 부분은 거의 끝났다. 이제 이것을 실제 컴포넌트에 적용해보자.
안녕하세요 선생님,
글보고 열심히 따라가보려고 합니다.
혹시 리액트 프로젝트 안에서 추가하신 라이브러리들을 알 수 있을까요?
npm install 하신것들요 ㅎㅎ
아 그리고 .tsx 를 붙히지 않으신 파일은 모두 .js 파일 맞나요?
제가 타입스크립트를 처음 공부하는거라서...
안녕하세요! 글 잘봤습니다
혹시 타입스크립트 리액트와 스프링부트 연동을 어떻게 했는지 알 수 있을까요?