Search

React & 노드를 이용한 SNS 서비스

React & Node SNS 서비스를 제작하면서 공부한 내용 정리 & 코드에 대한 리뷰입니다

1. 간단 정리

> 배포된 사이트 주소 : https://taewitter.com (현재는 운영 중단)
> 이용 스택 : React, Node.js, MYSQL, Redux, Redux-saga, Next.js
> 앞서 만든 SNS 사이트의 nunjucks 코드를 React로 바꿔보고 싶어 공부하고 만들기 시작
> Ant Design을 사용해서 빠르게 결과물을 만들어낼 수 있도록 하였음
> 서버사이드 랜더링까지 적용하여 검색 엔진에까지 노출될 수 있게끔 개발
> 도메인을 구매한 뒤, nginx + https까지 적용
> AWS를 통해 사이트를 직접 배포하는 과정까지 진행 (위 주소를 통해 접속 가능)

2. 공부 내용

(아래는 이후 복습을 진행하며 추가로 작성할 예정)

3. 세부 정리

1. 이용한 기술 스택

최근 React와 Node.js에 대해 중점적으로 공부하고 있다. 따라서 React, Node.js, Redux, Redux-saga, Next.js를 이용해서 실제 운영 가능한 SNS 웹 사이트를 제작하고 도메인을 구매해 실제로 지인들에게 배포해보았다. DB의 경우에는 MYSQL을 이용했으며 세션 쿠키는 Redis에 저장했고, 업로드 되는 사진의 경우에는 S3에 저장하였다. 디자인보다 기능적인 부분에 집중하기 위해서 디자인에는 Ant Design을 이용해 빠르게 개발하였다.

2. 개발 진행 플로우 정리

처음에는 Reducer & Saga에서 shortid, faker 등의 더미데이터 라이브러리를 이용해 프론트 서버를 구축하고, 이후에 더미데이터를 실제 백엔드 서버의 데이터로 대체해주는 방식으로 제작하였다. 또, 필요하다고 생각되는 기능이 있을 때마다 추가로 컴포넌트 & Redux & Redux Saga 및 백엔드 라우터들을 만들어주었다. 이후에 도메인을 구매해 AWS 서버와 연동해주었고, snap과 nginx을 사용해 https를 적용해주었다.

3. 코드 및 로직 소개

코드를 전부 소개하기에는 지나치게 길기 때문에 세부적인 코드에 대한 설명보다는 큰 틀에서 각 컴포넌트 및 페이지 등에 대해 설명해보겠다. 백과 프론트 폴더 구성은 위와 같다.

3-1. 컴포넌트

공통 헤더
로그인 과정
좋아요/리트윗/댓글/팝오버 기능

3-2. 모델 (MYSQL)

각각의 모델들의 경우에는 sequelize를 이용하여 만들었다. sequelize는 nodejs에서 MYSQL을 쉽게 다룰 수 있도록 도와주는 라이브러리이다. MYSQL을 분명 열심히 공부했었는데 최근에 sequelize를 중점적으로 사용하다보니 MYSQL 문법이 살짝 가물가물하다 ㅎㅎ.. 다시 열심히 복습해야겠다.
npx sequelize db:create 명령어를 통해 react-nodebird 데이터베이스를 만들어주었으며 모델은 위와 같이 comment, hashtag, image, post, report, user 5가지를 만들어주었다. 이후 모델을 통해서 만들어진 각각의 테이블들은 관계형 데이터베이스로 서로가 서로에게 연결돼있다.
모델을 통해서 만들어진 테이블들은 위와 같다. 모델은 5개 뿐이지만 각각 모델 간의 관계를 통해 만들어진 테이블들도 있다. 가끔씩 테이블을 Drop 해야할 때가 있었는데, 아무래도 각각의 테이블들이 서로 관계를 맺고 있다보니 관계까지 신경써서 테이블을 삭제해줘야해서 상당히 귀찮을 때가 많았다 (Workbench에 기능 추가해주십쇼...)

3-3. 라우터

제작한 라우터들은 위와 같고, app.js에서 이 각각의 라우터들을 장착하여 사용하였다. 그중 post 라우터를 만드는데 가장 많은 공을 들였다. S3에 이미지를 업로드하는 것부터 게시글 신고, 게시글 삭제 및 수정, 댓글 삭제 등 기능에 대한 수많은 라우터를 만들고 app.js에 장착해주었다.
routes/post.js - 1
router/post.js - 2
app.js
프론트 및 Redux로부터 특정 액션을 통해 API에 axios 요청을 보내면 백엔드 서버에서 이를 받아서 처리한 뒤, 응답으로 특정 결과를 보내주는 방식이다.

