Next.js 서버사이드렌더링
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
next: 13.0.4
antd: 5.0.1
소스 : https://github.dev/braverokmc79/node-bird-sns
제로초 소스 : https://github.com/ZeroCho/react-nodebird
84.nginx + https 적용하기
Node.js/Tomcat에서 앱이 이미 실행 중이라 가정
이 글에서는 하나의 서버 연동만을 다룬다. 여러 서버를 연동하고 싶으면 아래 글을 참고
(nginx에 여러 서버, 여러 도메인(Subdomain) 연동하기 (+ ssl 적용))
1. Install nginx
apt-get install nginx
2. nginx 설정 변경
Configuration 파일: /etc/nginx/sites-available/default
server { listen 80 default_server; listen [::]:80 default_server; root [[root directory]]; server_name _; location / { # First attempt to serve request as file, then # as directory, then fall back to displaying a 404. try_files $uri $uri/ =404; proxy_pass [[http://IP주소:port]]; } }
port는 따로 수정하지 않았다면 Node.js의 기본값은 3000, Tomcat의 기본값은 8080이다.
3. nginx 재시작
Node.js를 실행하고 nginx를 재시작
/etc/init.d/nginx restart
80포트로 접속하면 proxy_pass에 적어둔 주소로 가지는 것을 확인할 수 있다.
4. nginx 관련 기타
nginx 상태 확인: status nginx.service or nginx -t
nginx 로그 위치: /var/log/nginx
아래는 예제 설정 파일
1. 가정 조건
Apple Service
Server: Nginx
Port: 3000
Domain: fruit.com www.fruit.com
Root Directory: /var/html/www/apple
2. Example
/etc/nginx/sites-available/default
server { listen 80 default_server; listen [::]:80 default_server; root /var/html/www/apple; server_name fruit.com www.fruit.com; location / { # First attempt to serve request as file, then # as directory, then fall back to displaying a 404. try_files $uri $uri/ =404; proxy_pass http://localhost:3000; } }
출처
https://it.tarashin.com/nginx-node-js-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0/
85.백엔드에 https 적용하기
강의 :
다음 참조:
우분투 20.04 에서 Nginx 톰캣 연동 및 Ssl 설치
letsencrypt란?
Let's Encrypt는 사용자에게 무료로 TLS 인증서를 발급해주는 비영리기관이다.
이 글에서는 https://letsencrypt.org/ko/getting-started 에서 제공하는 Shell Access 권한이 있을 경우에 사용하는 certbot client를 이용하여 작업을 하였다.
1. certbot 설치
certbot은 자동으로 인증서를 발급받고 관리 및 갱신해주는 프로그램이다.
https://certbot.eff.org/instructions?ws=nginx&os=ubuntufocal 에서 nginx와 ubuntu를 선택하여 설치방법을 따라하였다.
certbot 사이트에 나와있는 설치방법
- SSH into the server
- Install snapd
- Ensure that your version of snapd is up to date
- Remove certbot-auto and any Certbot OS packages
- Install Certbot
- Prepare the Certbot command
- Choose how you'd like to run Certbot
- Test automatic renewal
- Confirm that Certbot worked
1.1 pem 키를 이용하여 ssh로 서버에 접속한다.
1.2 snapd를 설치한다.
sudo apt update sudo apt-get install snapd
1.3 snapd를 최신버전으로 업데이트 한다.
sudo snap install core; sudo snap refresh core
1.4 다른 certbot 패키지들이 있는지 확인하고 있다면 제거해준다.
sudo apt-get remove certbot
1.5 certbot 설치
sudo snap install --classic certbot
1.6 certbot 커맨드를 이용하기 위해 로컬 폴더에 링크
sudo ln -s /snap/bin/certbot /usr/bin/certbot
1.7 이후의 과정은 아래의 과정을 진행 후 다시 작업
2. nginx 설정
2.1 nginx conf 파일 수정
ubuntu 기본 설정을 따라 nginx 설정파일을 연다.
sudo vi /etc/nginx/sites-available/default
server_name의 example.com을 자신의 도메인에 맞게 설정하면 된다.
이 서버는 FE의 배포 서버이므로 아래와 같이 location을 설정하였다.
server { listen 80; server_name example.com; location / { root /home/ubuntu/Project/Front/build; index index.html index.htm; try_files $uri /index.html; } }
위와 같이 default 파일을 변경한 후 :wq를 사용하여 저장한다.
2.2 certbot 실행
sudo certbot --nginx
위 명령어를 실행하면 이메일을 적어주는 것이 나온 후 약관을 묻는다.
이메일을 적어주고 동의하자.
그 이후 자신이 사용할 도메인을 선택하거나 입력해주면 작업이 완료된다.
Congratulations! 가 출력되면 https가 적용된 것이다.
2.3 nginx 재실행 및 redirect 추가
sudo systemctl restart nginx
nginx를 재시작해주면 https가 잘 작동하는 것을 볼 수 있다.
원래는 nginx 설정내에서 80포트를 443포트로 redirect 해주는 코드가 필요하지만 certbot이 알아서 default파일을 아래와 같이 바꾸어 놓으니 따로 작업을 안해주어도 된다.
# sudo vi /etc/nginx/sites-available/default server { server_name example.com; location / { root /home/ubuntu/Project/Front/build; index index.html index.htm; try_files $uri /index.html; } listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot } server { if ($host = example.com) { return 301 https://$host$request_uri; } # managed by Certbot listen 80; server_name example.com; return 404; # managed by Certbot }
이번 포스트에서는 FE 배포 서버를 기준으로 하였으나 BE API 서버는 nginx의 프록시 서버를 이용하여 동일한 과정을 진행하면 가능하다.
86.nginx와 콘솔 에러 해결하기
강의 :
참조: 다방 사이트
프론트 엔드
next.config.js
hidden-source-map 설정
webpack(config, { webpack }) { const prod = process.env.NODE_ENV === 'production'; const newConfig = { ...config, mode: prod ? 'production' : 'development', plugins: [ ...config.plugins, new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/), ], }; if (prod) { newConfig.devtool = 'hidden-source-map'; } return newConfig; }, }
store/configureStore.js 에서 다음과 같이 변경
~ const configureSotre = () => { const sagaMiddleware = createSagaMiddleware(); const middlewares = [sagaMiddleware]; const enhancer = process.env.NODE_ENV === 'production' ? compose(applyMiddleware(...middlewares)) : composeWithDevTools(applyMiddleware(...middlewares)); // const store = createStore(reducer, enhancer); const store = createStore(reducer, enhancer); store.sagaTask = sagaMiddleware.run(rootSaga); return store; }; ~
https://nextjs.org/docs/api-reference/next/link
다음/링크
예
계속 진행하기 전에 라우팅 소개 를 먼저 읽어 보시기 바랍니다 .
경로 간의 클라이언트 측 전환은 에서 Link내보낸 구성 요소 를 통해 활성화할 수 있습니다 next/link.
예를 들어 pages다음 파일이 있는 디렉토리를 고려하십시오.
- pages/index.js
- pages/about.js
- pages/blog/[slug].js
다음과 같이 이러한 각 페이지에 대한 링크를 가질 수 있습니다.
import Link from 'next/link' function Home() { return ( <ul> <li> <Link href="/">Home</Link> </li> <li> <Link href="/about">About Us</Link> </li> <li> <Link href="/blog/hello-world">Blog Post</Link> </li> </ul> ) } export default Home
Link다음 소품을 받아들입니다.
- href- 탐색할 경로 또는 URL입니다. 이것은 유일한 필수 소품입니다. 객체일 수도 있습니다. 여기에서 예를 참조하세요.
- as- 브라우저 URL 표시줄에 표시될 경로에 대한 선택적 데코레이터. Next.js 9.5.3 이전에는 동적 경로에 사용되었습니다 . 작동 방식을 보려면 이전 문서 를 확인하세요. 참고: 이 경로가 이전 문서에 제공된 경로와 다른 경우 / 동작은 href이전 문서 에 표시된 대로 사용됩니다 .hrefas
- legacyBehavior- 행동을 변경하여 아이가 <a>. 기본값은 false입니다.
- passHref- 자녀에게 재산 Link을 보내도록 강요 합니다. href기본값은false
- prefetch- 백그라운드에서 페이지를 미리 가져옵니다. 기본값은 true입니다. 뷰포트에 있는 모든 <Link />항목(초기 또는 스크롤을 통해)은 미리 로드됩니다. 프리페치는 를 전달하여 비활성화할 수 있습니다 prefetch={false}. prefetch가 로 설정 되면 false호버에서 프리페치가 계속 발생합니다. 정적 생성 을 사용하는 JSON페이지는 더 빠른 페이지 전환을 위해 데이터가 있는 파일을 미리 로드 합니다. 프리페치는 프로덕션에서만 활성화됩니다.
- replacehistory- 스택에 새 URL을 추가하는 대신 현재 상태를 바꿉니다 . 기본값은false
- scroll- 탐색 후 페이지 상단으로 스크롤합니다. 기본값은true
- shallowgetStaticProps- 재실행 하지 않고 현재 페이지의 경로를 업데이트 getServerSideProps하거나 getInitialProps. 기본값은false
- locale- 활성 로케일이 자동으로 추가됩니다. locale다른 로캘을 제공할 수 있습니다. false href기본 동작으로 로케일을 포함해야 하는 경우 비활성화됩니다 .
legacyBehavior가 로 설정되지 않은 경우 true모든 anchor태그 속성은 , , 등 next/link으로 전달될 수 있습니다.classNameonClick
Https 적용후 에러 발생한다면
1. node.js 에서 app.js app.set('trust proxy', 1); 설정
app.js
~if (process.env.NODE_ENV === 'production') { //app.set('trust proxy', 1); console.log(" production 실행 "); app.use(morgan('combined')); app.use(hpp()); app.use(helmet({ contentSecurityPolicy: false })); app.use(cors({ //origin: 'https://nodebird.com' // origin: true, // orign: true 로 설정해두면 * 대신 보낸 곳의 주소가 자동으로 들어가 편리합니다. //프론트 URL 주소 origin: ["http://localhost:3060", "http://macaronics.iptime.org", "http://macaronics.iptime.org:3060"], credentials: true })); } ~
2.app.js 에서 app.use(session 에서 proxy:true 설정
~ app.use(session({ secret: process.env.COOKIE_SECRET, resave: false, saveUninitialized: false, proxy:true, cookie: { httpOnly: true, secure: false, //**쿠키를 저장할 도메인 설정 //http://192.168.120.136/ //domain: process.env.NODE_ENV === 'production' && '.mynodebird.com' domain: process.env.NODE_ENV === 'production' && 'macaronics.iptime.org' } })); ~
3. nginx 에서 proxy_set_header X-Forwarded-Proto $scheme; 추가
server{ server_name 192.168.120.137; listen 80; location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://localhost:3065; #whatever port your app runs on proxy_redirect off; charset utf-8; } }
87.게시글 수정하기
강의 :
백엔드
routes/post.js
~ //게시글 수정 router.patch('/:postId', isLoggedIn, async (req, res, next) => { const hashtags = req.body.content.match(/#[^\s#]+/g); try { await Post.update({ content: req.body.content }, { where: { id: req.params.postId, UserId: req.user.id }, }); const post = await Post.findOne({ where: { id: req.params.postId } }); if (hashtags) { const result = await Promise.all(hashtags.map((tag) => Hashtag.findOrCreate({ where: { name: tag.slice(1).toLowerCase() } } ))); //setHashtags 기존 해시태그 제거후 업데이트 await post.setHashtags(result.map((v) => v[0])); } res.status(200).json({ PostId: parseInt(req.params.postId, 10), content: req.body.content }) } catch (error) { console.error(error); next(error); } }); ~
프론트엔드
reducers/post.js
~ //글수정 case UPDATE_POST_REQUEST: draft.updatePostLoading = true; draft.updatePostDone = false; draft.updatePostError = null; break; case UPDATE_POST_SUCCESS: draft.updatePostLoading = false; draft.updatePostDone = true; draft.mainPosts.find((v) => v.id === action.data.PostId).content = action.data.content; break; case UPDATE_POST_FAILURE: draft.updatePostLoading = false; draft.updatePostError = action.error; break; ~
saga/post.js
~ //게시글 수정 function updatePostAPI(data) { console.log("게시글 수정 : ", data); return axios.patch(`/post/${data.PostId}`, data); } function* updatePost(action) { try { const result = yield call(updatePostAPI, action.data); yield put({ type: UPDATE_POST_SUCCESS, data: result.data }); } catch (err) { console.log(err); yield put({ type: UPDATE_POST_FAILURE, error: err.response.data }); } } function* watchUpdatePost() { yield takeLatest(UPDATE_POST_REQUEST, updatePost); } ~
components/PostCard.js
~ const onClickUpdate = useCallback(() => { setEditMode(true); }, []); const onCancelUpdatePost = useCallback(() => { setEditMode(false); }, []); const onChangePost = useCallback((editText) => () => { dispatch({ type: UPDATE_POST_REQUEST, data: { PostId: post.id, content: editText, }, }); }, [post]); ~ <Space wrap> {!post.RetweetId && <Button type='primary' info onClick={onClickUpdate} >수정</Button>} <Button type='primary' danger loading={removePostLoading} onClick={onRemovePost}>삭제</Button> </Space> ~ <> <div style={{ float: 'right' }}>{moment(post.createdAt).format('YYYY.MM.DD')}</div> <Card.Meta avatar={ <Link href={`/user/${post.User.id}`} prefetch={false}> <Avatar>{post.User.nickname[0]}</Avatar> </Link> } title={post.User.nickname} description={<PostCardContent editMode={editMode} onChangePost={onChangePost} onCancelUpdatePost={onCancelUpdatePost} postData={post.content} />} /> </>
components/PostCardContent.js
import React, { useCallback, useState, useEffect } from 'react'; import { Input, Button, Space } from 'antd'; import PropTypes from 'prop-types'; import Link from 'next/link'; import { useSelector } from 'react-redux'; const { TextArea } = Input; const PostCardContent = ({ postData, editMode, onChangePost, onCancelUpdatePost }) => { const { updatePostLoading, updatePostDone } = useSelector((state) => state.post); const [editText, setEditText] = useState(postData); const onChangeText = useCallback((e) => { setEditText(e.target.value); }); useEffect(() => { if (updatePostDone) { onCancelUpdatePost(); } }, [updatePostDone]); return ( <div> { editMode ? ( <> <TextArea value={editText} onChange={onChangeText} /> <Space wrap> <Button type='primary' info loading={updatePostLoading} onClick={onChangePost(editText)} > 수정</Button> <Button type='primary' danger onClick={onCancelUpdatePost} >취소</Button> </Space> </> ) : postData && postData.split(/(#[^\s#]+)/g).map((v, index) => { if (v.match(/(#[^\s#]+)/)) { return <Link key={index} href={`/hashtag/${v.slice(1)}`} prefetch={false} >{v}</Link> } return v; }) } </div > ); }; PostCardContent.propTypes = { postData: PropTypes.string.isRequired, editMode: PropTypes.bool, onCancelUpdatePost: PropTypes.func.isRequired } PostCardContent.defaultProps = { editMode: false } export default PostCardContent;
88.빠르게 어드민 페이지 만들기
강의 :
89.팔로잉한 게시글만 가져오기
강의 :
related
const followings = await User.findAll({ attributes: ['id'], include: [{ model: User, as: 'Followers', where: { id: req.user.id } }] }); const where = { UserId: { [Op.in]: followings.map((v) => v.id) } };
unrelated
const followings = await User.findAll({ attributes: ['id'], include: [{ model: User, as: 'Followers', where: { id: req.user.id } }] }); const where = { UserId: { [Op.notIn]: followings.map((v) => v.id).concat(req.user.id) } };
routes/posts.js
const express = require('express'); const { Op } = require('sequelize'); const { Post, Image, User, Comment } = require('../models'); const router = express.Router(); //GET /posts router.get('/', async (req, res, next) => { try { console.log(" 마지막 아이디 :", req.query.lastId); const where = {}; if (parseInt(req.query.lastId, 10)) { //초기 로딩이 아닐때 //다음 코드 내용은 id 가 lastId 보다 작은 것 => id < lastId // Op 의미는 연산자 의미 lt 는 < where.id = { [Op.lt]: parseInt(req.query.lastId, 10) } }; const posts = await Post.findAll({ where, limit: 10, order: [ ['createdAt', 'DESC'], [Comment, 'createdAt', 'DESC'] ], // offsset: parseInt(req.params.limit), //21,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1 include: [{ model: User, attributes: ['id', 'nickname'] }, { model: Image }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'] }] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] }, { model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] } ] }); res.status(200).json(posts); } catch (error) { console.error("posts error : ", error); next(error); } }); router.get('/related', async (req, res, next) => { try { const followings = await User.findAll({ attributes: ['id'], include: [{ model: User, as: 'Followers', where: { id: req.user.id } }] }); const where = { UserId: { [Op.in]: followings.map((v) => v.id) } }; if (parseInt(req.query.lastId, 10)) { // 초기 로딩이 아닐 때 where.id = { [Op.lt]: parseInt(req.query.lastId, 10) } } // 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 const posts = await Post.findAll({ where, limit: 10, order: [ ['createdAt', 'DESC'], [Comment, 'createdAt', 'DESC'], ], include: [{ model: User, attributes: ['id', 'nickname'], }, { model: Image, }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'], }], }, { model: User, // 좋아요 누른 사람 as: 'Likers', attributes: ['id'], }, { model: Post, as: 'Retweet', include: [{ model: User, attributes: ['id', 'nickname'], }, { model: Image, }] }], }); res.status(200).json(posts); } catch (error) { console.error(error); next(error); } }); router.get('/unrelated', async (req, res, next) => { try { const followings = await User.findAll({ attributes: ['id'], include: [{ model: User, as: 'Followers', where: { id: req.user.id } }] }); const where = { UserId: { [Op.notIn]: followings.map((v) => v.id).concat(req.user.id) } }; if (parseInt(req.query.lastId, 10)) { // 초기 로딩이 아닐 때 where.id = { [Op.lt]: parseInt(req.query.lastId, 10) } } // 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 const posts = await Post.findAll({ where, limit: 10, order: [ ['createdAt', 'DESC'], [Comment, 'createdAt', 'DESC'], ], include: [{ model: User, attributes: ['id', 'nickname'], }, { model: Image, }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'], }], }, { model: User, // 좋아요 누른 사람 as: 'Likers', attributes: ['id'], }, { model: Post, as: 'Retweet', include: [{ model: User, attributes: ['id', 'nickname'], }, { model: Image, }] }], }); res.status(200).json(posts); } catch (error) { console.error(error); next(error); } }) module.exports = router;
댓글 ( 4)
댓글 남기기