9장. 노드버드 SNS 만들기

My_Code·2024년 2월 7일

Node.js 교과서

목록 보기
9/11
post-thumbnail

다음 내용은 인프런에서 공부한 내용을 복습하는 차원에서 기록한 것입니다.
출처 : https://www.inflearn.com/course/%EB%85%B8%EB%93%9C-js-%EA%B5%90%EA%B3%BC%EC%84%9C


💻 9.1 프로젝트 구조 갖추기

📌 폴더 구조 설정

✏️ views(템플릿 엔진), routes(라우터), public(정적 파일), passport(패스포트) 폴더 생성

  • app.js와 .env 파일도 생성

📌 라우터 생성

✏️ 라우터별 설명

  • routes/page.js: 템플릿 엔진을 렌더링하는 라우터
  • controllers/page.js: page.js에 해당하는 컨트롤러
  • views/layout.html: 프론트 엔드 화면 레이아웃(로그인/유저 정보 화면)
  • views/main.html: 메인 화면(게시글들이 보임)
  • views/profile.html: 프로필 화면(팔로잉 관계가 보임)
  • views/error.html: 에러 발생 시 에러가 표시될 화면
  • public/main.css: 화면 CSS

✏️ 서버 실행 후 모습


📌 컨트롤러와 서비스

✏️ 컨트롤러는 라우터의 마지막에 위치하는 미들웨어로 응답을 보내는 역할

  • 라우터 -> 컨트롤러(요청, 응답을 알고 있음, req랑 res) -> 서비스(요청, 응답을 모름)
  • 컨트롤러에서 사용자에게 보여지는 것까지 로직을 서비스라는 개념으로 한 번 더 분리
  • 서비스는 해당 로직을 담당하면서 요청(req)이나 응답(res)에 대해 모름
  • 서버는 항상 HTTP 요청만 받는 것이 아니기 때문에 요청이나 응답을 몰라야 함
  • 서버는 웹 소켓 요청을 받을 수도 있고, RPC라는 HTTP와 다른 프로토콜의 요청을 받을 수 있음
  • 서비스는 어떠한 요청이 오든 동일한 로직으로 수행해야 함
  • 현재는 완벽하게 컨트롤러와 서비스를 분리하진 않았지만 passport를 통한 로그인 시 localStrategy.js가 서비스의 역할을 한다고 생각함


💻 9.2 데이터베이스 세팅하기

📌 모델 생성

✏️ 모델별 설명

  • models/user.js: 사용자 테이블과 연결됨
    • provider: 카카오 로그인인 경우 kakao, 로컬 로그인(이메일/비밀번호)인 경우 local
    • snsId: 카카오 로그인인 경우 주어지는 id
  • models/post.js: 게시글 내용과 이미지 경로를 저장(이미지는 파일로 저장)
  • models/hashtag.js: 해시태그 이름을 저장(나중에 태그로 검색하기 위해서)

📌 model/index.js

✏️ 시퀄라이즈가 자동으로 생성해주는 코드 대신 다음과 같이 변경

  • 모델들을 models 폴더에서 자동으로 불러옴(readdirSync)
  • 모델 간 관계가 있는 경우 관계 설정
  • User(1):Post(다)
  • Post(다):Hashtag(다)
  • User(다):User(다)
const Sequelize = require('sequelize');
// const User = require('./user');
// const Post = require('./post');
// const Hashtag = require('./hashtag');
const fs = require('fs');
const path = require('path');

const env = process.env.NODE_ENV || 'development';
const config = require('../config/config.json')[env];
const db = {};
const sequelize = new Sequelize(  // config에 있는 설정값들을 통해 시퀄라이즈 연결방법
  config.database, config.username, config.password, config,
);

// DB에 config에서 가져온 설정값을 기반으로 시퀄라이즈 적용
db.sequelize = sequelize;

// 아래와 같이 직접 하나씩 구현도 가능
// db.User = User;
// db.Post = Post;
// db.Hashtag = Hashtag;

// User.initiate(sequelize);
// Post.initiate(sequelize);
// Hashtag.initiate(sequelize);
// User.associate(db);
// Post.associate(db);
// Hashtag.associate(db);

