๐Ÿ‘ ํ•ด์‹œํƒœ๊ทธ ๊ตฌํ˜„

๋ฐ•์ƒ์€ยท2022๋…„ 6์›” 28์ผ
0

๐Ÿƒ blegram

๋ชฉ๋ก ๋ณด๊ธฐ
4/20

Next.js์™€ Node.js์˜ Sequelize๋ฅผ ์ด์šฉํ•ด ํ•ด์‹œํƒœ๊ทธ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” ํฌ์ŠคํŠธ์ž…๋‹ˆ๋‹ค.

๋“ค์–ด๊ฐ€๊ธฐ ์ „์— ํ•ด์‹œํƒœ๊ทธ์™€ ๊ด€๊ณ„๋ฅผ ๋งบ์„ ๋Œ€์ƒ์— ๋Œ€ํ•ด์„œ ๋จผ์ € ์ƒ๊ฐํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.
์‰ฝ๊ฒŒ ์ƒ๊ฐํ•ด๋ณด๋ฉด ํ•ด์‹œํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ ํ•ด๋‹น ๊ฒŒ์‹œ๊ธ€์— ํ•ด์‹œํƒœ๊ทธ ์ปฌ๋Ÿผ์„ ๋งŒ๋“ค๊ณ  ๋ชจ๋‘ ๋„ฃ์–ด๋ฒ„๋ฆฌ๋ฉด ๋ฉ๋‹ˆ๋‹ค.
์ด๋ ‡๊ฒŒ ๋งŒ๋“ค๋ฉด ์†Œ๊ทœ๋ชจ๋กœ ์šด์˜๋œ๋‹ค๋ฉด ํฌ๊ฒŒ ๋ฌธ์ œ๋Š” ์—†๋‹ค๊ณ  ์ƒ๊ฐํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๊ทœ๋ชจ๊ฐ€ ์ปค์งˆ์ˆ˜๋ก DB์— ์ค‘๋ณต๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด๊ฐ€๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด #์˜ค๋Š˜์ด๋ผ๋Š” ํ•ด์‹œํƒœ๊ทธ๋ฅผ A, B, C๋ผ๋Š” ๊ฒŒ์‹œ๊ธ€์— ๋ชจ๋‘ ์‚ฌ์šฉํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๋ฉด A, B, C ๋ชจ๋“  ํ…Œ์ด๋ธ”์˜ ํ•ด์‹œํƒœ๊ทธ ์ปฌ๋Ÿผ์— #์˜ค๋Š˜์ด๋ผ๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ๋Š” ์ค‘๋ณต์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.
์ด๋Ÿฐ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ํ•ด์‹œํƒœ๊ทธ์™€ ๊ฒŒ์‹œ๊ธ€์„ N:M์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ“ƒ ๋ชจ๋ธ ์ •์˜

N:M ๊ด€๊ณ„๋Š” ํ•„์—ฐ์ ์œผ๋กœ ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์ด ์ƒ๊ธฐ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
1:1์ธ ๊ฒฝ์šฐ๋Š” ์›ํ•˜๋Š” ๋ชจ๋ธ์— foreign key๋ฅผ ๋„ฃ์œผ๋ฉด ๋˜๊ณ , 1:N์ธ ๊ฒฝ์šฐ๋Š” N ์ชฝ(ํฌํ•จ๋˜๋Š” ์ชฝ)์— foreign key๋ฅผ ๋„ฃ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.
N:M์ผ ๊ฒฝ์šฐ์— ์ค‘๊ฐ„ ํ…Œ์ด๋ธ” ์—†์ด ์–‘์ชฝ์— ๋‹ค ๋„ฃ๋Š”๋‹ค๊ณ  ๊ฐ€์ •ํ•ด๋ณด๋ฉด ํ•œ ๋ฒˆ์˜ ๊ด€๊ณ„์ผ ๋•Œ๋Š” ๊ดœ์ฐฎ์ง€๋งŒ ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ์—ฐ๊ฒฐ๋  ๊ฒฝ์šฐ์—๋Š” ํ•˜๋‚˜์˜ ์ปฌ๋Ÿผ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
๋”ฐ๋ผ์„œ ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์„ ์ด์šฉํ•ด์„œ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์—๋Š” ์–‘์ชฝ์˜ foreign key๊ฐ€ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค. ๊ทธ ์–‘์ชฝ์˜ foreign key์˜ ๋ช…์นญ์„ ์•„๋ž˜์˜ ๋ชจ๋ธ ์ •์˜์˜ foreignKey์—์„œ ์ •ํ•ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ through๋Š” ์‹ค์ œ ์ƒ์„ฑ๋  ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์˜ ๋ช…์นญ์„ ์ •ํ•˜๋Š” ๊ฒƒ์ด๊ณ , as๋Š” ์ฝ”๋“œ์ƒ์—์„œ ์‚ฌ์šฉํ•  ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์˜ ์ด๋ฆ„์„ ์ •ํ•˜๋Š” ๊ฒƒ(+ ์ƒ์„ฑ๋  ํ—ฌํผ ๋ฉ”์„œ๋“œ์˜ ์ด๋ฆ„ ์ง€์ •)์ž…๋‹ˆ๋‹ค.

  • Sequelize์—์„œ ๊ด€๊ณ„๋ฅผ ์ง€์ •ํ•˜๋ฉด ๊ด€๊ณ„์— ๋งž๊ฒŒ ์ž๋™์œผ๋กœ ๋ช‡ ๊ฐ€์ง€์˜ ํ—ฌํผ ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•ด์ค๋‹ˆ๋‹ค.

