9장. 익스프레스로 SNS 서비스 만들기

마조리카·2021년 4월 23일
0
post-thumbnail

NodeBird 앱 만들기

기능 : 로그인, 이미지 업로드, 게시글 작성, 해시태그 검색, 팔로잉 등

9.1 프로젝트 구조 갖추기

  1. NordBird 폴더 생성
  2. 항상 package.json을 제일 먼저 만들어야 합니다.
{
  "name": "nodebird",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "LEETAEHEE",
  "license": "MIT",
  "devDependencies": {
    "nodemon": "^2.0.7"
  },
  "dependencies": {
    "bcrypt": "^5.0.1",
    "cookie-parser": "^1.4.5",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-session": "^1.17.1",
    "morgan": "^1.10.0",
    "multer": "^1.4.2",
    "mysql2": "^2.2.5",
    "nunjucks": "^3.2.3",
    "passport": "^0.4.1",
    "passport-kakao": "^1.0.1",
    "passport-local": "^1.0.0",
    "sequelize": "^6.6.2",
    "sequelize-cli": "^6.2.0"
  }
}
  1. npm 다운로드
npm i -D nodemon
npm i express express-session nunjucks morgan cookie-parser sequelize mysql2 sequelize-cli dotenv multer
npx sequelize init

npx sequelize init(전역설치처럼 이용하기 - npx)
를 사용하면 config, migrations, models, seeders 폴더가 생성됩니다.

자체적으로 view, routes, public, passport 폴더도 생성합니다.

app.js와 .env 파일도 NordBird 폴더안에 만들어줍니다.
.env에는 COOKIE_SECRET=nodebirdsecret을 추가합니다.

기본적인 라우터와 템플린 엔진도 만듭니다.

routes 폴더안에 page.js
views 폴더안에 layout.html, main.thml, profile.html, join.html, error.html을 생성
약간의 디자인을 위해 public 폴더안에 main.css

//app.js
const express = require('express');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config(); //require한 다음 최대한 위에 적어주는게 좋다.
//dotenv를 하는 순간 process env의 설정 값들이 들어가는데 선언한 이후로 들어간다.
//만약 이 위에 process.env.COOKIE_SECRET 이런게 있다면 dotenv 적용이 안된다.
const pageRouter = require('./routes/page');

const app = express();
app.set('port', process.env.PORT || 8001); //8001번 포트를 쓰겠다. 개발할 때, 배포할 때 포트를 달리 사용하기 위해 process.env.PORT한거
app.set('view engine', 'html');
nunjucks.configure('views', { //템플릿 엔진을 위한 설정
  express: app,
  watch: true,//watch 옵션을 true로 설정하면 HTML 파일이 변경될 때 템플릿 엔진을 다시 렌더링하도록 한다.
});

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

app.use('/', pageRouter);

app.use((req, res, next) => { //모든 라우터들 뒤에 나오니깐 404처리 미들웨어
  const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
  error.status = 404;
  next(error); //에러 처리 미들웨어로 넘겨줌
});

app.use((err, req, res, next) => { //에러 처리 미들웨어
  res.locals.message = err.message; //res.locals.~ 는 템플릿 엔진의 변수임.
  res.locals.error = process.env.NODE_ENV !== 'production' ? err : {}; //개발 모드에서는 에러 상세 내역을 보여주게 하고, 개발 이외의 모드라면 안보여주게 처리하는거임.
  res.status(err.status || 500);
  res.render('error');
  //메서드 체이닝 방식 : res.status(~).render('error');
});

app.listen(app.get('port'), () => {
  console.log(app.get('port'), '번 포트에서 대기중');
});
//routes/page.js -> 템플릿 엔진을 렌더링하는 라우터
const express = require('express');

const router = express.Router();

router.use((req, res, next) => { //지금은 일단 무시
  res.locals.user = null; 
  res.locals.followerCount = 0;
  res.locals.followingCount = 0;
  res.locals.followerIdList = [];
  next();
}); //res.locals로 묶은 이유는 모든 템플릿 엔진에서 공통으로 사용하기 때문.

router.get('/profile', (req, res) => { //profile 페이지 보여줌
  res.render('profile', { title: '내 정보 - NodeBird' });
});

router.get('/join', (req, res) => { //join 페이지 보여줌
  res.render('join', { title: '회원가입 - NodeBird' });
});

router.get('/', (req, res, next) => { //메인 페이지 보여줌
  const twits = []; //SNS 들어가자마자 보이는 대표적인? 게시물들을 넣어주는 공간. 일단은 게시물 없으니 빈 배열로 만듦.
  res.render('main', {
    title: 'NodeBird',
    twits,
  });
});

module.exports 

콘솔에

npx sequelize db:create

하면 디폴트 설정인 development DB 설정대로 스키마를 만들어준다.

