이 블로그는 광고 클릭 수익으로 운영됩니다!
괜찮으시다면 광고 차단을 풀어주세요 ㅠㅠ

게시글

강좌13 - MongoDB - 10달 전 등록 / 9달 전 수정

Mongoose(몽구스) 스키마(Schema)

조회수:
0
이 블로그는 광고 클릭 수익으로 운영됩니다!
괜찮으시다면 광고 차단을 풀어주세요 ㅠㅠ
이 블로그는 광고 클릭 수익으로 운영됩니다!
괜찮으시다면 광고 차단을 풀어주세요 ㅠㅠ

안녕하세요. 이번 시간에는 몽구스 스키마에 대해 알아보겠습니다. 지난 시간에 몽구스는 스키마가 장점이라고 말씀드렸죠?

MySQL같은 SQL에 익숙하신 분들은 처음에 MongoDB나 Redis같은 nosql을 사용하면 헤맵니다. 바로 테이블이 없기 때문입니다. 다큐먼트에 아무거나 넣어도 에러가 생기지 않습니다! 어떻게보면 에러가 안 나서 매우 편리한 기능인 것 같지만, 실제로 사용하다보면 아무거나 다 들어가서 문제가 생깁니다. 실수로 오타를 낸 데이터도 들어가고, 같은 필드인데 자료형이 다른 경우도 생깁니다.

스키마

이러한 문제를 막기 위해 몽구스는 Schema(스키마)를 도입했습니다. 몽구스는 사용자가 작성한 스키마를 기준으로 데이터를 DB에 넣기 전에 먼저 검사합니다. 스키마에 어긋나는 데이터가 있으면 에러를 발생시킵니다. 즉, 테이블과 어느 정도 비슷한 역할을 합니다. 또한 스키마를 설정할 때 인덱스까지 같이 걸어둘 수도 있습니다. 기본값도 설정해줄 수 있고요. 구조에 관한 편의 기능들을 하나로 모아두었다고 생각하시면 됩니다.

스키마를 한 번 만들어보겠습니다. 보통 한 파일에 하나의 스키마를 작성합니다. 물론 스키마가 커지면 여러 파일로 분산해도 됩니다.

const mongoose = require('mongoose');

const imageSchema = new mongoose.Schema({
  width: Number,
  height: Number,
});

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true, trim: true },
  nickname: String,
  birth: { type: Date, default: Date.now },
  point: { type: Number, default: 0, max: 50, index: true },
  image: imageSchema,
  likes: [String],
  any: [mongoose.Schema.Types.Mixed ],
  id: mongoose.Schema.Types.ObjectId,
});
userSchema.index({ email: 1, nickname: 1 });

module.exports = mongoose.model('User', userSchema);

저어어얼대 제 블로그 스키마가 아닙니다! 그냥 예시를 들기 위해 만들어보았습니다.

imageSchema는 보조적인 것이고 자세히 보셔야 할 부분은 userSchema입니다. 스키마는 저렇게 만듭니다. 객체 안의 속성명들이 필드의 이름이 됩니다. 

email 필드의 자료형은 문자열이고, 필수이고, 유일해야 하고, 소문자여야 한다고 정해주었습니다(uppercase로 대문자도 가능합니다). password 필드는 문자열이고, 필수이고, 공백을 제거한다고 정했습니다(trim은 String.prototype.trim이라고 생각하시면 됩니다).

nickname 필드는 문자열인데 다른 옵션이 없기 때문에 그냥 String만 적어주었습니다. nickname은 필수가 아니라서 생략해도 됩니다. birth 필드는 Date고요. 기본값으로 현재 시간을 넣습니다. 기본값이란 사용자가 데이터를 넣지 않아도 자동으로 입력되는 값입니다.

point 필드는 숫자이고, 기본값은 0, 최댓값은 50입니다(min 옵션으로 최솟값을 지정할 수도 있습니다). 빠르게 찾을 수 있도록 인덱스도 걸어주었습니다. image 필드에는 객체가 들어갑니다. 따로 imageSchema를 만들어서 image 필드 안에 무엇이 들어가야 할지 정해주었습니다. 이렇게 스키마 안에 다른 스키마를 넣는 것도 가능합니다. 아래와 같이 다른 스키마를 쓰지 않고 표현도 가능합니다.

image: {
  width: Number,
  height: Number,
},

likes 필드는 문자열의 배열입니다. 배열 표시를 해 두고 [String] 또는 [{ type: String }] 이렇게 활용 가능합니다. 객체를 넣으면 required나 trim 같은 옵션을 줄 수도 있겠죠?

any와 id 필드는 조금 특이합니다. 각각 아무거나 넣어도 되는 필드(배열 안에), ObjectId를 넣을 수 있는 필드입니다.

몽고디비는 복합 인덱스(두 개 이상의 필드에 동시 인덱스)도 가능하기 때문에 스키마에서 직접 걸어줄 수 있습니다. userSchema.index() 부분을 보시면 됩니다.

