2차 프로젝트 회고록 | ADLIP

김태규·2021년 11월 7일
3
post-thumbnail
post-custom-banner

프로젝트 소개

이번 2차 프로젝트에서는 여가 액티비티 플랫폼 '프립(Frip)'을 클론코딩하게 되었다. 프립은 다양한 액티비티, 원데이 클래스, 소셜클럽, 여행 상품 등을 탐색해서 참여할 수 있는 사이트이다. 1차 프로젝트와 다른 점은 프론트엔드와 백엔드 중 하나를 선택하여 개발에 참가했다는 점이다. 저는 프론트엔드 개발자로서 이번 프로젝트에 참가하게 되었고, 팀은 프론트엔드 5명과 벡엔드 2명으로 구성되었다. 팀명은 치열한 논의끝에(?) ADLIP으로 정해졌다.

Frip 사이트
ADLIP 프론트엔드 GitHub
ADLIP 백엔드 GitHub
ADLIP 배포


사용된 기술

프론트엔드

  • React
  • React Router
  • Styled Component
  • Restful API
  • Git & GitHub

백엔드

  • Node.js
  • Express
  • MySQL
  • Prisma ORM
  • Git & GitHub

팀의 역할 분배


모델링


https://dbdiagram.io/d/61621ca4940c4c4eec8d57e3


ADLIP 시연 영상


협업의 경험

12일 동안 모든걸 쏟아부은 느낌이었다. 7명의 팀원 모두 각자 선호하는 개발 방식과 시간대가 있었겠지만, 서로 조금씩 배려해주면서 조율을 해주었다. 주말을 제외한 평일에는 매일 워워크의 한 지점에서 모두 모여서 계속 소통하면서 개발을 진행하였고, 밤늦게 집에 돌아간 후에도 줌에서 모여서 새벽까지 함께 서로 어려워하는 부분을 공유하고 해결하면서 밤낮 가리지 않고 개발에 몰입했다. 1차 프로젝트와는 다르게 프론트엔드에만 집중했기 때문에 조금 더 깊게 파고들어볼 수 있었고, 여러 기능들을 구현하면서 새롭게 배운 부분이 많았다.

우리 팀이 소통이 잘되는 편이라고 생각했음에도 서로의 코드를 합쳤을 때 어긋나는 경우가 많았고, 여러 사람이 함께 모여 일하는 것이 쉬운 것이 아님을 다시 깨달았다. 팀원 모두 체력적으로 힘들어서 예민해지고 짜증나는 경우도 각자
있었을 것이라고 생각된다. 그럼에도 불구하고 끝까지 서로 배려해주려고 노력해준 팀원들에게 고맙다고 말하고 싶다.


아쉬운 점

1차 프로젝트 때를 교훈삼아 다른사람이 봐도 이해하기 쉽도록 코드를 작성하려고 노력했지만 시간이 점점 촉박해지면서 코드 리팩토링을 소홀히 한 것 같아서 반성을 했다.

사이트 구조가 1차 때보다 복잡하고 구현할 것도 많다보니 프론트엔드와 백엔드를 병합하고 어긋나는 부분을 수정할 시간이 하루가 채 되지 않았다. 조금 더 시간이 있었다면 더 좋은 결과물이 나올수 있었을 것 같아서 많이 아쉬웠다.

메인 페이지와 상세 페이지에 들어갈 캐러셀과 리뷰 페이지 구현을 맡았는데, 프립에 있는 기능들을 최우선적으로 구현하다보니 리뷰에서 중요한 부분인 후기 추가와 삭제 구현에 많은 시간을 쏟지 못했다. 조금 더 시간이 있었다면 후기 CRUD를 확실하게 구현해보고 싶다고 생각했다.


기록하고 싶은 코드

2차 프로젝트에서 달라진 점 중 하나는 1차 프로젝트와는 다르게 라이브러리의 제한이 없다는 점이었다. 또한, 1차 프로젝트에서는 리액트의 클래스 컴포넌트를 사용했다면 2차 프로젝트에서는 함수형 컴포넌트를 사용할 수 있었다. 그리고 CSS와 SCSS가 아닌 Styled Components 를 사용하여 스타일 효과를 주었다.

메인 페이지 캐러셀

메인 페이지 캐러셀을 만들 때 React Slick 라이브러리를 사용하여 구현해보았다. 라이브러리를 처음 사용보았는데, 장점으로는 라이브러리 없이 캐러셀을 구현할 때보다 훨씬 쉽고 빠르게 캐러셀을 만들 수 있었다는 점이고, 단점은 라이브러리에서 제공해주는 형태 외에 내가 원하는 모양으로 바꾸는 것이 힘들다는 점이었다.

