관리 메뉴

진취적 삶

09 익스프레스로 SNS 서비스 만들기 본문

개발 도서/Node.js 교과서

09 익스프레스로 SNS 서비스 만들기

hp0724 2023. 7. 14. 11:46

9.1 프로젝트 구조 갖추기

npx sequelize init 

명령어 호출하면 config, migrations,models,seeders 폴더 생성

필요한 npm

npm i express cookie-parser express-session morgan multer dotenv nunjucks
npm i -D nodemon
app.use(
  session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
      httpOnly: true,
      secure: false,
    },
  })
);

세션을 설정할 때 session({...}) 메소드를 사용하며, 다음과 같은 옵션을 설정할 수 있습니다:

  • resave: 클라이언트 요청이 있을 때마다 세션을 다시 저장할지 여부를 결정합니다. **false**로 설정하면 세션이 변경되지 않으면 저장되지 않습니다.
  • saveUninitialized: 초기화되지 않은 세션을 저장할지 여부를 결정합니다. **false**로 설정하면 초기화되지 않은 세션은 저장되지 않습니다.
  • secret: 세션 ID를 암호화하는 데 사용되는 비밀 키입니다. 이 값을 알지 못한 경우에는 세션을 해독할 수 없으므로 보안상 중요합니다.
  • cookie: 세션 쿠키에 대한 옵션을 설정합니다. **httpOnly**는 JavaScript에서 세션 쿠키를 액세스할 수 없도록 하고, **secure**는 HTTPS 연결에서만 세션 쿠키를 전송하도록 합니다

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 app = express();
app.set("port", process.env.PORT || 8001);
app.set("view engine", "html");
nunjucks.configure("views", {
  express: app,
  watch: true,
});

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.rul} 라우터가 없습니다`);
  error.status = 404;
  next(error);
});

app.use((error, req, res, next) => {
  res.locals.message = error.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"), "번 포트에서 대기하는중");
});

.env 파일에 비밀번호를 저장하는 이유는 다음과 같습니다:

  1. 보안: 비밀번호를 코드에 하드코딩하는 것은 보안 위험이 큽니다. 코드를 공유하거나 코드 저장소에 업로드 할 때 실수로 비밀번호를 노출시키는 경우가 많기 때문입니다. .env 파일을 사용하여 비밀번호를 저장하면 코드 저장소에 업로드하지 않도록하고, 코드를 공유 할 때 비밀번호를 노출시키지 않을 수 있습니다.
  2. 유지보수: 비밀번호가 하드코딩되어 있으면 코드를 변경할 때마다 비밀번호를 수정해야합니다. 이러한 방식으로 코드를 작성하면 유지 보수가 어려울 수 있습니다. .env 파일을 사용하여 비밀번호를 저장하면 비밀번호를 한 곳에서 관리하므로 코드 수정이 간단해지고 유지 보수성이 향상됩니다.
  3. 환경 관리: 환경마다 다른 비밀번호가 필요한 경우가 있습니다. 예를 들어, 개발 환경과 프로덕션 환경에 각각 다른 데이터베이스 비밀번호가 필요합니다. .env 파일을 사용하면 각각의 환경에 맞게 쉽게 비밀번호를 관리할 수 있습니다.

routes/page.js

**res.locals**를 사용하면 변수를 공유하고, 코드의 재사용성을 높일 수 있으며, 뷰 데이터를 저장하고 미들웨어와 라우터 사이의 데이터 공유를 할 수 있습니다.

const express = require("express");
const {
  renderProfile,
  renderJoin,
  renderMain,
} = require("../controllers/page");

const router = express.Router();
//모든 변수는 템플릿 엔진에서 공통으로 사용할 예정
router.use((req, res, next) => {
  res.locals.user = null;
  res.locals.followerCount = 0;
  res.locals.followingCount = 0;
  res.locals.followingIdList = [];
  next();
});
//라우터 마지막에 위치해 클라이언트에 응답을 보내는 미들웨어
// 컨트롤러
router.get("/profile", renderProfile);
router.get("/join", renderJoin);
router.get("/", renderMain);

module.exports = router;

controllers/page.js

exports.renderProfile = (req, res) => {
  res.render("profile", { title: `내 정보- NodeBird` });
};

exports.renderJoin = (req, res) => {
  res.render("join", { title: `회원가입- NodeBird` });
};

exports.renderMain = (req, res) => {
  const twits = [];
  res.render("main", {
    title: "NodeBird",
    twits,
  });
};

res.send res.json res.redirect 등이 존재하는 미들웨어

실무에서 코드를 편하게 관리하기 위해 컨트롤러를 따로 분리

9.2 데이터베이스 세팅하기

로그인 기능을 위한 사용자 테이블 , 게시글을 저장할 게시글 테이블도 필요하고

해시태크를 사용하므로 해시태그 테이블도 만들어야 한다.

static associate(db) {
    //1:N
    db.User.hasMany(db.Post);
    //user 모델간 N:M
    //as 키는 foreignKey 와 반대되는 모델을 가르킨다. 
    //팔로잉
    db.User.belongsToMany(db.User, {
      foreignKey: "followingId",
      as: "Followers",
      through: "Follow",
    });
    //팔로워 
    db.User.belongsToMany(db.User, {
      foreignKey: "followerId",
      as: "Followings",
      through: "Follow",
    });
  }

**associate**함수는 모델의 정의 객체 내에 정의됩니다. 이 함수는 다른 모델과의 관계를 설정하기 위해 사용됩니다. 다른 모델과의 관계를 설정하려면 belongsTo, hasOne, hasMany, belongsToMany 등의 메소드를 사용해야 합니다. 각 메소드는 연관된 모델과의 관계를 설정하기 위한 다양한 옵션을 제공합니다.

관계 메서드를 사용하여 다양한 데이터베이스 작업을 수행할 수 있습니다. 예를 들어, **user.getPosts()**를 사용하여 특정 **user**와 연관된 모든 **Post**를 검색하고, **user.addPost()**를 사용하여 새로운 **Post**를 생성 및 연결할 수 있습니다.

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

세션과 쿠키 처리등 복잡한 작업이 많으므로 검증된 모듈을 사용하자.

req.session 객체는 express-session 에서 생성하는것이므로 passport 미들웨어는 express-session 이들웨어보다 뒤에 연결해야 한다.

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

module.exports = () => {
    //로그인 시 실행되면 req.session 객체에 어떤 데이터를 저장할지 정하는 메서드 
  passport.serializeUser((user, done) => {
    //첫번째 인수는 에러 , 두번째 인수는 저장하고싶은 데이터 
    done(null, user.id);
  });
  //각 요청마다 실행 passport.session 미들웨어가 호출 
  //serializeUser done 의 두번째 인수로 넣은 데이터가 매개변수가 된다. 
  passport.deserializeUser((id, done) => {
    User.findOne({ where: { id } })
      .then((user) => done(null, user))
      .catch((err) => done(err));
  });

  local();
  kakao();
};

serializeUser 는 사용자 정보 객체에서 아이디만 추려 세션에 저장하는것이고 ,deserializeUser는 세션에 저장한 아이디를 통해 사용자 정보 객체를 불러오는것이다 . 이는 세션에 불필요한 데이터를 담아두지 않기 위한 과정이다.

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

로그인 이후의 과정

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

9.3.1 로컬 로그인 구현

로컬 로그인은 아이디 /비밀번호 또는 이메일 /비밀번호를 통해 로그인하는것

로그인 → 회원가입 로그인 라우터 접근 불가

로그인 하지 않은 사용자 → 로그아웃 라우터에 접근 불가

라우터에 접근 권한을 제어하는 미들웨어가 필요하다.

미들웨어

//로그인 여부를 확인
exports.isLoggedIn = (req, res, next) => {
  //로그인 중이면  isAuthenticated true
  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}`);
  }
};

