본문 바로가기

javascript/nodejs

Sequelize : 런타임 코드 생성을 활용하는 ORM~

 Sequelize는 프로미스 기반의 ORM 으로, 일반적인 SQL 및 NoSQL 영역의 데이터베이스도 처리할 수 있다. 모델간의 관계를 지정 (Association) 하면 런타임에 코드가 생성되는 등 라이브러리 전반적으로 런타임 속성 할당이 많이 발생하기 때문에, 타입스크립트를 도입하여 사용하더라도 큰 메리트는 없으며, 모델간의 관계에 대응되는 함수들을 수동으로 declare 해야 하기 때문에 꽤 번거롭게 느껴진다. 물론 자동완성 기능이 크게 중요하지 않고, 자바스크립트 기반으로 ORM을 사용할 예정이라면 충분히 좋은 선택이 될 수 있다.

 현재 글에서는 Sequelize ORM을 타입스크립트 환경에서 사용하는 경우에 대해 정리한다.


https://github.com/blaxsior/web_study/tree/express_with_ts_mysql_sequelize

 

GitHub - blaxsior/web_study: 웹을 공부하면서 만든 코드를 정리합니다.

웹을 공부하면서 만든 코드를 정리합니다. Contribute to blaxsior/web_study development by creating an account on GitHub.

github.com

현재 프로젝트에서 사용한 코드가 담겨있는 깃허브 주소이다.


초기 설정

tsconfig.json

moduleResolution : node 로 설정한다. 만약 해당 옵션을 classic으로 설정하면, 프로젝트가 Sequelize 패키지를 제대로 인식하지 못한다.

npm을 통한 설치

npm install sequelize @types/node @types/validator

 sequelize 패키지 및 타입을 위한 패키지를 설치한다. 이때 sequelize의 타이핑을 위해 @types/sequelize의 설치를 고려할 수도 있지만, 기본 패키지 자체만으로도 충분하므로, 굳이 설치할 필요는 없다. 나의 환경에서는 해당 패키지의 설치가 sequelize 내 일부 기능에 대한 자동완성 기능을 막아, 오히려 사용하지 않는 편이 더 나았다.


데이터베이스를 Sequelize와 동기화

import { Sequelize } from 'sequelize';

export const sequelize = new Sequelize(
    process.env.DB_NAME!, // 데이터베이스의 이름
    process.env.DB_ID ?? "root", // 데이터베이스 유저 이름
    process.env.DB_PASSWORD ?? "", // 비밀번호
    { // 여러가지 설정을 정의할 수 있음
        host : '127.0.0.1', // 호스트의 IP 주소
        dialect: 'mysql', // 사용할 데이터베이스의 종류
        port: 3306 // 포트 넘버
    }
);

 sequelize에서 Sequelize을 import 한 후, 데이터베이스를 사용하기 위해 필요한 각종 정보들을 해당 클래스에 넘겨준다. 생성된 sequelize 객체는 데이터베이스 및 모델을 동기화하는데 사용된다.

import { sequelize as db } from './util/database.js';

const result = await db.sync();

이전에 생성된 sequelize 객체의 sync 메서드를 실행하면 설정된 정보를 기반으로 데이터베이스와 동기화를 시도한다. 동기화에 실패하면 에러를 발생시키므로, try / catch 을 이용한 적절한 에러처리가 필요하다.


모델의 생성

 Sequelize에서 모델은 실제 데이터베이스 상 테이블 구조와 대응된다. 이때 특정 모델이 데이터베이스 내 어떤 테이블과 대응해야 할지 (모델이 어떤 이름의 테이블과 대응될지), 모델이 어떤 칼럼(프로퍼티)를 가져야 할지를 지정해야 하여 모델을 생성하게 되는데, 모델 생성 방법에는 크게 2가지 방법이 존재한다.

현재 예시에는 다음 인터페이스가 사용된다.

import { Model, Optional} from 'sequelize';

export interface IUser {
    id: number;
    name : string;
    email : string;
}

export interface IUserPrimaryKey extends Pick<IUser,'id'> {};

export interface IUserInput extends Optional<IUser, 'id'> {}
export class IUserInstance extends Model<IUser, IUserInput> {} // for define

