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

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

by 답수 2022. 4. 22.
728x90
SMALL

 

 

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

 

 

타입스크립트를 포함한 익스프레스의 라우터를 구현해보자.

 

routes/middleware.ts

우선 미들웨어 파일을 만들 것이다. 이 파일에는 현재 사용자의 인증 여부를 파악하는 함수들을 만든다. 

import {Request, Response, NextFunction} from 'express';

const isLoggedIn = (req: Request, res: Response, next: NextFunction) => {
    if (req.isAuthenticated()) next();
    else res.status(401).send('로그인이 필요합니다!');
};

const isNotLoggedIn = (req: Request, res: Response, next: NextFunction) => {
    if (!req.isAuthenticated()) next();
    else res.status(401).send('로그인한 사용자는 접근할 수 없습니다.');
};

export { isLoggedIn, isNotLoggedIn };

 

isAuthenticated() 메소드는 로그인 판단 여부를 확인하는 기능을 한다. isLoggedIn 변수의 req에 담겨 있는 유저의 정보가 로그인되어 있으면 이 메소드는 true를 반환하고, next()를 호출해서 다음 작업을 실행하게 한다. 로그인이 유효하지 않다면 401에러(클라이언트는 해당 리소스에 접근할 자격이 없음)를 반환하게 된다.

 

여기서 각 변수의 매개변수에 Request, Response, NextFunction의 타입 정의를 직접 했다. 라우터와 직접 연결되어 있으면 알아서 타입 추론이 되기 때문에 타이핑을 할 필요가 없지만, 라우터와 분리되는 순간 타이핑을 해야 한다. 타입스크립트 입장에서 위의 변수들은 그저 하나의 함수일 뿐이기 때문에 매개변수와 리턴값(위의 함수들은 리턴값이 없어서 여기서는 제외)을 직접 타이핑해야 한다.

 

routes/user.ts

user.ts에는 사용자 정보와 관련된 라우터들을 작성한다.

import * as express from 'express';
import * as bcrypt from 'bcrypt';
import { isLoggedIn } from './middleware';
import User from '../models/user';

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.get()의 user 변수를 보면 req.user 뒤에 ! 를 붙이고 있다. 이는 타입스크립트의 한계 때문에 적어야 하는 것인데, !가 없다면 개체가 'undefined'인 것 같습니다.ts(2532) 와 같은 에러 문구가 뜬다.

실제로는 isLoggedIn 함수를 통과해야 user 변수를 실행할 수 있다. 즉, isLoggedIn 미들웨어를 통과했다는 말은 req.user가 존재한다는 것을 증명한다는 말이다.

하지만 타입스크립트는 타입을 추론만 할 뿐 실제 로직이 어떻게 돌아가는지 모르기 때문에 req.user가 있는지 없는지 알 수 없어서 에러가 나는 것이다.  그래서 저렇게 뒤에 !를 붙인다.

 

다음으로 로그인 라우터를 추가한다.

// 로그인
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);
});

 

라우터 안에 보면 passport.authenticat()라는 메소드가 보인다. passport.authenticat()는 req에 있는 유저의 데이터로 인증하는 미들웨어다. 인증이 성공한다면 req.user 속성이 인증된 사용자로 성정된다. 이 메소드는 두 개의 매개 변수를 가진다. 첫 번째는 strategy이고 두 번째는 콜백함수다. 여기서 strategy는 리퀘스트를 인증하는 기능이고, 리퀘스트는 인증 메커니즘을 구현함으로써 수행된다. 인증 메커니즘은 리퀘스트의 암호나 아이디를 인코딩하는 방법이다. strategy의 기본 값은 'local'이다. 우리 코드도 strategy에 'local'을 적었다. 로컬은 username과 password를 확인하고 이를 통해 콜백 함수를 실행한다.

*ref

 

다음은 로그아웃 라우터

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

 

특정 사용자 정보를 불러오는 라우터

