FeedB는 개발자들이 자신이 만든 토이 프로젝트나 사이드 프로젝트를 공유하고, 다른 사람들이 피드백을 남길 수 있는 서비스입니다.
코드잇의 마지막 프로젝트로 FeedB라는 프로젝트를 진행하게 되었습니다.
서비스 시안이 나와서, 프론트엔드 팀은 각자의 역할을 나누어 작업을 시작하였습니다.
제가 맡은 부분은 메인페이지입니다. 아직 와이어프레임만 나와 UI 작업만 하면 되는데요 간단하게 사진을 보며 어떤 걸 만들어야할지 알아보겠습니다.
- 로고
- 업로드 버튼
- 프로필
- 드롭다운 박스
- 기술 스택 리스트
- 기술 스택 아이템
- 선택된 기술 스택을 모아두는 박스
참고: 프로젝트 리스트는 공통 컴포넌트로 처리하기로 했으며, 제 역할이 아니기 때문에 구현 항목에서 제외하였습니다.
function DropDownBox() {
const { isOpen, toggleState } = useToggleHook();
return (
<div className="flex cursor-pointer items-center gap-2" onClick={toggleState}>
<Profile />
<button className="h-5 w-5">
{isOpen ? (
<>
<Image src={SmallArrowIcon} alt="open_dropbox" width={20} height={20} priority />
</> //위로향한 화살표 이지미로 바꿔야함
) : (
<>
<Image src={SmallArrowIcon} alt="open_dropbox" width={20} height={20} priority />
</>
)}
</button>
{isOpen && (
<div className="absolute right-0 top-[65px] w-40 rounded-lg border border-solid border-gray-300 bg-white px-4 py-3 text-sm text-black">
<Link className="block cursor-pointer p-2" href="/mypage">
마이페이지
</Link>
<Link className="block cursor-pointer border-b border-solid border-gray-300 p-2" href={"/"}>
프로필 정보 수정
</Link>
<Link className="block cursor-pointer p-2" href={"/"}>
로그아웃
</Link>
</div>
)}
</div>
);
}
제가 만들어야 할 드롭다운 박스는 왼쪽에 있는 UI입니다. 오른쪽에 있는 UI는 나중에 디자인 시안에 추가된 것이므로, 처음에는 왼쪽 UI만 신경 써서 작업했습니다.
이로 인해, 다른 곳에서는 재사용할 수 없는 드롭다운 박스 코드가 만들어졌습니다. 이후 오른쪽 UI도 등장하면서 두 가지 UI를 모두 처리할 수 있는 컴포넌트로 코드를 수정해야 했습니다.
이러한 요구 사항을 모두 만족하기 위해, 컴포넌트를 어떤 방법으로 구현할지 고민한 결과 컴파운드 패턴이 가장 적합하다고 판단했습니다. 그래서 컴포넌트를 이 패턴에 맞게 리팩토링했습니다.
function DropDown({ children, className, ref }: DropDownProps) {
const DefaultDropDownClass =
"absolute z-50 rounded-lg border border-solid border-gray-300 bg-white px-4 py-3 text-sm text-black";
const DropDownClass = twMerge(DefaultDropDownClass, className);
return (
<div ref={ref} className={DropDownClass}>
{children}
</div>
);
}
function LinkItem({ children, className, href }: DropDownLinkProps) {
const DefaultLinkItemClass =
"text-nowrap block cursor-pointer p-2 text-black font-semibold hover:bg-gray-100 rounded";
const LinkItemClass = twMerge(DefaultLinkItemClass, className);
return (
<Link href={href} className={LinkItemClass}>
{children}
</Link>
);
}
function TextItem({ children, className, onClick }: DropDownProps) {
const DefaultTextItemClass = "text-nowrap cursor-pointer p-2 text-black font-semibold hover:bg-gray-100 rounded";
const TextItemClass = twMerge(DefaultTextItemClass, className);
return (
<p className={TextItemClass} onClick={onClick}>
{children}
</p>
);
}
function HR() {
return <hr className="m-1" />;
}
DropDown.LinkItem = LinkItem;
DropDown.TextItem = TextItem;
DropDown.HR = HR;
export default DropDown;
function HeaderDropDownBox() {
const { isOpen, toggleState } = useToggleHook();
return (
<>
<div className="relative flex cursor-pointer items-center gap-2" onClick={toggleState}>
<ProfileImage imageUrl={""} className="h-9 w-9" />
<button type="button" className="h-5 w-5">
{isOpen ? (
<Image src={SmallTopArrowIcon} alt="유저 옵션." width={20} height={20} priority />
) : (
<Image src={SmallArrowIcon} alt="유저 옵션." width={20} height={20} priority />
)}
</button>
</div>
{isOpen && (
<DropDown className="right-0 top-[65px] w-40">
<DropDown.LinkItem href="/mypage">마이페이지</DropDown.LinkItem>
<DropDown.HR />
<DropDown.TextItem>로그아웃</DropDown.TextItem>
</DropDown>
)}
</>
);
}
코드를 보시면 아시겠지만, 드롭다운 박스에 필요한 요소들을 각각 하나의 컴포넌트로 쪼개서 선언했습니다. 드롭다운 박스를 사용하는 방법을 보면, 필요한 요소들을 자유롭게 추가하거나 제거할 수 있으며, 두 가지 디자인 시안을 모두 하나의 컴포넌트로 만들 수 있습니다. 이를 통해 재사용이 용이해지고, UI 디자인이 변경되어도 쉽게 대응할 수 있습니다.
또한, 페이지 이동이 필요한 요소에는 Link
태그를, 단순 클릭으로 메서드를 실행해야 하는 요소에는 p
태그를 사용하여 드롭다운 박스를 구성할 수 있도록 했습니다.
스타일이 조금 추가되거나 변경되는 경우에는 새로운 컴포넌트를 만드는 대신, className 속성에 스타일을 추가로 부여하여 원하는 스타일로 확장할 수 있도록 했습니다.
여기서 사용된 twMerge()
함수는 첫 번째 인수로 기존 스타일을, 두 번째 인수로 추가 스타일을 받아, 만약 중복되는 스타일이 있으면 두 번째 인수의 스타일로 덮어씌우는 기능을 제공합니다.
이렇게 해서, 앞서 고려했던 모든 부분을 만족하는 컴포넌트를 만들 수 있었습니다.
이제 각각의 요소를 구현했으니, 이제는 헤더 컴포넌트를 페이지에 적용해야 합니다.
페이지에 헤더 컴포넌트를 어떻게 적용했는지 설명해보겠습니다.
코드 중복을 방지하고 유지보수를 용이하게 하기 위해 헤더 컴포넌트를 layout.ts 파일에 선언했습니다. 아래는 해당 방식을 이용한 예시입니다:
import Header from "@/app/_components/Header/Header";
import type { Metadata } from "next";
import "@/app/_styles/globals.css";
export const metadata: Metadata = {
title: "FeedB",
description: "FeedBd에 오신 걸 환영합니다",
};
export default function MainPageLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<div id="modal" />
<Header />
{children}
</>
);
}
사이드바의 기능은 간단히 말씀드리면, 사용자가 원하는 기술 스택을 선택하여 그에 해당하는 프로젝트만 필터링하여 보여주는 역할을 합니다.
사용자가 기술 스택을 선택하면, 선택한 기술 스택이 사이드바 상단에 있는 박스에 나타나도록 UI가 구현되어 있습니다.
다시 요약해보면, 사이드바는 기술 스택을 선택하여 해당하는 프로젝트를 필터링하고, 선택한 기술 스택이 상단에 표시되는 UI를 제공합니다.
이를 위해 Context API를 사용하여 상태를 관리할 수 있습니다. Context를 사용하면 상태를 한 곳에서 중앙 집중적으로 관리할 수 있으며, 상태를 공유하는 모든 컴포넌트가 상태의 변화를 감지하여 즉시 업데이트할 수 있습니다.
이렇게 하면 사용자가 기술 스택을 선택할 때마다 모든 관련된 컴포넌트가 상태를 공유하고 있는지 확인하고, 해당 정보를 즉시 업데이트하여 UI에 반영할 수 있습니다.
const StackContext = createContext<StackContextType>({
stackState: [],
setStackState: () => {},
isChangeStack: () => {},
isDeleteStack: () => {},
});
export const useGetStack = () => useContext(StackContext);
function StackProvider({ children }: { children: ReactNode }) {
const [stackState, setStackState] = useState<string[]>([]);
const isChangeStack = useCallback((stack: string) => {
setStackState(prev => {
const isAlreadyStack = prev.includes(stack);
if (isAlreadyStack) {
return prev;
} else {
return [stack, ...prev];
}
});
}, []);
const isDeleteStack = useCallback((stack: string) => {
setStackState(prev => {
const isFilterStack = prev.filter(data => stack !== data);
return isFilterStack;
});
}, []);
return (
<StackContext.Provider value={{ stackState, setStackState, isChangeStack, isDeleteStack }}>
{children}
</StackContext.Provider>
);
}
선택된 기술 스택의 상태를 관리하기 위해 하나의 useState
상태를 생성하고, 선택된 기술 스택이 변경될 때마다 이 상태를 업데이트하는 isChangeStack()
함수를 선언했습니다. 또한, 초기화 버튼을 누를 때 상태를 초기화하기 위해 isDeleteStack()
함수도 선언했습니다.
다시 요약하면, 아래와 같은 방식으로 기능을 구현했습니다:
useState
훅을 사용하여 선택된 기술 스택의 상태를 관리합니다.isChangeStack()
함수를 만들어서 선택된 기술 스택이 변경될 때마다 이 상태를 업데이트합니다.isDeleteStack()
함수를 구현했습니다.function SideBar() {
const fullStackData = frontEndStack.concat(backEndStack);
return (
<div className="w-56">
<StackProvider>
<StackBox stackDatas={fullStackData} />
<StackList title="프론트엔드" stackDatas={frontEndStack} />
<StackList title="백엔드" stackDatas={backEndStack} />
</StackProvider>
</div>
);
}
만들어진 Context를 StackList와 StackBox 컴포넌트의 부모 요소로 감쌌습니다. 이렇게 함으로써 앞서 선언한 useState 상태를 서로 공유할 수 있게 되었고, isChangeStack()
와 isDeleteStack()
함수도 StackProvider
컴포넌트의 하위 요소에서 자유롭게 사용할 수 있게 되었습니다.
다시 정리하면, 아래와 같은 방식으로 기능을 구현했습니다:
생성한 Context를 StackList와 StackBox 컴포넌트의 부모 요소인 StackProvider로 감싸서 상태를 공유합니다.
이로써 isChangeStack()
와 isDeleteStack()
함수도 StackProvider 컴포넌트의 하위 요소에서 자유롭게 사용할 수 있게 되었습니다.
메인 페이지에 있는 사이드바와 선택된 기술 스택을 보여주는 컴포넌트의 디자인이 변경되고, 컴포넌트 구조도 변경되었습니다. 이에 따라 StackProvider를 어떻게 감싸야 하는지에 대해 고민하게 되었습니다.
다시 정리해보겠습니다:
먼저, 변경된 컴포넌트 구조에 맞게 StackProvider를 어떤 부모 요소에 위치시켜야 하는지 고려해야 합니다. 새로운 컴포넌트 구조에 맞게 StackProvider를 적절한 위치에 배치해야 합니다.
컴포넌트 디자인이 변경되었으므로, StackProvider 주위의 요소들도 변경된 디자인에 맞게 조정해야 합니다. 이를 위해서는 컴포넌트 간의 관계와 구조를 파악하여 StackProvider를 적절한 위치에 배치하고, 필요한 스타일과 레이아웃을 적용해야 합니다.
변경된 디자인에 맞게 StackProvider를 감싸는 것이 중요하므로, 디자인 변경에 따른 요소들의 관계와 위치를 고려하여 적절한 구조를 만들어야 합니다.
이러한 고려를 바탕으로 변경된 컴포넌트 구조와 디자인에 맞게 StackProvider를 적절한 위치에 감싸주어야 합니다.
"use client";
function SelectStack() {
return (
<StackProvider>
<SideBar />
<section>
<div className="mb-3 flex h-10 items-center justify-between">
<SortFilter />
<SearchBar />
</div>
<StackBox />
</section>
</StackProvider>
);
}
export default SelectStack;
결국 StackProvider를 다른 컴포넌트들과 함께 묶어주었다. 다른 컴포넌트들도 모두 클라이언트 컴포넌트이므로 큰 문제가 되지 않을 것 같아서 그렇게 결정했다.
전역 상태 관리 라이브러리를 사용하면 이 문제를 바로 해결할 수 있겠지만, 프로젝트 초기에 전역 상태 관리 라이브러리를 도입하는 것에 대한 결정을 내리지 않았다. 이것은 프로젝트가 너무 복잡해지거나 프로젝트의 규모가 커지는 등의 이유로 인해 추가적인 상태 관리가 필요한 경우에 다시 고려해보기로 했다.
만약 팀원들도 비슷한 상황에 놓이게 된다면, 그때 다시 이 문제를 논의하여 전역 상태 관리 라이브러리를 도입할지에 대한 결정을 내릴 것이다. 현재로서는 아직까지 팀원들이 전역 상태 관리가 필요하지 않은 것 같다.
📌 FeedB 깃허브 링크
https://github.com/Feed-B/frontend