// 아래는 위와 동일한 실행을 하나 반복적인 내용을 자동화하기 위한 코드
const basename = path.basename(__filename);
fs.readdirSync(__dirname)
  .filter(file => {
    return  file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js';
  })
  .forEach((file) => {
    const model = require(path.join(__dirname, file));
    db[model.name] = model;
    model.initiate(sequelize);
  });

Object.keys(db).forEach(modelName => {
  console.log(modelName);
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

module.exports = db;

📌 associate 작성하기

✏️ 모델간의 관계들 associate에 작성

  • 1대다: hasMany와 belongsTo

  • 다대다: belongsToMany

    • foreignKey: 외래키
    • as: 컬럼에 대한 별명
    • through: 중간 테이블명
  • /model/user.js

const Sequelize = require('sequelize');

// 테이블 생성
class User extends Sequelize.Model {
    // 테이블 정보
    static initiate(sequelize) {
        User.init({
            email: {
                type: Sequelize.STRING(40),
                allowNull: true,
                unique: true,
            },
            nick: {
                type: Sequelize.STRING(15),
                allowNull: false,
            },
            password: {
                type: Sequelize.STRING(100),
                allowNull: true,
            },
            provider: {
                type: Sequelize.ENUM('local', 'kakao'),
                allowNull: false,
                defaultValue: 'local',
            },
            snsId: {
                type: Sequelize.STRING(30),
                allowNull: true
            }
        }, {
            sequelize,
            timestamps: true,  //createdAt, updatedAt
            underscored: false,
            modelName: 'User',  // 자바스크립트에서 사용하는 이름
            tableName: 'users',  // 데이터베이스에서 사용하는 테이블 이름
            paranoid: true,  // deletedAt 유저 삭제일 (soft delete)
            charset: 'utf8mb4',
            collate: 'utf8mb4_general_ci',
        })
    }
    // 테이블 관계
    static associate(db) {
        db.User.hasMany(db.Post);
        // 연예인의 팔로워를 찾으려면 연예인의 id로 찾아야 함
        db.User.belongsToMany(db.User, {  // 팔로워(유명 연예인의 팬)
            foreignKey: 'followingId',
            as: 'Followers',
            through: 'Follow'
        });

        // 내가 팔로잉을 하는 사람을 찾으려면 나의 id로 찾아야 함
        db.User.belongsToMany(db.User, {  // 팔로잉 (유명 연예인)
            foreignKey: 'followerId', 
            as: 'Followings',
            through: 'Follow'
        });
    }
}


module.exports = User;
  • /model.post.js
const Sequelize = require('sequelize');

// 테이블 생성
class Post extends Sequelize.Model {
    // 테이블 정보
    static initiate(sequelize) {
        Post.init({
            content: {
                type: Sequelize.STRING(140),
                allowNull: false,
            },
            img: {
                type: Sequelize.STRING(200),
                allowNull: true,
            }
        }, {
            sequelize,
            timestamps: true,
            underscored: false,
            paranoid: true,
            modelName: 'Post',
            tableName: 'posts',
            charset: 'utf8mb4',
            collate: 'utf8mb4_general_ci'
        })
    }
    // 테이블 관계
    static associate(db) {
        db.Post.belongsTo(db.User);
        db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' });
    }
}


module.exports = Post;
  • /model/hashtag.js
const Sequelize = require('sequelize');

// 테이블 생성
class Hashtag extends Sequelize.Model {
    // 테이블 정보
    static initiate(sequelize) {
        Hashtag.init({
            title: {
                type: Sequelize.STRING(15),
                allowNull: false,
                unique: true,
            }
        }, {
            sequelize,
            timestamps: true,
            underscored: false,
            paranoid: true,
            modelName: 'Hashtag',
            tableName: 'hashtags',
            charset: 'utf8mb4',
            collate: 'utf8mb4_general_ci'
        })

    }
    // 테이블 관계
    static associate(db) {
        db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
    }
}


module.exports = Hashtag;

📌 팔로워 - 팔로잉 다대다 관계

✏️ User(다):User(다)

  • 다대다 관계이므로 중간 테이블(Follow) 생성됨
  • 모델 이름이 같으므로 구분 필요함(as가 구분자 역할, foreignKey는 반대 테이블 컬럼의 프라이머리 키 컬럼)
  • 시퀄라이즈는 as 이름을 바탕으로 자동으로 addFollower, getFollowers, addFollowing, getFollowings 메서드 생성

📌 모델과 서버 연결하기

✏️ sequelize.sync()가 테이블 생성

  • IF NOT EXIST(SQL문)으로 테이블이 없을 때만 생성해줌
  • app.js
...

const nunjucks = require('nunjucks');
const { sequelize } = require('./models');

...

// 넌적스 템플릿엔진을 사용할 때 view폴더를 사용
// 즉, render를 사용할 때 views폴더에 있는 html 파일을 사용한다는 의미
nunjucks.configure('views', {
    express: app,
    watch: true,
});

// 실제로 sequelize를 데이터베이스에 연결하는 부분
sequelize.sync()
    .then(() => {
        console.log('데이터베이스 연결 성공');
    })
    .catch((err) => {
        console.error(err);
    })

...


💻 9.3 passport 모듈로 로그인

📌 패스포트

✏️ 로그인 과정을 쉽게 처리할 수 있게 도와주는 Passport 설치하기

  • 비밀번호 암호화를 위한 bcrypt도 같이 설치
  • 설치 후 app.js와도 연결
  • passport.initialize(): 요청 객체에 passport 설정을 심음
  • passport.session(): req.session 객체에 passport 정보를 저장
  • express-session 미들웨어에 의존하므로 이보다 더 뒤에 위치해야 함
  • app.js
...

const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const passport = require('passport');
const { sequelize } = require('./models');

dotenv.config();  // process.env
const pageRouter = require('./routes/page');
const authRouter = require('./routes/auth');
const passportConfig = require('./passport');

const app = express();
passportConfig();
app.set('port', process.env.PORT|| 8001);
app.set('view engine', 'html');  //템플릿엔진으로 읽을 때 html 파일을 사용

...

app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,  // 자바스크립트 접근 못하게 만들때
        secure: false,  // https 사용할 때 true
    }
}));
//passport 미들웨어의 위치는 반드시 express-session 밑에 선언해야 함

