- 주제 : "내가 살만한 동네는 어디일까?" 라는 주제로 시작된 동네 추천 웹 애플리케이션 프로젝트
- 개발 기간 : 2023.10.02 - 2023.10.17
- 배포 URL : [과금으로 인한 배포 중단]
Front
Back, Database
Deploy
- 저장소 복제 (Clone Repository)
$ git clone https://github.com/beomgye/where-is-my-hometown.git
- 종속성 설치 (Install Dependencies)
$ npm install
- 애플리케이션 실행 (Run Application)
$ npm run dev
src
┣ components
┃ ┣ common
┃ ┃ ┣ Address
┃ ┃ ┣ Button
┃ ┃ ┣ Container
┃ ┃ ┣ Form
┃ ┃ ┣ Header
┃ ┃ ┣ InputField
┃ ┃ ┣ Loading
┃ ┃ ┣ NavBar
┃ ┃ ┗ Radio
┃ ┣ screens
┃ ┃ ┣ AssetInputForm
┃ ┃ ┣ BuildingTypeForm
┃ ┃ ┣ LocationForm
┃ ┃ ┣ SelectInfo
┃ ┃ ┣ SplashScreen
┃ ┃ ┣ SummaryForm
┃ ┃ ┗ TransactionTypeForm
┃ ┗ index.js
┣ constants
┃ ┗ index.js
┣ containers
┃ ┗ OptionContainer
┣ hooks
┃ ┣ useFindMyHome.js
┃ ┗ useStepControl.js
┣ pages
┃ ┣ api
┃ ┃ ┗ hello.js
┃ ┣ _app.jsx
┃ ┣ _document.jsx
┃ ┗ index.jsx
┣ styles
┃ ┣ global.js
┃ ┗ variables.js
┗ utils
┣ customStyles.js
┗ formatMoney.js
초기 로딩화면 |
---|
자산 입력 단계 |
---|
지도선택 단계 |
---|
거래형식 및 결과화면 |
---|
AI가 추천해주는 결과 |
---|
각 페이지 별 여러개의 form을 하나로 합쳐 불필요한 렌더링 문제를 막고자하여 이에 관한 방법을 찾던 중, react-hook-form을 사용하여 여러 개의 form을 하나로 컨트롤 하여 불필요한 렌더링 문제를 해결하였다.
useForm을 사용해 하나의 form을 만들어주고, 이에 사용할 수 있는 control, watch, handleSubmit, reset을 컴포넌트에 전달하여 하나의 form을 공유할 수 있도록 제작하였다.
// OptionContainer.jsx
import { useForm } from 'react-hook-form';
const OptionContainer = () => {
const { control, watch, handleSubmit, reset } = useForm({
defaultValues: {
assets: '',
location: '주소를 입력해주세요.'
}
});
return (
<>
{isLoading && <Loading />}
{step === 0 && <AssetInputForm control={control} onSubmit={handleSubmit(onSubmit)} />}
{step === 1 && (
<LocationForm
control={control}
setBcode={setBcode}
onSubmit={handleSubmit(onSubmit)}
onGoBack={decreaseStep}
/>
)}
...
{step === 5 && result ? <SelectInfo townList={result} onRefreshButton={onReset} /> : ''}
</>
);
};
export default OptionContainer;
자산 입력 창에서 react-hook-form에 입력값을 전달하는 코드
// AssetInputForm.jsx
const AssetInputForm = ({ control, ...props }) => {
return (
<Form
title="자산 입력"
description="갖고 있는 자산을 입력해 주세요."
navbarProps={{
current: 0,
stepOptions: StepOptions
}}
buttonText="다음 단계"
{...props}
>
<Container>
<Controller
name="assets"
control={control}
rules={defaultInputRule}
render={({ field, fieldState: { error } }) => (
<InputField
id="assets"
label="자산"
placeholder="자산을 입력해 주세요."
error={error?.message}
ref={field.ref}
value={formatMoney(field.value)}
onChange={(newValue) => {
field.onChange(newValue);
}}
{...field}
/>
)}
/>
</Container>
</Form>
);
};
react-hook-form 을 사용하여 단계별로 옵션을 선택하면 다음과 같이 하나의 form에 담긴 모습을 볼 수 있었다.
기존 Kakao Maps API를 React에 맞게 포멧팅한 라이브러리를 사용하였다. 라이브러리를 사용하기 위해선 필수적으로 Kakao Maps API를 불러와야 한다. API를 보호하기 위해 env를 사용하였다.
const API = process.env.NEXT_PUBLIC_KAKAO_APP_JS_KEY;
const KAKAO_SDK_URL = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${API}&libraries=services,clusterer&autoload=false`;
Kakao 지도 Javascript API 는 지도와 함께 사용할 수 있는 라이브러리
를 지원하고 있다. 라이브러리는 javascript API와 관련되어 있지만 특화된 기능을 묶어둔 것을 말한다.
clusterer
: 마커를 클러스터링 할 수 있는 클러스터러 라이브러리 이다.services
: 장소 검색과 주소-좌표변환을 할 수 있는services
라이브러리 이다.autoload=false
: 동적 로드로 사용할 수 있게 변환해주는파라미터
이다.
Daum 우편번호 서비스를 활용하여 원하는 주소를 검색할 수 있게 만들었으며, 검색 결과값을 통해 React-kakao-Maps SDK
에 위치값 좌표를 나타내고, 백엔드에 전달할 법정동 코드를 전달해준다.
completeHandler
는 Daum 우편번호 서비스를 검색이 끝냈을 때 사용자가 선택한 정보를 받아올 콜백 함수 핸들러이다.react-daum-postcode
라이브러리를 이용하여 간편하게 사용하였다.address, changeAddress, setBcode, error
를 넘겨준 이유는LocationForm
에 전달하여react-hook-form
에 기본값을 전달하기 위해 사용하였다.
const Address = ({ address, changeAddress, setBcode, error }) => {
const [center, setCenter] = useState({ lat: 33.452613, lng: 126.570888 });
const [isModalOpen, isSetModalOpen] = useState(false);
const [isInfoOpen, setIsInfoOpen] = useState(false);
const completeHandler = (data) => {
changeAddress(data.roadAddress);
setBcode(data.bcode);
isSetModalOpen(false);
if (changeAddress) {
changeAddress(data.roadAddress);
}
};
return(
<DaumPostcode style={{ height: '100%' }} onComplete={completeHandler} />
)
kakaoMapGeoCoder
는 주소-변환 객체를 생성해주는 역할을 해준다.- useEffect는 사이드 이펙트 변화를 감지하기 위하여 사용하였고, 의존성 배열에 있는 address 값이 변경되면 실행되게끔 사용하였다.
const kakaoMapGeoCoder = () => {
window.kakao.maps.load(() => {
// 주소-좌표 변환 객체를 생성합니다
const geocoder = new window.kakao.maps.services.Geocoder();
// 주소로 좌표를 검색합니다
geocoder.addressSearch(address, function (result, status) {
// 정상적으로 검색이 완료됐으면
if (status === window.kakao.maps.services.Status.OK) {
setCenter({
lat: Number(result[0].y),
lng: Number(result[0].x)
});
}
});
});
};
useEffect(() => {
kakaoMapGeoCoder();
}, [address]);
- react-kakao-maps-sdk 공식 문서를 참조하여 쉽게 사용할 수 있다.
- Modal 컴포넌트는 react-modal 라이브러리를 사용하여 DaumPostCode를 띄우게 사용하였다.
return (
<>
<KakaoMap center={center} isPanto level={3}>
<MapMarker position={center} clickable onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
{isInfoOpen && <InfoWindow>{address}</InfoWindow>}
</MapMarker>
</KakaoMap>
<AddressContainer>
<InputField
id="location"
type="text"
onClick={openModal}
error={error?.message}
value={address}
readOnly
/>
<AddressButton onClick={openModal}>장소 선택</AddressButton>
</AddressContainer>
<Modal isOpen={isModalOpen} ariaHideApp={false} style={ModalCustomStyles}>
<DaumPostcode style={{ height: '100%' }} onComplete={completeHandler} />
<CloseButtonWrapper>
<CloseButton onClick={closeModal}>닫기</CloseButton>
</CloseButtonWrapper>
</Modal>
</>
);
};
stepOptions
배열에 있는 단계 정보를 렌더링하고, 페이지를 이동할 때마다 현재 활성화된 단계에 "active" 클래스를 부여하여 강조해준다.
const NavBar = ({ navbarProps: { current, stepOptions }, text, ...props }) => {
return (
<StyledNavBar {...props}>
<ul className="stepContainer">
{stepOptions &&
stepOptions.map((step, index) => (
<li className={current === index ? 'active' : ''} key={step.id}>
<div className="circle">{index + 1}</div>
<div className="stepDetails">
<div className="stepText">
Step
{index + 1}
</div>
<div className="stepName">{step.value}</div>
</div>
</li>
))}
</ul>
</StyledNavBar>
);
};
앞서 form에서 선택한 데이터(자산, 위치, 거래자산, 건물유형)들을 가져와 사용자들에게 보여주는 컴포넌트 이다.
-
거래 유형 및 건물 유형 가져오는 함수
-
주어진 id를 사용하여
TransactionTypeOptions
,BuildingTypeOptions
배열에서 해당 거래, 건물 유형 이름을 찾아와 반환해 줬다.const SummaryForm = ({ watch, ...props }) => { const getTransactionTypeName = (id) => { const transactionType = TransactionTypeOptions.find((type) => type.id === id); return transactionType ? transactionType.value : ''; }; const getBuildingTypeName = (id) => { const buildingType = BuildingTypeOptions.find((type) => type.id === id); return buildingType ? buildingType.value : ''; };
-
정보를 요약해서 사용자에게 표시한 코드
return ( <Form title="마무리 단계" description="총 마무리 단계 입니다." navbarProps={{ current: 4, stepOptions: StepOptions }} buttonText="확인" goBackButton refreshButton {...props} > <Container> <StyledSummaryForm> <TotalContainer> <Asset> <AssetTitle>자산</AssetTitle> <AssetValue>{`${formatMoney(property)} 원`}</AssetValue> </Asset> <hr /> <Location> <LocationTitle>위치</LocationTitle> <LocationValue>{location}</LocationValue> </Location> <Trade> <TradeTitle>거래 방식</TradeTitle> <TradeValue>{getTransactionTypeName(transactionType)}</TradeValue> </Trade> <BuildingType> <BuildingTitle>건물 유형</BuildingTitle> <BuildingValue>{getBuildingTypeName(buildingType)}</BuildingValue> </BuildingType> </TotalContainer> </StyledSummaryForm> </Container> </Form> ); };
MultiFormContainer 에서 step 이 5가 되면 useFindMyHome 훅에서 가져온 findMyHome 을 통해 form 에 담긴 데이터들을 백엔드 서버에 요청하도록 설계하였다.
const { result, setResult, isLoading, findMyHome } = useFindMyHome();
const onSubmit: SubmitHandler<MultiFormProps> = useCallback(async () => {
if (step < 4) {
increaseStep();
return;
}
try {
const response = await findMyHome({
isKBApi: 0,
property: Number(watch('assets')),
neighborhoodCode: bcode,
transactionType: Number(watch('transactionType')),
buildingType: Number(watch('buildingType')),
recommendedNumber: 1
});
if (response.status === 200) {
increaseStep();
} else {
alert('추천 동네를 불러오는 데 실패했습니다.');
}
} catch (error) {
console.log(error);
alert('추천 동네를 불러오는 데 실패했습니다.');
}
}, [step, bcode, findMyHome, increaseStep, watch]);
MultiFormContainer 에서 받은 데이터를 axios POST 방식으로 입력값들을 전송 후, 이에 나온 결과값을 다시 받아 사용할 수 있도록 설계하였다.
const useFindMyHome = (): UseFindMyHomeProps => {
const [result, setResult] = useState('');
const [isLoading, setIsLoading] = useState(false);
const findMyHome = useCallback(async (info: HometownProps) => {
setIsLoading(true);
try {
const response = await axios.post('/whereismyneighborhood', info, {
headers: {
'Content-Type': 'application/json'
}
});
setResult(response.data);
return response;
} catch (error) {
alert(`error: ${error}`);
return error;
} finally {
setIsLoading(false);
}
}, []);
return {
result,
setResult,
isLoading,
setIsLoading,
findMyHome
};
};
export default useFindMyHome;
- Feat | 새로운 기능을 추가
- Fix | 버그 수정
- Design | CSS 등 사용자 UI 디자인 변경
- !BREAKING CHANGE | 커다란 API 변경의 경우
- !HOTFIX | 급하게 치명적인 버그를 고쳐야 하는 경우
- Style | 코드 포맷 변경, 세미 콜론 누락, 코드 수정이 없는 경우
- Refactor | 프로덕션 코드 리팩토링
- Comment | 필요한 주석 추가 및 변경
- Docs | 문서 수정
- Test | 테스트 코드, 리팩토링 테스트 코드 추가, Production Code(실제로 사용하는 코드) 변경 없음
- Chore | 빌드 업무 수정, 패키지 매니저 수정, 패키지 관리자 구성 등 업데이트, Production Code 변경 없음
- Rename | 파일 혹은 폴더명을 수정하거나 옮기는 작업만인 경우
- Remove | 파일을 삭제하는 작업만 수행한 경우