Session 방식을 사용한 로그인 방식을 사용해보려고 했는데, 나와있는 예제들이 잘 없길래 내가 만들어가면서 정리하는 포스팅.
내가 원하는 Session 방식 로그인 목표는 다음과 같다.
내가 원하는 기능들
- 회원가입 구현
- Email 중복 확인
- Nickname 중복 확인
- 중복 확인을 확인했는지 검사
- Session 방식 로그인 구현
- 서버에 Session ID를 받아와서 쿠키로 관리
- 페이지 로드시 Session 상태에 따라 화면 분기
- 유효한 Session이면 데이터 요청
- 유효하지 않은 Session이면 로그인하라고 알림
대충 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 정책이라고 하는 게 있다. Cross-origin resource sharing이라고 하는 게 있는데, origin이 서로 다른 곳에서 리소스를 공유하는 것에 대한 정책이다. 이 정책은 기본적으로 서로의 리소스를 공유하는 걸 금지한다.
조금 쉽게 설명해보자면, 2학년 5반(Origin)에 있는 칠판 지우개(Resource)는 6반(Origin)에게 공유할 수 없다. 그러니까 6반이 5반의 칠판 지우개를 달라고 요청해도 주지 않는다는 것이다. 사실 우리땐 네 것이 내 것이었다만
같이 사는 세상 공유하면 좋지 않나? 같이 좀 쓰자! 라고 말하기엔 세상은 차갑고 위험한 곳. 금지하는 이유엔 다 이유가 있다.
위의 예제는 귀여운 편이라서 그렇게 위험해 보이진 않다. 칠판 지우개 거 뭐 빌려줄 수도 있지 다른 예시를 들어보자.
5반(Origin)에 지각비를 걷는 통(Resource)이 있다고 해보자. 기본적으로 5반 내의 학생들은 지각통에 접근할 수 있다. (믿음과 신뢰로ㅋㅋ) 그런데 5반이 다른 반에서도 접근할 수 있도록 해놨다면,,!! 6반이 5반인 척 하고 나 지각비를 더 냈다고 내지도 않은 지각비를 빼갈 수도 있는 것이다. (CSRF 공격)
사실 당연한 것. 그러므로 같은 반 내에서만 자원을 공유해야 하는 것이다.
그리고 여담으로 남의 사이트 이미지를 가져다가 쓰면 그 사이트의 트래픽이 많아져서 부담해야 할 돈이 많아지기도 하고(?)
그래서~ 그 동일한 출처임을 밝히는 거이 바로 origin 속성이다. 보통 백엔드 서버와 프론트엔드 서버의 주소는 다르기 때문에, 여기에 서버 말고 허용할 출처를 기입하는 것. 나는 프론트엔드의 개발 환경을 적어주었다.
그 보호되어야 할 자원에는 쿠키, 세션, 토큰 등이 있는데 주로 사용자 인증과 관련된 것이다. 사용자 인증에 관한 것은 조심스럽게 다뤄져야 해서, 이 credentials 속성이 있어야 같이 날아간다.
이게 무슨 말이냐면, 브라우저가 쿠키, 세션 등을 가지고 있는 상태에서, FE 쪽에서 어떤 요청을 서버에게 보낸다고 해보자. 그러면 이 요청에 자동으로 브라우저가 가진 쿠키, 세션이 같이 담겨져서 날아간다는 것이다. credentials이 설정되어 있지 않으면 서버에서 쿠키를 받아볼 수 없다.
언제나 그렇듯,, 출처가 다른 곳에서 쿠키를 공유할 상황이 생긴다. 이럴 때 어떻게 설정을 해줘야 하는지, 그 설정을 왜 해줘야 하는지에 대한 이해가 필요한 것~
보다 자세한 CORS 설명이 필요하다면~ 구글에 검색해보기!
라우팅에 대한 전반적인 건 이전 포스트를 참고!
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); });
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)여기는 코드가 살짝 길어서~ 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); });
세션 아이디를 생성하고, 그 세션 아이디에 따른 유저 정보를 저장하고 있다. 아래 코드.
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에 대한 유저 정보가 필요할 때 요놈을 불러다가 쓰면 된다.
그리고.. 꽤나 애를 먹었던 부분이다.
Cookie 지정 후 응답
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을 설정하지 않으면 쿠키가 함께 날아가지 않는다고만 설명이 되어있지, 저것에 대한 설명은 어디에도 없던데... 너무해! 여튼 이건 클라이언트 쪽 이야기라 후에 서술하겠다.
로그아웃하는 부분이다.
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 코드를 넘겨주었다.
세션 상태를 확인하는 부분.
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인 것 같더라(!) 당연히 새 객체를 만들어줘서 반환해주는 줄 알았는데,,, 그래서 저기에 없애면 원본도 없어져버려서 새로운 객체를 만들어주어서 처리했다.
이렇게 하면~ 서버 코드는 끝! 넘 길어지니까 클라이언트는 다음 포스트에서 계속~