// passport연결시 req.user, req.login, req.logout가 생성됨 
// (즉, passport가 로그인시 필요한 것들을 만들어 줌)
app.use(passport.initialize());  
app.use(passport.session());  
// connect.sid라는 이름으로 세션 쿠키가 브라우저로 전송 (쿠키 로그인을 도와주는 함수)

// 브라우저에 connect.sid=123876128942와 같은 쿠키가 저장됨
// 이후에는 쿠키와 함께 서버로 보내짐 (이 때 cookie-parser가 분석해서 보냄)
// passport가 cookie-parser에 의해 만들어진 객체를 통해 /passport/index.js에서 세션쿠키를 통해 유저아이디를 찾음
// passport.session 미들웨어가 passport.deserializeUser 메서드를 호출

📌 패스포트 모듈 작성

✏️ passport/index.js 작성

  • passport.serializeUser: req.session 객체에 어떤 데이터를 저장할 지 선택, 사용자 정보를 다 들고 있으면 메모리를 많이 차지하기 때문에 사용자의 아이디만 저장
  • passport.deserializeUser: req.session에 저장된 사용자 아이디를 바탕으로 DB 조회로 사용자 정보를 얻어낸 후 req.user에 저장
  • 즉, 로그인 사용자 정보 -> passport.serializeUser() -> 사용자 아이디만 저장 -> passport.deserializeUser() -> DB에서 User 객체를 찾아서 req.user에 저장 -> req.user 데이터를 라우터에서 활용
const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');


module.exports = () => {
    // controller/auth.js에 있는 req.login에 의해서 실행됨
    passport.serializeUser((user, done) => {  // user === exUser
        done(null, user.id);  //user의 id만 추출 -> req.session에 사용자 아이디를 세션에 저장
    });
    // 세션에 저장할 때 { 세션쿠키: 유저아이디 } -> 메모리에 저장
    // user정보를 통째로 사용하면 메모리가 너무 많이 사용되기에 유저아이디만 추출해서 사용
    // 하지만 메모리에 로그인정보를 저장한다는 것 자체가 문제가 되지만
    // 이에 대한 해결법은 추후에 공부할 예정
    
    // cookie-parser에 의해 객체가 된 세션 쿠키 값(123876128942)를 기준으로 유저아이디를 찾음
    // passport.session 미들웨어가 passport.deserializeUser 메서드를 호출
    // 결과적으로 req.user를 만드는 곳임
    passport.deserializeUser((id, done) => {  // 세션쿠키 값을 통해 얻은 유저 아이디를 가지고 User 정보를 복원시킴
        User.findOne({ where: { id } })
            .then((user) => done(null, user))  // 그 복원된(조회된) 정보가 req.user가 됨
            .catch(err => done(err));
    });

    local();
};

