프로젝트/할일목록(TODO) 앱

할일목록(TODO) 앱 4 - API 설계 및 구현

syleemomo 2021. 10. 5. 20:23
728x90

* 기능정의 

  • 회원가입
  • 로그인
  • 로그아웃
  • 사용자정보 변경
  • 사용자정보 삭제
  • todo 생성 (해당 사용자 기준)
  • todo 목록조회 (해당 사용자 기준)
  • todo 조회 (해당 사용자 기준)
  • todo 변경 (해당 사용자 기준)
  • todo 삭제 (해당 사용자 기준)

 

* API 설계

HTTP 메서드는 흔히 CRUD(Creat, Read, Update, Delete) 라고 하며, REST API 에서는 GET(데이터 조회), POST(데이터 생성), PUT(데이터 변경), DELETE(데이터 삭제) 를 기본적으로 수행한다. 아래와 같은 URL 주소를 Rest API 엔드포인트라고 한다.

 

User 모델관련

URL URL 설명 HTTP 메서드
/api/users/register 회원가입 POST
/api/users/login 로그인 POST
/api/users/logout 로그아웃 POST
/api/users/{id} 사용자정보 변경 PUT
/api/users/{id} 사용자정보 삭제 DELETE

 

Todo 모델관련

URL URL 설명 HTTP 메서드
/api/todos 전체 할일 목록 조회 GET
/api/todos/{id} 특정 할일 조회 GET
/api/todos 새로운 할일 생성 POST
/api/todos/{id} 특정 할일 변경 PUT
/api/todos/{id} 특정 할일 삭제 DELETE

 

HTTP 메서드 참고 자료

 

HTTP 요청 메서드 - HTTP | MDN

HTTP는 요청 메서드를 정의하여, 주어진 리소스에 수행하길 원하는 행동을 나타냅니다. 간혹 요청 메서드를 "HTTP 동사"라고 부르기도 합니다. 각각의 메서드는 서로 다른 의미를 구현하지만, 일부

developer.mozilla.org

Restful URL 설계 규칙

 

[REST API] URL 규칙, RESTful한 URL이란?

REST API URL 규칙, RESTful한 URL이란?RESTful API REST API 설계시 가장 중요한 항목은 아래 두가지이다. 1️⃣ URI는 정보의 자원을 표현해야 한다는 점 2️⃣ 자원에 대한 행위는 HTTP Method(GET, POS..

devuna.tistory.com

https://www.moesif.com/blog/technical/api-design/Which-HTTP-Status-Code-To-Use-For-Every-CRUD-App/

 

Which HTTP Status Code to Use for Every CRUD App

How to use best use the correct HTTP status code in API design for CRUD apps (Create, Read, Update, Delete).

www.moesif.com

 

* API 서버 구현시 사용된 NODE, NPM 버전 확인하기 

해당 API 서버는 위와 같은 버전에서 구현하였다. 버전이 다르면 서버가 제대로 동작하지 않을수 있다. 

 

* 완성된 API 서버의 전체 패키지 목록 확인하기 

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "axios": "^1.4.0",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "express-async-handler": "^1.2.0",
    "jsonwebtoken": "^9.0.1",
    "mongoose": "^7.4.2",
    "morgan": "^1.10.0"
  }
}

API 서버를 완성하면 위와 같은 라이브러리들과 버전이 설치된다. 

 

* API 구현

 

* API 기본구조 작성하기

server >  src 폴더 하위에 routes 폴더를 생성한다. routes 폴더 하위에 users.js 와 todos.js 파일을 생성한다. 

const express = require('express')
const User = require('../models/User') 

const router = express.Router()

router.post('/register', (req, res, next) => {
  res.json("회원가입")
})
router.post('/login', (req, res, next) => {
  res.json("로그인")
})
router.post('/logout', (req, res, next) => {
  res.json("로그아웃")
})
router.put('/:id', (req, res, next) => {
  res.json("사용자정보 변경")
})
router.delete('/:id', (req, res, next) => {
  res.json("사용자정보 삭제")
})

module.exports = router

users.js 파일을 위와 같이 작성한다. express.Router() 를 이용하여 라우터를 모듈형태로 만든다. API 설계를 바탕으로 기본적인 틀만 작성해둔다. 

const express = require('express')
const Todo = require('../models/Todo') 

const router = express.Router()

router.get('/', (req, res, next) => {
  res.json("전체 할일목록 조회")
})
router.get('/:id', (req, res, next) => {
  res.json("특정 할일 조회")
})
router.post('/', (req, res, next) => {
  res.json("새로운 할일 생성")
})
router.put('/:id', (req, res, next) => {
  res.json("특정 할일 변경")
})
router.delete('/:id', (req, res, next) => {
  res.json("특정 할일 삭제")
})

module.exports = router

todos.js 파일을 위와 같이 작성한다. express.Router() 를 이용하여 라우터를 모듈형태로 만든다. API 설계를 바탕으로 기본적인 틀만 작성해둔다. 

var usersRouter = require('./src/routes/users')
var todosRouter = require('./src/routes/todos')

 

server > index.js 파일에 위와 같은 코드를 추가한다. 사용자가 /api/users 라우터에 요청을 보낸 경우 처리할 라우트핸들러 함수를 임포트한다. 또한, 사용자가 /api/todos 라우터에 요청을 보낸 경우 처리할 라우트핸들러 함수도 임포트한다. 

app.use('/api/users', usersRouter) // User 라우터
app.use('/api/todos', todosRouter) // Todo 라우터

사용자가 /api/users 라우터에 요청을 보내면 usersRouter 모듈이 실행된다. 또한, 사용자가 /api/todos 라우터에 요청을 보내면 todosRouter 모듈이 실행된다. 모듈은 파일과 유사한 개념이다. 

var express = require('express') // node_modules 내 express 관련 코드를 가져온다
var app = express()
var cors = require('cors') 
var logger = require('morgan')
var mongoose = require('mongoose')
var axios = require('axios')
var usersRouter = require('./src/routes/users')
var todosRouter = require('./src/routes/todos')

var corsOptions = { // CORS 옵션
    origin: 'http://127.0.0.1:5501',
    credentials: true
}
const CONNECT_URL = 'mongodb://localhost:27017/syleemomo'
mongoose.connect(CONNECT_URL)
.then(() => console.log("mongodb connected ..."))
.catch(e => console.log(`failed to connect mongodb: ${e}`))

app.use(cors(corsOptions)) // CORS 설정
app.use(express.json()) // request body 파싱
app.use(logger('tiny')) // Logger 설정 

app.use('/api/users', usersRouter) // User 라우터
app.use('/api/todos', todosRouter) // Todo 라우터

app.get('/hello', (req, res) => { // URL 응답 테스트
  res.json('hello world !')
})
app.post('/hello', (req, res) => { // POST 요청 테스트 
  console.log(req.body)
  res.json({ userId: req.body.userId, email: req.body.email })
})
app.get('/error', (req, res) => { // 오류 테스트 
  throw new Error('서버에 치명적인 에러가 발생했습니다.')
})
app.get('/fetch', async (req, res) => {
  const response = await axios.get('https://jsonplaceholder.typicode.com/todos')
  res.send(response.data)
})

// 폴백 핸들러 (fallback handler)
app.use( (req, res, next) => {  // 사용자가 요청한 페이지가 없는 경우 에러처리
    res.status(404).send("Sorry can't find page")
})
app.use( (err, req, res, next) => { // 서버 내부 오류 처리
    console.error(err.stack)
    res.status(500).send("something is broken on server !")
})
app.listen(5000, () => { // 5000 포트로 서버 오픈
    console.log('server is running on port 5000 ...')
})

이렇게 하면 localhost:5000/api URL 주소 아래에 우리가 설계한 Rest API URL 을 정의할 수 있다. localhost:5000/api/users 는 User (사용자) 관련 기능을 처리하는 라우터이고, localhost:5000/api/todos 는 Todo (할일) 관련 기능을 처리하는 라우터이다.

 

* API 테스트하기

Rest API 테스트 도구

HTTP 메서드 요청 중에서 GET, POST 는 브라우저에서 처리가 가능하지만 PUT, DELETE 까지 함께 처리하기 위해서는 API 테스터가 필요하다. 크롬 웹스토어에서 Talend API Tester 앱을 설치하고 API 요청을 보내서 테스트해보자!

회원가입 요청

http://localhost:5000/api/users/register 주소로 POST 요청을 보낸다. 서버는 "회원가입" 이라는 메세지를 응답으로 전달한다.

로그인 요청

http://localhost:5000/api/users/login 주소로 POST 요청을 보낸다. 서버는 "로그인" 이라는 메세지를 응답으로 전달한다.

로그아웃 요청

http://localhost:5000/api/users/logout 주소로 POST 요청을 보낸다. 서버는 "로그아웃" 이라는 메세지를 응답으로 전달한다. 

사용자정보 변경 요청

http://localhost:5000/api/users/12345 주소로 PUT 요청을 보낸다. 서버는 "사용자정보 변경" 이라는 메세지를 응답으로 전달한다.

사용자정보 삭제 요청

http://localhost:5000/api/users/12345 주소로 DELETE 요청을 보낸다. 서버는 "사용자정보 삭제" 이라는 메세지를 응답으로 전달한다.

전체 할일목록 조회 요청

http://localhost:5000/api/todos 주소로 GET 요청을 보낸다. 서버는 "전체 할일목록 조회" 라는 메세지를 응답으로 전달한다.

특정 할일 조회 요청

http://localhost:5000/api/todos/12345 주소로 GET 요청을 보낸다. 서버는 "특정 할일 조회" 라는 메세지를 응답으로 전달한다.

새로운 할일 생성 요청

http://localhost:5000/api/todos 주소로 POST 요청을 보낸다. 서버는 "새로운 할일 생성" 이라는 메세지를 응답으로 전달한다.

특정 할일 변경 요청

http://localhost:5000/api/todos/12345 주소로 PUT 요청을 보낸다. 서버는 "특정 할일 변경" 이라는 메세지를 응답으로 전달한다.

특정 할일 삭제 요청

http://localhost:5000/api/todos/12345 주소로 DELETE 요청을 보낸다. 서버는 "특정 할일 삭제" 이라는 메세지를 응답으로 전달한다.

 

* 환경변수 설정하기 

비밀키, API 키, 데이터베이스 주소와 같은 민감한 정보는 절대 소스코드에 추가해서되는 안된다. 이러한 정보는 .env 파일을 생성해서 소스코드와 분리해야 한다. 이를 위하여 아래와 같이 필요한 라이브러리를 설치해준다.

dotenv 패키지 설치

설치가 완료되면 package.json 파일이 위치한 곳에서 .env 파일을 생성하고 아래와 같이 MongoDB 데이터베이스 주소를 환경변수에 저장한다. 환경변수는 소스코드에 저장되는 것이 아니라 서버가 운영되고 있는 PC에 OS 변수로 저장된다. 

MONGODB_URL=mongodb://localhost:27017/syleemomo
JWT_SECRET=sunrise

환경변수 설정부분을 모듈화하기 위하여 package.json 파일이 위치한 곳에서 config.js 파일을 생성하고 아래와 같이 작성한다.

MONGODB_URL=mongodb://127.0.0.1:27017/syleemomo
JWT_SECRET=sunrise

DB 연동이 잘 되지 않으면 localhost 를 127.0.0.1 로 변경하자!

const dotenv = require('dotenv')

dotenv.config() // process.env 객체에 환경변수 설정

// console.log(process.env)

module.exports = {
  MONGODB_URL: process.env.MONGODB_URL,
  JWT_SECRET: process.env.JWT_SECRET,
}

dotenv.config() 를 실행하면 .env 파일에 설정한 환경변수를 읽어서 process.env 객체에 주입해준다. 즉, 로컬 PC에 저장된 환경변수를 express 프레임워크에서 사용할 수 있도록 해준다. MONGODB_URL 은 데이터베이스 주소이고, JWT_SECRET 은 사용자 인증을 위한 비밀키이다. 

node_modules
.env

.gitignore 파일에 .env 파일을 추가한다. .env 파일의 내용은 민감한 정보이므로 버전관리에서 제외하고 깃허브에 올리지 않도록 설정한다.

 

* 데이터베이스 주소를 환경변수로 변경하기 

var config = require('./config')

server > index.js 파일에 위의 코드를 상단에 추가한다.

const CONNECT_URL = 'mongodb://localhost:27017/syleemomo'
mongoose.connect(CONNECT_URL)
.then(() => console.log("mongodb connected ..."))
.catch(e => console.log(`failed to connect mongodb: ${e}`))

해당 코드블럭을 아래와 같이 변경한다.

mongoose.connect(config.MONGODB_URL)
.then(() => console.log("mongodb connected ..."))
.catch(e => console.log(`failed to connect mongodb: ${e}`))

이렇게 하면 데이터베이스 주소를 환경변수가 저장된 .env 파일에서 읽어와서 실행한다. 

 

* 사용자 인증을 위한 jwt 토큰 생성하기 

https://www.daleseo.com/js-jwt/

 

자바스크립트로 JWT 토큰을 발급하고 검증하기

Engineering Blog by Dale Seo

www.daleseo.com

 

jsonwebtoken 패키지 설치