마지막 줄 mongoose.model()을 호출할 때 스키마가 등록됩니다. 나중에 스키마를 수정할 때, 미리 저장되어 있던 데이터들은 바꾸지 않으니 조심해야 합니다. 대신 SQL처럼 alter table 명령 없이 자유롭게 수정할 수 있어 좋습니다.

참고로 몽구스는 model의 첫 번째 인자로 컬렉션 이름을 만듭니다. User이면 소문자화 후 복수형으로 바꿔서 users 컬렉션이 됩니다. Book 스키마였다면 books 컬렉션이 됩니다. 이런 강제 개명이 싫다면 세 번째 인자로 컬렉션 이름을 줄 수 있습니다. mongoose.model('User', userSchema, 'myfreename')

위와 같이 스키마를 만들었다면 꼭 서버 실행하는 부분에서 require을 해주어야 합니다. require을 해주는 순간 스키마가 등록(model 메소드가 호출)되어서 앞으로 DB 작업을 할 때 스키마에 맞춰 검사합니다. 실컷 스키마 다 만들어놓고 require을 안 해서 스키마 적용이 안 된다고 하시는 분 많이 봤습니다;;

require('./경로/userSchema'); // 이렇게만 DB 연결 부분에 적어주면 됩니다.

편의 기능

위와 같이 타입을 고정해주는 것만으로도 좋지만 추가적으로 여러 편의 기능도 제공해줍니다. 많이 쓰이는 세 가지 기능을 알아보겠습니다. 먼저 virtual입니다. 다큐먼트에는 없지만 객체에는 있는 가상의 필드를 만들어줍니다.

userSchema.virtual('detail').get(function() {
  return `저는 ${this.nickname}이고 생일은 ${this.birth.toLocaleString()}입니다.`;
});

스키마에 virtual을 붙이면 users 컬렉션을 조회할 때 { email: ..., password: ..., nickname: ..., detail: ... }처럼 detail 필드가 생깁니다. 그리고 get 메소드 안에 넣어준 함수의 return 값이 들어있습니다. 기존 필드들을 활용해서 새로운 가상 필드를 만드는 기능입니다.

그 다음으로 소개할 기능은 스키마나 다큐먼트에 사용자 정의 메소드를 붙이는 겁니다. 

userSchema.methods.comparePassword = function(pw ,cb)  {
  if (this.password === pw) {
    cb(null, true);
  } else {
    cb('password 불일치');
  }
};
// 나중에 user 다큐먼트를 받게 되면
user.comparePassword('비밀번호', function(err, result) {
  if (err) {
    throw err;
  }
  console.log(result);
});

직접 비밀번호가 일치하는지 확인하는 코드를 짤 필요없이 userSchema에 정의한 메소드로 재사용할 수 있습니다. 또 다큐먼트가 아닌 모델이나 쿼리에 직접 static method를 붙일 수도 있습니다.

userSchema.statics.findByPoint = function(point) {
  return this.find({ point: { $gt: point } });
};
userSchema.query.sortByName = function(order) {
  return this.sort({ nickname: order });
};

Users.findByPoint(50).sortByName(-1);

statics은 Users 모델에서 바로 쓸 수 있는 메소드이고, query는 한 번 쿼리 후에 이어서 쓰는 메소드입니다. 위와 같이 여러분이 직접 만들 수 있습니다.

마지막으로 소개할 것은 pre와 post 메소드입니다. 스키마에 붙여 사용하는데, 각각 특정 동작 이전, 이후에 어떤 행동을 취할 지를 정의할 수 있습니다. Hook를 건다고 생각하시면 됩니다.

userSchema.pre('save', function(next) {
  if (!this.email) { // email 필드가 없으면 에러 표시 후 저장 취소
    throw '이메일이 없습니다';
  }
  if (!this.createdAt) { // createdAt 필드가 없으면 추가
    this.createdAt = new Date();
  }
  next();
});
userSchema.post('find', function(result) {
  console.log('저장 완료', result);
});

위의 것은 save하기 에 호출됩니다. next를 실행하지 않으면 save가 되지 않기 때문에 다큐먼트 저장 전 최종 검증으로 쓸 수 있습니다. 아래 것은 find를 호출한 다음에 실행됩니다. 딱히 사용처는 생각나지 않는데 통계를 내거나 할 때 사용할 수 있을 것 같습니다.

모델 메소드

이렇게 스키마 정의하고 모델과 연결한 후에는 다른 파일에서 이 모델을 불러와 실제 쿼리를 날릴 수 있습니다. 유명한 find와 findOne은 기본이고요. 몽구스에서 제공하는 메소드들이 몇 가지 더 있습니다. 유명한 것들만 간단히 알아봅시다.

find, findOne, findById

사용법은 아래와 같습니다. 프로젝션은 select처럼 원하는 필드만 가져오는 것을 뜻합니다. 자세한 사항은 쿼리 빌더 강좌에 있습니다. 콜백 함수는 넣어주면 콜백 형식(콜백 함수의 첫 번째 인자는 에러, 두 번째 인자는 결과)으로 결과를 받고 넣어주지 않으면 프로미스로 받습니다. 프로미스로 받는 것은 몽구스 프로미스 강좌를 참고하세요.