📌 패스포트 처리 과정

✏️ 로그인 과정

  1. /auth/login 라우터를 통해 로그인 요청이 들어옴
  2. passport.authenticate 메서드 호출
  3. 로그인 전략(LocalStrategy) 수행(전략은 뒤에 알아봄)
  4. 로그인 성공 시 사용자 정보 객체와 함께 req.login 호출
  5. req.login 메서드가 passport.serializeUser 호출
  6. req.session에 사용자 아이디만 저장해서 세션 생성
  7. express-session에 설정한 대로 브라우저에 connect.sid 세션 쿠키 전송
  8. 로그인 완료

✏️ 로그인 이후 과정

  1. 모든 요청에 passport.session() 미들웨어가 passport.deserializeUser 메서드 호출
  2. connect.sid 세션 쿠키를 읽고 세션 객체를 찾아서 req.session으로 만듦
  3. req.session에 저장된 아이디로 데이터베이스에서 사용자 조회
  4. 조회된 사용자 정보를 req.user에 저장
  5. 라우터에서 req.user 객체 사용 가능

📌 로컬 로그인 구현하기

✏️ passport-local 패키지 필요

  • 로컬 로그인 전략 수립
  • 로그인에만 해당하는 전략이므로 회원가입은 따로 만들어야 함
  • 사용자가 로그인했는지, 하지 않았는지 여부를 체크하는 미들웨어도 만듦
  • /middlewares/index.js
exports.isLoggedIn = (req, res, next) => {
    if (req.isAuthenticated()) {  // 패스포트 통해서 로그인 했는지
        next();
    } else {
        res.status(403).send('로그인 필요');
    }
};

exports.isNotLoggedIn = (req, res, next) => {
    if (!req.isAuthenticated()) { 
        next();
    } else {
        const message = encodeURIComponent('로그인한 상태입니다.');
        res.redirect(`/?error=${message}`);
    }
};

📌 회원가입 라우터

✏️ routes/auth.js, controller/auth.js 작성

  • bcrypt.hash로 비밀번호 암호화
  • hash의 두 번째 인수는 암호화 라운드
  • 라운드가 높을수록 안전하지만 오래 걸림
  • 적당한 라운드를 찾는 게 좋음
  • ?error 쿼리스트링으로 1회성 메시지
  • /routes/auth.js
const express = require('express');
const passport = require('passport');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { join, login, logout } = require('../controllers/auth');
const router = express.Router();

// POST /auth/join
router.post('/join', isNotLoggedIn, join);

// POST /auth/login
router.post('/login', isNotLoggedIn, login);

// Get /auth/logout
router.get('/logout', isLoggedIn, logout);

module.exports = router;
  • /controller/auth.js
const User = require('../models/user');
const bcrypt = require('bcrypt');
const passport = require('passport');

exports.join = async (req, res, next) => {
    const { nick, email, password } = req.body;  // 구조분해 할당에 의해 req.body에서 자동으로 할당됨
    try {
        const exUser = await User.findOne({ where: { email } });
        if (exUser) {
            return res.redirect('/join?error=exist');
        }
        const hash = await bcrypt.hash(password, 12); // 비밀번호 암호화
        await User.create({
            email,
            nick,
            password: hash,
        });
        return res.redirect('/');
    } catch(err) {
        console.log(err);
        next(err);
    }
};

📌 로그인 라우터

✏️ controllers/auth.js 작성

  • passport.authenticate(‘local’): 로컬 전략
  • 전략을 수행하고 나면 authenticate의 콜백 함수 호출됨
  • authError: 인증 과정 중 에러,
  • user: 인증 성공 시 유저 정보
  • info: 인증 오류에 대한 메시지
  • 인증이 성공했다면 req.login으로 세션에 유저 정보 저장
