본문 바로가기
개발관련/웹 개발(FullStack)

[FastAPI + React] 소셜 로그인 구현 (4) - 플래너 구현하기

by yjoo_ 2023. 10. 13.

https://github.com/Jym-lab/React_FastAPI_planner

 

GitHub - Jym-lab/React_FastAPI_planner: 리액트와 FastAPI 소셜로그인을 활용한 간단한 플래너

리액트와 FastAPI 소셜로그인을 활용한 간단한 플래너. Contribute to Jym-lab/React_FastAPI_planner development by creating an account on GitHub.

github.com

이번을 마지막으로 소셜 로그인 포스팅을 마친다.

전체적인 코드를 보고 싶다면 Github를 참고하도록 하자.


이번 시간에는 간단한 플래너를 만들고 그 폼을 전달하는걸 해볼 것이다.

당연히 로그인 상태를 검증하는 방식과, 로그인 상태라면 플래너를 작성하고 그 플래너들을 가져오도록 설계한다.

 

먼저 리액트부터 구현하도록 하겠다!

 

지난번에 이어 GoogleLoginBtn.jsx 파일을 열어 서버에서 받아온 토큰 값을 저장한다.

단 토큰 값을 받아 올 때마다 만료시간을 함께 저장해서 로컬스토리지 값을 컨트롤 해줄 생각이다.

무슨 말인지 이해가 안간다면 일단 코드를 보자

import { GoogleLogin } from '@react-oauth/google'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import React from 'react'