Users.find(조건, 프로젝션, 콜백) // 조건에 해당하는 모든 것을 쿼리
Users.findOne(조건, 프로젝션, 콜백) // 조건에 해당하는 첫 번째 것을 쿼리
Users.findById(아이디, 프로젝션, 콜백)

findOneAndRemove, findOneAndUpdate, findByIdAndRemove, findByIdAndUpdate

조회하는 메소드가 아니라 삭제 또는 수정하는 메소드입니다. 역시 콜백 유무에 따라 프로미스처럼 쓸 수도 있습니다. update 시에는 객체 형식의 옵션을 줄 수 있는데요. upsert(조건에 맞는 다큐먼트가 없을 시 생성), multi(여러 개 동시 업데이트), new(결과로 변경된 문서 반환) 등이 자주 쓰입니다.

특히 많이 헷갈려하시는 것이 multi와 new 옵션을 주지 않았을 때입니다. multi를 주지 않으면 하나의 다큐먼트만 업데이트되고, new를 주지 않으면 업데이트 후에도 업데이트 전 다큐먼트가 반환됩니다.

Users.findOneAndRemove(조건, 콜백)
Users.findByIdAndRemove(아이디, 콜백)
Users.findOneAndUpdate(조건, 변경, 옵션, 콜백)
Users.findByIdAndUpdate(아이디, 변경, 옵션, 콜백)
Users.findOneAndUpdate({ name: 'zerocho' }, { name: 'babo' }, { multi: true, new: true }) // 예시

다음 시간에는 populate에 대해 알아보겠습니다!

투표로 게시글에 관해 피드백을 해주시면 많은 도움이 됩니다. 오류가 있다면 어떤 부분에 오류가 있는지도 알려주세요! 잘못된 정보가 퍼져나가지 않도록 도와주세요.
Copyright © 2016- 무단 전재 및 재배포 금지

댓글

5개의 댓글이 있습니다.
3달 전
안녕하세요 제로님. 스키마에 조금 궁금한점이 있어서요 만약에 하나의 배열을 데이터에 담으려면 어떤 스킴을 사용하는게 좋을까요? Array를 생각해보니 계속해서 쌓이게 될것이고 String으로 하자니 배열을 담을때 문자열로 변경하고 이런 번거로움이 있을거 같아서요 하나의 배열만 담을려면 최선의 방법은 무엇일까요.
3달 전
하나의 컬렉션에 진짜 배열 하나만 필요하신건가요? 배열의 요소를 각각 하나의 다큐먼트로 저장한 후 나중에 서버단에서 배열로 바꾸는 게 나아보입니다. 배열보다는 객체가 관리하기 좋아서요
3달 전
답변 감사합니다. zero님 말 그대로 싱글 배열 입니다. 단 하나의 배열 입니다. 다만 하나의 컬렉션에 하나의 배열만 필요한 것은 아니고 임베디드 도큐먼트로 들어갈 예정이라 이렇게 질문을 남깁니다. 예로. input 태그의 네임이 동일 한 경우 node.js 에서 배열로 반환을 하더라구요.(아마도 body-parser가 알아서 처리해주지 않나 싶습니다.) 이러 한 이유들로 배열을 넣어야 할 상황이라서요.
3달 전
그렇다면 [String]이나 [Number]같은 스키마를 사용하시면 되지 않을까요? Array가 계속 쌓이게될 것 같다고 하셨는데 이 말씀의 뜻을 잘 모르겠습니다.
3달 전
제가 질문의 요지를 정확하게 표현하지 못한 것 같네요^^;;


예로 다음과 같이 배열이 하나 있고 특정 임베디드 도큐먼트에 다음과 같은 배열이 단 하나만 쌓이고 업데이트 시에 다른 단 하나의 배열이 업데이트 됩니다. 이런 상황의 경우 결국 말씀하신대로 String을 사용해야 할까요. 다만 제가 걱정중인것은 String으로 데이터를 삽입하면 불러올때마다 파싱작업이 필요할것 같아서요.


[1,2,3,4,5,6]
3달 전
그냥 배열로 하면 무슨 문제가 생기나요? 임베디드 다큐먼트에도 배열로 처리 가능합니다.
3달 전
하나의 배열 데이터만 들어간다 해도 그냥 스키마 타입을 배열로 해도 되겠군요, 어차피 _id 값으로 모든 걸 조회하니. 조언 감사합니다. zero님 많이 배워갑니다 ^^.
4달 전
잘 배우고 있습니다. 질문이 있는데 몽구스를 활용해 Mysql의 Trigger같은 기능을 사용할 수 있나요?
4달 전
pre랑 post 메서드를 한 번 보시겠어요?
5달 전
잘 배웠습니다. 감사합니다.
9달 전
따라서 공부해보는 중에 static method 부분에서 실행이 안되길래 확인해보니 예시코드 중 .statics -> .static 으로 오타가 있어서 안되는거였습니다 ㅁ. 다른분들을 위해 수정 부탁드립니담
9달 전
감사합니다
10달 전
mongo db 처음 사용해보는중인데 많은 도움되고 있습니다 감사합니다.