3-4. Reducer & Saga

이번에 처음으로 Redux & Redux-Saga를 써보았는데, 물론 코드가 직관적이라는 점은 좋았지만 코드가 조금은 쓸데없이? 길어진다는 느낌을 받았다. 최근에는 Redux toolkit을 이용함으로써 코드가 많이 줄어들고 있는 추세라고 하는데, 다음 프로젝트에서는 이 Redux toolkit을 이용해 더 효율적인 Redux 사용을 해보고 싶다. Reducer와 Saga를 작성하는 과정은 '공부 내용' 파트에 자세하게 정리해놨으니 추가적인 설명은 생략하겠다.

3-4. 도메인 구매 및 AWS 배포

도메인은 gabia에서 1년을 기간으로 약 14000원을 주고 구매했다.
ec2에 Ubuntu를 이용해 배포한 뒤, 탄력적 IP를 통해 IP를 고정하였고, 호스팅 영역에 AWS에서 배포된 서버와 taewitter.com 및 백엔드 도메인을 연결해주었다.
또, S3를 이용하여 SNS에 업로드된 사진을 로컬이 아닌 S3에서 관리할 수 있도록 해주었다.

4. 완성 결과물

완성된 결과물은 아래와 같다.

4-1. 로그인 되지 않은 메인페이지

4-2. 로그인이 완료된 메인페이지

4-3. 회원가입 페이지 & 약관

해당 약관에 동의하지 않은 경우 가입하기 버튼을 누르더라도 '약관에 동의해야합니다!'라는 문구가 출력된다 또, 이메일이 사용 중인 경우엔 '사용 중인 이메일입니다'가, 닉네임이 사용 중인 경우엔 '사용 중인 닉네임입니다'라는 경고창이 출력되도록 하였다.

4-4. 프로필 페이지 (닉네임 수정, 팔로잉 & 팔로워)

내가 팔로우한 사람들의 목록, 그리고 나를 팔로우하는 사람들의 목록을 확인할 수 있으며 닉네임도 변경이 가능하다. 앞서 회원가입할 때도 마찬가지였지만 만약에 바꾸려는 닉네임을 사용하고 있는 사용자가 이미 존재할 경우, '사용중인 닉네임입니다!'라는 경고창이 뜨며 닉네임이 수정되지 않는다.

4-5. 내가 쓴 게시물만 불러오기 (프로필에 '게시물' 클릭)

좌측에 게시글 | 팔로잉 | 팔로워 버튼 중 '게시글' 버튼을 누르면 내가 쓴 게시물만 모아서 볼 수 있다.

4-6. 댓글 기능 및 좋아요 기능

댓글을 달 수 있고, 댓글을 삭제할 수도 있다. 원래 댓글을 수정하는 기능도 넣어놨었는데 UI 상으로 별로 보기가 안 좋아서 지워버렸다. 실제로 인스타그램도 댓글 수정 기능이 없는데, 왜 안 넣어놨는지 살짝 알거같았다.

4-7. 해시태그 검색 기능

위 사진은 #고양국제고라는 태그를 단 뒤에 위 헤더의 '해시태그 검색'이라는 placeholder가 있는 검색바에서 '고양국제고'를 검색한 결과이다. 그렇게 하면 해당 해시태그를 가지고

4-8. 게시글 수정 기능

댓글은 수정하지 못하지만 게시글은 수정할 수 있다. 이미지 수정 기능도 넣을까했는데 인스타그램도 이미지 수정 기능이 없는걸 보고 다음 기회에 만들어보기로 했다.

4-9. 사진 여러 장 업로드

사진은 꼭 1장만 올려야되는 것이 아니라 여러 장을 업로드하는 것도 가능하며, 우측의 '2개의 사진 더보기' 버튼을 누를 경우, 나머지 사진을 마우스로 넘기며 볼 수 있다.

4-10. 검색 엔진 최적화

실제로 Next.js를 이용해 서버사이드 랜더링을 적용해놓았기 때문에 위와 같이 검색 엔진에서 검색이 가능한 것을 볼 수 있다.