controllers. auth

const bcrypt = require("bcrypt");
const passport = require("passport");
const User = require("../models/user");

exports.join = async (req, res, next) => {
  const { email, nick, password } = req.body;
  try {
    const exUser = await User.findOne({ where: { email } });
    //기존에 같은 이메일로 사용자있는지 조회후 있으면 redirect
    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 (error) {
    console.error(error);
    return next(error);
  }
};

exports.login = (req, res, next) => {
  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) => {
      if (loginError) {
        console.error(loginError);
        return next(loginError);
      }
      return res.redirect("/");
    });
  })(req, res, next);
};

exports.logout = (req, res) => {
  req.logout(() => {
    res.redirect("/");
  });
};

authenticate 의 첫번째 매개변수값이 있다면 실패 두번째 매개변수의 자리는 사용자 정보 이 자리에 값이 있다면 성공 이 값으로 req.login 메서드를 호출

req.logout 메서드는 req.user 객체와 req.session 객체를 제거한다.

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(
      {
        usernameField: "email",
        passwordField: "password",
        passReqToCallback: false,
      },
      async (email, password, done) => {
        try {
          const exUser = await User.findOne({ where: { email } });
          if (exUser) {
            const result = await bcrypt.compare(password, exUser.password);
            //로그인 성공시
            if (result) {
              done(null, exUser);
            } else {
              //로그인 실패시
              done(null, false, { message: "비밀번호가 일치하지 않습니다" });
            }
          } else {
            done(null, false, { message: "가입되지 않은 회원입니다. " });
          }
        } catch (error) {
          //서버 에러시
          console.error(error);
          done(error);
        }
      }
    )
  );
};

