sequelize

'프론트 쪽 코드만 짜면 안 돼요, 잘 짜져있긴 하지만, 앞으로 인수인계가 다 끝났을 때, 서버 쪽 코드도 변경할 수 있어야 합니다.'
코드를 파악하는 겸, 프론트 쪽으로 넘어오는 데이터를 사용하여 새로운 디자인에 맞추어 메인페이지를 수정하다가 들었던 이야기였습니다.

첫번째 파도

제가 회사에서 처음으로 만났던 파도는 sequelize라고 생각합니다. ORM, ODM이 무엇인지도 제대로 모르는 저에게 sequelize는 정말 낯설었습니다. 이 글을 보시는 분을 저와 같은 상황에 있다고 판단하여 '이건 알고 sequelize를 사용할 걸...' 하는 글을 적어보고자 합니다. 물론 제 글보다 훨씬 좋은 양질의 글이 많다고 생각합니다.

ORM? ODM?

우선 저는 간략하게 ORM, ODM이 무엇인지 알아보고자 했습니다. 제가 알아본 바론 아래와 같습니다.

  • ODM(Object Document Mapper), monk, mongoose 등이 있습니다.
  • ORM(Object Relational Mapper), 저희가 쓰려는 sequelize가 이것입니다.
  • 역할 : DBMS(Data Base Management System)와 프로그램 코드 사이에 위치하여 전보다 훨씬 간결한 표현문으로 같은 기능을 수행할 수 있게 해줍니다.
  • 참조한 자료 : What is the difference between ODM and ORM?

'그러니까…. ORM덕분에 자바스크립트로 데이터베이스에 접근해서 내가 하고 싶은 것을 할 수 있는 거구나!'
퇴근 후에 여러 글을 보며 제가 내린 결론은 이것이었습니다. 덕분에 JS를 이용해서 CRUD(Create, Remove, Update, Delete)가 쉽게 가능한 것이었습니다. 이후 저는 회사에 사용된 sequelize 코드를 본격적으로 파악하기 시작했습니다.

sequelize와 데이터베이스 연결

const Sequelize = require('sequelize');

const { database, username, password, ...options } = {
  database: process.env.DB_DBNAME,
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  host: process.env.DB_HOST,
  dialect: process.env.DB_DIALECT,
  logging: false, 
};

database, username, password 는 database의 이름, 비밀번호, 사용자 이름을 말합니다.

저의 경우 HomeBrew를 사용해서 postgresql을 설치하고, brew install postgresql
pg_ctl -D /usr/local/var/postgres start - DB 서버를 실행하고,
createuser -P -W [사용자명] -사용자를 생성하고,
createdb [DB 명] - DB를 생성하고,
psql [DB 명] - DB에 접속하여

postgresDB 서버를 로컬에 만들었습니다. 위의 정보는 사수님이 모두 정리해서 저에게 이미지로 만들어 보내주었습니다. (사수님 감사합니다) 위에서 만든 [사용자명], [DB명] 를 사용하여 database, username, password를 작성합니다.
아 참고로 pg_ctl -D /usr/local/var/postgres stop을 사용해서 DB 서버를 종료할 수 있습니다.

host는 열려있는 서버의 host를 말합니다. 저는 로컬로 만들어서 localhost라고 적으면 되었습니다.
dialect는 데이터베이스의 sql언어를 말합니다. 해석하면 방언이라는 뜻인데, 어떤 sql언어를 사용할 것인지 정하는 것입니다. 저는 postgres라고 적었습니다.

더 많은 constructor 옵션은 sequelize의 constructor API 주소를 통해 들어가서 확인할 수 있습니다.

models

sequelize로 테이블을 정의해 줄 수 있습니다.

const step = (sequelize, DataTypes) => {
  const Step = sequelize.define("step", {
    title: DataTypes.STRING,
    path: { type: DataTypes.STRING, unique: "uniqueStep" },
    description: DataTypes.STRING,
    index: { type: DataTypes.INTEGER, defaultValue: 0 },
    parentCourse: { type: DataTypes.INTEGER, unique: "uniqueStep" },
  });
  return Step;
};

export default step;

