- 배포 URL: https://breadgood-22.github.io/frontend
- 테스트 계정
ID
: [email protected]PassWord
: breadgood
- 빵굿빵굿(BreadGood)은 집콕 트렌드와 맞물려 성장한 집빵족을 위한 베이킹 커뮤니티 마켓 서비스입니다.
- '빵을 좋아한다'란 의미의 서비스명으로 더 간편한, 더 맛있는 빵을 만들고 싶은 홈베이커들이 정보를 공유하고 집에서 구운 빵을 자랑하는 SNS로 기획했습니다.
- 사용자는 나만의 베이킹 꿀팁과 사진을 공유하거나, 직접 만든 빵과 사용하지 않는 베이킹 관련 물품을 판매 및 구매할 수 있습니다.
- 사용자 검색 기능을 통해 다른 사용자를 찾을 수 있고, 홈에서 팔로우하는 사용자의 게시글을 보고 좋아요와 댓글을 주고 받을 수 있습니다.
팀장 & 프론트엔드 | 디자인 & 프론트엔드 | 문서화 & 프론트엔드 | 문서화 & 프론트엔드 |
---|---|---|---|
서현주 | 방선아 | 김종인 | 임하연 |
GitHub | GitHub | GitHub | GitHub |
전체 개발 일정: 2022.12.09 ~ 2023.01.22
1️⃣ 1차 개발: 2022.12.09 ~ 2022.12.31
2️⃣ 1차 배포: 2022.12.31
3️⃣ 리팩토링 및 2차 개발: 2023.01.01 ~ 2023.01.22
4️⃣ 2차 배포: 2023.01.22
- FrontEnd : React, React-router, Styled-components, Axios, Eslint, Prettier
- BackEnd : 제공된 API 사용
- GitHub Pages
스프린트 방식을 도입해 매주 스프린트 목표에 집중하여 개발할 수 있는 환경을 구축했습니다.
[스프린트 플래닝]
- 매주 스프린트 첫째날에 스프린트 플래닝을 진행하여 한주의 목표를 설정하고 일정을 조율했습니다.
[데일리 스크럼]
- 매일 업무 시작 전, 끝나기 전에 데일리 스크럼을 진행하여 각자의 진행 상황과 어려운 점을 공유했습니다.
- 공유된 이슈를 팀원들이 함께 해결하며 당일에 이슈를 해결해 빠른 피드백과 개선이 이루어졌습니다.
[스프린트 회고]
- KPT 방식의 회고를 진행하여 우리 팀이 잘한 점, 부족한 점을 얘기해서 다음 스프린트 때 시도할 구체적인 액션을 정했습니다.
- 도출된 액션은 다음 스프린트 때 시도하여 팀에 필요한 변화를 빠르게 개선했습니다.
- 각자 작업 시작 전 테스크 파악을 위해 GitHub Issues를 사용해 이슈를 작성했습니다.
- 각자의 진행상황을 한눈에 파악하기 위해 GitHub Issues와 GitHub Projects를 사용했습니다.
- 깃 브랜치 전략과 브랜치 네이밍 컨벤션을 설정하여 목적에 맞게 브랜치를 사용해 작업 흐름 파악이 용이하도록 했습니다.
- 코드 컨벤션을 설정하여 코드 작성 시 혼란을 겪지 않고, 코드로 인한 오류 발생을 줄였습니다.
- 커밋 컨벤션을 설정하여 일관성 있는 커밋 메시지를 작성해 팀원들이 읽기 편하도록 했습니다.
-
개발 시작 전 폴더 구조를 설정했으나, 개발 1주차를 마치고 폴더의 역할 구분이 모호하다는 문제점을 파악 했습니다.
-
프로젝트 규모와 생산성을 고려해 페이지 별로 폴더를 구분하고 섹션을 기준으로 컴포넌트를 분리하여 폴더 구조를 리팩토링 했습니다.
폴더 구조 보기
├── App.js ├── api │ ├── apiController.js │ ├── comment │ │ ├── addCommentReport.js │ │ ├── deleteComment.js │ │ └── getAllComment.js │ ├── follow │ │ ├── addFollow.js │ │ ├── deleteFollow.js │ │ ├── getFollowers.js │ │ └── getFollowings.js │ ├── imgUpload │ │ └── addImage.js │ ├── index.js │ ├── login │ │ └── addLogin.js │ ├── post │ │ ├── addHeart.js │ │ ├── addPost.js │ │ ├── addPostReport.js │ │ ├── deleteHeart.js │ │ ├── deletePost.js │ │ ├── getHomeFeeds.js │ │ ├── getPost.js │ │ ├── getPosts.js │ │ └── updatePost.js │ ├── product │ │ ├── addProduct.js │ │ ├── deleteProduct.js │ │ ├── getProductDetail.js │ │ ├── getProducts.js │ │ └── updateProduct.js │ ├── profile │ │ └── getUserInfo.js │ ├── search │ │ └── getSearchResult.js │ └── signup │ ├── addAccountNameValid.js │ ├── addEmailValid.js │ └── addUserInfo.js ├── assets │ ├── icons │ └── images ├── components │ ├── chatRoom │ │ ├── ChatContents │ │ │ ├── index.jsx │ │ │ └── style.js │ │ └── ChatInput │ │ ├── index.jsx │ │ └── style.js │ ├── comment │ │ ├── Comment │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── CommentInput │ │ │ ├── index.jsx │ │ │ └── style.js │ │ └── CommentList │ │ ├── index.jsx │ │ └── style.js │ ├── common │ │ ├── ActiveInputs │ │ │ └── style.js │ │ ├── Button │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── Header │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── Layout │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── LikeButton │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── Modal │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── Navbar │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── Post │ │ │ ├── index.jsx │ │ │ └── style.js │ │ └── PostList │ │ ├── index.jsx │ │ └── style.js │ ├── follow │ │ └── Follow │ │ ├── index.jsx │ │ └── style.js │ ├── home │ │ └── NoFollowings │ │ ├── index.jsx │ │ └── style.js │ ├── index.js │ ├── login │ │ └── LoginForm │ │ ├── index.jsx │ │ └── style.js │ ├── post │ │ └── PostContainer │ │ ├── index.jsx │ │ └── style.js │ ├── postEdit │ │ └── PostEditForm │ │ ├── index.jsx │ │ └── style.js │ ├── postUpload │ │ ├── Photo │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── PhotoUploadList │ │ │ ├── index.jsx │ │ │ └── style.js │ │ └── PostUploadForm │ │ ├── index.jsx │ │ └── style.js │ ├── product │ │ └── ProductForm │ │ ├── index.jsx │ │ └── style.js │ ├── profile │ │ ├── PostGallery │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── PostsContainer │ │ │ ├── index.jsx │ │ │ └── style.js │ │ ├── ProductsContainer │ │ │ ├── index.jsx │ │ │ └── style.js │ │ └── UserInfoContainer │ │ ├── index.jsx │ │ └── style.js │ ├── profileSetting │ │ └── ProfileForm │ │ ├── index.jsx │ │ └── style.js │ ├── search │ │ └── SearchCard │ │ ├── index.jsx │ │ └── style.js │ ├── signup │ │ └── SignupForm │ │ ├── index.jsx │ │ └── style.js │ └── start │ ├── LoginButtons │ │ ├── index.jsx │ │ └── style.js │ └── Splash │ ├── index.jsx │ └── style.js ├── context │ └── AuthProvider.js ├── hooks │ ├── useDebounce.js │ ├── useHeight.js │ └── useIntersect.js ├── index.js ├── pages │ ├── AddProductPage │ │ └── index.jsx │ ├── ChatListPage │ │ ├── index.jsx │ │ └── style.js │ ├── ChatRoomPage │ │ └── index.jsx │ ├── ErrorPage │ │ ├── index.jsx │ │ └── style.js │ ├── FollowerPage │ │ ├── index.jsx │ │ └── style.js │ ├── FollowingPage │ │ ├── index.jsx │ │ └── style.js │ ├── HomePage │ │ └── index.jsx │ ├── LoginPage │ │ ├── index.jsx │ │ └── style.js │ ├── PostEditPage │ │ └── index.jsx │ ├── PostPage │ │ └── index.jsx │ ├── PostUploadPage │ │ ├── index.jsx │ │ └── style.js │ ├── ProductEditPage │ │ └── index.jsx │ ├── ProfileEditPage │ │ └── index.jsx │ ├── ProfilePage │ │ ├── index.jsx │ │ └── style.js │ ├── ProfileSettingPage │ │ ├── index.jsx │ │ └── style.js │ ├── SearchPage │ │ ├── index.jsx │ │ └── style.js │ ├── SignupPage │ │ ├── index.jsx │ │ └── style.js │ ├── StartPage │ │ ├── index.jsx │ │ └── style.js │ └── index.js ├── router │ ├── ProtectedRoute.jsx │ ├── PublicRoute.jsx │ └── Router.jsx ├── style │ ├── font.css │ ├── globalStyles.jsx │ └── theme.jsx └── utils └── timeForToday.js
- 프로젝트 초반 페어 프로그래밍으로 공통 컴포넌트 개발을 진행하여 팀에서 공통으로 사용될 컴포넌트의 이해도를 높였습니다.
- 팀원이 Pull Request를 등록하면 컨벤션은 잘 지켜졌는지, 코드의 개선점에 대해 리뷰를 주고 받는 자유로운 코드 리뷰 문화를 형성했습니다.
1. 스플래시 | 2. 회원가입 |
---|---|
![]() |
![]() |
3. 로그인 | 4. 프로필 수정 |
---|---|
![]() |
![]() |
5. 게시글 등록 | 6. 게시글 수정/삭제 |
---|---|
![]() |
![]() |
7. 상품 등록 | 8. 상품 수정/삭제 |
---|---|
![]() |
![]() |
9. 유저 검색 | 10. 유저 프로필 |
---|---|
![]() |
![]() |
11. 유저 팔로우/취소 | 12. 팔로우/팔로잉 |
---|---|
![]() |
![]() |
13. 팔로잉 피드 (홈) | 14. 게시글 좋아요/댓글 |
---|---|
![]() |
![]() |
15. 채팅 | 16. 로그아웃 |
---|---|
![]() |
![]() |
컴포넌트에서 비즈니스 로직을 분리하여 경직성을 낮추고 유지 보수성을 높이고자 합니다.
[문제 상황]
- 컴포넌트 내에 뷰와 비즈니스 로직이 함께 존재해 응집도가 낮고 코드가 복잡해지는 문제가 발생
[해결]
-
기능별로 모듈화한 API 호출 로직을 컴포넌트에서 호출하도록 리팩토링 진행
-
컴포넌트 내 코드의 양을 평균 20% 줄여 관심사 분리 및 코드의 가독성 개선
변경 전 코드
import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import * as S from './style'; import { PhotoUploadList } from '../../postUpload/PhotoUploadList'; import { MediumImgButton, HeaderUpload, PostAlertModal } from '../../index'; import { axiosPrivate, axiosImg, BASE_URL } from '../../../api/apiController'; import basicProfile from '../../../assets/images/basic-profile-img.png'; export function PostEditForm() { const [isLoading, setIsLoading] = useState(false); const [isDisabled, setIsDisabled] = useState(true); const [profile, setProfile] = useState(''); const [text, setText] = useState(''); const [postImg, setPostImg] = useState([]); const [isVisibleAlert, setIsVisibleAlert] = useState(false); const location = useLocation(); const textRef = useRef(); const navigate = useNavigate(); const postId = location.pathname.split('/')[2]; const accountname = JSON.parse(localStorage.getItem('accountname')); const MAX_UPLOAD = 3; // 게시글 콘텐츠 및 이미지 가져오기 const getPostContent = async () => { setIsLoading(true); try { const { data: { post: { content, image }, }, } = await axiosPrivate.get(`/post/${postId}`); const postImg = image.split(','); setText(content); setPostImg(postImg); } catch (e) { console.log(e); } setIsLoading(false); }; // 프로필 이미지 가져오기 const getProfile = async () => { setIsLoading(true); try { const { data: { profile: { image }, }, } = await axiosPrivate.get(`/profile/${accountname}`); setProfile(image); } catch (e) { console.log(e); } setIsLoading(false); }; useEffect(() => { getPostContent(); getProfile(); }, []); // profile image 렌더링 const renderProfileImage = () => { let profileImage = basicProfile; if (profile !== `${BASE_URL}/Ellipse.png`) profileImage = profile; return <S.ProfileImg src={profileImage} />; }; const handleTextArea = (e) => { textRef.current.style.height = 'auto'; textRef.current.style.height = `${textRef.current.scrollHeight}px`; setText(e.target.value); }; // 이미지 업로드 const handleFileUpload = async (file) => { setIsLoading(true); try { const formData = new FormData(); formData.append('image', file); const { data } = await axiosImg.post('/image/uploadfile', formData); return `${BASE_URL}/${data.filename}`; } catch (e) { console.log(e); } setIsLoading(false); }; const handleGetImageUrl = async (e) => { if (postImg.length < MAX_UPLOAD) { const file = e.target.files[0]; const imgUrl = await handleFileUpload(file); const copyPostImg = [...postImg]; copyPostImg.push(imgUrl); setPostImg(copyPostImg); e.target.value = ''; } else { alert('이미지는 3장까지 업로드 가능합니다'); } }; // 포스트 수정 업로드 const handlePostUpload = async () => { setIsLoading(true); try { const res = await axiosPrivate.put(`/post/${postId}`, { post: { content: text, image: postImg.join(','), }, }); navigate(`/profile/${accountname}`); } catch (e) { console.log(e); } setIsLoading(false); }; // 업로드 버튼 컨트롤 useEffect(() => { if (text || postImg.length) { setIsDisabled(false); } else { setIsDisabled(true); } }, [text, postImg]); return ( <> <HeaderUpload isDisabled={isDisabled} handlePostUpload={handlePostUpload} setIsVisibleAlert={setIsVisibleAlert} /> <S.Container> <h2 className='sr-only'>게시글 작성</h2> {renderProfileImage()} <S.PostWrite> <h3 className='sr-only'>게시글 작성 form</h3> <S.Form> <S.ContentInput onInput={handleTextArea} ref={textRef} defaultValue={text} /> <S.ImgUploadButton onChange={handleGetImageUrl}> <h4 className='sr-only'>이미지 업로드 버튼</h4> <MediumImgButton /> </S.ImgUploadButton> {postImg.length === 0 ? null : <PhotoUploadList imgSrc={postImg} setPostImg={setPostImg} />} </S.Form> </S.PostWrite> </S.Container> {isVisibleAlert && <PostAlertModal setIsVisibleAlert={setIsVisibleAlert} />} </> ); }
변경 후 코드
import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { BASE_URL } from '../../../api/apiController'; import { addImage, getUserInfo, addPost } from '../../../api'; import * as S from './style'; import { PhotoUploadList } from '../PhotoUploadList'; import { MediumImgButton, HeaderUpload, PostAlertModal } from '../../index'; import basicProfile from '../../../assets/images/basic-profile-img.png'; export function PostUploadForm() { const [isLoading, setIsLoading] = useState(false); const [text, setText] = useState(''); const [postImg, setPostImg] = useState([]); const [profile, setProfile] = useState(''); const [isDisabled, setIsDisabled] = useState(true); const [isVisibleAlert, setIsVisibleAlert] = useState(false); const textRef = useRef(); const navigate = useNavigate(); const accountname = JSON.parse(localStorage.getItem('accountname')); const MAX_UPLOAD = 3; const getProfile = async () => { setIsLoading(true); const { image } = await getUserInfo(accountname); setProfile(image); setIsLoading(false); }; useEffect(() => { getProfile(); }, []); const renderProfileImage = () => { let profileImage = basicProfile; if (profile !== `${BASE_URL}/Ellipse.png`) profileImage = profile; return <S.ProfileImg src={profileImage} />; }; const handleTextArea = (e) => { textRef.current.style.height = 'auto'; textRef.current.style.height = `${textRef.current.scrollHeight}px`; setText(e.target.value); }; const handleGetImageUrl = async (e) => { setIsLoading(true); if (postImg.length < MAX_UPLOAD) { const file = e.target.files[0]; const imgUrl = await addImage(file); const copyPostImg = [...postImg]; copyPostImg.push(imgUrl); setPostImg(copyPostImg); e.target.value = ''; } else { alert('이미지는 3장까지 업로드 가능합니다'); } setIsLoading(false); }; const handlePostUpload = async () => { const images = postImg.join(','); setIsLoading(true); await addPost(text, images); navigate(`/profile/${accountname}`); setIsLoading(false); }; useEffect(() => { if (text || postImg.length) { setIsDisabled(false); } else { setIsDisabled(true); } }, [text, postImg]); return ( <> <HeaderUpload isDisabled={isDisabled} handlePostUpload={handlePostUpload} setIsVisibleAlert={setIsVisibleAlert} /> <S.Container> <h2 className='sr-only'>게시글 작성</h2> {renderProfileImage()} <S.PostWrite> <h3 className='sr-only'>게시글 작성 form</h3> <S.Form> <S.ContentInput onInput={handleTextArea} ref={textRef} /> <S.ImgUploadButton onChange={handleGetImageUrl}> <h4 className='sr-only'>이미지 업로드 버튼</h4> <MediumImgButton /> </S.ImgUploadButton> {postImg.length === 0 ? null : <PhotoUploadList imgSrc={postImg} setPostImg={setPostImg} />} </S.Form> </S.PostWrite> </S.Container> {isVisibleAlert && <PostAlertModal setIsVisibleAlert={setIsVisibleAlert} />} </> ); }
빵굿빵굿 사이트의 성능 분석 점수를 높이기 위해 성능 개선을 위한 최적화 작업을 진행하고자 합니다.
[문제 상황]
- 스타트 페이지에서 로고 이미지의 크기가 커서 늦게 렌더링되는 현상 발생
[해결]
- 이미지 포맷을
png
에서WebP
로 변경 - 이미지 크기가 400 KB에서 16.7 KB로 줄어 로드 시간 단축
[문제 상황]
- 사용자가 용량이 큰 이미지를 업로드 시 미리보기 이미지가 화면에 느리게 보여지고 게시글 업로드 시 서버와의 통신 시간이 지연되는 문제 발생
[해결]
-
browser-image-compression
라이브러리를 사용해 이미지 압축 API에 추가 -
1개의 이미지 파일 실행 결과, 파일 용량이 216.8 KB에서 147.1 KB로 약 32% 감소
import imageCompression from 'browser-image-compression'; export async function imageResize(file) { const options = { maxSizeMB: 0.2, maxWidthOrHeight: 1920, useWebWorker: true, }; try { const compressedFile = await imageCompression(file, options); const newFile = new File([compressedFile], `${compressedFile.name}`, { type: compressedFile.type, }); return newFile; } catch (e) { console.log(e); } }
일부 남은 기능 및 추가 기능을 구현해 프로젝트의 완성도를 높이고자 합니다.
- 프로필 수정 페이지
- 이미지 여러장 등록
- 이미지 슬라이드
- 채팅방 리스트
- 404 및 로딩 컴포넌트