//config/config.js (시퀄라이즈 설정)
{
  "development": { //개발용 DB
    "username": "root",
    "password": "MySQL_비밀번호",
    "database": "nodebird",
    "host": "127.0.0.1",
    "dialect": "mysql"
    //operatorAliases 속성은 삭제해준다
  },
  "test": { //테스트용 DB
    "username": "root",
    "password": null,
    "database": "nodebird_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": { //배포용 DB
    "username": "root",
    "password": null,
    "database": "nodebird_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

9.2 데이터 베이스 세팅하기

models/index.js 는 기본적으로 생성되는것에서 아래 처럼 수정

//models/index.js
//기본적으로 생성되는 형식이 있는데 깔끔하게 바꾼거임!
const Sequelize = require('sequelize');
const env = process.env.NODE_ENV || 'development';
const config = require('../config/config')[env]; //config에 config.js의 development 객체를 담아두는거임
const User = require('./user');
const Post = require('./post');
const Hashtag = require('./hashtag');

const db = {};
const sequelize = new Sequelize(
  config.database, config.username, config.password, config,
);

db.sequelize = sequelize;
db.User = User; //사용자와 게시글은 1:N 관계
db.Post = Post; //게시글과 해시태그는 N:M 관계
db.Hashtag = Hashtag;

User.init(sequelize);
Post.init(sequelize);
Hashtag.init(sequelize);

User.associate(db);
Post.associate(db);
Hashtag.associate(db);

module.exports = db;

sns구현을 위해선 사용자 테이블, 게시물 테이블, 해시태그 테이블이 필요합니다.

//models/user.js
const Sequelize = require('sequelize');

module.exports = class User extends Sequelize.Model { //공식문서를 따르는 형식
  static init(sequelize) {
    return super.init({
      email: { //시퀄라이즈는 id 생략한다.
        type: Sequelize.STRING(40),
        allowNull: true,
        unique: true, //빈 값이 있어도 unique하게 구분된다.
      },
      nick: {
        type: Sequelize.STRING(15),
        allowNull: false,
      },
      password: {
        type: Sequelize.STRING(100), //해시화하면 길어지기 때문에 100글자로 지정해놓은거
        allowNull: true, //sns 로그인하는 경우에는 비밀번호가 없을 수 있다.
      },
      provider: {
        type: Sequelize.STRING(10),
        allowNull: false,
        defaultValue: 'local', //local을 통해 로그인한거 / kakao, facebook 등등 가능
      },
      snsId: { //카카오, 네이버 등등으로 로그인하면 snsId라는걸 주는데 그걸 저장하고 있어야만 나중에
        //로그인할 때 id처럼 활용할 수 있다.
        type: Sequelize.STRING(30),
        allowNull: true,
      },
    }, {
      sequelize,
      timestamps: true, //true - createdAt, updatedAt이 자동으로 기록된다.
      underscored: false,
      modelName: 'User',
      tableName: 'users',
      paranoid: true, //paranoid - deletedAt이 자동으로 기록된다.
      charset: 'utf8',
      collate: 'utf8_general_ci',
    });
  }

  // 관계 설정하는 곳 
  //user와 post는 1:N관계
  //user와 user은 N:M관계
  static associate(db) {
    db.User.hasMany(db.Post);
    db.User.belongsToMany(db.User, { //사용자 테이블 간의 관계를 표현한거임.
      foreignKey: 'followingId',//왜 여기선 foreignKey를 넣어줬냐면
      //안넣어주면 userId와 userId가 돼서 헷갈리게 된다.
      //그래서 일부러 구분 짓기 위해 foreignKey 넣어준거.
      as: 'Followers',//as에는 foreignKey와 반대되는 것을 넣어줘야됨. -> 그래야 나중에 followers들을 가져올 때 followingId를 보고 가져올 수 있다.
      through: 'Follow', //Follow라는 중간 테이블을 만들어줌.
    });
    db.User.belongsToMany(db.User, {
      foreignKey: 'followerId',
      as: 'Followings',
      through: 'Follow',
    });
  }
};
//시퀄라이즈는 as 이름을 바탕으로 자동으로 
//addFollower, getFollwers, addFollowing, getFollowings 메서드를 생성해준다.
//models/post.js
const Sequelize = require('sequelize');

module.exports = class Post extends Sequelize.Model {
  static init(sequelize) {
    return super.init({
      content: { 
        type: Sequelize.STRING(140),
        allowNull: false,
      },
      img: { //단 한개의 이미지만 올릴 수 있음.
        type: Sequelize.STRING(200),
        allowNull: true,
      },
    }, {
      sequelize,
      timestamps: true,
      underscored: false,
      modelName: 'Post',
      tableName: 'posts',
      paranoid: false,
      charset: 'utf8mb4',
      collate: 'utf8mb4_general_ci',
    });
  }

  static associate(db) {
    db.Post.belongsTo(db.User);
    db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' }); //N:M 관계에서 중간 테이블 이름은 through로 지어준다.
  }//foreignKey 안넣어주면 기본적으로 postId랑 hashtagId가 된다.
  //as를 안넣어주면 기본적으로 post.getHashtags, post.addHashtags, hashtags.getPosts 같은 기본이름으로 관계 메서드가 생성됩니다.
};
//models/hashtag.js
const Sequelize = require('sequelize');

module.exports = class Hashtag extends Sequelize.Model {
  static init(sequelize) {
    return super.init({
      title: {
        type: Sequelize.STRING(15),
        allowNull: false,
        unique: true,
      },
    }, {
      sequelize,
      timestamps: true,
      underscored: false,
      modelName: 'Hashtag',
      tableName: 'hashtags',
      paranoid: false,
      charset: 'utf8mb4',
      collate: 'utf8mb4_general_ci',
    });
  }

  static associate(db) {
    db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag' });
  }
};

생성된 모델은

db.sequelize.models.PostHashtag
db.sequelize.models.Follow

와같이 접근가능하다.

위 app.js 를 수정하여 서버와 모델을 연경한다.

//app.js 수정
const express = require('express');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config();
const pageRouter = require('./routes/page');
const { sequelize } = require('./models');

const app = express();
app.set('port', process.env.PORT || 8001);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express: app,
  watch: true,
});
sequelize.sync({ force: false }) //sequelize.sync()가 테이블 생성해준다.
//테이블 정의한거 수정했다고(ex - hashtag.js 수정했다고 db의 테이블이 바로 바뀌지 않음) 테이블이 자동으로 수정되는게 아님!
//두 가지 방법 있음 - 첫번째 force: true - 테이블이 지워졌다가 다시 생성됨(대신 데이터가 지워지는거니까 조심해야한다.)
//alter : true - 데이터는 유지하고 테이블 컬럼 바뀐걸 반영하고 싶을 때 사용(컬럼이랑 기존 데이터들이랑 안맞아서 에러 나는 경우가 많다. 
//예를 들어 allowNull이 false인 컬럼을 추가했을 때 기존 데이터들은 그 컬럼에 해당하는 데이터가 없어서 에러 발생함)
//일단 force: false로 해놓고 수정사항 있으면 true로 변경사항 반영 / 실무에서는 force : true 절대 쓰면 안된다. only 개발용
  .then(() => {//promise기 때문에 .then(), .catch() 붙여주면 좋음.
    console.log('데이터베이스 연결 성공');
  })
  .catch((err) => {
    console.error(err);
  });

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

app.use('/', pageRouter);

app.use((req, res, next) => {
  const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
  error.status = 404;
  next(error);
});

app.use((err, req, res, next) => {
  res.locals.message = err.message;
  res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
  res.status(err.status || 500);
  res.render('error');
});

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

9.3 Passport 모듈로 로그인 구현하기

Passport 관련 패키지를 설치합니다

$ npm i passport passport-local, passport-kakao bcrypt

app.js 와 연결합니다

//app.js
const express = require('express');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const passport = require('passport');

dotenv.config();
const pageRouter = require('./routes/page');
const authRouter = require('./routes/auth');
const { sequelize } = require('./models');
const passportConfig = require('./passport');

const app = express();
passportConfig(); // passport/index.js 실행한거
app.set('port', process.env.PORT || 8001);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express: app,
  watch: true,
});
sequelize.sync({ force: false })
  .then(() => {
    console.log('데이터베이스 연결 성공');
  })
  .catch((err) => {
    console.error(err);
  });

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
})); 