// POST /auth/login
exports.login = (req, res, next) => {
    // login 호출 시 passport.authenticate가 실행되는데
    // 이 때 'local' 에 의해서 localStrategy가 실행됨
    // localStrategy에 의해 반환된 done을 가지고 콜백함수 실행
    // done(서버실패, 성공유저, 로직실패)이 (authError, user, info) => {}을 호출시킴
    // done에 의해서 user === exUser
    passport.authenticate('local', (authError, user, info) => {
        if (authError) {  // 서버 실패한 경우
            console.error(authError);
            return next(authError);
        }
        if (!user) {  // 로직 실패한 경우
            return res.redirect(`/?loginError=${info.message}`);
        }
        // req.login에 의해 /passport/inde.js 가 실행됨
        return req.login(user, (loginError) => {  // 로그인 성공한 경우
            if (loginError) {
                console.error(loginError);
                return next(loginError);
            }
            return res.redirect('/');
        })
    })(req, res, next);
};

📌 로컬전략 작성

✏️ passport/localStrategy.js 작성

  • usernameField와 passwordField가 input 태그의 name(body-parser의 req.body)
  • 사용자가 DB에 저장되어있는지 확인한 후 있다면 비밀번호 비교(bcrypt.compare)
  • 비밀번호까지 일치한다면 로그인
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const User = require('../models/user');

module.exports = () => {
    passport.use(new LocalStrategy({
        usernameField: 'email',  // req.body.email을 읽어서 사용
        passwordField: 'password',  // req.body.password을 읽어서 사용
        passReqToCallback: false
    }, async (email, password, done) => {  // done(서버실패, 성공유저정보, 로직실패)
        // 로그인 시켜야 하는지 말아야 하는지 판단하는 로직
        try {
            // email을 통해 User 모델에서 사용자가 있는지 확인
            const exUser = await User.findOne({ where: { email } });
            if (exUser) {
                const result = await bcrypt.compare(password, exUser.password)  // 사용자가 입력한 비번과 DB의 비번을 비교
                if (result) {
                    done(null, exUser);
                } else {
                    done(null, false, { message: '비밀번호가 일치하기 않습니다.' });
                }
            } else {
                done(null, false, { message: '가입되지 않은 회원입니다.' });
            }
        } catch(error) {
            console.error(error);
            done(error);
        }
    }));
};

📌 카카오 로그인 구현

✏️ passport/kakaoStrategy.js 작성

  • clientID에 카카오 앱 아이디 추가
  • callbackURL: 카카오 로그인 후 카카오가 결과를 전송해줄 URL
  • accessToken, refreshToken: 로그인 성공 후 카카오가 보내준 토큰(사용하지 않음)
  • profile: 카카오가 보내준 유저 정보
  • profile의 정보를 바탕으로 회원가입
const passport = require("passport");
const { Strategy: KakaoStrategy } = require('passport-kakao');
const User = require("../models/user");

module.exports = () => {
    passport.use(new KakaoStrategy({
        clientID: process.env.KAKAO_ID,
        callbackURL: '/auth/kakao/callback',
    // accessToken, refreshToken를 받아오긴 하지만 여기서는 사용하지 않음
    // accessToken, refreshToken는 카카오API를 사용할 경우에 사용함
    }, async (accessToken, refreshToken, profile, done) => {  
        console.log('profile', profile);
        try {
            const exUser = await User.findOne({
                where: { snsId: profile.id, provider: 'kakao' }
            });
            if (exUser) {
                done(null, exUser);
            } else {
                const newUser = await User.create({
                    email: profile._json?.kakao_account?.email,
                    nick: profile.displayName,
                    snsId: profile.id,
                    provider: 'kakao',
                });
                console.log(exUser);
                done(null, newUser);
            }
        } catch (error) {
            console.error(error);
            done(error);
        }
    }));
};

📌 카카오 로그인용 라우터 만들기

✏️ 회원가입과 로그인이 전략에서 동시에 수행됨

  • passport.authenticate(‘kakao’)만 하면 됨
  • /kakao/callback 라우터에서는 인증 성공 시(res.redirect)와 실패 시(failureRedirect) 리다이렉트할 경로 지정
  • /routes/auth.js
const express = require('express');
const passport = require('passport');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { join, login, logout } = require('../controllers/auth');
const router = express.Router();

