TIL | MongoDB, Promise

unihit·2021년 2월 23일
0

TIL

목록 보기
13/25
post-thumbnail

REST API

여러 메소드 사용하기

REST API에서는 요청의 종류에 따라 다른 HTTP 메소드를 사용한다.

HTTP 메소드는 여러 종류가 있는데 그 중 주로 사용되는 것은 다음과 같다.

  • GET: 데이터를 가져올 때 사용
  • POST: 데이터를 등록할 때 사용, 인증작업을 거칠 때 사용 (Login 등)
  • DELETE: 데이터를 지울 때 사용
  • PUT: 데이터를 교체할 때 사용
  • PATCH: 데이터의 특정 필드를 수정할 때 사용

라우터에서 각 메소드에 대한 요청을 준비할 때는 .get, .post, .delete, .put, .patch 를 사용하면 된다.

exports.list = (ctx) => {
    ctx.body = 'listed';
};

exports.create = (ctx) => {
    ctx.body = 'created';
};

exports.delete = (ctx) => {
    ctx.body = 'deleted';
};

exports.replace = (ctx) => {
    ctx.body = 'replaced';
};

exports.update = (ctx) => {
    ctx.body = 'updated';
};

exports.변수명 = ...으로 내보내기 한 코드는 파일을 불러올 때 다음과 같이 사용할 수 있다.

const 모듈명 = require('파일명');
모듈명.변수명

MongoDB

MongoDB는 문서지향적 데이터베이스이다.

NoSQL 데이터베이스 중에서 1위

NoSQL은 Not Only SQL의 약자로 기존의 RDBMS의 한계를 극복하기 위해 만들어졌다.

이는 MySQL, PostgreSQL등 처럼 관계형 데이터베이스가 아니기 때문에 고정된 스키마를 가지고 있지 않으며, JOIN 같은 것이 존재하지 않는다.

문서

여기서 말하는 문서는, RDBMS의 record와 비슷한 개념이다.

데이터베이스의 데이터 구조는 한 개 이상의 key-value 쌍으로 이루어져 있다.

MongoDB에서 데이터를 담는 형태

{
	"_id": ObjectId("5099803df3f4948bd2f98391"),
	"username": "velopert",
	"name": { first: "M.J.", last: "Kim" }
}

각 문서는 _id라는 고유값을 가지게 되는데 시간/머신ID/프로세스ID/순차번호로 이루어져 있어 값의 고유함을 보장한다.

문서는 동적인 스키마를 가질 수 있다.

여러 문서들이 들어 있는 곳을 컬렉션이라고 부른다.

기존의 RDBMS에서는 테이블이라는 개념을 사용하고, 각 테이블마다 같은 스키마를 가져야 한다.

만약 데이터 구조를 바꿔야한다면, 전체 데이터를 바꿔야한다.

하지만, MongoDB에서는 한 컬렉션에서 다른 스키마를 가지고 있을 수 있다.

예를 들어, 다음 데이터들이 한 컬렉션 안에서 공존할 수 있다.

{
	"_id": ObjectId("594948a081ad6e0ea526f3f5")
	"username": "velopert"
},
{
	"_id": ObjectId("59494fca81ad6e0ea526f3f6")
	"username": "nakim",
	"phone": "010-3333-6666
}

전화번호가 필요 없었는데 나중에 필요해졌다고 가정했을 때, RDBMS에서는 한 테이블에서는 모든 데이터가 같은 스키마를 가져야 하기 때문에, 기존 데이터들도 다 하나하나 수정을 해줘야한다.

MongoDB에서는 컬렉션안의 데이터들은 같은 스키마를 가질 필요가 없으므로 그냥 넣어주면 된다.

그리고, 만약에 필요한 데이터가 없으면 유저한테서 새로 입력을 받는 로직을 작성하면 된다.

스키마 디자인

기존 RDBMS에서 블로그를 위한 데이터 스키마를 디자인한다면, 각 포스트, 덧글마다 테이블을 만들고 필요에 따라 JOIN해서 사용하는게 일반적이다.

하지만, MongoDB에서는 데이터 스키마를 디자인 할 때는 한 문서에 최대한 많은 데이터를 넣는 것이 관습이다. 즉, 포스트 내부에 덧글 배열을 내부에 넣는것이다.