1. ๊ฒŒ์‹œ๊ธ€ ๋ชจ๋ธ ์ •์˜

  • models/post.js
const Post = (sequelize, DataTypes) => {
  const Post = sequelize.define(
    "Post",
    {
      _id: {
        type: DataTypes.INTEGER.UNSIGNED,
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        comment: "๊ฒŒ์‹œ๊ธ€์˜ ์•„์ด๋”” ( ๊ฒŒ์‹œ๊ธ€์„ ์‹๋ณ„ํ•  ๊ฐ’ )",
      },
      content: {
        type: DataTypes.STRING(2200),
        alllowNull: true,
        comment: "๊ฒŒ์‹œ๊ธ€์˜ ๋‚ด์šฉ ( ์ตœ๋Œ€ 2200์ž, , ํŠน์ˆ˜๋ฌธ์ž ๊ฐ€๋Šฅ )",
      },
    },
    {
      sequelize,
      timestamps: true,
      paranoid: false,
      underscored: false,
      modelName: "Post",
      tableName: "posts",
      charset: "utf8mb4",
      collate: "utf8mb4_general_ci",
    },
  );

  Post.associate = db => {
    // ํ•ด์‹œํƒœ๊ทธ ( N : M ) ( ๊ฒŒ์‹œ๊ธ€๊ณผ ํ•ด์‹œํƒœ๊ทธ )
    db.Post.belongsToMany(db.Hashtag, {
      through: "PostHashtags",
      as: "postHashtager",
      foreignKey: "PostId",
      onDelete: "cascade",
    });
    
    // ... ๋‚˜๋จธ์ง€ ๋‹ค๋ฅธ ๊ด€๊ณ„๋“ค์€ ์ƒ๋žต
  };

  return Post;
};

export default Post;

2. ํ•ด์‹œํƒœ๊ทธ ๋ชจ๋ธ ์ •์˜

const Hashtag = (sequelize, DataTypes) => {
  const Hashtag = sequelize.define(
    "Hashtag",
    {
      _id: {
        type: DataTypes.INTEGER.UNSIGNED,
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        comment: "ํ•ด์‹œํƒœ๊ทธ์˜ ์•„์ด๋”” ( ํ•ด์‹œํƒœ๊ทธ์„ ์‹๋ณ„ํ•  ๊ฐ’ )",
      },
      content: {
        type: DataTypes.STRING(40),
        alllowNull: true,
        comment: "ํ•ด์‹œํƒœ๊ทธ์˜ ๋‚ด์šฉ ( ์ตœ๋Œ€ 40์ž, ํŠน์ˆ˜๋ฌธ์ž ๋ถˆ๊ฐ€๋Šฅ )",
      },
    },
    {
      sequelize,
      timestamps: true,
      paranoid: false,
      underscored: false,
      modelName: "Hashtag",
      tableName: "hashtags",
      charset: "utf8",
      collate: "utf8_general_ci",
    },
  );

  Hashtag.associate = db => {
    // ํ•ด์‹œํƒœ๊ทธ ( N : M ) ( ๊ฒŒ์‹œ๊ธ€๊ณผ ํ•ด์‹œํƒœ๊ทธ )
    db.Hashtag.belongsToMany(db.Post, {
      through: "PostHashtags",
      as: "PostHashtaged",
      foreignKey: "HashtagId",
      onDelete: "cascade",
    });
  };

  return Hashtag;
};

