본문 바로가기
JavaScript/Node.js

Node) RESTful API 정리

by 박채니 2023. 1. 4.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
개인 포스팅용으로 내용에 오류 및 잘못된 정보가 있을 수 있습니다.

 

RESTful API란?

https://spoqa.github.io/2012/02/27/rest-introduction.html

 

REST 아키텍처를 훌륭하게 적용하기 위한 몇 가지 디자인 팁

최근의 서버 프로그램은 여러 웹 브라우저는 물론이며, 아이폰, 안드로이드 애플리케이션과의 통신에 대응해야 합니다. 이번 글에선 여러 문제를 지혜롭게 대처할 수 있는 REST 아키텍처에 대해

spoqa.github.io

https://meetup.nhncloud.com/posts/92

 

REST API 제대로 알고 사용하기 : NHN Cloud Meetup

REST API 제대로 알고 사용하기

meetup.nhncloud.com

https://sanghaklee.tistory.com/57

 

RESTful API 설계 가이드

1. RESTful API 설계 가이드본 문서는 REST API를 좀 더 RESTful 하게 설계하도록 가이드할 목적으로 만들어졌다.따라서, 기본적인 REST API 개념 설명은 아래의 링크로 대신한다. REST API 제대로 알고 사용

sanghaklee.tistory.com

 


RESTful API 만들기

 

models/index.ts

import { Sequelize } from "sequelize";
import config from "../config/config";
import User from "./user";
import Post from "./post";
import Tag from "./tag";
import PostTag from "./postTag";

interface IDb {
  sequelize: Sequelize;
}

// MySQL 연결 객체 생성
const { database, username, password } = config["development"];
let sequelize = new Sequelize(
  database,
  username,
  password,
  config["development"]
);
const db: IDb = { sequelize };

User.initiate(sequelize);
Post.initiate(sequelize);
Tag.initiate(sequelize);
PostTag.initiate(sequelize);

User.associate();
Post.associate();
Tag.associate();

export default db;

 

models/user.ts

import { Sequelize, Model, DataTypes } from "sequelize";
import Post from "./post";

class User extends Model {
  public readonly id!: number;
  public username!: string;
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;

  static initiate(sequelize: Sequelize) {
    User.init(
      {
        username: {
          type: DataTypes.STRING(10),
          allowNull: false,
        },
      },
      {
        sequelize,
        timestamps: true,
        underscored: true,
        modelName: "User",
        tableName: "users",
        paranoid: false,
        charset: "utf8",
        collate: "utf8_general_ci",
      }
    );
  }

  static associate() {
    User.hasMany(Post, { foreignKey: "user_id" });
  }
}

export default User;

 

models/post.ts

import {
  Sequelize,
  Model,
  DataTypes,
  BelongsToManyAddAssociationMixin,
} from "sequelize";
import User from "./user";
import Tag from "./tag";

class Post extends Model {
  public readonly id!: number;
  public title!: string;
  public content!: string;
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;
  public tags!: Tag[];

  public addTag!: BelongsToManyAddAssociationMixin<Tag, string>;

  static initiate(sequelize: Sequelize) {
    Post.init(
      {
        title: {
          type: DataTypes.STRING(15),
          allowNull: false,
        },
        content: {
          type: DataTypes.STRING(200),
          allowNull: false,
        },
      },
      {
        sequelize,
        timestamps: true,
        underscored: true,
        modelName: "Post",
        tableName: "posts",
        paranoid: false,
        charset: "utf8mb4",
        collate: "utf8mb4_general_ci",
      }
    );
  }

  static associate() {
    Post.belongsTo(User, { foreignKey: "user_id" });
    Post.belongsToMany(Tag, { through: "postTag" });
  }
}

export default Post;

 

models/tag.ts

import { Sequelize, Model, DataTypes } from "sequelize";
import db from ".";
import Post from "./post";

interface TagAttributes {
  title: string;
}

class Tag extends Model<TagAttributes> {
  public readonly id!: number;
  public title!: string;

  static initiate(sequelize: Sequelize) {
    Tag.init(
      {
        title: {
          type: DataTypes.STRING(10),
          allowNull: false,
          //   unique: true,
        },
      },
      {
        sequelize,
        timestamps: false,
        underscored: true,
        modelName: "tag",
        tableName: "tags",
        paranoid: false,
        charset: "utf8mb4",
        collate: "utf8mb4_general_ci",
      }
    );
  }

  static associate() {
    Tag.belongsToMany(Post, { through: "postTag" });
  }
}

export default Tag;

 

models/postTag.ts → 추후 브릿지 테이블에서 삭제를 위해 모델 생성

import { Sequelize, Model, DataTypes } from "sequelize";

