모델 사용법

  • 이 글은 4. Model Usage를 번역한 글입니다.
  • 아직 입문자이다보니 오역이 있을 수 있습니다. 양해 부탁드립니다.
  • 매끄러운 번역을 위하여 의역을 한 경우가 있습니다. 원문의 뜻을 최대한 해치지 않도록 노력했으니 안심하셔도 됩니다.
  • 영어 단어가 자연스러운 경우 원문 그대로의 영단어를 적었습니다.
  • 저의 보충 설명은 인용문에 적었습니다.

시작하기 전에

  • 이 글은 공식 도큐먼트의 4번째 챕터입니다. 앞부분은 Sequelize의 기본적인 설정과 자료형 등에 대한 내용으로 이해에 큰 어려움이 없으므로 생략합니다.
  • 원문에서는 예시 코드를 제공하지만, 코드만 제공할 뿐 CRUD를 위한 데이터셋 세팅, 실습 환경에 대한 정보가 없고, 실행 결과를 확인할 수 있는 방법도 알려주지 않습니다. 그래서 간단하게나마 실습해볼 수 있도록 세팅하는 방법을 알려드리고, 예제 코드 결과를 확인해볼 수 있는 코드를 설명 중간중간에 함께 올려드리니 필요에 따라 활용해주시면 감사드리겠습니다.

1. NPM 프로젝트 생성

$ mkdir sequelize-tutorial
$ cd sequelize-tutorial

$ npm init
$ npm install sequelize mysql2

2. index.js 작성

const Sequelize = require('sequelize');
const config = {
  "username": "root",               // user name
  "password": "mysql",              // password
  "database": "sequelize_tutorial", // schema name
  "host": "127.0.0.1",                // db address
  "dialect": "mysql",                // database type
  "operatorsAliases": false
}

const sequelize = new Sequelize(
  config.database, config.username, config.password, config,
);

/*
  Sequelize code here
*/

sequelize.sync()

저는 MySQL 기반으로 실습을 진행했습니다. MySQL 이외의 환경으로 실습하고자 하는 분들은 각자 환경에 맞게 설치해주시면 됩니다. dialect 부분을 다른 DB로 변경해준 뒤, npm start를 해보시면 설치해야 하는 DB 모듈을 알려줍니다. 사용할 수 있는 다른 DB로는 'sqlite', 'postgres', 'mssql' 등이 있습니다.

Sequelize는 기본적으로 Promise 기반이기 때문에, 모든 CRUD 작업이 Promise의 형태로 시작하고 끝난다는 점을 감안하여 코드를 작성해주셔야 원하는 결과를 보실 수 있습니다. 위의 코드의 sync()가 실행되면 index.js 내에서 작성된 Sequelize 작업이 실제 DB와 동기화되면서 실행됩니다. 따라서 테이블 정의, 연결 관계 정의 등과 같은 작업은 sync() 이전에 완료해야 합니다. sync()의 결과는 Promise로, 동기화가 완료된 뒤 .then()을 통하여 원하는 CRUD 작업을 수행할 수 있게 됩니다.

index.jsconfig 객체에 적은 database의 이름에 맞춰서 실제 DB에 동일한 이름의 데이터베이스를 생성해줘야 합니다. 그렇지 않으면 Sequelize가 동기화를 하지 못합니다.

여기까지 하셨으면 도큐먼트를 보면서 실습을 할 준비가 다 되신 겁니다.


데이터 조회 / Finder

Finder 메서드는 DB로부터 데이터를 쿼리하는 데에 사용됩니다. Finder 메서드는 평범한 객체를 반환하는 것이 아니고, 모델 인스턴스를 반환합니다. 이 모델 인스턴스에는 각종 멤버가 들어있어 활용할 수 있습니다.

이 문서에서는 다양한 Finder 메서드에 대하여 알아봅니다.

find - 단일 요소를 DB에서 검색

// ID로 검색
Project.findById(123).then(project => {
  // project는 Project의 인스턴스로, ID가 123인 테이블 항목의 데이터를 담고 있다.
  // 이러한 항목이 존재하지 않는다면, null을 반환받는다.
})

// 특정 컬럼으로 검색
Project.findOne({ where: {title: 'aProject'} }).then(project => {
  // project는 Project 테이블에서 title이 'aProject'인 첫번째 항목 || 또는 null
})