// 특정 사용자 정보 불러오기
interface IUser extends User {
    PostCount: number;
    FollowingCount: number;
    FollowerCount: number;
}
router.get('/:id', async(req, res, next) => {   
    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);
    }
});

 

jsonUser 변수를 보면, 해당 유저의 sns에 해당하는 포스트 개수와 팔로잉, 팔로워 수를 가져와야 하는데 이와 관련된 코드들이 User클래스에서 작성하지 않았었다. 이를 작업하기 위해서 User 클래스에 추가를 하거나, type만 모아둔 파일에 해당 인터페이스를 추가하면 되지만 강사님 같은 경우는 사용하는 코드의 바로 위에 인터페이스를 정의했다. 이 속성들은 여기서 한 번만 사용되기 때문에 오히려 다른 곳에 쓰는 것이 더 가독성이 떨어질 수도 있기 때문이라고 생각한다. 이런 부분은 개발자 개인의 코딩 스타일이라고 하는데 나도 이 방법이 마음에 든다!

 

아 그리고 또! api 엔드포인트를 '/:id' 이런 식으로 한 이유는 :id 같은 키워드를 주소에 넣어주면 나중에 로그를 볼 때 주소만 보고도 어떤 데이터에 작업을 했는지 더 쉽게 알아볼 수 있기 때문이라고 한다. 이런 사소한 부분들이 유지보수하는데 꽤 도움이 될 것 같다.

 

마지막으로 사용자를 팔로잉하는 사람들의 정보를 불러오는 라우터

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'],
        })
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

 

followings 변수를 보면 User.getFollowings() 라는 메소드가 있다. 이 메소드는 기존에 만들지 않았던 부분이기 때문에 User클래스에 추가해야 한다.

// back/models/user.ts

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 addFollowing: BelongsToManyAddAssociationMixin<User, number>;
    static getFollowings: BelongsToManyGetAssociationsMixin<User>;
    static removeFollowings: BelongsToManyRemoveAssociationMixin<User, number>;     // remove는 제네릭이 두 개 필요함
    static getFollowers: BelongsToManyGetAssociationsMixin<User>;
    static removeFollowers: BelongsToManyRemoveAssociationMixin<User, number>;
    static 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.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;

 

사실 강사님은 getFollowings를 public으로 선언했었는데, 나는 에러가 나서 static으로 바꿨다. 코드를 봐도 인스턴스에서 메소드를 사용하는 것이 아니라, User 클래스에서 바로 이름을 호출하기 때문에 static을 사용하는 것이 맞는 것 같다.

 

그런데...

 

getFollowings 타입 형식인 BelongsToManyGetAssociationsMixin 은 뭘까..? 

(alias) type BelongsToManyGetAssociationsMixin<TModel> = (options?: BelongsToManyGetAssociationsMixinOptions | undefined) => Promise<TModel[]>
import BelongsToManyGetAssociationsMixin
The getAssociations mixin applied to models with belongsToMany. An example of usage is as follows:


User.belongsToMany(Role, { through: UserRole });

interface UserInstance extends Sequelize.Instance<UserInstance, UserAttributes>, UserAttributes {
 getRoles: Sequelize.BelongsToManyGetAssociationsMixin<RoleInstance>;
 // setRoles...
 // addRoles...
 // addRole...
 // createRole...
 // removeRole...
 // removeRoles...
 // hasRole...
 // hasRoles...
 // countRoles...
}

 

BelongToMany는 데이터베이스 n:m 관계 설정시 사용한다. 하지만 데이터베이스에서 이를 직접 구현할 수 없기 때문에 이러한 관계를 일대다 관계로 분리해야 한다. 우리의 코드 맨 아래 모델간 관계 형성한 코드를 보자.

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

 

이렇게 관계를 설정하면 관계를 중개하는 하나의 테이블(모델)이 생성된다. 그리고 이때 through 속성은 테이블의 이름을 정하기 때문에 필수 속성이다. 그래서 저 위의 속성이 뭔데...? 내일 강의 들으면서 다시 공부해보자.

728x90
LIST

댓글