...

// GET /auth/kakao
router.get('/kakao', passport.authenticate('kakao'));  // 카카오톡 로그인 화면으로 redirect

// /auth/kakao -> 카카오톡로그인화면 -> /auth/kakao/callback

// GET /auth/kakao/callback
router.get('/kakao/callback', passport.authenticate('kakao', {
    failureRedirect: '/?loginError=카카오로그인 실패',
}), (req, res) => {
    res.redirect('/');
});

module.exports = router;

📌 카카오 로그인 앱 만들기

✏️ https://developers.kakao.com에 접속하여 회원가입

  • NodeBird 앱 만들기

📌 카카오 앱 키 저장

✏️ REST API 키를 저장해서 .env에 저장


📌 카카오 웹 플랫폼 추가

✏️ 웹 플랫폼을 추가해야 Redirect URI 등록할 수 있음


📌 카카오 동의항목 설정

✏️ 이메일 등의 정보를 얻기 위해 동의항목 설정

  • 동의 목적에 작성한 내용이 사용자에게 보여짐


💻 9.4 Multer 모듈로 이미지 업로드 구현하기

📌 이미지 업로드 구현

✏️ form 태그의 enctype이 multipart/form-data

  • body-parser로는 요청 본문을 해석할 수 없음
  • multer 패키지 필요 (npm i multer)
  • 이미지를 먼저 업로드하고, 이미지가 저장된 경로를 반환할 것임
  • 게시글 form을 submit할 때는 이미지 자체 대신 경로를 전송

📌 이미지 업로드 라우터 구현

✏️ fs.readdir, fs.mkdirSync로 upload 폴더가 없으면 생성

  • multer() 함수로 업로드 미들웨어 생성
  • storage: diskStorage는 이미지를 서버 디스크에 저장(destination은 - 저장 경로, filename은 저장 파일명)
  • limits는 파일 최대 용량(5MB)
  • upload.single(‘img’): 요청 본문의 img에 담긴 이미지 하나를 읽어 설정대로 저장하는 미들웨어
  • 저장된 파일에 대한 정보는 req.file 객체에 담김
  • /routes/post.js
const express = require('express');
const router = express.Router();
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const fs = require('fs');
const multer = require('multer');
const path = require('path');
const { afterUploadImage, uploadPost } = require('../controllers/post');

try {
    fs.readdirSync('uploads');
} catch (error) {
    fs.mkdirSync('uploads');
}

// 이미지를 올리기 위한 multer
const upload = multer({
    storage: multer.diskStorage({
        destination(req, file, cb) {
            console.log(file);
            cb(null, 'uploads/');
        },
        filename(req, file, cb) {
            console.log(file);
            const ext = path.extname(file.originalname);  // 확장자를 추출
            cb(null, path.basename(file.originalname, ext) + Date.now() + ext);  // 이미지.png -> 이미지12039123.png와 같이 변경(중복 제거를 위해)
        }
    }),
    limits: { fileSize: 20 * 1024 * 1024 },
});

// multer 사용 시 FormData객체에서 사용하는 formData.append('img', this.files[0])의 'img'변수와 동일하게 사용해야 함
router.post('/img', isLoggedIn,upload.single('img'), afterUploadImage);

const upload2 = multer();  // 게시글을 올리기 위한 multer
router.post('/', isLoggedIn, upload2.none(), uploadPost);


module.exports = router;

📌 게시글 등록

✏️ upload2.none()은 multipart/form-data 타입의 요청이지만 이미지는 없을 때 사용

  • 게시글 등록 시 아까 받은 이미지 경로 저장
  • 게시글에서 해시태그를 찾아서 게시글과 연결(post.addHashtags)
  • findOrCreate는 기존에 해시태그가 존재하면 그걸 사용하고, 없다면 생성하는 시퀄라이즈 메서드
  • /controllers/post.js
const Post = require('../models/post');
const Hashtag = require('../models/hashtag');

// 업로드한 이미지의 url를 프론트로 보내는 곳 (미리보기 때문에)
exports.afterUploadImage = (req, res) => {
    console.log(req.file);
    res.json({ url: `/img/${req.file.filename}` });
};

