DB-5 M:N (23/01/16)

nazzzo·2023년 1월 17일
0

M:N 관계 테이블 구현하기

ORM(Sequelize)을 사용해서 M:N 관계를 구현하는 방법을 정리합니다



1. 모델 생성

테이블 모델을 생성하고 다음과 같은 메서드를 통해 테이블간 관계를 형성합니다

  • hasMany : One to Many 관계를 맺기 위해 사용합니다 (부모 모델)
  • belongsTo : One to Many 관계를 맺기 위해 사용합니다 (자식 모델)
  • belongsToMany : Many To Many 관계를 맺기 위해 사용합니다.
    두 테이블간의 관계를 나타내는 조인 테이블이 자동으로 생성됩니다

[User]

module.exports = (sequelize, Sequelize) => {
  class User extends Sequelize.Model {
    static initialize() {
      return this.init(
        {
          userid: {
            type: Sequelize.STRING(60),
            primaryKey: true,
          },
          userpw: {
            type: Sequelize.STRING(64),
            allowNull: false,
          },
          username: {
            type: Sequelize.STRING(30),
            allowNull: false,
          },
          provider: {
            type: Sequelize.ENUM("local", "kakao"),
            allowNull: false,
            defaultValue: "local",
          },
          snsId: {
            type: Sequelize.STRING(30),
            allowNull: true,
          },
        },
        {
          sequelize,
        }
      );
    }
    // PK > FK
    static associate(models) {
      this.hasMany(models.Board, {
        foreignKey: "userid",
      });

      this.hasMany(models.Comment, {
        foreignKey: "userid",
      });

      // M:N 관계 설정 메서드 ~ 조인 테이블이 자동으로 생성됩니다!
      this.belongsToMany(models.Board, {
        through: "Liked", // 조인 테이블 명칭
        foreignKey: 'userid',
      });
    }
  }
  User.initialize();
};

[Board]

module.exports = (sequelize, Sequelize) => {
  class Board extends Sequelize.Model {
    static initialize() {
      this.init(
        {
          subject: {
            type: Sequelize.STRING(100),
            allowNull: false,
          },
          content: {
            type: Sequelize.TEXT,
            allowNull: true,
          },
          createdAt: {
            type: Sequelize.DATE,
            defaultValue: sequelize.fn("now"),
          },
          hit: {
            type: Sequelize.INTEGER,
            defaultValue: 0,
          },
        },
        {
          sequelize,
        }
      );
    }
    // FK > PK
    static associate(models) {
      this.belongsTo(models.User, {
        foreignKey: "userid",
        // 코드를 읽는 순간 Board에도 userid 필드가 생성됩니다
      });

      this.hasMany(models.Comment, {
        foreignKey: "boardid",
        // 자식 테이블에 생길 필드명입니다
      });
      
	  // User 테이블과 M:N 관계를 맺으며 조인 테이블을 생성합니다
      this.belongsToMany(models.User, {
        through: "Liked",
        foreignKey: "boardid",
      });

      // Hash 테이블과 M:N 관계를 맺으며 조인 테이블을 생성합니다
      this.belongsToMany(models.Hash, {
        through: "Hashtag",
        foreignKey: 'boardid',
      });
    }
  }

  Board.initialize();
};

[Hash]

module.exports = (sequelize, Sequelize) => {
    class Hash extends Sequelize.Model {
      static createTable() {
        return this.init(
          {
            tagname: {
              type: Sequelize.STRING(30),
              allowNull: false,
              primaryKey: true,
            },
          },
          {
            sequelize
          }
        );
      }
      static associate(models) {
        this.belongsToMany(models.Board, {
          through: "Hashtag",
          foreignKey: 'tagname'
        })
      }
    }
    Hash.createTable();
  };
  

[index]

1) 인스턴스(sequelize) 생성
2) associate 함수(정적 메서드) 실행

const fs = require("fs");
const path = require("path");
const Sequelize = require("sequelize");
const config = require("../config");
const db = config.db[config.env];

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

// 모델을 생성하기 위해 익명함수를 실행하는 코드입니다
fs.readdirSync(__dirname) // __dirname : 현재위치에 대한 절대경로를 불러오는 내장객체
  .filter(v => v.indexOf("index"))
  .forEach((file) => {
    require(path.join(__dirname, file))(sequelize, Sequelize); 
  // path.join : 경로와 파일을 인자로 받습니다
  });

// 생성한 모델 객체로부터 associate 함수를 실행하기 위한 코드입니다
const { models } = sequelize
for (const v in models) {
  if (typeof models[v].associate === "function")
  sequelize.models[v].associate(models);
}
// console.log(models)

module.exports = {
  sequelize,
  Sequelize,
};



코드 실행 결과 belongsToMany 메서드로 인해 다음과 같은 조인 테이블이 생성됩니다

mysql> desc Liked;
+---------+-------------+------+-----+---------+-------+
| Field   | Type        | Null | Key | Default | Extra |
+---------+-------------+------+-----+---------+-------+
| userid  | varchar(30) | NO   | PRI | NULL    |       |
| BoardId | int         | NO   | PRI | NULL    |       |
+---------+-------------+------+-----+---------+-------+


mysql> desc Hashtag;
+---------+-------------+------+-----+---------+-------+
| Field   | Type        | Null | Key | Default | Extra |
+---------+-------------+------+-----+---------+-------+
| boardid | int         | NO   | PRI | NULL    |       |
| tagname | varchar(30) | NO   | PRI | NULL    |       |
+---------+-------------+------+-----+---------+-------+



