SKYROCKET - tumblbug 모티브의 펀딩 사이트 개발 프로젝트

Hayoung·2021년 8월 12일
3

Projects

목록 보기
2/2

다음 주부터 기업 협업에 참여하게 되었다.
1분 1초라도 더 예습을 진행해야하는 상황이지만, 프로젝트를 회고하기 위해 글을 남겨본다.
(굉장한 장문이 될 것으로 예상합니다😇)

🌈 프로젝트 소개

🌴 개요

"SKYROCKET"
세상의 모든 펀딩이 SKYROCKET하는 날까지!

이 프로젝트는 >wecode Fullstack 1기 SKYROCKET 팀에서 진행한 크라우드 펀딩 사이트 tumblbug을 모티브로한 펀딩 사이트 개발 프로젝트입니다.

서비스명이자 팀명인 SKYROCKET급등하다라는 뜻을 가진 동사로,
SKYROCKET 서비스와 팀원 개개인이 끊임없이 발전하기를 원하는 마음💖을 담았습니다.

🌴 DEMO

SKYROCKET Demo 영상

🌴 기간

2021.7.26 ~ 2021.8.13 (16일간)

🌴 팀원

기존 프론트 담당자는 본인 포함 3명이었으나, 도중에 팀원이 프로젝트에서 하차하게 되어 2명이서 진행했다.

🌴 GitHub Repository

🌴 API Document

SKYROCKET API Document

🌴 기술 스택

  • Front-end
    React(Functional Components), React Hooks, React Router Dom, styled-components

  • Back-end
    Node.js, Express, Prisma, MySQL, Bcrypt, JWT, Babel, Nodemon, Jest, SuperTest

  • Common
    Kakao Social Login API, Git, GitHub, Axios, ESLint, Prettier

  • Tools
    Trello, Slack, Postman

🌴 프로젝트 진행 방식

회의 진행

매일 오전 10:00~10:30Daily (Standup) Meeting을 진행하며 프로젝트 진행 상황, 블로커를 공유했다.
일정에 차질이 생길 것으로 예상될 시에는 유동적으로 티켓과 기능, 기획을 조절하며 진행했다.

그 외 기능 구현에 대한 블로커 해결이나, 상의해야할 사항 등이 생길 때는 팀원들과 Google Meet에 상시로 모여서 회의를 통해 조율해나갔다. 회의를 정말 많이 진행했던 것 같다.

비단 회의 뿐만 아니라, 슬랙의 팀 채널 또한 적극 활용했다.
비대면 업무가 일상이 된 시대이다.
전달 사항을 구체적인 문장으로 표현하여 최대한 정확히 전달하도록 노력했다.
말과 달리 글은 기록으로 남기 때문이다📝

티켓 분배

이번 프로젝트는 1주를 하나의 Sprint로 잡아 초반에 모든 티켓을 다 할당하지 않고, 각 Sprint씩 티켓을 분배하는 방식으로 진행해보았다.
해당 Sprint 기간 내에 담당한 티켓을 Done시키겠다는 마음가짐으로!

차기 Sprint에는 각 팀원의 진행 상황과 프로젝트 우선 순위에 따라 티켓을 차등적으로 분배하는 형식으로 진행해보았다.

1차 프로젝트 당시, 프로젝트 초기부터 모든 티켓을 세세하게 나누어 분배했었는데, 상황에 따라 유동적으로 스케줄을 조절하는 것에 어려움을 느꼈었기 때문이다.

두 가지 방식 모두 진행해본 결과, 각각 장단점이 있다고 생각했다.
이번에 도입한 방식의 경우에는 차기 Sprint의 티켓 담당자가 정해져 있지 않기 때문에, 스케줄과 기능을 자유롭게 조율하기 편한 점은 확실하게 있었다.

하지만 다음 Sprint의 업무가 확실하게 분장되어 있지 않았기에, 목표 의식이 뚜렷히 가지지 않은 채 진행하면 자칫 루즈해지기 쉬울 수도 있겠다는 생각이 들었다.
때문에 "우선 순위 순으로 업무를 소화해나가겠다"는 목표 의식을 강하게 의식한 채로 진행을 해야할 필요가 있다고 생각했다. (모든 업무 진행방식이 다 그렇겠지만.)

🌴 주요 구현 사항

🌸은 내가 담당한 기능.
🌼은 팀원과 공동으로 구현한 기능.

이번 프로젝트에서는 기본적인 펀딩 사이트 Flow를 구현하되, 1차 프로젝트에서 구현해보지 못했던 소셜 로그인을 꼭 도입하는 것을 목표로 했다.

Front-end

1. 공통 구현 사항

  • 공용 Header
    • 레이아웃 구현🌸
    • 공용 Header가 일부 페이지에서는 렌더링 되지 않도록 동적 라우팅 구현🌸
    • 유저가 로그인했을 시에만 로그아웃 버튼 렌더링 및 로그아웃 기능 구현🌸
  • 공용 Footer
    • 레이아웃 구현
    • 공용 Footer가 일부 페이지에서는 렌더링 되지 않도록 동적 라우팅 구현🌸
  • utils.js에 공용 모듈을 분리
    • 금액 세 자리 단위마다 콤마 추가하는 함수🌸
    • 프로젝트 달성률을 계산하고, 소숫점을 제거하여 출력하는 함수🌸
    • 프로젝트 남은 날짜를 계산하는 함수🌸
  • Axios 인스턴스를 생성하여 Request의 공통 부분을 통합화🌸
  • styled-components와 props.children을 활용하여 Container Component 구현🌸
  • flex와 같이 자주 사용하는 CSS를 모듈화, ThemeProvider를 사용하여 컬러 변수를 Global화🌸

