이 글을 지난 9월 18일부터 10월 6일까지 진행된 미니 프로젝트의 과정을 아카이빙하기 위해 작성되었습니다.
클론 프로젝트였습니다.PET(Product, End-User, Tech)의 세 관점에서 선택한 서비스를 분석해야 했습니다.데일리 스탠드업 미팅(Daily Standup Meeting)을 진행했습니다. 구체적으로 어제 한 일과 오늘 할 일, 그리고 블로커를 공유하므로써 프론트엔드와 백엔드 간의 의사소통을 이어나갔습니다.프로젝트 매니저(Project Manager) 역할을 수행하였기 때문에 매일 회의록을 작성하고, 일정을 챙기기 위한 노력을 기울였습니다.프론트엔드 리더(Front-End Leader) 역할도 함께 맡아 아래와 같이 다른 작업자의 PR 리뷰를 진행했습니다.
// 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


Radio와 RadioGroup 중 먼저 자식 컴포넌트인 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를 추가했습니다.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>
);
};

팀 버너스리의 주장과는 달리 장애인을 배려하는 소스 코드를 찾을 수 없었습니다. 그렇기에 Router.js에 SkipNavigation 컴포넌트를 최상단에 선언하여 모든 페이지에서 반복 노출되는 Header와 GNB 등을 건너 뛰어넘을 수 있게 했습니다.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>
);
};
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>
);
React props drilling은 기본 개념입니다. 그 내용을 요약하면 props를 하위 컴포넌트로 전달하는 하는 것이 전부입니다.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} />
)
}
state 갱신 함수를 전달받아 해당 함수를 실행시켜 상위 컴포넌트의 데이터를 갱신하는 것이 핵심이라고 할 수 있습니다.바로구매는 라우팅 시 데이터를 가지고 이동해야 합니다. 이것을 가능케 하는 것이 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;
}
Detail.js에서 전달한 데이터를 묶어 navigate 시 state에 담아 보냅니다. Cart.js에서는 location.state를 불러와 값을 꺼내 사용하면 됩니다.// 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`,
};
Pagination과 Query 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;
life Cycle은 크게 따지면 셋으로 나눌 수 있습니다. 물론 세부적으로 따지고 들면 여럿이지만, 여기에 적자면 매우 길어질 것이므로 추후 포스팅에서 이 주제를 다루는 것으로 건너뛰겠습니다.ummount 시점을 이해하는 것이 중요했습니다.장바구니에서 이탈 시에 최종 데이터를 서버에 전달해 주세요.
useEffect()를 활용하고자 했습니다. 그 결과물은 아래와 같습니다.useEffect(() => {
return () => {
patchCartInfo();
};
});
useEffect()의 return 뒤에 최종 결과값을 서버에 전달하는 함수를 호출합니다. 이렇게 되면 컴포넌트가 화면에서 사라지기 직전에 정상적으로 서버에 데이터를 보내면서 자신의 역할을 다하게 됩니다.이미지 업로드 기능과 미리보기 기능을 구현했습니다. 다만, 이미지 서버 미구현에 따라 정상적인 업로드 과정을 확인할 수 없었고, 다중 이미지 업로드까지 구현하지 못했습니다.input의 유효성 검증 기능을 실시간으로 확인할 수 있게 디벨롭했습니다. swiper.js 라이브러리로 슬라이딩 배너를 구현했습니다. 다만, 과거 버전과는 달리 Autoplay, Navigation, Pagination module을 따로 불러와야 하는 식으로 변해 그 부분에서 러닝 커브가 있었습니다.Safari 이슈로 인해 caption 태그의 position: static 처리가 추가되어야 합니다..a11y-hidden {
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
position: absolute;
width: 1px;
height: 1px;
}
// 브랜치 이름은 기능 및 컴포넌트별로 명명합니다.
feature/submit
component/button
// 긴급한 오류를 수정하기 위해 아래와 같은 브랜치를 생성할 수도 있습니다.
hotfix
// PR은 하나의 기능 개발 완료 시 진행합니다. 여러 commit이 쌓여서 하나의 PR이 완성됩니다. 즉 commit은 PR에 대한 상세 개발 내역입니다.
- PR: 로그인 화면 개발
- commit: 인풋 컴포넌트 개발 / 버튼 컴포넌트 개발 / 유효성 검사 기능 추가 등
// commit 메시지는 아래와 같이 나눠 작성합니다.
[feat] 제목 // 기능 추가
[fix] 제목 // 버그 수정
[refact] 제목 // 리팩토링
[style] 제목 // UI 수정
프론트엔드 개발자는 소스 코드를 작성하는 것에 귀찮아 해야 합니다.
어떻게 하면 사람들을 잘 이끌 수 있을까요? 이것은 개인 과제로 남겨 오래도록 곱씹어야겠습니다.Vite로 React 초기 세팅하기, Redux로 전역 상태 관리하기, Portal로 Modal 컴포넌트 구현하기, Theme Provider로 테마 스위치 기능 구현하기 등을 다음 기술 과제로 삼았습니다.
좋은 글 감사합니다.