Sequelize에서 모델을 타입스크립트 기반으로 생성하기 위해서는 해당 모델이 Model 클래스를 상속해야 한다. 이때 모델 클래스는 제네릭 파라미터로 2개의 인자를 요구한다.

  1. TModelAttributes : 해당 모델의 구조를 나타내는 인터페이스.
  2. TCreationAttributes : 해당 모델을 생성할 때 사용될 인터페이스.

 위 예시에서는 IUser 및 IUserInput 인터페이스를 Model의 인자로 넘기고 있다. 이 경우 우리가 만들고 싶은 모델은 IUser 인터페이스의 구조를 따라 id, name, email 과 같은 인자를 가져야 하며, 해당 모델을 이용하여 인스턴스를 만들거나, 값을 설정할 때는 IUserInput 인터페이스를 이용하게 된다. 

 왜 TCreationAttributes가 따로 존재할까? 데이터베이스에 데이터를 삽입할 때, 모든 칼럼에 대응되는 정보를 입력할 필요가 없는 경우가 있다. 예를 들어 칼럼에 AUTO_INCREMENT을 지정하여 DBMS가 자동적으로 값을 할당해주거나, NULL 을 허용하여 단순히 빈칸으로 존재해도 되는 경우, 모든 칼럼에 대응되는 정보를 입력할 필요가 없다. 그러나 Sequelize 패키지는 이러한 정보를 알지 못하기 때문에, 사용자가 이러한 정보를 인터페이스 형식으로 넘겨줘야 한다.

 위 코드를 생각해보자. IUser 클래스의 경우, id 정보를 DBMS 자체적으로 할당하기를 원한다. 이 경우 id 자체는 모든 User 에 대한 인스턴스에 대해 존재하지만, 이를 수동으로 입력할 필요는 없다. 따라서 id 프로퍼티만을 Optional로 지정한 IUserInput 인터페이스를 추가적으로 Model 클래스의 인자로 전달하여 값을 입력할 때 사용할 인터페이스를 알린다.


 위 코드에서는 3개의 인터페이스 IUser, IUserPrimaryKey, IUserInput 및 클래스 IUserInstance 을 선언해 두었다. 전자 3개의 인터페이스는 모델의 정보를 나타내기 위해 사용되고, 후자의 클래스는 모델을 직접적으로 생성하는데 사용된다.

이러한 정보를 기반으로 모델 생성 방법을 계속 살펴보자.


Model 클래스 상속

//#1. 모델을 상속하는 클래스 생성
export class User extends IUserInstance  implements IUser {
    declare id: number;
    declare name: string;
    declare email: string;
};
//#2. 칼럼 및 모델 관련 설정 수행
User.init({
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    name: {
        type: DataTypes.STRING,
        allowNull: false
    },
    email: {
        type: DataTypes.STRING,
        allowNull: false
    }
},
    {
        sequelize: db,
        modelName: 'user'
    }
);

 모델 클래스를 직접적으로 다루는 방식이다. Model 클래스를 상속하고, 모델에 대응되는 프로퍼티를 declare을 통해 선언한다. 이후 init 메서드를 이용하여 칼럼 및 데이터베이스 정보를 설정하게 된다. 


init 메서드는 2개의 객체를 인자로 받는다. 첫번째 객체는 칼럼 정보를 담고 있는 객체이다.

column1 : {
    type: Datatypes.~,
    autoIncrement: ~,
    primaryKey: ~,
    ... 다른 설정들 ...
}

 칼럼 정보는 칼럼 이름 : 설정 을 key - value 형식으로 지정한다. 이때 이름은 실제 테이블의 칼럼명으로 사용된다. 위 코드를 이용하는 경우, column1 이라는 칼럼을 대응되는 객체의 설정으로 생성하게 된다.

 설정 중 type 프로퍼티는 sequelize 패키지의 Datatypes 내부에 선언되어 있다. 이때 Datatypes에 내장된 타입들이 DBMS상의 타입과 1대 1로 완전히 매칭되지는 않으므로, 공식 문서를 참고하여 어떤 타입을 사용할지 고민하자.


init 메서드의 두번째 인자는 테이블 및 모델에 대한 설정을 위한 객체이다.

  • sequelize : 이전에 생성한 Sequelize 객체를 넘긴다.
  • modelName : 모델의 이름을 설정한다. 따로 설정하지 않으면 모델 클래스의 이름을 이용한다. 이후 해당 이름을 기반으로 하여 함수나 프로퍼티 등이 생성된다.