//라우터에 가기 전에 이 두개를 연결해줘야함
app.use(passport.initialize());//요청 객체에 passport 설정을 심음.
app.use(passport.session());//req.session 객체에 passport 정보를 저장.
//이 두 개는 session보다 아래에 있어야 된다.
//얘네가 있음으로써 로그인 이후 요청부터 passport.session()이 실행될 때 index.js의 deserializeUser()가 실행된다.

app.use('/', pageRouter);
app.use('/auth', authRouter);

app.use((req, res, next) => {
  const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
  error.status = 404;
  next(error);
});

app.use((err, req, res, next) => {
  res.locals.message = err.message;
  res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
  res.status(err.status || 500);
  res.render('error');
});

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

패스포트 모듈 만들기

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

module.exports = () => {
  passport.serializeUser((user, done) => { //auth.js에서 여기로 넘어온다.
    done(null, user.id); //그러면 user.id만 뽑아서 done을 해준다. -> req.session 객체에 user의 id만 저장하는거
    //user만 쓰면 유저를 통째로 저장할 수 있긴 하지만 서버 메모리를 너무 많이 잡아먹기 때문에 id만 저장함
    //세션에 아이디를 저장해야 되는 이유 : 메모리에 { id: 3, 'connect.sid': s%1242535325356 } 이런식으로 저장되는데
    //connect.sid는 세션 쿠키임. 세션 쿠키는 브라우저로 간다. 브라우저에서 요청을 보낼 때마다 쿠키를 같이 넣어서 보내줌.
    //서버가 이 쿠키를 보고 3번 사용자의 쿠키구나 라는걸 인식함
    //그리고 3번 사용자를 deserializeUser해서 복구를 해줌
    //done 되는 순간 auth.js의 나머지 부분을 실행하러 돌아감
  });


//req.session에 저장된 사용자 아이디를 바탕으로 DB 조회를 하여 사용자 정보를 얻어낸 후 유저 정보 전체를 복구해서 req.user에 저장해줌.
  passport.deserializeUser((id, done) => { 
    User.findOne({ where: { id } }) 
      .then(user => done(null, user))
      .catch(err => done(err));
  });
// 로그인 되어 있을 때 req.user을 하면 로그인한 사용자의 정보가 나옴, req.isAuthenticated()은 로그인 했으면 true 아니면 false 반환.

  local();
  kakao();
};

