프로젝트 소개
완성된 코드 보러가기
https://github.com/bluepebble25/react-login

최근 styled-component와 Typescript, TDD를 연습하고자 프로젝트를 하나 해보기로 했다.
프론트엔드의 중요 개념들을 연습하기에는 로그인 제대로 만들어보기만한게 없는 것 같아서 로그인을 구현해보려고 했다. 그런데 통신과 인증을 연습하기 위해서는 더미 데이터가 아니라 API를 하나 만드는 게 좋을 것 같았다. API를 만들자면 못 만들것도 없지만 인증과 예외처리를 구현하자니 머리가 아팠다. 배보다 배꼽이 더 큰 것 같던 차에, 마침 감사하게도 개발바닥의 leejaycoke님께서 배포하셨던 API (https://github.com/leejaycoke/pilot-react)를 발견해서 도커로 돌릴 수 있었다.
(감사합니다!)
나도 저 프로젝트에서 제시되었던 조건들에 덧붙여 나름 내가 이번 프로젝트에서 지킬 요구사항 및 달성할 목표를 아래와 같이 정해보았다.
- React 레이어 나눠서 데이터 요청하기
- Typescript
- CSS-in-JS (emotion으로 결정!)
- TDD (jest 기반 react-test-library, react에 내장된 거 쓰자)
- axios 인스턴스 사용
- 헤더 설정해서 API 요청
- 색은 컬러 팔레트 만들고 거기서 가져다 쓰기
- 컴포넌트는 합성 컴포넌트로 만들기 (children 이용 및 점표기법)
- 될 수 있으면 Object 대신 class 사용하기 (class 연습 위해!)
emotion 선택 이유: 이번에 처음 써보지만 styled-components와는 문법이 비슷하면서도, <div css={divStyle}> 처럼 css를 입힐 수 있어서 본래의 태그 이름을 한눈에 알 수 있기 때문에 선택했다.
겪었던 일 중 가장 어려웠던 일
1. 토큰을 어디에 저장할 것인가
처음에는 토큰을 sessionStorage에 저장하는 방식으로 프로젝트를 완성했다. 그런데 탭이나 브라우저 간 세션 공유를 하지 못해 아쉬웠다. 그래서 방법을 찾아보던 중, localStorage의 값 변경을 감지하는 onStorage라는 이벤트를 이용해 세션을 공유하는 방법을 알게 되었다. 이렇게 해보니 세션 공유를 하는데는 성공했다. 하지만 내가 이 코드를 제대로 이해한 것 같지도 않은데 사용하기에는 찝찝하고, 비동기 방식의 이벤트와 라우팅이 얽힌 여러가지 문제로 인해 라우팅에 무한 루프가 발생거나, 그걸 고치자니 세션이 공유되지 않는 문제 등 이 방법을 사용하기에는 힘들어보였다. 그래서 다른 사이트들은 토큰을 어떻게 관리하나 관찰했더니 세션 쿠키라는 것을 사용하고 있었다.
세션 쿠키는 세션스토리지와 같은 효과를 낼 수 있고, 브라우저나 창 간의 공유도 쉬우며, httpOnly와 secure 옵션을 통해 악성 공격을 방어할 수 있다는 장점이 있다. 다만 이 옵션은 서버쪽에서 설정해주는 옵션이고 res가 아닌 헤더에 쿠키를 실을 때 설정할 수 있는 옵션이다. 이 프로젝트에서는 다른 분이 배포한 서버 코드를 돌려서 사용했는데, res로 토큰을 받아오는 방식으로 구현되어 있고, flask로 구현되어 있어 아쉽게도 바로 서버 코드를 수정하기는 어려웠다. 이건 나중에 서버에서 헤더랑 옵션 설정하는 법을 공부해서 수정해보기로 했다. 하지만 토큰 저장을 어디에 해야 좋을지 고민한 덕분에 onStorage라는 이벤트와 세션 쿠키, httpOnly나 secure 옵션에 대해 잘 알게 되어서, 깊게 파고든 보람이 있었다.
2. TDD 방식으로 개발하기
프로젝트에서 테스트 코드를 작성해보는 건 처음이라 뭘 테스트 해야 하는 건지 감이 잘 안왔다. 그래서 TDD의 정석처럼 테스트 코드를 먼저 짜고 프로젝트 코드를 짜는 게 아니라, 어느 정도 화면과 API 요청 코드의 얼개를 작성한 뒤에 세밀화를 그려야 하는 시점에 테스트 코드를 짜보았다. TDD라기에는 초기 단계부터 테스트 코드를 짜지는 못했지만 적어도 API 요청을 하는 코드를 만드는 시점에 테스트 코드를 짰더니 피드백에 도움이 되었다. 테스트 코드를 짜니 프로그램 조금 수정할 때마다 공포에 떨며 모든 경우의 수에 대해 일일이 수동으로 디버깅해보지 않고 자동으로 쫘르륵 검사해주니까 편하고, 오류가 났다면 왜 오류가 났는지 명확한 문장으로 이유를 알 수 있어서 좋았다.
이제 적어도 무엇을 테스트해야 하는지는 조금 알 것 같다. API 요청을 하면 원하는 응답이 오는지와 에러 처리, 상태나 이벤트에 따른 화면 렌더링/라우팅, 그리고 토큰 유효성과 인증 라우터가 잘 작동하는지 정도를 검사하면 될 것 같다.
앞으로 개선해야 할 사항
- 테스트를 돌릴 때 서버에 의존적이다. 지금은 소규모이니 괜찮지만 만약 규모가 큰 다른 프로젝트에서라면 서버를 돌릴 수 없는 상황(개발이 덜 됐다거나 일시적인 오류로 작동시킬 수 없다거나)에서 테스트를 돌릴 수 없다. mock 서버 만드는 법도 공부해야겠다.
- 이건 하든말든 별 상관 없는건데 emotion 에서 css={}를 사용하려면 항상 JSX 프라그마를 선언해줘야 했다. CRA 환경에서는 craco로 바벨 설정을 할 수 있다. 프라그마 선언을 안 해도 되도록 한번 설정해보자.
배운 점
- React 이벤트객체 타입 설정
- 합성 컴포넌트 만드는 법
- FontAwesome 아이콘 넣기 및 타입 설정
- 인증 라우터 만들기 (PrivateRouter, AuthRouter)
- API 메소드는 class 정적 메소드가 좋은듯
- react-router-dom의 navigate()는 useEffect() 내부에서만 사용할 수 있음
- axios interceptor로 header에 token 바로 반영하기
- axios 에러 처리
- 테스트코드 작성방법 (TDD하기)
- [DOM] Input elements should have autocomplete attributes (suggested: "current-password") 오류
- 아이콘 있는 input 만들기
React의 이벤트 객체 타입설정
React.ReactNode
이벤트 객체 - e: React.ChangeEvent<HTMLInputElement>
이벤트 핸들러 - onSubmit?: React.FormEventHandler<HTMLFormElement>
이벤트 객체와 이벤트 핸들러는 타입이 미묘하게 다르다
onClick 이벤트는 MouseEvent로 설정하면 된다.
합성 컴포넌트 만드는 법
저기 위에 컴포넌트를 children으로 및 점 표기법으로 합성 컴포넌트로 만드는게 목표라고 써놓긴 했지만 이전에 해본적이 없었다. 이 두 글의 도움을 많이 받았다.
React Children 과 친해지기
카카오엔터테인먼트 FE 기술블로그
fe-developers.kakaoent.com
합성 컴포넌트로 재사용성 극대화하기
카카오엔터테인먼트 FE 기술블로그
fe-developers.kakaoent.com
JSX 태그의 사이에 놓인 객체가 사실은 React.createElement(type, [props], [...children]) 중에서 children에 해당한다는 것을 이용해서 컴포넌트를 만들때는 액자를 만든다고 접근하니 쉬웠다. children은 아무리 텍스트밖에 오지 않을 것 같다고 해도 type을 string으로 주면 안된다. <span>으로 둘러싸인 텍스트가 children으로 들어올 수도 있기 때문이다.
children의 타입은 항상 React.ReactNode로 준다.
<Card>와 Card 안에서만 쓰일 <Card.Title>을 만들기 위해 Card.tsx 파일에서 CardMain과 CardTitle 컴포넌트를 Object로 합성해 Card라는 이름으로 내보냈다.
const Card = Object.assign(CardMain, {
Title: CardTitle,
});
export default Card;
이러면 다음과 같이 활용할 수 있다. 그리고 Card에서 점만 입력하면 자동완성으로 하위 컴포넌트를 볼 수 있기 때문에 편리하고, 컴포넌트간의 관계를 잘 나타내준다.
<Card>
<Card.Title>Login</Card.Title>
<Form>
...
</Form>
</Card>
FontAwesome 아이콘 넣기 및 타입 설정
fontawesome react 공식 문서
https://fontawesome.com/v5/docs/web/use-with/react
패키지 명은 fontawesome이 아니다! fortawesome이다
// 필수
npm install @fortawesome/fontawesome-svg-core
npm install @fortawesome/react-fontawesome
// solid는 검은색으로 꽉 찬 아이콘, regular는 선만 있는 것
npm install @fortawesome/free-solid-svg-icons
npm install @fortawesome/free-regular-svg-icons
사용법
1. 일단 fontawesome 사이트에서 원하는 아이콘을 검색하고 fa-user처럼 아이콘 이름을 알아낸다.

2. fa-user에서 하이픈(-)을 뺀 faUser 처럼 import 해온다. solid(꽉 찬 아이콘)이 아니라 regular(테두리만 있는 것)로 import해올 수도 있다. 물론 설치가 되어있어야 한다.
import { faUser } from '@fortawesome/free-solid-svg-icons';
3. 사용
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
// 컴포넌트처럼 아무데나 삽입할 수 있다.
<FontAwesomeIcon icon={faUser} />
+) 아이콘 타입
Input 컴포넌트의 props로 fontawesome 아이콘 객체를 받아오는 코드를 짰는데 아이콘의 type을 설정해줘야 했다.
아래와 같이 IconDefinition을 import해오면 된다.
타입은 어떡하지 하다가 ctrl 마우스 클릭으로 {faUser}의 근원을 타고타고 올라가다보니 iconDefinition을 발견했다.
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
interface InputProps {
type: string;
name: string;
placeholder?: string;
icon?: IconDefinition;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
function Input({ type, name, placeholder, icon, onChange }: InputProps) {
return (
<div>
{icon && <FontAwesomeIcon icon={icon} />}
<input
type={type}
name={name}
placeholder={placeholder}
onChange={onChange}
/>
</div>
);
}
export default Input;
인증 라우터 만들기 (PrivateRouter, AuthRouter)
보통 사람들이 PrivateRouter라고 불리는 인증 라우터를 만들었다.
로그인 여부를 확인해서 하지 않았다면 로그인 페이지로 이동시키고 했다면 통과시키는 라우터이다.
처음에는 인증 여부에 따라 <Route> 혹은 <Navigate> 객체를 반환하는 <Authroute>를 만드려고 했는데, react-router-dom v6에서는 <Routes> 안에 <Route> 대신 다른 요소를 넣으면 에러가 발생해서 <Authroute>를 바로 넣지는 못하고, <Route>의 element로 AuthRoute를 주는 식으로 해결했다.
// App.tsx
<Routes>
<Route index element={<LoginPage />} />
<Route
path="/userinfo"
element={
<AuthRoute>
<UserInfoPage />
</AuthRoute>
}
/>
</Routes>
/userinfo에 접속하면 AuthRoute 컴포넌트가 로그인 여부를 검사해서, 만약 로그인 했다면 children으로 넣은 컴포넌트를 반환하고, 아니라면 로그인 페이지로 이동시키는 <Navigate> 컴포넌트를 반환하도록 했다.
const isAuthed..로 세션에서 토큰을 가져오는 부분을 꼭 function AuthRoute 안에 넣어야 한다. 무심코 바깥에 작성했다가 제대로 토큰을 가져오지 못해 로그인페이지인 루트(/)로 이동했는데 거기서는 토큰이 존재하면 userInfo 페이지로 이동하라고 코드를 짜놓아서 둘이 핑퐁핑퐁 무한루프가 일어났었다. 꼭 필요한 경우가 아니라면 로직에 필요한 변수는 꼭 함수 안쪽에 작성하자.
// src/components/Auth/AuthRoute.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
interface AuthRouteProps {
children: React.ReactNode;
}
function AuthRoute({ children }: AuthRouteProps) {
const isAuthed = sessionStorage.getItem('accessToken');
return isAuthed ? <>{children}</> : <Navigate replace to="/" />;
}
export default AuthRoute;
API 메소드는 class 정적 메소드가 좋은듯
import axios from '../core/axios';
export class AuthApi {
static async login(account: string, password: string) {
const res = await axios.post('/auth/login', {
account: account,
password: password,
});
sessionStorage.setItem('accessToken', res.data.accessToken);
return res;
}
static async logout() {
const res = axios.get('/auth/logout');
sessionStorage.removeItem('accessToken');
return res;
}
static async getUserInfo() {
const res = axios.get('/v1/users/me');
return res;
}
}
AuthApi라는 이름의 클래스 안에 static 키워드로 인증과 관련된 정적 메소드들을 작성했다. 그러면 AuthApi.login()하는 식으로 클래스의 인스턴스를 생성하지 않고 바로 메소드를 사용할 수 있다. 인스턴스를 생성할 것도 아닌데 js에서 굳이 객체지향 형태로 만든 이유는 object로 묶으면 아래처럼 가독성이 좋지 않기 때문이다. 이건 개인적인 선호이다.
const authApi = {
login: function() {
//...
},
logout: function() {
//...
}
}
클래스 형태로 만들어서 좋은 다른 이유는 찾지 못했지만 관련있는 service 함수들의 묶음은 클래스로 묶어야 할 것 같은 느낌이 든다. (Java의 후유증?)
그리고 나중에 extends 할 일이 생길지도 모르니까
react-router-dom의 navigate()는 useEffect() 내부에서만 사용할 수 있음
제곧내
로그인 페이지에 접속해서 이미 로그인 인증 토큰이 존재한다면 유저 페이지로 navigate하라는 코드를 짰는데 바깥에 하면 노란 경고를 띄우면서 적용이 안되더라구.. 이렇게 바꿨다
컴포넌트가 마운트 된 뒤에 useEffect가 실행되는데 그때 navigate가 작동하기 위함이다.
useEffect(() => {
if (sessionStorage.getItem('accessToken')) {
navigate('/userinfo');
}
}, [navigate]);
axios interceptor로 header에 token 바로 반영하기
axios 인스턴스를 만드는 시점에 instance.defaults.headers.common['Authorization'] = `Bearer ${token}`; 이런 식으로 작성을 해도 이것은 인스턴스를 생성하는 시점에 초기값을 설정하는 코드이기 때문에 토큰이 갱신되어도 자동으로 반영되지 않는다.
request interceptor를 설정하면 요청을 보내기 전에 헤더를 갱신하는 동작을 수행하도록 시킬 수 있다.
import axios from 'axios';
import { getCookie } from '../helper/cookie';
const instance = axios.create({
baseURL: 'http://localhost:5000',
});
instance.interceptors.request.use(function (config) {
const token = getCookie('accessToken');
config!.headers!.Authorization = `Bearer ${token}`;
return config;
});
export default instance;
config!.headers!.Authorization = `Bearer ${token}` 이 부분에 느낌표가 있는 이유는 typescript가 자꾸 '개체가 'undefined'인 것 같습니다.'라고 타입 추론을 하고 빨간 밑줄을 치길래 절대 undefined일리가 없는 애들이라고 알려주기 위해서이다.
axios 에러 처리
내가 겪은 문제
await 함수는 then, catch문으로 에러 처리하는 것이 불가능하므로 try~catch문으로 에러 처리를 한다.
typescript에서 axios error와 response 하위 값들의 type을 추론하지 못해서 조금 애먹었다.
복잡한 해결방법
한번에 message 값을 구하려고 const message = err.response.data.message를 했더니 여러가지 복합적인 에러가 발생했다.
(response와 data가 undefined라고 걱정해서 각각 느낌표 붙여주고, 그러면 또 '{}' 형식에 'message' 속성이 없습니다.ts(2339) 라고 data라는 오브젝트에 message란 프로퍼티는 예정되어 있지 않은 녀석이라고 한다. 이러면 const data = err.response.data as ErrorResponseDataType으로 data가 message라는 프로퍼티를 가지고 있다고 타입을 설정해주고 나서야 const message = data.message를 할 수 있다.)
interface ErrorResponseDataType {
code: number;
message: string;
}
더 간단하게 해결할 수 없을까 했는데 끊는 관점을 조금 달리 했더니 문제를 해결했다. data 부분이 아니라 response에 AxiosError로 타입 설정을 하면 된다.
간단한 해결 방법
1. 일단 catch문에서 e로 axios error 객체를 받는다. e를 바로 쓰기에는 에러가 나므로 as 키워드를 사용해 타입을 AxiosError로 선언해준다.
2. err.response에 Axiosresponse로 타입 선언을 한다.
3. res.data 아래에 있는 값을 편하게 가져올 수 있다.
import { AxiosError, AxiosResponse } from 'axios';
interface ErrorResponseDataType {
code: number;
message: string;
}
function LoginPage() {
// ...
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await AuthApi.login(loginInfo.account, loginInfo.password);
navigate('/userinfo');
} catch (e) {
const err = e as AxiosError;
const res = err.response as AxiosResponse;
const message = res.data.message;
setError(message);
}
};
}
테스트코드 작성방법 (TDD 하기)