// MainCarousel.js
import React from 'react';
import styled from 'styled-components';
import Slider from 'react-slick';
import { Link } from 'react-router-dom';
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';

function NextArrow(props) {
  const { className, style, onClick } = props;
  return (
    <div
      className={className}
      style={{
        ...style,
        display: 'block',
        right: '3px',
        height: '40px',
        width: '40px',
        zIndex: '2',
      }}
      onClick={onClick}
    />
  );
}

function PrevArrow(props) {
  const { className, style, onClick } = props;
  return (
    <div
      className={className}
      style={{
        ...style,
        display: 'block',
        left: '3px',
        height: '40px',
        width: '40px',
        zIndex: '2',
      }}
      onClick={onClick}
    />
  );
}

function MainCarousel(props) {
  const { carouselData, isDot } = props;

  const settings = {
    dots: isDot,
    infinite: true,
    speed: 500,
    slidesToShow: 1,
    slidesToScroll: 1,
    nextArrow: <NextArrow />,
    prevArrow: <PrevArrow />,
    autoplay: true,
    autoplaySpeed: 2000,
  };

  return (
    <div>
      <StyledSlider {...settings} propsName={carouselData?.name}>
        {carouselData?.map(image => {
          return (
            <Link to='/' key={image.id}>
              <img alt={image.name} src={image.url} />
            </Link>
          );
        })}
      </StyledSlider>
    </div>
  );
}

export default MainCarousel;

const StyledSlider = styled(Slider)`
  width: 768px;
  margin: 20px;

  .slick-prev:before,
  .slick-next:before {
    font-size: 40px;
  }
`;

상세 페이지 캐러셀

상세 페이지에서 리뷰 페이지에 있는 후기들을 미리 볼 수 있는 캐러셀을 구현했다. 처음에는 상세 페이지 캐러셀도 React Slick 을 이용하여 구현하려고 했지만 원하는 대로 스타일을 바꾸는 것이 어려워서 직접 구현하는 것으로 방향을 바꿨다.

