July249 / frontend

빵굿빵굿 - 집빵족을 위한 베이킹 SNS (리액트 팀 프로젝트)

Home Page:https://breadgood-22.github.io/frontend

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

집빵족을 위한 베이킹 SNS, 빵굿빵굿 🍞

UIdesign9


1. 프로젝트 소개

  • 빵굿빵굿(BreadGood)은 집콕 트렌드와 맞물려 성장한 집빵족을 위한 베이킹 커뮤니티 마켓 서비스입니다.
  • '빵을 좋아한다'란 의미의 서비스명으로 더 간편한, 더 맛있는 빵을 만들고 싶은 홈베이커들이 정보를 공유하고 집에서 구운 빵을 자랑하는 SNS로 기획했습니다.
  • 사용자는 나만의 베이킹 꿀팁과 사진을 공유하거나, 직접 만든 빵과 사용하지 않는 베이킹 관련 물품을 판매 및 구매할 수 있습니다.
  • 사용자 검색 기능을 통해 다른 사용자를 찾을 수 있고, 홈에서 팔로우하는 사용자의 게시글을 보고 좋아요와 댓글을 주고 받을 수 있습니다.

2. 팀원 소개 및 역할 분담

팀장 & 프론트엔드 디자인 & 프론트엔드 문서화 & 프론트엔드 문서화 & 프론트엔드
프로필 이미지 프로필 이미지 프로필 이미지 프로필 이미지
서현주 방선아 김종인 임하연
GitHub GitHub GitHub GitHub

   22팀🐱


3. 개발 일정

전체 개발 일정: 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


4. 개발 환경

기술

  • FrontEnd : React, React-router, Styled-components, Axios, Eslint, Prettier
  • BackEnd : 제공된 API 사용

협업도구

  • 디자인 도구: Figma
  • 협업 도구: FigJam, Notion, Gather

배포

  • GitHub Pages

5. 팀 협업 방식

🏃🏻 스프린트 도입

스프린트 방식을 도입해 매주 스프린트 목표에 집중하여 개발할 수 있는 환경을 구축했습니다.

스크린샷 2023-01-16 오후 1 58 47

[스프린트 플래닝]

  • 매주 스프린트 첫째날에 스프린트 플래닝을 진행하여 한주의 목표를 설정하고 일정을 조율했습니다.

[데일리 스크럼]

  • 매일 업무 시작 전, 끝나기 전에 데일리 스크럼을 진행하여 각자의 진행 상황과 어려운 점을 공유했습니다.
  • 공유된 이슈를 팀원들이 함께 해결하며 당일에 이슈를 해결해 빠른 피드백과 개선이 이루어졌습니다.

[스프린트 회고]

  • KPT 방식의 회고를 진행하여 우리 팀이 잘한 점, 부족한 점을 얘기해서 다음 스프린트 때 시도할 구체적인 액션을 정했습니다.
  • 도출된 액션은 다음 스프린트 때 시도하여 팀에 필요한 변화를 빠르게 개선했습니다.

💬 진행상황 공유

  • 각자 작업 시작 전 테스크 파악을 위해 GitHub Issues를 사용해 이슈를 작성했습니다.
  • 각자의 진행상황을 한눈에 파악하기 위해 GitHub Issues와 GitHub Projects를 사용했습니다. 스크린샷 2023-01-16 오후 1 58 47 스크린샷 2023-01-16 오후 1 58 47

📝 컨벤션 설정


🗂️ 폴더 구조 설정

  • 개발 시작 전 폴더 구조를 설정했으나, 개발 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를 등록하면 컨벤션은 잘 지켜졌는지, 코드의 개선점에 대해 리뷰를 주고 받는 자유로운 코드 리뷰 문화를 형성했습니다.

6. 구현 기능

1. 스플래시 2. 회원가입
3. 로그인 4. 프로필 수정
5. 게시글 등록 6. 게시글 수정/삭제
7. 상품 등록 8. 상품 수정/삭제
9. 유저 검색 10. 유저 프로필
11. 유저 팔로우/취소 12. 팔로우/팔로잉
13. 팔로잉 피드 (홈) 14. 게시글 좋아요/댓글
15. 채팅 16. 로그아웃

7. v1.0.0 이후 개선사항

API 로직 분리

컴포넌트에서 비즈니스 로직을 분리하여 경직성을 낮추고 유지 보수성을 높이고자 합니다.

[문제 상황]

  • 컴포넌트 내에 뷰와 비즈니스 로직이 함께 존재해 응집도가 낮고 코드가 복잡해지는 문제가 발생

[해결]

  • 기능별로 모듈화한 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% 감소 image image

    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 및 로딩 컴포넌트

About

빵굿빵굿 - 집빵족을 위한 베이킹 SNS (리액트 팀 프로젝트)

https://breadgood-22.github.io/frontend


Languages

Language:JavaScript 99.0%Language:HTML 1.0%Language:CSS 0.1%