구조는 이렇게 만들었다. __를 앞에 붙이면 정렬할 때 맨 앞에 와서 찾기 쉽다.
login.test는 로그인 시 발생할 수 있는 케이스들에 대한 테스트이고, userinfo.test는 로그인에 성공한 다음 유저 정보를 보여주는 페이지로 이동하는데, 그 과정이 잘 이루어지나 테스트하는 코드이다.
로그인 테스트에서 화면을 렌더링하고 id와 password를 입력하는 부분이 반복되어서 그 부분을 함수로 만들었다. 만약 userinfo.test처럼 로그인하는 동작이 테스트에 앞서 필요한 경우 이 부분을 import해오면 된다. 왜 따로 __test__안에 lib 폴더를 만들어서 분리하지 않았냐면 login.tsx 파일을 만들어 분리를 해봤더니 테스트가 존재하지 않아서 에러가 났다. 테스트 폴더 안에 있거나 거기에서 import해오는 파일도 테스트 코드가 필요한가보다. 그래서 그냥 테스트 코드 안에 작성해 놓고 만약 login 동작이 필요하다면 거기에서 import해오는 것으로 작성했다.
// login.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import App from '../App';
interface loginInfoProps {
id: string;
password: string;
}
export const login = (loginInfo: loginInfoProps) => {
render(<App />, { wrapper: BrowserRouter });
const id = screen.getByPlaceholderText('ID');
const password = screen.getByPlaceholderText('Password');
const button = screen.getByRole('button', { name: /Login/i });
fireEvent.change(id, { target: { value: loginInfo.id } });
fireEvent.change(password, { target: { value: loginInfo.password } });
fireEvent.click(button);
};
test("잘못된 아이디/비밀번호로 로그인하면 '아이디 혹은 비밀번호가 올바르지 않습니다.' 메세지를 출력한다'", async () => {
const loginInfo = { id: 'wrongid', password: '1111' };
login(loginInfo);
const ErrorMessage = await screen.findByText(
/아이디 혹은 비밀번호가 올바르지 않습니다./i
);
expect(ErrorMessage).toBeInTheDocument();
});
test("아이디나 비밀번호의 길이가 11글자 이상이면 '입력값을 확인해주세요.'라는 메세지를 출력한다.", async () => {
const loginInfo = { id: '일이삼사오육칠팔구십1', password: '1111' };
login(loginInfo);
const ErrorMessage = await screen.findByText(/입력값을 확인해주세요./i);
expect(ErrorMessage).toBeInTheDocument();
});
// userinfo.test.tsx
import { screen } from '@testing-library/react';
import { login } from './login.test';
test('로그인에 성공하면 /userinfo로 이동해 유저 정보를 가져온다.', async () => {
const loginInfo = { id: 'devbadak', password: '1234' };
login(loginInfo);
expect(await screen.findByText(/foo/i)).toBeInTheDocument();
});
[DOM] Input elements should have autocomplete attributes (suggested: "current-password") 오류
이 warning을 해결하려면 password type인 input에 autocomplete 속성을 주면 된다. on을 주면 자동완성을 켜고 off로 설정하면 자동완성을 끈다.
<input type="password" name="password" autocomplete="on" />
만약 나처럼 react에서 Input을 컴포넌트로 만드는 경우에는 props 조건부로 type이 password인 경우에만 autoComplete 속성을 주도록 하면 된다.
function Input({ type, name, placeholder, icon, onChange }: InputProps) {
return (
<div css={inputBoxStyle}>
<input
type={type}
name={name}
placeholder={placeholder}
onChange={onChange}
css={inputStyle}
{...(type === 'password' && { autoComplete: 'on' })}
/>
{icon && (
<span css={iconStyle}>
<FontAwesomeIcon icon={icon} color={palette.grey} />
</span>
)}
</div>
);
}
아이콘 있는 input 만들기