사용자 인증을 위하여 해당 수업에서는 jwt 토큰 (json web token) 을 사용한다. 해당 토큰을 생성하고 검증하기 위해서는 위와 같은 라이브러리가 필요하므로 설치하도록 한다.

const jwt = require('jsonwebtoken')

// HS256 암호화 알고리즘 -> 대칭키 알고리즘
const token = jwt.sign({ email: "test@gmail.com" }, "비밀키", { expiresIn: '1d' })
console.log(token) // jwt 토큰 혹은 시그니쳐(signiture) -> base64 형식의 문자열

// 사용자 식별(권한검사) + 사용자정보 위변조 여부 검사 + 로그인여부 판별
const decodedResult = jwt.verify(token+'sss', '비밀키')
console.log(decodedResult)

package.json 파일이 위치한 곳에서 jwt.js 파일을 생성하고 위와 같이 작성한다. 토큰이 위조되면 ("sss" 추가) 아래와 같은 에러가 발생한다.

시그니쳐(signature)가 위조되었기 때문이다. 

 

const config = require('./config')
const jwt = require('jsonwebtoken')

const generateToken = (user) => { // 토큰 생성
  return jwt.sign({
    _id: user._id,  // 사용자 정보 (json)
    name: user.name,
    email: user.email,
    userId: user.userId,
    isAdmin: user.isAdmin,
    createdAt: user.createdAt,
  },
  config.JWT_SECRET, // jwt 비밀키
  {
    expiresIn: '1d', // 만료기한 (하루)
    issuer: 'sunrise',
  })
}

package.json 파일이 위치한 곳에서 auth.js 파일을 생성하고 위와 같이 작성한다. auth.js 파일에서는 사용자 인증과 관련된 유틸리티 함수를 추가한다. generateToken 함수는 jsonwebtoken 라이브러리의 sing 메서드를 이용하여 사용자 인증을 위한 토큰을 생성한다. sing 메서드의 첫번째 인자는 사용자 정보이고, 두번째 인자는 암호화된 사용자 정보(시그니쳐)를 파싱(해석)하기 위한 비밀키이다. 해당 키가 없으면 제대로된 사용자 정보를 조회하지 못한다. 세번째 인자는 토큰의 만료기한과 토큰 발행기관을 설정한다. 

 

* 사용자 권한 검증하기 

const isAuth = (req, res, next) => { // 권한확인
  const bearerToken = req.headers.authorization // 요청헤더에 저장된 토큰
  if(!bearerToken){
    res.status(401).json({message: 'Token is not supplied'}) // 헤더에 토근이 없는 경우
  }else{
    const token = bearerToken.slice(7, bearerToken.length) // Bearer 글자는 제거하고 jwt 토큰만 추출
    jwt.verify(token, config.JWT_SECRET, (err, userInfo) => {
      if(err && err.name === 'TokenExpiredError'){ // 토큰만료
        return res.status(419).json({ code: 419, message: 'token expired !'})
      }else if(err){
        return res.status(401).json({ code: 401, message: 'Invalid Token !'})
      }
      req.user = userInfo
      next()
    })
  }
}

auth.js 파일에 위 코드를 추가한다. isAuth 함수는 사용자 권한 (authorization)을 확인하기 위한 로직이다. 예를 들어 내가 작성하지 않은 블로그 글을 함부로 수정하지 못하도록 제한하는 용도이다. 브라우저는 현재 웹사이트에서 요청을 보낸 사용자가 누구인지 서버에게 알려주기 위하여 요청헤더(request header)에 토큰을 실어서 서버에게 전달한다. 물론 브라우저에 저장된 토큰은 서버가 이전에 생성해서 브라우저에게 보낸 것이다. 

만약 요청헤더에 토큰이 존재하지 않으면 401(권한에러) 코드로 오류를 발생시키고, 브라우저에게 에러에 대한 메세지를 전송한다. 토큰이 존재하면 토큰을 파싱한다. 토큰 앞에는 주로 "Bearer" 라는 접두사 문자열을 포함시켜서 보내기 때문에 실제 토큰은 해당 문자열을 제외해줘야 한다. 

isAuth 함수는 jsonwebtoken 라이브러리의 verify 메서드를 이용하여 사용자 권한을 검증한다. 즉, 사용자가 회원가입이나 로그인 이후에 토큰이 발급되면, 토큰을 검증했을때 오류가 나지 않기 때문에 해당 서비스를 이용할 권한이 있다고 판단한다. 이러한 경우에는 req 객체의 user 프로퍼티에 검증한 사용자정보를 저장하고 다음 라우트핸들러 함수로 넘어간다. 즉, 서비스 사용을 허용한다.

만약 토큰을 검증했을때 에러가 나면 토큰이 만료되었거나 유효하지 않은 토큰이기 때문이다. 전자는 419 (토큰만료) 코드로 오류를 발생시키고, 브라우저에게 에러에 대한 메세지를 전송한다. 후자는 401 (권한에러) 코드로 오류를 발생시키고, 브라우저에게 에러에 대한 메세지를 전송한다. 두 경우 모두 권한이 필요한 서비스는 사용하지 못한다. 

 

* 리팩토링 : 토큰을 검증한 후 사용자정보를 곧바로 req.user 에 담는데 만약, 토큰에 민감한 정보를 담지 않기 위하여 토큰 발급시 이메일과 같은 사용자 식별을 위한 필드만 담은 경우 req.user 에는 이메일 정보만 존재한다. 그러므로 사용자 아이디나 이름과 같은 다른 정보가 필요하다면 이메일 정보로부터 다시 사용자 DB에서 조회해서 해당 사용자의 상세정보를 얻어야 한다. 또한, 페이로드에는 이메일 정보만 넣어두고, 이메일 정보도 복호화가 가능한 양방향 암호화를 먼저 한다음에 jwt 토큰에 담아서 발급하면 디버거로 jwt 토큰을 복호화해도 이메일 정보가 암호화 되어 있으므로 탈취가 되어도 보안이 유지된다. 

 

* 사용자 권한 검증하기 - 관리자

const isAdmin = (req, res, next) => { // 관리자 확인
  if(req.user && req.user.isAdmin){
    next()
  }else{
    res.status(401).json({ code: 401, message: 'You are not valid admin user !'})
  }
}

토큰을 검증해서 권한이 있는 사용자인 경우 req.user 값이 존재한다. 또한 해당 사용자의 isAmin 필드가 true 인 경우 관리자이므로 관리자 서비스를 이용할 수 있도록 next() 를 이용하여 다음 라우트핸들러 함수를 실행한다. 관리자가 아니면 401 (권한에러) 코드로 오류를 발생시키고, 브라우저에게 에러에 대한 메세지를 전송한다. 즉, 관리자 권한이 없기 때문에 해당 서비스는 사용하지 못하도록 제한한다. 

const config = require('./config')
const jwt = require('jsonwebtoken')

const generateToken = (user) => { // 토큰 생성
  return jwt.sign({
    _id: user._id,  // 사용자 정보 (json)
    name: user.name,
    email: user.email,
    userId: user.userId,
    isAdmin: user.isAdmin,
    createdAt: user.createdAt,
  },
  config.JWT_SECRET, // jwt 비밀키
  {
    expiresIn: '1d', // 만료기한 (하루)
    issuer: 'sunrise',
  })
}

const isAuth = (req, res, next) => { // 권한확인
  const bearerToken = req.headers.authorization // 요청헤더에 저장된 토큰
  if(!bearerToken){
    res.status(401).json({message: 'Token is not supplied'}) // 헤더에 토근이 없는 경우
  }else{
    const token = bearerToken.slice(7, bearerToken.length) // Bearer 글자는 제거하고 jwt 토큰만 추출
    jwt.verify(token, config.JWT_SECRET, (err, userInfo) => {
      if(err && err.name === 'TokenExpiredError'){ // 토큰만료
        res.status(419).json({ code: 419, message: 'token expired !'})
      }else if(err){
        res.status(401).json({ code: 401, message: 'Invalid Token !'})
      }
      req.user = userInfo
      next()
    })
  }
}

const isAdmin = (req, res, next) => { // 관리자 확인
  if(req.user && req.user.isAdmin){
    next()
  }else{
    res.status(401).json({ code: 401, message: 'You are not valid admin user !'})
  }
}

module.exports = {
  generateToken,
  isAuth,
  isAdmin,
}

auth.js 파일의 전체코드는 위와 같다. 작성한 함수들은 외부에서 사용할 수 있도록 모듈형태로 만들어서 내보낸다. 

 

* 서버에서 비동기 코드 에러 처리하기

https://changjoopark.medium.com/express-%EB%9D%BC%EC%9A%B0%ED%8A%B8%EC%97%90%EC%84%9C-async-await%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%A0%A4%EB%A9%B4-7e8ffe0fcc84

 

Express 라우트에서 Async Await를 사용하려면?

Node.js 7.6 버전 이후에 도입된 async — await는 callback 혹은 Promise 중첩이 불러오는 코드의 지저분함과 핸들링의 어려움을 많이 제거해주었습니다. async-await는 기본적으로 비동기 코드를 하나의 흐

changjoopark.medium.com

서버에러 처리를 위한 패키지 설치

서버에서 데이터베이스에 요청을 보내고 결과를 받는 일을 전부 비동기적으로 발생한다. 이때 async, await 을 사용하여 비동기 코드를 작성하는데 에러를 처리하기 위해서는 try, catch 로 async, await 구문을 감싸줘야 한다. 하지만 매번 이러한 코드가 중복되면 코드의 가독성이 떨어지기 때문에 try, catch 로 에러를 자동으로 처리해주는 express-async-handler 라이브러리를 사용하도록 한다.

const expressAsyncHandler = require('express-async-handler') 
const { generateToken, isAuth } = require('../../auth')

src > routes > users.js 파일에 위 코드를 추가하다. 비동기 코드의 에러처리를 위한 라이브러리와 사용자 인증과 권한을 처리하기 위한 유틸리티 함수를 불러온다. 

const expressAsyncHandler = require('express-async-handler') 
const { isAuth } = require('../../auth')

src > routes > todos.js 파일에 위 코드를 추가하다. 비동기 코드의 에러처리를 위한 라이브러리와 사용자 인증과 권한을 처리하기 위한 유틸리티 함수를 불러온다. 

 

* 회원가입 구현하기

server > src > routes > users.js 파일에 아래 코드 부분을 수정한다. 

router.post('/register', expressAsyncHandler(async (req, res, next) => {
  console.log(req.body)
  const user = new User({
    name: req.body.name,
    email: req.body.email,
    userId: req.body.userId,
    password: req.body.password,
  })
  const newUser = await user.save() // 사용자정보 DB 저장
  if(!newUser){
    res.status(401).json({ code: 401, message: 'Invalid User Data'})
  }else{
    const { name, email, userId, isAdmin, createdAt } = newUser 
    res.json({ 
      code: 200, 
      token: generateToken(newUser), 
      name, email, userId, isAdmin, createdAt
    })
  }
}))

사용자가 /api/users/register 주소로 POST 요청을 보내면 회원가입이 진행된다. MongoDB 의 데이터 모델로 새로운 사용자를 생성하고, save() 메서드를 이용하여 DB 에 저장한다. 만약 DB 에 사용자 정보를 저장하다가 에러가 발생하면 401 (권한에러) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 사용자 정보 저장이 성공적으로 완료되면 해당 사용자 정보에서 필요한 필드만 추출해서 브라우저에게 전송한다. 이때 generateToken 함수를 이용하여 사용자 정보로부터 jwt 토큰을 생성하고, 브라우저에 함께 전달한다. 브라우저는 해당 토큰을 로컬스토리지 등에 저장해뒀다가 요청시 요청헤더의 authorization 속성에 실어서 보낸다. 

 

* 회원가입 테스트하기

{
  "name": "홍길동",
  "email": "gildong@gmail.com",
  "userId": "gildong",
  "password": "gildonghong1234"
}

마지막 password 이후에 콤마(,)가 있으면 제대로 요청이 실행되지 않는다. 

회원가입 요청

http://localhost:5000/api/users/register 주소로 POST 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 

브라우저가 보낸 요청본문

브라우저가 보낸 요청본문(request body)의 내용이 서버에 출력된다. 

회원가입 응답

서버가 보낸 응답은 위와 같다. 회원가입을 하기 위하여 서버에 전달한 사용자 정보를 응답으로 보내준다. password 필드는 민감한 정보라서 제외하고, 나머지 필드는 응답으로 보낸다. jsonwebtoken 으로 생성한 시그니처(암호화한 사용자정보) 또는 jwt 토큰도 함께 응답으로 전달된다. 해당 토큰은 추후 로그인 테스트를 위하여 메모장에 저장해둔다. 해당 토큰의 만료기한은 하루이다.

MongoDB User 컬렉션

데이터베이스에도 가입한 사용자정보가 잘 저장된 모습이다.

 

* 로그인 구현하기 

server > src > routes > users.js 파일에 아래 코드 부분을 수정한다. 

router.post('/login', expressAsyncHandler(async (req, res, next) => {
  console.log(req.body)
  const loginUser = await User.findOne({
    email: req.body.email, 
    password: req.body.password,
  })
  if(!loginUser){
    res.status(401).json({ code: 401, message: 'Invalid Email or Password' })
  }else{
    const { name, email, userId, isAdmin, createdAt } = loginUser 
    res.json({ 
      code: 200, 
      token: generateToken(loginUser), 
      name, email, userId, isAdmin, createdAt
    })
  }
}))