Project.findOne({
  where: {title: 'aProject'},
  attributes: ['id', ['name', 'title']]
}).then(project => {
  // project는 Project 테이블에서 title이 'aProject'인 첫번째 항목 || 또는 null
  // project.title은 프로젝트의 name 값을 가진다 (SQL AS)
})

findOrCreate - 특정 요소를 검색하거나, 존재하지 않으면 새로 생성

findOrCreate 메서드는 DB에 특정 요소가 존재하는지 검사합니다. 만약 존재한다면 해당하는 인스턴스를 반환하고, 그렇지 않으면 새로 생성합니다.

빈 DB에 User 모델이 있고, 거기에 usernamejob 컬럼이 있다고 가정합시다.

User
  .findOrCreate({where: {username: 'sdepold'}, defaults: {job: 'Technical Lead JavaScript'}})
  .spread((user, created) => {
    console.log(user.get({
      plain: true
    }))
    console.log(created)

    /*
     findOrCreate 메서드는 검색되었거나 또는 생성된 객체를 포함한 배열, 그리고 boolean값을 반환합니다. 여기서 boolean값은, 새 객체가 생성되었을 경우 true, 그렇지 않을 경우 false입니다.

    [ {
        username: 'sdepold',
        job: 'Technical Lead JavaScript',
        id: 1,
        createdAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET),
        updatedAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET)
      },
      true ]

 위의 예시에서 "spread" 메서드는 배열을 user와 created 2개 부분으로 나누어 이어지는 콜백에 인자로 전달합니다. 따라서 "user"는 반환된 배열의 0번째 인덱스에 존재하는 객체이고, "created"는 true값을 갖는 boolean 변수입니다.
    */
  })

where에 함께 전달되는 defaults 옵션은 검색 결과가 존재하지 않을 경우 새로 생성되는 요소가 갖는 기본값입니다. 위 예시의 경우, 새로 생성되는 요소의 username은 'sdepold'입니다.

이미 존재하는 요소는 findOrCreate 메서드로 검색하더라도 변경되지 않습니다.

findAndCountAll - 복수의 요소를 검색하고, 해당하는 데이터와 그 갯수를 반환

findAllcount를 합친 편리한 메서드입니다. 특히 Pagination와 관련된 처리를 할 때, limitoffset을 사용하는 동시에 해당 쿼리에 부합하는 레코드 갯수도 알아야하는 경우 유용합니다.

작업에 성공했을 경우, 콜백에서는 2개의 속성을 가진 객체를 받습니다.

  • count: where 절, 연결 관계 등의 조건에 부합하는 레코드의 전체 갯수. (정수)
  • rows: where 절, 연결 관계 등의 조건에 부합하는 레코드들을 limitoffset 범위 내에서 담아낸 객체 배열
Project
  .findAndCountAll({
     where: {
        title: {
          [Op.like]: 'foo%'
        }
     },
     offset: 10,
     limit: 2
  })
  .then(result => {
    console.log(result.count);
    console.log(result.rows);
  });

findAndCountAll 메서드는 include를 지원합니다. required 옵션이 true인 경우에 대하여서만 count에 반영됩니다.

프로필이 포함된 사용자 모두를 찾고 싶다고 가정해봅시다:

User.findAndCountAll({
  include: [
     { model: Profile, required: true}
  ],
  limit: 3
});

Profile 모델에 대한 include절의 옵션이 required: true를 가지므로, 위 코드는 INNER JOIN의 결과를 반환할 것이고, 오직 프로필을 가지고 있는 사용자만이 카운트될 것입니다. 여기서 required를 제거하면, 프로필 유무와 상관 없이 모든 사용자가 카운트됩니다. where문을 include절에 추가하면 자동으로 해당 모델에 대하여 required 옵션을 추가합니다.

User.findAndCountAll({
  include: [
     { model: Profile, where: { active: true }}
  ],
  limit: 3
});

required 옵션이 없는 include 절의 추가는 결국 User 기준으로 LEFT JOIN과 같은 결과를 만들어냅니다.

위에 작성된 쿼리문은 active 상태의 프로필을 가진 사용자만을 카운트합니다. include절이 where 문을 포함할 경우 required는 암묵적으로 true 값이 주어지기 때문이죠.