export default Hashtag;

๐Ÿง ํ•ด์‹œํƒœ๊ทธ ๋“ฑ๋ก/์ฐพ๊ธฐ

1. ์ •๊ทœ ํ‘œํ˜„์‹์œผ๋กœ ํ•ด์‹œํƒœ๊ทธ ์ฐพ๊ธฐ

์‚ฌ์šฉ์ž๋Š” ์ผ๋ฐ˜ ํ…์ŠคํŠธ์™€ ํ•ด์‹œํƒœ๊ทธ๋ฅผ ์„ž์–ด์„œ ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์š”์ฒญ์„ ๋ณด๋‚ด๊ธฐ ๋•Œ๋ฌธ์— ํ…์ŠคํŠธ ์ค‘์—์„œ ํ•ด์‹œํƒœ๊ทธ๋ฅผ ์ถ”์ถœํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ํ•ญ์ƒ ์ •์ƒ์ ์œผ๋กœ ํ•ด์‹œํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ํŠน์ดํ•˜๊ฒŒ ์ƒ๊ธด ํ•ด์‹œํƒœ๊ทธ์กฐ์ฐจ๋„ ์ œ๋Œ€๋กœ ์ถ”์ถœํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ •๊ทœ ํ‘œํ˜„์‹์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด ###ํ•ด์‹œํƒœ๊ทธ, #ํ•ด#ํ•ด์‹œ์™€ ๊ฐ™์€ ๊ฒฝ์šฐ #ํ•ด์‹œํƒœ๊ทธ, #ํ•ด, #ํ•ด์‹œ์™€ ๊ฐ™์ด ์ถ”์ถœํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์œ„์˜ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š” ์ •๊ทœ ํ‘œํ˜„์‹์€ /#[a-z0-9_๊ฐ€-ํžฃ]+/gm์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์— String.prototype.match(์ •๊ทœํ‘œํ˜„์‹)์„ ์ด์šฉํ•ด์„œ ์›ํ•˜๋Š” ํ•ด์‹œํƒœ๊ทธ๋งŒ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.

2. ๊ฒŒ์‹œ๊ธ€/ํ•ด์‹œํƒœ๊ทธ ๋“ฑ๋ก

  • /routes/post.js
// ์ด๋ฏธ์ง€, ๋Œ“๊ธ€, ์œ ์ € ์ฒ˜๋Ÿผ ํ˜„ ํฌ์ŠคํŠธ์˜ ์ฃผ์ œ์™€ ๋ฒ—์–ด๋‚˜๋Š” ๋‚ด์šฉ์€ ๋ชจ๋‘ ์ƒ๋žตํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

import db from "../models/index.js";

const { Post, Hashtag } = db;