이를 서브다큐먼트라고 부르며, 서브다큐먼트도 일반 문서를 다루는 것 처럼 쿼리를 할 수 있다.

책에 대한 정보를 데이터베이스에 넣는다고 할 때, RDBMS에서는 책의 정보와 저자의 정보를 따로 books, authors 이렇게 다른 테이블에 넣는게 일반적이지만, MongoDB에서는 저자의 정보를 books 문서 내부에 서브다큐먼트로 넣는 것이 좋다.

이렇게 하면 한번의 쿼리로 원하는 정보를 바로 불러올 수 있다. 하지만 저자의 정보가 자주 바뀔 수 있다면, 컬렉션을 따로 분리하고 authors 문서idbooks에 넣어준다.

이런식으로, 책의 모든 정보를 가져오기 위해 두번 쿼리를 하게 되지만, 데이터베이스 인덱싱이 잘 되어있다면, 성능상으로는 RDBMS에서의 JOIN과 큰 차이가 나지 않는다.

MongoDB 설치

macOS

macOS에서는 brew를 이용해서 설치한다.

$ brew update
$ brew install mongodb
# 서버 실행하기
$ mkdir db
$ mongod --dbpath ./db

MongoDB Atlas에서 호스팅 받기

MongoDB를 설치하는게 부담스럽다면, MongoDB Atlas에서 무료 호스팅을 받을 수 있다.

Mongoose 설치 및 적용

Mongoose는 MongoDB 기반 ODM(Object Data Modelling) 라이브러리이다.

이 라이브러리는 데이터베이스 상의 문서들을 JS 상의 객체로 사용할 수 있게 해준다.

설치

$ yarn add mongoose
$ yarn add dotenv

dotenv는 환경변수들을 파일에 넣고 사용할 수 있게 해주는 개발용 모듈이다.

mongoose를 연결할 때 서버에 대한 계정과 비밀번호를 입력하게 되는데, 이런 민감한 정보들은 코드상에서 하드코딩하기 보다는 환경변수로 설정하는 것이 좋다.

만약 오픈소스로 공개를 하는 경우에는 .gitignore를 통해서 환경변수가 들어있는 파일을 제외하면 된다.

.env 환경변수 파일 만들기

환경변수에서는 서버에서 사용할 포트, 그리고 MongoDB 주소를 넣는다.

프로젝트 루트 경로에 .env 파일을 생성하고 다음을 입력한다.

PORT=4000
MONGO_URI=mongodb://localhost/heurm

heurm은 우리가 만들 데이터베이스 이름으로, 데이터베이스는 사전에 만들어주지 않아도 자동으로 생성된다.

만약, mongodb를 설치하지 않았다면 아래와 같이 설정한다.


PORT=4000
MONGO_URI=mongodb+srv://unihit:<password>@boilerplate.eaiaz.mongodb.net/myFirstDatabase?retryWrites=true&w=majority

해당 password 부분에 자신이 설정했던 비밀번호를 입력하고 myFirstDatabase 부분을 자신의 데이터 베이스 이름으로 바꾸게 되면 설정이 완료되게 된다.

mongoose에서 데이터베이스에 요청할 때 Promise를 사용할 수 있다. 이 때 어떤 Promise를 사용할지 정해주어야 한다. Promise도 여러 종류의 구현체가 있기 때문이다.

지금 사용하는 노드 버전은 자체적으로 Promise를 내장하고 있기 때문에, 이것을 사용할 수 있도록, mongoose.Promise = global.Promise;를 설정해주어야 한다.

데이터베이스 스키마와 모델

MongoDB는 고정적인 스키마를 갖지 않는다고 했다.

여기서 만드는 스키마는 데이터베이스 서버측에서 만드는 스키마가 아닌 웹 서버가 데이터베이스에 들어있는 문서들을 객체화하여 사용할 수 있도록 스키마를 설정해주는 것이다.

데이터베이스의 실제 데이터와 웹 서버의 스키마가 일치하지 않아도 정상적으로 작동한다.

단, 만약 데이터베이스 상에서는 있는 정보가 서버측의 데이터 스키마에서는 설정되어 있지 않다면 해당 정보는 undefined로 보여지게 된다.