passport 과정

  • 로그인
  1. 로그인 요청이 들어옴
  2. passport.authenticate 메서드 호출
  3. 로그인 전략 수행(전략은 뒤에 알아봄)
  4. 로그인 성공 시 사용자 정보 객체와 함께 req.login 호출
  5. req.login 메서드가 passport.serializeUser 호출
  6. req.session에 사용자 아이디만 저장
  7. 로그인 완료
  • 로그인 이후
  1. 모든 요청에 passport.session() 미들웨어가 passport.deserializeUser 메서드 호출
  2. req.session에 저장된 아이디로 데이터베이스에서 사용자 조회
  3. 조회된 사용자 정보를 req.user에 저장
  4. 라우터에서 req.user 객체 사용 가능

구현하기

//routes/middlewares.js 
//-> 로그인했는지 안했는지 여부를 체크해주는 라우터
exports.isLoggedIn = (req, res, next) => {
  if (req.isAuthenticated()) {//req.isAuthenticated()가 true면 로그인 되어 있는거
    next();
  } else {
    res.status(403).send('로그인 필요');
  }
};

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

//routes/page.js
const express = require('express');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const { Post, User } = require('../models');

const router = express.Router();

router.use((req, res, next) => { //use를 하면 모든 라우터에 공통 적용되는 특성 적용한거
  res.locals.user = req.user; //req.user는 passport의 deserializeUser에서 나온거
  res.locals.followerCount = 0;
  res.locals.followingCount = 0;
  res.locals.followerIdList = [];
  next();
});

router.get('/profile', isLoggedIn, (req, res) => {
  res.render('profile', { title: '내 정보 - NodeBird' });
});

router.get('/join', isNotLoggedIn, (req, res) => {
  res.render('join', { title: '회원가입 - NodeBird' });
});

router.get('/', async (req, res, next) => {
  try {
    const posts = await Post.findAll({ //업로드한 게시물 모두 찾아주고
      include: {
        model: User,
        attributes: ['id', 'nick'],
      },
      order: [['createdAt', 'DESC']],
    });
    res.render('main', { //찾은 게시물들을 twits에 넣어준다. 
      title: 'NodeBird',
      twits: posts,
    });
  } catch (err) {
    console.error(err);
    next(err);
  }
});

module.exports = router;

//routes/auth.js
const express = require('express');
const passport = require('passport');
const bcrypt = require('bcrypt');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const User = require('../models/user');

const router = express.Router();

router.post('/join', isNotLoggedIn, async (req, res, next) => {//회원가입 라우터
  //async로 가려면 inNotLoggedIn을 통과해야 하므로, 로그인 안한 사람들만 접근할 수 있게 해줌
  const { email, nick, password } = req.body; //이메일, 이름, 비밀번호를 받아와서
  try {
    const exUser = await User.findOne({ where: { email } }); //가입했던 이메일인지 확인해준다.
    if (exUser) {
      return res.redirect('/join?error=exist');
      //있으면 ?error=exist라는 
      //쿼리 스트링을 붙여서 redirect 해줌.
      //그러면 프론트엔트 개발자는 이 쿼리 스트링을 보고 
      //이미 이메일이 존재하는 이메일이구나를 알아차림
    }
    const hash = await bcrypt.hash(password, 12); //존재하지 않는 이메일이면 회원가입 시킨다.
    //회원가입 할 때 hash화를 해서 회원가입시킴.
    //(두번째 인자는 얼마나 복잡하게 할 것인지임,
    //숫자가 클수록 해킹 위험은 적지만 오래 걸림.) 
    await User.create({
      email,
      nick,
      password: hash, //비밀번호만 hash화 해서 유저 생성
    });
    return res.redirect('/'); //DB에 생성한 후 redirect로 메인 페이지로 돌아가기
  } catch (error) {
    console.error(error);
    return next(error);
  }
});
//로그인은 세션 문제도 있고, SNS로 로그인 할 때 
//local로 로그인 할 때가 달라서 로직이 복잡해지기 때문에 
//passport 라이브러리를 사용한다.
//프론트에서 서버로 로그인 요청을 보낼 때 아래의 라우터가 
//실행되는데 그 때 passport.authenticate('local')까지 실행되고
//이게 실행되면 localStrategy.js을 찾아가서 그 파일을 실행한다!!
//(index.js에서 local()을 했기 때문인가? 어떻게 찾아간다는거지)

