본문 바로가기
개발자 도전기/[STUDY] JS || TS

ts-node | NodeBird | 라우터 만들기(2)

by 답수 2022. 5. 2.
728x90
SMALL

 

 

※ 인프런 - Node.js에 TypeScript 적용하기(feat. NodeBire) by 조현영 강의를 기반으로 정리한 내용입니다.

 

 

routes/user.ts

저번에 user라우터를 만들고 있었고, 이어서 진행해보자. user라우터는 다음과 같이 완성됐다.

import * as express from 'express';
import * as bcrypt from 'bcrypt';
import * as passport from 'passport';

import { isLoggedIn, isNotLoggedIn } from './middleware';
import User from '../models/user';
import Post from '../models/post';
import Image from '../models/image';

const router = express.Router();

// 사용자 정보 불러오기
router.get('/', isLoggedIn, (req, res) => {     // get은 req, res에 타입 정의가 되어있기 때문에 따로 타입핑 안해도 됨
    const user = req.user!.toJSON();
    delete user.password;
    return res.json(user);
});

// 회원가입
router.post('/', async(req, res, next) => {
    try {
        const exUser = await User.findOne({      // 먼저 회원이 가입되어 있는지 여부 확인
            where: {
                userId: req.body.userId,
            }
        });
        if (exUser) return res.status(403).send('이미 사용 중인 아이디입니다.');    // 아이디 중복 체크
        const hashedPassword = await bcrypt.hash(req.body.password, 12);    // 두 번째 매개변수 숫자 클수록 암호화 보안↑, 그러나 암호화 시간↑ 컴퓨터 성능에 따라 조절 잘해야 함
        const newUser = await User.create({     // 중복된 아이디 아니라면 새로운 유저 생성
            nickname: req.body.nickname,
            userId: req.body.userId,
            password: hashedPassword,
        });
        return res.status(200).json(newUser);
    } catch(err) {
        console.error(err);
        next(err);
    }
});

// 로그인
router.post('/login', isNotLoggedIn, (req, res, next) => {
    passport.authenticate('local', (err: Error, user: User, info: { message: string }) => {   // 타입추론이 any라면 직접 정의하는 것이 좋음
        if (err) {
            console.error(err);
            return next(err);
        }
        if (info) {
            return res.status(401).send(info.message);
        }
        return req.login(user, async(loginErr: Error) => {
            try {
                if (loginErr) return next(loginErr);
                const fullUser = await User.findOne({
                    where: { id: user.id },
                    include: [{
                        model: Post,
                        as: 'Posts',
                        attributes: ['id'],
                    }, {
                        model: User,
                        as: 'Followings',
                        attributes: ['id']
                    }, {
                        model: User,
                        as: 'Followers',
                        attributes: ['id']
                    }],
                    attributes: {
                        exclude: ['password'],      // 내 정보 불러오는 것이기 때문에 비밀번호 제외한 정보 다 불러오기
                    }
                });
                return res.json(fullUser);
            } catch (e) {
                console.error(e);
                return next(e);
            }
        });
    })(req, res, next);
});

// 로그아웃
router.post('/logout', isLoggedIn, (req, res) => {
    req.logout();
    req.session!.destroy(() => {
        res.send('logout 성공!');
    });
});

// 특정 사용자 정보 불러오기
interface IUser extends User {      // 한 번만 쓰이는 경우 그 파일에만, 여러번 사용되면 type 파일에 모아두기(개인 코딩 스타일대로!)
    PostCount: number;
    FollowingCount: number;
    FollowerCount: number;
}
router.get('/:id', async(req, res, next) => {   
    /**
     * 왜 '/:id' 이런 식?
     * :id처럼 주소에 넣어주면 나중에 로그를 볼 때 주소만 보고도 어떤 데이터에 작업을 했는지 알아볼 수 있기 때문
     */
    try {
        const user = await User.findOne({
            where: { id: parseInt(req.params.id, 10)},
            include: [{
                model: Post,
                as: 'Posts',
                attributes: ['id'],
            }, {
                model: User,
                as: 'Followings',
                attributes: ['id']
            }, {
                model: User,
                as: 'Followers',
                attributes: ['id']
            }],
            attributes: ['id', 'nickname'],     // 남의 정보 가져오는 것이기 때문에 아이디와 닉네임만
        });
        if (!user) return res.status(404).send('No user!');
        const jsonUser = user.toJSON() as IUser;
        jsonUser.PostCount = jsonUser.Posts ? jsonUser.Posts.length : 0;
        jsonUser.FollowingCount = jsonUser.Followings ? jsonUser.Followings.length : 0;
        jsonUser.FollowerCount = jsonUser.Followers ? jsonUser.Followers.length : 0;
        return res.json(jsonUser);
    } catch(err) {
        console.error(err);
        return next(err);
    }
});