2. 메인 페이지

  • 레이아웃 구현
  • 메인 상단의 Carousel 구현
  • 상품 리스트 API와 연계하여, 해당 카테고리 상품을 동적으로 출력🌼

3. 상품 리스트 페이지

  • 레이아웃 구현🌸
  • 상품 리스트 API와 연계하여, 모든 상품 정보를 동적으로 출력🌸
  • 프로젝트의 진행률을 나타내는 Progress Bar 구현🌸
  • 프로젝트의 남은 기간을 나타내는 Indicator 출력🌸

4. 상세 페이지

  • 레이아웃 구현
  • 상품의 ID에 따른 동적 라우팅 구현🌸
  • 상품 상세 API와 연계하여, 상품 정보를 동적으로 출력🌼
  • 결제일을 한국시각(년, 월, 일)으로 표시🌸
  • 선물 선택의 옵션 클릭 시, 추가 후원금 선택 박스를 Toggle🌸
  • 추가 후원금 금액 선택에 따라 후원금 금액 변화
  • 상세 페이지에서 API의 Fetch가 완료되지 않았을 때, 각종 금액 및 숫자 지표를 0으로 표시🌸

5. 로그인/회원가입

  • 레이아웃 구현🌸
  • 로그인/회원가입 페이지에서 공통적으로 사용되는 요소를 공용 컴포넌트로 분리하여 재사용🌸
  • 카카오 소셜 로그인/회원가입 기능 구현🌸
  • 일반 로그인/회원가입 기능 구현🌸
  • 회원가입 폼의 약관 동의 Checkbox 전체 선택/해제 기능 구현🌸
  • 로그인/회원가입 폼의 Input값을 취득하는 Custom Hook 작성🌸
  • 로그인/회원가입 페이지 전용 Header 구현🌸

6. 후원 현황 페이지

  • 레이아웃 구현
  • mock data를 활용하여 후원 현황 데이터를 출력

Back-end

1. 카카오 소셜 로그인/회원가입 API

2. 일반 로그인 API

3. 일반 회원가입 API

4. Access Token 인가 Middleware

5. 상품 리스트 API 🌸

6. 상품 카테고리 API 🌸

7. 상품 상태 API 🌸

8. 에러 Error Generator 🌸


🌈 Front-end? Back-end?

이번 프로젝트를 시작하기 앞서, Front와 Back 중 어떤 스택을 선택할지 고민을 많이 했다.

주력 분야에 집중할 것인가? vs 전체적인 Flow에 대한 이해를 높일 것인가?

결국 나의 선택은...프론트 + 백 = ✅풀스택😇이 되었다.

2차 프로젝트에도 풀스택을 선택한 이유는 아래와 같다.

  1. 프 & 백 관계 없이 지금까지 학습한 지식을 활용해보는 시간을 갖고, 프로젝트의 전체적인 흐름을 파악하는 힘을 기르고 싶었다. 다양한 Flow를 경험하고 싶었다.

  2. 프로젝트의 백엔드 기반 (API의 사양, DB 모델) 등을 직접 파악할 수 있기 때문에, 프론트와 백엔드가 통신할 시에 발생되는 의사소통 오류가 줄어든다.

  3. 각 API의 요청과 응답을 바로 확인할 수 있고, 프 or 백 중 부족한 로직이나 오류가 발생했을 때, 해당 스택 담당자를 거치지 않고도 다이렉트로 대응할 수 있기 때문에 시간이 절약된다.


🌈 What did I do

프로젝트를 진행하며 기억하고 싶은 코드와 담당 파트에 대해 남겨본다.

🌴 Front-end

이번에 프론트엔드 파트를 구현하며 가장 중요시한 것은 "재사용성이 뛰어난 코드를 작성하자💪"이다.
이 점을 항상 의식하며 개발을 진행했다.

1. Header

Header가 가진 기능은 로그아웃 기능이다.

  • 로그인을 한 유저 → 토큰🙆🏻‍♀️
  • 로그인을 하지 않은 유저 → 토큰🙅🏻‍♀️
function RightMenu() {
  const token = localStorage.getItem('token');

  return <RightMenuBox>{token ? <LogoutLink /> : <LoginLink />}</RightMenuBox>;
}

정말 간단한 로직이다.
로그인 완료 시 토큰을 LocalStorage에 저장해두기 때문에 LogalStorage로부터 토큰을 가져온 후,
토큰이 존재하면 로그아웃 링크를 출력, 토큰이 없으면 로그인 링크를 출력한다.

로그아웃 로직이 담긴 LogoutLink 컴포넌트를 살펴보자.

function LogoutLink() {
  const kakaoToken = window.Kakao.Auth.getAccessToken();
  const history = useHistory();

  const handleKakaoLogout = () => {
    window.Kakao.API.request({
      url: '/v1/user/unlink',
      success: res => {
        Object.keys(localStorage)
          .filter(key => key.startsWith('kakao_'))
          .forEach(key => localStorage.removeItem(key));

        localStorage.removeItem('token');
        alert('성공적으로 로그아웃 되었습니다. 다음에 또 만나요!🙋‍♀️');
        history.push('/');
      },
      fail: error => {
        console.log(error);
        Object.keys(localStorage)
          .filter(key => key.startsWith('kakao_'))
          .forEach(key => localStorage.removeItem(key));

        localStorage.removeItem('token');
        alert('성공적으로 로그아웃 되었습니다. 다음에 또 만나요!🙋');
      },
    });
    window.Kakao.Auth.setAccessToken(undefined);
  };

  const handleNormalLogout = () => {
    localStorage.removeItem('token');
    alert('성공적으로 로그아웃 되었습니다. 다음에 또 만나요🥰');
    history.push('/');
  };

  return (
    <>
      <PledgesButton to="/pledges">후원 현황</PledgesButton>
      <LogoutButton
        onClick={kakaoToken ? handleKakaoLogout : handleNormalLogout}
      >
        <SignInSignUp>로그아웃</SignInSignUp>
        <UserAvatarIcon />
      </LogoutButton>
    </>
  );
}