router.post('/login', (req, res, next) => {
  //req.user -> 로그인 하기 전이니까 안들어 있음.
  passport.authenticate('local', (authError, user, info) => { //미들웨어 확장하는 패턴!
    if (authError) { //서버 에러 발생한 경우
      console.error(authError);
      return next(authError);
    }
    if (!user) {
      return res.redirect(`/?loginError=${info.message}`); //로그인이 실패한 경우 메시지를 담아서 프론트로 넘겨줌
    }
    return req.login(user, (loginError) => {//로그인이 성공한 경우 req.login을 사용하며 사용자 객체(user)를 넣어준다.
      //req.login을 하는 순간 req.login(user, )
      //까지만 실행되고 passport/index.js으로
      //가서 passport.serializeUser((user, done) 이걸 실행한다. 
      if (loginError) {
        console.error(loginError);
        return next(loginError); //에러가 있었으면 에러 처리하러 가고
      }
      //여기서 세션 쿠키를 브라우져로 보내준다.
      //그 다음 요청 부터는 세션 쿠키가 보내져서
      //서버가 누가 로그인 했는지 알게 되는거
      return res.redirect('/'); //없었으면 메인 페이지로 돌아가고 로그인 성공!
    });
  })(req, res, next); // 미들웨어 내의 미들웨어에는 
  //(req, res, next)를 붙인다. 
  //(이게 미들웨어 확장법)
});

router.get('/logout', isLoggedIn, (req, res) => { //로그인 한 사람만 로그아웃 할 수 있게 isLoggedIn 추가함.
  //req.user -> 로그인 되어 있는 상태니까 들어있음
  req.logout(); //이거 실행하면 서버에서 세션 쿠키를 삭제해버림
  //-> 로그인이 됐는지 안됐는지는 세션에 세션 쿠키가
  //들어 있나 없나를 확인하는 것이기 때문에 없으면
  //로그아웃됨을 알 수 있다.
  req.session.destroy();
  res.redirect('/');
});

// 이 부분은 카카오 로그인 전력
router.get('/kakao', passport.authenticate('kakao')); //카카오 로그인하기를 누르면 
//passport.authenticate('kakao')가 실행된다. 
//-> 이게 실행되면 kakaoStrategy.js으로 간다. 
//그리고 카카오 홈페이지 갔다옴

router.get('/kakao/callback', passport.authenticate('kakao', {
  failureRedirect: '/', //카카오 로그인 실패시 여기로 옴
}), (req, res) => { //kakaoStrategy에서 로그인에 성공했으면 여기로 온다.
  res.redirect('/');
});
//근데 /kakao/callback 요청을 한적이 없는데?? 
//-> 카카오에서 이쪽으로 요청을 쏴줌
module.exports = router;
  • passport.authenticate(‘local’): 로컬 전략임. 전략을 수행하고 나면 authenticate의 콜백 함수 호출됨.
  • authError: 인증 과정 중 에러
  • user: 인증 성공 시 유저 정보
  • info: 인증 오류에 대한 메시지
  • 인증이 성공했다면 req.login으로 세션에 유저 정보 저장

로컬 로그인 전략(Strategy)작성

//passport/localStrategy.js
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');

const User = require('../models/user');

module.exports = () => {
  passport.use(new LocalStrategy({//프론트에서 요청의 body로 email과 password를 보내줘야됨
    usernameField: 'email', //얘가 req.body.email이 되야됨
    passwordField: 'password',//req.body.password
  }, async (email, password, done) => { //위의 'email' == email, 'password' == password
    try {
      const exUser = await User.findOne({ where: { email } });
      if (exUser) { //이메일 가진 사람이 있으면
        const result = await bcrypt.compare(password, exUser.password); //프론트에서 받은 비밀번호와 DB의 비밀번호를 
        //비교해서 true, false를 리턴
        if (result) { //비번 일치
          done(null, exUser); //첫번째 인수 기본적으로 null, 
          //두번째는 성공했을 경우에는 유저 객체를 넣어줌.
          //그리고 done이 실행되면 아까 auth.js에서 실행하다 
          //여기로 넘어왔는데 나머지를 실행하러 다시 돌아간다.
        } else { //비번 불일치
          done(null, false, { message: '비밀번호가 일치하지 않습니다.' }); //실패했을 때는 두번째 인자에 false, 
          //세번째는 실패 이유
        }
      } else { //이메일 가진 사람이 없을 때
        done(null, false, { message: '가입되지 않은 회원입니다.' });
      }
    } catch (error) {
      console.error(error);
      done(error); //서버 에러 났을 때는 이렇게 처리
    }
  }));
};