4-11. snap과 nginx로 https 적용

snap과 nginx를 이용하여 사이트에 https를 적용하였다.
https란?
이외에도 기능들이 더 많이 있는데 하도 오랫동안 진행했던 프로젝트이다 보니 몇 가지는 나도 잘 기억이 안 난다 ㅎㅎ... 더 기억이 나는데로 추가해놓겠다.

5. 트러블슈팅중 기억에 남는 것들

지금까지 진행했던 프로젝트들 중에서 한 달이라는 가장 장기적으로 오랫동안, 심혈을 기울여서 했던 프로젝트였던만큼 사실 셀 수 없이 많은 트러블슈팅들이 있었다.
이런거나...
이런거나...
이런거나...
대략 에러만 수십, 아니 수 백번 본 것 같고, 에러 하나 붙잡고 3일 이상 해결하려고 삽질했던 적도 있다. 물론 당시에는 많이 힘들었지만 지나고보니 그렇게 삽질을 하면서 에러를 해결해나가는 과정에서 배운 부분이 정말많았던 것 같다. 수많은 트러블 슈팅들 중에서 기억에 남는 몇 가지를 소개하고자 한다.

5-1. 댓글 삭제 기능

댓글을 삭제하는 기능이 정말 단순해보이고 실제로 그렇게 어려운건 아니었는데, 이 부분에서 생각이 꼬여 굉장히 많이 헤맸다. 왜냐하면 댓글을 삭제한다는 것은 아래와 같은 구조 안에 있는 객체를 삭제해야하는 것이기 때문이다.
대략 적으로 표현하면 다음과 같은 구조이다. mainPosts = [{ Comments: [{ }, { }, ... ] }, { }, ...] 배열 안에 객체 안에 배열 안에 객체... 말만 들어도 조금 헷갈린다. 그래도 백엔드에서 댓글을 삭제하는 것은 어렵지 않았는데 프론트단 Reducer에서 댓글을 삭제하는 코드를 작성하는 것이 매우 헷갈렸다.
const onRemoveComment = useCallback((CommentId) => () => { if (!id) { return alert('로그인이 필요합니다'); } return dispatch({ type: REMOVE_COMMENT_REQUEST, data: { id: CommentId, PostId: post.id, }, }); }, [post.id]); .. <Commentremove onClick={onRemoveComment(item.id)}> 삭제하기 </Commentremove>
JavaScript
프론트에서 '삭제하기' 버튼을 누르면 REMOVE_COMMENT_REQUEST 액션이 dispatch 되고, 데이터로 댓글의 id와 게시글의 id를 보내준다.
백엔드에서 댓글을 삭제하는 라우터이다. 특정 게시물의 특정 댓글을 삭제하는 요청이 오면, Comment 테이블에서 요청으로 온 req.params.id에 해당하는 댓글을 찾는다. 그리고 만약에 삭제하려는 댓글이 존재하지 않으면 '댓글이 존재하지 않습니다'를 응답으로 보내주고, 삭제하려는 댓글이 존재할 경우, 요청으로 온 req.params.id에 해당하는 댓글을 삭제해준다.
router.delete('/comment/:postId/:id', isLoggedIn, async (req, res, next) => { // DELETE /post/comment/1 try { const comment = await Comment.findOne({ where: { id: parseInt(req.params.id, 10) } }); if(!comment) { return res.status(404).send('댓글이 존재하지 않습니다.'); } await Comment.destroy({ where: { id: parseInt(req.params.id, 10) }, include: [{ model: User, attributes: ['id', 'nickname'], }], }); res.status(200).json({ id: parseInt(req.params.id, 10), PostId: parseInt(req.params.postId, 10) }); } catch (error) { console.error(error); next(error); } });
JavaScript
위 과정까지는 무난했는데 아래 Reducer를 작성하는데에서 문제가 발생했다. 배열 안에 객체 안에 배열 객체를 어떻게 삭제해야할지 고민이 많이 됐기 때문이다. 고민을 정말 많이하고 이것저것 시도해보다 겨우 답을 찾아냈다. const post = draft.mainPosts.find((v) => v.id === action.data.PostId);로 mainPosts 배열에서 요청으로 온 게시글의 id와 같은 게시글을 찾아 post 변수로 할당해준다. 그리고 post 배열 안의 Comments에서 요청으로 온 댓글의 id와 일치하지 않는 댓글을 삭제해준다.
결과물만 보면 간단해보이지만, post.Comments.filter로 post.Comments의 각 객체들만을 삭제해준다는 생각을 하기까지 오랜 시간이 걸렸다. 왜냐하면 가장 상위 mainPosts 배열로부터 안으로 파고들어가야한다고 생각했기 때문인데, 곰곰히 생각해보니 굳이 그럴 필요 없이 post.Comments의 id를 통해 삭제 요청이 온 댓글만을 삭제해주면 해결되는 문제였다.
... case REMOVE_COMMENT_REQUEST: draft.removeCommentLoading = true; draft.removeCommentDone = false; draft.removeCommentError = null; break; case REMOVE_COMMENT_SUCCESS: { const post = draft.mainPosts.find((v) => v.id === action.data.PostId); post.Comments = post.Comments.filter((v) => v.id !== action.data.id); draft.removeCommentLoading = false; draft.removeCommentDone = true; break; } case REMOVE_COMMENT_FAILURE: draft.removeCommentLoading = false; draft.removeCommentError = action.error; break; ...
JavaScript
그 결과 데이터베이스와 프론트 화면 모두에서 댓글이 정상적으로 삭제됐다!

5-2. 이미지 리사이징 문제 - 1

S3를 이용해 이미지 리사이징을 적용할 때도 수없이 많은 오류가 발생했다. 그중 아래 403 (Forbidden) 오류가 계속 뜨면서 화가 많이났다
이는 s3 버킷을 퍼블릭으로 열지 않아서 발생했던 문제로 아래 JSON 코드를 작성하고, 모든 퍼블릭 엑세스 차단을 풀어주니 해결됐다.

5-3. 이미지 리사이징 문제 - 2

위 문제를 해결하고 난 뒤, 영어로 제목이 지어진 이미지의 경우에는 thumb 폴더로 이동하면서 정상적으로 업로드되지만 한글이나 '_' 등의 기호가 섞여서 제목이 지어진 이미지의 경우에는 아래와 같은 오류가 발생하며 업로드되지 않았다.
정보를 찾아보다보니 한글의 경우 브라우저는 자동으로 encodeURI 해주지만, 노드의 경우 그렇지 않기 때문에 저장할때부터 한글은 encodeURIComponent로 감싸서 저장해줘야 했다. 또한 대소문자가 달라도 문제가 생기므로 전부 소문자로 만들어서 넣어주었다.
lambda/index.js
const AWS = require('aws-sdk'); const sharp = require('sharp'); const s3 = new AWS.S3(); exports.handler = async (event, context, callback) => { const Bucket = event.Records[0].s3.bucket.name; // react-nodebird-s3 const Key = decodeURIComponent(event.Records[0].s3.object.key); // original/12312312_abc.png console.log(Bucket, Key); const filename = encodeURIComponent(Key.split('/')[Key.split('/').length - 1]); const ext = Key.split('.')[Key.split('.').length - 1].toLowerCase(); const requiredFormat = ext === 'jpg' ? 'jpeg' : ext; console.log('filename', filename, 'ext', ext); try { const s3Object = await s3.getObject({ Bucket, Key }).promise(); console.log('original', s3Object.Body.length); const resizedImage = await sharp(s3Object.Body) .resize(300, 300, { fit: 'inside' }) .toFormat(requiredFormat) .toBuffer(); await s3.putObject({ Bucket, Key: `thumb/${filename}`, Body: resizedImage, }).promise(); console.log('put', resizedImage.length); return callback(null, `thumb/${filename}`); } catch (error) { console.error(error) return callback(error); } }
JavaScript
여기서 추가적인 문제가 발생했는데, 한글을 encodeURIComponent로 감싸주다보니 파일명이 지나치게 길어졌고, 기존에 만들었던 image 테이블 모델이 이를 수용하지 못하였다. 그래서 기존에 String 200 글자로 지정해놨던 image 모델의 src 컬럼을 넉넉하게 400글자로 늘려주었다. 그렇게 하고 나니 한글과 영어 제목의 이미지 관련없이 전부 정상적으로 잘 업로드됐다.
models/image.js
const Sequelize = require('sequelize'); module.exports = class Image extends Sequelize.Model { static init(sequelize) { return super.init({ src: { type: Sequelize.STRING(200), type: Sequelize.STRING(400), allowNull: false, }, }, { sequelize, timestamps: true, underscored: false, modelName: 'Image', tableName: 'images', paranoid: false, charset: 'utf8', collate: 'utf8_general_ci', }); } static associate(db) { db.Image.belongsTo(db.Post); } };
JavaScript

6. 개발하면서 추가적으로 구현해보고 싶었던 기능

6-1. 네이버 & 카카오 로그인 기능

해당 기능을 구현하던 중에 갑작스럽게 외할아버지가 돌아가셨고, 며칠동안 장례를 치르게 됐다. 그리고 장례를 마치고 집에 돌아온 뒤 IT 산업기능요원에 지원하기 위한 자소서나 이력서 등을 작성하고 면접을 준비하다보니 위 기능을 구현할 충분한 시간이 되지 않았다.
이전 프로젝트에서 네이버 & 카카오 로그인 기능을 구현했던 적이 있다. 그런데 이전에는 서버가 백엔드 서버만 존재했고, html 파일의 경우에는 res.render을 통해 백엔드 서버에 랜더링 해줬기 때문에 따로 프론트 서버를 두지 않았다. 하지만 이번에는 이전과 달리 프론트 서버와 백엔드 서버를 따로 두었다. 그래서 네이버와 카카오 로그인을 구현하는 부분이 다소 헷갈려서 바로 구현하지는 못했다. 백엔드에서 네이버 & 카카오 로그인을 하는 것까지는 구현했는데 여기서 로그인한 사용자에게 토큰을 부여해서 프론트에서까지 로그인을 유지하게끔 하는 부분을 아직 구현하지 못하였다. 회사 지원이 마무리되고 이후에 추가적인 시간이 주어진다면 마저 구현해보고 싶다.

6-2. 이미지 수정 기능

최근 인스타그램을 주된 SNS 매체로 사용하던 중 느낀 가장 큰 불편함은 게시글로 이미지를 업로드하고 나면 수정이 되지 않는다는 것이다. 예를 들어, 10장의 사진을 선택하고 열심히 태그를 달면서 올렸는데, 만약 그중 1장을 실수로 잘못된 사진을 올렸다면 게시글 전체를 지운 뒤에 처음부터 같은 작업을 다시 반복해야한다. 아직 구현해보지는 않았지만 업로드한 사진을 수정하는 것이 그렇게 어려운 일이 아닐거 같은데 왜 인스타그램에서 절대로 구현하지 않는 것인지는 모르겠다. 서버 상에 무리가 갈 것 같아서 그러는 것인지 뭔지... 잡설이 길었는데, 이후에 추가적으로 시간이 생긴다면 이미지를 수정할 수 있는 기능을 만들어보고 싶다.

6-3. 서버 최적화

현재 메인페이지를 접속하는데 시간이 꽤 많이 걸린다. 최적화 부분을 깊게 공부한 것이 아니라서 아직 정확한 이유는 모르겠다. 아마 필요 이상으로 처음부터 랜더링하는 부분이 많아서 페이지가 로드되는데 많은 시간이 걸리고 있다고 추축 중이다. 모든 정보를 미리 불러오는 것이 아니라, 일부 필요한 정보만 불러오도록 하면 지금보다 더 빠른 속도로 서버를 운영할 수 있을 것이라고 생각한다. 이후에 서버 최적화와 관련된 부분을 더 공부해서 서버의 속도를 더 빠르게 최적화 시켜보고 싶다.

7. 후기

처음에 구상했던 시간보다 만드는데 훨씬 더 오랜 시간이 걸렸다. 에러가 이렇게까지 많이 발생할 것이라고 생각하지 못했는데 생각 이상으로 수많은 에러들을 마주하게 된 것이 완성까지 더 오랜 시간이 걸리게끔 한 것 같다. 그래도 확실히 이번 프로젝트를 진행하면서 Node.js는 물론이고 React, Redux, Next.js 등 수많은 기술스택을 꽤나 능수능란하게 사용할 수 있게 됐다. 조만간 동아리 홈페이지를 제작하는 프로젝트를 진행하게 될 듯 한데, 이번에 공부한 내용을 기반으로 멋있는 동아리 홈페지이지를 제작해보고 싶다. 더 나아가, 최근에는 Redux-Saga보다 Redux toolkit을 많이 사용한다고 하여 이후에 Redux toolkit을 공부한 뒤에 이전의 코드를 대체해보고 싶다.