TIL | MongoDB, 회원가입 로그인 API

unihit·2021년 2월 24일
0

TIL

목록 보기
14/25
post-thumbnail

MongoDB

bodyParser 적용

API 기능을 구현하기 전에 koa-bodyparser라는 미들웨어를 적용한다.

이 미들웨어는 POST/PUT 등의 메소드의 Request Body에 JSON 형식으로 데이터를 넣어주면 이를 파싱해서 서버측에서 사용할 수 있도록 해준다.

install

$ yarn add koa-bodyparser

미들웨어 적용

// src/index.js
// .env 파일에서 환경변수 불러오기
require("dotenv").config();

const koa = require("koa");
const Router = require("koa-router");

const app = new koa();
const router = new Router();
const api = require("./api");

const mongoose = require("mongoose");
const bodyParser = require("koa-bodyparser");

// Node의 네이티브 Promise 사용
mongoose.Promise = global.Promise;

// mongodb 연결
mongoose
  .connect(process.env.MONGO_URI)
  .then((res) => {
    console.log("Successfully connected to mongodb");
  })
  .catch((e) => {
    console.error(e);
  });

// PORT 값이 설정되어 있지 않다면 4000을 사용한다.
const port = process.env.PORT || 4000;

// bodyparser 적용, 라우터 적용코드보다 상단에 있어야 한다.
app.use(bodyParser());

// api 라우트를 /api 경로 하위 라우트로 설정
router.use("/api", api.routes());
app.use(router.routes()).use(router.allowedMethods());

app.listen(port, () => {
  console.log("heurm server is listening to port " + port);
});

bodyParser를 적용해서 다음과 같은 형식으로 body에 있는 JSON 객체를 이용할 수 있게 된다.

app.use(async ctx => {
    // 아무것도 없으면 {} 가 반환됩니다.
    ctx.body = ctx.request.body;
});

NODE_PATH 설정

만든 모델은 books.controller.js 파일에서 사용하기 위해서는 다음과 같이 코드를 불러와야 한다.

const Book = require('../../models/book');

../../를 사용하는 것은 보기 안 좋기도 하고 헷갈리기도 하기 때문에 NODE_PATH를 설정해서 보기 좋게 바꿔줄 수 있다.

package.json 파일의 스크립트 부분에서, NODE_PATH를 src로 설정하면 프로젝트의 루트 디렉토리를 src로 지정해서 require('models/book')과 같은 형식으로 파일을 불러올 수 있다.

"scripts": {
    "start": "NODE_PATH=src node src",
    "dev": "NODE_PATH=src nodemon --watch src/ src/index.js"
  }

만약 윈도우를 사용한다면, cross-env를 설치하고 스크립트 앞부분에 cross-env를 넣자

$ yarn add cross-env
"scripts": {
    "start": "cross-env NODE_PATH=src node src",
    "dev": "cross-env NODE_PATH=src nodemon --watch src/ src/index.js"
  }

jsconfig.json 생성

절대 경로로 불러온 파일도 IDE에서 자동완성이 제대로 되도록 .jsconfig.json 파일을 프로젝트 루트 디렉토리에 만들어준다.

{
  "compilerOptions": {
    "baseUrl": "./src"
  }
}

데이터 생성과 조회

  • books API 기능 구현

books.controller.js 파일의 상단에 만든 모델을 불러온다.

const Book = require('models/book');

데이터 생성

create 함수 구현

// src/api/books/books.contriller.js
const Book = require("models/book");

exports.create = async (ctx) => {
  // request body에서 값들을 추가한다.
  const { title, authors, publishedDate, price, tags } = ctx.request.body;

  // Book 인스턴스를 생성
  const book = new Book({
    title,
    authors,
    publishedDate,
    price,
    tags,
  });
  // 만들어진 Book 인스턴스를 아래와 같이 수정할 수도 있다.
  // book.title = title;

  // .save()를 실행하면 이 때 데이터베이스에 실제로 데이터를 작성한다.
  // Promise를 반환한다.
  try {
    await book.save();
  } catch (e) {
    // http 상태 500과 Internal Error라는 메시지를 반환하고 에러를 기록한다.
    return ctx.throw(500, e);
  }

	// 저장한 결과를 반환
  ctx.body = book;
};