카카오 로그인 전략 작성

//passport/kakaoStrategy.js
const passport = require('passport');
const KakaoStrategy = require('passport-kakao').Strategy;

const User = require('../models/user');

module.exports = () => {
  passport.use(new KakaoStrategy({
    //localStrategy와는 다르게 clientID, callbackURL이 담긴다.
    clientID: process.env.KAKAO_ID,
    callbackURL: '/auth/kakao/callback',
  }, async (accessToken, refreshToken, profile, done) => { //accessToken, refreshToken은 OAUTH2를 공부해보자 / 지금은 profile만 받아온다.
    console.log('kakao 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 && profile._json.kakao_account_email, //이메일은 profile._json.kakao_~에 있음
          nick: profile.displayName,
          snsId: profile.id,
          provider: 'kakao',
        });
        done(null, newUser); //회원가입 후 로그인
        //회원가입과 로그인이 동시에 일어난다.
      }
    } catch (error) {
      console.error(error);
      done(error);
    }
  }));
};
  • clientID에 카카오 앱 아이디 추가
  • callbackURL: 카카오 로그인 후 카카오가 결과를 전송해줄 URL
  • accessToken, refreshToken: 로그인 성공 후 카카오가 보내준 토큰(사용하지 않음)
  • profile: 카카오가 보내준 유저 정보
  • profile의 정보를 바탕으로 회원가입

multer패키지로 이미지 업로드하기

만약 form 태그의 enctype이 multipart/form-data일 때는 body-parser로는 요청 본문을 해석할 수 없으므로 multer가 필요하다.
npm i multer로 패키지 설치해준다.

//routes/post.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const { Post, Hashtag } = require('../models');
const { isLoggedIn } = require('./middlewares');

const router = express.Router();

try { //uploads 폴더에 파일들을 업로드 하는데 
  //없으면 안되니깐 없다면 생성하게 하는 코드임.
  fs.readdirSync('uploads');
} catch (error) {
  console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
  fs.mkdirSync('uploads');
}

const upload = multer({ //업로드 미들웨어
  storage: multer.diskStorage({ 
    //diskStorage는 이미지를 서버 디스크에 저장한다는거
    destination(req, file, cb) { //저장 경로
      cb(null, 'uploads/'); 
      //uploads 폴더에 img를 업로드 하겠다.
    },
    filename(req, file, cb) { //저장 파일명
      const ext = path.extname(file.originalname);
      cb(null, path.basename(file.originalname, ext) + Date.now() + ext);
      //파일명은 원래 파일명에 날짜를 더해서 만들어주겠다.
      //(중복 방지)
    },
  }),
  limits: { fileSize: 5 * 1024 * 1024 }, //파일 용량 제한: 5MB 
});


//이미지 업로드하는 라우터
router.post('/img', isLoggedIn, 
	upload.single('img'), (req, res) => { 
  //요청 본문의 img에 담긴 이미지 하나를 
  //읽어 설정대로 저장하는 미들웨어

  //로그인한 사람이 post /img 요청을 보내면서 
  //form에서 img라는 키로 이미지를 업로드해야됨. 
  //key가 일치해야된다.
  console.log(req.file);
  res.json({ url: `/img/${req.file.filename}` }); 
  //업로드 완료하면 이 파일을 요청할 수 있는 
  //url을 프론트로 돌려보내줌 / 실제 파일은 
  //uploads에 있는데 요청 주소는 img/임 
  //-> express static이 이 역할 수행
});

module.exports = router;

게시글 등록 라우터

//routes/post.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const { Post, Hashtag } = require('../models');
const { isLoggedIn } = require('./middlewares');

const router = express.Router();

try {
  fs.readdirSync('uploads');
} catch (error) {
  console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
  fs.mkdirSync('uploads');
}

const upload = multer({
  storage: multer.diskStorage({
    destination(req, file, cb) {
      cb(null, 'uploads/');
    },
    filename(req, file, cb) {
      const ext = path.extname(file.originalname);
      cb(null, path.basename(file.originalname, ext) + Date.now() + ext);
    },
  }),
  limits: { fileSize: 5 * 1024 * 1024 },
});

