2차 프로젝트 회고록

류창선·2023년 10월 8일
8

front-end

목록 보기
36/38
post-thumbnail

이 글을 지난 9월 18일부터 10월 6일까지 진행된 미니 프로젝트의 과정을 아카이빙하기 위해 작성되었습니다.

1. 프로젝트 인트로

1.1. 목표

  • 이번 프로젝트의 목표는 제시된 여러 사이트 가운데 하나를 선택해 개발하는 것으로, 일종의 클론 프로젝트였습니다.
  • 단순하게 웹 사이트를 구현하는 것이 아니라, PET(Product, End-User, Tech)의 세 관점에서 선택한 서비스를 분석해야 했습니다.
  • 예를 들어, 회원가입 단계에서 성별과 주소 등이 없는 까닭에 대해 회원가입 과정을 최대한 간소화하기 위해, 남녀노소 관련 정보과 고기 구매와는 무관하기 때문이라는 팀 단위 논의를 거쳐 타겟 사이트를 분석했습니다.
  • 제가 속한 팀은 갓 잡은 신선한 육류를 배송하는 서비스에 전념하고 있는 정육각을 선택했습니다.

1.2. 협업

  • Agile 방법론을 도입해 매일 오전 10시부터 데일리 스탠드업 미팅(Daily Standup Meeting)을 진행했습니다. 구체적으로 어제 한 일과 오늘 할 일, 그리고 블로커를 공유하므로써 프론트엔드와 백엔드 간의 의사소통을 이어나갔습니다.
  • 이번 프로젝트에서는 프로젝트 매니저(Project Manager) 역할을 수행하였기 때문에 매일 회의록을 작성하고, 일정을 챙기기 위한 노력을 기울였습니다.
  • 더불어 프론트엔드 리더(Front-End Leader) 역할도 함께 맡아 아래와 같이 다른 작업자의 PR 리뷰를 진행했습니다.
  • 아래 README.md는 실제 사용한 프로젝트 컨벤션으로 프로젝트 시작 전에 팀원들에게 공유한 자료 가운데 일부를 발췌한 것입니다.
// import 순서는 아래와 같이 정렬합니다.
import React from 'react';                 // 1. React + hook
import Button from 'url';                  // 2. Components
import './Button.scss'                     // 3. Scss

// 변수와 함수의 이름은 camelCase를 따릅니다.
const userInfo;
const submitComment = () => {
	...
}

// 상수는 UPPER_SNAKE_CASE를 따릅니다.
const USER_DATA;

// 변수와 조합해 문자열을 생성하는 경우에는 ES6 템플릿 리터럴을 사용합니다.
const message = `hello, ${name}!`;         // good
const message = 'hello' + name + "!";      // bad
  • 협업의 결과물입니다. 메인, 상품 목록, 상품 상세, 로그인, 회원가입, 마이페이지, 장바구니, 주문, 결제 등 MVP(Minimum Viable Product, 최소 기능 제품) 모델을 선정했고, 아래의 사진은 클론 프로젝트의 메인 화면입니다.

2. 기술 스택 및 사용기(Front-End)

2.1. React

2.1.1. Component

  • 1차 프로젝트와 마찬가지로 공용 컴포넌트를 만들어 소스 코드 리사이클에 힘썼습니다.

2.1.1.1 Radio

  • 여러 컴포넌트 가운데 두 가지 정도만 살펴보겠습니다. RadioRadioGroup 중 먼저 자식 컴포넌트인 Radio입니다.
const Radio = props => {
  // props
  // name: [String]
  // value: [String]
  // text: [String]
  // defaultChecked: [String]
  const {
    type = 'radio',
    className = 'radio',
    name,
    value,
    text,
    defaultChecked,
  } = props;

  return (
    <label className="label">
      <input
        type={type}
        className={className}
        name={name}
        value={value}
        defaultChecked={defaultChecked}
        tabIndex={0}
      />
      <span>{text}</span>
    </label>
  );
};
  • Radio의 props로 type, className, name, value, text, defaultChecked를 정했습니다.
  • 기본적으로 Radio는 여러 개의 Radio 중에서 하나만 선택됩니다. 그리고 공통의 묶음 처리를 name으로 하며, 각각의 값을 value로 구분합니다. 또한 초기값(여기서는 defalutCheck)이 미리 선택되어 있습니다. 그리고 웹 접근성 차원에서 탭 이동 시 Radio에 접근할 수 있게 tabIndex를 추가했습니다.
