(팀) StackOverflow 사이트 클론

(팀) StackOverflow 사이트 클론

Author
Practices
Published
July 1, 2023
Description
StackOverflow 사이트를 클론 코딩하는 팀 프로젝트입니다.
Features
Tags
React.js
Vite
Styled-Component
Axios
TanstackQuery
Redux/toolkit
Public
ServerOff

📝 프로젝트 정보

 
팀명 : 클론할거죠
팀원 : FE 4명 / BE 3명
기간 : 2023.06.09 - 2023.06.27
 
배포 : 📌 배포 링크 (서버 OFF)
test 아이디
비밀번호
123123
 

⭐️ 담당 기능

CRUD

전체 질문 목록 R ( + 스켈레톤) 상세설명 클릭
💡
- 유저 아이디 첫 알파벳을 이용한 유저 이미지 생성 - 새로고침할 때마다 유저 이미지 배경 색상이 랜덤으로 렌더링 - 초, 분, 시간, 일 단위로 글을 올린 시간에 따라 달라지는 'asked~' 문장 구현 - 총 질문 수는 천 단위마다 쉼표(,)를 추가하도록 구현
notion image
재사용 가능한 스켈레톤 컴포넌트 생성 스크린샷 및 상세설명 클릭
notion image
// 스켈레톤 컴포넌트 코드 import styled from 'styled-components'; export type SkeletonProps = { width: number | string; height: number | string; }; export function Skeleton({ width, height }: SkeletonProps): React.ReactNode { return <S.StyledSkeleton width={width} height={height} />; } export default function SkeletonContainer() { return ( <S.SkeletonContainer> <Skeleton width="630px" height="30px" /> <Skeleton width="630px" height="30px" /> <Skeleton width="600px" height="80px" /> <Skeleton width="600px" height="80px" /> <Skeleton width="600px" height="80px" /> </S.SkeletonContainer> ); } const S = { SkeletonContainer: styled.div` height: 100%; padding: 24px 24px 0 24px; width: 100%; display: flex; gap: 10px; flex-direction: column; justify-content: center; align-items: center; > div { display: flex; flex-direction: column; margin: 10px 0; } > div:nth-child(2) { margin-bottom: 25px; } `, StyledSkeleton: styled.div<SkeletonProps>` position: relative; overflow: hidden; height: ${({ height }) => height}; width: 90%; background-color: #f3f3f3; border-radius: 5px; @keyframes skeleton { 0% { background-color: rgba(164, 164, 164, 0.1); } 50% { background-color: rgba(164, 164, 164, 0.3); } 100% { background-color: rgba(164, 164, 164, 0.1); } } &:before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; animation: skeleton 1.6s infinite ease-in-out; } `, };
답변 CRUD 상세설명 클릭
  1. 로그인 후 투표 기능 등 활성화
  1. 로그인 후에 POST 요청 시, 미리 저장된 유저 정보가 담긴 글을 등록할 수 있도록 구현
  1. 로그인 후에는 삭제 확인 메시지를 유저가 따라 입력하도록 하여 유저의 삭제 의도가 확실히 있을 때에만 댓글이 삭제 되도록 구현
      • Delete 버튼은 해당 유저가 작성한 댓글에만 렌더링됨
      • Delete 버튼을 실수로 눌러도 그 뒤에 뜨는 창에 확인 문구를 작성해야지만 최종적으로 DELETE 요청이 수행됨
  1. 공유하기 기능 추가
      • 주소가 들어있는 인풋창을 클릭하면 주소가 클립보드에 저장
      • 페이스북, 트위터에 바로 공유 가능
투표 CRD 상세설명 클릭
  • up 투표 후, 중복 투표 방지를 위해 재 클릭 시, 투표된 결과를 rollback 하도록 구현했습니다.
  • 네트워크 비용 절감을 위해 백엔드로 DELETE 요청 대신, 프론트의 useState로 해결했습니다.
import '../../index.css'; import { styled } from 'styled-components'; import { useState } from 'react'; import { QuestionAnswer } from '../../types/types'; import { IoMdArrowDropup, IoMdArrowDropdown } from 'react-icons/io'; interface GuestDeleteProps { item: QuestionAnswer; isLoggedIn: boolean; } export default function VoteCount({ item, isLoggedIn }: GuestDeleteProps) { const [isUpClicked, setIsUpClicked] = useState(false); const [isDownClicked, setIsDownClicked] = useState(false); const [voteCount, setVoteCount] = useState(item.voteCount); const handleUpClick = () => { if (voteCount && !isUpClicked && !isDownClicked) { setVoteCount(voteCount + 1); setIsUpClicked(true); } else if (voteCount && isUpClicked) { setVoteCount(voteCount - 1); setIsUpClicked(false); } }; const handleDownClick = () => { if (voteCount && !isDownClicked && !isUpClicked) { setVoteCount(voteCount - 1); setIsDownClicked(true); } else if (isDownClicked) { setVoteCount(voteCount && voteCount + 1); setIsDownClicked(false); } }; return ( <div> <S.ArrowBox className={isLoggedIn ? '' : 'isNotLoggedIn'} onClick={handleUpClick} isUpClicked={isUpClicked} > <IoMdArrowDropup size={28} /> </S.ArrowBox> <S.VoteNumber> {item.voteCount && isUpClicked ? item.voteCount + 1 : isDownClicked ? item.voteCount && item.voteCount - 1 : item.voteCount} </S.VoteNumber> <S.ArrowBox className={isLoggedIn ? '' : 'isNotLoggedIn'} onClick={handleDownClick} isDownClicked={isDownClicked} > <IoMdArrowDropdown size={28} /> </S.ArrowBox> </div> ); } interface StyledDivProps { isUpClicked?: boolean; isDownClicked?: boolean; } const S = { ArrowBox: styled.div<StyledDivProps>` &.isNotLoggedIn { opacity: 0.14; } border: 1px solid var(--color-button-lightgray); color: var(--color-content-desc); width: 40px; height: 40px; border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; &:hover { background-color: var(--color-button-orange-hover); } &:active { outline: 3.5px solid rgba(179, 211, 234, 0.5); } border: ${({ isUpClicked, isDownClicked }) => { if (isUpClicked) { return '1.34px solid var(--color-layout-orange)'; } else if (isDownClicked) { return '1.34px solid var(--color-layout-orange)'; } }}; > svg:nth-child(1) { color: ${({ isUpClicked, isDownClicked }) => { if (isUpClicked) { return 'var(--color-layout-orange)'; } else if (isDownClicked) { return 'var(--color-layout-orange)'; } }}; } `, VoteNumber: styled.div` height: 37px; line-height: 37px; text-align: center; color: var(--color-content-desc); font-size: 1.3rem; font-weight: 600; `, };
 
 