스키마와 모델

mongoose에서는 스키마(schema)와 모델(model)이라는 개념이 존재해서 혼동하기 쉽ㄴ다.

스키마는 해당 컬렉션의 문서에 어떤 종류의 값이 들어가는지를 정의한다.

  • 블로그 포스트 스키마의 예제 코드
let mongoose = require('mongoose');
let Schema = mongoose.Schema;

let blogSchema = new Schema({
  title:  String,
  author: String,
  body:   String,
  comments: [{ body: String, date: Date }],
  date: { type: Date, default: Date.now },
  hidden: Boolean,
  meta: {
    votes: Number,
    favs:  Number
  }
});

문서의 각 항목이 어떤 형식인지 정의가 되어 있다.

예를 들어 포스트의 제목(title)은 문자열 형태이고 덧글(comments)은 객체로 이루어진 배열이고, date는 날짜형식이다.

반면, 모델은 스키마를 통해서 만드는 '인스턴스'이다.

let Blog = mongoose.model('Blog', blogSchema);

이 객체를 통해서 데이터베이스에 실제 작업을 할 수 있게 된다. 이를 통해서 데이터를 조회하거나, 추가하거나, 수정하거나, 삭제할 수 있다.

Blog라는 이름을 가진 인스턴스(모델)가 blogSchema라는 스키마에 의해서 만들어졌다.

스키마 / 모델 만들기

구상하기

책의 정보를 담는 데이터에는 어떤 값들이 필요할까?

  • 책이름
  • 저자
  • 출판일
  • 가격
  • 태그
  • 데이터 생성날짜

각 항목들이 어떤 형식으로 이뤄져야 하는지 생각해보면...

  • 책이름: 책 이름은 문자열 형식
  • 저자: 저자의 정보가 들어가는 곳은 저자의 이름과 이메일이 들어가도록 설정해준다. 공동저자가 있을 수 있기 때문에 배열형태로 한다.
  • 출판일: 날짜가 들어간다.
  • 가격: 숫자가 들어간다.
  • 태그: 태그는 문자열 배열이 들어가도록 설정
  • 데이터 생성날짜: 날짜가 들어간다. 기본값으로 데이터가 만들어지는 서버의 시간을 넣는다.

코드 작성

// src/models/book.js

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

// Book에서 사용할 서브다큐먼트의 스키마
const Author = new Schema({
  name: String,
  email: String,
});

const Book = new Schema({
  title: String,
  authors: [Author], // 위에서 만든 Author 스키마를 가진 객체들의 배열형태로 설정
  publishedDate: Date,
  price: Number,
  tags: [String],
  createdAt: {
    // 기본값을 설정할 때는 객체로 설정한다.
    type: Date,
    default: Date.now, // 기본값은 현재 날짜
  },
});

// 스키마를 모델로 변환하여, 내보내기를 한다.
module.exports = mongoose.model('Book', Book);

model 함수에서는 기본적으로 두개의 파라미터를 필요로 한다.

  1. 해당 스키마의 이름
  2. 스키마 객체

스키마의 이름을 정해주면, 복수형태로 컬렉션 이름을 만들어준다.

예를 들어, Book으로 스키마의 이름을 설정하면, 실제 데이터베이스에서 생성되는 컬렉션 이름은 books이다.

그리고, BookInfo로 설정하면 bookinfos로 만들어진다.

MongoDB에서 컬렉션 이름을 만들때의 컬렉션을 구분자를 사용하지 않는다. (user_info)

복수형태로 사용하는 것이 컨벤션(books)

만약 이 컨벤션을 따르고 싶지 않으면 세번째 파라미터로 원하는 이름을 정해줄 수 있다.

mongoose.model('Book', Book, 'custom_book_collection');

모델을 생성하면서 사용한 이름은 나중에 다른 스키마에서 현재 스키마를 참조해야 할 때 사용된다.

MongoDB Client, Robomongo 설치

MongoDB를 PC에 설치했다면, mongo 명령어를 사용해서 데이터베이스에 접속해서 데이터를 조회할 수 있다. 하지만 이는 초반에는 불편할 수 있다.

편의를 위해서 GUI 환경의 MongoDB Client인 Robomongo라는 소프트웨어를 설치해서 사용한다.