router.post('/img', isLoggedIn, upload.single('img'), (req, res) => {
  console.log(req.file);
  res.json({ url: `/img/${req.file.filename}` });
});

//왜 이미지 업로드하는 라우터, 
//게시글 업로드 하는 라우터 따로 분리해 놓을까?
//이미지, 게시글 동시에 업로드하게 라우터를 묶어 
//놓으면 이미지 압축하는데 시간이 오래 걸리기 
//때문에 업로드하는데 많은 시간이 소요된다.
//하지만 분리해 놓는다면 특히 이미지를 먼저 받고 
//게시글을 그 후에 받는다면
//게시글을 작성하는 동안 이미지를 압축하므로 
//시간을 단축할 수 있다.
//다만 url은 프론트로 보내줘서 게시글이랑 url
//(먼저 업로드한 이미지, 동영상 파일이 어디에 
//있는지에 대한 주소)이랑은 묶여있게끔 해준다.


//게시글 업로드하는 라우터
const upload2 = multer();
router.post('/', isLoggedIn, upload2.none(), async (req, res, next) => {
  //위에서 이미지 같은건 이미 업로드 했기 때문에 
  //body들만 업로드하면 돼서 upload.none()한거
  try {

    console.log(req.user);
    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);  //정규 표현식 / / 안에 사용한다.
    //알아두면 좋음!!
    //[]는 여러개중 하나, ^는 부정, \s는 띄어쓰기, #은 샵, *은 모두, g는 골라라 
    //-> 띄어쓰기랑 샵이 아닌 애들 모두를 골라라라는 의미임
    
    //set() 사용하면 중복 되는 것도 막아줘서 더 좋음 
    //-> 사용하는 방법 알아두면 좋을듯
    
    if (hashtags) { //해쉬태그가 있다면
      const result = await Promise.all( 
        //시퀄라이즈 메서드들은 모두 promise니깐
        //한번에 처리해주기 위해 promise.all함
        hashtags.map(tag => { 
          //findOrCreate와 비슷한거: upsert 
          //-> update+insert 존재하지 않는다면 
          //추가하고 존재한다면 update한다. 
          //공식문서 참고!
          return Hashtag.findOrCreate({ 
            //findOrCreate -> 중복 저장되지 않도록 해준다.
            //검사를 해서 이미 있다면 넘어가고 없다면 
            //생성해주는 시퀄라이즈 메서드임.
          
            
            where: { title: tag.slice(1).toLowerCase() }, // tag.slice.toLowerCase가 [#노드, #익스프레스]를 [노드, 익스프레스] 로 만들어준다.
            //이걸 findOrCreate하니깐 findOrCreate(노드), findOrCreate(익스프레스) 이런식으로 됨
          })
        }),
      );
      //console.log(result); 
      //-> [[해시태그 객체, true], [해시태그 객체, false]] 
      //이런식으로 이차원 배열로 결과값이 나온다. 
      //true는 생성되었다는거고 false는 이미 존재한다 라는 뜻.
      await post.addHashtags(result.map(r => r[0])); 
      //게시글에서 해시태그를 찾아서 게시글과 연결해준다.
      //addFollowings([1, 2, 3])처럼 id를 넣어도 되고, 시퀄라이즈 객체 자체를 넣어도 된다.
    }
    res.redirect('/');
  } catch (error) {
    console.error(error);
    next(error);
  }
});

module.exports = router;

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

const express = require('express');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const { Post, User } = require('../models');

const router = express.Router();

router.use((req, res, next) => { //use를 하면 모든 라우터에 공통 적용되는 특성 적용한거
  res.locals.user = req.user; //req.user는 passport의 deserializeUser에서 나온거임
  res.locals.followerCount = 0; //임시 값 넣어 놓은거임
  res.locals.followingCount = 0;
  res.locals.followerIdList = [];
  next();
});

router.get('/profile', isLoggedIn, (req, res) => {
  res.render('profile', { title: '내 정보 - NodeBird' });
});

router.get('/join', isNotLoggedIn, (req, res) => {
  res.render('join', { title: '회원가입 - NodeBird' });
});

router.get('/', async (req, res, next) => {
  try {
    const posts = await Post.findAll({ //업로드한 게시물 모두 찾아주고
      include: {
        model: User,
        attributes: ['id', 'nick'],
      },
      order: [['createdAt', 'DESC']],
    });
    res.render('main', { //찾은 게시물들을 twits에 넣어준다. 
      title: 'NodeBird',
      twits: posts,
    });
  } catch (err) {
    console.error(err);
    next(err);
  }
});

module.exports = router;

부가기능

팔로잉 기능 구현하기

  • 사용자 id는 req.params.id로 접근 가능
  • user.addFollowing(사용자아이디)로 팔로잉하는 사람 추가 가능