목업 서버 구축

- 백엔드 코드가 완성되기 전에, MSW를 활용한 목업 서버를 구축하여 무한 스크롤 기능을 구현하였습니다.
  • msw 세팅 ()
react-query를 활용한 무한 스크롤 상세 설명 클릭
💡
- 페이지 바닥이 viewport에서 감지될 때 데이터를 추가패칭하는 방식
- 사용자 viewport에 따라 무한 스크롤 구현 방식에 차이가 있는 것에 대비하여, 클릭해서 데이터를 불러올 수 있는 버튼 추가
(예 : 무한 스크롤 핸들러 코드)
(의사 코드)
  1. size와 page 값을 Params에서 추출
    1. size: 한 번에 게시글 몇 개까지 보낼 것인가?
    2. page: 현재 페이지?
// client/src/temp/handlers.ts // 목업 서버 (무한 스크롤 GET) import allQuestions from './AllQuestionQuery.json'; import { rest } from 'msw'; export const handlers = [ rest.get('/questions', (req, res, ctx) => { const searchParams = new URLSearchParams(req.url.search); const size = searchParams.get('size'); const page = searchParams.get('page'); if (!size || !page) { return res( ctx.status(400), ctx.json({ message: 'Invalid query parameters' }) ); } const from = parseInt(page) * parseInt(size); const to = (parseInt(page) + 1) * parseInt(size); const processedData = allQuestions.data.slice(from, to); return res( ctx.status(200), ctx.json({ ...allQuestions, data: processedData }) ); }), ];
(예 : 답변 DELETE 핸들러 코드)
(의사 코드)
  1. 특정 질문 게시글 Id와 내부 답변 게시글 Id를 Params에서 추출
  1. 목업 데이터베이스 질문 목록에서 해당 질문 데이터를 index를 구하고, 그 뒤에 그 질문 데이터 내부에 있는 답변 index 값을 구함
  1. 두 index 값이 다 존재할 경우(유효한 삭제 요청) splice를 이용해 답변 index를 삭제
  1. 삭제 성공 시 코드 200으로 response
// client/src/temp/handlers.ts rest.delete('/answers/', (req, res, ctx) => { const searchParams = new URLSearchParams(req.url.search); const questionId = Number(searchParams.get('questionId')); const questionAnswerId = Number(searchParams.get('answerId')); const certainQuestionIndex = questionQuery.data.findIndex(question => { return question.questionId === questionId; }); const certainAnswerIndex = questionQuery.data[ certainQuestionIndex ].questionAnswers.findIndex( answer => answer.questionAnswerId === questionAnswerId ); if (certainQuestionIndex !== -1 && certainAnswerIndex !== -1) { questionQuery.data[ certainQuestionIndex ].questionAnswers.splice(certainAnswerIndex, 1); } return res(ctx.status(200)); }),
 
 

`CKEditor`을 사용하여 웹 에디터 도입

notion image
 

배포 (
chore: 배포 준비
)

  • firebase로 클라이언트 배포
 
 

🔖 Project Docs

사용자 요구사항 정의서

notion image
 

API 명세서

notion image
</details>
 
 
 

📌 Team Convention

Commit Convention

메세지
설명
feat
새로운 기능 추가
fix
버그 수정
refactor
코드 리팩토링
style
코드 포맷팅, 세미콜론 누락, 코드 스타일 변경 등
remove
사용하지 않는 파일 또는 폴더 삭제
rename
파일 또는 폴더명 수정
test
테스트 코드, 리펙토링 테스트 코드 추가
docs
문서명 수정
chore
빌드 업무 수정, 패키지 매니저 수정
init
초기 설정
 

Branch Convention

이름
설명
main
서비스 운영 브랜치
dev
메인 브랜치 배포 전 릴리즈 브랜치
be-feat/feature name
BE 기능 개발 브랜치 브랜치
fe-feat/feature name
FE 기능 개발 브랜치 브랜치