설치 완료 후에 세팅을 다 하고 finish를 눌러 앱을 실행시킨다.

위 팝업창이 뜨게 되는데 Create 버튼을 누른다.

나는 MongoDB atlas를 이용하고 있기 때문에 해당 페이지로 돌아가서 connect 버튼을 누른다.

해당 부분을 복사한다.

From SRV 버튼 옆에 복사한 부분을 붙여 넣고 <password> 부분과 myFirstDatabase 부분을 자신이 설정해준 부분과 맞도록 고친 뒤에 버튼을 클릭해서 적용한다.

그럼 자동으로 필요한 부분이 전부 채워지게 된다.

Test를 해서 연결을 test 해보면 되는데 만약 연결 오류가 나면 MongoDB로 다시 돌아가서 네트워크 설정을 해준다.

MongoDB Atlas는 IP 화이트 리스트에 등록된 IP에 한해서 DB 접근을 허용한다.

접속하는 호스트의 IP가 IP 화이트 리스트에 등록되지 않은 IP로 변경된 경우 MongoDB Compass에서 connection string을 제대로 작성해도 connection closed 오류가 발생하여 DB에 접근할 수 없다.

이때, 접속 IP를 모든 IP에 대해 일시적으로 허용해서 문제를 해결할 수 있다.

  1. MongoDB Atlas 접속

  2. Network Access 버튼 선택

  3. IP 화이트 리스트에 자신의 IP가 등록되어 있는지 확인한다. (등록되어 있지 않은 경우 EDIT 버튼 선택)

  4. 등록되어 있지 않은 경우 EDIT 버튼을 누르고 뜬 창에서 ALLOW ACCESS FROM ANYWHERE을 클릭

  5. Confirm 버튼을 클릭

  6. Network Access에서 IP 화이트 리스트 확인

  7. 그리고 다시 Robo3T로 돌아와서 Test 버튼을 눌러서 연결이 성공되었는지 확인한다.

자바스크립트 비동기 처리와 콜백함수

  • Promise는 자바스크립트 비동기 처리에 사용되는 객체

비동기 처리

특정 코드의 연산이 끝날 때까지 코드의 실행을 멈추지 않고 다음 코드를 먼저 실행하는 자바스크립트의 특성

비동기 처리의 첫번째 사례

비동기 처리의 사례중 대표적으로 제이쿼리의 ajax가 있다.

화면에서 서버로 데이터를 요청했을 때 서버가 언제 그 요청에 대한 응답을 줄지 모르는데 다른 코드가 처리 완료되는 것을 기다릴 수 없다. 동기처리로 진행할 시에는 웹 어플리케이션을 실행하는데 많은 시간이 걸린다.

콜백 함수로 비동기 처리 방식의 문제점 해결

콜백함수를 이용하면 자바스크립트의 비동기 처리 방식에 의해 발생하는 문제점들을 해결할 수 있다.

콜백 함수의 동작 방식은 식당 자리 예약과 같다.

식당에 사람이 많으면 대기가 명단에 이름을 쓴 다음 전화로 자리가 났다고 연락이 온다.

자리가 났을 때만 연락이 오기 때문에 미리 가서 기다릴 필요도 없고, 직접 식당 안에 들어가서 자리가 비어 있는지 확인할 필요도 없다.

자리가 준비된 시점, 즉 데이터가 준비된 시점에서만 원하는 동작(자리에 앉아 주문, 특정 값 출력 등)을 수행할 수 있다.

콜백 지옥 (Callback hell)

콜백 지옥은 비동기 처리 로직을 위해 콜백 함수를 연속해서 사용할 때 발생하는 문제이다.

.get('url', function(response) {
	parseValue(response, function(id) {
		auth(id, function(result) {
			display(result, function(text) {
				console.log(text);
			});
		});
	});
});

서버에서 데이터를 받아와 화면에 표시하기까지 인코딩, 사용자 인증 등을 처리해야 하는 경우가 있다. 만약 이 모든 과정을 비동기로 처리해야 한다고 하면 위와 같이 콜백을 계속 무는 형식으로 코딩하게 된다. 이런 코드 구조는 가독성도 떨어지고 로직을 변경하기도 어렵다.