참고로 만들때 src/api/books/index.js파일에 기능이 없는 부분은 주석처리하자.

Postman으로 테스팅을 해보자.

POST 메소드로 http://localhost:4000/api/books에 Request Body는 JSON 형식으로 Send를 해본다.

{
    "title": "React.js Tutorials",
    "publishedDate": "2017-07-13",
    "authors": [
        {
            "name": "velopert",
            "email": "public.velopert@gmail.com"
        },
        {
            "name": "nakim",
            "email": "nakim@nakim.com"
        }
    ],
    "tags": ["react.js", "node.js"],
    "price": 100
}

다음과 같이 나타나면 잘 된 것이다. send를 몇 번 눌러보면서 _id값이 바뀌는 것을 확인 할 수 있다.

Robomongo를 열어서 데이터베이스에 접속 후, 다음과 같이 데이터를 조회한다.

데이터 조회

  • 여러개의 데이터 목록을 조회할 때는 .find()를 사용한다.
exports.list = async (ctx) => {
  // 변수를 미리 만들어준다.
  // (let이나 const는 scope가 블록 단위이기 때문에 try 바깥에 선언해준다.)
  let books;

  try {
    // 데이터를 조회한다.
    // .exec()를 뒤에 붙여줘야 실제로 데이터베이스에 요청이 된다.
    // 반환값은 Promise이므로 await를 사용할 수 있다.
    books = await Book.find().exec();
  } catch (e) {
    return ctx.throw(500, e);
  }

  ctx.body = books;
};

Postman으로 요청을 GET으로 바꿔서 보내본다.

DB안의 모든 데이터가 나타나게 된다.

이번에는 데이터를 _id의 역순으로 정렬하고 3개만 보여주도록 제한을 준다.

즉, 최근 생성된 3개만 보여준다는 의미이다.

exports.list = async (ctx) => {
  // 변수를 미리 만들어준다.
  // (let이나 const는 scope가 블록 단위이기 때문에 try 바깥에 선언해준다.)
  let books;

  try {
    // 데이터를 조회한다.
    // .exec()를 뒤에 붙여줘야 실제로 데이터베이스에 요청이 된다.
    // 반환값은 Promise이므로 await를 사용할 수 있다.
    books = await Book.find()
      .sort({ _id: -1 }) // _id의 역순으로 정렬
      .limit(3) // 3개만 보여지도록 정렬
      .exec(); // 데이터를 서버에 요청
  } catch (e) {
    return ctx.throw(500, e);
  }

  ctx.body = books;
};

이런 식으로 데이터를 요청하는 것을 쿼리라고 한다.

만약 어떤 것이 있는지 알고 싶다면 공식문서의 queries 페이지를 참조한다.

단일 데이터 조회

  • 특정 아이디를 가진 데이터를 조회

특정 아이디를 가진 데이터를 조회할 때는 .findByOne을 사용한다.

get이라는 함수를 만들어서 다음과 같이 코드를 작성한다.

exports.get = async (ctx) => {
  // URL 파라미터에서 id값을 읽어온다.
  const { id } = ctx.params;

  let book;

  try {
    book = await Book.findById(id).exec();
  } catch (e) {
    return ctx.throw(500, e);
  }

  if (!book) {
    // 데이터가 존재하지 않으면 상태 404를 반환
    ctx.status = 404;
    ctx.body = { message: "book not found" };
    return;
  }

  ctx.body = book;
};

그 다음에는 /:id 라우트에 방금 만든 get 함수를 전달한다.

// src/api/books/index.js
const Router = require("koa-router");

const books = new Router();
const booksCtrl = require("./books.controller");

books.get("/", booksCtrl.list);
books.get("/:id", booksCtrl.get);
books.post("/", booksCtrl.create);
books.delete("/", booksCtrl.delete);
books.put("/", booksCtrl.replace);
books.patch("/", booksCtrl.update);