이외 다양한 설정이 가능하다. 이것 역시 공식 문서를 참고하자.


 

sequelize.define

export const User = db.define<IUserInstance>('user', {
    id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
    },
    name: {
        type: DataTypes.STRING,
        allowNull: false
    },
    email: {
        type: DataTypes.STRING,
        allowNull: false
    }
});

 모델을 좀더 쉽게 만들기 위한 메서드로, 내부적으로 위에서 언급한 Model 클래스를 이용하여 모델을 생성한다.  sequelize 객체에서 직접 모델을 정의하므로, model.init 에서처럼 sequelize을 따로 지정할 필요는 없다. 모델 생성 방식에 비해 코드가 짧다는 장점이 있다.

 define 방식이 전반적으로 더 편리한 방식인 것은 맞지만, 모델을 상속하는 방식의 경우 커스텀 함수를 추가할 수 있다는 장점이 있으므로 사용은 개개인의 선택에 따른다. 현재 글에서는 모델 상속 방식을 이용한다.


간단한 CRUD

Sequelize는 CRUD 동작을 지원한다. 자세한 내용은 공식 문서를 참고하자.


Create

데이터를 생성하는 경우 build 혹은 create을 이용한다.

 build 메서드를 이용하면 객체를 생성하기는 하지만, 바로 데이터베이스 상에 적용하지는 않는다. 만약 데이터베이스와 동기화하고 싶다면 save 메서드를 실행한다.

let user = User.build({
    name: "blaxsior",
    email: "~~~~~"
});
user.save();

/*아래 코드와 동일하다.*/

let user = User.create(~);

 앞에서 Sequelize의 모델을 만들때는 제네릭 파라미터에 2개의 인자를 넘겨야 하며, 2번째 인자는 이후 모델의 값을 입력할 때 사용된다고 했는데, 위 그림을 보면 2번째 인자로 보낸 IUserInput을 입력 형식으로 취하고 있음을 볼 수 있다. 따라서 인터페이스에 맞게 값을 입력하지 않으면 타입 체크에서 에러가 발생하므로, 입력을 강제할 수 있다.


READ

데이터를 읽는 경우, find~ 메서드를 이용한다.

let user = await User.findByPk(1);
// 이외 많은 find 메서드가 존재한다.

다양한 메서드가 존재하므로, 적당히 선택하여 사용하자.


Update

데이터를 갱신하는 경우 set 혹은 update을 사용한다.

set을 사용하는 경우 객체의 내용이 갱신되지만, 데이터베이스에 반영되지는 않는다. 만약 변경 사항을 반영하고 싶은 경우 save 메서드를 실행한다.

user = await User.findByPk(1);

user.set({name: "blaxsior2", email:"~~~~~~"},{});
user.save();

/*아래 코드와 동일하다.*/

user.update({name: "blaxsior2", email:"~~~~~~"},{});

set 혹은 update 메서드는 2개의 인자를 받는다. 첫번째 인자에는 변경할 칼럼 및 내용을 객체 형태로 전달하고, 두번째 인자에는 갱신 동작에 대한 설정을 지정한 객체를 전달한다.

첫번째 인자는 Partial<TModelAttributes> 타입이므로, 모든 프로퍼티를 선택적으로 갱신할 수 있다.


Delete

model.destroy 메서드를 이용한다.

await user.destroy();

모델간 관계 지정 ( Association )

모델간의 관계는 one-to-one, one-to-many, many-to-many 로 나뉜다. 관계를 지정하는 경우 4개의 메서드를 이용한다.

  • hasOne : one-to-one 관계를 지정하는데 사용된다.
  • hasMany : one-to-many 관계를 지정하는데 사용된다.
  • belongsTo : one-to-one 혹은 one-to-many 관계를 지정하는데 사용된다.
  • belongsToMany: many-to-many 관계를 지정하는데 사용된다.