findAndCountAll에 인자로 전달되는 옵션 객체는 findAll과 동일합니다.

findAll - 복수의 요소를 검색

// 복수의 요소 검색
Project.findAll().then(projects => {
  // projects는 모든 Project 모델의 항목들을 배열로 받아온다
})

// 아래와 같이 사용하는 것도 가능하다
Project.all().then(projects => {
  // projects는 모든 Project 모델의 항목들을 배열로 받아온다
})

// 특성을 지정하여 검색하는 것도 가능하다 - 일부만 검색
Project.findAll({ where: { name: 'A Project' } }).then(projects => {
  // projects는 특정된 이름을 가진 Project 모델의 항목을 배열로 받아온다
})

// 특정 범위 내에서 검색
Project.findAll({ where: { id: [1,2,3] } }).then(projects => {
  // projects는 id 값이 1, 2 또는 3인 Projects의 항목들을 배열로 받아온다
  // 내부적으로는 SQL IN을 사용하여 이루어진다
})

Project.findAll({
  where: {
    id: {
      [Op.and]: {a: 5},           // AND (a = 5)
      [Op.or]: [{a: 5}, {a: 6}],  // (a = 5 OR a = 6)
      [Op.gt]: 6,                // id > 6
      [Op.gte]: 6,               // id >= 6
      [Op.lt]: 10,               // id < 10
      [Op.lte]: 10,              // id <= 10
      [Op.ne]: 20,               // id != 20
      [Op.between]: [6, 10],     // BETWEEN 6 AND 10
      [Op.notBetween]: [11, 15], // NOT BETWEEN 11 AND 15
      [Op.in]: [1, 2],           // IN [1, 2]
      [Op.notIn]: [1, 2],        // NOT IN [1, 2]
      [Op.like]: '%hat',         // LIKE '%hat'
      [Op.notLike]: '%hat',       // NOT LIKE '%hat'
      [Op.iLike]: '%hat',         // ILIKE '%hat' (case insensitive)  (PG only)
      [Op.notILike]: '%hat',      // NOT ILIKE '%hat'  (PG only)
      [Op.overlap]: [1, 2],       // && [1, 2] (PG array overlap operator)
      [Op.contains]: [1, 2],      // @> [1, 2] (PG array contains operator)
      [Op.contained]: [1, 2],     // <@ [1, 2] (PG array contained by operator)
      [Op.any]: [2,3]            // ANY ARRAY[2, 3]::INTEGER (PG only)
    },
    status: {
      [Op.not]: false           // status NOT FALSE
    }
  }
})

조건 연산자를 위한 구체적인 범위를 전달할 때, 값이 여러 개일 경우 '배열'을 사용한다는 것에 유의하세요.
PG 배열은 PostgreSQL만을 위한 것으로, 여기에서는 설명을 생략합니다.

OR와 NOT을 위한 복잡한 필터 쿼리문

중첩된 AND, OR, NOT 조건 등을 사용하여 여러 단계에 걸친 복잡한 where 쿼리문을 작성하는 것도 가능합니다. 이를 위하여 or, and, not 연산자를 사용할 수 있습니다.

Project.findOne({
  where: {
    name: 'a project',
    [Op.or]: [
      { id: [1,2,3] },
      { id: { [Op.gt]: 10 } }
    ]
  }
})

Project.findOne({
  where: {
    name: 'a project',
    id: {
      [Op.or]: [
        [1,2,3],
        { [Op.gt]: 10 }
      ]
    }
  }
})

위의 두 코드는 동일하게 아래의 코드로 변환됩니다:

SELECT *
FROM `Projects`
WHERE (
  `Projects`.`name` = 'a project'
   AND (`Projects`.`id` IN (1,2,3) OR `Projects`.`id` > 10)
)
LIMIT 1;

not의 경우:

Project.findOne({
  where: {
    name: 'a project',
    [Op.not]: [
      { id: [1,2,3] },
      { array: { [Op.contains]: [3,4,5] } }
    ]
  }
});

아래의 코드로 변환됩니다:

SELECT *
FROM `Projects`
WHERE (
  `Projects`.`name` = 'a project'
   AND NOT (`Projects`.`id` IN (1,2,3) OR `Projects`.`array` @> ARRAY[3,4,5]::INTEGER[])
)
LIMIT 1;