// DetailCarousel.js
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
import { faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { faThumbsUp } from '@fortawesome/free-regular-svg-icons';
import { API } from '../../API/api';

const SLIDERWIDTH = 732;

function DetailCarousel(props) {
  const { pathParameterId } = props;
  const [reviews, setReviews] = useState([]);
  const [position, setPosition] = useState(0);
  const dataForSlide = reviews.filter(
    review => review.CommentImage?.length !== 0
  );
  const slideLength = Math.ceil(dataForSlide?.length / 3);

  useEffect(() => {
    getImages();
  }, []);

  const getImages = () => {
    fetch(`${API}/products/${pathParameterId}/comments`)
      .then(res => res.json())
      .then(res => setReviews(res.comment))
      .catch(console.log);
  };

  const changeImageToLeft = position => {
    let newPosition = position - 1;
    const maxPosition = slideLength - 1;
    if (newPosition < 0) newPosition = maxPosition;
    setPosition(newPosition);
  };

  const changeImageToRight = position => {
    let newPosition = position + 1;
    const maxPosition = slideLength - 1;
    if (maxPosition < newPosition) newPosition = 0;
    setPosition(newPosition);
  };

  const handleLike = review => {
    const index = reviews.indexOf(review);
    const newReviews = [...reviews];
    newReviews[index].isLiked = !newReviews[index].isLiked;
    setReviews(newReviews);
  };

  return (
    <SliderWrapper>
      <BtnPrevWrapper>
        <BtnPrev onClick={() => changeImageToLeft(position)}>
          <FontAwesomeIcon icon={faChevronLeft} size='2x' />
        </BtnPrev>
      </BtnPrevWrapper>
      <BtnNextWrapper>
        <BtnNext onClick={() => changeImageToRight(position)}>
          <FontAwesomeIcon icon={faChevronRight} size='2x' />
        </BtnNext>
      </BtnNextWrapper>
      <Slider
        style={{
          transform: `translateX(
                ${position * -SLIDERWIDTH}px`,
        }}
      >
        {dataForSlide.map(review => {
          const { id, nickname, commentText, CommentImage } = review;
          const showingImg = CommentImage[0];

          return (
            <ReviewWrapper key={id}>
              <Link to={`/products/${pathParameterId}/comments?page=1`}>
                <ReviewImgWrapper>
                  <ReviewImg alt='애드립' src={showingImg?.commentImageUrl} />
                </ReviewImgWrapper>
              </Link>
              <UserProfile>
                <FontAwesomeIcon icon={faUserCircle} />
                <NickName>{nickname}</NickName>
              </UserProfile>
              <ReviewText>{commentText}</ReviewText>
              <LikeBtn
                onClick={() => handleLike(review)}
                style={
                  review.isLiked
                    ? { backgroundColor: 'red' }
                    : { backgroundColor: 'white' }
                }
              >
                <FontAwesomeIcon
                  icon={faThumbsUp}
                  style={review.isLiked ? { color: 'white' } : { color: 'red' }}
                />
              </LikeBtn>
            </ReviewWrapper>
          );
        })}
      </Slider>
      <ReviewNumber>
        <StyledLink to={`/products/${pathParameterId}/comments?page=1`}>
          <MoreReview>{reviews.totalConutOfComment}개 후기 더 보기</MoreReview>
          <FontAwesomeIcon icon={faChevronRight} />
        </StyledLink>
      </ReviewNumber>
    </SliderWrapper>
  );
}

export default DetailCarousel;

리뷰 페이지

리뷰 페이지에서는 하나의 상품에 대한 회원들의 다양한 후기들을 볼 수 있다. 후기에 이미지가 있는 경우 클릭했을 경우 모달창을 통해 볼 수 있고, 후기들을 최신순, 평점 높은순, 평점 낮은순으로 정렬할 수도 있다. 페이지네이션으로 10개씩 볼 수 있도록 구현했다.

// Comment.js
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from 'react';
import { useHistory, useLocation, useParams } from 'react-router';
import CommentHeader from './Components/CommentHeader';
import ReviewCard from './Components/ReviewCard';
import Paging from './Components/Paging';
import ReviewHandler from './Components/ReviewHandler';
import { API_ENDPOINT } from '../../api';

const LIMIT = 10;

function Comment() {
  const history = useHistory();
  const location = useLocation();
  const params = useParams();
  const paramsId = params.id;
  const [reviews, setReviews] = useState([]);
  const [totalCountOfComment, setTotalCountOfComment] = useState(0);
  const [ratingAvg, setRatingAvg] = useState(0);
  const sortOptionData = [
    {
      id: 1,
      option: '최신순',
      isChecked: true,
      orderBy: 'latest',
    },
    {
      id: 2,
      option: '평점 높은순',
      isChecked: false,
      orderBy: 'ratingHigh',
    },
    {
      id: 3,
      option: '평점 낮은순',
      isChecked: false,
      orderBy: 'ratingLow',
    },
  ];
  const [isSortOptionOpen, setIsSortOptionOpen] = useState(false);
  const [sortOptions, setSortOptions] = useState(sortOptionData);
  const [page, setPage] = useState(1);
  const [selectedSortOption] = sortOptions.filter(option => option.isChecked);
  const order = selectedSortOption.orderBy;
  const query = `orderBy=${order}&offset=${(page - 1) * LIMIT}`;

  useEffect(() => {
    getReviews();
  }, []);

  useEffect(() => {
    fetch(`${API_ENDPOINT}/products/${paramsId}/comments?${query}`)
      .then(res => res.json())
      .then(res => {
        setReviews(res.Comment);
      })
      .catch(console.log);
  }, [query, paramsId]);

  useEffect(() => {
    const newPageNumber = Number(location.search[location.search.length - 1]);
    setPage(newPageNumber);
  }, [location]);

  const getReviews = () => {
    fetch(
      `${API_ENDPOINT}/products/${paramsId}/comments?orderBy=latest&offset=0`
    )
      .then(res => res.json())
      .then(res => {
        setReviews(res.Comment);
        setTotalCountOfComment(res.totalCountOfComment);
        setRatingAvg(res.ratingAvg);
      })
      .catch(console.log);
  };

  const handleLike = review => {
    const index = reviews.indexOf(review);
    const newReviews = [...reviews];
    newReviews[index].isLiked = !newReviews[index].isLiked;
    setReviews(newReviews);
  };

  const changeSortOption = sort => {
    const newSortOptions = [...sortOptions];
    const sortIndex = sort.id - 1;
    newSortOptions.forEach(option => (option.isChecked = false));
    newSortOptions[sortIndex].isChecked = true;
    setSortOptions(newSortOptions);
    history.push(`/products/${paramsId}/comments?page=1`);
    window.scrollTo(0, 0);
    setIsSortOptionOpen(false);
  };

  const handlePageChange = page => {
    setPage(page);
    history.push(`/products/${paramsId}/comments?page=${page}`);
    window.scrollTo(0, 0);
  };

  const deleteReview = id => {
    let confirmDelete = window.confirm('정말로 삭제하시겠습니까?');
    if (confirmDelete) {
      const newReviews = [...reviews].filter(review => id !== review.id);
      setReviews(newReviews);
    }
  };

  return (
    <section>
      <CommentHeader
        isSortOptionOpen={isSortOptionOpen}
        setIsSortOptionOpen={setIsSortOptionOpen}
        sortOptions={sortOptions}
        changeSortOption={changeSortOption}
        totalCountOfComment={totalCountOfComment}
        ratingAvg={ratingAvg}
      />
      <ReviewCard
        reviews={reviews}
        handleLike={handleLike}
        deleteReview={deleteReview}
      />
      <ReviewHandler reviews={reviews} setReviews={setReviews} />
      <Paging
        page={page}
        handlePageChange={handlePageChange}
        totalCountOfComment={totalCountOfComment}
      />
    </section>
  );
}

export default Comment;

후기 추가 및 삭제

후기 추가 버튼을 누르면 후기를 작성할 수 있는 모달창이 열리고 평점과 함께 후기를 추가 및 삭제할 수 있도록 구현해보았다. 프립 사이트와는 별개로 구현한 것이기도 하다.

// AddReviewModal.js
import React, { useState, useRef } from 'react';
import styled from 'styled-components';

const MODALWIDTH = 600;

function AddReviewModal(props) {
  const { toggleModal, reviews, setReviews } = props;
  const [newText, setNewText] = useState('');
  const [newRating, setNewRating] = useState(0);
  const textRef = useRef();
  const ratingRef = useRef();

  const handleTextChange = () => {
    const newText = textRef.current.value;
    setNewText(newText);
  };

  const handleRatingChange = () => {
    const newRating = ratingRef.current.value;
    setNewRating(newRating);
  };

  const addNewReview = () => {
    if (newText === '') return alert('후기를 입력해주세요');
    if (newRating === 0) return alert('별점을 선택해주세요');

    const today = new Date();
    const year = today.getFullYear();
    const month = ('0' + (today.getMonth() + 1)).slice(-2);
    const day = ('0' + today.getDate()).slice(-2);
    const dateString = year + '-' + month + '-' + day;

    setReviews([
      {
        id: reviews.length + 1,
        nickname: nickname,
        rating: Number(newRating),
        commentText: newText,
        createdAt: dateString,
        CommentImage: [],
        isLike: false,
      },
      ...reviews,
    ]);

    toggleModal();
    window.scrollTo(0, 0);
  };

  return (
    <ReviewInputWrapper>
      <ReviewTextArea
        placeholder='후기를 입력해주세요.'
        ref={textRef}
        onChange={handleTextChange}
      />
      <ReviewInputBtn>
        <div>
          <RatingSelect ref={ratingRef} onChange={handleRatingChange}>
            <option value='0'>-- 별점 선택 --</option>
            <option value='1'>★️</option>
            <option value='2'>★★</option>
            <option value='3'>★★★️</option>
            <option value='4'>★★★★️</option>
            <option value='5'>★★★★★</option>
          </RatingSelect>
        </div>
        <div>
          <button onClick={addNewReview}>작성완료</button>
          <button onClick={toggleModal}>나가기</button>
        </div>
      </ReviewInputBtn>
    </ReviewInputWrapper>
  );
}

export default AddReviewModal;

마무리

위코드에서 1차, 2차 프로젝트를 진행하면서 협업을 통해 개발을 한다는 것이 무엇인지 알 수 있는 계기가 된 것 같다. 개발의 세계가 정말 끝이 없다는 것을 다시 한 번 느끼게 되었고, 내가 아직 많이 부족하고 모르는 부분이 많다는 것을 알 수 있었다.

그래도 예전에는 무엇을 모르는지조차 모르는 상태였다면, 지금은 내가 무엇이 부족하고 앞으로 어떤 것을 공부하면 좋을지 조금은 알게 되었다고 생각한다. 여기에서 멈추지 않고, 계속 배우고 발전하면서 읽기 쉬운 코드를 쓰고 함께 일하기 좋은 프론트엔드 개발자가 되고 싶다고 다짐해본다.

post-custom-banner

2개의 댓글

comment-user-thumbnail
2021년 11월 7일

겸손함에 대명사

답글 달기
comment-user-thumbnail
2021년 12월 18일

2차 프로젝트할 때 생각나네요~!~!~! 즐거웠습니다 애드립 화이팅💥

답글 달기