// 2021/12/22 - ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑํ•˜๊ธฐ - by 1-blue
router.post("/", async (req, res, next) => {
  const { content } = req.body;

  try {
    // ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ
    const createdPost = await Post.create({ content, UserId: req.user._id });

    // ํ•ด์‹œํƒœ๊ทธ ์ถ”์ถœ
    const hashtags = content.match(/#[a-z0-9_๊ฐ€-ํžฃ]+/gm);
    if (hashtags) {
      // ํ•ด์‹œํƒœ๊ทธ๋“ค ์ƒ์„ฑ
      const hashtagPromiseList = hashtags.map(hashtag => {
        // ๋งจ ์•ž "#"์ œ๊ฑฐ
        const content = hashtag.substr(1, hashtag.length);
        // ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ๊ฐ€์ ธ์˜ค๊ณ  ์—†์œผ๋ฉด ์ƒ์„ฑ
        return Hashtag.findOrCreate({ where: { content } });
      });
      const results = await Promise.all(hashtagPromiseList);

      // ๊ฒŒ์‹œ๊ธ€๊ณผ ํ•ด์‹œํƒœ๊ทธ ์ค‘๊ฐ„ ํ…Œ์ด๋ธ”์˜ ์ปฌ๋Ÿผ ์ƒ์„ฑ ( ๊ฒŒ์‹œ๊ธ€๊ณผ ํ•ด์‹œํƒœ๊ทธ ์—ฐ๊ฒฐ )
      const hashtagPostPromiseList = results.map(hashtag => hashtag[0].addPostHashtaged(createdPost._id));
      await Promise.all(hashtagPostPromiseList);
    }

    return res
      .status(201)
      .json({ ok: true, message: "๊ฒŒ์‹œ๊ธ€์„ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค." });
  } catch (error) {
    console.error("POST /post error >> ", error);
    return next(error);
  }
});

3. ํŠน์ • ํ•ด์‹œํƒœ๊ทธ์— ํ•ด๋‹นํ•˜๋Š” ๊ฒŒ์‹œ๊ธ€๋“ค ์ฐพ๊ธฐ

  • /routes/posts.js
// ํŽ˜์ด์ง€ ๋„ค์ด์…˜, ์œ ์ €, ๋Œ“๊ธ€ ์ฒ˜๋Ÿผ ํ˜„ ์ฃผ์ œ์— ๋ฒ—์–ด๋‚˜๋Š” ๋‚ด์šฉ์€ ์ œ์™ธํ–ˆ์Šต๋‹ˆ๋‹ค.

router.get("/hashtag/:hashtagText", async (req, res, next) => {
  const hashtagText = decodeURI(req.params.hashtagText);

  try {
    // ํŠน์ • ํ•ด์‹œํƒœ๊ทธ ์ฐพ๊ธฐ
    const hashtag = await Hashtag.findOne({
      where: { content: hashtagText },
    });

    if (!hashtag)
      return res.status(200).json({
        ok: true,
        message: "ํ•ด์‹œํƒœ๊ทธ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.",
        limit,
        posts: [],
        postCount: 0,
        hashtag: hashtagText,
      });
    
    // ์•„๋ž˜ ๋‘ ๊ฐœ์˜ ๋ฉ”์„œ๋“œ ์ด๋ฆ„์ด "PostHashtaged"์ธ ์ด์œ ๋Š” ์ด์ „์— "as"๋ฅผ ์ด์šฉํ•ด์„œ ์ •์˜ํ•ด์ค€ ์ด๋ฆ„์ด๊ธฐ ๋•Œ๋ฌธ

    // ํŠน์ • ํ•ด์‹œํƒœ๊ทธ์™€ ๊ด€๊ณ„์žˆ๋Š” ๊ฒŒ์‹œ๊ธ€๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ
    const postsOfHashtag = await hashtag.getPostHashtaged({
      attributes: ["_id", "createdAt", "content", "UserId"],
    });

    // ํŠน์ • ํ•ด์‹œํƒœ๊ทธ์™€ ๊ด€๊ณ„์žˆ๋Š” ๋ชจ๋“  ๊ฒŒ์‹œ๊ธ€ ๊ฐœ์ˆ˜ ๊ตฌํ•˜๊ธฐ ( ์›๋ž˜๋Š” 10๊ฐœ์”ฉ ๋ถ„ํ• ์œผ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ ๋•Œ๋ฌธ์— ์ด ๊ฐœ์ˆ˜๋ฅผ ๋”ฐ๋กœ ๊ตฌํ•˜๋Š” ๊ฒƒ )
    const postsOfHashtagCount = await hashtag.countPostHashtaged();

    res.status(200).json({
      ok: true,
      posts: postsOfHashtag,
      postCount: postsOfHashtagCount,
      hashtag: hashtagText,
    });
  } catch (error) {
    console.error("GET /post/hashtag/:hashtag error >> ", error);
    return next(error);
  }
});

โœจ ํ•ด์‹œํƒœ๊ทธ ๋ Œ๋”๋ง

1. ํ•ด์‹œํƒœ๊ทธ ๋งํฌ ์ฒ˜๋ฆฌ

// ๋ Œ๋”๋งํ•  ๊ฒŒ์‹œ๊ธ€์˜ ๋‚ด์šฉ์— ํ•ด์‹œํƒœ๊ทธ๊ฐ€ ์กด์žฌํ•œ๋‹ค๋ฉด ํ•ด์‹œํƒœ๊ทธ์— ๋งํฌ๋ฅผ ๋‹ฌ์•„์„œ ํด๋ฆญ ์‹œ ํ•ด๋‹น ํ•ด์‹œํƒœ๊ทธ๋ฅผ ํฌํ•จํ•˜๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๊ฒŒ ๋งŒ๋“ฆ
const preprocessHashtag = useCallback((contents: string) => {
  return contents.split(/(#[^\s#]+)/gm).map((text) => {
    if (text[0] !== "#") return text;

    return (
      <Link
        key={text}
        href={`/hashtag/${encodeURI(text.substr(1, text.length))}`}
      >
        <a className="post-card-content-hashtag">{text}</a>
      </Link>
    );
  });
}, []);

0๊ฐœ์˜ ๋Œ“๊ธ€