limit, offset, order, group으로 데이터 집합을 조작하기

limit, offset, order, group을 사용하여 보다 연관성이 높은 데이터를 얻을 수 있습니다.

// 반환되는 항목의 개수 제한
Project.findAll({ limit: 10 })

// 첫 10개 항목은 반환받지 않고 넘긴다
Project.findAll({ offset: 10 })

// 첫 10개 항목은 반환받지 않고 넘긴 뒤, 2개 항목만 반환한다
Project.findAll({ offset: 10, limit: 2 })

// ORDER BY title DESC
Project.findAll({order: 'title DESC'})

// GROUP BY name
Project.findAll({group: 'name'})

위에서 마지막 2개 예제의 경우, 조건으로 전달되는 스트링은 이스케이프 되지 않고 그대로 전달됩니다. order 또는 group을 위하여 스트링을 전달할 때는 항상 이와 같이 그대로 전달됩니다. 컬럼 이름을 이스케이프하고 싶다면, 컬럼 하나만 전달할지라도 반드시 인자 목록으로 전달해야 합니다.

위의 코드는 스트링 그대로 전달되지만, 아래의 코드에서는 모두 백틱에 감싸져서 전달되고 있습니다. SQL 함수를 사용하는 경우에는 이것이 필요하겠죠?

something.findOne({
  order: [
    // `name` 으로 반환된다
    ['name'],
    // `username` DESC 으로 반환된다
    ['username', 'DESC'],
    // max(`age`) 으로 반환된다
    sequelize.fn('max', sequelize.col('age')),
    // max(`age`) DESC 으로 반환된다
    [sequelize.fn('max', sequelize.col('age')), 'DESC'],
    // otherfunction(`col1`, 12, 'lalala') DESC 으로 반환된다
    [sequelize.fn('otherfunction', sequelize.col('col1'), 12, 'lalala'), 'DESC'],
    // otherfunction(awesomefunction(`col`)) DESC 으로 반환된다. 이러한 중첩은 계속 일어날 수 있습니다.
    [sequelize.fn('otherfunction', sequelize.fn('awesomefunction', sequelize.col('col'))), 'DESC']
  ]
})

order/group을 사용할 때에 배열의 요소는 다음과 같이 변환됩니다:

  • String: 따옴표로 감싸집니다.
  • 배열: 첫번째 요소는 따옴표로 감싸지고, 두번째부터는 첫번째 요소 뒤에 스트링 그대로 붙어서 반환됩니다.
  • 객체(모델): Raw 형태의 모델이 따옴표 없이 포함됩니다. Raw 형태가 아닌 경우, 쿼리문은 실행에 실패합니다.
  • Sequelize.fn는 함수, Sequelize.col은 따옴표로 감싼 컬럼 이름을 반환합니다.

Raw 형태가 무엇인지는 다음 섹션에서 설명합니다.
위의 예시에서는 order나 group에서 객체가 사용되는 경우가 없는데, 아래의 Eager loading된 연결 모델을 정렬하기 부분에서 사용례가 등장합니다.

Raw Query

별다른 조작 없이 거대한 크기의 데이터셋을 그대로 받고 싶을 수도 있습니다. Sequelize는 사용자가 select한 각각의 레코드를 인스턴스로 만들고, 유틸리티 함수 - update, delete, get- 메서드 등 - 를 부여합니다. 레코드가 수천 개라면, 시간이 좀 걸리겠지요. 단지 데이터만 필요할 뿐, 이러한 유틸리티 메서드가 필요하지 않다면 아래와 같은 방법을 사용하면 됩니다.

// DB로부터 대형 사이즈의 데이터셋을 받아야 하는데, 각 요소를 위한 DAO(데이터 접근 객체)를 만드는 데에 시간 쓰기 싫으신가요?
// 데이터 자체만을 받아오기 위한 옵션이 있습니다.
Project.findAll({ where: { ... }, raw: true })

count - 요소에 대한 빈도 측정

데이터베이스 내의 항목 개수를 재는 메서드입니다.

Project.count().then(c => {
  console.log("There are " + c + " projects!")
})

Project.count({ where: {'id': {[Op.gt]: 25}} }).then(c => {
  console.log("There are " + c + " projects with an id greater than 25.")
})