사용자가 /api/users/login 주소로 POST 요청을 보내면 로그인이 진행된다. mongoose 의 findOne 메서드와 브라우저에서 전송한 이메일, 비밀번호 정보를 이용하여 DB에 해당 사용자가 있는지 쿼리한다. 만약 DB 에서 해당 사용자를 찾지 못하면 401 (권한에러) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에서 해당 사용자를 찾는데 성공하면 해당 사용자 정보에서 필요한 필드만 추출해서 브라우저에게 전송한다. 이때 generateToken 함수를 이용하여 사용자 정보로부터 jwt 토큰을 생성하고, 브라우저에 함께 전달한다. 브라우저는 해당 토큰을 로컬스토리지 등에 저장해뒀다가 요청시 요청헤더의 authorization 속성에 실어서 보낸다. 

 

* 로그인 테스트하기

{
  "email": "gildong@gmail.com",
  "password": "gildonghong1234"
}

마지막 password 이후에 콤마(,)가 있으면 제대로 요청이 실행되지 않는다. 

로그인 요청

http://localhost:5000/api/users/login 주소로 POST 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 

브라우저가 보낸 요청본문(request body)의 내용이 서버에 출력된다. 

로그인 응답

서버가 보낸 응답은 위와 같다. 로그인을 하기 위하여 서버가 DB 에서 찾은 사용자 정보를 응답으로 보내준다. password 필드는 민감한 정보라서 제외하고, 나머지 필드는 응답으로 보낸다. jsonwebtoken 으로 생성한 시그니처(암호화한 사용자정보) 또는 jwt 토큰도 함께 응답으로 전달된다. 해당 토큰은 추후 사용자정보 변경 테스트를 위하여 메모장에 저장해둔다. 해당 토큰의 만료기한은 하루이다.

 

* 사용자 정보 변경 구현하기

server > src > routes > users.js 파일에 아래 코드 부분을 수정한다. 

// isAuth : 사용자를 수정할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const user = await User.findById(req.params.id)
  if(!user){
    res.status(404).json({ code: 404, message: 'User Not Founded'})
  }else{
    user.name = req.body.name || user.name 
    user.email = req.body.email || user.email
    user.password = req.body.password || user.password
    const updatedUser = await user.save()
    const { name, email, userId, isAdmin, createdAt } = updatedUser
    res.json({
      code: 200,
      token: generateToken(updatedUser),
      name, email, userId, isAdmin, createdAt
    })
  }
}))

사용자가 /api/users/{id} 주소로 PUT 요청을 보내면 사용자정보를 수정한다. 흔히 사용자 아이디나 비밀번호를 잊어버린 경우에 해당 로직을 실행한다. mongoose 의 findById 메서드와 URL 파라미터로 전달된 사용자 도큐먼트의 고유 ID 값을 이용하여 DB에 해당 사용자가 있는지 쿼리한다. 만약 DB 에서 해당 사용자를 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에서 해당 사용자를 찾는데 성공하면 해당 사용자 정보를 브라우저에서 전송한 요청본문(request body) 값으로 수정한다. 만약 요청본문에 사용자 정보 중 일부가 존재하지 않으면 해당 필드는 기존에 저장된 값을 그대로 사용한다. 이때 generateToken 함수를 이용하여 업데이트된 사용자 정보로부터 다시 jwt 토큰을 생성하고, 브라우저에 함께 전달한다. 브라우저는 해당 토큰을 로컬스토리지 등에 저장해뒀다가 요청시 요청헤더의 authorization 속성에 실어서 보낸다. 

* 취약점 : 내가 해당 사이트 회원이고 현재 로그인한 경우 사용자정보 수정 버튼을 클릭하면, 권한이 있으므로 사용자정보를 수정할 수 있다. 이때, req.user 는 로그인한 사용자의 정보이다. 만약 사용자정보 수정버튼 클릭시 URL 입력창에 해당 사용자의 ID값이 URL 파라미터에 포함되어 있지 않거나 id 값을 브라우저 단에서 자동으로 조회해서 숨겨서 넘어간다면 로그인한 사용자의 ID 값이 전달될 것이므로  자신의 정보를 수정하게 된다. 단, 여기서 한가지 의문점은 브라우저단에서 어떻게 로그인한 사용자의 ID 를 조회하느냐? 그리고 만약 로그인한 사용자가 다른 회원의 고유 ID 값을 탈취해서 알고 있다면 URL 파라미터의 id 값에 다른 사용자의 고유 ID값을 넣어서 다른 사용자의 정보를 수정할 수도 있지 않을까?

* 해결방안 : 토큰은 로그인한 사용자 정보를 가지고 있으므로 굳이 URL 파라미터에 사용자의 고유 ID 값을 전달할 필요없이 토큰을 isAuth 함수에서 파싱해서 로그인한 사용자 정보를 추출하고, 업데이트할 사용자를 찾을때 추출된 사용자 정보(현재 로그인된 사용자의 정보)가 담긴 req.user 를 넣어서 사용자 정보를 변경하면, 업데이트할 사용자와 현재 로그인한 사용자는 동일인물이 된다. 라우트도 router.put('/') 로 변경하는게 맞는거 같다. 

* 사용자정보 삭제도 마찬가지로 라우트를 "/"로 변경하고, 삭제할 사용자를 찾을때 토큰을 파싱해서 추출된 사용자 정보인 req.user 의 _id 값으로 검색해서 삭제하면 현재 로그인한 사용자 자신의 정보가 삭제된다. 

 

* 사용자정보 변경 테스트하기

현재 저장된 사용자의 고유한 ID 값

우선 현재 저장된 사용자 중에서 업데이트를 수행할 사용자의 고유한 ID 값을 확인한다. 

{
  "email": "gildong-update@gmail.com",
  "password": "gildonghong1234-update"
}

마지막 password 이후에 콤마(,)가 있으면 제대로 요청이 실행되지 않는다. 해당 테스트에서 name 필드는 수정하지 않는다. 

사용자정보 변경요청 실패

http://localhost:5000/api/users/64cf2cc3472eaa4ee5968e0c 주소로 PUT 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 물론 사용자 ID 는 블로그와 다를수 있다. MongoDB 에서 확인한 ID 값이다. 사용자정보를 수정하기 위해서는 권한이 필요하다. 해당 권한은 isAuth 라는 함수에서 처리한다. 로그인시 발급받은 토큰을 함께 전송하지 않았기 때문에 권한이 없다고 응답을 받는다.

브라우저가 보낸 응답에서 401 권한에러를 확인할 수 있다.

사용자정보 변경 응답

서버가 보낸 응답은 위와 같다. 사용자정보를 변경하려고 하였으나 브라우저 요청시 토큰을 함께 전달하지 않아서 유효하지 않은 사용자이므로 위와 같은 오류를 전송한다.

토큰 추가후 재요청

토큰을 추가하고 재요청하였으나 아래와 같이 유효하지 않는 토큰이라고 한다. 

유효하지 않은 토큰
유효한 토큰 전송

Authorization 헤더를 추가하고 위와 같이 Bearer 문자열에서 한칸 띄우고 로그인시 저장해둔 토큰을 따옴표 없이 붙여넣기한다. 

브라우저가 보낸 응답에서 200 성공을 확인할 수 있다.

사용자정보 변경 응답

서버가 보낸 응답은 위와 같다. 사용자정보를 변경하고 업데이트된 사용자 정보를 응답으로 보내준다. password 필드는 민감한 정보라서 제외하고, 나머지 필드는 응답으로 보낸다. jsonwebtoken 으로 생성한 시그니처(암호화한 사용자정보) 또는 jwt 토큰도 함께 응답으로 전달된다. jwt 토큰에는 사용자정보가 담겨있으므로 사용자정보가 변경되었으면 당연히 jwt 토큰을 새로 생성하고 발급해야 한다. 해당 토큰은 추후 사용자정보 삭제 테스트를 위하여 메모장에 저장해둔다. 해당 토큰의 만료기한은 하루이다.

 

* 사용자정보 삭제하기

server > src > routes > users.js 파일에 아래 코드 부분을 수정한다. 

// isAuth : 사용자를 삭제할 권한이 있는지 검사하는 미들웨어 
router.delete('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const user = await User.findByIdAndDelete(req.params.id);
  if (!user) {
    res.status(404).json({ code: 404, message: 'User Not Founded'})
  }else{
    res.status(204).json({ code: 204, message: 'User deleted successfully !' })
  }
}))

사용자가 /api/users/{id} 주소로 DELETE 요청을 보내면 사용자정보를 삭제한다. 흔히 사용자가 회원탈퇴를 진행하는 경우이다. mongoose 의 findByIdAndDelete 메서드와 URL 파라미터로 전달된 사용자 도큐먼트의 고유 ID 값을 이용하여 DB에 해당 사용자가 있는지 쿼리한다. 만약 DB 에서 해당 사용자를 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에서 해당 사용자를 찾는데 성공하면 해당 사용자 정보를 DB에서 삭제한다. 전달할 사용자정보나 jwt 토큰이 존재하지 않으므로 204(No Content) 코드를 메세지와 함께 브라우저에게 전송한다. 

 

* 사용자정보 삭제 테스트하기

현재 저장된 사용자의 고유한 ID 값

 

우선 현재 저장된 사용자 중에서 업데이트를 수행할 사용자의 고유한 ID 값을 확인한다. 

http://localhost:5000/api/users/64cf2cc3472eaa4ee5968e0c 주소로 DELETE 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 물론 사용자 ID 는 블로그와 다를수 있다. MongoDB 에서 확인한 ID 값이다. 사용자정보를 삭제하기 위해서는 권한이 필요하다. 해당 권한은 isAuth 라는 함수에서 처리한다. 사용자정보 변경시 발급받은 토큰을 함께 전송하지 않았기 때문에 권한이 없다고 응답을 받는다.

브라우저가 보낸 응답에서 401 권한에러를 확인할 수 있다.

서버가 보낸 응답은 위와 같다. 사용자정보를 변경하려고 하였으나 브라우저 요청시 토큰을 함께 전달하지 않아서 유효하지 않은 사용자이므로 위와 같은 오류를 전송한다.

사용자정보 삭제 요청 실패

이번에는 위와 같이 요청헤더에 발급받은 jwt 토큰을 함께 실어서 보낸다. 하지만 현재 블로그에서 생성한 사용자의 고유한 ID 값인 64cf2cc3472eaa4ee5968e0c 가 아니라  64cf2cc3472eaa4ee5968e01 와 같이 끝자리를 "c" 에서 "1" 로 변경해서 보낸다. 

서버가 보낸 응답은 위와 같다. 사용자정보를 삭제하려고 하였으나 삭제할 사용자정보를 DB에서 찾지 못한 경우이므로 404 (Not Found) 코드로 에러를 발생시키고 메세지를 브라우저에게 전송한다.

사용자삭제 요청 성공

서버가 보낸 응답은 위와 같다. 사용자정보 제대로 삭제하고 응답을 보낸다. 

사용자정보 삭제가 성공하면 204 (No Content) 코드와 함께 응답을 보내준다. 

해당 사용자 삭제 확인

MongoDB 에도 해당 사용자가 삭제되었음을 확인할 수 있다.

 

* todo 생성하기 (해당 사용자 기준)

server > src > routes > todos.js 파일에 아래 코드 부분을 수정한다. 

// isAuth : 새로운 할일을 생성할 권한이 있는지 검사하는 미들웨어 
router.post('/', isAuth, expressAsyncHandler(async (req, res, next) => {
  const searchedTodo = await Todo.findOne({
    author: req.user._id, 
    title: req.body.title,
  })
  if(searchedTodo){
    res.status(204).json({ code: 204, message: 'Todo you want to create already exists in DB !'})
  }else{
    const todo = new Todo({
      author: req.user._id, // 사용자 id
      title: req.body.title,
      description: req.body.description,
    })
    const newTodo = await todo.save()
    if(!newTodo){
      res.status(401).json({ code: 401, message: 'Failed to save todo'})
    }else{
      res.status(201).json({ 
        code: 201, 
        message: 'New Todo Created',
        newTodo // DB에 저장된 할일
      })
    }
  }
}))

사용자가 /api/todos 주소로 POST 요청을 보내면 해당 사용자의 ID 를 이용하여 새로운 할일을 생성한다. 이때 해당 사용자가 생성한 동일한 제목의 할일이 이미 존재하면 204 (No Content) 코드와 메세지가 브라우저에게 전송된다. 그렇지 않으면 MongoDB 의 데이터 모델로 새로운 할일을 생성하고, save() 메서드를 이용하여 DB 에 저장한다. 만약 DB 에 새로운 할일을 저장하다가 에러가 발생하면 401 (권한에러) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 새로운 할일을 성공적으로 저장하면 201 (Created) 코드와 함께 브라우저에게 메세지를 전달한다. 또한, DB에 생성된 새로운 TODO 도 함께 브라우저에게 전송한다. 만약 회원가입이나 로그인이 되어있지 않아서 브라우저 요청시 토큰이 없으면 isAuth 함수에 의하여 에러를 발생시킨다. 

 

* todo 생성 테스트하기 (해당 사용자 기준)

우선 토큰을 새로 발급받기 위하여 회원가입을 진행하고 새로운 사용자를 DB 에 추가한다.