export const make_association = () => {
    //User : Product => one to many
    User.hasMany(Product);
    Product.belongsTo(User, { constraints: true, onDelete: 'CASCADE' });

    //User : Cart => one to one
    User.hasOne(Cart);
    Cart.belongsTo(User, { constraints: true });

    //User : Order => one to many
    User.hasMany(Order);
    Order.belongsTo(User, { constraints: true, onDelete: 'CASCADE' });

    //Cart : Product => many to many, through CartItem
    Cart.belongsToMany(Product, { through: CartItem });
    Product.belongsToMany(Cart, { through: CartItem });

    //Order : Product => many to many, through OrderItem
    Order.belongsToMany(Product, { through: OrderItem });
    Product.belongsToMany(Order, { through: OrderItem });
}

 

  • one-to-one : foo.hasOne(bar) / bar.belongsTo(foo)
  • one-to-many : foo.hasMany(bar) / bar.belongsTo(foo)
  • many-to-many : foo.belongsToMany(bar) / bar.belongsToMany(foo)

 many-to-many 관계의 경우 두 모델 사이를 연결하기 위한 추가적인 모델이 요구되는데, 이를 through 프로퍼티를 통해 전달해야 코드가 제대로 동작하게 된다.


런타임에 생성되는 특별한 함수 & 프로퍼티와 믹스인

 위와 같이 관계를 설정하면 관계를 설정한 모델들 사이에 특별한 함수나 믹스인이 생성된다. 이들은 "런타임" 환경에서 생성되므로, 타입스크립트에서는 기본적으로 이러한 함수들을 감지할 수 없고, 기본적으로 자동완성도 지원하지 않는다. 따라서 이러한 함수들은 declare을 통해 클래스 내부에 따로 선언해둬야만 사용할 수 있다.

one-to-one

  //이러한 선언들은 모델 클래스 내부에 설정한다.
  /*with Cart*/
    declare getCart: HasOneGetAssociationMixin<Cart>;
    declare setCart: HasOneSetAssociationMixin<Cart,IOrderPrimaryKey>;
    declare createCart: HasOneCreateAssociationMixin<Cart>;
 
     /*with User*/
    declare getUser: BelongsToGetAssociationMixin<User>;
    declare setUser: BelongsToSetAssociationMixin<User, IUserPrimaryKey>;
    declare createUser: BelongsToCreateAssociationMixin<User>;

 모델 각각에 get/set/create[상대이름] 형태의 메서드가 생성된다. 타입스크립트는 기본적으로 이러한 함수들을 전혀 체크할 수 없으므로, 이들을 사용하기 위해서는 위와 같이 일일이 클래스 내부에 선언해줘야 한다.


one-to-many

    /*with Order : one 측*/
    declare getOrders: HasManyGetAssociationsMixin<Order>;
    declare countOrders: HasManyCountAssociationsMixin;
    declare hasOrder: HasManyHasAssociationMixin<Order, IOrderPrimaryKey>;
    declare hasOrders: HasManyHasAssociationsMixin<Order, IOrderPrimaryKey>;
    declare setOrders: HasManySetAssociationsMixin<Order, IOrderPrimaryKey>;
    declare addOrder: HasManyAddAssociationMixin<Order, IOrderPrimaryKey>;
    declare addOrders: HasManyAddAssociationsMixin<Order, IOrderPrimaryKey>;
    declare removeOrder: HasManyRemoveAssociationMixin<Order, IOrderPrimaryKey>;
    declare removeOrders: HasManyRemoveAssociationsMixin<Order, IOrderPrimaryKey>;
    declare createOrder: HasManyCreateAssociationMixin<Order>;
    
    /*with User : many 측*/
    declare getUser?: BelongsToGetAssociationMixin<User>;
    declare setUser?: BelongsToSetAssociationMixin<User, IUserPrimaryKey>;
    declare createUser?: BelongsToCreateAssociationMixin<User>;

그냥 위에 있는 함수들을 선언해주면 된다.