max - 특정 테이블 내에서 특정 컬럼에 대하여 최대값을 구하기

특정 컬럼에 대하여 최대값을 찾는 메서드입니다.

/*
  각자의 나이를 가진 3개의 person 객체를 가정합니다:
  각각은 10세, 5세, 40세입니다.
*/
Project.max('age').then(max => {
  // 40을 반환합니다
})

Project.max('age', { where: { age: { [Op.lt]: 20 } } }).then(max => {
  // 10을 반환합니다
})

min - 특정 테이블 내에서 특정 컬럼에 대하여 최소값을 구하기

특정 컬럼에 대하여 최소값을 찾는 메서드입니다.

/*
  각자의 나이를 가진 3개의 person 객체를 가정합니다:
  각각은 10세, 5세, 40세입니다.
*/
Project.min('age').then(max => {
  // 5을 반환합니다
})

Project.min('age', { where: { age: { [Op.gt]: 5 } } }).then(min => {
  // 10을 반환합니다
})

sum - 특정 컬럼에 대하여 각 레코드들의 총합을 구하기

테이블의 특정 컬럼에 대한 총합을 계산하는 메서드입니다.

/*
  각자의 나이를 가진 3개의 person 객체를 가정합니다:
  각각은 10세, 5세, 40세입니다.
*/
Project.sum('age').then(max => {
  // 55을 반환합니다
})

Project.sum('age', { where: { age: { [Op.gt]: 5 } } }).then(min => {
  // 50을 반환합니다
})

Eager loading

쿼리문을 통하여 어떤 데이터를 불러올 때, 해당 데이터와 연결된 다른 모델의 데이터를 불러와야할 때도 있습니다. 이것을 Eager loading 이라고 부릅니다. find 또는 findAll 을 호출할 때에 include 특성을 사용하면 됩니다. 이번 예제를 위하여 다음과 같이 설정합니다:

const User = sequelize.define('user', { name: Sequelize.STRING })
const Task = sequelize.define('task', { name: Sequelize.STRING })
const Tool = sequelize.define('tool', { name: Sequelize.STRING })

Task.belongsTo(User)
User.hasMany(Task)
User.hasMany(Tool, { as: 'Instruments' })

sequelize.sync().then(() => {
  // 여기서부터 코드를 작성합니다
})

아래에서는 3개 테이블에 각각 데이터가 들어있다고 가정하고 설명하고 있습니다. 직접 데이터를 넣어줄 수도 있겠지만, 저는 Sequelize 문법을 사용해서 넣어봤습니다.

sequelize.sync()
  .then(() => { Task.create({ name: 'A Task' })})
  .then(() => { Tool.create({ name: 'Toothpick' })})
  .then(() => {
    return User.create({ name: 'John Doe' })})
  .then( user => {
    return Task.findOne({ name: 'A Task' })
  .then( task => {
    user.addTasks(task)
    return user })})
  .then( user => { Tool.findOne({ name: 'Toothpick' })
  .then((tool) => {
    user.addInstruments(tool)
    return user})})

User와 연결된 모든 Task를 가져옵니다:

Task.findAll({ include: [ User ] }).then(tasks => {
  console.log(JSON.stringify(tasks))

  /*
    [{
      "name": "A Task",
      "id": 1,
      "createdAt": "2013-03-20T20:31:40.000Z",
      "updatedAt": "2013-03-20T20:31:40.000Z",
      "userId": 1,
      "user": {
        "name": "John Doe",
        "id": 1,
        "createdAt": "2013-03-20T20:31:45.000Z",
        "updatedAt": "2013-03-20T20:31:45.000Z"
      }
    }]
  */
})

여기서 반환된 인스턴스(tasks)에 포함된 user 속성은 접근자(accessor)라고 하는데, 접근자가 단수(singular)인 점에 유의하세요. 형성된 연걸 관계가 일대다 이기 때문에 그렇습니다.

여기서 '일'은 User, '다'은 Task입니다. 아래 예시에서도 그대로 확인합니다.

다음으로는 다대일 관계를 갖는 데이터를 가져옵니다:

User.findAll({ include: [ Task ] }).then(users => {
  console.log(JSON.stringify(users))

  /*
    [{
      "name": "John Doe",
      "id": 1,
      "createdAt": "2013-03-20T20:31:45.000Z",
      "updatedAt": "2013-03-20T20:31:45.000Z",
      "tasks": [{
        "name": "A Task",
        "id": 1,
        "createdAt": "2013-03-20T20:31:40.000Z",
        "updatedAt": "2013-03-20T20:31:40.000Z",
        "userId": 1
      }]
    }]
  */
})

여기서 users에 포함된 tasks는 복수(plural)입니다. 형성된 관계가 다대일 이기 떄문입니다.

연결 관계에 as 옵션을 사용하여 별칭(alias)이 존재한다면, 모델을 include 할 때에 이를 반드시 명시해야 합니다. 아래의 예시를 보면서, User의 Tool이 Instruments라는 별칭으로 불러지는지 확인하세요. 코드가 제대로 실행되려면, 불러오고자 하는 모델과 별칭을 제대로 지정해야 합니다.

User.findAll({ include: [{ model: Tool, as: 'Instruments' }] }).then(users => {
  console.log(JSON.stringify(users))

  /*
    [{
      "name": "John Doe",
      "id": 1,
      "createdAt": "2013-03-20T20:31:45.000Z",
      "updatedAt": "2013-03-20T20:31:45.000Z",
      "Instruments": [{ // 연결된 모델 이름이 달라졌다
        "name": "Toothpick",
        "id": 1,
        "createdAt": null,
        "updatedAt": null,
        "userId": 1
      }]
    }]
  */
})

include 옵션에 연결 관계를 가리키는 별칭 을 인자로 포함시키는 식으로 사용할 수도 있습니다.

User.findAll({ include: ['Instruments'] }).then(users => {
  console.log(JSON.stringify(users))
// User와 Tool 간에 형성된 연결 관계에서 연결된 모델에 대한 별칭이 쓰였겠죠?
  /*
    [{
      "name": "John Doe",
      "id": 1,
      "createdAt": "2013-03-20T20:31:45.000Z",
      "updatedAt": "2013-03-20T20:31:45.000Z",
      "Instruments": [{
        "name": "Toothpick",
        "id": 1,
        "createdAt": null,
        "updatedAt": null,
        "userId": 1
      }]
    }]
  */
})

User.findAll({ include: [{ association: 'Instruments' }] }).then(users => {
  console.log(JSON.stringify(users))
// association 옵션으로 연결된 모델에 대한 별칭을 전달하는 것도 가능합니다
  /*
    [{
      "name": "John Doe",
      "id": 1,
      "createdAt": "2013-03-20T20:31:45.000Z",
      "updatedAt": "2013-03-20T20:31:45.000Z",
      "Instruments": [{
        "name": "Toothpick",
        "id": 1,
        "createdAt": null,
        "updatedAt": null,
        "userId": 1
      }]
    }]
  */
})

Eager loading을 할 때에 where 문을 사용하여 연결 모델을 필터링하는 것도 가능합니다. 아래의 예시에서는 User의 항목들을 모두 반환하되, Tool 모델에서 where 문을 만족하는 항목이 연결된 경우만 반환합니다:

User.findAll({
    include: [{
        model: Tool,
        as: 'Instruments',
        where: { name: { [Op.like]: '%ooth%' } }
    }]
}).then(users => {
    console.log(JSON.stringify(users))

    /*
      [{
        "name": "John Doe",
        "id": 1,
        "createdAt": "2013-03-20T20:31:45.000Z",
        "updatedAt": "2013-03-20T20:31:45.000Z",
        "Instruments": [{
          "name": "Toothpick",
          "id": 1,
          "createdAt": null,
          "updatedAt": null,
          "userId": 1
        }]
      }],

      [{
        "name": "John Smith",
        "id": 2,
        "createdAt": "2013-03-20T20:31:45.000Z",
        "updatedAt": "2013-03-20T20:31:45.000Z",
        "Instruments": [{
          "name": "Toothpick",
          "id": 1,
          "createdAt": null,
          "updatedAt": null,
          "userId": 1
        }]
      }],
    */
  })

Eager loading으로 불러온 모델은 include.where을 통하여 1차적으로 필터링되고, include.required는 암묵적으로 true로 설정됩니다. 그말인즉슨 연결된 모델에서 조건에 맞는 항목들을 가져올 때에 INNER JOIN이 이루어집니다.