{
  "name": "홍길동",
  "email": "gildong@gmail.com",
  "userId": "gildong",
  "password": "gildonghong1234"
}

마지막 password 이후에 콤마(,)가 있으면 제대로 요청이 실행되지 않는다.

회원가입 요청

http://localhost:5000/api/users/register 주소로 POST 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 새로 발급된 토큰을 메모장에 저장해둔다. 

새로운 할일생성 요청 실패

http://localhost:5000/api/todos 주소로 POST 요청을 보낸다. 요청헤더에 발급받은 토큰을 추가하지 않았기 때문에 isAuth 함수에 의하여 권한오류가 발생한다. 

새로운 할일생성 요청 실패

http://localhost:5000/api/todos 주소로 POST 요청을 보낸다. 이번에는 요청헤더에 발급받은 토큰도 함께 서버로 보낸다. 하지만 요청헤더(request body)에 title 정보를 제거하고 보낸다. 

Internal Server Error

데이터 스키마를 정의할때 title 필드는 required: true 로 설정하였으므로 express-async-handle 라이브러리에 의하여 위와 같은 서버오류를 발생시킨다. 

새로운 할일 생성 완료

http://localhost:5000/api/todos 주소로 POST 요청을 보낸다. 이번에는 요청헤더에 발급받은 토큰도 함께 서버로 보낸다. 그리고 요청헤더(request body)에 title 정보도 빠짐없이 보낸다.

새로운 할일생성 응답

서버가 보낸 응답은 위와 같다. 새로운 할일을 DB에 생성하고, 생성된 TODO를 응답으로 보내준다. author 는 TODO 를 생성한 사용자 고유 ID 값이다. 홍길동이라는 사용자의 TODO 인지 식별할 수 있다. 

MongoDB 에도 새로운 할일이 생성된 것을 확인할 수 있다. 

새로운 할일 생성 완료

동일한 요청을 한번더 보내서 새로운 할일을 생성할때 중복을 체크하는지 테스트해보자!

서버가 보낸 응답은 위와 같다. 이미 해당 사용자가 생성한 동일한 제목의 TODO 가 DB에 존재하기 때문에 새로 할일을 생성하지 않는다. 

 

* todo 전체목록 조회하기 (해당 사용자 기준)

server > src > routes > todos.js 파일에 아래 코드 부분을 수정한다. 

// isAuth : 전체 할일목록을 조회할 권한이 있는지 검사하는 미들웨어 
router.get('/', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todos = await Todo.find({ author: req.user._id }) // req.user 는 isAuth 에서 전달된 값
  if(todos.length === 0){
    res.status(404).json({ code: 404, message: 'Fail to find todos !'})
  }else{
    res.json({ code: 200, todos })
  }
}))

사용자가 /api/todos 주소로 GET 요청을 보내면 해당 사용자의 ID 를 이용하여 해당 사용자의 전체 할일목록을 조회한다. 만약 DB 에서 해당 사용자가 작성한 할일을 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 해당 사용자가 작성한 할일이 존재하면, 해당 사용자의 전체 할일목록을 브라우저에게 전송한다. 만약 회원가입이나 로그인이 되어있지 않아서 브라우저 요청시 토큰이 없으면 isAuth 함수에 의하여 에러를 발생시킨다.

 

* todo 전체목록 조회 테스트하기 (해당 사용자 기준)

{
  "title": "자바스크립트 공부하기",
  "description": "저녁에 까페가서 2시간동안 자바스크립트 비동기 공부하기"
}

{
  "title": "퇴근하고 집가서 샤워하기",
  "description": "저녁에 퇴근하고 집가서 시원한 물에 샤워하기"
}

전체 할일 목록을 조회하기 위하여 홍길동 사용자를 이용하여 여러개의 할일을 생성한다. 

todos 컬렉션

MongoDB 에 생성한 할일들이 저장되어 있다. 

{
  "name": "김철수",
  "email": "chulsu@gmail.com",
  "userId": "chulsu",
  "password": "chulsu1234"
}

회원가입을 통하여 새로운 사용자를 DB에 추가하자! 마지막 password 이후에 콤마(,)가 있으면 제대로 요청이 실행되지 않는다.

http://localhost:5000/api/users/register 주소로 POST 요청을 보낸다. 요청시 헤더의 Content-Type: application/json 인지 확인한다. 새로 발급된 토큰을 메모장에 사용자 이름과 함께 저장해둔다. 

홍길동

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmNzA3NTIwYjVjMTZkNWNiMDM0ZjEiLCJuYW1lIjoi7ZmN6ri464-ZIiwiZW1haWwiOiJnaWxkb25nQGdtYWlsLmNvbSIsInVzZXJJZCI6ImdpbGRvbmciLCJpc0FkbWluIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyMy0wOC0wNlQxMDowNTo0MS42MjJaIiwiaWF0IjoxNjkxMzE2MzQxLCJleHAiOjE2OTE0MDI3NDEsImlzcyI6InN1bnJpc2UifQ.dx5vTzcZyAxJURPS6yg4DCkLohUymD0aHDEjDeduQi8

김철수

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmODUyODg0NWUyZDJhM2I3YzJjZTciLCJuYW1lIjoi6rmA7LKg7IiYIiwiZW1haWwiOiJjaHVsc3VAZ21haWwuY29tIiwidXNlcklkIjoiY2h1bHN1IiwiaXNBZG1pbiI6ZmFsc2UsImNyZWF0ZWRBdCI6IjIwMjMtMDgtMDZUMTE6MzQ6MDAuNzg0WiIsImlhdCI6MTY5MTMyMTY0MCwiZXhwIjoxNjkxNDA4MDQwLCJpc3MiOiJzdW5yaXNlIn0.rupJFHV9HEwb4n18QyYIRTJsAOKhZFoGq67huQmatJM

 

김철수 사용자도 아래와 같이 여러개의 할일을 작성한다. 물론 요청헤더에 들어갈 토큰을 김철수의 토큰으로 변경한다. 

{
  "title": "일기장 쓰기",
  "description": "저녁에 집가서 30분동안 오늘 일어난 일을 일기로 작성하기"
}

{
  "title": "철수 친구들과 게임방 가기",
  "description": "저녁에 친구들과 게임방 가서 1시간동안 게임하기"
}

이제 김철수 사용자도 할일목록 2개가 생성되었다. 

 

http://localhost:5000/api/todos 주소로 GET 요청을 보낸다. 요청헤더에 발급받은 토큰 (김철수)도 함께 서버로 보낸다. 

김철수 사용자의 전체 할일목록 조회

http://localhost:5000/api/todos 주소로 GET 요청을 보낸다. 요청헤더에 발급받은 토큰 (홍길동)도 함께 서버로 보낸다. 이번에는 홍길동의 토큰으로 변경해서 보낸다. 

홍길동 사용자의 전체 할일목록 조회

 

아래와 같이 새로운 사용자를 한명 더 추가해서 전체 할일목록을 조회해보자!

{
  "name": "임윤아",
  "email": "yuna@gmail.com",
  "userId": "yuna",
  "password": "yuna1234"
}

임윤아 사용자 추가

 

임윤아 사용자의 토큰도 메모장에 추가해놓도록 하자!

홍길동

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmNzA3NTIwYjVjMTZkNWNiMDM0ZjEiLCJuYW1lIjoi7ZmN6ri464-ZIiwiZW1haWwiOiJnaWxkb25nQGdtYWlsLmNvbSIsInVzZXJJZCI6ImdpbGRvbmciLCJpc0FkbWluIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyMy0wOC0wNlQxMDowNTo0MS42MjJaIiwiaWF0IjoxNjkxMzE2MzQxLCJleHAiOjE2OTE0MDI3NDEsImlzcyI6InN1bnJpc2UifQ.dx5vTzcZyAxJURPS6yg4DCkLohUymD0aHDEjDeduQi8

김철수

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmODUyODg0NWUyZDJhM2I3YzJjZTciLCJuYW1lIjoi6rmA7LKg7IiYIiwiZW1haWwiOiJjaHVsc3VAZ21haWwuY29tIiwidXNlcklkIjoiY2h1bHN1IiwiaXNBZG1pbiI6ZmFsc2UsImNyZWF0ZWRBdCI6IjIwMjMtMDgtMDZUMTE6MzQ6MDAuNzg0WiIsImlhdCI6MTY5MTMyMTY0MCwiZXhwIjoxNjkxNDA4MDQwLCJpc3MiOiJzdW5yaXNlIn0.rupJFHV9HEwb4n18QyYIRTJsAOKhZFoGq67huQmatJM

임윤아

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmOGUzYzk4YjQyMmIwYTVlMGVmOWMiLCJuYW1lIjoi7J6E7Jyk7JWEIiwiZW1haWwiOiJ5dW5hQGdtYWlsLmNvbSIsInVzZXJJZCI6Inl1bmEiLCJpc0FkbWluIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyMy0wOC0wNlQxMjoxMjo0NC4xNTNaIiwiaWF0IjoxNjkxMzIzOTY0LCJleHAiOjE2OTE0MTAzNjQsImlzcyI6InN1bnJpc2UifQ.BaAHN_5z8aWlWVwwGgh8cxyF5Zlb8gYP-ye34jUf1IE

임윤아 사용자의 토큰으로 요청헤더의 Authorization 을 변경해서 해당 사용자의 전체 할일목록을 조회하면 위와 같이 404 (Not Found) 에러를 발생시키고 브라우저에게 메세지와 함께 전송한다. 

 

* 특정 todo 조회하기 (해당 사용자 기준)

server > src > routes > todos.js 파일에 아래 코드 부분을 수정한다. 

// isAuth : 특정 할일을 조회할 권한이 있는지 검사하는 미들웨어 
router.get('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    res.json({ code: 200, todo })
  }
}))

사용자가 /api/todos/{id} 주소로 GET 요청을 보내면 해당 사용자의 ID와 URL 파라미터로 전달된 TODO 의 고유한 ID를 이용하여 해당 사용자가 작성한 특정 할일을 조회한다. 만약 DB 에서 해당 사용자가 작성한 특정 할일을 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 해당 사용자가 작성한 특정 할일이 존재하면, 해당 사용자의 특정 TODO를 브라우저에게 전송한다. 만약 회원가입이나 로그인이 되어있지 않아서 브라우저 요청시 토큰이 없으면 isAuth 함수에 의하여 에러를 발생시킨다.

 

* 특정 todo 조회 테스트하기 (해당 사용자 기준)

만약 위와 같이 김철수 사용자가 작성한 특정 할일을 조회하려면 해당 TODO의 ID 값을 확인해야 한다. 위에서는 64cf884c845e2d2a3b7c2ced 이다. 

홍길동

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmNzA3NTIwYjVjMTZkNWNiMDM0ZjEiLCJuYW1lIjoi7ZmN6ri464-ZIiwiZW1haWwiOiJnaWxkb25nQGdtYWlsLmNvbSIsInVzZXJJZCI6ImdpbGRvbmciLCJpc0FkbWluIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyMy0wOC0wNlQxMDowNTo0MS42MjJaIiwiaWF0IjoxNjkxMzE2MzQxLCJleHAiOjE2OTE0MDI3NDEsImlzcyI6InN1bnJpc2UifQ.dx5vTzcZyAxJURPS6yg4DCkLohUymD0aHDEjDeduQi8

김철수

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmODUyODg0NWUyZDJhM2I3YzJjZTciLCJuYW1lIjoi6rmA7LKg7IiYIiwiZW1haWwiOiJjaHVsc3VAZ21haWwuY29tIiwidXNlcklkIjoiY2h1bHN1IiwiaXNBZG1pbiI6ZmFsc2UsImNyZWF0ZWRBdCI6IjIwMjMtMDgtMDZUMTE6MzQ6MDAuNzg0WiIsImlhdCI6MTY5MTMyMTY0MCwiZXhwIjoxNjkxNDA4MDQwLCJpc3MiOiJzdW5yaXNlIn0.rupJFHV9HEwb4n18QyYIRTJsAOKhZFoGq67huQmatJM

임윤아

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2NGNmOGUzYzk4YjQyMmIwYTVlMGVmOWMiLCJuYW1lIjoi7J6E7Jyk7JWEIiwiZW1haWwiOiJ5dW5hQGdtYWlsLmNvbSIsInVzZXJJZCI6Inl1bmEiLCJpc0FkbWluIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyMy0wOC0wNlQxMjoxMjo0NC4xNTNaIiwiaWF0IjoxNjkxMzIzOTY0LCJleHAiOjE2OTE0MTAzNjQsImlzcyI6InN1bnJpc2UifQ.BaAHN_5z8aWlWVwwGgh8cxyF5Zlb8gYP-ye34jUf1IE

또한, 김철수 사용자의 JWT 토큰도 알고 있어야 한다. 

특정 할일 조회 요청
서버에 기록된 로그

 

홍길동 사용자가 작성한 특정 할일을 조회하려면 아래와 같이 조회하려는 특정 TODO 의 ID 값을 알고 있어야 한다. 

위에서는 64cf83a0845e2d2a3b7c2ce5 이다.

 

* 특정 todo 변경하기 (해당 사용자 기준)