2.1.1.2. RadioGroup
  • RadioGroup 컴포넌트는 Radio 컴포넌트의 묶음 컴포넌트입니다. 따라서 두 컴포넌트 사이에는 부모 자식 관계가 성립됩니다.
import Radio from '../Radio/Radio';

const RadioGroup = props => {
  // props
  // name: [String]
  // value: [String]
  // text: [String]
  // defaultChecked: [String]
  const { name, data } = props;

  return (
    <div className="radio-group">
      {data.map(item => {
        return (
          <Radio
            key={item.id}
            name={name}
            value={item.value}
            text={item.text}
            defaultChecked={item.defaultChecked}
          />
        );
      })}
    </div>
  );
};
  • 하나의 RadioGroup 안에 여러 Radio가 속할 수 있습니다. 그래서 데이터 처리가 반드시 필요합니다. 실제 RadioGroup를 사용하는 곳에서 전달받은 배열 타입 데이터를 map 메서드로 받아 출력하면서 다른 key, value, text 등을 처리할 수 있어야 합니다.
  • 아래는 실제 RadioGroup를 사용할 때의 예시입니다. data와 name을 전달하는 방식으로 컴포넌트를 구성합니다. 아래 예시에서는 data를 상수 데이터로 넣고 있습니다.
import RadioGroup from '../../components/RadioGroup/RadioGroup';
import DELIVERY_DATA from '../../data/deliveryData';

const Main = props => {
  return (
    <main id="main" className="main">
      <RadioGroup data={DELIVERY_DATA} name="delivery" />
    </main>
  );
};
  • 실제 렌더링되는 소스 코드는 아래와 같습니다.

2.1.2. Skip Navigation & Top Button

  • 타겟 서비스를 분석하는 과정에서 몇 가지 문제점을 발견했습니다. 웹 사이트를 이용함에 있어서 차별이 없어야 한다는 팀 버너스리의 주장과는 달리 장애인을 배려하는 소스 코드를 찾을 수 없었습니다. 그렇기에 Router.jsSkipNavigation 컴포넌트를 최상단에 선언하여 모든 페이지에서 반복 노출되는 HeaderGNB 등을 건너 뛰어넘을 수 있게 했습니다.
const SkipNavigation = () => {
  return (
    <section className="skip-navigation">
      <ul>
        <li>
          <a href="#main">본문 바로가기</a>
        </li>
        <li>
          <a href="#menu-list">메뉴 바로가기</a>
        </li>
      </ul>
    </section>
  );
};
  • TopButton을 추가한 이유도 위와 크게 다르지 않습니다. 뷰포트 스크롤이 길어지면 길어질수록 최상단으로 이동하는 일은 노동에 가깝게 느껴집니다.
const TopButton = () => {
  const goToTop = () => {
    window.scroll({
      top: 0,
      // behavior: 'smooth',
    });
  };

  return (
    <button
      className="top-btn"
      type="button"
      aria-label="화면 최상단 이동"
      onClick={goToTop}
    >
      <span>Top</span>
    </button>
  );
};

2.1.3. React Scroll

  • 기왕 스크롤을 언급한 김에 조금 더 이야기를 이어보겠습니다. SPA인 React는 라우팅 시에 전 라우터의 스크롤을 기억하고 있었습니다. 이로 인해 유저로 하여금 불필요한 스크롤 조작을 강제하게 합니다. 이 불편함을 해소하기 위하여 라우터 이동 시 스크롤을 초기화하는 방법을 찾았습니다. 아래는 이번 프로젝트에 적용된 소스 코드입니다.
// InitializeScroll.js
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export default function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}
// Router.js
return (
    <BrowserRouter>
      <InitializeScroll />
  	 	 <Routes>
           <Route
             path="/"
             element={
               <Main />
             }
             />
           <Route
             path="/list"
             element={<List />}
             />
           <Route
             path="/detail/:id"
             element={<Detail />}
             />
           ...
    	</Routes>
    </BrowserRouter>
	);
  • useEffect의 의존성 배열에 Router pathname을 구독하고, 그 값이 변경되면 윈도우 스크롤을 최상단으로 이동하게 하는 로직입니다.