many-to-many

 	/*with Product : many*/
    declare getProducts: BelongsToManyGetAssociationsMixin<Product>;
    declare countProducts: BelongsToManyCountAssociationsMixin
    declare hasProduct: BelongsToManyHasAssociationMixin<Product, IProductPrimaryKey>
    declare hasProducts: BelongsToManyHasAssociationsMixin<Product, IProductPrimaryKey>
    declare setProducts: BelongsToManySetAssociationsMixin<Product, IProductPrimaryKey>
    declare addProduct: BelongsToManyAddAssociationMixin<Product, IProductPrimaryKey>
    declare addProducts: BelongsToManyAddAssociationsMixin<Product, IProductPrimaryKey>
    declare removeProduct: BelongsToManyRemoveAssociationMixin<Product, IProductPrimaryKey>
    declare removeProducts: BelongsToManyRemoveAssociationsMixin<Product, IProductPrimaryKey>
    declare createProduct: BelongsToManyCreateAssociationMixin<Product>;
    declare orderitem: OrderItem|IOrderItemInput;
    
    
    /*with Cart*/
    declare getCarts: BelongsToManyGetAssociationsMixin<Cart>;
    declare countCarts: BelongsToManyCountAssociationsMixin
    declare hasCart: BelongsToManyHasAssociationMixin<Cart, ICartPrimaryKey>
    declare hasCarts: BelongsToManyHasAssociationsMixin<Cart, ICartPrimaryKey>
    declare setCarts: BelongsToManySetAssociationsMixin<Cart, ICartPrimaryKey>
    declare addCart: BelongsToManyAddAssociationMixin<Cart, ICartPrimaryKey>
    declare addCarts: BelongsToManyAddAssociationsMixin<Cart, ICartPrimaryKey>
    declare removeCart: BelongsToManyRemoveAssociationMixin<Cart, ICartPrimaryKey>
    declare removeCarts: BelongsToManyRemoveAssociationsMixin<Cart, ICartPrimaryKey>
    declare createCart: BelongsToManyCreateAssociationMixin<Cart>;
    declare cartitem: CartItem|ICartItemInput;

many-to-many 관계에서는 through에 해당하는 모델을 경유하여 두개의 모델이 관계를 가진다. 이때 belongsToMany를 통해 관계를 형성하면 각각의 모델로부터 through 모델을 접근할 수 있도록 프로퍼티가 할당된다. 이때 프로퍼티의 이름은 modelName에 지정한 이름과 동일하다.

  위 코드에서는 cartitem이 through에 해당하는 모델이다. 이때 해당 모델에 대한 타입이 CartItem|ICartItemInput으로 되어있는 모습을 볼 수 있다. 이러한 객체들을 다루는 경우 controller 부분에서 값을 직접 지정하거나, 다른 객체를 할당하는 동작이 가능하기 때문에 해당 모델에 대한 인터페이스도 함께 명시한다.

product.orderitem 객체에 인터페이스를 구현하는 객체를 할당하고 있다.

 ORM을 통해 데이터베이스를 관리하는 경우, 관계 설정을 통해 사용하는 코드의 비중이 높다. 이때 타입스크립트 환경에서 Sequelize을 사용하는 경우 관계를 활용한 코드를 사용하기 위해 위와 같이 상당한 양의 타입을 선언해둬야 하므로, 사용에 불편함이 따른다.

 


Join을 사용하는 경우 : Eager Loading 

https://sequelize.org/v7/manual/eager-loading.html

 

위 코드에서는 req.user.getOrders을 통해 유저의 주문을 조회하고 있다. Order 및 Product는 many-to-many 관계를 가지고 있는데, 두개의 값을 엮어서 조회하고 싶은 경우 include 옵션을 이용한다.

 좌측은 include 옵션 없이 order을 요청한 것이고, 우측은 include 옵션을 추가하여 데이터를 요청한 것이다.

 좌측은 order 데이터 자체만을 담고 있으나, 우측은 각 order과 관계를 가진 Product 객체들을 products 라는 이름으로 가지고 있다. Product 의 modelName 은 product 인데, 여기에 복수형 s 를 붙여 products 라는 이름으로 Product 배열을 반환한 것이다. 이러한 값들은 런타임 동작에 의존하므로, 타입스크립트에서 접근할 수 없다.


결론

 Sequelize 패키지는 자바스크립트의 런타임 동작을 많은 부분에서 활용한 ORM으로, 타입스크립트 환경에서는 런타임에 생성되는 코드에 대해 추적할 수 없기 때문에 많은 타입 선언을 필요로 한다. 따라서 단순 자바스크립트 환경에서는 직관적이고 간단하다는 장점을 가지지만, 타입스크립트 환경에서는 부가적인 작업을 해야하는 불편한 패키지가 된다.

 만약 타입스크립트 환경에서 반드시 Sequelize 만을 사용해야 한다면 위의 방식에 따라 코드를 개발하면 된다. 그러나, 다른 ORM을 사용할 수 있는 환경이라면 타입스크립트 및 데코레이터 기반의 typeORM 패키지를 권장한다.