// isAuth : 특정 할일을 변경할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    todo.title = req.body.title || todo.title
    todo.description = req.body.description || todo.description
    todo.isDone = req.body.isDone || todo.isDone
    todo.lastModifiedAt = new Date() // 수정시각 업데이트
    todo.finishedAt = todo.isDone ? todo.lastModifiedAt : todo.finishedAt
    
    const updatedTodo = await todo.save()
    res.json({
      code: 200,
      message: 'TODO Updated',
      updatedTodo
    })
  }
}))

사용자가 /api/todos/{id} 주소로 PUT 요청을 보내면 해당 사용자의 ID와 URL 파라미터로 전달된 TODO 의 고유한 ID를 이용하여 해당 사용자가 작성한 특정 할일을 조회한다. 만약 DB 에서 해당 사용자가 작성한 특정 할일을 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 해당 사용자가 작성한 특정 할일이 존재하면, 해당 사용자의 특정 TODO를 업데이트하고, 업데이트된 TODO 를 브라우저에게 전송한다. 만약 회원가입이나 로그인이 되어있지 않아서 브라우저 요청시 토큰이 없으면 isAuth 함수에 의하여 에러를 발생시킨다. 

todo 의 title, description, isDone 필드는 요청본문(request body)에서 전달된 값이 있으면 해당값으로 업데이트하고, 그렇지 않으면 기존에 저장된 값을 유지한다. todo 의 lastModifiedAt 필드는 업데이트가 수행된 현재시간으로 변경한다. todo 의 finishedAt 필드는 isDone 필드의 값이 true 이면 업데이트가 수행된 현재시간으로 변경해서 현재 할일을 끝맞쳤음을 표시한다. 반면에 isDone 필드가 false 이면 기존에 저장된 값을 유지한다. 

 

* 특정 todo 변경 테스트하기 (해당 사용자 기준)

http://localhost:5000/api/todos/{id} 주소로 PUT 요청을 보낸다. 현재는 홍길동 사용자의 특정 할일을 변경한다. isDone 필드만 true 로 변경한다. 

홍길동 사용자의 특정 할일 변경 응답

isDone 필드가 true 로 변경되고, lastModifiedAt, finishedAt 필드가 업데이트된 시각으로 변경되었음을 확인할 수 있다. 

MongoDB 에서도 업데이트된 사항을 확인할 수 있다. 

특정 할일 변경 실패

브라우저 요청시 TODO 의 ID 값 끝자리를 다르게 고치고, 다시 요청을 보내보자!

서버는 업데이트할 TODO 를 찾지 못하였으므로 404 (Not Found) 에러를 발생시키고, 오류 메세지를 브라우저에게 전송한다. 

 

* 특정 todo 삭제하기 (해당 사용자 기준)

// isAuth : 특정 할일을 삭제할 권한이 있는지 검사하는 미들웨어 
router.delete('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    await Todo.deleteOne({ 
      author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
      _id: req.params.id // TODO id 
    })
    res.status(204).json({ code: 204, message: 'TODO deleted successfully !' })
  }
}))

사용자가 /api/todos/{id} 주소로 DELETE 요청을 보내면 해당 사용자의 ID와 URL 파라미터로 전달된 TODO 의 고유한 ID를 이용하여 해당 사용자가 작성한 특정 할일을 DB에서 삭제한다. 이때 DB 에서 해당 사용자가 작성한 특정 할일을 찾지 못하면 404 (Not Found) 코드로 오류를 발생시키고, 에러 메세지를 브라우저에게 전달한다. DB 에 해당 사용자가 작성한 특정 할일이 존재하면, 해당 사용자의 특정 TODO를 삭제하고, 204(No Content) 코드를 브라우저에게 전송한다. 만약 회원가입이나 로그인이 되어있지 않아서 브라우저 요청시 토큰이 없으면 isAuth 함수에 의하여 에러를 발생시킨다. 

 

* 특정 todo 삭제 테스트하기 (해당 사용자 기준)

우선 DB에서 삭제하고 싶은 TODO 의 ID 값을 확인한다. 현재는 64cf8257845e2d2a3b7c2ce2 이다.

http://localhost:5000/api/todos/{id} 주소로 DELETE 요청을 보낸다. 현재는 홍길동 사용자의 특정 할일을 삭제한다. 

홍길동 사용자의 특정 할일 삭제 응답
홍길동 사용자의 할일목록 조회

MongoDB 에서 확인해보면 해당 할일이 삭제되었음을 확인할 수 있다. 

 

* 코드 리팩토링하기 

server > src > routes > user.js 파일에 아래 코드를 추가한다.

user.lastModifiedAt = new Date() // 수정시각 업데이트
// isAuth : 사용자를 수정할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const user = await User.findById(req.params.id)
  if(!user){
    res.status(404).json({ code: 404, message: 'User Not Founded'})
  }else{
    user.name = req.body.name || user.name 
    user.email = req.body.email || user.email
    user.password = req.body.password || user.password
    user.isAdmin = req.body.isAdmin || user.isAdmin 
    user.lastModifiedAt = new Date() // 수정시각 업데이트
    
    const updatedUser = await user.save()
    const { name, email, userId, isAdmin, createdAt } = updatedUser
    res.json({
      code: 200,
      token: generateToken(updatedUser),
      name, email, userId, isAdmin, createdAt
    })
  }
}))

이렇게 하면 사용자정보가 마지막으로 업데이트된 시각을 알 수 있다. 또한, isAdmin 필드도 업데이트가 가능하도록 코드를 추가한다.

취약점: 사용자가 직접 자신의 관리자 권한을 변경하는 것은 이상하다. 추후 관리자가 특정 사용자에게 관리자 권한을 부여하도록 수정한다. 

 

const isAuth = (req, res, next) => { // 권한확인
  const bearerToken = req.headers.authorization // 요청헤더에 저장된 토큰
  if(!bearerToken){
    res.status(401).json({message: 'Token is not supplied'}) // 헤더에 토근이 없는 경우
  }else{
    const token = bearerToken.slice(7, bearerToken.length) // Bearer 글자는 제거하고 jwt 토큰만 추출
    jwt.verify(token, config.JWT_SECRET, (err, userInfo) => {
      if(err && err.name === 'TokenExpiredError'){ // 토큰만료
        res.status(419).json({ code: 419, message: 'token expired !'})
      }else if(err){
        res.status(401).json({ code: 401, message: 'Invalid Token !'})
      }else{
        req.user = userInfo
        next()
      }
    })
  }
}

server > auth.js 파일의 해당부분을 위와 같이 수정한다. 

req.user = userInfo
next()

해당 코드를 else 구문 안에 위치시킨다. 이렇게 하지 않으면 에러처리하고 나서도 next()를 이용하여 그 다음 라우트핸들러 함수로 넘어간다. 그래서 아래와 같이 에러를 일으킨다.

 

const isAuth = (req, res, next) => { // 사용자 권한 검증하는 미들웨어 
    const bearerToken = req.headers.authorization // 요청헤더의 Authorization 속성
    if(!bearerToken){
        return res.status(401).json({ message: 'Token is not supplied' }) // return 추가
    }else{
        const token = bearerToken.slice(7, bearerToken.length) // Bearer 글자 제거하고 토큰만 추출
        jwt.verify(token, config.JWT_SECRET, (err, userInfo) => {
            if(err && err.name === 'TokenExpiredError'){
                return res.status(419).json({ code: 419, message: 'token expired!' })
            }else if(err){
                return res.status(401).json({ code: 401, message: 'Invalid Token' }) // 토큰이 위변조가 되어서 복호화를 할수 없는 경우
            }
            req.user = userInfo
            next() // 권한이 있는 사용자의 서비스 허용 
        })
    }
}

auth.js 파일에서 토큰이 없을때 401 권한에러를 발생시키면서 응답을 전달하는데 이때 return 문을 추가한다. 

 

* populate 메서드로 author id 를 user 정보로 치환하기

server > src > routes > todos.js 파일의 해당 부분을 아래와 같이 수정한다. 

// isAuth : 전체 할일목록을 조회할 권한이 있는지 검사하는 미들웨어 
router.get('/', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todos = await Todo.find({ author: req.user._id }).populate('author') // req.user 는 isAuth 에서 전달된 값
  if(todos.length === 0){
    res.status(404).json({ code: 404, message: 'Fail to find todos !'})
  }else{
    res.json({ code: 200, todos })
  }
}))

해당 사용자의 전체 할일목록을 조회할때 populate 메서드를 이용하여 사용자 ID 값을 실제 사용자정보로 치환한다. populate 메서드를 이용하여 사용자의 ID값을 사용자의 전체 도큐먼트를 치환하기 위해서는 Todo 모델의 author 필드에 ref: 'User'가 지정되어 있어야 한다.

const todos = await Todo.find({ author: req.user._id }).populate('author') // req.user 는 isAuth 에서 전달된 값

기존의 전체 할일목록 조회

기존에는 author 필드가 사용자 ID 값이다. 

populate 메서드를 사용하면 위와 같이 author 필드에는 실제 사용자정보가 포함된다. 

취약점: 사용자정보 중에서 password 필드는 제외하고 보여주도록 수정하도록 한다.

router.get('/', isAuth, expressAsyncHandler(async (req, res, next) => { // /api/todos/
    const todos = await Todo.find({ author: req.user._id }).populate('author', ['name', 'userId']) // 수정
    if(todos.length === 0){
        res.status(404).json({ code: 404, message: 'Failed to find todos!'})
    }else{
        res.json({ code: 200, todos })
    }
}))

취약점을 해결하기 위하여 routes > todos.js 파일에서 전체 할일목록을 조회할때 사용자 정보는 name, usuerId 필드만 보여주도록 코드를 수정한다.  

 

* Todo 스키마 필드 추가하기

server > src > models > Todo.js 파일을 아래와 같이 수정한다.

const mongoose = require('mongoose')

const { Schema } = mongoose
const { Types: { ObjectId } } = Schema