//routes/user.js
const express = require('express');

const { isLoggedIn } = require('./middlewares');
const User = require('../models/user');

const router = express.Router();

//REST API는 동사를 사용하면 안되지만 어쩔 수 없이 타협해서 follow 사용함. -> HTTP API정도로 말할 수 있을듯
router.post('/:id/follow', isLoggedIn, async (req, res, next) => {
  try {
    const user = await User.findOne({ where: { id: req.user.id } }); //내가 누군지를 찾고(나에 대한 객체)
    if (user) {
      await user.addFollowing(parseInt(req.params.id, 10)); 
      //parseInt는 req.params.id를 10진수로 바꾸는거다
      //(1:N 관계이기 때문에 복수 사용 가능) /
      //setFollowings를 하면 팔로잉하는 목록 수정 가능 
      //-> 기존 목록 다 제거하고 통째로 대체하는거임 /
      //removeFollowings하면 제거 / 
      //getFollwings는 팔로잉 목록 가져오기 
      //-> 관계 쿼리임
      res.send('success');
    } else {
      res.status(404).send('no user');
    }
  } catch (error) {
    console.error(error);
    next(error);
  }
});

module.exports = router;
//passport/index.js
const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');

module.exports = () => {
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({
      where: { id },
      include: [{ 
        //req.user안에다가 followings, followers 
        //들을 넣어주려면 include를 넣어줘야 한다.
        //req.user.Followers, req.user.Followings로 
        //팔로워, 팔로잉 접근 가능

        model: User, 
        //둘 다 model: User이기 때문에 구별이 
        //필요한 것들은 as를 통해 구별해준다.
        attributes: ['id', 'nick'], 
        //보안상 위험하기 때문에 프론트에서 꼭 
        //필요한 것들만 프론트로 보내준다.
        as: 'Followers',
      }, {
        model: User,
        attributes: ['id', 'nick'],
        as: 'Followings',
      }],
    })
      .then(user => done(null, user)) 
    //디시리얼라이즈로부터 req.user, req.isAuthenticated()이 생성된다.
      .catch(err => done(err));
  });

  local();
  kakao();
};

해시태그 검색 기능 구현

//routes/page.js
const express = require('express');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const { Post, User, Hashtag } = require('../models');

const router = express.Router();

router.use((req, res, next) => { //임시 데이터 수정
  res.locals.user = req.user; //req.user는 deserializeUser로부터 생성된다.(중요)
  res.locals.followerCount = req.user ? req.user.Followers.length : 0; //req.user가 있다는건 로그인한 경우라는 것이고 
  //그렇다면 팔로워 수를 알려주고, 그게 아니라면 0
  res.locals.followingCount = req.user ? req.user.Followings.length : 0;
  res.locals.followerIdList = req.user ? req.user.Followings.map(f => f.id) : []; 
  //팔로잉하고 있는 사람들의 아이디들 알려줌 
  //-> 이미 팔로우 하고 있는 사람들은 [팔로우하기] 
  //버튼대신 [언팔로우] 버튼을 보여줘야 하기 
  //때문에 FollowingIdList를 받아온거(변수명 잘못 지으셨대..)
  next();
});

router.get('/profile', isLoggedIn, (req, res) => {
  res.render('profile', { title: '내 정보 - NodeBird' });
});

router.get('/join', isNotLoggedIn, (req, res) => {
  res.render('join', { title: '회원가입 - NodeBird' });
});

router.get('/', 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 (err) {
    console.error(err);
    next(err);
  }
});

// GET /hashtag?hashtag=노드 -> 이런식으로 요청이 들어온다. 
//한글 검색할 때는 문제가 생길 수도 있기 때문에 데이터를 보낼 때 
//주소에 한글이 있다면 encodeURIComponent를 
//사용해주고 서버쪽에서는 decodeURIComponent로 
//받아줘야 한다.
router.get('/hashtag', async (req, res, next) => { //해시태그 검색 페이지라서 page.js에 추가함
  const query = decodeURIComponent(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'] }] }); //hashtag.getPosts해서 해당하는 해시태그에 딸려있는 게시글들을 가져와준다.
      //belongsToMany이기 때문에 getPosts로 복수형을 사용함
      //include: [{ model: User }]로 게시글의 작성자까지 다 가져온다.
      //만약 include에 model: User만 하면 id, password, provider ... 
      //프론트로 모두 다 가져오는데 이러면 보안상 위협되므로 
      //attributes 설정해서 꼭 필요한 것만 보내주는게 좋다. 
    }

    return res.render('main', {
      title: `#${query} 검색 결과 | NodeBird`,
      twits: posts,
    });
  } catch (error) {
    console.error(error);
    return next(error);
  }
});

module.exports = router;

0개의 댓글