들어가며
사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. 그 과정에서 생각하고 배웠던 점들을 하나씩 작성하고자 합니다. 먼저 NestJS와 TypeORM을 활용해서 개발을 하고 있는데, 왜 TypeORM을 활용하는지, 다른 ORM을 사용하면 더 장점은 없는지에 대해 알아야 더 좋은 서비스를 만들 수 있다고 생각했습니다. 지금이라도 Node.js의 ORM에 대해 이해하기 위해 글을 작성합니다.
ORM이란?
ORM은 사물을 추상화시켜 이해하려는 OOP적 사고방식과 Data Model을 정형화하여 관리하려는 RDB 사이를 연결할 계층의 역할로 제시된 패러다임으로 RDB의 모델을 OOP에 Entity 형태로 투영시키는 방식을 사용합니다. 즉, Object와 Database 간에 연결 역할을 해주는 것이 ORM입니다.
그럼 ORM에 대해 조금 더 자세하게 알아보겠습니다.
ORM과 NodeJS 추상화 계층
ORM을 알아보기 전 추상화 계층을 살펴보겠습니다.
저수준: 데이터베이스 드라이버
데이터베이스 드라이버는 데이터베이스에 대한 연결을 처리합니다. 이 레벨에서는 raw SQL 쿼리를 작성하여 데이터베이스에 넘기고, 데이터베이스로 부터 응답을 받게 됩니다. NodeJS의 생태계에서는 이러한 역할을 하는 많은 라이브러리가 있습니다.
- https://github.com/mysqljs/mysql
- https://github.com/brianc/node-postgres
- https://github.com/mapbox/node-sqlite3
각 라이브러리는 기본적으로 동일한 방식으로 동작합니다.
#!/usr/bin/env node
// $ npm install pg
const { Client } = require('pg')
const connection = require('./connection.json')
const client = new Client(connection)
client.connect()
const query = `SELECT
ingredient.*, item.name AS item_name, item.type AS item_type
FROM
ingredient
LEFT JOIN
item ON item.id = ingredient.item_id
WHERE
ingredient.dish_id = $1`
client.query(query, [1]).then((res) => {
console.log('Ingredients:')
for (let row of res.rows) {
console.log(`${row.item_name}: ${row.quantity} ${row.unit}`)
}
client.end()
})
데이터베이스 인증정보를 가져오고, 새 데이터베이스 인스턴스를 만들고, 연결하고, 문자열 형식으로 쿼리를 전송하고, 결과를 비동기적으로 처리합니다.
중간 수준: 쿼리 빌더
이는 데이터베이스 드라이버 모듈을 사용하는 것과 완전한 ORM을 사용하는 것 사이의 중간 정도 수준입니다. 작성하는 쿼리는 기본적으로 SQL 쿼리와 유사합니다. 한 가지 좋은 점은 문자열을 연결해서 SQL 쿼리를 만드는 것보다는 더 편리한 방식으로 동적 쿼리를 프로그래밍 방식으로 생성할 수 있다는 것입니다. (밑의 예시는 Knex를 활용한 쿼리 빌더 구조입니다.)
#!/usr/bin/env node
// $ npm install pg knex
const knex = require('knex')
const connection = require('./connection.json')
const client = knex({
client: 'pg',
connection,
})
client
.select([
'*',
client.ref('item.name').as('item_name'),
client.ref('item.type').as('item_type'),
])
.from('ingredient')
.leftJoin('item', 'item.id', 'ingredient.item_id')
.where('dish_id', '=', 1)
.debug()
.then((rows) => {
console.log('Ingredients:')
for (let row of rows) {
console.log(`${row.item_name}: ${row.quantity} ${row.unit}`)
}
client.destroy()
})
고수준: ORM
이제 우리가 고려할 가장 높은 수준의 추상화입니다. 일반적으로 ORM을 사용할 때는 사전에 설정을 하는데 많은 시간을 쏟아야 합니다. ORM의 요점은, 이름에서 알 수 있는 것처럼 관계형 데이터 베이스의 데이터를 애플리케이션의 객체에 매핑하는 것입니다. 따라서 애플리케이션 코드에서 이러한 객체의 구조와 관계를 정의해야 합니다. (다음은 Sequelize를 활용해서 구조와 관계를 정의하는 예시입니다.)
#!/usr/bin/env node
// $ npm install sequelize pg
const Sequelize = require('sequelize')
const connection = require('./connection.json')
const DISABLE_SEQUELIZE_DEFAULTS = {
timestamps: false,
freezeTableName: true,
}
const { DataTypes } = Sequelize
const sequelize = new Sequelize({
database: connection.database,
username: connection.user,
host: connection.host,
port: connection.port,
password: connection.password,
dialect: 'postgres',
operatorsAliases: false,
})
const Dish = sequelize.define(
'dish',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING },
veg: { type: DataTypes.BOOLEAN },
},
DISABLE_SEQUELIZE_DEFAULTS,
)
const Item = sequelize.define(
'item',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING },
type: { type: DataTypes.STRING },
},
DISABLE_SEQUELIZE_DEFAULTS,
)
const Ingredient = sequelize.define(
'ingredient',
{
dish_id: { type: DataTypes.INTEGER, primaryKey: true },
item_id: { type: DataTypes.INTEGER, primaryKey: true },
quantity: { type: DataTypes.FLOAT },
unit: { type: DataTypes.STRING },
},
DISABLE_SEQUELIZE_DEFAULTS,
)
Item.belongsToMany(Dish, {
through: Ingredient,
foreignKey: 'item_id',
})
Dish.belongsToMany(Item, {
through: Ingredient,
foreignKey: 'dish_id',
})
Dish.findOne({ where: { id: 1 }, include: [{ model: Item }] }).then((rows) => {
console.log('Ingredients:')
for (let row of rows.items) {
console.log(
`${row.dataValues.name}: ${row.ingredient.dataValues.quantity} ` +
row.ingredient.dataValues.unit,
)
}
sequelize.close()
})
ORM 활용의 장단점
ORM을 사용하지 않을 경우 개발자가 개발을 위해 Database에 접근하기 위해서는 SQL Query문을 직접 만들어야 합니다. 이를 위해 SQL 문법을 잘 알고 있어야 하며, 쿼리문 작성 시간이 오래 걸려서 개발이 지체되는 문제가 발생했습니다.
ORM을 활용하면 중복 코드를 방지할 수 있고, 다른 데이터베이스로 쉽게 교체도 가능하며, 여러 테이블에 쉽게 쿼리를 날릴 수 있고, 인터페이스를 작성하는 시간을 아껴 비즈니스 로직에 집중할 수 있다는 장점이 있습니다. 하지만 ORM을 활용하면 여러 단점들도 있습니다.
1. SQL이 아닌 ORM 자체를 배우게 된다.
사람들은 SQL은 배우기 어렵고, ORM을 배우면 하나의 언어만 사용하여 애플리케이션을 작성할 수 있다는 믿음을 갖곤 합니다. 이러한 사고방식에는 문제가 있습니다. ORM은 꽤 복잡한 라이브러리 중 하나입니다. ORM을 사용하기 위해서 배워야 할 것은 많으며, 이를 배우는 것은 결코 쉬운 일이 아닙니다.
그리고 특정 ORM에 익숙해져 버리면, 다른 ORM 사용을 원활하게 하지 못할 수도 있습니다. 이는 마치 JS/Node.js에서 C#/.NET 환경으로 오는 것과 유사한 기분이 들 수 있습니다.
Node.js에는 수많은 ORM이 있고, 또 모든 플랫폼에 대해 수백 개의 ORM이 존재합니다. 이를 다 배운다는 것은 악몽과도 같습니다. 그러나 SQL 구문을 배운다면 이런 걱정을 할 필요가 없습니다. SQL을 사용하여 쿼리를 생성하는 방법을 배우게 되면, 이 하나의 지식을 여러 플랫폼 사이에서 공유할 수 있습니다.
2. ORM 호출은 비효율적일 수 있다.
ORM의 본래 목적은 데이터베이스에 저장된 기본 데이터를, 특정 애플리케이션 내에서 상호작용할 수 있는 객체에 매핑하는 것입니다. 따라서 ORM을 사용하여 데이터를 가져올 때 몇 가지 비효율성이 발생하게 됩니다.
아래 추상화 레벨별 예제를 살펴보겠습니다.
1. pg 드라이버 사용
이 방법에서는 쿼리를 직접 손으로 쓰면 됩니다. 이는 우리가 원하는 데이터를 얻을 수 있는 가장 간결한 방법입니다.
SELECT
ingredient.*, item.name AS item_name, item.type AS item_type
FROM
ingredient
LEFT JOIN
item ON item.id = ingredient.item_id
WHERE
ingredient.dish_id = ?;
만약 저수준의 드라이버를 활용한다면 간단한 쿼리라도, 동적 쿼리를 생성하는 경우 매우 귀찮아질 수 있습니다. 사용자가 특정 기준에 따라 데이터를 가져오는 예제를 상상해보겠습니다.
SELECT * FROM things WHERE color = ?;
그러나 옵션이 다양해지면 아래와 같이 복잡해집니다.
SELECT * FROM things; -- Neither
SELECT * FROM things WHERE color = ?; -- Color only
SELECT * FROM things WHERE is_heavy = ?; -- Is Heavy only
SELECT * FROM things WHERE color = ? AND is_heavy = ?; -- Both
2. 쿼리 빌더 사용
앞선 예와 마찬가지로 복잡한 쿼리를 다룰 때 일부 사소한 형식과 불필요한 몇 가지를 제외하면 동일합니다.
(Knex 쿼리 빌더 사용 예)
select
*, "item"."name" as "item_name", "item"."type" as "item_type"
from
"ingredient"
left join
"item" on "item"."id" = "ingredient"."item_id"
where
"dish_id" = ?;
3. Sequelize ORM
이제 ORM으로 생성한 쿼리를 살펴보겠습니다.
SELECT
"dish"."id", "dish"."name", "dish"."veg", "items"."id" AS "items.id",
"items"."name" AS "items.name", "items"."type" AS "items.type",
"items->ingredient"."dish_id" AS "items.ingredient.dish_id",
"items->ingredient"."item_id" AS "items.ingredient.item_id",
"items->ingredient"."quantity" AS "items.ingredient.quantity",
"items->ingredient"."unit" AS "items.ingredient.unit"
FROM
"dish" AS "dish"
LEFT OUTER JOIN (
"ingredient" AS "items->ingredient"
INNER JOIN
"item" AS "items" ON "items"."id" = "items->ingredient"."item_id"
) ON "dish"."id" = "items->ingredient"."dish_id"
WHERE
"dish"."id" = ?;
이 쿼리는 앞선 쿼리와 많이 다릅니다. 앞서 정의한 관계 때문에 Sequelize는 요청한 것보다 더 많은 정보를 얻으려고 합니다.
3. ORM이 만능은 아니다.
일부 쿼리는 ORM 작업으로 표현할 수 없습니다. 이러한 쿼리를 생성하는 경우에는 SQL 쿼리를 직접 생성하는 작업으로 회귀해야 합니다. 이는 ORM을 사용하는 와중에도 코드 베이스에 여전히 하드 코딩된 쿼리가 존재할 수 있다는 것을 의미합니다. 이러한 프로젝트를 개발하는 개발자는 ORM이나 SQL 구문 모두를 알아야 합니다.
ORM으로 표현할 수 없는 쿼리에는 쿼리에 서브 쿼리가 포함된 경우입니다.
SELECT *
FROM item
WHERE
id NOT IN
(SELECT item_id FROM ingredient WHERE dish_id = 2)
AND id IN
(SELECT item_id FROM ingredient WHERE dish_id = 1);
이 쿼리는 앞서 언급한 ORM을 사용하여 명확하게 나타낼 수 없습니다. 이러한 상황에 대처하기 위해 ORM에서는 쿼리 인터페이스에 로우 쿼리 문자열을 주입하는 기능을 제공하는 것이 일반적입니다.
Sequelize의 경우에는 로우 쿼리문을 실행하는 .query() 메서드를 제공합니다. Knex 객체에는 로우 쿼리를 실행하는 .raw() 메서드도 있습니다. 즉 여전히 특정 쿼리를 사용하기 위해서는 SQL을 이해해야 합니다.
어떤 ORM을 활용할까?
저는 프로젝트에서 ORM을 활용해야겠다고 생각했습니다. 먼저, 프로젝트의 특성상 조회 쿼리가 많을 것으로 예상되는데, 조회에 특화된 데이터베이스로의 변경도 있을 수 있는 바, ORM을 활용하면 데이터베이스의 변경이 효율적일 것이라고 판단했고, 그 결과 ORM을 사용해야겠다고 생각했습니다. 그 후, 저는 어떤 ORM을 활용할 것이며, 어떻게 활용할 것인가에 대해 생각해야 했습니다. 먼저 NodeJS에는 다양한 ORM이 존재하는데, 그중에서 어떤 ORM을 활용하면 좋을까 생각했습니다.
먼저 TypeORM, Sequelize ORM을 두고 생각했습니다. 많은 기업이나 프로젝트에서 많이 활용하는 ORM이었기에, 분명 각각의 장단점이 있을 것이라 생각했고, 이 두 가지를 비교 분석해보면 좋겠다고 생각했습니다.
1. 마이그레이션
마이그레이션 관점에서 TypeORM, Sequelize를 비교해보면, 먼저 sequelize에서 제공하는 마이그레이션은 아쉽게도 TypeORM에서 제공하는 entities의 변화를 자동 감지해서 마이그레이션 하는 기능은 가지고 있지 않습니다. sequelize의 migration:generate 커맨드는 TypeORM의 migration:create와 같습니다.
반대로 TypeORM에서는 되지 않는 migration:revert:all을 sequelize에서는 db:migrate:undo:all을 사용해서 모든 마이그레이션 파일들의 down 메서드를 실행할 수 있습니다.
sequelize는 seeding을 cli에서 지원해줘서 정해진 인터페이스에 맞는 데이터들만 up, down 메서드에 아래와 같이 집어넣어 주면 손쉽게 사용할 수 있습니다.
import People from 'src/seeders/People'
export default {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('People', People)
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('People', null, {})
}
}
반면에 TypeORM을 사용할 때 seeding을 하려면 커넥션을 직접 연 다음 아래처럼 구현해야 하는 불편함이 있습니다. 물론 seeding을 위한 라이브러리가 있기 때문에, 큰 문제는 없지만, 라이브러리를 설치해서 활용해야 하는 불편함은 여전히 존재합니다.
export default async function seedPeople(numFake = 10) {
const entities = await Promise.all([Array(numFake).fill(0).map(fakeUser)])
await People.insert(entities)
}
2. N+1 문제
N+1 문제를 다루는 방법 또한 TypeORM과 Sequelize에서 차이가 있습니다.
먼저 TypeORM의 경우 스키마 선언부에서 eager loading과 lazy loading의 적용 여부를 결정할 수 있습니다.
// src/entities/People.ts
@ManyToOne(type => Company, { eager: true })
@JoinColumn()
company: Company
Sequelize는 find* 메서드에 옵션으로 include를 아래처럼 추가해줘야 eager loading을 할 수 있습니다.
const people = await People.findOne({ include: Companies, where: { id: 1 } })
그럼 JOIN을 한 것과 같이 아래의 결과가 나옵니다.
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"city": "Berlin",
"company": {
"id": 1,
"department": "finance"
}
}
반대로 lazy loading의 경우 include 옵션을 사용하지 않으면 가능합니다.
3. 문법 비교
TypeORM과 Sequelize의 문법을 비교해보면 다음과 같습니다.
// 1. SQL
const query = "SELECT * FROM post WHERE authorId = 12 AND status = 'active'";
// Sequelize
models.Post.findAll({
where: { authorId: 12, status: 'active' }
})
// TypeORM
connection.getRepository(Post).find({ where: { authorId: 12, status: 'active' } })
// 2. SQL
const query = "select *
from category_page
where category_id = 5
and (show_at is null or show_at >= now())
and (hide_at is null or hide_at <= now())
order by updated_at desc
limit 1";
// Sequelize
models.CategoryPage.findOne({
where: {
category_id: 5,
show_at: {
[Op.or]: [
{ [Op.eq]: null },
{ [Op.gte]: now }
]
},
hide_at: {
[Op.or]: [
{ [Op.eq]: null },
{ [Op.lte]: now }
]
}
},
order: [['updated_at', 'DESC']]
})
// 참고 EQ("="), GTE(">="), GT(">"), LT("<"), LTE("<=");
// TypeORM
connection.getRepository(CategoryPage)
.createQueryBuilder()
.where("category_id = :categoryId", { categoryId: 5 })
.andWhere("(show_at is null or show_at >= now())" )
.andWhere("(hide_at is null or hide_at <= now())" )
.orderBy("updated_at", "DESC")
.limit(1)
.getMany()
함수 실행 속도 비교 (10번 실행한 결과의 평균 수치)
- | raw | typeORM | Sequelize.js |
Query 1 | 0.8 | 2.19 | 8.42 |
Query 2 | 1.51 | 3.58 | 4.02 |
Query 3 | 1.39 | 3.92 | 9.13 |
(출처 : https://kyungyeon.dev/posts/3)
TypeORM을 활용하면 추상화의 중간 수준인 쿼리 빌더를 활용해서 쿼리 메서드를 자유자재로 활용할 수 있지만, Sequelize의 경우 고수준의 ORM 메서드를 활용해야 한다는 점이 큰 차이점이었습니다. (아직까지 Sequelize로 쿼리 빌더를 활용할 수 있는지는 잘 모르겠습니다.) 이로 인해, Sequelize의 러닝 커브가 클 수 있겠다는 생각을 했습니다. 또한 성능면에서도 TypeORM과 Sequelize의 차이가 두드러졌습니다.
4. 선택한 ORM
ORM에 대해 간략하게 알아보고 비교 분석하면서, TypeORM을 사용하는 것이 프로젝트에 적절할 수 있겠다고 생각했습니다. 마이그레이션의 경우, revert 하는 경우보다 entities의 변화를 자동 감지해서 마이그레이션 하는 기능을 조금 더 많이 활용할 수 있겠다고 생각했습니다. 또한 Sequelize를 활용하면 러닝 커브가 높을 수 있다고 생각했고, TypeORM을 활용하면 SQL을 조금이라도 안다면 금방 활용할 수 있겠다고 판단했습니다. 그리고 마지막으로, TypeORM과 Sequelize의 성능이 차이가 있기에, TypeORM을 활용하는 것이 효과적일 수 있겠다고 생각했습니다.
마치며
앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다.
참고 및 출처
'Project > 서버 개발' 카테고리의 다른 글
[Project] 프로젝트 삽질기9 (feat Queue, bull) (1) | 2022.03.15 |
---|---|
[Project] 프로젝트 삽질기8 (feat ormConfig) (0) | 2022.03.15 |
[Project] 프로젝트 삽질기6 (feat PostgreSQL 버전) (0) | 2022.03.11 |
[Project] 프로젝트 삽질기5 (feat Seeding, Faker) (0) | 2022.03.11 |
[Project] 프로젝트 삽질기4 (feat 마이그레이션) (0) | 2022.03.10 |