const todoSchema = new Schema({ // 스키마 정의
  author: {
    type: ObjectId, 
    required: true,
    ref: 'User'
  },
  category: {
    type: String,
    required: true,
    trim: true
  },
  imgUrl: {
    type: String,
    required: true,
    trim: true
  },
  title: {
    type: String,
    required: true,
    trim: true
  },
  description: {
    type: String,
    trim: true,
  },
  isDone: {
    type: Boolean,
    default: false,
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
  lastModifiedAt: {
    type: Date,
    default: Date.now,
  },
  finishedAt: {
    type: Date,
    default: Date.now
  }
})

const Todo = mongoose.model('Todo', todoSchema)
module.exports = Todo

// todo 데이터 생성 테스트
// const todo = new Todo({
//   author: '111111111111111111111111', // 24자리
//   title: ' 주말에 공원 산책하기  ',
//   description: '주말에 집 주변에 있는 공원에 가서 1시간동안 산책하기',
// });
// todo.save().then(() => console.log('todo created !'));

도큐먼트 그룹핑을 위하여 category 필드를 추가한다. 또한, Todo 의 대표 이미지를 보여주기 위하여 imgUrl 필드도 추가한다. 

category: {
    type: String,
    required: true,
    trim: true
},
imgUrl: {
    type: String,
    required: true,
    trim: true
},

 

* TODO 생성, 변경 로직 수정하기

server > src > routes > todos.js 파일의 해당 부분을 아래와 같이 수정한다.

// isAuth : 새로운 할일을 생성할 권한이 있는지 검사하는 미들웨어 
router.post('/', isAuth, expressAsyncHandler(async (req, res, next) => {
  const searchedTodo = await Todo.findOne({
    author: req.user._id, 
    title: req.body.title,
  })
  if(searchedTodo){
    res.status(204).json({ code: 204, message: 'Todo you want to create already exists in DB !'})
  }else{
    const todo = new Todo({
      author: req.user._id, // 사용자 id
      title: req.body.title,
      description: req.body.description,
      category: req.body.category, // 추가
      imgUrl: req.body.imgUrl // 추가
    })
    const newTodo = await todo.save()
    if(!newTodo){
      res.status(401).json({ code: 401, message: 'Failed to save todo'})
    }else{
      res.status(201).json({ 
        code: 201, 
        message: 'New Todo Created',
        newTodo // DB에 저장된 할일
      })
    }
  }
}))

새로운 할일 생성시 category, imgUrl 필드를 추가한다. 

// isAuth : 특정 할일을 변경할 권한이 있는지 검사하는 미들웨어 
router.put('/:id', isAuth, expressAsyncHandler(async (req, res, next) => {
  const todo = await Todo.findOne({ 
    author: req.user._id,  // req.user 는 isAuth 에서 전달된 값
    _id: req.params.id // TODO id 
  })
  if(!todo){
    res.status(404).json({ code: 404, message: 'Todo Not Found '})
  }else{
    todo.title = req.body.title || todo.title
    todo.description = req.body.description || todo.description
    todo.isDone = req.body.isDone || todo.isDone
    todo.category = req.body.category || todo.category // 추가
    todo.imgUrl = req.body.imgUrl || todo.imgUrl       // 추가
    todo.lastModifiedAt = new Date() // 수정시각 업데이트
    todo.finishedAt = todo.isDone ? todo.lastModifiedAt : todo.finishedAt
    
    const updatedTodo = await todo.save()
    res.json({
      code: 200,
      message: 'TODO Updated',
      updatedTodo
    })
  }
}))

특정 할일 변경시 category, imgUrl 필드도 변경해준다.

 

* 도큐먼트 그룹핑을 위한 더미 데이터 생성하기 

https://bobbyhadz.com/blog/javascript-generate-random-date

 

How to generate a random Date in JavaScript | bobbyhadz

A step-by-step guide on how to generate random dates within a given range in JavaScript.

bobbyhadz.com

 

server > buildDocuments.js 파일을 생성하고 아래와 같이 작성한다. 

var mongoose = require('mongoose')
const User = require('./src/models/User')
const Todo = require('./src/models/Todo')
const config = require('./config')

const category = ['오락', '공부', '음식', '자기계발', '업무', '패션', '여행']
const done = [true, false]
let users = []

mongoose.connect(config.MONGODB_URL)
.then(() => console.log("mongodb connected ..."))
.catch(e => console.log(`failed to connect mongodb: ${e}`))

// 랜덤날짜 생성
const generateRandomDate = (from, to) => {
  return new Date(
    from.getTime() +
      Math.random() * (to.getTime() - from.getTime())
  )
}

// 배열에서 랜덤값 선택
const selectRandomValue = (arr) => {
  return arr[Math.floor(Math.random() * arr.length)]
}

// 랜덤 문자열 생성
const generateRandomString = n => {
  const alphabet = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
  const str = new Array(n).fill('a')
  return str.map(s => alphabet[Math.floor(Math.random()*alphabet.length)]).join("")
}

// user 데이터 생성 테스트
const createUsers = async (n, users) => {
  console.log('creating users now ...')
  for(let i=0; i<n; i++){
    const user = new User({
      name: generateRandomString(5),
      email: `${generateRandomString(7)}@gmail.com`,
      userId: generateRandomString(10),
      password: generateRandomString(13),
    });
    users.push(await user.save())
  }
  return users
}

// todo 데이터 생성 테스트
const createTodos = async (n, user) => {
  console.log(`creating todos by ${user.name} now ...`)
  for(let i=0; i<n; i++){
    const todo = new Todo({
      author: user._id, 
      title: generateRandomString(10),
      description: generateRandomString(19),
      imgUrl: `https://wwww.${generateRandomString(10)}.com/${generateRandomString(10)}.png`,
      category: selectRandomValue(category),
      isDone: selectRandomValue(done),
      createdAt: generateRandomDate(new Date(2023, 5, 1), new Date()),
      lastModifiedAt: generateRandomDate(new Date(2023, 5, 1), new Date()),
      finishedAt: generateRandomDate(new Date(2023, 5, 1), new Date()),
    })
    await todo.save()
  }
}

// 사용자와 해당 사용자의 할일목록을 순서대로 생성함 
const buildData = async (users) => {
  users = await createUsers(7, users)
  users.forEach(user => createTodos(30, user))
}

// 데이터 생성
buildData(users)

category, isDone, createdAt, lastModifiedAt, finishedAt 필드로 그룹핑을 하기 위하여 배열에 그룹핑을 위한 값들을 넣어놓고, 랜덤하게 뽑아서 데이터를 생성할 예정이다. 예를 들어 category 배열에는 현재 7개의 값이 들어있다. 해당 값을 랜덤하게 뽑아서 todo 생성시 category 필드에 저장하면 추후 7개의 값으로 그룹핑이 가능하다. 

더미 데이터 생성

위와 같이 터미널에서 buildDocuments.js 파일을 실행하면 사용자 7명과 각 사용자를 기준으로 30개의 할일이 생성된다. 즉, 총 7 * 30 = 210 개의 TODO가 생성된다. 

 

생성된 TODO 1
생성된 TODO 2
생성된 TODO 3

 

* 생성한 더미 데이터 파일로 만들기 

 

* Aggregation 을 이용하여 도큐먼트 그룹핑하기

https://masteringjs.io/tutorials/mongoose/aggregate

 

An Introduction to Mongoose Aggregate

Aggregations in Mongoose let you perform complex transformations on your data in MongoDB. Here's what you need to know.

masteringjs.io

 

server > src > routes > todos.js 파일에 해당 코드를 추가한다. 

const mongoose = require('mongoose')
const { Types: { ObjectId } } = mongoose

해당 파일의 맨 상단에 추가한다.

router.get('/group/:field', isAuth, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  if(!req.user.isAdmin){
    res.status(401).json({ code: 401, message: 'You are not authorized to use this service !'})
  }else{
    const docs = await Todo.aggregate([
      {
        $group: {
          _id: `$${req.params.field}`,
          count: { $sum: 1 }
        }
      }
    ])
    
    console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
    docs.sort((d1, d2) => d1._id - d2._id)
    res.json({ code: 200, docs})
  }
}))

router.get('/group/mine/:field', isAuth, expressAsyncHandler(async (req, res, next) => { // 대쉬보드
  const docs = await Todo.aggregate([
    {
      $match: { author: new ObjectId(req.user._id) }
    },
    {
      $group: {
        _id: `$${req.params.field}`,
        count: { $sum: 1 }
      }
    }
  ])
  
  console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
  docs.sort((d1, d2) => d1._id - d2._id)
  res.json({ code: 200, docs})
}))

/api/todos/group/{field} URL 주소로 접근하면 관리자를 위한 어드민 페이지에 필요한 데이터를 그룹핑한다. /api/todos/group/{field} URL 주소로 접근하면 field 라는 URL 파라미터에 따라 TODO 도큐먼트를 그룹핑해주고, 각 그룹에 몇개의 도큐먼트가 있는지 계산해준다. 그리고 ID 기준으로 정렬해서 보여준다. 단, 관리자 권한을 가진 사용자만 그룹핑 결과를 확인할 수 있고, 일반 사용자는 해당 서비스를 허용하지 않는다. 

/api/todos/group/mine/{field}  URL 주소로 접근하면 일반사용자를 위한 대쉬보드에 필요한 데이터를 그룹핑한다. /api/todos/group/mine/{field}  URL 주소로 접근하면 각 사용자 기준으로 field 라는 URL 파라미터에 따라 TODO 도큐먼트를 그룹핑해주고, 각 그룹에 몇개의 도큐먼트가 있는지 계산해준다. 그리고 ID 기준으로 정렬해서 보여준다. 

 

https://github.com/Automattic/mongoose/issues/1671

 

What is the difference between mongoose.Schema.Types and mongoose.Types? · Issue #1671 · Automattic/mongoose

I see examples all over the place that use both and from what I can see they seem to be the same, but I just don't want to put something in production that breaks because I should use one instead o...

github.com

참고: mongoose.Schema.Types.ObjectId 와 mongoose.Types.ObjectId 의 차이점은 다음과 같다. mongoose.Schema.Types.ObjectId 는 스키마를 정의할때만 사용한다. mongoose.Types.ObjectId 는 실제 도큐먼트를 조회하거나 변경할때 사용한다. 만약 이를 혼용해서 사용하면 에러가 발생한다. 

 

*  그룹핑 테스트하기

테스트를 위하여 관리자 권한이 있는 사용자를 MongoDB에서 확인한다.

{
  "email": "sun@gmail.com",
  "password": "1234567890"
}

토큰을 새로 발급받기 위하여 관리자 권한을 가진 사용자로 로그인한다.

발급받은 토큰을 메모장에 저장해둔다.

관리자 권한을 가진 사용자의 토큰을 이용하여 아래와 같이 /api/todos/group/category 주소로 GET 요청을 보낸다.

카테고리별 그룹핑 요청
카테고리별 그룹핑

todos 컬렉션이 총 7개의 그룹으로 묶여지고 각 그룹에 몇개의 TODO가 속해있는지 알 수 있다. 유흥은 다른 테스트를 위하여 이전에 따로 카테고리에 추가한 것이고, null 은 category 필드를 추가하기 이전의 데이터가 있어서 결과가 이렇게 나온 것이다. 실제로는 7개의 그룹이 맞다. 

할일종료 여부에 따른 그룹핑

todos 컬렉션에서 할일종료 여부에 따라 그룹으로 묶으면 위와 같다. 물론 실제로 한다면 각 사용자의 대쉬보드가 있고, 대쉬보드에 각 사용자가 전체할일에서 몇개의 할일을 끝냈는지 파악하려면 사용자별로 isDone 을 그룹핑하는게 좋다. 

 

특정 사용자

특정 사용자를 기준으로 그룹핑하기 위하여 해당 사용자의 정보를 가지고 로그인을 진행하고, 토큰을 발급받는다.

{
  "email": "lwvbnex@gmail.com",
  "password": "uhqjkycersxcb"
}

특정 사용자 로그인을 통한 토큰 발급받기

해당 사용자의 토큰을 이용하여 category, isDone 필드에 대한 그룹핑을 수행한다.

특정 사용자 기준 카테고리별 그룹핑
특정 사용자 기준 카테고리별 그룹핑 결과

사용자별로 30개씩 TODO 도큐먼트를 생성하였으므로 그룹을 전부 합치면 30개가 된다.

특정 사용자 할일종료 여부에 따른 그룹핑
특정 사용자 할일종료 여부에 따른 그룹핑 결과

 

* 날짜에 따라 그룹핑하기

server > src > routes > todos.js 파일에 아래 코드를 추가한다.

router.get('/group/date/:field', isAuth, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  if(!req.user.isAdmin){
    res.status(401).json({ code: 401, message: 'You are not authorized to use this service !'})
  }else{
    if(req.params.field === 'createdAt' || req.params.field === 'lastModifiedAt' || req.params.field === 'finishedAt'){
      const docs = await Todo.aggregate([
        {
          $group: {
            _id: { year: { $year: `$${req.params.field}` }, month: { $month: `$${req.params.field}` } },
            count: { $sum: 1 }
          }
        }
      ])
      
      console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
      docs.sort((d1, d2) => d1._id - d2._id)
      res.json({ code: 200, docs})
    }else{
      res.status(204).json({ code: 204, message: 'No Content'})
    }
  }
}))

/api/todos/group/date/{field} 주소로 접근하면 TODO 도큐먼트의 필드가 createdAt, lastModifiedAt, finishedAt 필드인 경우에만 그룹핑한다. $group 의 _id 를 위와 같이 작성하여 동일한 년, 월에 작성한 TODO를 그룹핑한다. 예를 들어 2023년 7월에 작성한 TODO 끼리 그룹핑하고 2023년 8월에 작성한 TODO끼리 그룹핑한다. 

router.get('/group/mine/date/:field', isAuth, expressAsyncHandler(async (req, res, next) => { // 어드민 페이지
  if(req.params.field === 'createdAt' || req.params.field === 'lastModifiedAt' || req.params.field === 'finishedAt'){
    const docs = await Todo.aggregate([
      {
        $match: { author: new ObjectId(req.user._id) }
      },
      {
        $group: {
          _id: { year: { $year: `$${req.params.field}` }, month: { $month: `$${req.params.field}` } },
          count: { $sum: 1 }
        }
      }
    ])
    
    console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
    docs.sort((d1, d2) => d1._id - d2._id)
    res.json({ code: 200, docs})
  }else{
    res.status(204).json({ code: 204, message: 'No Content'})
  }
}))

위의 코드도 동일한 동작을 수행하지만 우선 해당 사용자가 작성한 TODO만 걸러낸 다음 년, 월을 기준으로 그룹핑한다. 

 

* 날짜에 따라 그룹핑하기 테스트하기

전체 할일목록 생성날짜별 그룹핑
전체 할일목록 생성날짜별 그룹핑 결과

전체 할일목록을 생성날짜별로 년, 월을 기준으로 그룹핑하면 위와 같다. 

전체 할일목록 업데이트 날짜별 그룹핑
전체 할일목록 업데이트 날짜별 그룹핑
전체 할일목록 종료날짜별 그룹핑
전체 할일목록 종료날짜별 그룹핑 결과

 

특정 사용자의 TODO 생성날짜별 그룹핑
특정 사용자의 TODO 생성날짜별 그룹핑 결과
특정 사용자의 TODO 업데이트 날짜별 그룹핑
특정 사용자의 TODO 업데이트 날짜별 그룹핑 결과
특정 사용자의 TODO 할일종료 날짜별 그룹핑
특정 사용자의 TODO 할일종료 날짜별 그룹핑 결과

 

* isAdmin 함수 사용하기

server > src > routes > todos.js 파일에 해당 코드부분을 수정한다.

const { isAuth, isAdmin } = require('../../auth')

isAdmin 함수를 사용하기 위하여 임포트한다.

router.get('/group/:field', isAuth, isAdmin, expressAsyncHandler(async (req, res, next) => {
    const docs = await Todo.aggregate([
        {
            $group: {
                _id: `$${req.params.field}`,
                count: { $sum: 1 }
            }
        }
    ])
    console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
    docs.sort((d1, d2) => d1._id - d2._id)
    res.json({ code: 200, docs })
}))

라우트핸들러 함수 안에서 조건문을 사용하는 대신 isAdmin 함수를 미들웨어 함수로 넣어서 체크해준다.

router.get('/group/date/:field', isAuth, isAdmin, expressAsyncHandler(async (req, res, next) => {
    if(req.params.field === 'createdAt' 
    || req.params.field === 'lastModifiedAt'
    || req.params.field === 'finishedAt'){
        const docs = await Todo.aggregate([
            {
                $group: {
                    _id: { year: { $year: `$${req.params.field}`}, month: { $month: `$${req.params.field}`}},
                    count: { $sum: 1 }
                }
            },
            { $sort : { _id : 1 } } // 날짜 오름차순 정렬
        ])
        console.log(`Number Of Group: ${docs.length}`) // 그룹 갯수
        docs.sort((d1, d2) => d1._id - d2._id)
        res.json({ code: 200, docs})
    }else{
        res.status(204).json({ code: 204, message: 'No Content'})
    }
}))

