[YACHT DICE] - React, FastAPI로 회원가입/로그인 구현하기

조민수·2024년 7월 30일
0

개발

목록 보기
9/9

Day 4. 로그인/회원가입 구현하기


그동안 개발은 조금씩 조금씩 했는데, 하나의 feature가 완성된 느낌이라 포스팅...
각설하고,
오늘로서 완성된 회원가입/로그인은 React TypeScript, FastAPI, MySQL을 통해 구현되었다.

단계는 다음과 같다.

  1. MySQL 스키마, 테이블 세팅
  2. React Front-end 구축
  3. FastAPI 백엔드 구축
  4. SignUp, Login API 설계, DB save, load
  5. JWT token을 통한 로그인 세션 유지

1. MySQL 세팅

일단, 매우 간단하게 진행했다.
yacht스키마에 user_info 테이블을 구성했다.
column으로는 단순하게 id, email, password, friends로 구성했다.
추후 변경 여부는 70%...?

CREATE TABLE yacht.user_info (
    id VARCHAR(255) PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    friends JSON
);

2. Front-End UI 구축

현재는 간단하게 구현했고, 아직 반응형 UI 조차 잡지 않았다.
PWA 최적화를 위해 모바일 반응형은 필수라, 모바일 반응형 공부를 조금 더 하고 잡을 예정

폴더 구조는
src/pages/StartPage.tsx에서
src/components/Login.tsx/components/SignUp.tsx를 불러오는 구조로 구축했다.

정말 당연하게도 input이나, button은 역시 Mui


3. Front-End 로직 구축

일단 로그인/회원가입 form을 통해 데이터 fetching이 요구되고,
이를 react-queryuseMutationaxios로 구현했다.
(역시 아는 맛이 편해...)

  1. src/api/instance.ts
import axios from 'axios';

export const axiosInstance = axios.create({
  baseURL: 'http://localhost:8000',
  headers: {
    'Content-Type': 'application/json',
  },
});

...

export const signup = async (newUser: {
  id: string;
  email: string;
  password: string;
}) => {
  return axiosInstance.post('/signup', newUser);
};

export const checkId = async (id: string) => {
  return axiosInstance.get(`/check-id/${id}`);
};

export const login = async (user: { id: string; password: string }) => {
  return axiosInstance.post('/login', user);
};
  1. src/components/Login.tsx
import React, { useState } from 'react';
...
export const Login: React.FC<LoginProps> = ({ setIsSignUp }) => {
	
  	...
    
	const [formData, setFormData] = useState({
    	id: '',
    	password: '',
  	});
  	// 로그인 버튼 클릭 시, BE로 보낼 user 데이터
	
	const setAuthState = useSetRecoilState(authState);
    // 로그인 유지시킬 recoil value
    
    const mutation = useMutation((user: { id: string; password: string }) =>
    login(user));
  	// login() method를 통해 `/login`으로 user data post
    
    const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const { id, password } = formData;
    mutation.mutate(
      // post
      { id, password },
      {
        onSuccess: (data) => {
          // post return success -> return value
          const token = data.data.access_token;
          // BE는 로그인을 성공한 유저에게 토큰을 부여한다.
          localStorage.setItem('token', token);
          // localStorage를 통해 유저의 토큰을 저장한다.
          setAuthState({ isLogin: true });
          // recoil의 로그인 값을 true로 변경
          alert(`WELCOME ${id}`);
          navigate('/');
          // 로그인 성공 -> 메인화면으로 이동
        },
        onError: () => {
          alert('LOGIN FAIL');
        },
      },
    );
  };
  
  ...
}

SignUp.tsx도 이와 유사하게 구축되었다.

  1. src/atoms/authAtom.ts
import { atom } from 'recoil';

const token = localStorage.getItem('token');
const isLogin = !!token;

export const authState = atom<{ isLogin: boolean }>({
  key: 'authState',
  default: { isLogin },
});

로컬스토리지에서 토큰을 가져와 로그인을 유지 여부를 결정한다.


4. Back-End 구축

먼저, FastAPI를 하도 오랜만에 해서 폴더 구축부터 코드 작성까지 대부분을 전지전능 GPT4o님과 함께했다.

  1. main.py
    메인에서 신경써야 할 것 중, 가장 먼저 한 것은 언제나 마주했던 CORS ERROR 핸들링이었다.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth

app = FastAPI()

# CORS 설정
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 모든 출처 허용
    allow_credentials=True,
    allow_methods=["*"],  # 모든 HTTP 메서드 허용
    allow_headers=["*"],  # 모든 헤더 허용
)