LocalStrategy 첫번째 인수로 주어진 객체는 전략에 관한 설정

실제 전략을 수행하는 async 함수 LocalStrategy 두번째 인수로 들어간다.

전략은 사용자 데이터 베이스에에서 일치하는 이메일이 있는지 찾은후 bcrypt의 compare 함수로 비밀번호를 비교한다. 성공할경우 done 함수의 두번째 인수로 사용자 정보 넣어보내기

두번째 인수를 사용하지 않는 경우는 로그인에 실패했을 때뿐 .

세번째 인수를 사용하는 경우는 로그인 처리과정 실패

첫번째 인수는 서버쪽에서 에러 발생

9.3.2. 카카오 로그인 구현하기

처음 로그인할때는 회원 가입 처리를 해야하고, 두번째 로그인부터는 로그인 처리를 해야한다.

const passport = require("passport");
const KakaoStrategy = require("passport-kakao").Strategy;

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

module.exports = () => {
  passport.use(
    new KakaoStrategy(
      {
        clientID: process.env.KAKAO_ID,
        callbackURL: "/auth/kakao/callback",
      },
      async (accessToken, refreshToken, profile, done) => {
        console.log("kakao profile", profile);
        try {
          const exUser = await User.findOne({
            where: { snsId: profile.id, provider: "kakao" },
          });
          //유저가 이미 존재하므로 사용자 정보과 함께 done 함수 호출 
          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",
            });
            done(null, newUser);
          }
        } catch (error) {
          console.error(error);
          done(error);
        }
      }
    )
  );
};

9.3.3 naver 만들어보기

애플리케이션 - NAVER Developers

에 들어가서 Client ID Client secret 받아오고

enum 에 naver 추가하기

  provider: {
          type: Sequelize.ENUM("local", "kakao", "naver"),
          allowNull: false,
          defaultValue: "local",
        },

네이버 전략 만들고

const passport = require("passport");
const NaverStrategy = require("passport-naver").Strategy;
const User = require("../models/user");