이와 같은 구조를 콜백 지옥이라고 한다.

콜백 지옥을 해결하는 법

일반적인 방법으로는 Promise나 Async를 사용하는 방법이 있다. 만약 코딩 패턴으로만 콜백 지옥을 해결하려면 아래와 같이 각 콜백 함수를 분리해주면 된다.

function parseValueDone(id) {
	auth(id, authDone);
}
function authDone(result) {
	display(result, displayDone);
}
function displayDone(text) {
	console.log(text);
}
$.get('url', function(response) {
	parseValue(response, parseValueDone);
});

위 코드는 앞의 콜백 지옥 예시를 개선한 코드다.

중첩해서 선언한 콜백 익명 함수를 각각의 함수로 구분했다.

  1. ajax 통신으로 받은 데이터를 parseValue() 메서드로 파싱한다.
  2. parseValueDone()에 파싱한 결과값인 id가 전달되고 auth() 메서드가 실행된다.
  3. auth() 메서드로 인증을 거치고 나면 콜백 함수 authDone()이 실행된다.
  4. 인증 결과값 result로 display()를 호출하면 마지막으로 displayDone() 메서드가 수행되면서 text가 콘솔에 출력된다.

위와 같은 코딩 패턴으로도 콜백 지옥을 해결할 수 있지만 Promise나 Async를 이용하면 더 편하게 구현할 수 있다.

Promise

  • 프로미스는 자바스크립트 비동기 처리에 사용되는 객체이다.
  • Promise는 현재는 당장 얻을 수는 없지만 가까운 미래에는 얻을 수 있는 어떤 데이터에 접근하기 위한 방법을 제공한다. 당장 원하는 데이터를 얻을 수 없다는 것은 데이터를 얻는데까지 지연시간(delay, latency)이 발생한다는 것을 의미한다.
  • 대표적으로 I/O나 Network를 통해서 데이터를 얻어오는데, CPU에 의해 실행되는 코드 입장에서는 엄청나게 긴 지연시간으로 여겨지기 때문에 Non-blocking 코드를 지향하는 자바스크립트에서는 비동기 처리가 필수적이다.

Promise가 필요한 이유

프로미스는 주로 서버에서 받아온 데이터를 화면에 표시할 때 사용한다. 일반적으로 웹 어플리케이션을 구현할 때 서버에서 데이터를 요청하고 받아오기 위해 아래와 같은 API를 사용한다.

fetch("api주소")

위 API가 실행되면 서버에다가 데이터를 보내달라는 요청을 보낸다. 그런데 여기서 데이터를 받아오기도 전에 데이터를 다 받아온 것처럼 화면에 데이터를 표시하려고 하면 오류가 발생하거나 빈 화면이 뜬다.

이와 같은 문제점을 해결하기 위한 방법중 하나가 프로미스이다.

fetch()

fetch("api주소")
.then(res => res.json())
.then(res => {
	// json 형태로 전달받은 서버로부터의 응답
});

fetch API는 promise 방식을 기반으로 하고 있다.

Promise 방식

then 키워드는 앞의 함수 실행이 끝나면 다음으로 할 일을 정해주는 것으로 fetch()는 웹 브라우저에게 요청을 보내는 것이고 뒤에 then이 붙으면 요청이 끝나고서 then을 실행한다.

fetch는 서버와의 통신에서 사용된다. 서버쪽에서 요청을 받고 응답할 때의 소스코드를 보자

const fetch = ('응답받은 서버주소') => {
  return new Promise((resolve, reject) => resolve('전달해줄 데이터'));
}

fetch()는 Promise 기반이다. 그래서 Promise라는 클래스를 사용한다. 그래서 서버에서는 Promise의 인스턴스를 반환해준다.

resolve는 정상적으로 실행이 되었을 때 클라이언트에게 전달해줄 값을 인자로 넣어준다.

reject는 클라이언트의 요청에 문제가 있을 때, catch로 반환할 값을 전달해준다.

fetch().then((res) => {
	console.log(res);
});

fetch()에서 반환받은 Promise 인스턴스 안의 then이라는 함수에다 요청 뒤에 처리해줄 함수를 인자로 전달해 준다. 위 코드의 전달해줄 함수의 매개변수 res가 API에서 요청으로 전달받은 값으로 들어온다.