SKYROCKET 사이트는 일반 로그인/소셜 로그인 2가지 타입의 Authentication을 제공한다.

따라서 모든 타입에 각각 로그아웃을 지원해야한다. 로그아웃 버튼은 하나이지만, 유저가 어떤 타입의 로그인 방식을 사용했는지에 따라 로그아웃 방식이 달라진다.

  • 카카오 소셜 로그인을 한 유저 → 카카오 측에서 발급된 토큰 보유
  • 일반 로그인을 한 유저 → 우리 백엔드 측에서 발급된 토큰 보유

로그아웃 버튼을 클릭했을 때, 만약 유저가 카카오 측의 토큰을 보유하고 있다면 카카오 로그아웃 로직인 handleKakaoLogout을, 아니라면 handleNormalLogout 로직을 실행한다.

일반 로그아웃 로직인 handleNormalLogout은 간단하다. 로컬 스토리지에 저장된 유저의 토큰을 삭제해주면 된다.

카카오 로그아웃의 경우, 카카오 로그인 API 공식 문서의 "연결 끊기" 항목을 참조하여 구현했다.

로그아웃 메서드도 별도로 존재하지만, "연결 끊기" 메서드를 사용하여 유저와 SKYROCKET 웹사이트의 연결 자체를 끊게 했다.

소셜 로그아웃 성공 시, Success라는 파라미터 안에서 LocalStorage의 kakao_로 시작되는 정보와 함께 토큰을 삭제해줌으로써 카카오 소셜 로그인의 연결을 끊는다.

2. 상품 리스트 페이지

애증의 리스트 페이지😇

리스트 API로부터 받은 데이터를 바탕으로, 전 상품을 렌더링하고 있다.
최대한 텀블벅 페이지와 동일한 레이아웃 그대로 구현해보았다.

↑ 텀블벅의 리스트 페이지
↓ SKYROCKET의 리스트 페이지!☺️

펀딩 금액 우측의 펀딩 진행률은, 리스트 API로부터 받아온 프로젝트 시작일과 종료일을 바탕으로 값을 계산하는 함수를 별도로 만들어서 산출해냈다.

다음은 펀딩 진행 상태를 시각적으로 보여주는 Progress Bar.

<ProgressBar achievedRate={getAchievedRate(achievedAmount, goalAmount)} />
function ProgressBar({ achievedRate }) {
  const rate = achievedRate > 100 ? 100 : achievedRate;

  return <StatusBar achievedRate={rate}></StatusBar>;
}

const StatusBar = styled.div`
  width: 100%;
  height: 2px;
  margin-bottom: 15px;
  background: ${({ theme }) => theme.colors.grey700};

  &:after {
    display: block;
    content: '';
    width: ${({ achievedRate }) => achievedRate}%;
    height: 100%;
    background: ${({ theme }) => theme.colors.red100};
  }
`;

export default ProgressBar;

Progress Bar는 펀딩 진행률을 props로 받아서,
펀딩 진행률 퍼센트% 값만큼 Indicator의 width를 설정해주어 표현하게끔 했다.

상품 카드 하단의 우측에 있는 펀딩 남은 시간도 리스트 API로부터 받아온 펀딩 시작일, 종료일을 바탕으로 값을 계산하는 함수를 별도로 만들어서 산출해냈다.

3. 상품 상세 페이지

프로젝트 발표일 오전, 수호님과 함께 상세 페이지를 급하게 작업했다😣

수호님이 만들어주신 상세 페이지 레이아웃과
미정님이 프로젝트 발표 전날 완성해주신 상세 API를 연계하여 데이터를 출력해냈다.

금액 3자리 수 마다 콤마 추가, 펀딩 진행률, 펀딩 남은 시간 등은 API로부터 받아온 데이터를 바탕으로 프론트에서 계산을 해주어야했다.
타 컴포넌트에서도 존재하는 요소라 별도로 모듈화해둔 덕분에, 데이터를 렌더링하는 것에는 다행히 큰 시간이 소요되지 않았다.

상세 페이지 상단의 펀딩 진행중 박스의 "결제일"의 경우, 2021년 12월 32일과 같이 년월일 형태로 출력해주어야 할 필요가 있었다.

Date 객체를 사용하여 paymentDate의 년, 월, 일 값을 각각 가져와서 템플릿 리터럴로 포맷을 바꿔주었다.

const formatDate = paymentDate => {
  const newDay = new Date(paymentDate);
  const year = newDay.getFullYear();
  const month = newDay.getMonth();
  const date = newDay.getDate();

  return `${year}${month}${date}`;
};

이렇게 모듈화와 재사용의 중요성을 다시 한 번 느꼈다.

4. 일반 로그인 & 카카오 로그인

일반 로그인

로그인 페이지 또한, 텀블벅의 레이아웃을 99% 재현해보았다.

SKYROCKET 로그인 페이지의 전체적인 shape이 더 둥글다는 것을 알 수 있는데,
이유는 각 요소들에 전체적으로 통일감을 부여하고자 카카오 로그인 버튼의 디자인 가이드에서 규정하고 있는 radius에 맞추었기 때문이다.

