React Session 로그인 구현(w. 회원가입, JSON Server, react-query): (1) 서버

김 주현·2023년 12월 3일

React Session 로그인

목록 보기
1/2

Session 방식을 사용한 로그인 방식을 사용해보려고 했는데, 나와있는 예제들이 잘 없길래 내가 만들어가면서 정리하는 포스팅.


기능 목표

내가 원하는 Session 방식 로그인 목표는 다음과 같다.

내가 원하는 기능들

  • 회원가입 구현
    • Email 중복 확인
    • Nickname 중복 확인
    • 중복 확인을 확인했는지 검사
  • Session 방식 로그인 구현
    • 서버에 Session ID를 받아와서 쿠키로 관리
  • 페이지 로드시 Session 상태에 따라 화면 분기
    • 유효한 Session이면 데이터 요청
    • 유효하지 않은 Session이면 로그인하라고 알림

API 명세

대충 API 명세는 다음과 같이 작성했다. 자세한 건 아니고 간단하게만 쓰겠음. 내가 하려는 프로젝트에서 가져옴(ㅋㅋ)

정상 응답은 Status Code 200으로 돌려주고, 먼가 이상하면 500으로 돌려준다.

중복 확인

이메일 중복확인

POST /member/check/email

  • Resquest: email
  • Response
    • 200: N/A
    • 500 - 1202: 이미 Email 존재

닉네임 중복확인

POST /member/check/nickname

  • Resquest: nickname
  • Response
    • 200: N/A
    • 500 - 1202: 이미 Nickname 존재

회원가입

회원가입

POST /member/siginup

  • Resquest: email, password, nickname
  • Response
    • 200: N/A
    • 500 - 1202: 이미 Email 존재
    • 500 - 1203: 이미 Nickname 존재

로그인/로그아웃/로그인 상태

로그인

POST /auth/login

  • Resquest: email, password
  • Response
    • 200: Set-Cookie: COMP_SESSION_ID=[VALUE]
    • 500 - 1202: Email 없음
    • 500 - 1203: 계정 정보 불일치

로그아웃

POST /auth/login

  • Resquest: Cookie: COMP_SESSION_ID=[VALUE]
  • Response
    • 200: Set-Cookie: COMP_SESSION_ID=''
    • 500 - 1202: 넘어온 쿠키 없음

상태

GET /auth/status

  • Resquest: Cookie: COMP_SESSION_ID=[VALUE]
  • Response
    • 200: email, nickname
    • 500 - 1202: 넘어온 세션 쿠키 없음
    • 500 - 1203: 유효하지 않은 세션ID

서버

중요한 부분만 체크하면서 넘어가겠다. 어디까지나 난 FE로서,, 목업이 필요해서 만든 것. 그래서 세션도 그냥 야매임

폴더 구조

  • /DB : db.json을 조작하는 메서드 모아둠
  • /Routes : 경로 별로 Controller 나눔
  • /Session : 들어오는 세션들 저장하는 곳

서버 코드

server.js

/** JSON Server */
import jsonServer from 'json-server';

/** Cookie Parser */
import cookieParser from 'cookie-parser';

/** Cors Middleware */
import cors from 'cors';

/** Route Import */
import { getMembersRoute } from './Routes/member.js';
import { getAuthRoute } from './Routes/auth.js';

/** 서버 생성 및 Middleware 적용 */
const server = jsonServer.create();
const router = jsonServer.router('db.json');
const middlewares = jsonServer.defaults();

server.use(
  cors({
    origin: 'http://localhost:5173',
    credentials: true,
  })
);
server.use(jsonServer.bodyParser);
server.use(cookieParser());
server.use(middlewares);

/** Route 설정 */
server.use('/member', getMembersRoute(server._router));
server.use('/auth', getAuthRoute(server._router));
server.use(router);

/** 서버 열기 */
server.listen(3001, () => {
  console.log('JSON Server is running');
});

CORS

다른 건 쓱 보면 될 것 같은데, CORS는 짚어줘야 할 것 같아서!

기본적으로 브라우저에서는 CORS 정책이라고 하는 게 있다. Cross-origin resource sharing이라고 하는 게 있는데, origin이 서로 다른 곳에서 리소스를 공유하는 것에 대한 정책이다. 이 정책은 기본적으로 서로의 리소스를 공유하는 걸 금지한다.

