배운 내용
- CSR
본문
요약
프론트엔드에도 인증을 위해 로그인, 회원가입 그리고 리디렉션을 구현해주어야 한다. 세부적인 구현 설명은 아래와 같다.
- 로그인, 회원가입 페이지를 구현한다.
- 백엔드에 HTTP 요청을 보냈을 때 403(forbidden)이 리턴되면 로그인 페이지로 리디렉트(redirect)한다.
- 백엔드 서비스로부터 받은 토큰을 로컬 스토리지에 저장하고 매 요청마다 해더에 Bearer 토큰으로 지정한다.
react-router-dom 라이브러리는 CSR(Client-Side Routing)을 돕는다. 모든 라우팅은 클라이언트 코드 즉, 자바스크립트가 해결한다. 우리의 경우 그 자바스크립트가 리엑트다. 브라우저의 '/' 경로에 처음 접근했을 때 필요한 모든 리소스(html, js, css)가 리턴된다. 만약 '/login'를 주소창에 입력하면 react-router가 이를 가로채 URL을 파싱해서 login 탬플릿(페이지)을 렌더링한다. login 템플릿(페이지)을 가져오는 두 번째 요청은 인터넷이 끊기더라도 실행된다.
npm install react-router-dom
로그인 컴포넌트
라우팅을 테스트하기 위해 src 폴더 아래에 로그인 컴포넌트를 만들자.
[Login.js]
import React from "react";
class Login extends React.Component {
constructor(props){
super(props);
}
render(){
return <p>로그인 페이지</p>
}
}
export default Login;
src 폴더 아래에 라우팅을 할 수 있게 해주는 컴포넌트를 만들자. AppRoute.js에 모든 라우팅 규칙을 작성한다.
[AppRouter 컴포넌트]
import React from "react";
import "./index.css";
import App from "./App";
import Login from "./Login";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Box from "@material-ui/core/Box";
import Typography from "@material-ui/core/Typography";
function Copyright(){
return(
<Typography variant="body2" color="textSecondary" align="center">
{"Copyright ⓒ"}
wonchanLee, {new Date().getFullYear()}
{"."}
</Typography>
);
}
class AppRouter extends React.Component{
render(){
return(
<div>
<Router>
<Routes>
<Route path="/login" element={<Login/>}/>
<Route path="/" element={<App/>}/>
</Routes>
<Box>
<Copyright/>
</Box>
</Router>
</div>
);
}
}
export default AppRouter;
기존에는 ReactDOM에 App 컴포넌트를 넘겨줬지만,이제는 경로에 따라 실행되는 컴포넌트가 다르므로 그 정보를 갖고 있는 AppRouter 컴포넌트를 가장 먼저 넘겨주어 렌더링해야 한다. Index.js에서 코드를 아래와 같이 수정한다.
[Index.js]
import AppRouter from './AppRouter';
ReactDOM.render(
<React.StrictMode>
<AppRouter />
</React.StrictMode>,
document.getElementById('root')
);
http://localhost:3000/login 페이지로 들어가보면, 로그인 페이지 문구가 뜨면서 라우팅이 제대로 동작함을 확인할 수 있다.
접근 거부 시 로그인 페이지로 라우팅하기
우리는 localhost:8080/todo에 API 콜을 했을 때 거부 받을 수 있다. 모든 유형의 HTTP 메서드(GET/POST/PUT/DELETE)에서 로그인을 하지 않은 경우 로그인 페이지로 리디렉트한다.
App.js에서 componentDidMount 또는 add, delete, update 등의 함수를 보면 API 콜을 할 때, ApiService의 call 메서드를 사용한다. 따라서 우리는 리디렉트하는 로직을 ApiService에 작성해야 한다.
ApiService의 call 함수는 결국 fetch 메서드를 부른다. fetch 메서드는 .then()의 매개변수에 resolve 상태에서 실행할 함수를 넘겨주고, .catch()의 매개변수에 reject 상태에서 실행할 함수를 넘겨준다. catch 메서드 안에 상태코드가 403(forbidden)인 경우 login 페이지로 리디렉트하는 함수를 작성한다.
[ApiService.js]
import { API_BASE_URL } from "./api-config";
export function call (api, method, request){
var options = {
headers: new Headers({
"Content-Type": "application/json",
}),
url: API_BASE_URL + api,
method: method,
};
if (request){
// GET method
options.body = JSON.stringify(request);
}
return fetch(options.url, options)
.then((response) =>{
if (!response.ok) {
return Promise.reject(response);
}
return response.json() ;
})
.catch((error) => {
console.log(error.status);
if(error.status === 403){
window.location.href = "/login"; // redirect
}
return Promise.reject(error);
});
}
localhost:3000으로 접근하면 login 페이지로 리디렉트되는 것을 확인할 수 있다.
로그인 페이지
웹 스토리지를 이용하면, 사용자의 브라우저에 데이터를 key-value 형태로 저장할 수 있다. 웹 스토리지 종류는 2개가 있다.
- 세션 스토리지 (Session Storage) : 브라우저를 닫으면 스토리지가 사라진다.
- 로컬 스토리지 (Local Storage) : 브라우저를 닫아도 스토리지가 사라지지 않는다.
로컬 스토리지는 도메인마다 따로 저장되어서, 다른 도메인끼리는 서로의 로컬 스토리지를 알지 못한다. 로그인 시 받은 토큰을 로컬 스토리지에 저장하고, 백엔드에 API 콜을 할 때마다 로컬 스토리지에서 토큰을 불러와 헤더에 추가한다.
[ApiService.js]
export function call (api, method, request){
let headers = new Headers({
"Content-Type":"application/json",
});
const accessToken = localStorage.getItem("ACCESS_TOKEN");
if(accessToken && accessToken !== null){
headers.append("Authorization", "Bearer " + accessToken);
}
let options = {
headers: headers,
url: API_BASE_URL + api,
method: method,
};
...
}
export function signin(userDTO){
return call("/auth/signin", "POST", userDTO)
.then((response => {
if(response.token){
// 로컬 스토리지에 저장
localStorage.setItem("ACCESS_TOKEN", response.token);
// token이 존재하는 경우 Todo 화면으로 리디렉트
window.location.href="/";
}
}));
}
Login 컴포넌트를 email, pw를 받는 인풋 필드와 로그인 버튼으로 이루어지도록 수정한다. 로그인 버튼을 클릭하면 /auth/sigin로 POST 요청을 전달한다.
[Login.js]
import React from "react";
import { signin } from "./ApiService";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import {Container} from "@material-ui/core";
class Login extends React.Component {
constructor(props){
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event){
event.preventDefault();
const data = new FormData(event.target);
const email = data.get("email");
const password = data.get("password");
// ApiService의 signin 메서드를 사용해 로그인
signin({ email: email, password: password});
}
render(){
return (
<Container component="main" maxWidth="xs" style={{marginTop: "8%"}}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography component="h1" variant="h5">
로그인
</Typography>
</Grid>
</Grid>
<form noValidate onSubmit={this.handleSubmit}>
{/* submit 버튼을 클릭하면 handleSubmit이 실행됨*/}
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
variant="outlined"
required
fullWidth
id="email"
label="이메일 주소"
name="email"
autoComplete="email"
/>
</Grid>
<Grid item xs={12}>
<TextField
variant="outlined"
required
fullWidth
name="password"
label="패스워드"
type="password"
id="password"
autoComplete="current-password"
/>
</Grid>
<Grid item xs={12}>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
>로그인</Button>
</Grid>
</Grid>
</form>
</Container>)
}
}
export default Login;
로그아웃과 글리치 해결
로그아웃 서비스를 만들겠습니다. AppBar/Toolbar를 사용해 navigationBar 변수에 JSX 코드를 작성한다. JSX 코드 속 Button에 signout 함수를 연결한다.
[ApiService.js]
export function signout(){
localStorage.setItem(ACCESS_TOKEN, null);
window.location.href='/login';
}
[App.js]
import {call, signout} from './ApiService';
import {AppBar, Toolbar, Grid, Button} from '@material-ui/core';
// render() 중
// navigatorBar 추가
var navigationBar = (
<AppBar position="static">
<Toolbar>
<Grid justify="space-between" container>
<Grid item>
<Typography variant="h6">오늘의 할 일</Typography>
</Grid>
<Grid item>
<Button color="inherit" onClick={signout}>
로그아웃
</Button>
</Grid>
</Grid>
</Toolbar>
</AppBar>
);
...
return <div className="App">
{navigationBar} {/* 네비게이션 바 렌더링*/}
<Container maxWidth="md">
<AddTodo add={this.add}/>
<div className="TodoList">{todoItems}</div>
</Container>
</div>;
UI 글리치 해결
로그인하지 않고, Todo 리스트 페이지로 접속했을 때 로그인 페이지로 라우팅하기까지 시간이 걸리는데, 이 시간은 백엔드 서버에 Todo를 요청하고 결과를 받는 데 걸리는 시간이다. Todo 리스트를 받아오기 전까지 로딩중이라는 메시지를 띄우겠다.
//class App 생성자 중
this.state={
items: [],
// 로딩 상태
loading: true,
};
// class App 중
componentDidMount() {
call("/todo", "GET", null).then((response) =>
this.setState({items: response.data, loading: false})
);
}
...
var todoListPage = (
<div>
{navigationBar} {/* 네비게이션 바 렌더링*/}
<Container maxWidth="md">
<AddTodo add={this.add}/>
<div className="TodoList">{todoItems}</div>
</Container>
</div>
);
// 로딩 페이지
var loadingPage = <h1> 로딩중.. </h1>;
// loading중이면, loading page를 content로
var content = loadingPage;
if(!this.state.loading) content = todoListPage;
return <div className="App">{content}</div>;
계정 생성 페이지
ApiService에 백엔드 서버에 signup API를 요청하는 함수를 작성한다.
[ApiService.js]
export function signup(userDTO){
return call("/auth/signup", "POST", userDTO);
}
AppRouter에 /signup 경로로 들어올 때 렌더링할 컴포넌트를 연결한다.
[AppRouter.js]
class AppRouter extends React.Component{
render(){
return(
<div>
<Router>
<Routes>
<Route path="/login" element={<Login/>}/>
<Route path="/signup" element={<SignUp/>}/>
<Route path="/" element={<App/>}/>
</Routes>
...
</Router>
</div>
);
}
}
SignUp 페이지(컴포넌트)를 작성한다.
[SignUp.js]
import React from "react";
import {
Button,
TextField,
Link,
Grid,
Container,
Typography
} from "@material-ui/core";
import {signup} from "./ApiService";
class SignUp extends React.Component{
constructor(props){
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event){
event.preventDefault();
const data = new FormData(event.target);
const username = data.get("username");
const email = data.get("email");
const password = data.get("password");
signup({email: email, username:username, password: password})
.then((response)=>{
// 계정 생성 성공 시 login 페이지로 리디렉트
window.location.href="/login";
})
}
render(){
return (
<Container component="main" maxWidth="xs" style={{marginTop: "8%"}}>
<form noValidate onSubmit={this.handleSubmit}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography component="h1" variant="h5">
계정 생성
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
autoComplete="fname"
name="username"
variant="outlined"
required
fullWidth
id="username"
label="사용자 이름"
autoFocus
/>
</Grid>
<Grid item xs={12}>
<TextField
variant="outlined"
required
fullWidth
id="email"
label="이메일 주소"
name="email"
autoComplete="email"
/>
</Grid>
<Grid item xs={12}>
<TextField
variant="outlined"
required
fullWidth
name="password"
label="패스워드"
type="password"
id="password"
autoComplete="current-password"
/>
</Grid>
<Grid item>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
>
계정 생성
</Button>
</Grid>
</Grid>
<Grid container justifyContent="flex-end">
<Grid item>
<Link href="/login" variant="body2">
이미 계정이 있습니까? 로그인 하세요.
</Link>
</Grid>
</Grid>
</form>
</Container>
)
}
}
export default SignUp;
로그인 컴포넌트에도 Signup으로 redirect하는 링크를 작성한다.
[Login.js]
class Login extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event) {
...
}
render() {
return (
<Container component="main" maxWidth="xs" style={{ marginTop: "8%" }}>
<Grid container spacing={2}>
...
</Grid>
<form noValidate onSubmit={this.handleSubmit}>
<Grid container spacing={2}>
...
<Link href="/signUp" variant="body2">
<Grid item>계정이 없습니까? 여기서 가입하세요.</Grid>
</Link>
</Grid>
</form>
</Container>
);
}
}
export default Login;
궁금한 점
- 계정 생성 링크 오른쪽으로 옮기기
알게된 점
- example
출처: React.js, 스프링 부트, AWS로 배우는 웹 개발 101
'React, 스프링, AWS로 배우는 웹개발 101' 카테고리의 다른 글
이 책을 마치며.. (0) | 2022.04.12 |
---|---|
프로덕션 배포 (0) | 2022.04.07 |
인증 백엔드 통합 (0) | 2022.03.28 |
프론트엔드 개발 (0) | 2022.03.16 |
백엔드 개발 (0) | 2022.02.27 |