router.get('/:id/followings', isLoggedIn, async(req, res, next) => {
    try {
        // 항상 먼저 해당 사용자가 존재하는지 먼저 찾아보기! 탈퇴했을수도 있으니까
        const user = await User.findOne({
            where: { id: parseInt(req.params.id, 10) || (req.user && req.user.id) || 0 },
        });
        if (!user) return res.status(404).send('No user');
        // 그러고 나서 팔로워 찾기
        const followings = await User.getFollowings({
            attributes: ['id', 'nickname'],
            limit: parseInt(req.query.limit as string, 10),     // string으로 타입 강제해서 에러 해결
            offset: parseInt(req.query.offset as string, 10),
        });
        return res.json(followings);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

router.get('/:id/followers', isLoggedIn, async(req, res, next) => {
    try {
        // 항상 먼저 해당 사용자가 존재하는지 먼저 찾아보기! 탈퇴했을수도 있으니까
        const user = await User.findOne({
            where: { id: parseInt(req.params.id, 10) || (req.user && req.user.id) || 0 },
        });
        if (!user) return res.status(404).send('No user');
        // 그러고 나서 팔로워 찾기
        const followers = await User.getFollowers({
            attributes: ['id', 'nickname'],
            limit: parseInt(req.query.limit as string, 10),
            offset: parseInt(req.query.offset as string, 10),
        });
        return res.json(followers);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

// 팔로워 삭제
router.delete('/:id/follower', isLoggedIn, async (req, res, next) => {
    try {
        const me = await User.findOne({
            where: { id: req.user!.id },
        });
        await me!.removeFollower(parseInt(req.params.id, 10));
        res.send(req.params.id);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

// 팔로우하기
router.post('/:id/follow', isLoggedIn, async (req, res, next) => {
    try {
        const me = await User.findOne({
            where: {id: req.user!.id},
        });
        await me!.addFollowing(parseInt(req.params.id, 10));
        res.send(req.params.id);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

// 팔로우 취소
router.delete('/:id/follow', isLoggedIn, async (req, res, next) => {
    try {
        const me = await User.findOne({
            where: {id: req.user!.id},
        });
        await me!.removeFollowing(parseInt(req.params.id, 10));
        res.send(req.params.id);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

// 게시글 가져오기
router.get('/:id/posts', async (req, res, next) => {
    try {
        const posts = await Post.findAll({
          where: {
            UserId: parseInt(req.params.id, 10) || (req.user && req.user.id) || 0,  // 유저 아이디가 특정 사람의 아이디 || 내 아이디 || 내 아이디마저 없다면 0 넣는 꼼수
            RetweetId: null,
          },
          include: [{
            model: User,
            attributes: ['id', 'nickname'],
          }, {
            model: Image,
          }, {
            model: User,
            as: 'Likers',
            attributes: ['id'],
          }],
        });
        res.json(posts);
      } catch (err) {
        console.error(err);
        return next(err);
      }
});

// 닉네임 수정
router.patch('/nickname', isLoggedIn, async (req, res, next) => {
    try {
      await User.update({
        nickname: req.body.nickname,
      }, {
        where: { id: req.user!.id },
      });
      res.send(req.body.nickname);
    } catch (err) {
      console.error(err);
      return next(err);
    }
  });

export default router;

 

user 라우터에 맞게 user 모델 코드도 수정, 보완한다.

import { 
    BelongsToManyAddAssociationMixin,
    BelongsToManyGetAssociationsMixin, BelongsToManyRemoveAssociationMixin, 
    DataTypes, HasManyGetAssociationsMixin, Model 
} from 'sequelize';
import { dbType } from '.';
import Post from './post';
import { sequelize } from './sequelize';

class User extends Model {
    public readonly id!: number;   // !를 붙이는 이유? 반드시 존재한다는 것을 시퀄라이즈에 확신시키는 것
    public nickname!: string;
    public userId!: string;
    public password!: string;
    public readonly createAt!: Date;    // 시퀄라이즈 내에서 자체적으로 수정하기 때문에 readonly로
    public readonly updateAt!: Date;

    public readonly Posts?: Post[];
    public readonly Followers?: User[];
    public readonly Followings?: User[];

    static getFollowings: BelongsToManyGetAssociationsMixin<User>;     // The getAssociations mixin applied to models with belongsToMany
    static getFollowers: BelongsToManyGetAssociationsMixin<User>;
    public addFollowing!: BelongsToManyAddAssociationMixin<User, number>;
    public removeFollowing!: BelongsToManyRemoveAssociationMixin<User, number>;     // remove는 제네릭이 두 개 필요함
    public removeFollower!: BelongsToManyRemoveAssociationMixin<User, number>;
    public getPost!: HasManyGetAssociationsMixin<Post>;
}

User.init({
    nickname: {
        type: DataTypes.STRING(20),
    },
    userId: {
        type: DataTypes.STRING(20),
        allowNull: false,
        unique: true
    },
    password: {
        type: DataTypes.STRING(100),
        allowNull: false
    }
}, {    // 시퀄라이즈로 모델과 연동
    sequelize,
    modelName: 'User',
    tableName: 'user',
    charset: 'utf8',    // 한글 인식 가능하도록
    collate: 'utf8_general_ci'
});

// 모델간 관계 형성
export const associate = (db: dbType) => {
    db.User.hasMany(db.Post, { as: 'Posts' });
    db.User.hasMany(db.Comment);
    db.User.belongsToMany(db.Post, { through: 'Like', as: 'Liked'});
    db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followings', foreignKey: 'followerId' });  // as가 가리키는 것과 foreignKey가 가리키는 것은 서로 반대
    db.User.belongsToMany(db.User, { through: 'Follow', as: 'Followers', foreignKey: 'followingId' });
}

export default User;

 

 

Sequelize를 통한 모델간 관계

위의 유저 모델 코드에서 보는 것과 같이 시퀄라이즈를 사용하려면 직접 코드를 짜서 관계를 형성해야 한다. 위 코드로 보면 User는 Post, Comment와 1:N 관계를 가지고 있고 이는 hasMany를 통해 구현했고 팔로우, 좋아요 기능을 위해 User의 N:M 관계를 belongsToMany로 지정해줬다.

(이때 through 속성은 N:M 관계에서 소스 및 대상을 결합하는데 사용되는 테이블의 이름이다.)

 

이렇게 관계를 지정하면 User.getFollowers() 같은 관계에 상응하는 여러 메소드들이 생성된다. 하지만 타입스크립트는 런타임에 제공되는 메소드들을 가지고 있지 않기 때문에 직접 타이핑을 해줘야 했고, 그래서 User 클래스에서 우리가 사용할 메소드들을 직접 입력했던 것이다.

class User extends Model {
    public readonly id!: number;   // !를 붙이는 이유? 반드시 존재한다는 것을 시퀄라이즈에 확신시키는 것
    public nickname!: string;
    public userId!: string;
    public password!: string;
    public readonly createAt!: Date;    // 시퀄라이즈 내에서 자체적으로 수정하기 때문에 readonly로
    public readonly updateAt!: Date;

    public readonly Posts?: Post[];
    public readonly Followers?: User[];
    public readonly Followings?: User[];

    static getFollowings: BelongsToManyGetAssociationsMixin<User>;     // The getAssociations mixin applied to models with belongsToMany
    static getFollowers: BelongsToManyGetAssociationsMixin<User>;
    public addFollowing!: BelongsToManyAddAssociationMixin<User, number>;
    public removeFollowing!: BelongsToManyRemoveAssociationMixin<User, number>;     // remove는 제네릭이 두 개 필요함
    public removeFollower!: BelongsToManyRemoveAssociationMixin<User, number>;
    public getPost!: HasManyGetAssociationsMixin<Post>;
}

 

  • 각 메소드에 대한 설명

라우트를 작성하면서 타입스크립트를 사용하는 경우가 모델 부분에서 관계를 형성하는 것 외에는 별로 없었다. 라우터 자체는 그냥 로직이다. 관계를 선언하고 관계에 따른 메소드에 타이핑을 해야 한다.

 

routes/post.ts

다음으로 게시글에 대한 라우트를 만들 것이다. 그전에 post 모델에 관계를 먼저 지정해준다.

import { dbType } from ".";
import { sequelize } from "./sequelize";
import { BelongsToManyAddAssociationsMixin, DataTypes, HasManyAddAssociationMixin, HasManyAddAssociationsMixin, Model } from "sequelize";
import Hashtag from "./hashtag";
import Image from "./image";

class Post extends Model {
    public readonly id!: number;
    public content!: string;
    public readonly createAt!: Date;
    public readonly updateAt!: Date;

    public addHashTags!: BelongsToManyAddAssociationsMixin<Hashtag, number>;
    public addImages!: HasManyAddAssociationsMixin<Image, number>;
    public addImage!: HasManyAddAssociationMixin<Image, number>;
}

Post.init({
    content: {
        type: DataTypes.TEXT,
        allowNull: false,
    }
}, {
    sequelize,
    modelName: 'Post',
    tableName: 'post',
    charset: 'utf8mb4', // 이모티콘 등의 문자들도 많이 사용하기 때문에
    collate: 'utf8mb4_general_ci'   // 텍스트 정렬할 때 a 다음에 b 가 나타나야 한다는 생각으로 나온 정렬방식. 일반적으로 널리 사용
});

// 모델간 관계 형성
export const associate = (db: dbType) => {
    db.Post.belongsTo(db.User);     // 게시글을 작성한 사람
    db.Post.hasMany(db.Comment);    // 게시글은 여러 개의 댓글 가지고 있음
    db.Post.hasMany(db.Image);      // 게시글은 여러 개의 이미지를 가지고 있음
    db.Post.belongsTo(db.Post, { as: 'Retweet' });      // 하나의 게시글은 다른 게시글에 리트윗이 될 수 있음
    db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' });           // 해시태그와 다대다 관계
    db.Post.belongsToMany(db.User, { through: 'Like', as: 'Likers' });       // 게시글은 좋아요를 누른 사용자와 다대다 관계
};
// 관계에 대한 코드 작성하였다면, 이에 맞는 라우트 작성하기 (routes/post.ts)

export default Post;

 

이후 post 라우터를 만들기 위해서 설치해야 할 모듈들이다.

npm i multer @types/multer multer-s3 @types/multer-s3 aws-sdk
  • multer: 파일 업로드를 위해 사용되는 multipart/form-data를 다루기 위한 node.js의 미들웨어
  • multer-s3: 이미지 업로드 시 로컬 서버가 아닌 아마존의 S3에 업로드
  • aws-sdk: S3에 업로드하기 위해 aws 설정 

 

우선 AWS 설정 코드를 작성한다.

// AWS 설정
AWS.config.update({
    region: 'ap-northeast-2',
    accessKeyId: process.env.S3_ACCESS_KEY_ID,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
});

 

multer를 이용하여 S3 버킷을 생성하고 지정된 버킷에 객체를 업로드하도록 코드를 작성한다.

const upload = multer({
    storage: multerS3({
        s3: new AWS.S3(),
        bucket: 'ts-nodebird',
        key(req, file, cb) {
            cb(null, `orginal/${+new Date()}${path.basename(file.originalname)}`);
        },
    }),
    limits: { fileSize: 20 * 1024 * 1024 },
});
  • storage: 파일이 저장될 위치로, S3에 저장될 수 있도록 설정
  • bucket: 파일을 저장하는데 사용되는 버킷
  • key: 파일의 이름
  • limits: 업로드된 데이터의 한도를 설정

 

다음으로 게시글과 관련된 라우터를 구현한다.

router.post('/', isLoggedIn, upload.none(), async (req, res, next) => {
    try {
        const hashtags: string[] = req.body.content.match(/#[^\s]+/g);
        const newPost = await Post.create({
            content: req.body.content,
            UserId: req.user!.id,
        });
        if (hashtags) {
            const promises = hashtags.map((tag) => Hashtag.findOrCreate({
                where: { name: tag.slice(1).toLowerCase() },
            }));
            const result = await Promise.all(promises);
            await newPost.addHashTags(result.map(r => r[0]));
        }
        if (req.body.image) {
            if (Array.isArray(req.body.image)) {    // 이미지가 여러 장일 때
                const promises = req.body.image.map((image: string) => Image.create({ src: image }));
                const images = await Promise.all(promises);
                await newPost.addImages(images);
            }
            else {
               const image = await Image.create({ src: req.body.image });
               await newPost.addImage(image);       // 시퀄라이즈에서는 단수, 복수도 신경써야 함
            }
        }
        const fulllPost = await Post.findOne({      // 게시글 가져오기
            where: { id: newPost.id },
            include: [{
                model: User,
                attributes: ['id', 'nickname'],
            }, {
                model: Image,
            }, {
                model: User,
                as: 'Likers',
                attributes: ['id'],
            }],
        });
        return res.json(fulllPost);
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

 

 

back/index.ts 파일에 user, post 라우터 미들웨어를 추가한다.

import * as express from "express";
import * as morgan from "morgan";
import * as cors from "cors";
import * as cookieParser from "cookie-parser";
import * as expressSession from "express-session";
import * as dotenv from "dotenv";
import * as passport from "passport";
import * as hpp from "hpp";
import helmet from "helmet";

import { sequelize } from './models';
import userRouter from './routes/user';
import postRouter from './routes/post';

dotenv.config();
const app = express();
// 환경변수 설정
const prod: boolean = process.env.NODE_ENV === 'production'; // 배포용

app.set('port', prod ? process.env.PORT : 3065);    // 배포용이면 포트 자유자재로 바꿀 수 있도록, 개발용이면 3065로 고정

// 시퀄라이즈
sequelize.sync({ force: false })    // true면 서버 재시작할 때마다 db 초기화됨(배포 때 재앙;;). 나중에 개발할 때 테이블 컬럼 등 수정요소 있으면 true
    .then(() => {
        console.log('데이터베이스 연결 성공!');
    })
    .catch((err: Error) => {
        console.error(err);
    });

// 미들웨어 장착
if (prod) {
    app.use(hpp());
    app.use(helmet());
    app.use(morgan('combined'));
    app.use(cors({
        origin: /nodebird\.com$/,
        credentials: true
    }));
} else {
    app.use(morgan('dev'));
    app.use(cors({
        origin: true,
        credentials: true
    }));
}

app.use('/', express.static('uploads'));
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(expressSession({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET!,    // 타입스크립트에서는 dotenv 인식 못 함. ! 를 통해 에러 없앰
    cookie: {
        httpOnly: true, // //자바스크립트를 통해 세션 쿠키를 사용할 수 없도록 함
        secure: false,  // true일 시 https 환경에서만 세션 처리 가능
        domain: prod ? '.nodebird.com' : undefined
        // domain: prod && '.nodebird.com'  // domain은 string | undefined 타입 형식으로 에러남(js에서는 문제 없음)
    },
    name: 'rnbck'
}));
app.use(passport.initialize());
app.use(passport.session());
app.use('/user', userRouter);
app.use('/post', postRouter);
app.get('/', (req, res) => {
    res.send('nodebird 백엔드 정상 동작!');
});

app.listen(app.get('port'), () => {
    console.log(`server is running on ${app.get('port')}`);
});

 

 

더 구현해야 할 라우터들이 있지만, 우선 여기까지 작성하고 한 번 테스트를 돌려보자.

 

연결 잘 된다! 다음 작업도 계속 이어서 가보자구우우

728x90
LIST

댓글