조금 쉽게 설명해보자면, 2학년 5반(Origin)에 있는 칠판 지우개(Resource)는 6반(Origin)에게 공유할 수 없다. 그러니까 6반이 5반의 칠판 지우개를 달라고 요청해도 주지 않는다는 것이다. 사실 우리땐 네 것이 내 것이었다만

같이 사는 세상 공유하면 좋지 않나? 같이 좀 쓰자! 라고 말하기엔 세상은 차갑고 위험한 곳. 금지하는 이유엔 다 이유가 있다.

보안 상의 이유

위의 예제는 귀여운 편이라서 그렇게 위험해 보이진 않다. 칠판 지우개 거 뭐 빌려줄 수도 있지 다른 예시를 들어보자.

5반(Origin)에 지각비를 걷는 통(Resource)이 있다고 해보자. 기본적으로 5반 내의 학생들은 지각통에 접근할 수 있다. (믿음과 신뢰로ㅋㅋ) 그런데 5반이 다른 반에서도 접근할 수 있도록 해놨다면,,!! 6반이 5반인 척 하고 나 지각비를 더 냈다고 내지도 않은 지각비를 빼갈 수도 있는 것이다. (CSRF 공격)

사실 당연한 것. 그러므로 같은 반 내에서만 자원을 공유해야 하는 것이다.

그리고 여담으로 남의 사이트 이미지를 가져다가 쓰면 그 사이트의 트래픽이 많아져서 부담해야 할 돈이 많아지기도 하고(?)

origin

그래서~ 그 동일한 출처임을 밝히는 거이 바로 origin 속성이다. 보통 백엔드 서버와 프론트엔드 서버의 주소는 다르기 때문에, 여기에 서버 말고 허용할 출처를 기입하는 것. 나는 프론트엔드의 개발 환경을 적어주었다.

credentials

그 보호되어야 할 자원에는 쿠키, 세션, 토큰 등이 있는데 주로 사용자 인증과 관련된 것이다. 사용자 인증에 관한 것은 조심스럽게 다뤄져야 해서, 이 credentials 속성이 있어야 같이 날아간다.

이게 무슨 말이냐면, 브라우저가 쿠키, 세션 등을 가지고 있는 상태에서, FE 쪽에서 어떤 요청을 서버에게 보낸다고 해보자. 그러면 이 요청에 자동으로 브라우저가 가진 쿠키, 세션이 같이 담겨져서 날아간다는 것이다. credentials이 설정되어 있지 않으면 서버에서 쿠키를 받아볼 수 없다.

무조건 금지?

언제나 그렇듯,, 출처가 다른 곳에서 쿠키를 공유할 상황이 생긴다. 이럴 때 어떻게 설정을 해줘야 하는지, 그 설정을 왜 해줘야 하는지에 대한 이해가 필요한 것~

보다 자세한 CORS 설명이 필요하다면~ 구글에 검색해보기!

라우팅

라우팅에 대한 전반적인 건 이전 포스트를 참고!

/member

Routes/member.js

import { createUser, deleteUser, hasEmail, hasNickname, updateUser } from '../DB/index.js';
import jsonServer from 'json-server';