module.exports = books;

이렇게 API를 만들고 나서 아까 목록에 있었던 id를 주소 뒤에 붙여넣는다.

GET http://localhost:4000/api/books/603474b64a8f5b46d19cb4c7 이런식으로 요청을 하면 주어진 id로 데이터 하나를 조회하게 된다.

그리고 마지막 문자를 다른 값으로 변형시키면, 404가 나오게 된다. 하지만 아예 문자 하나를 지워버리거나 하나를 더 넣어버리면 Internal Error가 나타난다.

id를 파라미터로 받을 때는 에러를 처리해주거나, 사전에 id 검증작업을 거쳐야 한다.

일단은 에러를 처리하는 방식으로 코드를 수정한다.

// src/api/books/books.controller.js
exports.get = async (ctx) => {
  // URL 파라미터에서 id값을 읽어온다.
  const { id } = ctx.params;

  let book;

  try {
    book = await Book.findById(id).exec();
  } catch (e) {
    if (e.name === "CastError") {
      ctx.status = 400;
      return;
    }
    return ctx.throw(500, e);
  }

  if (!book) {
    // 데이터가 존재하지 않으면 상태 404를 반환
    ctx.status = 404;
    ctx.body = { message: "book not found" };
    return;
  }

  ctx.body = book;
};

데이터 삭제와 수정, 그리고 요청 검증

데이터 삭제

데이터를 삭제하는데는 여러가지 방법이 있다.

  • .remove: 특정 조건을 만족하는 데이터들을 모두 지운다.
  • .findByIdAndRemove: id를 찾아서 지운다.
  • .findOneAndRemove: 특정 조건을 만족하는 데이터 하나를 찾아서 지운다.

일단 .findByIdAndRemove를 사용한다.

// src/api/books/books.controller.js
exports.delete = async (ctx) => {
  const { id } = ctx.params;

  try {
    await Book.findByIdAndRemove(id).exec();
  } catch (e) {
    if (e.name === "CastError") {
      ctx.status = 400;
      return;
    }
  }

  // No Content
  ctx.status = 204;
};

그 다음에는 books 라우트 인덱스에서 delete API에 id 파라미터를 받도록 설정한다.

그리고 앞으로 구현할 put과 patch도 id를 필요로 하니, 해당 API 들에도 :id를 넣는다.

const Router = require("koa-router");

const books = new Router();
const booksCtrl = require("./books.controller");

books.get("/", booksCtrl.list);
books.get("/:id", booksCtrl.get);
books.post("/", booksCtrl.create);
books.delete("/:id", booksCtrl.delete);
books.put("/:id", booksCtrl.replace);
books.patch("/:id", booksCtrl.update);

module.exports = books;

PUT과 PATCH

PUT과 PATCH는 서로 비슷하지만 역할이 다르다.

둘 다 데이터를 변경하지만 PUT의 경우에는 데이터를 통째로 바꿔버리는 메소드이며, PATCH의 경우에는 주어진 필드만 수정하는 메소드이다.

따라서 구현 방식은 비슷할 수도 있지만 PUT의 경우에는 모든 필드를 받도록 데이터를 검증해야한다.

추가적으로 PUT의 경우에 데이터가 존재하지 않는다면 데이터를 새로 만들어줘야 하는게 원칙이다.

Request Body를 검증하려면, if문, 혹은 배열과 반복문을 통해서 각 필드를 일일히 체크하는 방식도 있지만, 이를 더 편하게 해주는 라이브러리가 존재한다.

심지어는 값의 형식도 검사할 수 있게 해준다.

Joi

Joi라는 라이브러리가 그 역할을 해주니 이 라이브러리를 설치하자.

$ yarn add joi

설치가 완료되었으면 comments.controller.js의 상단에서 Joi를 불러온다.

이번에는 ObjectId도 사전 검증하기 위해서 ObjectId도 mongoose에서 불러온다.

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

위 코드의 두번째 줄은 다음 코드와 동일하다.

const ObjectId = require('mongoose').Types.ObjectId

