이 글을 지난 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로 테마 스위치 기능 구현하기
등을 다음 기술 과제로 삼았습니다.
좋은 글 감사합니다.