/** @param {jsonServer.JsonServerRouter} router */
export const getMembersRoute = (router) => {
  router.post('/check/email', (req, res) => {
    if (hasEmail(req.body.email)) {
      return res.status(500).send({ statusCode: 1201, message: '이미 존재하는 이메일이삼' });
    }

    return res.sendStatus(200);
  });

  router.post('/check/nickname', (req, res) => {
    if (hasNickname(req.body.nickname)) {
      return res.status(500).send({ statusCode: 1202, message: '이미 존재하는 닉네임이삼' });
    }

    return res.sendStatus(200);
  });

  router.post('/signup', (req, res) => {
    const { email, password, nickname } = req.body;

    if (!email || !password || !nickname) {
      return res.status(500).send({ statusCode: 1201, message: '잘못된 데이터를 전송함' });
    }

    if (hasEmail(email)) {
      return res.status(500).send({ statusCode: 1202, message: 'Already exists email.' });
    }

    if (hasNickname(nickname)) {
      return res.status(500).send({ statusCode: 1203, message: 'Already exists nickname.' });
    }

    createUser({ email, password, nickname });

    return res.sendStatus(200);
  });

JSDoc

getMembersRoute 위에 보면 요상한 주석이 다음과 같이 있다.

/** @param {jsonServer.JsonServerRouter} router */

요거는 JSDoc이라고 해서 코드에 대한 설명을 붙여주는 마크업 언어인데~ 이걸 왜 해줬냐면, Typescript에서는 타입에 따라 Intellisense를 자동으로 지원해주는데, 바닐라 JS에서는 타입 추론이 안 되면 아무것도 표시해주지 않기 때문이다. 요거 불편해서 router에 대한 타입을 일러준 것. 그러면 Intellisense가 자동으로 나와서 편하다!

응답 보내기

Router에서 제공하는 콜백 함수의 Response 객체는 Method Chaining을 지원한다. Status Code를 지정해주고 싶으면 status() 를 쓰면 되고, 데이터를 보내려면 send() 를 쓰면 된다. 아무 응답 없이 Code만 보내고 싶으면 sendStatus() 를 쓰면 된다.

참고로 기본 상태는 200이므로, 데이터만 보내고 싶으면 send() 를 쓰면 된다.

  • 응답 코드와 함께 데이터 보내기: return res.status(500).send(DATA)
  • 응답 코드만 보내기: return res.sendStatus(200)

/auth/login

여기는 코드가 살짝 길어서~ endpoint 마다 잘라서 봐보겠다. 전체적인 구조는 /member와 동일하다. 먼저 /login 부분.

Route/auth.js - /login

router.post('/login', (req, res) => {
  const user = findUser(req.body.email);

  if (!user) {
    return res.status(500).send({ statusCode: 1101, message: 'Not found email.' });
  }

  const isSamePassword = user.password === req.body.password;

  if (!isSamePassword) {
    return res
      .status(500)
      .send({ statusCode: 1102, message: 'Bad request cause wrong password.' });
  }

  const newSessionId = uuid();

  Session.save(newSessionId, user);

  return res
    .cookie('COMP_SESSION_ID', newSessionId, {
      path: '/',
      maxAge: 3600000,
      sameSite: true,
      httpOnly: false,
    })
    .sendStatus(200);
});

Session 생성 및 저장

세션 아이디를 생성하고, 그 세션 아이디에 따른 유저 정보를 저장하고 있다. 아래 코드.

  const newSessionId = uuid();

  Session.save(newSessionId, user);

머..머.. 사실 SessionId는 Secrect Key와 함께 조합해서 생성하겠지만 어 그냥 uuid야~

Session에 save() 메소드를 통해서 저장해주고 있는데, Session은 다음과 같다.

Session/index.js

const sessions = new Map();

export const has = (sessionId) => sessions.has(sessionId);
export const get = (sessionId) => sessions.get(sessionId);
export const save = (sessionId, userData) => sessions.set(sessionId, userData);
export const deleteSession = (sessionId) => sessions.delete(sessionId);

별 거 없다. 실제로도 Session을 이렇게 관리하는진 모르겠지만,, 중요한 건 Session ID마다 연결된 유저 데이터가 존재한다는 것. 나는 Map 객체로 구현해보았다. 이제 세션 ID에 대한 유저 정보가 필요할 때 요놈을 불러다가 쓰면 된다.

그리고.. 꽤나 애를 먹었던 부분이다.

  return res
    .cookie('COMP_SESSION_ID', newSessionId, {
      path: '/',
      maxAge: 3600000,
      sameSite: true,
      httpOnly: false,
    })
    .sendStatus(200);

Middleware로 cookie-parser를 탑재하면 response에 cookie 메서드을 사용할 수 있게 된다. 요 메서드를 사용하면 클라이언트 쪽에서 응답을 받았을 때, Set-Cookie가 설정돼서 날아가고, Set-Cookie 헤더를 받은 브라우저는 자동으로 localStorage에 저장한다.

(1) 잊지 말자. Set-Cookie가 설정되면 브라우저가 자동으로 저장한다.

그런데~ 이렇게 설정을 해도 Set-Cookie는 분명 응답 헤더에 가는데, 쿠키가 저장이 안 되는 경우가 있다.

(2) FE의 credentials을 확인해보자.

Set-Cookie Header가 담긴 응답을 요청할 때 credentials이 설정되어 있어야 쿠키가 localStorage에 저장된다.

이것 때문에 삽질을 한 나... 중요하니까 온갖 강조 다 쓰기

credentials을 설정하지 않으면 쿠키가 함께 날아가지 않는다고만 설명이 되어있지, 저것에 대한 설명은 어디에도 없던데... 너무해! 여튼 이건 클라이언트 쪽 이야기라 후에 서술하겠다.

/auth/logout

로그아웃하는 부분이다.

Route/auth.js - /logout

router.post('/logout', (req, res) => {
  const cookies = req.headers.cookie;

  if (!cookies) {
    return res.sendStatus(200);
  }

  const cookieParams = cookies
    .split(';')
    .map((param) => Object.fromEntries(new URLSearchParams(param.trim())))
    .reduce((acc, cur) => {
      acc = { ...acc, ...cur };
      return acc;
    }, {});

  const sessionId = cookieParams['COMP_SESSION_ID'];

  if (!sessionId) {
    return res.sendStatus(200);
  }

  Session.deleteSession(sessionId);

  return res.clearCookie('COMP_SESSION_ID').sendStatus(200);
});

쿠키를 처리하는 과정 때문에 조오금 복잡해 보이지만~ 별 거 없다.

쿠키 처리

credentials를 설정해놓았기에 넘어온 쿠키는 내가 만든 쿠키~ 너를 위해 구웠지 뿐만 아니라 다른 쿠키도 섞여있을 수도 있다. 쿠키는 a=a; b=b의 형태를 가지고 있기 때문에, 그걸 적절히 조작해서 {a: a, b: b}로 만드는 로직이다.

이때 꿀팁인데, a=a와 같은 꼴이 있을 때 =을 기준으로 split해서 처리하려면 넘 복잡해지는데, 저 new URLSearchParams() 클래스에 저런 꼴을 넘겨주고, Object.fromEntries()에 다시 넣으면 위에서 말한 로직이 간편하게 만들어진다.

그런데 저런 형태가 여러개여서, reduce를 통해 하나의 객체가 되게끔 조작해준 것~ 아주 좋아요

세션 쿠키 없애기

clearCookie() 를 이용하면 브라우저 쿠키에 저장되어 있는 해당 키값을 제거해준다. 와우. 그 외의 경우는 이미 다 사라진 경우니까 200 코드를 넘겨주었다.

/auth/status

세션 상태를 확인하는 부분.

Route/auth.js - /status

router.get('/status', (req, res) => {
  const cookies = req.headers.cookie;

  if (!cookies) {
    return res.status(500).send({ statusCode: 1202, message: '쿠키에 세션값이 없음' });
  }

  const cookieParams = cookies
    .split(';')
    .map((param) => Object.fromEntries(new URLSearchParams(param.trim())))
    .reduce((acc, cur) => {
      acc = { ...acc, ...cur };
      return acc;
    }, {});

  const sessionId = cookieParams['COMP_SESSION_ID'];

  if (!sessionId) {
    return res.status(500).send({ statusCode: 1202, message: '쿠키에 세션값이 없음' });
  }

  const user = Session.get(sessionId);

  if (!user) {
    return res.status(500).send({ statusCode: 1203, message: '유효한 세션값이 아님' });
  }

  const passedUser = { ...user };

  delete passedUser.id;
  delete passedUser.password;

  return res.status(200).send(passedUser);
});

사실 코드가 좀 중복되는 부분이 있어서 함수로 빼거나 middleware로 빼주려고 했는데 에잉 쯧 귀찮아서 내비뒀다.

유저 정보 응답하기

  const passedUser = { ...user };

  delete passedUser.id;
  delete passedUser.password;

세션 ID에 해당하는 유저 정보가 있으면 넘겨주는데, 이때 PK인 id와 보안상의 password는 필요 없으니 없애줘서 넘겨주는 것이 좋다. user에서 직접 없애주지 않고 새로운 객체를 만들어 없애준 이유는~ 저 Session.get()가 넘겨준 녀석이 ByRef인 것 같더라(!) 당연히 새 객체를 만들어줘서 반환해주는 줄 알았는데,,, 그래서 저기에 없애면 원본도 없어져버려서 새로운 객체를 만들어주어서 처리했다.


이렇게 하면~ 서버 코드는 끝! 넘 길어지니까 클라이언트는 다음 포스트에서 계속~

profile
FE개발자 가보자고🥳

0개의 댓글