먼저 PUT 메소드에서 사용할 replace를 구현한다.

exports.replace = async (ctx) => {
  const { id } = ctx.params;
  let book;

  if (!ObjectId.isValid(id)) {
    // Bad Request
    ctx.status = 400;
    return;
  }

  // 먼저 검증할 스키마를 준비한다.
  const schema = Joi.object().keys({
    // 객체의 field를 검증
    // 뒤에 required()를 붙여주면 필수 항목이라는 의미이다.
    title: Joi.string().required(),
    authors: Joi.array().items(
      Joi.object().keys({
        name: Joi.string().required(),
        email: Joi.string().email().required(), // 이런 방식으로 이메일도 쉽게 검증할 수 있다.
      })
    ),
    publishedDate: Joi.date().required(),
    price: Joi.number().required(),
    tags: Joi.array().items(Joi.string().required()),
  });

  // 다음에는 validate를 통해서 검증한다.
  // 첫번째 파라미터는 검증할 객체이고, 두번째는 스키마
  const result = Joi.valid(ctx.request.body, schema);

  // 스키마가 잘못됐다면
  if (result.error) {
    ctx.status = 400; // bad request
    ctx.body = result.error;
    return;
  }

  try {
    // 아이디로 찾아서 업데이트
    // 파라미터는 (아이디, 변경할 값, 설정) 순서
    book = await Book.findByIdAndUpdate(id, ctx.request.body, {
      upsert: true, // 이 값을 넣어주면 데이터가 존재하지 않으면 새로 만들어준다.
      new: true, // 이 값을 넣어줘야 반환하는 값이 업데이트된 데이터이다. 이 값이 없으면 ctx.body = book 했을때 업데이트 전의 데이터를 보여준다.
    });
  } catch (e) {
    return ctx.throw(500, e);
  }

  ctx.body = book;
};

지금은 에러처리만 해줬다.

데이터를 교체할 때는 .findByIdAndUpate를 사용하며 upset 설정을 함으로써, 데이터가 존재하지 않으면 새로 데이터를 만들도록 설정했다.

기존의 POST 요청때 사용했던 request body를 복사하고, PUT http://localhost:4000/api/books/:id 요청을 넣어보자.

만약 id가 존재하지 않는다면 새 데이터를 생성하고, 스키마가 잘못되었다면 오류가 발생한다.

데이터페이스에 접근하는 REST API를 만들때는 파라미터와 스키마를 검사하는 것이 중요하다.

그렇게 해야만 값이 잘못되어서 발생할 수 있는 버그를 잡을 수 있다.

PATCH 구현

PATCH 메소드는, 주어진 필드만 수정을 해준다. 구현 방식은 PUT과 비슷하지만, 주어진 값만 수정하도록 로직을 작성해야 한다.

exports.update = async (ctx) => {
  const { id } = ctx.params;
  let book;

  if (!ObjectId.isValid(id)) {
    ctx.status = 400; // bad request
    return;
  }

  try {
    // 아이디를 찾아서 업데이트 한다.
    // 파라미터는 (아이디, 변경할 값, 설정) 순서
    book = await Book.findByIdAndUpdate(id, ctx.request.body, {
      // upsert의 기본값은 false
      new: true,
    });
  } catch (e) {
    return ctx.throw(500, e);
  }

  ctx.body = book;
};

이제, PATCH http://localhost:4000/api/books/:id 요청을 request body에 일부 필드를 생략하고 요청을 해보자.

이것이 바로 REST API 제작이다. 이렇게 요청에서 받은 값을 토대로 데이터를 조회하고, 생성하고, 수정하고, 삭제를 하면서 프로젝트의 기능을 구현해나간다.

이제 더 이상 books API와 모델은 필요하지 않으니 삭제하도록 하자.

회원가입 / 로그인 API 만들기

앞으로 만들 회원인증 시스템에서는 이메일 로그인과 페이스북, 구글을 이용한 소셜로그인 기능을 구현한다.

먼저 이메일 회원인증부터 구현하도록 한다.