라우트핸들러 함수 안에서 조건문을 사용하는 대신 isAdmin 함수를 미들웨어 함수로 넣어서 체크해준다.

 

* 사용자정보 수정 및 삭제 API 리팩토링하기 

// isAuth : 사용자를 수정할 권한이 있는지 검사하는 미들웨어
router.put('/', isAuth, expressAsyncHandler(async (req, res, next) => {
    const user = await User.findById(req.user._id)
    if(!user){
        res.status(404).json( { code: 404, message: 'User Not Found '})
    }else{
        console.log(req.body)
        user.name = req.body.name || user.name
        user.email = req.body.email || user.email 
        user.password = req.body.password || user.password
        user.isAdmin = req.body.isAdmin || user.isAdmin 
        user.lastModifiedAt = new Date() // 수정시각 업데이트

        const updatedUser = await user.save() // DB 에 사용자정보 업데이트
        const { name, email, userId, isAdmin, createdAt } = updatedUser
        res.json({
            code: 200,
            token: generateToken(updatedUser),
            name, email, userId, isAdmin, createdAt
        })
    }
}))

router.delete('/', isAuth, expressAsyncHandler(async (req, res, next) => {
    const user = await User.findByIdAndDelete(req.user._id)
    if(!user){
        res.status(404).json({ code: 404, message: 'User Not Found'})
    }else{
        res.status(204).json({ code: 204, message: 'User deleted Successfully!'})
    }
}))

지금 현재는 회원가입이나 로그인이나 사용자정보 수정시 사용자 고유 ID 값을 브라우저로 보내지 않는다. 보안상 문제 때문이다. 만약 사용자의 고유 ID 값을 브라우저에서 알 수 있다면 해커가 해당 사용자의 고유 ID 값을 이용하여 데이터베이스에서 사용자정보를 해킹할 수도 있다. 또한, 토큰에 _id 값을 함께 암호화하여 브라우저로 전송한다고 하더라도 비밀키 없이는 _id 값을 알기 힘들다. 

사용자의 고유 ID 값을 브라우저가 알지 못하기 때문에 기존의 사용자정보 변경과 사용자정보 삭제 API 는 /api/users 를 put 요청으로 보내거나 /api/users 를 delete 요청으로 보내는게 맞는것 같다. 또한, 사용자 고유 id 값은 isAuth 함수에서 비밀키로 복호화한 req.user 로부터 추출하는게 더 이치에 맞는것 같다. 

 

* TODO 의 카테고리, 할일종료 여부별 그룹핑 코드 리팩토링하기

router.get('/group/:field', isAuth, isAdmin, expressAsyncHandler(async (req, res, next) => {
   
    if(req.params.field === 'category' || req.params.field === 'isDone'){
        const docs = await Todo.aggregate([
            {
                $group: {
                    _id: `$${req.params.field}`,
                    count: { $sum: 1 }
                }
            }
        ])
        console.log(`Number Of Group: , ${docs.length}`) // 그룹 갯수 
        docs.sort((d1, d2) => d1._id - d2._id) // _id 기준으로 정렬
        res.json({ code: 200, docs })
    }else{
        res.status(400).json({ code: 400, message: 'you gave wrong field to group documents !'})
    }
}))
router.get('/group/mine/:field', isAuth, expressAsyncHandler(async (req, res, next) => { // 사용자 대쉬보드
    if(req.params.field === 'category' || req.params.field === 'isDone'){
        const docs = await Todo.aggregate([
            {
                $match: { author: new ObjectId(req.user._id) } // 내가 작성한 할일목록 필터링
            },
            {
                $group: {
                    _id: `$${req.params.field}`,
                    count: { $sum: 1 }
                }
            }
        ])
        console.log(`Number Of Group: , ${docs.length}`) // 그룹 갯수 
        docs.sort((d1, d2) => d1._id - d2._id) // _id 기준으로 정렬
        res.json({ code: 200, docs })
    }else{
        res.status(400).json({ code: 400, message: 'you gave wrong field to group documents !'})
    }
}))

사용자나 개발자가 서버에 그룹핑을 위한 요청을 보낼때 category, isDone 필드 이외의 값을 입력할 수 있으므로 조건문을 이용하여 필드명이 category, isDone 이 아닌 경우에는 잘못된 필드값을 입력하였다고 사용자에게 알려준다. 물론 다른 파일에 isFieldValid 와 같은 미들웨어 함수를 만들고, 라우트에 추가해도 된다. 

 

 

*jwt 토큰의 단점

https://brunch.co.kr/@jinyoungchoi95/1

 

JWT(Json Web Token) 알아가기

jwt가 생겨난 이유부터 jwt의 실제 구조까지 | 사실 꾸준히 작성하고 싶었던 글이지만 JWT를 제대로 개념을 정리하고 구현을 진행해본 적이 없었는데 리얼월드 프로젝트를 진행하면서 JWT에 대한

brunch.co.kr

비밀키 없이도 토큰만 있으면 사용자의 고유 ID 값을 알 수 있다. 그래서 민감한 정보는 토큰에 포함하지 않는것이 좋다. 

 

 

 

 

 

 

 

 

 

 

--------------------------------------- 이전 수업내용 ---------------------------------------

localhost:5000/api/todos/ URL 주소로 접속하면 브라우저 응답으로 "all todo list"라는 문자열을 전송해준다. 즉, 브라우저 화면에 해당 문자열이 표시된다. localhost:5000/api/todos/ 는 Rest API에서 엔드 포인트라고 한다.

TodoRouter.get('/', (req, res) => {
    res.send('all todo list')
})

express.Router 클래스는 위와 같이 곧바로 get 메서드를 사용하여 라우팅 가능하다. 

전체 할일목록을 조회하는 API 테스트

TodoRouter.route('/:id').get( (req, res) => {
    res.send(`todo ${req.params.id}`)
})

todo 폴더의 index.js 파일에 위 코드를 추가한다. req 는 요청객체(request object)이며 params 프로퍼티(객체)는 URL 주소의 파라미터를 조회한다. 예를 들어, /api/todos/37 이라고 사용자가 URL 주소에 접속하면 req.params.id 는 37이 된다. 

const express = require('express')
const TodoRouter = express.Router()