Eager loading 된 모델로 상위 단계의 where 사용하기

SQL에서 JOIN을 할 때에는 ON을 사용하여 연결된 테이블에 대한 필터링을 진행합니다.

연결 모델을 필터링하는 데에 사용된 조건(ON)을 상위 WHERE로 옮기려면, '$nested.column$' 문법을 사용합니다.

User.findAll({
    where: {
        '$Instruments.name$': { [Op.iLike]: '%ooth%' }
    },
    include: [{
        model: Tool,
        as: 'Instruments'
    }]
}).then(users => {
    console.log(JSON.stringify(users));

    /*
      [{
        "name": "John Doe",
        "id": 1,
        "createdAt": "2013-03-20T20:31:45.000Z",
        "updatedAt": "2013-03-20T20:31:45.000Z",
        "Instruments": [{
          "name": "Toothpick",
          "id": 1,
          "createdAt": null,
          "updatedAt": null,
          "userId": 1
        }]
      }],

      [{
        "name": "John Smith",
        "id": 2,
        "createdAt": "2013-03-20T20:31:45.000Z",
        "updatedAt": "2013-03-20T20:31:45.000Z",
        "Instruments": [{
          "name": "Toothpick",
          "id": 1,
          "createdAt": null,
          "updatedAt": null,
          "userId": 1
        }]
      }],
    */
  })

전부 include하기

연결된 모델의 모든 컬럼을 포함한 결과를 얻으려면, include 옵션에 all: true 만 전달하면 됩니다.

User.findAll({ include: [{ all: true }]})
  .then(users => {
    console.log(JSON.stringify(users))
  });

/*
  [{
    "id":1,
    "name":"John Doe",
    "createdAt":"2019-01-05T05:55:17.000Z",
    "updatedAt":"2019-01-05T05:55:17.000Z",
    "tasks":
      [{"id":1,
        "name":"A Task",
        "createdAt":"2019-01-05T05:55:17.000Z",
        "updatedAt":"2019-01-05T05:55:17.000Z",
        "userId":1}],
    "Instruments":
      [{"id":1,
        "name":"Toothpick",
        "createdAt":"2019-01-05T05:55:17.000Z",
        "updatedAt":"2019-01-05T05:55:17.000Z",
        "userId":1}]
  }]
*/

Soft 삭제된 레코드 포함하기

연결된 모델에서 Soft 삭제된 레코드까지 포함하려면, include 옵션에 paranoid: false를 전달하면 됩니다.

User.findAll({
    include: [{
        model: Tool,
        where: { name: { [Op.like]: '%ooth%' } },
        paranoid: false
    }]
});

Eager loading된 연결 모델을 정렬하기

일대다 연결 관계의 경우 활용할 수 있는 내용입니다.

Company.findAll({ include: [ Division ], order: [ [ Division, 'name' ] ] });
Company.findAll({ include: [ Division ], order: [ [ Division, 'name', 'DESC' ] ] });
Company.findAll({
  include: [ { model: Division, as: 'Div' } ],
  order: [ [ { model: Division, as: 'Div' }, 'name' ] ]
});
Company.findAll({
  include: [ { model: Division, as: 'Div' } ],
  order: [ [ { model: Division, as: 'Div' }, 'name', 'DESC' ] ]
});
Company.findAll({
  include: [ { model: Division, include: [ Department ] } ],
  order: [ [ Division, Department, 'name' ] ]
});

include의 인자를 보면 배열 내에 단일 요소만 전달할 때와 객체로 전달할 때가 다른 것을 알 수 있습니다. JOIN할 모델만 명시하고자 할 때는 모델 이름만을 단일 요소로 전달합니다. 반면 JOIN할 모델에 대한 별칭, 기타 JOIN할 모델에 대한 설정을 전달하려면 객체를 사용하여 다같이 작성해주면 됩니다.

include에 전달해주는 여러 속성값들은 마치 sequelize.define을 해줄 때와 유사하게 임시적인 세팅값을 전달해준다고 볼 수 있습니다. Subquery를 직관적으로 떠올리시면 이해가 더 빠를 듯 합니다.