define은 모델을 선언해주는 것이라고 보았습니다. sequelize.define('step', { 이부분은 '모델 정의해주는데, 이름은 step으로 해줘' 로 이해하였습니다. 밑의 DataTypes들은 각 column마다 데이터 타입을 말해줍니다. path와 parentCourse에 있는 unique 값은 이따가 알아보도록 합시다.

belongsToMany? hasMany? belongsTo? hasOne?

이것이 무엇인지 알아보기 전에, 저는 관계형 데이터베이스가 무엇인지에 대해서 더 알아봐야겠다는 생각이 들었습니다.
짤막하게, 코드를 가져와 보겠습니다.

LowerStep.belongsToMany(User, { through: "user-finished-lowerStep", as: "FinishedUsers" });
LowerStep.belongsToMany(User, { through: "user-recent-lowerStep" });

User.belongsToMany(LowerStep, { through: "user-finished-lowerStep", as: "FinishedLowerSteps" });
User.belongsToMany(LowerStep, { through: "user-recent-lowerStep", as: "RecentLowerStep" });

Course.hasMany(Step, { foreignKey: "parentCourse" });

Step.hasMany(LowerStep, { foreignKey: "parentStep" });

Notification.belongsTo(User, { onDelete: "CASCADE" });

위는 회사에서 만든 association의 일부입니다.
위와 같이 설정했을 때, belongsToMany는 'm:n' 관계를 만들어줍니다. belongToMany 함수에는 through 옵션이 꼭 필요한데, 이 through 옵션에 적혀 있는 값을 통해서 table이 만들어지고, 그 테이블에는 targetID, sourceID가 저장됩니다.

as는 별칭으로 만들어줄 수 있습니다. 이 별칭에 add, remove, update를 붙여서 메서드로 사용하거나

newApplication: async (course, __, { models }) => {
      return await models.User.findAll({
        include: {
          model: models.Course,
          as: "AppliedCourses",//별칭
          where: {
            id: course.id,
          },
        },
      });
    },

이런 방식으로 model에서 찾는 방식으로 사용할 수 있습니다.

hasMany는 '1:n' 관계를 만들어줍니다. 이 메서드를 사용하게 되면 하위의 모델에 자기 자신의 key를 심어주는데, foreignKey가 그 키의 이름입니다.
belongsTo는 '1:1'관계를 만들어줍니다. 이 메서드도foreignKey를 만들어주고 하위의 모델에 key를 심어줍니다. (지금의 경우에는 Notification에 userID가 생성됩니다.) onDelete: "CASCADE"는 '이 모델이 지워지면 우리도 같이 지워 질꺼야' 라는 옵션으로 사용하였습니다.

여기서는 사용하지 않았지만, hasOne 이라는 메서드도 존재합니다. 이 메서드도 '1:1' 관계를 만들어주는데, belongsTo와 다른 점은 foreignKey를 부모에게 만들어준다는 점입니다.

그런 기능을 하는군요. 그런데 m:n, 1:n, 1:1 이게 대체 뭘까요? 저는 알아볼 필요가 있었습니다.

1:1 1:n m:n

1:1은 말 그대로 1대 1의 관계를 나타냅니다.
엔티티와 엔티티끼리 서로를 볼 때 반드시 한 가지의 관계만을 가지고 있을 때를 말하는 것이죠.

1:n 관계는 한 쪽 엔티티가 관계를 맺은 엔티티 쪽의 여러 객체를 가질 수 있는 것을 말합니다. RDB에서 매우 흔한 형태라고 합니다.

n:m 관계는 관계를 맺은 양쪽 엔티티 모두에서 1 : M 관계를 맺고 있을 때를 말합니다. 즉, 서로서로 1:N 관계로 보고 있는 것이라고 보면 됩니다.

참고: https://victorydntmd.tistory.com/30

그렇기에, n:m관계를 가진 곳은 두 개 다 belongsToMany 관계로 엮어져 있어야 하고, hasMany는 foreignKey가 필요했던 것이었습니다.

Composite Unique Key

저희 사이트에서 사용하는 데이터는 course, step, lowerStep이 있습니다. course내부에 step, step내부에 lowerstep이 들어있는 구조라고 볼 수 있죠. 그림으로 그려보자면 아래와 같습니다.

20190717_151048_321.jpg

step에서 course의 ID를, lowerStep에서 step의 아이디를 각각 parentCourse, parentStep으로 가지고 나중에 필요할 때 ID 를 사용하여 데이터를 가져오는 방식으로 사용하고 있었습니다.

//이전코드
const step = (sequelize, DataTypes) => {
  const Step = sequelize.define("step", {
    title: DataTypes.STRING,
    path: { type: DataTypes.STRING, unique: true },
    description: DataTypes.STRING,
    index: { type: DataTypes.INTEGER, defaultValue: 0 },
  });
  return Step;
};
export default step;
//현재코드
const step = (sequelize, DataTypes) => {
  const Step = sequelize.define("step", {
    title: DataTypes.STRING,
    path: { type: DataTypes.STRING, unique: "uniqueStep" },
    description: DataTypes.STRING,
    index: { type: DataTypes.INTEGER, defaultValue: 0 },
    parentCourse: { type: DataTypes.INTEGER, unique: "uniqueStep" },
  });
  return Step;
};

export default step;

위의 코드에서 보는 것처럼 step 모델을 정의할 때엔 원래 parentCourse를 선언하지 않았었습니다. association을 통해서 만들어지기 때문이었죠. 그리고 unique:'uniqueStep' 은 path에 unique: true 로만 작성되어 있었습니다. 이렇게 옵션을 주게 되면, step 테이블에서는 같은 path가 존재할 수 없게 됩니다. 저희는 path를 title을 통해서 만들었습니다.

하지만 여기서 문제가 발생했습니다.

다른 코스에서 같은 이름의 스텝을 만든다면?

다른 코스에서도 같은 이름의 step을 만들 수 없는 문제가 생겼습니다. 어떤 코스에 있는 step이든 step테이블에 저장될 테고, step테이블에서는 {unique: true} 옵션을 주어 중복된 path를 가질 수 없으니까요.

그래서 Sequelize의 기능으로 step이나 lowerStep을 탐색할 때 path속성에 부모가 되는 값의 path 값을 추가로 넣어 업데이트해 주도록 만들어 보려고 했습니다. 하지만 이 방법은 잘못된 방법이라고 금세 생각해 낼 수 있었습니다. 만약 course의 path 값을 변경한다고 가정했을 때, 그 course에 속해있는 모든 step의 path 값이 업데이트되어야 하고, 모든 lowerStep의 path또한 다시 업데이트되어야 하기 때문이죠. 다른 방법을 찾아야 했습니다.

composite Key를 한 줄로 표현하자면 다음과 같습니다.

'composite Key는 주어진 테이블의 둘 이상의 필드 또는 열의 조합인 키입니다.'

저는 step 테이블 내에서 parentCourse와 path가 합쳐진 값이 unique 하게 존재하는 방법이 필요했고, composite Key는 그 방법을 실현하기에 꽤 알맞다고 판단했습니다. 그리하여, Sequelize를 통해서 composite Key를 작성할 수 있는 방법을 찾아보았습니다. 아래는 sequelize에 있는 예시 입니다.

// unique속성은 string이나 boolean이 될 수 있습니다.
// 만약 당신이 같은 문자열을 여러개의 columns에 제공한다면, 그것들은 composite unique key를 생성할 것입니다.
uniqueOne: { type: Sequelize.STRING,  unique: 'compositeIndex' },
uniqueTwo: { type: Sequelize.INTEGER, unique: 'compositeIndex' },

그리고 적용했습니다. association을 통해 만들어지던 parentCourse를 define할 때 집어넣고 composite unique key를 사용하도록 하였죠. 그리하여 중복된 이름의 step도 다른 course 내에서 만들 수 있게 되었습니다.

마무리

쓴 글을 다시 읽어보면서 '정말 두서없네!🤢🤮'라고 생각했습니다. 제가 겪은 경험을 공유하고 도움이 되고 싶어 공유했는데 도움이 될지 안 될지 잘 모르겠네요. 오히려 피해가 되지 않을지 걱정입니다. 제가 작성한 내용 중에 틀린 내용이라던가 궁금한 점이 있으시면 댓글로 마구마구 지적 부탁드립니다.
지금도 이 path를 title로 저장하는 것이 올바른 것인지에 대해 많이 고민하고 있습니다. id를 사용해서 path를 설정하고 그에 상응하는 값을 가져오는 것이 나은가? 이대로 둘 것인가? 하며 말입니다. 데이터를 가져올 때도 어떻게 데이터를 가져오면 좋을지 계속해서 고민하고 있습니다.
다음 글은 조금 더 나아지고 가볍고 짧은 글로 계획하고 있습니다. 두서없고 퀄리티가 좋지 않은 긴 글을 끝까지 읽어주셔서 감사합니다.🙇‍♂️
아 참, velog에서 이 시리즈 가 정말 많은 도움이 되었습니다. 감사하다는 말씀을 드리고 싶네요.