각 기업마다 서비스의 통일감과 아이덴티티를 위해 디자인 시스템을 갖추고 있듯, 사소한 부분이라도 서비스의 전체적인 밸런스를 유지하는 것이 정말 중요하다고 생각한다.

기능적인 면에서는 로그인 컴포넌트와 일반 로그인 API와 연계시켜 일반 로그인을 구현했다.

이메일 또는 패스워드가 일치하지 않을 때, Alert를 발생시켜 유저에게 잘못된 정보가 입력되었음을 알린다.

로그인이 정상적으로 성공하면, 환영 메시지와 함께 백엔드로부터 건네받은 토큰을 LocalStorage에 저장 후 메인으로 Redirect시켜 유저의 동선을 변경한다.

일반 로그인은 여러 번 진행했던 터라 스무스하게 진행했다.

다음은 이번 프로젝트의 주요 목표였던 카카오 소셜 로그인!

카카오 소셜 로그인

카카오 로그인 로직은 로그인/회원가입 페이지에서 동시에 사용되기 때문에 useKakaoLogin이라는 Custom Hook으로 분리시켜, 여러 컴포넌트에서 재사용할 수 있도록 구현했다.

카카오 소셜 로그인의 전체적인 흐름은 다음과 같다.


이미지를 클릭하시면 출처로 이동합니다.

  1. 클라이언트 사이트에서 유저가 카카오 로그인 버튼을 클릭하면 카카오 로그인 팝업이 뜸. 유저는 카카오 로그인 폼에 카카오 정보를 입력 후 로그인을 클릭. 카카오 백엔드 측의 카카오 로그인 API가 호출됨.
  2. 카카오 백엔드 측은 유저로부터 받은 정보가 카카오 DB에 존재하는지 확인. 카카오 DB에 존재하는 회원이라면, 카카오 측이 클라이언트에게 유저 정보와 함께 암호화한 토큰을 전달함.
  3. 클라이언트는 Header에 카카오로부터 받은 토큰을 담아 우리 측 백엔드에서 제작한 카카오 로그인 API를 호출함.
  4. 우리 측 백엔드는 클라이언트로부터 받은 카카오 토큰을 카카오 백엔드 측에 Request로 보내며, 사용자 확인을 요청함.
  5. 카카오 측은 카카오 토큰을 복호화한 유저 정보를 전달함.
  6. 우리 측 백엔드는 복호화된 유저 정보가 DB에 존재하는지 확인 후, 존재하는 회원이라면 토큰을 발급해서 클라이언트에게 전달. 존재하지 않는 회원이라면 DB에 회원 정보를 저장 후 동시에 토큰을 클라이언트에게 전달함.
  7. 클라이언트는 토큰을 LocalStorage 등의 저장 공간에 저장함.
  8. 소셜 로그인 완료!
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { KAKAO_LOGIN_API } from '../config';