class PostTag extends Model {
  public post_id!: number;
  public tag_id!: number;
  public readonly createdAt!: Date;
  public readonly updatedAt!: Date;

  static initiate(sequelize: Sequelize) {
    PostTag.init(
      {
        post_id: {
          type: DataTypes.INTEGER,
          allowNull: false,
        },
        tag_id: {
          type: DataTypes.INTEGER,
          allowNull: false,
        },
      },
      {
        sequelize,
        timestamps: true,
        underscored: true,
        modelName: "PostTag",
        tableName: "postTag",
        paranoid: false,
        charset: "utf8mb4",
        collate: "utf8mb4_general_ci",
      }
    );
  }
}

export default PostTag;

 

app.ts

import express from "express";
import db from "./models";
import dotenv from "dotenv";
import apiRouter from "./routes/apiRouter";
import bodyParser from "body-parser";

dotenv.config();

const app = express();
app.set("port", process.env.PORT);

db.sequelize
  .sync({ force: false })
  .then(() => {
    console.log("데이터베이스 연결 성공");
  })
  .catch((err) => {
    console.error(err);
  });

app.use(bodyParser.json());

app.use("/api", apiRouter);

app.listen(app.get("port"), () => {
  console.log(app.get("port"), "번에서 대기 중!");
});

 

routes/apiRouter.ts

import express from "express";
import userRouter from "./userRouter";
import postRouter from "./postRouter";

const app = express();

app.use("/users", userRouter);
app.use("/posts", postRouter);

export default app;

 

routes/userRoutes.ts

import express from "express";
import {
  getUser,
  getUsers,
  createUser,
  updateUser,
  deleteUser,
} from "../controllers/user";

const router = express.Router();

router.get("/", getUsers);
router.get("/:id", getUser);
router.post("/", createUser);
router.put("/:id", updateUser);
router.delete("/:id", deleteUser);

export default router;

 

controllers/user.ts

import User from "../models/user";
import { NextFunction, Request, Response } from "express";

// GET /users
const getUsers = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const users = await User.findAll();
    return res.status(200).json(users);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// GET /users/:id
const getUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id = Number(req.params.id);
    const user = await User.findOne({ where: { id } });
    if (!user) {
      return res.status(404).json({ message: "검색 결과가 없습니다." });
    }
    return res.status(200).json(user);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// POST /users
const createUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { username }: { username: string } = req.body;
    const user = await User.create({ username });

    return res.status(201).json(user);
  } catch (error) {
    console.log(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// PUT /users/:id
const updateUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { username }: { username: string } = req.body;
    const id = Number(req.params.id);
    const [affectedCount] = await User.update(
      {
        username,
      },
      {
        where: { id },
      }
    );

    if (!affectedCount) {
      return res.status(404).json({ message: "존재하지 않는 회원입니다." });
    }

    const user = await User.findOne({ where: { id } });
    return res.status(200).json(user);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// DELETE /users/:id
const deleteUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id = Number(req.params.id);
    await User.destroy({ where: { id } });
    return res.status(200).json({ message: "삭제 완료" });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

export { getUsers, getUser, createUser, updateUser, deleteUser };

 

 

routes/postRoutes.ts

import express from "express";
import {
  getPosts,
  getPost,
  createPost,
  updatePost,
  deletePost,
} from "../controllers/post";

const router = express.Router();

router.get("/", getPosts);
router.get("/:id", getPost);
router.post("/", createPost);
router.put("/:id", updatePost);
router.delete("/:id", deletePost);

export default router;

 

controllers/post.ts

import { NextFunction, Request, Response } from "express";
import Post from "../models/post";
import Tag from "../models/tag";
import PostTag from "../models/postTag";

interface IPost {
  title: string;
  content: string;
  user_id: number;
  tags: string[];
}

// GET /posts
const getPosts = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const posts = await Post.findAll({
      include: [{ model: Tag, through: { attributes: [] } }], // 브릿지 테이블의 컬럼은 조회에서 제거
    });
    return res.status(200).json(posts);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// GET /posts/:id
const getPost = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id = Number(req.params.id);
    const post = await Post.findOne({
      where: { id },
      include: [{ model: Tag }],
    });
    if (!post) {
      return res.status(404).json({ message: "게시글이 존재하지 않습니다." });
    }
    return res.status(200).json(post);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// POST /posts
const createPost = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { title, content, user_id, tags }: IPost = req.body;
    // 게시글 테이블에 저장
    const post = await Post.create({
      title,
      content,
      user_id,
    });
    // 태그 테이블에 저장
    if (tags) {
      tags.forEach(async (tag: string) => {
        // 안쓰는 변수는 _로 함, [모델, 생성여부] 리턴
        const [tagModel, _] = await Tag.findOrCreate({
          where: { title: tag },
        });
        // 브릿지 테이블에 저장
        await post.addTag(tagModel);
      });
    }
    return res.status(201).json(post);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// PUT /posts/:id
const updatePost = async (req: Request, res: Response, next: NextFunction) => {
  try {
    let { title, content, tags }: IPost = req.body;
    const id = Number(req.params.id);

    // 게시글 수정
    const [affectedCount] = await Post.update(
      { title, content },
      { where: { id } }
    );

    if (!affectedCount) {
      return res.status(404).json({ message: "존재하지 않는 게시글입니다." });
    }

    const post = await Post.findOne({
      where: { id },
      include: [{ model: Tag, through: { attributes: [] } }],
    });

    // 태그 수정
    if (tags) {
      // 게시글에 딸린 기존 태그들 조회
      const existedTags = await Tag.findAll({
        attributes: ["id", "title"],
        include: [{ model: Post, where: { id }, attributes: [] }],
      });

      existedTags.forEach((existedTag: Tag) => {
        // 기존에 존재하는 태그 - 유지 / tags 배열에서 삭제
        if (tags.includes(existedTag.title)) {
          tags = tags.filter((tag: string) => tag !== existedTag.title);
        } else {
          // 기존에 존재하지 않는 태그 - 기존 태그를 브릿지 테이블에서 삭제
          PostTag.destroy({ where: { tag_id: existedTag.id, post_id: id } });
        }
      });

      console.log(tags);
      // 태그 추가하기
      if (tags.length) {
        tags.forEach(async (tag: string) => {
          const [tagModel, _] = await Tag.findOrCreate({
            where: { title: tag },
          });
          // 브릿지 테이블에 저장
          await post!.addTag(tagModel);
        });
      }
    }
    return res.status(200).json(post);
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

// DELETE /posts/:id
const deletePost = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id = Number(req.params.id);
    await Post.destroy({ where: { id } });
    return res.status(200).json({ message: "게시글 삭제 완료" });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: "서버 오류" });
  }
};