export const GoogleLoginBtn = () => {
	const loginHandle = (response) => {
		const decode_token = jwtDecode(response.credential)
		// FastAPI 서버로 보낼 데이터 폼
		const data = {
			email: decode_token.email,
			username: decode_token.family_name + decode_token.given_name,
			exp: decode_token.exp
		}
		//post 요청을 보낸다.
		axios.post("http://localhost:8000/user/login", data,
			{
				headers: {
					'Content-Type': 'application/json'
				}
			}
		)
		.then(response => {
			// 성공적인 요청시 response 값을 localStorage에 저장한다.
			const myToken = {
				token: response.data,
				expire: Date.now() + 60 * 60 * 1000
			};
			localStorage.setItem('token', JSON.stringify(myToken));
		})
		.catch(error => {
//(..생략..)

전체적인 코드가 좀 길어지긴 했으나, then부분을 보면 된다.

 

myToken이라는 새로운 객체를 선언해서 Date.now() + 1시간으로 서버에서 받아온 token을 저장한다.

 

그걸 함께 묶어 token이란 이름으로 setItem해준다.

 

이 때 중요한 건 이 객체를 JSON형태로 변환시켜주어야 한다.

 

이제 로그인하고 F12(개발자 모드) -> Application -> localStorage를 확인해보면

Local Storage에 해당 값이 저장이 됐다.

 

이제 다음으로 진행해보자.

src 디렉토리 하위에 utils 디렉토리를 만들고, Authenticate.js를 만들어준다.

 

이 파일에서는 axios를 사용해서 토큰을 보낼 때 자주 사용하는 axios를 만들 것이고

 

추가적으로 현재 로컬스토리지에 토큰이 있는지 없는지 검사하는 getToken함수를 작성할 것이다.

 

먼저 getToken함수를 다음과 같이 작성해준다.

import React from 'react'

export const getToken = () => {
	//로컬스토리지 값을 가져와서 JSON을 Object로 변환해준다.
	const myToken = JSON.parse(localStorage.getItem('token'));
	// 토큰이 없거나 만료되었다면 삭제하고 null을 리턴한다
	if (!myToken)
		return null;
	if (myToken.expire <= Date.now()){
		localStorage.removeItem('token')
		return null;
	}
	return myToken.token
}

이제 이 값을 가지고 리액트에서 로그인 버튼을 띄워줄지, 로그아웃 버튼을 띄워줄지 만들어보자.

App.js로 이동한다

import './App.css';
import { GoogleLoginBtn } from './components/GoogleLoginBtn';
import { getToken } from './utils/Authenticate';

function App() {
// 토큰이 있다면 토큰이 리턴되어 True, 없다면 null이 리턴되어 False 
const ACCESS_TOKEN = getToken()
	return (
		<div className="App">
		{!ACCESS_TOKEN &&
			<GoogleLoginBtn />
		}
		{ACCESS_TOKEN &&
			<button>로그아웃</button>
		}
		</div>
	);
}

export default App;

ACCESS_TOKEN을 getToken()함수로 가져오고, Token 여부에 따라 렌더링을 다르게 해준다.

 

그리고 이번엔 GoogleLoginBtn.jsx로 이동한다

		.then(response => {
			// 성공적인 요청시 response 값을 localStorage에 저장한다.
			const myToken = {
				token: response.data,
				expire: Date.now() + 60 * 60 * 1000
			};
			localStorage.setItem('token', JSON.stringify(myToken));
            // 로그인에 성공하면 새로고침 처리해준다.
			window.location.reload()
		})

마지막으로 로그인 성공 시 새로고침 처리를 해준다.

로그인을 해보자

 

이제 로그인은 잘 되지만, 로그아웃은 안될 것이다.

로그아웃 처리도 간단하게 만들어보자.

Authenticat.js에 로그아웃 함수를 추가한다.

 

import React from 'react'

export const getToken = () => {
	const myToken = JSON.parse(localStorage.getItem('token'));
	if (!myToken)
		return null;
	if (myToken.expire <= Date.now()){
		localStorage.removeItem('token')
		return null;
	}
	return myToken.token
}

export const Logout = () => {
	// 로그아웃 버튼을 클릭하면 스토리지에서 token을 삭제
	localStorage.removeItem('token');
    // token이 삭제되면 로그인 버튼이 다시 출력되도록 리로드
	window.location.reload();
}

로컬스토리지를 삭제하고 페이지를 새로고침 시켜준다.

 

로그아웃 버튼에 OnClick으로 해당 함수를 할당해주면 된다.

 

<button onClick={Logout}>로그아웃</button>

 

이제 로그아웃 처리도 잘 될 것이다.


FastAPI에 플래너 CRUD를 구현하고

리액트에서 해당 API를 호출하여 사용자 로그인 시 CRUD를 수행할 수 있도록 만들어보겠다.

 

먼저 FastAPI부터 CRUD를 작성해준다.

# 새롭게 User를 선언해준다.
class User(SQLModel, table=True):
	email: EmailStr = Field(primary_key=True)
	username: str

# 기존 User모델은 Signup 모델로 변경해준다. Field도 당연히 지워준다.
# 회원가입용 모델이다.
class Signup(BaseModel):
	email: EmailStr
	username: str
	exp: int

class TokenResponse(BaseModel):
	access_token: str
	token_type: str

지난시간에 귀찮아서 exp(토큰 만료시간)까지 같이 DB에 저장했던 부분을 수정해주고

새로운 유저모델을 선언해준다.

 

물론 routes/user.py의 로그인 함수도 다음과 같이 수정되어야 한다.

from fastapi import APIRouter, Depends, HTTPException, status
from auth.jwt import create_access_token
from database.connetion import get_session
from models.users import Signup, TokenResponse, User


user_router = APIRouter(
	tags=["User"],
)

@user_router.post("/login", response_model=TokenResponse)
# body는 Signup 모델로 변경한다.
async def login(body: Signup,session=Depends(get_session)) -> dict:
	# 실제 DB테이블은 User 모델이므로 User테이블을 조회한다
	existing_user = session.get(User , body.email)
	try:
		if existing_user:
			access_token = create_access_token(body.email, body.exp)
		else:
        	# 입력 모델이 변경되었으므로 User클래스 인스턴스를 만들어 DB에 저장한다.
			_user = User(email=body.email, username=body.username)
			session.add(_user)
			session.commit()
			session.refresh(_user)
			access_token = create_access_token(body.email, body.exp)
		return {
				"access_token": access_token,
				"token_type": "Bearer"
			}
	except:
		raise HTTPException(
			status_code=status.HTTP_400_BAD_REQUEST,
			detail="Bad Parameter",
		)

이제 Event 모델을 사용해서 DB에 저장해보자

 

각 User가 Event 구조를 하나씩 가질 수 있도록 세팅해줄 것이다.

 

models/users.py에 코드를 새로 작성해준다.

from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr
from sqlmodel import Field, Relationship, SQLModel

# 실질적인 Event모델
class EventBase(SQLModel):
	title: str
	description: Optional[str] = None
	date: datetime
	location: Optional[str] = None

# EventBase를 상속하여 사용
class Event(EventBase, table=True):
	id: Optional[int] = Field(default=None, primary_key=True)
    # User의 email컬럼을 외래키로 받아온다.
	user_email: EmailStr = Field(foreign_key='user.email')
    # User모델과의 관계를 정의함
	user: Optional["User"] = Relationship(back_populates="events")

class User(SQLModel, table=True):
	email: EmailStr = Field(primary_key=True)
	username: str
    # Event 모델과의 관계를 정의함. Event모델은 user로 User모델에 접근가능
	events: List["Event"] = Relationship(back_populates="user")

# (..생략..)

 

SQLModel에서는 서로간의 관계를 연결해주어야 외래키로 사용할 수 있다.

 

Event에 user_email을 외래키로 받아와주자.

 

이렇게 처리를 해주면 User는 Event와 연결구조만 갖고있고, 그 자체로 존재한다.

 

그러나 Event의 경우 user_email이 User 테이블의 레코드로 외래키가 지정된다면

 

논리적으로 User: Event (1:N) 관계가 성립된다.

 

 

이제 Event의 CRUD를 수행하는 API를 작성한다.

 

단순 CRUD일 뿐이니 코드에 대한 자세한 설명은 주석으로만 추가한다.

from fastapi import APIRouter, Depends
from sqlmodel import select
from auth.authenticate import authenticate
from database.connetion import get_session
from models.users import Event, EventBase


event_router = APIRouter(
	tags=["Event"],
)

@event_router.get("/")
async def get_events(session=Depends(get_session), user: str=Depends(authenticate)):
	# SQLModel의 select ORM 이벤트 소유자의 이메일과 현재 요청한 유저가 같은 데이터만 가져온다
	_event = session.exec(select(Event).filter(Event.user_email == user)).all()
	return _event

@event_router.post("/")
async def create_event(body: EventBase, session=Depends(get_session), user: str=Depends(authenticate)):
	# request body의 값을 딕셔너리로 넘겨준다. user_email이 body에 없으므로 따로 넣어준다.
    new_event = Event(**body.dict(), user_email=user)
	session.add(new_event)
	session.commit()
	session.refresh(new_event)
	return {
			"message": f"The event for {user} was successfully created."
		}

@event_router.delete("/{event_id}")
async def delete_event(event_id: int, session=Depends(get_session), user: str=Depends(authenticate)):
	# 데이터를 가져오던 중 에러가 나면 예외처리 해준다.
    try:
		_event = session.exec(select(Event).filter(Event.id == event_id)).one()
        # 요청한 유저가 이벤트 소유자인지 검사한다. 아니라면 소유자가 아니므로 에러를 출력한다.
		if (user == _event.user_email):
			session.delete(_event)
			session.commit()
			return {
				"message": f"The event for {_event.user_email} was successfully deleted."
			}
		return {
			"message": "Failed to delete an event. Not event owner"
		}
	except Exception as e:
		return {
			"message": f"Failed to delete an event {e}"
		}

코드 자체가 어렵지 않으니 읽어보면 되시겠다.

참고로 UPDATE는 폼을 또 만들어줘야 해서 귀찮아서 안했다.

 

이제 리액트에서 각 API를 호출해 화면에 호출해야한다.

 

먼저 /utils/Authenticate.js에 자주 사용하는 axios를 추가해준다.

 

이 axios는 앞으로 사용자가 로그인 권한이 필요한 요청을 할 때

 

토큰 정보만 입력하면 데이터를 바로 보낼 수 있도록 세팅해두는 것이다.

 

import axios from "axios";

export const getToken = () => {
	const myToken = JSON.parse(localStorage.getItem('token'));
	if (!myToken)
		return null;
	if (myToken.expire <= Date.now()){
		localStorage.removeItem('token')
		return null;
	}
	return myToken.token
}

export const Logout = () => {
	localStorage.removeItem('token');
	window.location.reload();
}
// axios 생성
export const authenticate = (token) => axios.create({
    baseURL: 'http://localhost:8000',
    // 이 함수로 axios 요청을 보내면 다음 헤더가 포함된다.
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
    },
});

authenticate함수는 token값을 받아서 보내는 함수다.

 

사용 방법은 authenticate(getToken()).[HTTP 메서드](URL)로 사용하면 된다.

아래 코드를 읽어보면 어떻게 사용하는지 감이 올 것이다.

 

이렇게 서버에서 받은 응답 값을 변수에 저장하여 사용하면 된다.

 

App.js를 수정한다.

import { useEffect, useState } from 'react';
import './App.css';
import { GoogleLoginBtn } from './components/GoogleLoginBtn';
import { Logout, authenticate, getToken } from './utils/Authenticate';

function App() {
	const ACCESS_TOKEN = getToken()
	const [events, setEvents] = useState([]);
	const [title, setTitle] = useState('');
	const [description, setDescription] = useState('');
	const [date, setDate] = useState('');
	const [location, setLocation] = useState('');

	const get_data = async () => {
		try {
			const response = await authenticate(ACCESS_TOKEN.access_token).get(`/events`);
			setEvents(response.data)
		} catch (error) {
			console.log(error)
		}
	}

	const handleSubmit = async (e) => {
		e.preventDefault();
		const event = { title, description , date , location};
		await authenticate(getToken().access_token).post(`/events`, event)
        // 데이터를 생성한 뒤 폼을 초기화.
		setTitle('')
		setDescription('')
		setDate('')
		setLocation('')
        // 최신 DB 값을 받아와 화면 최신화
		get_data()
	};

	const handleDelete = async (e) => {
		try {
			await authenticate(ACCESS_TOKEN.access_token).delete(`/events/${e.target.dataset.id}`);
			get_data()
		} catch(error) {
			console.log(error)
		}
	}
	useEffect(()=>{
		if (ACCESS_TOKEN){
			get_data();
		}
	},[])
	return (
		<div className="App">
		{!ACCESS_TOKEN &&
			<GoogleLoginBtn />
		}
		{ACCESS_TOKEN &&
			<>
				<button onClick={Logout}>로그아웃</button>
				<table>
					<thead>
						<tr>
							<th>Title</th>
							<th>Description</th>
							<th>Date</th>
							<th>Location</th>
						</tr>
					</thead>
					<tbody>
						{events.map(event => (
							<tr key={event.id}>
								<td>{event.title}</td>
								<td>{event.description}</td>
								<td>{event.date}</td>
								<td>{event.location}</td>
								<td><button onClick={handleDelete} data-id={event.id}>삭제</button></td>
							</tr>
						))}
					</tbody>
				</table>
				<form onSubmit={handleSubmit}>
					Title:
					<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
					<br/>
					Description:
					<input type="text" value={description} onChange={(e) => setDescription(e.target.value)} />
					<br/>
					Date:
					<input type="datetime-local" value={date} onChange={(e) => setDate(e.target.value+":00")} />
					<br/>
					Location:
					<input type="text" value={location} onChange={(e) => setLocation(e.target.value)} />
					<button type="submit" disabled={!title || !description || !date || !location}>Submit</button>
				</form>
			</>
		}
		</div>
	);
}

export default App;

조금 못생긴 폼을 집어넣긴 했으나 대충 코드를 설명해보자면

 

1. 로그인중인지 아닌지 getToken함수를 사용해 로컬스토리지의 토큰과 만료 시간을 계산하여 화면을 출력

2. 로그인 중이라면 생성 폼과 데이터 출력 폼을 출력한다.

3. 입력 폼 버튼에는 생성 API 호출과 입력 data를, 삭제 버튼에는 각 데이터의 id값을 받아와 올바른 URL로 삭제 요청을

 

각 생성과 삭제요청이 잘 되는지, DB에 저장이 잘 되는지 확인해보자.

이로써 소셜 로그인을 끝냈다.

 

자세한 코드는 깃허브를 참고하도록 하자.

https://github.com/Jym-lab/React_FastAPI_planner

 

GitHub - Jym-lab/React_FastAPI_planner: 리액트와 FastAPI 소셜로그인을 활용한 간단한 플래너

리액트와 FastAPI 소셜로그인을 활용한 간단한 플래너. Contribute to Jym-lab/React_FastAPI_planner development by creating an account on GitHub.

github.com

 

Reference

SQLModel Tutorial
https://sqlmodel.tiangolo.com/