function useKakaoLogin() {
  const history = useHistory();

  const handleKakaoLogin = () => {
    window.Kakao.Auth.login({
      scope: 'profile_nickname, account_email',
      success: authObj => {
        axios(KAKAO_LOGIN_API, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${authObj.access_token}`, // 카카오로부터 전달받은 토큰
          },
        }).then(res => {
          if (res.data.token) {
            localStorage.setItem('token', res.data.token);
            alert('환영합니다 우주인님🌸');
            history.push('/');
          } else {
            alert('정보를 다시 확인해 주세요🥲');
          }
        });
      },
      fail: error => {
        console.error(error);
      },
    });
  };

  return { handleKakaoLogin };
}

export default useKakaoLogin;

useKakaoLogin이 return하는 handleKakaoLogin은 아래 두 버튼을 onClick했을 때 발화된다.

↑ 로그인 페이지의 카카오 로그인 버튼

↑ 회원가입 페이지의 카카오 회원가입 버튼

위 버튼을 클릭하면 가장 먼저 Kakao.Auth.login 함수가 발동된다.
Request 시, 유저의 카카오 계정 로그인 폼과 함께 추가 항목 동의 화면을 팝업창으로 띄울 수 있는 함수이다.

유저가 카카오 계정 로그인 폼에서 카카오 계정 정보를 입력 후, "로그인"을 클릭하면 카카오 서버 측의 카카오 API를 호출하게 된다.

백엔드에게 카카오로부터 받은 토큰을 전달하며 카카오 측에 카카오 토큰의 복호화를 요구한다.
백엔드는 복호화되어 받은 유저 정보가 DB에 저장된지 여부를 확인한 후, 존재하는 유저 정보라면 즉시 토큰 발급, 존재하지 않는 유저라면 DB에 유저 정보를 저장한 후 토큰을 발급하여 클라이언트로 전달한다.

클라이언트는 응답으로 전달받은 토큰을 LocalStorage에 저장하며
환영 메시지와 함께 유저를 메인으로 Redirect시켜줌으로써 카카오 로그인 완료!☺️

프론트엔드 측에서는 크게 1️⃣ 백엔드에게 카카오 토큰을 전달하고, 2️⃣ 우리 측 백엔드에서 발급받은 토큰을 저장하는 과정 두 가지를 진행하는 것 같다.

사실상 위의 코드를 작성하는 것 자체는 오래 걸리지 않았지만, 카카오 로그인의 전체적인 흐름을 파악하고 진행하는 과정에 집중했다.
백엔드에서 작성한 카카오 로그인 API 코드를 먼저 파악한 후에 클라이언트 측의 코드를 작성하니, 훨씬 수월하게 진행할 수 있었다.

이 과정에서 이번 프로젝트에서 풀스택을 선택하길 잘했다는 보람을 느꼈다💖

5. 일반 회원가입

회원가입 페이지의 레이아웃을 보면, 로그인 페이지의 레이아웃과 상당히 공유하고 있는 요소가 많다는 것을 알 수 있다.

서로 공유하는 요소에 대해서는 개별 컴포넌트로 분리시켰다.
props.children를 이용해 컨텐츠를 표시하고, props를 통해 개별적인 스타일을 부여했다.

대표적인 예시로, 로그인/회원가입 페이지의 Button 컴포넌트를 소개한다.

import React from 'react';
import styled, { css } from 'styled-components';
import { flex } from '../../../styles/mixins';

function Button({ className, children, ...rest }) {
  return (
    <StyledButton className={className} {...rest}>
      {children}
    </StyledButton>
  );
}

const StyledButton = styled.button`
  ${flex('center', 'center')};
  width: 100%;
  height: 52px;
  border-radius: 12px;
  font-size: 16px;
  text-align: center;
  transition: opacity 250ms;

  &:hover {
    opacity: 0.7;
  }

  ${({ basic }) =>
    basic &&
    css`
      color: ${({ theme }) => theme.colors.grey300};
      background: ${({ theme }) => theme.colors.white};
      border: 1px solid ${({ theme }) => theme.colors.grey400};
    `}

  ${({ kakao }) =>
    kakao &&
    css`
      background: ${({ theme }) => theme.colors.kakaoYellow};
      border: 1px solid ${({ theme }) => theme.colors.kakaoYellow};

      & .logoKakao {
        width: 20px;
        margin-right: 10px;
      }
    `}

  ${({ primary }) =>
    primary &&
    css`
      margin-top: 25px;
      color: ${({ theme }) => theme.colors.white};
      background: ${({ theme }) => theme.colors.red100};
      border: 1px solid ${({ theme }) => theme.colors.red100};
      font-weight: bold;
    `}

export default Button;

다음은 회원가입 폼 하단의 약관동의 checkbox의 전체선택/해제 기능을 구현했다.

checkbox는 다음 3가지의 조건을 모두 만족한다.

  • 전체 선택 & 해제
  • 전체 선택 후, 하나라도 checkbox가 false이면 전체 선택 해제
  • "전체 동의" checkbox를 제외한 싱글 checkbox를 모두 선택 시, "전체 동의" checkbox 활성화

기존에는 map 메소드로 생성된 모든 checkbox 아이템에 onChange 이벤트로 체크된 checkbox의 ID를 state에 담아 체크 여부를 판단하고, state에 담긴 값을 바탕으로 checkbox의 상태를 변경하는 함수를 만들어 구현했었다.

코드를 작성하는 중에도 로직이 직관적이지 않고, 클린하지 못하다는 의심이 계속 들었다.

"checkbox의 상태를 변경하는 함수를 굳이 만들 필요가 없지 않을까?"
"어떻게 하면 함수를 정의하지 않아도 checkbox의 상태를 변경할 수 있을까?"

아래는 기존 코드. ↓

...
const [checkedItems, setCheckedItems] = useState([]);

  const handleSingleCheck = (event, id) => {
    const { checked } = event.target;

    if (checked) {
      setCheckedItems([...checkedItems, id]);
    } else {
      setCheckedItems(checkedItems.filter(checkBoxId => checkBoxId !== id));
    }
  };

  const handleAllCheck = event => {
    const { checked } = event.target;

    if (checked) {
      const allIdsArray = [];
      CHECKBOXES.forEach(checkBox => allIdsArray.push(checkBox.id));
      setCheckedItems(allIdsArray);
    } else {
      setCheckedItems([]);
    }
  };

  const handleCheckedStatus = id => {
    const singleCheckBoxes = CHECKBOXES.filter(item => item.id !== 1).map(
      item => item.id
    );

    if (id === 1) {
      return (
        checkedItems.length === CHECKBOXES.length ||
        singleCheckBoxes.every(item => checkedItems.includes(item))
      );
    }

    return checkedItems.includes(id);
  };

...

{CHECKBOXES.map(checkBox => (
  <Agreement key={checkBox.id} type={checkBox.id}>
    <Checkbox
      id={checkBox.id}
      type="checkbox"
      value={checkBox.id}
      onChange={
       checkBox.id === 1
        ? event => handleAllCheck(event)
        : event => handleSingleCheck(event, checkBox.id)
      }
      checked={handleCheckedStatus(checkBox.id)}
    />
    <AgreementLabel htmlFor={checkBox.id}>
      {checkBox.label}
    </AgreementLabel>
  </Agreement>
))}

...

아니나 다를까, 장현님께서 찜찜했던 부분을 시원하게 지적해주심과 동시에 아래와 같은 인사이트를 얻을 수 있었다.
장현님 감사합니다!

  • 매 렌더마다 체크박스의 상태를 체크하는 함수가 실행되고 있다.
  • 단순하게 checkbox의 수만큼 state의 초기값을 설정해서 checkbox를 관리
  • 전체 동의 checkbox를 나타낼 때 id === 1이라는 조건으로 나타낸 것이 직관적이지 않다.
  • 전체 동의 checkbox를 별도의 체크박스로 분리시킬 것.
  • 전체 동의 checkbox의 상태는 다른 state나 props로부터 계산이 가능하기 때문에, 전체 동의 checkbox용 state는 불필요할 것.

위의 사항을 바탕으로, state에 담긴 상태값을 바탕으로 checkbox를 관리하는 형태로 싹 리뉴얼했다.

...
const [isChecked, setIsChecked] = useState([false, false, false, false]);

...

  const handleSingleCheck = event => {
     const { id } = event.target;
     const statusUpdatedArray = [...isChecked];

     statusUpdatedArray[id - 1] = !statusUpdatedArray[id - 1];

     setIsChecked(statusUpdatedArray);
  };

  const handleAllCheck = event => {
    const { checked } = event.target;

    checked
      ? setIsChecked([true, true, true, true])
      : setIsChecked([false, false, false, false]);
  };

...

<Agreement type="all">
  <Checkbox
    id="all"
    type="checkbox"
    value="all"
    onChange={handleAllCheck}
    checked={isChecked.every(Boolean)}
  />
  <AgreementLabel htmlFor="all">전체동의</AgreementLabel>
</Agreement>
{CHECKBOXES.map((checkBox, index) => (
  <Agreement key={checkBox.id} type={checkBox.id}>
    <Checkbox
      id={checkBox.id}
      type="checkbox"
      value={checkBox.label}
      onChange={handleSingleCheck}
      checked={isChecked[index]}
    />
    <AgreementLabel htmlFor={checkBox.id}>
      {checkBox.label}
    </AgreementLabel>
  </Agreement>
))}

...

전체 동의 checkbox를 일반 checkbox와 분리시키니 checkbox를 핸들링하기 훨씬 용이해졌다.
정말 단순하게 state에 담긴 상태값을 갱신하는 느낌으로 수정했더니 한눈에 봐도 로직 자체도 간결해졌다.

단순하게 생각하는 것이 제일 현명하다는 것을 다시 한 번 느꼈다🤓

하지만 이 코드는 state에 checkbox의 갯수만큼 state를 담고 있기 때문에, checkbox의 갯수의 변동에 유연하지 못하다고 생각한다.
이 부분은 추후 리팩토링 때 개선해볼 예정!

🌴 Back-end

이번 프로젝트의 백엔드 파트에서는 아래의 사항을 중점적으로 의식하며 개발을 진행했다.

  • Prisma 순정 쿼리문을 사용
  • 작성된 코드의 디버깅과 유지 보수를 위해 유닛 테스트를 반드시 진행

DB와 직접적인 소통을 하는 Dao 뿐만 아니라, 유닛 테스트 내에서도 테스트 DB에 데이터를 삽입하는 과정 등에서 Prisma 순정 문법을 활용했다.

하지만 Prisma 순정 문법에 사용이 익숙하지 않았던 점, SQL문과 달리 테이블의 참조 관계가 그대로 Nesting 되어 결과 데이터가 출력된다는 점이 블로커가 되었었다.

한편으로는 where문에서 세부적인 조건을 더해 원하는 데이터를 Read해오는 과정에서는 Prisma가 제공하는 문법의 편리함을 느낄 수 있었다.

아직은 낯설 뿐, 자주 접하고 사용하게 되면 익숙해지리라💪

1. 상품 리스트 API

이번 프로젝트에서 개인적으로 어쩌면 가장 힘을 기울였던 상품 리스트 API.

기존에는 상품 리스트 페이지에 필터링과 인피니트 스크롤을 구현하기로 계획이 되어 있었다. 이 점을 고려하여 리스트 API를 개발했다.

필터링 기능은 API로 전체 리스트 정보를 받아와 filter 메소드 등으로 프론트 단에서 필터링 가공하는 방식이 아닌,
프론트 측에서 필터링 항목을 클릭할 시, 해당 필터링을 뜻하는 쿼리 스트링을 엔드포인트에 추가하여 통신하는 방법으로 진행하고자 했다.

일부 데이터만을 불러오는 필터링을 위해, 사용하지 않는 모든 데이터를 다 불러오는 것은 되려 리소스를 낭비하는 것이지 않을까라고 생각했기 때문이다.

쿼리 스트링으로 offset, limit, category, status라는 4개의 옵션을 받는다. 모든 쿼리 스트링은 정수를 받는다.

  • 카테고리 1 (게임) 선택 시
    /project?category=1
  • 카테고리 2 (패션) & 진행 중인 프로젝트 선택 시
    /project?category=2&status=1
  • index가 10인 상품부터 10개 씩 & 카테고리 3 (음악) & 진행 중인 프로젝트 선택 시
    /project?offset=10&limit=10&category=3&status=1

리스트 API의 사용 예시는 API Document에서 확인할 수 있다.

리스트 API의 자세한 사양은 다음과 같다.

  • 쿼리 스트링으로 받은 offset, limit, category, status라는 4개의 옵션을 고려하여 상품 리스트의 필터링 구현
    • req.query의 값이 존재하지 않을 시, 모든 상품 출력
    • 인피니트 스크롤을 고려하여 req.query.offset, req.query.limit값을 이용하여 take, skip 설정
    • req.query.category 값을 사용하여 카테고리별 상품 출력
    • req.query.status 값을 사용하여 펀딩 상태별 상품 출력
    • 정규표현식을 이용하여, req.query로 받은 값이 정수가 아닐 경우 에러 출력
    • req.query로 받은 값이 DB 내에 존재하지 않는 유효하지 않은 값이거나, 올바르지 않을 시 에러 출력
  • Prisma로 만들어진 결과 데이터 객체를 map 메소드를 이용하여 성형
  • 테스트 DB, jest, SuperTest를 활용하여 유닛 테스트 실행
    • 두가지 테스트 케이스 구현 (각 필터링은 optional이기 때문에, 0 : input이 존재하지 않을 때는 구현하지 않음)
      • 1 : 성공 (원하는 input이 들어왔을 때)
      • -1 : 유효하지 않은 input

2. 상품 카테고리, 상태 API

필터 내부의 상품의 카테고리, 상태 정보를 제공하는 API이다.

초반에 리스트 API를 구현할 당시에는 쿼리 스트링으로 카테고리 & 상태 정보를 받을 때는 game, fashion, music, onGoing, finished와 같이 텍스트 그대로 하드 코딩을 하여 받는 방식으로 설계되어 있었다.

이를테면 아래와 같은 방식 ⬇️

/project?category=game&status=onGoing

소헌님으로부터, 리스트 API에서 카테고리, 상태 쿼리 스트링 값을 받아올 때는 category, status 테이블에서 가지고 있는 PK인 ID를 기준으로 받아오는 편이 더 적절하다는 조언을 토대로 추가로 구현하게 된 API이다.

카테고리, 상태 API를 별도로 구현하는 것에 대한 이점은 아래와 같다.

  • 프론트에서 필터를 구현할 때, 카테고리와 상태 값을 직접 텍스트로 하드 코딩 할 필요가 없다.
  • 프론트에서 리스트 API의 엔드 포인트에 쿼리 스트링을 부여할 때, 위와 마찬가지로 하드 코딩을 하지 않아도 된다. 카테고리 & 상태 API로부터 받아온 값을 쿼리 파라미터로 전달만 하면 되기 때문.
  • 변경이 없는 PK인 ID 값으로 정보를 인식하기 때문에, 차후 category나 status 테이블 내 데이터 값이 변경됐을 시에도 수정 영향 범위가 적다.

카테고리, 상태 API의 사용 예시는 API Document에서 확인할 수 있다.

카테고리, 상태 API의 자세한 사양은 다음과 같다.

  • 상품의 카테고리 및 상태 정보를 출력
  • Prisma로 만들어진 결과 데이터 객체를 reduce 메소드를 이용하여 성형
  • jest, SuperTest를 활용하여 유닛 테스트 실행

3. Error Generator

Controller나 Service에서 에러 발생 시에, 에러 메시지와 상태 코드를 Throw하는 로직이 굉장한 중복을 일으키고 있다고 생각했다.

이 문제를 해결하고자, Error Generator라는 이름으로 중복되는 Throw Error 로직을 모듈화했다.

이전 westarbucks 프로젝트에서 Error Generator를 도입했었지만, 당시 구현했던 Error Generator는 에러 코드에 따라 에러 메시지를 정형화하여 제공하는 형태였다.

하지만 아래와 같은 이유로 westarbucks에서 Error Generator를 삭제했던 기억이 있다.

  • 상황에 맞는 에러 메시지를 제공하지 못한다
  • 오히려 유틸로 독립시키는 것이 과한 구조일지도 모른다
  • 애초에 프로젝트의 규모가 크지 않아 에러 모듈로 독립할 필요가 적다

이번 프로젝트에서 API 구현을 진행하며 어김없이 Throw Error 로직이 반복되는 현상을 발견했고, 소헌님께 조언을 구한 결과 지금이 어쩌면 Error Generator를 도입할 때!라고 하셨다🤭

이전에 만들었던 Error Generator와는 달리 에러 메시지를 정형화하지 않고, 인자로 받아온 에러 메시지를 그대로 출력시키는 형태로 구현했다.

const errorGenerator = ({ message = '', statusCode = 500 }) => {
  const err = new Error(message);
  err.statusCode = statusCode;
  throw err;
};

export default errorGenerator;

이름이 거창할 뿐, 코드 자체는 굉장히 간단하다.
에러 메시지와 Status Code를 받아 그대로 Throw하는 방식이다.

실제로 이 모듈을 사용할 때는 아래와 같이 사용한다.

errorGenerator({
  statusCode: 400,
  message: 'INVALID_CATEGORY',
});

Throw Error 로직을 엄청나게 절약하는 것에 성공했다고 장담은 못하겠다.
하지만 위의 코드를 봤을 때, Status Code와 에러 메시지가 객체 형태로 가독성이 좋게 담겨 있는 점에서 약간은 직관적으로 개선되지 않았나..라고 생각한다🙊

경제적으로 Throw Error 시키는 로직을 구현하기 위해 계속해서 고민해볼 예정이다.


🌈 새롭게 배운 점

🌴 결단의 중요성

약 2주라는 짧은 기간에 프로젝트를 진행하면서, 목표와 우선순위에 따라 구현할 부분을 과감하게 취사선택하는 결단력이 필요하다고 실감했다.

1차 프로젝트 때도 부족함을 느꼈기 때문에, 이번 프로젝트는 팀원들과 소통하거나 무언가 결정을 내릴 때 이전보다 단호!하고 확실하게, 그리고 내 생각과 의견을 보다 더 명확히 전하려고 노력했다.

프로젝트 초반에 프로젝트를 기획할 때부터 구현 부분을 필수 & 추가로 확실하게 분류했고, 주어진 시간 내에 해낼 수 있도록 많은 부분을 덜어냈다.

전 프로젝트보다 상황 판별과 취사선택하는 힘이 길러졌다고 생각했지만 그래도 아직 많이 많이 부족하다. 현실적으로 상황을 냉정하게 바라보고 결단을 내릴 수 있도록 더 연습해야겠다.

🌴 단순하게 생각하자

약간 연결고리가 없을지도 모르지만 문득 위의 짤이 떠올랐다.
단순하게 생각하자. 그냥 하는거지.

복잡하게 생각하면 할수록, 혼란스러운 내 사고와 마음이 그대로 코드에 반영된다는 것.
식상하지만 Simple is the best.라는 명언이 있지 않은가.
심플하게 담백하게 로직을 구성해보자. 쉬운 일이 아닐테지만.
분명 경험과 연습이 뒷받침되는 일이겠지.

🌴 자기 자신에게 거듭 질문하고, 체크하며 끊임없이 고민하는 시간을 갖자

이 부분은 평소에도 늘 강조하고 있던 점이지만, 다시 한 번 중요성을 깨달았다.
내가 개발한 로직이 적절한 솔루션인지에 대한 지속적인 고민을 해야한다.

과연 이 로직이 적절하게 구현된 것일까? 무언가 더 개선할 수 있는 방법이 없을까?와 같은 의문이 들었다면, 분명 어딘가 결여된 점이 있다는 것을 인지하고 있다는 상태일 것이다. 이런 건강한 의문이 들었을 때 조금만 더 개선해보려는 노력을 한다면, 보다 더 효율적이고 클린한 코드가 구현되지 않을까라는 생각을 했다. 내 로직에 대해 의문이 든 채로 그대로 진행을 하면, 언젠가는 그 부분이 큰 부메랑이 되어 나에게 돌아온다는 것을 실감했다.

(물론 생각의 늪에 너무 깊게 빠져있거나, 완벽한 코드를 짜야한다는 강박은 좋지 않다!)

리스트 API의 쿼리 스트링을 텍스트가 아닌 ID (정수)로 받아야한다는 것을 뒤늦게 깨닫는 과정에서 많이 느꼈다.

🌴 상황은 예상대로 흘러가지 않는다. 언제나 변수를 생각하자

프로젝트 종료 3~4일 직전에 갑자기 팀원이 연락 두절이 되어 중도 하차를 하게 된 사상 초유의 사태가 발생했다.
그 결과, 우리 팀의 스케줄과 기능 구현에 엄청난 폭풍우가 내렸다.

상품 리스트 페이지는 원래 그 팀원이 담당하던 부분이였는데, 이어서 구현을 담당하게 되었다.

리스트 페이지 기획 당시, 상품 리스트 출력 외에 카테고리 및 펀딩 상태 필터링 기능, 인피니트 스크롤도 구현할 예정이었다.

하지만 프로젝트 약 13일 차에 건네받은 리스트 페이지의 상태는 1차 코드 리뷰 수정 조차 반영되지 않은 날 것의 상태였다...그 외에도 프로젝트 진행 등 팀원들과 소통하는 과정에서 언급되었던 피드백이 전혀 반영되지 않은 상태였다.

다른 기능 구현 중에 급하게 받은 리스트 페이지였고...
정말 아쉽지만 목표로 한 필터링과 인피니트 스크롤을 완성하지 못했다.

원래 기획은 리스트 API로부터 받아온 전체 상품 데이터에서 filter 메소드를 통해 단순히 가공을 하는 방식으로 필터링하지 않고, 필터 메뉴에서 각 항목을 클릭했을 때, API의 엔드 포인트에 해당 필터의 쿼리스트링를 전달하여 값을 불러오는 방식을 계획했었다. (위의 리스트 API파트 참조)

리스트 페이지를 위해 리스트 API를 열심히 만든 일주일을 생각하면, 아직도 속상한 마음이 크다. 그 외에도 사태를 개선하게 위해 포기하게 된 여러 기능들...

한편으로는 모든 것을 감당하기에 역량이 아직 부족했기 때문에 해냈지 못했다는 생각도 든다.
아쉽지만 이 부분은 추후 리팩토링을 꼭 진행하여 반드시 구현해낼 생각이다.

...

팀 프로젝트를 진행하며 흔히 있을 법한 팀원과의 트러블, 기능 구현에 대한 어려움 등과 같은 문제에 대해서는 미리 마음의 준비를 하고 있었다.

하지만 팀원이 도중 하차하게 되는 상황은 전혀 예상하지 못했다.
모든 일은 예상대로, 계획대로 진행되지 않는다는 점을 다시 한 번 느낄 수 있었다.

모든 경우의 수를 염두해두고 진행할 것.
만일의 사태를 대비하여 여유를 두고 진행할 것.
예상 외의 상황이 발생했을 때도 당황하지 않을 것.


💖 마치며

이번 프로젝트는 1차 때와는 달리, 팀 프로젝트 목적을 "학습"에 두고 진행한 덕분에 이전보다는 스트레스와 압박감을 줄일 수 있어서 다행이었다. 그래서 코드 퀄리티를 향상시키는 것에 집중하며 한땀 한땀 개발을 진행했다.

하지만 역시 속도도 고려해야하는 법. 구현을 다하지 못한 부분에 있어서는 아쉬움이 정말 크다. 무엇이든 밸런스가 참 중요하다.

이번 경험으로 나는 어느 정도의 긴장감과 적절한 압력이 원동력이 되어 움직이는 사람이라는 것을 느꼈다. 차후의 프로젝트에서는 온전히 "학습"이라는 것에 집중하기 보다는 역시 "학습 + 개인적인 목표"의 밸런스를 잘 잡아서 진행해야겠다는 생각을 했다.

그리고 우리 팀 뿐만 아니라, 타 팀이 직면한 문제 또한 적극적으로 해결하는 데 도움을 주고 노력한 점에 대해서는 칭찬을 해주고 싶다😌

무엇보다 이번 프로젝트는 3명이라는 적은 구성원으로 진행되어 우여곡절이 많았지만, 우리 팀을 서로를 지지해준 수호님, 미정님께 감사드린다는 말을 드리고 싶어요!

profile
Frontend Developer. 블로그 이사했어요 🚚 → https://iamhayoung.dev

2개의 댓글

comment-user-thumbnail
2021년 8월 13일

너무 멋져요 👋🏻 많이 배웠어요

1개의 답글