export { getPosts, getPost, createPost, updatePost, deletePost };

회원 / 게시글 API 구현!

 

진행하면서 깨달은 지식 정리

 

  • postman으로 요청을 보냈을 때 req.body가 undefined 였던 현상

body-parser 미들웨어(요청의 본문에 있는 데이터를 해석해 req.body 객체로 만들어주는 역할)이 없어서 그랬던 것

app.ts 파일에 body-parser 미들웨어 추가하여 오류 해결

import bodyParser from "body-parser";

...

app.use(bodyParser.json());

...

 

  • include로 join하여 조회할 때, 브릿지 테이블의 컬럼들까지 다 같이 딸려나오는 현상 

 

다 딸려나오는 코드

const posts = await Post.findAll({
  include: [{ model: Tag }], // 브릿지 테이블의 컬럼은 조회에서 제거
});

여기서 attributes 속성을 적용해도 자꾸 딸려나와서.. 구글링해보니 아래와 같이 브릿지 테이블에 대한 attributes를 설정해야 했음!

const posts = await Post.findAll({
  include: [{ model: Tag, through: { attributes: [] } }], // 브릿지 테이블의 컬럼은 조회에서 제거
});

 

  • findOrCreate 혹은 update 등의 메소드 사용 시 리턴 값 받아 하드코딩..^^

하드코딩

    // 게시글 수정
    const success = await Post.update(
      { title, content },
      { where: { id } }
    );

    if (!success[0]) {
      return res.status(404).json({ message: "존재하지 않는 게시글입니다." });
    }

이처럼 update 시 리턴 값으로 Promise<[affectedCount:number]>를 주는데 이를 받아 [0]으로 하드코딩해서 가져오려고 했었음!

 

구조분해할당 이용

    // 게시글 수정
    const [affectedCount] = await Post.update(
      { title, content },
      { where: { id } }
    );

    if (!affectedCount) {
      return res.status(404).json({ message: "존재하지 않는 게시글입니다." });
    }

이처럼 받아서 사용하면 코드가 더 깔꼼쓰

 

이 때 만일, 리턴 값 중 사용하지 않는 값들은 '_'로 헷갈리지 않게 해주면 더 좋음

tags.forEach(async (tag: string) => {
  const [tagModel, _] = await Tag.findOrCreate({
    where: { title: tag },
  });

findOrCreate는 Promise<[M, boolean]> 값을 리턴([모델, 생성여부])하는데, 이를 [tagModel, created]로 받아주어야 하지만

created는 사용하지 않으므로 '_'로 처리!