2.1.4. Lifting State Up

  • React props drilling은 기본 개념입니다. 그 내용을 요약하면 props를 하위 컴포넌트로 전달하는 하는 것이 전부입니다.
  • 그러나 개발 중에 상품을 장바구니에 담기 위해 반드시 필요한 것이 있었습니다. 그것은 바로 장바구니에 몇 가지 상품이 담겼는지, 그리고 담기고 있는지를 확인할 수 있는 UI 구현입니다.
  • 전역 상태 관리에 대한 개념이 없는 이번 프로젝트에서 선택할 수 있는 방법은 둘 중 하나였습니다. 첫째는 useContext hook 사용이었고, 둘째는 State 끌어올리기였습니다.
  • 제가 후자를 선택한 까닭은 오로지 시간 때문이었습니다.
  • 상품 상세 화면의 장바구니 버튼을 눌러 갯수를 바꿔야 하는 상황에서 아래와 같은 작업이 이뤄졌습니다. 자세한 내용은 주석을 참고해 주세요.
// Router.js
const Router = () => {
 // 1. 상품 갯수를 반영하는 useState를 선언합니다.
 const [quantity, setQuantity] = useState('');
  
  // 2. state를 끌어올릴 함수를 만들고, 기존 값이 신규 값을 더하여 setter 함수로 전달합니다.
  const getQuantity = num => {
    const changedInt = Number(quantity);
    setQuantity(changedInt + num);
  };
  
   return (
    <BrowserRouter>
       <Routes>
          // 3. 상품 갯수 반영을 위한 곳에 함수와 useState의 변수를 추가합니다.
          <Route
          path="/detail/:id"
          element={<Detail getQuantity={getQuantity} quantity={quantity} />}
        />
       </Routes>
    </BrowserRouter>
  )
}
// Detail.js
const Detail = props => {
  const setQuantity = () => {
    // 4. 장바구니 버튼 클릭 시 실행할 함수 안에 setter 함수를 품은 함수를 호출합니다.
    getQuantity(count);
    fetch(`${API.CART}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        authorization: localStorage.getItem('accessToken'),
      },
      body: JSON.stringify({
        productId: productId,
        quantity: count,
      }),
    })
      .then()
      .then();
  };
  
  return (
    <Button name="장바구니" onClick={setQuantity} />
  )
}
  • props 끌어올리기에 대한 설명이 다소 장황해졌습니다. 골자는 데이터를 상위로 직접 전달하는 것이 아니라, state 갱신 함수를 전달받아 해당 함수를 실행시켜 상위 컴포넌트의 데이터를 갱신하는 것이 핵심이라고 할 수 있습니다.

2.1.5. useNavigate() & useLocation()

  • 이 프로젝트를 통해 위에서 아래로, 아래에서 위로 데이터를 전달하는 로직과는 다른 로직을 경험할 수 있었습니다.
  • 제품을 장바구니에 저장해놨다가 추후에 결제하는 것과 달리 바로구매는 라우팅 시 데이터를 가지고 이동해야 합니다. 이것을 가능케 하는 것이 useNavigate()useLocation()의 활용입니다.
// Detail.js
const navigate = useNavigate();

const productData = {
  productId: productId,
  quantity: count,
};

 navigate('/cart', {
   state: productData,
 });
// Cart.js
const location = useLocation();

if (location.state !== null) {
  const { productId, quantity } = location.state;
}
  • BE와 협의된 데이터를 제품 상세에서 장바구니로 전달하는 로직의 요약된 부분입니다. Detail.js에서 전달한 데이터를 묶어 navigatestate에 담아 보냅니다. Cart.js에서는 location.state를 불러와 값을 꺼내 사용하면 됩니다.

2.1.6. Global config

  • 전 프로젝트에서는 매번 바뀌는 백엔드의 ip 주소를 반영해야 하는 불편함이 종종 있었습니다. 그래서 BASE_URL을 세팅하여 데이터 fetch 시 수정 시간을 단축했습니다.
// config.js
// 1. BASE_URL에 담긴 API 주소만 변경하여 연동을 위한 준비 과정을 끝냅니다.
const BASE_URL = 'http://10.58.52.159:8000';

export const API = {
  SIGNUP: `${BASE_URL}/users/signup`,
  CHECK_DUPLICATE: `${BASE_URL}/users/checkduplicate`,
  LOGIN: `${BASE_URL}/users/login`,
  LIST: `${BASE_URL}/list`,
  DETAIL: `${BASE_URL}/list/detail`,
  REVIEW: `${BASE_URL}/review`,
  CART: `${BASE_URL}/cart`,
  CHARGE: `${BASE_URL}/payment/topupcredit`,
  PAYMENT: `${BASE_URL}/payment`,
  USER: `${BASE_URL}/users`,
  ORDER: `${BASE_URL}/order`,
  PAY: `${BASE_URL}/payment/complete`,
};

2.1.7. Pagination & Query string

  • PaginationQuery string은 이 클론 프로젝트의 주요한 개발 스펙이었습니다. 그래서 이 부분은 길어질언정 전체 소스 코드를 올립니다. OrderDetail.js는 주문내역 목록 화면이며, 이곳에 Pagination 컴포넌트를 import 하여 개발하였습니다.
  • 주석은 순수하게 제 실력과 관점에서 로직 프로세스를 정리한 것입니다.
// OrderDetail.js
import React, { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import Button from '../../../components/Button/Button';
import Pagination from '../Pagination/Pagination';
import './OrderDetails.scss';

const OrderDetails = () => {
  // [ 페이지네이션: 전체 데이터를 페이지별로 분리해서 보여주는 UI ]
  // 1. 쿼리 스트링의 원하는 값만 받아오기 위한 hook 함수를 선언합니다.
  const [searchParams, setSearchParams] = useSearchParams();

  // 3. API 호출 후 데이터를 저장할 state를 생성합니다.
  const [dataList, setDataList] = useState([]);

  // 4. 현재 페이지를 추적하고, 적용할 수 있게 hook 함수를 선언합니다.
  const [page, setPage] = useState(1);

  // 2. 페이지의 첫 컨텐트 위치(offset)와 페이지당 컨텐트 수(limit)의 값을 searchParams hook에서 가져와 각각 변수에 저장합니다. limit가 let인 까닭은 변할 수 있기 때문입니다.
  const offset = searchParams.get('offset');
  let limit = searchParams.get('limit');

  // 5. API 호출 후 변경된 데이터를 받아 쿼리 스트링에 offset을 반영하는 함수를 생성합니다. limit는 10으로 설정하여 패이지당 10개의 컨텐트를 보여줍니다.
  const setPaginationParams = () => {
    limit = 10;
    searchParams.set('offset', (page - 1) * limit);
    searchParams.set('limit', limit);
    setSearchParams(searchParams);
  };

  useEffect(() => {
    // 6. API 호출할 때, 쿼리 스트링에 각 값을 담아 요청합니다. (BE와 규격 협의 필요)
    fetch(`/data/orderMock.json?offset=${offset}&limit=${limit || 10}`)
      .then(response => response.json())
      .then(data => {
        // 7. 쿼리 스트링에 offset과 limit을 업데이트할 함수를 호출합니다.
        setPaginationParams();
        // 8. 받은 데이터를 역순으로 저장합니다.
        setDataList(data.reverse());
      });
    // 6~8번 과정이 page가 변할 때마다 이뤄져야 하므로, page를 구독하여 리렌더링을 준비합니다.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [page]);

  // 9. 전체 페이지 수를 계산합니다.
  const totalPages = Math.ceil(dataList.length / 10);

  return (
    <div className="order-details">
      {dataList?.length > 0 ? (
        <>
          <ol className="order-list">
            {dataList?.map((item, index) => {
              return (
                <li key={index}>
                  <Link to="/">
                    <span className="order">{item.order}</span>
                    <span className="order-number">
                      <span>주문 번호</span>
                      <em>{item.order_number}</em>
                    </span>
                    <span className="order-summary">
                      <span>주문 요약</span>
                      <em>{item.order_summary}</em>
                    </span>
                    <span className="order-price">
                      <span>예상 결제 금액</span>
                      <em>{item.order_price}</em>
                    </span>
                    <span className="order-date">
                      <span>도착 희망일</span>
                      <em>{item.order_date}</em>
                    </span>
                  </Link>
                </li>
              );
            })}
          </ol>

          {/* 
            10. Pagination 컴포넌트에 props 셋을 전달합니다. (11번 주석은 Pagination.js를 참고합니다.)
            - totalPages: 전체 페이지
            - page: 현재 페이지
            - setPage: 페이지 변경을 위한 setter 함수
          */}
          <Pagination totalPages={totalPages} page={page} setPage={setPage} />
        </>
      ) : (
        <>
          <span className="no-order">주문한 내역이 없습니다.</span>
          <Button name="쇼핑하러가기" />
        </>
      )}
    </div>
  );
};

export default OrderDetails;
import React from 'react';
import './Pagination.scss';

const Pagination = props => {
  const { totalPages, page, setPage } = props;
  // 11. 부모 컴포넌트에서 전달받은 totalPages만큼의 버튼을 생성합니다.
  const makePaginationButtons = totalPages => {
    let arr = [];
    for (let i = 0; i < totalPages; i++) {
      arr.push(
        <button
          type="button"
          key={i + 1}
          onClick={() => setPage(i + 1)}
          className={page - 1 === i ? 'selected' : ''}
        >
          {i + 1}
        </button>,
      );
    }
    return arr;
  };

  return (
    <div className="pagination">
      <button
        type="button"
        className="first direction"
        onClick={() => setPage(1)}
        disabled={page === 1}
      >
        first
      </button>
      <button
        type="button"
        className="prev direction"
        onClick={() => setPage(page - 1)}
        disabled={page === 1}
      >
        prev
      </button>
      <div className="pagination-number">
        {makePaginationButtons(totalPages)}
      </div>
      <button
        type="button"
        className="next direction"
        onClick={() => setPage(page + 1)}
        disabled={page === totalPages}
      >
        next
      </button>
      <button
        type="button"
        className="last direction"
        onClick={() => setPage(totalPages)}
        disabled={page === totalPages}
      >
        last
      </button>
    </div>
  );
};

export default Pagination;

2.1.8. Life Cycle & useEffect()

  • React의 life Cycle은 크게 따지면 셋으로 나눌 수 있습니다. 물론 세부적으로 따지고 들면 여럿이지만, 여기에 적자면 매우 길어질 것이므로 추후 포스팅에서 이 주제를 다루는 것으로 건너뛰겠습니다.
  • 컴포넌트의 생명 주기는 생성(mount) - 업데이트(update) - 제거(unmount)로 이뤄지며, 이 프로젝트에서는 ummount 시점을 이해하는 것이 중요했습니다.
  • 백엔드에서 요청한 요구 사항은 다음과 같았습니다.

    장바구니에서 이탈 시에 최종 데이터를 서버에 전달해 주세요.

  • 그래서 생명 주기와 함께 useEffect()를 활용하고자 했습니다. 그 결과물은 아래와 같습니다.
useEffect(() => {
  return () => {
    patchCartInfo();
  };
});
  • 장바구니 페이지에서 이탈 시 useEffect()return 뒤에 최종 결과값을 서버에 전달하는 함수를 호출합니다. 이렇게 되면 컴포넌트가 화면에서 사라지기 직전에 정상적으로 서버에 데이터를 보내면서 자신의 역할을 다하게 됩니다.

2.1.9. Etc

  • 이외에도 다양한 기능을 구현했습니다. 그 가운데 몇 가지만 언급하겠습니다.
  • 리뷰 작성 모달 팝업 내에서 이미지 업로드 기능미리보기 기능을 구현했습니다. 다만, 이미지 서버 미구현에 따라 정상적인 업로드 과정을 확인할 수 없었고, 다중 이미지 업로드까지 구현하지 못했습니다.
  • input의 유효성 검증 기능을 실시간으로 확인할 수 있게 디벨롭했습니다.
  • swiper.js 라이브러리로 슬라이딩 배너를 구현했습니다. 다만, 과거 버전과는 달리 Autoplay, Navigation, Pagination module을 따로 불러와야 하는 식으로 변해 그 부분에서 러닝 커브가 있었습니다.

2.2. Sass

  • 1차 프로젝트에서 사용한 것과 크게 달라진 점은 없었습니다. 그래서 추가된 내용 중에서 웹 접근성을 고려한 텍스트 감춤 스타일만 적고자 합니다.
  • 참고로 Safari 이슈로 인해 caption 태그의 position: static 처리가 추가되어야 합니다.
.a11y-hidden {
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  position: absolute;
  width: 1px;
  height: 1px;
}

2.3. Git & Github

  • 중구난방의 PR과 Commit 메시지가 혼란을 야기했습니다. 반드시 정리가 필요했습니다. 그래서 규칙을 만들어 함께 지켜나가자 건의했습니다. 그 내용은 아래와 같습니다.
// 브랜치 이름은 기능 및 컴포넌트별로 명명합니다.
feature/submit
component/button

// 긴급한 오류를 수정하기 위해 아래와 같은 브랜치를 생성할 수도 있습니다.
hotfix

// PR은 하나의 기능 개발 완료 시 진행합니다. 여러 commit이 쌓여서 하나의 PR이 완성됩니다. 즉 commit은 PR에 대한 상세 개발 내역입니다.
- PR: 로그인 화면 개발
- commit: 인풋 컴포넌트 개발 / 버튼 컴포넌트 개발 / 유효성 검사 기능 추가 등

// commit 메시지는 아래와 같이 나눠 작성합니다.
[feat] 제목          // 기능 추가
[fix] 제목           // 버그 수정
[refact] 제목        // 리팩토링
[style] 제목         // UI 수정

3. 프로젝트 아웃트로

프론트엔드 개발자는 소스 코드를 작성하는 것에 귀찮아 해야 합니다.

  • 문맥을 읽지 않으면 무슨 소리인가 싶습니다. 저는 효율적인 소스 코드 작성에 힘쓰라는 이야기로 받아 들였고, 이 프로젝트에서 가장 많이 생각났던 말이 이것이었기에 아웃트로의 도입에서 프론트엔드 멘토님의 말씀을 인용합니다.
  • 느낀 바가 많은 프로젝트였습니다. 의사소통의 중요성을 실감하게 되었습니다. 시간과 공간을 공유하는 회의 때에도 한 용어를 다르게 쓰면서 보이지 않는 거리가 생겨나는 것을 뒤늦게 깨달았습니다.
  • 쉽지 않은 난도의 프로젝트라고 생각합니다. 2인의 개발자를 이끌어 개발을 진행하는 지난 3주의 시간 동안에 수많은 좌절을 맛보았기 때문입니다. 현재의 실력은 서로 크게 다르지 않다고 생각합니다만, 문제의 핵심은 성실함에 있었습니다. 일정을 맞추기 위해 협의된 과제를 완료한 이는 찾아볼 수 없었고, 약속된 데일리 미팅 시간을 지키는 경우를 찾기 힘든 이도 있었습니다. 어떻게 하면 사람들을 잘 이끌 수 있을까요? 이것은 개인 과제로 남겨 오래도록 곱씹어야겠습니다.
  • API 문서가 데드라인 하루 전에 전달되는 이해할 수 없는 프로젝트였습니다. 결국 소스 코드 프리징 기간이나 자체 QA 기간을 확보할 수 없어서 마감 순간까지 불안했던 기억이 여전히 남아 있습니다.
  • Vite로 React 초기 세팅하기, Redux로 전역 상태 관리하기, Portal로 Modal 컴포넌트 구현하기, Theme Provider로 테마 스위치 기능 구현하기 등을 다음 기술 과제로 삼았습니다.
  • 끝으로 3주 간 프로젝트에서 작성된 PR 갯수를 스샷으로 남기면서 회고를 마무리합니다.
profile
Front-End Developer

1개의 댓글

comment-user-thumbnail
2023년 10월 9일

좋은 글 감사합니다.

답글 달기