2. 좋아요 기능 구현


  • create, destroy 메서드를 사용해서 좋아요를 추가하고 삭제합니다
  • useridboardid가 모두 같을 때는 에러를 출력해야 합니다 (중복 방지)

[Repository]

  async createLike(likeData) {
    console.log("repo :", likeData);
    try {
      const create = await this.Liked.create({
        BoardId: likeData.boardid,
        userid: likeData.userid,
      });
      return create;
    } catch (e) {
      throw new Error(e);
    }
  }
  async destroyLike({ boardid, userid }) {
    console.log("repo :", { boardid, userid });
    try {
      const destroy = await this.Liked.destroy({
        where: {
          BoardId: boardid,
          userid: userid,
        },
      });
      return destroy;
    } catch (e) {
      throw new Error(e);
    }
  }

3. 해시태그 기능 구현 ~ Promise.all


  • findOrCreate 메서드를 이용하면 중복값이 없을 때만 INSERT문을 실행할 수 있습니다
 `SELECT * FROM Hash WHERE tagname='#javascript'`
 `INSERT INTO Hash(tagname) VALUES ('#javascript')`
  • 글을 등록할 때 한 번에 여러 태그를 추가할 수 있도록 배열을 인자로 받고,
    findOrCreate 메서드를 실행할 때 반복문을 사용해야 합니다(map() 사용)

  • 반복문 + 비동기 함수의 결과 처리는 promise.all 메서드를 이용합니다
    Promise.all()은 여러 개의 Promise 객체를 받아 하나의 새로운 Promise 객체를 반환하고,
    새로운 Promise 객체는 전달된 모든 Promise 객체의 처리가 완료되면 결과를 배열로 반환합니다

  • Hash 테이블의 필드에 채워진 내용을 Hashtag 테이블에도 연동시켜야 합니다
    1) console.log(instance.__proto__)로 인스턴스가 상속받은 메서드를 확인할 수 있습니다
    그 중에서 belongsToMany로 엮인 테이블명을 찾습니다(addHashes)
    2) board.addHashes(tags.map((v)=>v[0])) 실행시
    Hashtag 테이블에 boardidtagname 필드의 값이 담깁니다

  • update를 구현할 때 해당 글의 해시태그도 같이 수정하려면 Hashtag 테이블의 row를 따로 삭제 & 생성해야 합니다


어우 복잡해라....


[Repository]

  async createBoard(writeData) {
    // writeData: { hashtag : [] }
    try {
      const createBoard = await this.Board.create(writeData);
      const addHash = writeData.hashtag.map((tagname) => this.Hash.findOrCreate({ where: { tagname } }))
      const tagResult = await Promise.all(addHash)
      // console.log(tagResult)
      // console.log(createBoard.__proto__)
      await createBoard.addHashes(tagResult.map(v=>v[0])) 
      return createBoard;
    } catch (e) {
      throw new Error(e);
    }
  }
  async updateBoard({ id, subject, content, hashtag }) {
    console.log("update :", id, subject, content, hashtag);
    try {
      const updateBoard = await this.Board.update(
        {
          subject: subject,
          content: content,
        },
        { where: { id: id } }
      );
      const addHash = hashtag.map((tagname) => this.Hash.findOrCreate({ where: { tagname } }))
      await this.Hashtag.destroy({ where: { boardid: id } });
      const addHashTag = hashtag.map((tagname) => this.Hashtag.create({ boardid: id, tagname }));
      await Promise.all(addHash, addHashTag)
      return updateBoard;
    } catch (e) {
      throw new Error(e);
    }
  }



마지막으로 좋아요와 해시태그가 잘 담겼는지 findOne()을 사용해서 확인해보겠습니다

  async findOne(id) {
    try {
      const view = await this.Board.findOne({
        where: { id: id },
      });
      // raw: true 옵션을 사용하면 SQL 쿼리문 실행결과만 반환합니다
      const comments = await view.getComments({ raw: true });
      const liked = await this.Liked.findAll({
        attributes: ['userid'],
        where: { boardid: id },
      });
      const hashtag = await this.Hashtag.findAll({
        attributes: ['tagname'],
        where: { boardid: id },
      });
      return { view: view, comments: comments, liked: liked, hashtag: hashtag };
    } catch (e) {
      throw new Error(e);
    }
  }



// get 요청으로 확인하기
{
    "view": {
        "id": 29,
        "subject": "수정 테스트",
        "content": "수정",
        "hit": 0,
        "createdAt": "2023-01-17T06:53:38.000Z",
        "updatedAt": "2023-01-17T06:53:58.000Z",
        "userid": "web7722"
    },
    "comments": [
        {
            "id": 7,
            "content": "댓글 입력 테스트",
            "createdAt": "2023-01-17T08:30:27.000Z",
            "updatedAt": "2023-01-17T08:30:27.000Z",
            "boardid": 29,
            "userid": "web7722"
        },
        {
            "id": 8,
            "content": "댓글 입력 테스트2",
            "createdAt": "2023-01-17T08:30:32.000Z",
            "updatedAt": "2023-01-17T08:30:32.000Z",
            "boardid": 29,
            "userid": "web7722"
        }
    ],
    "liked": [
        {
            "userid": "web7722"
        },
        {
            "userid": "web8833"
        }
    ],
    "hashtag": [
        {
            "tagname": "#modify"
        },
        {
            "tagname": "#test"
        }
    ]
}

0개의 댓글