app.include_router(auth.router)
  1. db.py
    MySQL을 연결하기위해 sqlalchemy를 활용해 DB를 연결했다.
    DB URL은 당연하게도 .env파일에 저장해 .gitignore 해두었다.
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
import os

load_dotenv()

DATABASE_URL = os.getenv("DATABASE_URL");

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
  1. models/user.py
    회원가입/로그인 시에 유저 정보를 가져오기위한 db model을 선언했다.
from sqlalchemy import Column, String, JSON
from app.db import Base

class User(Base):
    __tablename__ = 'user_info'
    
    id = Column(String, primary_key=True)
    email = Column(String, unique=True, index=True)
    password = Column(String)
    friends = Column(JSON, nullable=True)
  1. routers/auth.py
    현재는 로직이랑 router를 따로 분리하지 않았다.
    내부에 있는 보안관련 함수들을 추후에 dependencies/security.py로 분리할 예정

[ 주요 기능 ]

  • 회원가입 시, password를 bcrypt를 통한 해시암호화
  • 로그인 시, JWT토큰 생성, 부여
  • 회원가입 시, 새로운 회원 정보 저장
  • 회원가입 中 id 입력 시에, 중복 체크
...

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# db 연결
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
        
# 패스워드 암호화
def get_password_hash(password):
    return pwd_context.hash(password)

# 패스워드 복호화
def verify(plain : str, hashed : str) -> bool:
    return pwd_context.verify(plain, hashed)

# 로그인 성공한 유저에게 토큰 부여
def create_access_token(data : dict, expires_delta : timedelta = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=60)
    to_encode.update({"exp" : expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# login 라우터
@router.post('/login')
def login(user : user_schemas.UserLogin, db: Session = Depends(get_db)):
    is_user = db.query(user_models.User).filter(user_models.User.id == user.id).first()
    # db에서 넘어온 user data에 해당하는 user를 찾는다.
    if not is_user or not verify(user.password, is_user.password):
        # 유저가 존재하지 않거나, id, password 쌍이 일치하지 않는다면
        raise HTTPException(status_code=400, detail="INVALID ID or Password")
        # 400 오류 제공
        
    # 로그인 정보가 올바르다면
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub" : is_user.id}, expires_delta = access_token_expires
    )
    # 유저 id 정보가 담긴 로그인 토큰 부여, 현재는 60분 기준
    return {"access_token" : access_token, "token_type" : "bearer"}

# signup 라우터
@router.post('/signup', response_model= user_schemas.UserOut)
def create_user(user : user_schemas.UserRegister, db : Session = Depends(get_db)):   
    db_user = db.query(user_models.User).filter(user_models.User.email == user.email).first()
    # 겹치는 email 정보가 있는지 확인, 한 유저(email 당)는 하나의 계정만 생성 가능
    if db_user:
        return HTTPException(status_code=400, detail="Already Joined Email")
    # 이미 해당 email로 만든 계정이 있다면 400 오류 제공

    # 회원가입한 pw를 암호화
    hashed_pw = get_password_hash(user.password)
    db_user = user_models.User(id = user.id, email = user.email, password = hashed_pw)
    # id, email, 암호화된 pw를 mysql db에 저장
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    # db 최신화
    
    return db_user

# id 중복체크 라우터
@router.get('/check-id/{id}')
def check_id(id:str, db:Session = Depends(get_db)):
    db_user = db.query(user_models.User).filter(user_models.User.id == id).first()
    # 현재 입력중인 id가 중복인지 확인
    # 중복이라면 중복이라고 리턴
    return {"isDuplicate" : bool(db_user)}

5. 결과


이런식으로 유효성검사도 잘되고, 회원가입/로그인 모두 잘 동작한다.

alert는 개인적으로 굉장히 기본 모양을 싫어하기 때문에
sweetAlert를 통해 변경할 예정


마치며...

오랜만에 FastAPI를 하니, 굉장히 미숙했다...
GPT가 없었으면 스타벅스에서 멍때리는 원숭이가 될 뻔 했다.
그리고 app/schemas를 통해 유효성 검사를 진행하면서 프로젝트의 안정성이 높아진다는 것을 알 수 있었다.

그리고 개인적으로 useMutationrecoil이 너무 편리한데,
이거 이전에 나 개발 어떻게했나 싶다.

profile
사람을 좋아하는 Front-End 개발자

0개의 댓글