만드는 방법은 다음과 같다.
- <input />을 div로 감싼다. 이를 inputBox라고 하자. input의 높이 조절은 input에서 직접 하지 않고 inputBox에서 한다. inputBox에 높이 40px을 주고 input은 height: 100%로 inputBox를 꽉 채우는 식이다.
- input에 padding-left: 40px으로 적당히 준다.
- inputBox의 position은 relative로, 아이콘을 감싼 <span>에는 position: absolute을 준다.
- 아이콘을 left: 15px; (input의 왼쪽 패딩 40px의 반보다 조금 덜 준 수치로 주면 적당한듯 하다), top: 50%를 주고 아이콘의 머리 기준으로 50% 올라온것이므로 아이콘 몸통의 반만큼 끌어올려주기 위해 translateY(-50%)을 해준다.
<div css={inputBoxStyle}>
<input
// type, name, ...
css={inputStyle}
/>
{icon && (
<span css={iconStyle}>
<FontAwesomeIcon icon={icon} color={palette.grey} />
</span>
)}
</div>
const inputBoxStyle = css`
position: relative;
margin-top: 15px;
height: 40px;
`;
const inputStyle = css`
width: 100%;
height: 100%;
padding-left: 40px;
font-size: 16px;
&:focus {
border: 2px solid ${palette.blue};
outline: none;
}
`;
const iconStyle = css`
position: absolute;
top: 50%;
left: 15px;
transform: translateY(-50%);
`;
'프로젝트 회고' 카테고리의 다른 글
[Project/React] 다크모드 구현으로 Context API 학습하기 (1) | 2023.04.17 |
---|---|
[Project] 포카리뷰 (Phoca Review) 만들기 회고 | 토이프로젝트 (0) | 2023.03.30 |
[Project]Vanilla JS - Todo 리스트 만들기 (0) | 2022.04.25 |
- 프로젝트 소개
- 겪었던 일 중 가장 어려웠던 일
- 앞으로 개선해야 할 사항
- 배운 점
- React의 이벤트 객체 타입설정
- 합성 컴포넌트 만드는 법
- FontAwesome 아이콘 넣기 및 타입 설정
- 인증 라우터 만들기 (PrivateRouter, AuthRouter)
- API 메소드는 class 정적 메소드가 좋은듯
- react-router-dom의 navigate()는 useEffect() 내부에서만 사용할 수 있음
- axios interceptor로 header에 token 바로 반영하기
- axios 에러 처리
- 테스트코드 작성방법 (TDD 하기)
- [DOM] Input elements should have autocomplete attributes (suggested: "current-password") 오류
- 아이콘 있는 input 만들기