JOIN은 여러번 수행할 수 있습니다. 5번째 findAll()을 보면, include 안에 include를 또 사용하였죠. 여기서 Department 모델은 Division 모델과 연결을 형성하고, Division 모델은 Company와 연결 관계를 형성합니다. 만약 연결 관계가 없는데 이러한 코드를 시도하면 오류가 발생합니다.

이곳에서 사용된 order의 문법은 다음 글에서 다루고 있습니다. 궁금하신 분들은 먼저 보고 오세요.

다대다 연결 관계의 경우, through 테이블 내의 컬럼으로 정렬할 수도 있습니다.

Company.findAll({
  include: [ { model: Division, include: [ Department ] } ],
  order: [ [ Division, DepartmentDivision, 'name' ] ]
});

중첩된 Eager loading

연결된 모델의 모든 연결된 모델을 불러오기 위하여 중첩된 Eager loading을 할 수 있습니다.

User.findAll({
  include: [
    {model: Tool, as: 'Instruments', include: [
      {model: Teacher, include: [ /* etc */]}
    ]}
  ]
}).then(users => {
  console.log(JSON.stringify(users))

  /*
    [{
      "name": "John Doe",
      "id": 1,
      "createdAt": "2013-03-20T20:31:45.000Z",
      "updatedAt": "2013-03-20T20:31:45.000Z",
      "Instruments": [{ // 1:M and N:M association
        "name": "Toothpick",
        "id": 1,
        "createdAt": null,
        "updatedAt": null,
        "userId": 1,
        "Teacher": { // 1:1 association
          "name": "Jimi Hendrix"
        }
      }]
    }]
  */
})

위의 코드는 OUTER JOIN의 결과를 만듭니다. 그런데 연결된 모델에서 where문을 사용할 경우 INNER JOIN을 수행하게 되고, 조건에 부합하는 서브 모델의 항목만을 반환할 것입니다. 모든 항목을 반환하려면, required: false 옵션을 추가합니다.

User.findAll({
  include: [{
    model: Tool,
    as: 'Instruments',
    include: [{
      model: Teacher,
      where: {
        school: "Woodstock Music School"
      },
      required: false
    }]
  }]
}).then(users => {
  /* ... */
})

위의 쿼리문은 모든 User, 그리고 각 User가 가진 모든 Instrument를 반환하지만, Teacher의 경우 Woodstock Music School에 소속된 것만 표시될 것입니다.

all 옵션과 함께 nested 속성을 사용하는 것도 가능합니다.

User.findAll({ include: [{ all: true, nested: true }]});

코멘트

Sequelize 도큐먼트는 썩 친절하지 않다고 느껴집니다. 제공하는 기능이 많기에 도큐먼트가 방대하겠지만, 모든 기능을 잘 알려줄 정도로 도큐먼트가 충실하다는 인상은 아닙니다. 도큐먼트 뒷부분에서 등장하는 내용을 별다른 설명없이 뜬금포로 사용하여 앞부분 내용을 설명하기도 하는 통에, 저처럼 답답하면 진도를 못 빼는 성격으로는 정말 고통스러운 설명 스타일입니다.

SQL을 알더라도 용어를 묘하게 다르게 쓰다보니 헷갈리는 것도 문제입니다. 이게 이해를 헷갈리게 만드는 경우가 너무 많은 듯 합니다.

원래는 Association 부분만 읽어보려고 했는데, 정리가 제대로 안 되서 그냥 처음부터 쭉 읽어보고 정리해보기로 했습니다.

그 밖에 번역하다가 생각난 점들

  • ORM은 결국 DAO를 자바스크립트 개발자에게 제공하는 것. 그래서 Sequelize가 제공하는 모든 데이터는 DAO로서 래핑된 것들이다. 한 데이터를 열람하더라도 그와 연관된 다양한 메서드와 데이터를 부가적으로 알아서 제공해준다. includeas는 그런 때에 사용되는 문법. 게다가 한 테이블은 다른 테이블과 서로 연결된 경우가 흔하므로... 이러한 의도와 설계를 이해하고 공부하면 좀 더 이해가 직관적이고 명확할 것이다.

  • 이래저래 Sequelize는 postgreSQL을 밀어주는 ORM처럼 보인다. 전용 문법이 꽤 있다...

다음 편은 5장 Querying 으로 이어집니다.