module.exports = () => {
  passport.use(
    new NaverStrategy(
      {
        clientID: process.env.NAVER_CLIENT_ID,
        clientSecret: process.env.NAVER_CLIENT_SECRET,
        callbackURL: "/auth/naver/callback",
      },
      async (accessToken, refreshToken, profile, done) => {
        console.log("naver profile", profile);

        try {
          const exUser = await User.findOne({
            where: { snsId: profile.id, provider: "naver" },
          });
          if (exUser) {
            done(null, exUser);
          } else {
            const newUser = await User.create({
              email: profile.emails[0].value,
              nick: profile.displayName,
              provider: profile.provider,
              snsId: profile.id,
            });
            done(null, newUser);
          }
        } catch (error) {
          console.error(error);
          done(error);
        }
      }
    )
  );
};

passport index.js 에 추가

const passport = require("passport");
const local = require("./localStrategy");
const kakao = require("./kakaoStrategy");
const naver = require("./naverStrategy");

//user 테이블 참조
const User = require("../models/user");

module.exports = () => {
  //로그인 시 실행되면 req.session 객체에 어떤 데이터를 저장할지 정하는 메서드
  passport.serializeUser((user, done) => {
    //첫번째 인수는 에러 , 두번째 인수는 저장하고싶은 데이터
    done(null, user.id);
  });
  //각 요청마다 실행 passport.session 미들웨어가 호출
  //serializeUser done 의 두번째 인수로 넣은 데이터가 매개변수가 된다.
  //id 가 done 에서 두번째 인수에 넣은 데이터 (user.id)
  passport.deserializeUser((id, done) => {
    User.findOne({
      where: { id },
      include: [
        { model: User, attributes: ["id", "nick"], as: "Followers" },
        { model: User, attributes: ["id", "nick"], as: "Followings" },
      ],
    })
      .then((user) => done(null, user))
      .catch((err) => done(err));
  });

  local();
  kakao();
  naver();
};

데이터 베이스 동기화도 해주기

sequelize
	//false 를 frue 로 바꾸면 동기화 됨 
  .sync({ force: true })
  .then(() => {
    console.log("데이터베이스 연결 성공");
  })
  .catch((error) => {
    console.error(error);
  });

9.4 multer 패키지로 이미지 업로드 구현하기

routes.post

const express = require("express");
const multer = require("multer");
const path = require("path");
const fs = require("fs");

const { afterUploadImage, uploadPost } = require("../controllers/post");
const { isLoggedIn } = require("../middlewares");

const router = express.Router();

const uploadDir = "uploads";

if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir);
}

const upload = multer({
  storage: multer.diskStorage({
    destination(req, file, cb) {
      cb(null, uploadDir);
    },
    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 },
});

//POST/post/img
//이미지 저장
router.post("/img", isLoggedIn, upload.single("img"), afterUploadImage);

//POST/post
const upload2 = multer();
router.post("/", isLoggedIn, upload2.none(), uploadPost);

module.exports = router;

controller.posts

const { Post, Hashtag } = require("../models");

exports.afterUploadImage = (req, res) => {
  console.log(req.file);
  res.json({ url: `/img/${req.file.filename}` });
};

exports.uploadPost = async (req, res, next) => {
  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) {
      const result = await Promise.all(
        hashtags.map((tag) => {
          //해시태그 떄고 소문자로 바꾸기
          //findOrCreate 는 존재하면 가져오고 존재하지 않으면 생성후 가져오기
          return Hashtag.findOrCreate({
            where: { title: tag.slice(1).toLowerCase() },
          });
        })
      );
      //[모델,생성여부] 를 반환하므로r[0]을 통해서  모델만 추출
      await post.addHashtags(result.map((r) => r[0]));
    }
    res.redirect("/");
  } catch (error) {
    console.error(error);
    next(error);
  }
};

'개발 도서 > Node.js 교과서' 카테고리의 다른 글

11 노드 서비스 테스트하기  (0) 2023.07.14
10 웹 API 서버 만들기  (0) 2023.07.14
08 몽고디비  (0) 2023.07.14
07 MySQL  (0) 2023.07.14
06 익스프레스 웹 서버 만들기  (0) 2023.07.14