이메일 회원인증(로컬인증)을 구현하기 위해서는 다음 API를 만들어야 한다.

  • POST /api/register/local: 회원가입 API
  • POST /api/login/local: 로그인 API
  • GET /exists/:key/:value: 이메일 / 아이디 중복확인
  • POST /logout: 로그아웃

계정을 위한 데이터 스키마 디자인과 데이터 생성 / 검증작업 진행

회원가입시에 이메일, 아이디, 비밀번호 필요

이 정보들을 DB에 저장하게 되는데, 비밀번호의 경우에는 전달받은 비밀번호를 그대로 DB에 넣으면 보안상 매우 취약하다.

이에 대한 해법으로 DB에 비밀번호를 담을때 SHA-256과 같은 해싱 알고리즘 함수를 이용해서 데이터를 저장한다.

d// src/models/account.js
const mongoose = require("mongoose");
const { Schema } = mongoose;

const Account = new Schema({
  profile: {
    username: String,
    thumbnail: {
      type: String,
      default: "/static/images/default_thumbnail.png",
    },
  },
  email: { type: String },
  // 소셜 계정으로 회원가입 할 경우에 각 서비스에서 제공되는 id와 accessToken을 저장
  social: {
    facebook: {
      id: String,
      accessToken: String,
    },
    google: {
      id: String,
      accessToken: String,
    },
  },
  password: String, // 로컬계정의 경우 비밀번호를 해싱해서 저장
  thoughtCount: { type: Number, default: 0 }, // 서비스에서 포스트를 작성할 때마다 1씩 증가
  CreatedAt: { type: Date, default: Date.now },
});

module.exports = mongoose.model("Account", Account);

프로필 정보에서 '/static/images/default_thumbnail.png를 기본 프로필 사진으로 사용한다. 이 이미지는 나중에 설정한다.

로컬 회원가입을 했을때 전달받은 비밀번호를 해싱하여 password 값으로 저장하고, 소셜 계정 로그인을 했을때는 로그인을 할때 각 소셜 서비스에서 회원을 구분하기 위해서 제공해주는 id 값과, 해당 계정의 정보를 가져올때 필요한 accessToken 값을 데이터에 저장한다.

thoughtCount는 유저가 서비스에서 포스트를 작성할 때마다 1씩 더해지는 값이며, createdAt은 계정이 생성된 시각을 담는다.

SHA-256 해시 함수 사용

Node.js에는 암호화를 위한 crypto라는 모듈이 내장되어 있다.

SHA-256 해싱을 하는 방법은 다음과 같다:

const crypto = require('crypto');

const password = 'abc123';
const secret = 'MySecretKey1$1$234';

const hashed = crypto.createHmac('sha256', secret).update(password).digest('hex');

console.log(hashed);

일반 SHA-256과 HMAC SHA-256은 해싱 방식이 조금 다르다.

HMAC SHA-256의 경우에는 데이터를 주어진 비밀키(secret)와 함께 해싱을 하고, 해싱된 결과물을 비밀키와 함께 다시한번 해싱을 한다.

위의 코드를 src/index.js의 하단부에 넣고 실행시키면 콘솔에 해시가 찍힌다.

2873413f1c0b757400be1e65f4edb2b1f7b354cf497a6811d4e1ff92b4e0d3f0
heurm server is listening to port 4000
Successfully connected to mongodb

이를 테스트 했다면, 방금 작성한 코드를 지우고 사용할 비밀키를 .env 파일에 저장한다.

PORT=4000
MONGO_URI=mongodb://localhost/heurm
SECRET_KEY=MySecretKey1$1$234

그 다음에 hash라는 함수를 account.js 파일에 만든다.

const mongoose = require("mongoose");
const { Schema } = mongoose;
const crypto = require("crypto");

const hash = (password) => {
  return crypto
    .createHmac("sha256", process.env.SECRET_KEY)
    .update(password)
    .digest("hex");
};

const Account = new Schema({
	(...)
});

module.exports = mongoose.model("Account", Account);

Reference

https://backend-intro.vlpt.us/2/04.html

0개의 댓글