TodoRouter.route('/').get( (req, res) => {
    res.send('all todo list')
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

module.exports = TodoRouter

특정 할일을 조회하는 API 테스트

 

 

TodoRouter.route('/').post( (req, res) => {
    res.send(`todo ${req.body.name} created`)
})

todo 폴더의 index.js 파일에 위 코드를 추가한다. 

const express = require('express')
const TodoRouter = express.Router()

TodoRouter.route('/').get( (req, res) => { // 전체 할일 목록 조회
    res.send('all todo list')
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성
  res.send(`todo ${req.body.name} created`)
})

module.exports = TodoRouter

API 테스트 도구

GET 방식의 요청은 브라우저 주소창으로 직접 테스트 가능하지만 POST, PUT, DELETE 요청은 불가능하므로 크롬 웹스토어에서 API 테스트 도구에 대한 확장 프로그램을 설치한다. (웹스토어에서 api test 로 검색해서 별점이 많은 것을 선택)

특정 할일을 생성하는 API 테스트

METHOD 드롭다운 메뉴에서 POST 방식을 선택하고 주소창에 http://localhost:5000/api/todos/ 를 입력한다. HEADERS 에 Content-Type 이 application/json 인지 확인하고 우측 BODY 창에 생성하고자 하는 할일을 JSON 형태로 입력한다. 그런 다음 SEND 버튼을 클릭하면 아래 탭에서 HISTORY 탭을 보면 요청에 대한 응답이 제대로 도달했는지 HTTP 상태코드로 보여준다. 200 OK 라고 표시되면 성공한 것이다.

HTTP 탭을 보면 브라우저와 서버가 서로 주고 받는 메세지를 확인할 수 있다. HTTP/1.1 200 OK 메세지를 기준으로 상위에 있는 내용이 브라우저가 보내는 요청 메세지다. Content-Length 는 37 이고 Content-Type 은 application/json 이라는 것을 알 수 있다. Host 는 localhost:5000 이고 브라우저가 요청본문(request body) 부분에 실어 보내는 페이로드(payload)는 { "name": "syleemomo", "age": 3 } 이다. 서버는 응답으로 content-type 이 text/html 인 문서를 보낸다. 그리고 우리가 코드에 지정한 "todo syleemomo created" 을 응답 메세지로 전달해주었다. 

 

TodoRouter.route('/:id').put( (req, res) => {
    res.send(`todo ${req.params.id} updated`)
})

todo 폴더의 index.js 파일에 위 코드를 추가한다. 

const express = require('express')
const TodoRouter = express.Router()

TodoRouter.route('/').get( (req, res) => { // 전체 할일 목록 조회
    res.send('all todo list')
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성
  res.send(`todo ${req.body.name} created`)
})

TodoRouter.route('/:id').put( (req, res) => { // 특정 할일 업데이트
  res.send(`todo ${req.params.id} updated`)
})

module.exports = TodoRouter

특정 할일을 변경하는 API 테스트

METHOD 드롭다운 메뉴에서 PUT 방식을 선택하고 주소창에 http://localhost:5000/api/todos/23 를 입력한다. 23은 그저 변경하고자 하는 할일 정보의 id 이다. 다른 값을 넣어도 된다.

HEADERS 에 Content-Type 이 application/json 인지 확인하고 우측 BODY 창에 변경하고자 하는 할일 정보를 JSON 형태로 입력한다. 그런 다음 SEND 버튼을 클릭하면 아래 탭에서 HISTORY 탭을 보면 요청에 대한 응답이 제대로 도달했는지 HTTP 상태코드로 보여준다. 200 OK 라고 표시되면 성공한 것이다.

 

TodoRouter.route('/:id').delete( (req, res) => {
    res.send(`todo ${req.params.id} removed`)
})

todo 폴더의 index.js 파일에 위 코드를 추가한다. 

const express = require('express')
const TodoRouter = express.Router()

TodoRouter.route('/').get( (req, res) => { // 전체 할일 목록 조회
    res.send('all todo list')
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성
  res.send(`todo ${req.body.name} created`)
})

TodoRouter.route('/:id').put( (req, res) => { // 특정 할일 업데이트
  res.send(`todo ${req.params.id} updated`)
})

TodoRouter.route('/:id').delete( (req, res) => { // 특정 할일 삭제
  res.send(`todo ${req.params.id} removed`)
})

module.exports = TodoRouter

특정 할일을 제거하는 API 테스트

METHOD 드롭다운 메뉴에서 DELETE 방식을 선택하고 주소창에 http://localhost:5000/api/todos/23 를 입력한다. 23은 그저 삭제하고자 하는 할일 정보의 id 이다. 다른 값을 넣어도 된다.

 

* 실제적인 API 구현 - 특정 할일 생성

const Todo = require("../../models/Todo");

todo 폴더의 index.js 파일에 위 코드를 추가한다. 데이터베이스에 모델을 생성, 조회, 변경, 삭제할 것이므로 해당 모델을 임포트한다.  또한, 특정 할일을 생성하는 라우팅 처리로직을 아래와 같이 수정한다. 전체 코드는 아래와 같다. 

const express = require('express')
const TodoRouter = express.Router()
const Todo = require("../../models/Todo");

TodoRouter.route('/').get( (req, res) => { // 전체 할일 목록 조회
    res.send('all todo list')
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성 구현
    console.log(`name: ${req.body.name}`)

    Todo.findOne({ name: req.body.name, done: false }, async (err, todo) => { // 중복체크
        if(err) throw err;
        if(!todo){ // 데이터베이스에서 해당 할일을 조회하지 못한 경우
            const newTodo = new Todo(req.body);
            const createdTodo = await newTodo.save() // DB에 새로운 할일 저장
            res.json({ status: 201, msg: 'new todo created in db !', createdTodo})
        }else{ // 생성하려는 할일과 같은 이름이고 아직 끝내지 않은 할일이 이미 데이터베이스에 존재하는 경우
            const msg = 'this todo already exists in db !'
            console.log(msg) 
            res.json({ status: 204, msg})
        }
    })
})

TodoRouter.route('/:id').put( (req, res) => { // 특정 할일 업데이트
  res.send(`todo ${req.params.id} updated`)
})

TodoRouter.route('/:id').delete( (req, res) => { // 특정 할일 삭제
  res.send(`todo ${req.params.id} removed`)
})

module.exports = TodoRouter

todo 폴더의 index.js 파일에서 특정 할일을 생성하는 라우팅 처리로직을 위와 같이 수정한다. POST 방식으로 보낸 데이터는 요청본문(reqeust body)에 실려 서버로 전송된다. 우리는 req 객체의 body 프로퍼티로 요청본문을 파싱(parsing)하여 조회할 수 있다. 요청본문은 {"name": "learning express framework", "done": "false", "description": "learning express framework on this weekends"} 와 같은 JSON 문자열이다. 대부분의 오픈 API는 이러한 형태를 가진다. 

만약, 생성하려는 할일이 데이터베이스에 존재하지 않으면 요청본문을 이용하여 새로운 할일을 생성하고 201번 상태코드를 전송한다. 201번은 새로운 컨텐츠가 성공적으로 생성되었다는 의미다. 

특정 할일 생성 API 테스트

 

만약, 데이터베이스에 생성하려는 할일과 같은 이름(name 필드 참조)을 가지고 있고, 아직 끝내지 않은(done 필드 참조) 할일이 이미 존재하면 데이터 중복이므로 해당 메세지와 함께 204번 HTTP 상태코드를 전송한다. 204번은 콘텐츠 없음(No Content) 라는 의미이며 전달할 값이 없다는 뜻이다. 

API 테스트 도구에서 모든 조건은 그대로 두고 Send 버튼만 다시 클릭해보자! 이미 데이터베이스에 해당 할일이 존재하므로 중복 체크에 걸린다. 그럼 요청본문(BODY)에 다른 할일 정보를 입력하고 Send 버튼을 클릭해서 할일을 하나 더 생성해보자!

특정 할일 생성 API 중복 체크

 

이제 실제로 몽고 DB 에서 할일 정보가 추가되었는지 확인해보자! 

몽고 컴파스에서 새로운 할일 생성 확인

몽고 GUI 화면에 생성된 할일 목록을 확인할 수 있다. 생성을 하였으나 GUI 화면에 업데이트가 되지 않았다면 왼쪽 상단에 새로고침 버튼을 클릭한다. 

 

* 실제적인 API 구현 - 전체 할일목록 조회 

TodoRouter.route('/').get( async (req, res) => {
    const todos = await Todo.find()
    res.json({ status: 200, todos})
})

todo 폴더의 index.js 파일에 전체 할일 목록을 조회하는 라우팅 처리로직을 수정한다. 전체 코드는 아래와 같다.

const express = require('express')
const TodoRouter = express.Router()
const Todo = require("../../models/Todo");

TodoRouter.route('/').get( async (req, res) => { // 전체 할일 목록 조회 구현
  const todos = await Todo.find()
  res.json({ status: 200, todos})
})

TodoRouter.route('/:id').get( (req, res) => { // 특정 할일 조회
  res.send(`todo ${req.params.id}`)
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성 구현
    console.log(`name: ${req.body.name}`)

    Todo.findOne({ name: req.body.name, done: false }, async (err, todo) => { // 중복체크
        if(err) throw err;
        if(!todo){ // 데이터베이스에서 해당 할일을 조회하지 못한 경우
            const newTodo = new Todo(req.body);
            const createdTodo = await newTodo.save() // DB에 새로운 할일 저장
            res.json({ status: 201, msg: 'new todo created in db !', createdTodo})
        }else{ // 생성하려는 할일과 같은 이름이고 아직 끝내지 않은 할일이 이미 데이터베이스에 존재하는 경우
            const msg = 'this todo already exists in db !'
            console.log(msg) 
            res.json({ status: 204, msg})
        }
    })
})

TodoRouter.route('/:id').put( (req, res) => { // 특정 할일 업데이트
  res.send(`todo ${req.params.id} updated`)
})

TodoRouter.route('/:id').delete( (req, res) => { // 특정 할일 삭제
  res.send(`todo ${req.params.id} removed`)
})

module.exports = TodoRouter

GET 방식으로 조회하며 브라우저 화면 또는 API 테스트 도구에서 /api/todos/ 엔드포인트에 접속하면 데이터베이스에서 전체 할일목록(todos 컬렉션)을 쿼리한 다음 응답을 클라이언트로 전달한다.

async, await 키워드는 자바스크립트 최신문법에서 비동기를 처리하는 방법이다. 데이터베이스에서 쿼리를 요청하고 기다렸다가 쿼리가 완료되면 응답을 보낸다. 만약, async, await 키워드가 없으면 데이터베이스 쿼리가 완료되기 전에 응답을 보내므로 예상한 결과를 얻지 못한다. 

전체 할일 목록 조회 성공

 

하지만 JSON 응답이 눈으로 확인하기 힘들다. 그래서 크롬 웹스토어에서 JSON 뷰어 확장 프로그램을 설치한다. 

전체 할일목록 JSON 뷰어로 확인

이제 눈으로 확인하기 편하다.

 

* 실제적인 API 구현 - 특정 할일 조회 

TodoRouter.route('/:id').get( (req, res) => {
    Todo.findById(req.params.id, (err, todo) => {
        if(err) throw err;
        res.json({ status: 200, todo})
    })
})

todo 폴더의 index.js 파일에서 특정 할일을 ID 값으로 조회하는 라우팅 처리로직을 위와 같이 수정한다. 전체코드는 아래와 같다.

const express = require('express')
const TodoRouter = express.Router()
const Todo = require("../../models/Todo");

TodoRouter.route('/').get( async (req, res) => { // 전체 할일 목록 조회 구현
  const todos = await Todo.find()
  res.json({ status: 200, todos})
})


TodoRouter.route('/:id').get( (req, res) => {  // 특정 할일 조회 구현
  Todo.findById(req.params.id, (err, todo) => {
      if(err) throw err;
      res.json({ status: 200, todo})
  })
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성 구현
    console.log(`name: ${req.body.name}`)

    Todo.findOne({ name: req.body.name, done: false }, async (err, todo) => { // 중복체크
        if(err) throw err;
        if(!todo){ // 데이터베이스에서 해당 할일을 조회하지 못한 경우
            const newTodo = new Todo(req.body);
            const createdTodo = await newTodo.save() // DB에 새로운 할일 저장
            res.json({ status: 201, msg: 'new todo created in db !', createdTodo})
        }else{ // 생성하려는 할일과 같은 이름이고 아직 끝내지 않은 할일이 이미 데이터베이스에 존재하는 경우
            const msg = 'this todo already exists in db !'
            console.log(msg) 
            res.json({ status: 204, msg})
        }
    })
})

TodoRouter.route('/:id').put( (req, res) => { // 특정 할일 업데이트
  res.send(`todo ${req.params.id} updated`)
})

TodoRouter.route('/:id').delete( (req, res) => { // 특정 할일 삭제
  res.send(`todo ${req.params.id} removed`)
})

module.exports = TodoRouter

GET 방식으로 조회하며 브라우저 화면 또는 API 테스트 도구에서 /api/todos/616a447cb7b47521580c36d9 와 같이 ID 값을 포함한 엔드포인트에 접속하면 데이터베이스에서 특정 할일(todo 도큐먼트)을 ID 값으로 쿼리한 다음 응답을 클라이언트로 전달한다. 쿼리에 성공했으므로 HTTP 상태코드 200번도 함께 전송한다. 

특정 할일을 ID 값으로 쿼리한 결과 화면

 

* 실제적인 API 구현 - 특정 할일 변경

TodoRouter.route('/:id').put( (req, res) => {
   Todo.findByIdAndUpdate(req.params.id, req.body, {new: true}, (err, todo) => {
       if(err) throw err;
       res.json({ status: 204, msg: `todo ${req.params.id} updated in db !`, todo})
   })
})

todo 폴더의 index.js 파일에서 특정 할일에 대한 정보를 ID 값으로 업데이트하는 라우팅 처리로직을 위와 같이 수정한다. 전체코드는 아래와 같다.

findByIdAndUpdate 메서드의 첫번째 파라미터는 변경하고자 하는 할일의 ID 값이고, 두번째 파라미터는 변경하려는 데이터, 세번째 파라미터는 옵션이다. new: true 로 설정하면 업데이트가 완료된 데이터를 반환한다. new: false 로 설정하면 업데이트 되기 전의 데이터를 반환한다. 

const express = require('express')
const TodoRouter = express.Router()
const Todo = require("../../models/Todo");

TodoRouter.route('/').get( async (req, res) => { // 전체 할일 목록 조회 구현
  const todos = await Todo.find()
  res.json({ status: 200, todos})
})


TodoRouter.route('/:id').get( (req, res) => {  // 특정 할일 조회 구현
  Todo.findById(req.params.id, (err, todo) => {
      if(err) throw err;
      res.json({ status: 200, todo})
  })
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성 구현
    console.log(`name: ${req.body.name}`)

    Todo.findOne({ name: req.body.name, done: false }, async (err, todo) => { // 중복체크
        if(err) throw err;
        if(!todo){ // 데이터베이스에서 해당 할일을 조회하지 못한 경우
            const newTodo = new Todo(req.body);
            const createdTodo = await newTodo.save() // DB에 새로운 할일 저장
            res.json({ status: 201, msg: 'new todo created in db !', createdTodo})
        }else{ // 생성하려는 할일과 같은 이름이고 아직 끝내지 않은 할일이 이미 데이터베이스에 존재하는 경우
            const msg = 'this todo already exists in db !'
            console.log(msg) 
            res.json({ status: 204, msg})
        }
    })
})


TodoRouter.route('/:id').put( (req, res) => {  // 특정 할일 업데이트 구현 
  Todo.findByIdAndUpdate(req.params.id, req.body, {new: true}, (err, todo) => {
      if(err) throw err;
      res.json({ status: 204, msg: `todo ${req.params.id} updated in db !`, todo})
  })
})

TodoRouter.route('/:id').delete( (req, res) => { // 특정 할일 삭제
  res.send(`todo ${req.params.id} removed`)
})

module.exports = TodoRouter

PUT 방식으로 변경하며 API 테스트 도구에서 /api/todos/616a447cb7b47521580c36d9 와 같이 ID 값을 포함한 엔드포인트에 접속하면 데이터베이스에서 특정 할일(todo 도큐먼트)을 ID 값으로 쿼리한 다음 요청본문(request body) 정보를 활용하여 변경(업데이트)하고 처리결과를 클라이언트로 전달한다. 코드에서 todo 는 업데이트가 끝난 할일이다. 

단, ID 값은 블로그에 있는 ID 값이 아니라 전체 할일 목록을 조회해서 여러분이 생성한 할일의 ID 값을 확인한 다음 변경하고자 하는 할일의 ID 값을 테스트 도구의 URL 주소에 입력해야 한다. 

특정 할일을 ID 값으로 업데이트한 처리결과
브라우저 화면에서 특정 할일 변경후 확인

 

* 실제적인 API 구현 - 특정 할일 삭제

TodoRouter.route('/:id').delete( (req, res) => {
    Todo.findByIdAndRemove(req.params.id, (err, todo) => {
        if(err) throw err;
        res.json({ status: 204, msg: `todo ${req.params.id} removed in db !`})
    })
})

todo 폴더의 index.js 파일에서 특정 할일을 ID 값으로 삭제하는 라우팅 처리로직을 위와 같이 수정한다. 전체코드는 아래와 같다.

const express = require('express')
const TodoRouter = express.Router()
const Todo = require("../../models/Todo");

TodoRouter.route('/').get( async (req, res) => { // 전체 할일 목록 조회 구현
  const todos = await Todo.find()
  res.json({ status: 200, todos})
})


TodoRouter.route('/:id').get( (req, res) => {  // 특정 할일 조회 구현
  Todo.findById(req.params.id, (err, todo) => {
      if(err) throw err;
      res.json({ status: 200, todo})
  })
})

TodoRouter.route('/').post( (req, res) => { // 특정 할일 생성 구현
    console.log(`name: ${req.body.name}`)

    Todo.findOne({ name: req.body.name, done: false }, async (err, todo) => { // 중복체크
        if(err) throw err;
        if(!todo){ // 데이터베이스에서 해당 할일을 조회하지 못한 경우
            const newTodo = new Todo(req.body);
            const createdTodo = await newTodo.save() // DB에 새로운 할일 저장
            res.json({ status: 201, msg: 'new todo created in db !', createdTodo})
        }else{ // 생성하려는 할일과 같은 이름이고 아직 끝내지 않은 할일이 이미 데이터베이스에 존재하는 경우
            const msg = 'this todo already exists in db !'
            console.log(msg) 
            res.json({ status: 204, msg})
        }
    })
})


TodoRouter.route('/:id').put( (req, res) => {  // 특정 할일 업데이트 구현
  Todo.findByIdAndUpdate(req.params.id, req.body, {new: true}, (err, todo) => {
      if(err) throw err;
      res.json({ status: 204, msg: `todo ${req.params.id} updated in db !`, todo})
  })
})


TodoRouter.route('/:id').delete( (req, res) => {  // 특정 할일 삭제 구현
  Todo.findByIdAndRemove(req.params.id, (err, todo) => {
      if(err) throw err;
      res.json({ status: 204, msg: `todo ${req.params.id} removed in db !`})
  })
})

module.exports = TodoRouter

DELETE 방식으로 삭제하며 API 테스트 도구에서 /api/todos/616a411a04961263bdfbba9c 와 같이 ID 값을 포함한 엔드포인트에 접속하면 데이터베이스에서 특정 할일(todo 도큐먼트)을 ID 값으로 쿼리한 다음 삭제한다. 코드에서 todo 는 삭제가 끝난 할일이다.

단, ID 값은 블로그에 있는 ID 값이 아니라 전체 할일 목록을 조회해서 여러분이 생성한 할일의 ID 값을 확인한 다음 삭제하고자 하는 할일의 ID 값을 테스트 도구의 URL 주소에 입력해야 한다. 

특정 할일을 ID 값으로 삭제한 처리결과
브라우저 화면에서 특정 할일 삭제후 확인

이로써 API 구현과 테스트가 완료되었다. 야호!

 

 

728x90