- 사용성과 효율을 중심으로 디자인과 데이터 안정성 강화
- 웹과 앱 모두 최적화된 디자인을 제공
- 사용자 친화적인 경험과 직관적 인터페이스를 통한 빠른 적응 목표
- 기존 공유 페이지 기능에서 조금더 업데이트된 공유페이지 기능 구현
이제는 자버에서 더 편리한 서비스를 만나보세요!!🤗
개발 기간 : 2023년 09월 14 ~ 2023.10.05
Front-end 기술 스택 | |
Front-end 배포 | |
배포 | 🔗 JavaJober[자바자버] |
노션 | 👉 노션 바로가기 |
-> 템플릿 생성 클릭시 추천 템플릿▶ input창에 focus시 카테고리 템플릿 ▶ input창에 검색어 입력시 검색 템플릿 총 3번의 페이지 상태 변화가 있게 됩니다.
처음 코드 작성시 공통 모달 레이아웃 안에 모든 페이지를 관리할 각각의 state 값을 생성하고 true, false 로 모달안의 컨텐츠 상태값을 변경하게 하였으며, 2번째 페이지가 보일시 1번째 페이지가 보이지 않도록 하기 위해 false 값을 주었습니다.
이렇게 하나의 컴포넌트가 변경될때마다 이전 컴포넌트가 보이지 않게 하기 위해 true, false(boolean 타입)으로 컴포넌트 관리를 하다보니 , 공통 모달 안에 더 많은 컴포넌트가 변경될시 관리 하기 어렵고 코드가 복잡 해지는 문제점이 생겼습니다.
export const ModalOpen = () => {
const { Search } = Input;
// 모달 오픈을 관리하기 위한 상태관리
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
// 처음 추천 템플릿을 보여주기 위한 상태관리
const [showBestTemplate, setShowBestTemplate] = useState<boolean>(true);
// 인풋창에 포커스시 보여주기 위한 상태관리
const [categoryTemplate, setCategoryTemplate] = useState<boolean>(false);
// 인풋창에 입력시 변경되는 상태관리
const [inputText, setInputText] = useState('');
// 검색 버튼 클릭시 실행되는 함수
const onSearch = (value: string) => {
console.log(value);
alert('');
};
// 모달창을 보여주는 함수
const showModal = () => {
setIsModalOpen(true);
setShowBestTemplate(true);
setCategoryTemplate(false);
};
const handleSearchFocus = () => {
setShowBestTemplate(false); // Search 입력에 포커스가 클릭되면 BestTemplate 숨김
setCategoryTemplate(true);
if (inputText.length > 0) {
setCategoryTemplate(false);
} else {
return;
}
};
const handleOk = () => {
setIsModalOpen(false);
setShowBestTemplate(false);
//setInputText('');
};
const handleCancel = () => {
alert('취소');
setIsModalOpen(false);
setShowBestTemplate(true);
setCategoryTemplate(false);
//setInputText('');
};
const handleChangeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
if (e.target.value.length > 0) {
setCategoryTemplate(false);
setShowBestTemplate(false);
console.log(e.target.value);
} else {
setCategoryTemplate(true);
}
};
return (
<>
<Button className="buttonOpen" type="primary" onClick={showModal}>
템플릿 생성
</Button>
<Modals
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
maskClosable={false}
>
<ModalHeader>
<p>템플릿 선택하기</p>
</ModalHeader>
<SettingTemplet>
<p className="settingtText">템플릿 설정하기</p>
<SelectBox>
<InputBox>
<Select
className="selectbox"
defaultValue="문서제목"
allowClear
options={[{ value: '문서', label: '문서제목' }]}
/>
<Search
className="searchBox"
type="text"
placeholder="input search text"
onSearch={onSearch}
onFocus={handleSearchFocus}
value={inputText}
onChange={handleChangeText}
/>
</InputBox>
// 변경전
{showBestTemplate && <BestTemplate />}
{categoryTemplate && <CategoryTemplet />}
{inputText && <SelecteSearchTemplate inputText={inputText} />}
</SelectBox>
</SettingTemplet>
</Modals>
</>
);
};
-> 키값에 맞는 컴포넌트 객체를 생성하여 해당 객체를 상태관리 하도록 구현하였습니다. 하나의 setState 를 통하여 각각의 컴포넌트를 변경시켜 주도록 하였습니다.
export const ModalOpen = () => {
const { Search } = Input;
// modal contents 를 관리하는 state, type 생성
const [procedure, setProcedure] = useState<'recommand' | 'category' | 'search'>('recommand');
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [inputText, setInputText] = useState('');
// 키값에 맞는 컴포넌트 객체 생성
const PROCEDURE_MAPPER = {
recommand: <BestTemplate />,
category: <CategoryTemplate />,
search: <SelecteSearchTemplate inputText={inputText} />,
};
// 검색 버튼 클릭시 실행되는 함수
const onSearch = (value: string) => {
console.log(value);
alert('');
};
// 모달창을 보여주는 함수
const showModal = () => {
setIsModalOpen(true);
};
const handleSearchFocus = () => {
setProcedure('category');
};
const handleOk = () => {
setIsModalOpen(false);
setInputText('');
setProcedure('recommand');
};
const handleCancel = () => {
setIsModalOpen(false);
setInputText('');
setProcedure('recommand');
};
const handleChangeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
if (e.target.value.length > 0) {
setProcedure('search');
} else {
setProcedure('category');
}
};
return (
<>
<Button className="buttonOpen" type="primary" onClick={showModal}>
템플릿 생성
</Button>
<Modals
centered
title={
<ModalHeader
title="템플릿 선택하기"
handleOk={handleOk}
handleCloseModal={handleCancel}
/>
}
footer={null}
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
closeIcon={null}
>
<SettingTemplet>
<p className="settingtText">템플릿 설정하기</p>
<SelectBox>
<InputBox>
<Select
className="selectbox"
defaultValue="문서제목"
allowClear
options={[{ value: '문서', label: '문서제목' }]}
/>
<Search
className="searchBox"
type="text"
placeholder="input search text"
onSearch={onSearch}
onFocus={handleSearchFocus}
value={inputText}
onChange={handleChangeText}
/>
</InputBox>
// 변경후
{PROCEDURE_MAPPER[procedure]}
</SelectBox>
</SettingTemplet>
</Modals>
</>
);
};
🎈방법 1
📍처음 페이지 mount 시 서버에서 모든 데이터를 가져오는 api를 호출후 프론트에서 filter 처리후 결과값 노출
-> 해당 방법으로 기능 구현시 생기는 문제점 = 데이터 변경이 많이 있을 경우 프론트에서 filter 처리를 하면 최신으로 반영되는 데이터를 가져오지 못하는 문제점을 생각하였습니다.
또한 데이터가 많을수록 모든 데이터를 받아오는것은 성능 적으로도 좋지 않을것 같다고 판단하였습니다.
🎈방법 2
📍서버에서 입력값에 대해 필터링된 데이터에 대한 api 를 사용하여 결과값 노출
-> 첫번째 방식에서의 문제점을 생각하여 두번째 방식으로 검색 페이지를 구현하였습니다. 따라 서버에서 입력값에 대해 필터링된 api를 생성후 해당 api를 이용하여 검색 페이지를 구현하였습니다.
export const SelecteSearchTemplate: React.FC<Props> = ({ keyword }) => {
const [product, setProductInfo] = useState<ProductItem[]>([]);
const [filteredResults, setFilteredResults] = useState<ProductItem[]>([]);
useEffect(() => {
const getData = async () => {
try {
const response = await fetch(
`${import.meta.env.VITE_SERVER_BASE_URL}/${keyword}`,
);
if (response.ok) {
const data = await response.json();
setProductInfo([...product, ...data]);
} else {
console.error('Response not OK:', response);
}
} catch (error) {
console.error('Error while fetching data:', error);
}
};
getData();
}, [keyword, product]);
useEffect(() => {
const filteredResults = product.filter((item) =>
item.title.toLowerCase().includes(keyword),
);
setFilteredResults(filteredResults);
}, [keyword, product]);
return (
<>
<SeleteContainer>
<h3>검색결과</h3>
<ResultBox>
{filteredResults.map((item) => (
<ResultTemBox key={item.id}>{item.title}</ResultTemBox>
))}
</ResultBox>
<BestTemplate />
</SeleteContainer>
</>
);
};
export const SelecteSearchTemplate: React.FC<Props> = ({ inputText }) => {
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const [products, setProducts] = useState<ProductItem[]>([]);
useEffect(() => {
// 입력값이 변경될 때마다 debounce된 값을 업데이트.
const debounceTimer = setTimeout(() => {
setDebouncedInputValue(inputText);
}, 300); // 300 밀리초(0.3초) 디바운스 시간
return () => {
// 이전 타이머를 클리어.
clearTimeout(debounceTimer);
};
}, [inputText]);
useEffect(() => {
if (debouncedInputValue) {
const getData = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_SERVER_BASE_URL}`, {
params: {
search: debouncedInputValue,
},
});
const data = response.data.data.list;
setProducts([...data]);
} catch (error) {
console.error('API 호출 에러:', error);
}
};
getData();
} else {
setProducts([]);
}
}, [debouncedInputValue]);
return (
<>
<SeleteContainer>
<h3>{templateText.inputResult}</h3>
<ResultBox>
{products.map((item) => (
<ResultTemBox key={item.templateId}>
{item.templateTitle} <br />
{item.templateDescription}
</ResultTemBox>
))}
</ResultBox>
<BestTemplate PERSONAL={''} />
</SeleteContainer>
</>
);
};
-> 서버에서 user 입력값에 대해 filter 처리를 하고, debounce 를 사용하여 기능 구현하였습니다.
-> 일정 시간 동안 연속적으로 발생했던 이벤트들 중 마지막만 실행시켜 과다한 호출이나 렌더를 막아 최적화하는 기술 입니다.
따라 사용자가 검색창에 타이핑 할때마다 Api가 호출되는것이 아닌 , debounce 를 사용하여 마지막에 타이핑 입력할때 Api가 호출되도록 기능 구현을 하였습니다.
-> radio 버튼 클릭시 handleRadioChange 함수 실행.
<Radio
onChange={() => handleRadioChange(item)}
/>
-> radio 버튼의 속성값 checked를 이용하여 선택한템플릿의 아이디와 , 노출된 템플릿의 아이디가 같아 true이 되어야 체크가 되도록 조건식을 추가 하였습니다.
<Radio
onChange={() => handleRadioChange(item)}
checked={
selectedTemplate &&
selectedTemplate.templateId === item.templateId
}
/>
-> 한개의 버튼만 선택됩니다.
미리보기 페이지 구현을 위해 상태관리 라이브러리 zustand 를 사용해 Radio button 클릭시 해당 데이터가 store에 저장하도록 구현하였습니다.
-> 각 페이지 마다 펨플릿 옆에 radio버튼을 선택할수 있게 되고, 선택시 해당 id,title, description 이 전역관리 상태 store 저장됨.
📂store.ts -> store 와 type 생성
type TemplateState = {
selectedTemplate: {
category: string;
id: string;
title: string;
description: string;
};
setSelectedTemplate: (template: {
category: string;
id: string;
title: string;
description: string;
}) => void;
};
export const useTemplateStore = create<TemplateState>((set) => ({
selectedTemplate: {
category: '',
id: '',
title: '',
description: '',
},
setSelectedTemplate: (template) =>
set({ selectedTemplate: template }),
}));
📂RecommendInner.ts -> 만들어진 store에 선택한 템플릿 데이터 저장
const { setSelectedTemplate } = useTemplateStore();
const handleRadioChange = (item: TemplateData, status: boolean) => {
const param = {
category: PERSONAL,
id: item.id,
title: item.title,
description: item.description,
};
console.log(item);
console.log(status);
setSelectedTemplate(param);
};
return(
<Radio
onChange={(e) => handleRadioChange(item, e.target.checked)}
/>
)
-> store에 만들어진 setSelectedTemplate 를 이용해서 데이터 저장
문제점 : 템플릿에 있는 radio button 클릭시 해당 데이터를 store에 저장하고 store을 구독하고 있는 wallcomponent에 해당 데이터가 바로 나타내는 문제점이 생겼습니다.
radio button 클릭시 바로 등록된 템플릿이 보이는게 아닌, radio button 클릭후 "완료" 버튼을 눌러야 모달창이 닫힘과 동시에 wallcomponent에 등록된 템플릿이 보여야 합니다.
해결 방법 -> true, false 상태값에 대한 조건식을 추가해서 radio button 클릭시에는 상태가 false 이고, "확인" 버튼 클릭시에는 true. true 일때만 템플릿 등록이 되는 로직으로 구현하였습니다.
수정 코드 📂store.ts
export const useTemplateStore = create<TemplateState>((set) => ({
selectedTemplate: {
category: '',
templateId: '',
templateTitle: '',
templateDescription: '',
},
setSelectedTemplate: (template) => set({ selectedTemplate: template }),
// 새로운 상태값 추가
newStatus: false,
setNewStatus: (newStatus) => set({ newStatus }),
}));
📂RecommedInner.tsx
const handleRadioChange = (item: TemplateData) => {
const param = {
category: PERSONAL,
templateId: item.templateId,
templateTitle: item.templateTitle,
templateDescription: item.templateDescription,
};
setSelectedTemplate(param);
// radio 버튼 클릭시 Status false
setNewStatus(false);
};
📂TemplateModal.tsx
const { selectedTemplate, newStatus } = useTemplateStore();
const [templateHistory, setTemplateHistory] = useState<Array<TemplateItem>>(
[],
);
useEffect(() => {
// 상태값 조건식을 통하여 저장된 템플릿을 보여줌.
if (newStatus) {
setTemplateHistory((prevHistory) => [...prevHistory, selectedTemplate]);
}
}, [newStatus, selectedTemplate]);
return(
<BlockContainer blockName="templateBlock">
<div
className={`
${isEdit && 'px-[8px] pb-[8px] pt-[30px]'}
gap-4 grid sm:grid-cols-2 grid-cols-1
`}
>
{templateBlockSubData?.map((template) => (
<SingleTemplate
key={template.templateBlockUUID}
templateTitle={template.templateTitle}
templateDescription={template.templateDescription}
/>
))}
{isEdit && (
<>
<BlockContainer blockName="template">
<div className="sm:h-[210px] h-[115px] flex flex-col items-center justify-center gap-[8px] dm-16" ref={templateAddButtonRef}>
<ModalOpen />
</div>
</BlockContainer>
{templateHistory.map((template, index) => (
<BlockContainer key={index} blockName="template">
<div className="sm:h-[210px] h-[115px] p-block">
<div className="flex items-center justify-between mb-[12px]">
<h4 className="db-18 sm:db-20">{template.templateTitle}</h4>
</div>
<div className="flex sm:gap-[8px] gap-[6px]">
<p className="dm-16 text-gray88">
{template.templateDescription}
</p>
</div>
</div>
</BlockContainer>
))}
)
수정후 생긴 2차 문제점
🔥문제점 : 아래 이미지 처럼 추가 할때마다 안에 템플릿이 추가될때마다 템플릿 생성의 블럭이 자연스럽게 밀려나야 하는데, 위의 방법대로 구현하면 템플릿이 추가되도 템플릿 생성의 블럭의 위치는 그대로 있는
부자연스러운 모습이 보입니다.
기능 구현모습
오류 해결방법 -> 현재 store에 저장된 데이터로 wall 데이터로 전역적으로 쓰고 있다.
store.tsx
export const useWallStore = create<WallStoreType>((set) => ({
isEdit: false,
setIsEdit: (bool) => set(() => ({ isEdit: bool })),
isPreview: false,
setIsPreview: (bool) => set(() => ({ isPreview: bool })),
getWall: async () => {
const response = await fetch('http://localhost:3000/wall');
if (response.ok) {
set({ wall: await response.json() });
}
},
wall: {} as WallType,
setWall: (states: object) =>
set((state) => ({ wall: { ...state.wall, ...states } })),
}));
위의 템플릿 블록에 대한 컴포넌트 코드는 아래와 같다.
SingleTemplate.tsx
리액트에서는 state의 불변성을 지켜야 합니다.
import { useState } from 'react';
export default function App() {
const [cat, setCat] = useState({
name: 'howoo',
age: 6,
});
const handleChangeCatName = () => {
cat.name = 'mango';
setCat(cat);
};
console.log(cat); //{ name: 'mango', age: 6 }
return (
<div style={{ textAlign: 'center' }}>
<div>고양이 이름 : {cat.name}</div>
<button onClick={handleChangeCatName}>이름변경</button>
</div>
);
}
버튼을 누르면 console.log(cat)을 통해 실재 cat.name은 변경이 된것을 확인할 수 있지만 cat
의 참조값은 그대로이기 때문에 재랜더링이 발생하지 않습니다.
불변성을 지켜야한다는 의미는 얕은 비교를 하는 리액트의 특성상 참조형 데이터의 원본은 변하지 않게
유지해야하고 재랜더링을 위해 새로운 참조값을 set해야 함을 의미 합니다.
본 프로젝트에서는 wall(공용페이지에서 보여지는 모든 정보) 객체가 있습니다.
const wall = {
category: 'personal',
memberId: 1,
spaceId: 1,
shareURL: 'howooking',
wallInfoBlock: {
wallInfoBlockId: 9,
wallInfoTitle: '이호우',
wallInfoDescription: '안녕하세요. 고양이 개발자 이호우입니다.',
wallInfoImgURL: 'https://avatars.githubusercontent.com/u/87072568?v=4',
backgroundImgURL:
'https://images.unsplash.com/photo-1696251143046-2d32fb985b59?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2670&q=80',
},
blocks: [
{
blockUUID: '1108fff1-0106-4340-b505-280e15626ecc',
blockType: 'listBlock',
subData: [
{
listBlockId: 33,
listLabel: '학력/경력',
listTitle: '학력',
listDescription: '서울대학교',
isLink: false,
},
],
},
... 생략
-> 공유 페이지에서 발생하는 모든 onChange 이벤트는 wall 내부 값들을 실시간을 변경시켜야 합니다.
예를 들어 wall.wallInfoBlock.wallInfoTitle
값을 새로운 값으로 변경하기 위해서는 다음과 같이 해야 합니다.
setWall({
...wall,
wallInfoBlock: { ...wall.wallInfoBlock, wallInfoTitle: '새로운 값' },
});
위와 같이 wall 객체의 깊이가 얕은 경우는 어렵지 않게 불변성을 지킬 수 있으나 깊이가 깊어짐에 따라 불변성을 지키는 것은 불가능에 가까워 집니다.
-> 이 문제를 해결해주는 라이브러리가 'IMMER' 입니다.
문제점에 대한 해결 방법을 찾고 해당 라이브러리를 찾아 적용하기까지 많은 시간이 걸렸습니다.
이전에는 react 의 장점만 경험했던 부분과는 다르게, 해당 문제를 겪으면서 react 의 단점도 확연하게 느낄수 있게 된 경험이였습니다.
사용하는 기술 스택에 대해 장,단점을 모두 깨닫은 후에 해결 방안을 찾던 도중 react의 단점을 최소화 할수 있고, 더 나은 코드 개선을 위한 라이브러리 `IMMER'을 선택하게 되었습니다.
IMMER
를 사용하면 기존의 객체의 값를 다루는 문법을 사용하여 state를 업데이트 시켜줄 수 있습니다.
import { produce } from 'immer';
setWall(
produce(wall, (draft) => {
draft.wallInfoBlock.wallInfoTitle = '새로운 값';
}),
);
아키텍쳐(Architecture) |
---|
개체-관계 모델 (ERD) |
📂 API 명세서 🔗
API 명세서 |
---|
💜 Front-end
이정우(팀장) (Front-end) |
김하은 (Front-end) |
방미선 (Front-end) |
이미연(팀장) (Back-end) |
선예은 (Back-end) |
양수현 (Back-end) |
김희현 (Back-end) |
윤현진 (Back-end) |