// 실제 게시글이 업로드 되는 곳
exports.uploadPost = async (req, res, next) => {
    console.log(req.body);  // req.body.content와 req.body.url를 가져올 수 있음
    try {
        const post = await Post.create({
            content: req.body.content,
            img: req.body.url,
            UserId: req.user.id,
        });
        const hashtags = req.body.content.match(/#[^\s#]*/g);
        if (hashtags) {
            // Post와 Hashtag 사이의 다대다 관계 생성
            const result = await Promise.all(hashtags.map((tag) => {
                return Hashtag.findOrCreate({
                    where: { title: tag.slice(1).toLowerCase() }
                });
            }));
            console.log('result: ', result);
            // Hashtag.findOrCreate()의 반환값으로 [instance, created] 형태의 배열을 반환
            // instance에는 값이, created에는 새로운 해시태그가 생성되었는지 여부가 들어 있음
            await post.addHashtags(result.map(r => r[0]));
        }
        res.redirect('/');

    } catch (error) {
        console.error(error);
        next(error);
    }
};

📌 메인 페이지에서 게시글 보여주기

✏️ 메인 페이지(/) 요청 시 게시글을 먼저 조회한 후 템플릿 엔진 렌더링

  • 원래는 id만 가져왔으나 메인 페이지에서 게시글을 보여주기 위해 Post 모델에서 데이터들 가져옴
  • include로 관계가 있는 모델을 합쳐서 가져올 수 있음
  • Post와 User는 관계가 있음 (1대다)
  • 게시글을 가져올 때 게시글 작성자의 id와 nick값도 같이 가져오는 것
  • /controllers/page.js
// 라우터 -> 컨트롤러(요청, 응답을 알고 있음, req랑 res) -> 서비스(요청, 응답을 모름)
const Post = require('../models/post');
const User = require('../models/user');
const Hashtag = require('../models/hashtag');

...

exports.renderMain = async (req, res, next) => {
    try {
        const posts = await Post.findAll({
            include: {
                model: User,
                attributes: ['id', 'nick'],
            },
            order: [['createdAt', 'DESC']]
        });
        res.render('main', { 
            title: 'NodeBird',
            twits: posts,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.renderHashtag = async (req, res, next) => {
    const query = req.query.hashtag;
    if (!query) {
        return res.redirect('/');
    }
    
    try {
        const hashtag = await Hashtag.findOne({ where: { title: query } });
        let posts = [];
        if (hashtag) {
            posts = await hashtag.getPosts({
                include: [{ model: User, attributes: ['id', 'nick'] }],
                order: [['createdAt', 'DESC']]
            });
        }
        res.render('main', {
            title: `${query} | NodeBird`,
            twits: posts,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};


💻 9.5 팔로잉, 해시테그 기능 구현하기

📌 팔로잉 기능 구현하기

✏️ POST /:id/follow 라우터 추가

  • /사용자아이디/follow

  • 사용자 아이디는 req.params.id로 접근

  • user.addFollowing(사용자아이디)로 팔로잉하는 사람 추가

  • /routes/user.js

const express = require('express');
const router = express.Router();
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { follow, unfollow } = require('../controllers/user');

router.post('/:id/follow', isLoggedIn, follow);

module.exports = router;
  • /controllers/user.js
const User = require('../models/user');

exports.follow = async (req, res, next) => {
    // req.user.id와 req.params.id가 필요
    // 현재 유저의 id와 팔로우하는 사람의 id가 필요
    try {
        const user = await User.findOne({ where: { id: req.user.id } });
        if (user) {
            await user.addFollowing(parseInt(req.params.id, 10));
            res.send('success');
        } else {
            res.status(404).send('no user');
        }
    } catch (error) {
        console.error(error);
        next(error);
    }
};

✏️ deserializeUser 수정

  • 결과적으로 req.user를 만드는 곳이 deserializeUser이기 때문에 수정 필요

  • 기존에는 그냥 유저 id를 통해서 유저 정보만 가져옴

  • req.user.Followers로 팔로워 접근 가능

  • req.user.Followings로 팔로잉 접근

  • 단, 목록이 유출되면 안 되므로 팔로워/팔로잉 숫자만 프런트로 전달

  • 요약하면, as: 'Followers'와 as: 'Followings'는 User 모델이 Follow 테이블을 통해 자기 자신과 맺고 있는 팔로우 관계를 나타내며, 이 관계를 통해 팔로워와 팔로잉 사용자의 정보를 함께 조회할 수 있음

  • /passport/index.js

const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');


module.exports = () => {
    
  	...
  	
    passport.deserializeUser((id, done) => {  // 세션쿠키 값을 통해 얻은 유저 아이디를 가지고 User 정보를 복원시킴
        User.findOne({ 
            where: { id },
            // as: 'Followers'와 as: 'Followings'는 User 모델이 Follow 테이블을 통해 
            // 자기 자신과 맺고 있는 팔로우 관계를 나타내며, 이 관계를 통해 팔로워와 팔로잉 사용자의 정보를 함께 조회할 수 있음
            include: [
                {
                    model: User,
                    attributes: ['id', 'nick'],
                    as: 'Followers',
                },
                {
                    model: User,
                    attributes: ['id', 'nick'],
                    as: 'Followings',
                },
            ]
        })
            .then((user) => done(null, user))  // 그 복원된(조회된) 정보가 req.user가 됨
            .catch(err => done(err));
    });

    local();
    kakao();
};
  • /routes/page.js
const express = require('express');
const router = express.Router();
const { renderJoin, renderMain, renderProfile, renderHashtag } = require('../controllers/page');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');

// 공통적으로 사용하기 원하는 변수들을 res.locals로 설정
// 로그인 과정이 다 끝나면 여기서 라우터들이 동작함
router.use((req, res, next) => {
    res.locals.user = req.user;  // 로그인되어 있지 않으면 req.user는 null값을 가짐
    res.locals.followerCount = req.user?.Followers?.length || 0;
    res.locals.followingCount = req.user?.Followings?.length || 0;
    res.locals.followingIdList = req.user?.Followings?.map(f => f.id) || [];
    next();
});

router.get('/profile', isLoggedIn, renderProfile);
router.get('/join', isNotLoggedIn, renderJoin);
router.get('/', renderMain);
router.get('/hashtag', renderHashtag);  // hashtag?hashtag=node

module.exports = router;

📌 해시태그 검색 기능 추가

✏️ GET /hashtag 라우터 추가

  • 해시태그를 먼저 찾고(hashtag)

  • hashtag.getPosts로 해시태그와 관련된 게시글을 모두 찾음

  • 찾으면서 include로 게시글 작성자 모델도 같이 가져옴

  • /routes/page.js

const express = require('express');
const router = express.Router();
const { renderJoin, renderMain, renderProfile, renderHashtag } = require('../controllers/page');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');

...

router.get('/hashtag', renderHashtag);  // hashtag?hashtag=node

module.exports = router;
  • /controllers/page.js
// 라우터 -> 컨트롤러(요청, 응답을 알고 있음, req랑 res) -> 서비스(요청, 응답을 모름)
const Post = require('../models/post');
const User = require('../models/user');
const Hashtag = require('../models/hashtag');

...

exports.renderHashtag = async (req, res, next) => {
    const query = req.query.hashtag;
    if (!query) {
        return res.redirect('/');
    }
    
    try {
        const hashtag = await Hashtag.findOne({ where: { title: query } });
        let posts = [];
        if (hashtag) {
            posts = await hashtag.getPosts({
                // posts에게 User의 id와 nick값을 사용할 수 있게 해주는 옵션
                include: [{ model: User, attributes: ['id', 'nick'] }],
                order: [['createdAt', 'DESC']]
            });
        }
        res.render('main', {
            title: `${query} | NodeBird`,
            twits: posts,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};

📌 업로드한 이미지 보여주기

✏️ express.static 미들웨어로 uploads 폴더에 저장된 이미지 제공

  • 프런트엔드에서는 /img/이미지명 주소로 이미지 접근 가능
...

app.use(express.static(path.join(__dirname, 'public')));  // 프론트에서 public 폴더를 자유롭게 접근가능하게 허용
app.use('/img', express.static(path.join(__dirname, 'uploads')));  // img경로를 통해서 프론트가 uploads 폴더에 접근가능하게 허용

...

app.use('/', pageRouter);
app.use('/auth', authRouter);
app.use('/post', postRouter);
app.use('/user', userRouter);

...

app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기 중...');
});
profile
조금씩 정리하자!!!

0개의 댓글