Promise를 사용하여 작성한 방식

findUser(1).then(function (user) {
  console.log("user:", user)
})

function findUser(id) {
  return new Promise(function (resolve) {
    setTimeout(function () {
      console.log("waited 0.1 sec.")
      const user = {
        id: id,
        name: "User" + id,
        email: id + "@test.com",
      }
      resolve(user)
    }, 100)
  })
}

위 코드는 콜백 함수를 인자로 넘기는 대신 Promise 객체를 생성하여 리턴하였고, 호출부에서는 리턴받은 Promise 객체에 then() 메서드를 호출하여 결과값을 가지고 실행할 로직을 넘겨주고 있다.

콜백함수를 통해 비동기 처리를 하던 기존 코드와 가장 큰 차이점은 함수를 호출하면 Promise 타입의 결과값이 리턴되고, 이 결과값을 가지고 다음에 수행할 작업을 진행한다는 것이다. 따라서 기존 스타일보다 비동기 처리 코드임에도 불구하고 마치 동기 처리 코드처럼 읽히기 때문에 좀 더 직관적으로 느껴진다.

Promise 생성 방법

Promise 객체를 리턴하는 함수를 작성하는 방법

Promise 객체는 new 키워드와 생성자를 통해서 생성할 수 있는데 이 생성자는 함수를 인자로 받는다. 그리고 이 함수 인자는 resolvereject라는 2개의 함수형 파라미터를 가진다.

따라서 아래와 같이 Promise 객체를 생성해서 변수에 할당할 수 있다.

const promise = new Promise((resolve, reject) => { ... } );

실제로는 변수에 할당하기 보다는 어떤 함수의 리턴값으로 바로 사용되는 경우가 많다.

returnPromise = () => {
  new Promise((resolve, reject) => { ... } );
}

Promise의 3가지 상태(status)

status란 Promise의 처리 과정을 의미한다. new Promise()로 프로미스를 생성하고 종료될 때까지 3가지 상태를 갖는다.

  • Pending(대기): 비동기 처리 로직이 아직 완료되지 않은 상태
  • Fulfilled(이행): 비동기 처리가 완료되어 promise가 결과값을 반환해준 상태
  • Rejected(실패): 비동기 처리가 실패하거나 오류가 발생한 상태

Pending(대기)

아래와 같이 new Promise() 메서드를 호출하면 대기상태가 된다.

new Promise();

new Promise() 메서드를 호출할 때 콜백 함수를 선언할 수 있고, 콜백 함수의 인자는 resove, reject이다.

new Promise((resolve, reject) => {});

Fulfilled(이행)

콜백 함수의 인자 resolve를 아래와 같이 실행하면 이행상태가 된다.

new Promise((resolve, reject) => {
	resolve();
});

그리고 이행 상태가 되면 아래와 같이 then() 을 이용해서 처리 결과값을 받을 수 있다.

getData = () => {
	new Promise((resolve, reject) => {
		let data = 100;
		resolve(data);
	});
}

// resolve()의 결과 값 data를 resolvedData로 받음
getData().then((resolvedData) => {
	console.log(resolvedData); // 100
});

프로미스의 '이행' 상태를 다르게 표현해보면 '완료'이다.

Rejected(실패)

new Promise()로 프로미스 객체를 생성하면 콜백 함수 인자로 resolvereject를 사용할 수 있다.

여기서 reject를 아래와 같이 호출하면 실패(Rejected) 상태가 된다.

new Promise((resolve, reject) => {
	reject();
});

그리고, 실패 상태가 되면 실패한 이유(실패 처리의 결과 값)을 catch()로 받을 수 있다.

getData = () => {
	new Promise((resolve, reject) => {reject(new Error("Request is failed"));
	});
}

// reject()의 결과 값 Error를 err에 받음
getData().then().catch((err) => {console.log(err);
});

프로미스 처리 흐름 - 출처: MDN

Reference

https://joshua1988.github.io/web-development/javascript/promise-for-beginners/

https://helloinyong.tistory.com/68

https://www.daleseo.com/js-async-callback/

https://www.daleseo.com/js-async-promise/

https://backend-intro.vlpt.us/

https://life-of-panda.tistory.com/42

0개의 댓글