개발자
현재 Dropdown 컴포넌트를 compound pattern을 접목하여 구현을 해보고있습니다. ChatRoomHeader 라는곳에있는 DotsIcon을 클릭하면 Dropdown이 랜더링되게끔 구현을 하였는데요. 각 메뉴 리스트들을 배열로 해서 map을 사용하여 렌더링해주고있습니다. 제가 생각하는 문제점은 Dropdown안에 있는 로직들이 뭔가 전혀 상관없는곳에서 정의하고 props로 내려주는것에대해서 약간 문제점이 있어보이는것같습니다.. 혹시 다른방법이 있을까요? 아니면 참고할만한 블로그 알려주시면 감사하겠습니다 (__) 'use client'; import Link from 'next/link'; import ArrowLeftIcon from '@/public/arrow-left.svg'; import FlexBox from '@/components/ui/FlexBox'; import DotsIcon from '@/public/tabler_dots.svg'; import Dropdown from '@/components/ui/Dropdown/Dropdown'; import Divider from '@/components/ui/Divider'; export default function ChatRoomHeader({ title }: { title: string }) { const copyToClipboard = async () => { try { await navigator.clipboard.writeText(window.location.href); alert('복사 성공'); } catch { alert('복사 실패'); } }; const CHAT_ROOM_OPTIONS = [ { name: '공지', }, { name: '사진', }, { name: '링크' }, { name: '공유하기', event: copyToClipboard }, { name: '채팅방 나가기' }, ]; return ( <FlexBox as="header" justify="between" align="center" className=" p-8 h-14 tablet:h-24 w-full gap-4 bg-white border-b-[1px]" > <FlexBox className="gap-1"> <Link href="/community"> <ArrowLeftIcon className="w-7 h-7" /> </Link> <p className="text-xl font-bold">{title}</p> </FlexBox> <Dropdown> <Dropdown.Trigger> <DotsIcon className="w-6 h-6 tablet:w-7 tablet:h-7" /> </Dropdown.Trigger> <Dropdown.Menu> <li className="block tablet:hidden"> <Dropdown.Item>인원</Dropdown.Item> <Dropdown.Item>스케줄</Dropdown.Item> <Divider type="horizontal" /> </li> {CHAT_ROOM_OPTIONS.map((option) => ( <Dropdown.Item key={option.name} event={option.event}> {option.name} </Dropdown.Item> ))} </Dropdown.Menu> </Dropdown> </FlexBox> ); }
답변 3
인기 답변
안녕하세요! 좋은 고민을 하고 계시네요ㅎㅎ "제가 생각하는 문제점은 Dropdown안에 있는 로직들이 뭔가 전혀 상관없는곳에서 정의하고 props로 내려주는것" 이렇게 생각하신 이유가 무엇인가요? Dropdown.Item에 넘겨주는 event를 ChatRoomHeader에서 생성하고 있어서인가요? 그렇다면, Dropdown을 한번 더 작은 단위의 컴포넌트로 만들고 CHAT_ROOM_OPTIONS와 copyToClipboard를 Dropdown 컴포넌트에서 선언하면 어떤가요? 여전히 로직들이 이상한 곳에서 선언된다고 느끼시나요? (예시 1) 어딘가 문제가 있는 것 같은데 잘 모르겠지 않나요? 저도 그렇습니다. 다행히 많은 개발자들이 저희와 비슷한 생각을 오래전부터 해왔고 컴포넌트 설계, 아키텍처, 로직과 뷰의 분리 등 관련된 주제로 참고할 만한 소스들이 많습니다. 하지만, 당장 개발해야하는데 여러가지 소스들을 본다고 한들 바로 와닿지 않을 수 있습니다. 그래서 제가 주로 개발하는 방식으로 질문자님의 코드를 수정하려고 합니다. 우선 저는 질문자님의 코드를 보고 아래의 고민을 했습니다. 1. 범용적 vs 특정 도메인/기능에 맞게 짤건가 2. 전체적인 로직과 상태관리 3. 리액트 렌더링 사이클 "범용적 vs 특정 도메인에 맞게 짤건가" 범용적이라면 재사용성에 좀 더 초점을 두고 짠 컴포넌트라고 생각해주시면 됩니다. 예를 들면, 질문자님의 코드에서는 Dropdown을 포함한 순수 ui와 관련된 컴포넌트들이 해당된다고 볼 수 있습니다. 도메인에 종속되지 않고 어디서나 사용할 수 있도록 만들어진 컴포넌트입니다. 반대로 특정 도메인에 종속된 컴포넌트로는 이름만 봤을때 ChatRoomHeader가 해당된다고 볼 수 있습니다. ChatRoom이라는 특정 도메인을 위해 만들어진 컴포넌트라고 생각됩니다. 여기까지만 생각해서 컴포넌트를 다시 구성해본다면 (예시 2)가 만들어집니다. "전체적인 로직과 상태관리" 질문자님의 코드에는 상태 관리가 필요한 값이 딱히 없지만 만약 copyToClipboard에서 상태 값을 바꿔주고 해당 상태 값이 DropdownMenu에서 사용된다고 가정해봅시다 (예시 3). 예시는 "공유하기" 컴포넌트에 대한 것만 있지만 나머지 DropdownOption에도 각각의 상태 값이 있고 이거를 상위 컴포넌트 (DropdownMenu)에서 써야한다고 생각해봅시다. 그럼 그 상태 값들이 다 DropdownMenu에서 선언되어야하는 상황입니다. 분명 범용적으로 만들었는데 로직이 들어오고 상태가 들어오는 순간 뭔가 어긋나기 시작합니다. 이렇듯 개발을 하다보면 어느 순간 컴포넌트 간 경계선이 모호해지면서 어긋나는 경우가 있습니다. 이럴때는 좀 더 단순하게 생각하는 것도 방법입니다. 우선 로직면에서 생각을 해보니 DropdownOption들이 각각의 로직을 가지게 될 것 같습니다. 그래서 OPTIONS에 객체 형태로 로직을 담는것보다 차라리 개별 컴포넌트로 분리 시키는 걸 시도해봅니다 (예시 4) DropdownOption을 범용적으로 쓰고 로직을 머금는 중간 단계 컴포넌트를 작성했습니다. 그리고 DropdownMenu에서는 중간 단계 컴포넌트만 렌더링을 하고 있어요. 로직이 이상한 곳에서 선언되는 문제는 어느 정도 해결된 것 같지 않나요? 그럼 예시 3에서 봤던 "부모 컴포넌트로 공유되는 상태"는 어떻게 해결할 수 있을까요? 여러가지 방법이 있겠지만 저는 recoil과 커스텀 훅을 생성하는 방식으로 해결 할 것 같습니다 (예시 5) "리액트 렌더링 사이클" 마지막으로 리액트 렌더링 사이클은 눈치 채셨겠지만 리액트 라이프 사이클안에서 선언될 필요없는 값, 함수는 빼는 작업입니다. 예를 들면, OPTIONS, CHAT_ROOM_OPTIONS, 원글에 있는 copyToClipboard는 리액트 상태 값을 참조할 필요가 없고 굳이 리액트 컴포넌트 안에서 선언될 필요가 없어서 컴포넌트 밖에서 선언했습니다. 음, 나머지는 사실 정확한 요구사항이 뭔지 모르니 이 정도에서 멈추겠습니다. 제가 작업하는 흐름대로 나름 수정해봤지만 분명 안 좋은 부분도 존재하고 더 좋은 설계 방식이 있을겁니다. 역시 컴포넌트 설계는 어렵습니다. 코드에 정답은 없으니 지금 하고 계신 고민들 꾸준히 하시면서 공부하시면 될 것 같아요. 참고하면 좋을 만한 글들을 첨부하겠습니다. 한번 훑어보세요 :) - https://fe-developers.kakaoent.com/2022/221020-component-abstraction/ - https://blog.leehov.in/57 - https://velog.io/@teo/gradation-thinking - https://velog.io/@teo/MVI-Architecture - https://wit.nts-corp.com/2021/08/11/6461
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281// 예시 1 "use client"; import Dropdown from "@/components/ui/Dropdown/Dropdown"; import Divider from "@/components/ui/Divider"; const CustomDropdown = () => { const copyToClipboard = async () => { try { await navigator.clipboard.writeText(window.location.href); alert("복사 성공"); } catch { alert("복사 실패"); } }; const CHAT_ROOM_OPTIONS = [ { name: "공지", }, { name: "사진", }, { name: "링크" }, { name: "공유하기", event: copyToClipboard }, { name: "채팅방 나가기" }, ]; return ( <Dropdown> <Dropdown.Trigger> <DotsIcon className="w-6 h-6 tablet:w-7 tablet:h-7" /> </Dropdown.Trigger> <Dropdown.Menu> <li className="block tablet:hidden"> <Dropdown.Item>인원</Dropdown.Item> <Dropdown.Item>스케줄</Dropdown.Item> <Divider type="horizontal" /> </li> {CHAT_ROOM_OPTIONS.map((option) => ( <Dropdown.Item key={option.name} event={option.event}> {option.name} </Dropdown.Item> ))} </Dropdown.Menu> </Dropdown> ); }; import Link from "next/link"; import ArrowLeftIcon from "@/public/arrow-left.svg"; import FlexBox from "@/components/ui/FlexBox"; import DotsIcon from "@/public/tabler_dots.svg"; export default function ChatRoomHeader({ title }: { title: string }) { return ( <FlexBox as="header" justify="between" align="center" className=" p-8 h-14 tablet:h-24 w-full gap-4 bg-white border-b-[1px]" > <FlexBox className="gap-1"> <Link href="/community"> <ArrowLeftIcon className="w-7 h-7" /> </Link> <p className="text-xl font-bold">{title}</p> </FlexBox> <CustomDropdown /> </FlexBox> ); } // 예시 2 // 설명에 불필요한 부분은 생략했습니다 "use client"; import { MouseEventHandler } from "react"; const DropdownOption = ({ name, eventHandler, }: { name: string; eventHandler?: MouseEventHandler<HTMLButtonElement>; }) => { return ( <li> <button onClick={eventHandler}>{name}</button> </li> ); }; const copyToClipboard = () => { navigator.clipboard.writeText(window.location.href); }; const OPTIONS = [ { name: "공지", }, { name: "사진", }, { name: "링크" }, { name: "공유하기", eventHandler: copyToClipboard }, { name: "채팅방 나가기" }, ]; const DropdownMenu = () => { return ( <ul> {OPTIONS.map((item, index) => ( <DropdownOption key={`${index}-item`} name={item.name} eventHandler={item.eventHandler} /> ))} </ul> ); }; export default function ChatroomHeader() { return ( <> <DropdownMenu /> </> ); } // 예시 3 const copyToClipboard = (setter: Dispatch<SetStateAction<boolean>>) => { navigator.clipboard.writeText(window.location.href); setter(true); }; const OPTIONS = [ { name: "공지", }, { name: "사진", }, { name: "링크" }, { name: "공유하기", eventHandler: copyToClipboard }, { name: "채팅방 나가기" }, ]; const DropdownMenu = () => { const [isCopied, setIsCopied] = useState(false); return ( <ul> {OPTIONS.map((item, index) => ( <DropdownOption key={`${index}-item`} name={item.name} eventHandler={ item.eventHandler ? () => item.eventHandler(setIsCopied) : undefined } /> ))} <p>isCopied: {String(isCopied)}</p> </ul> ); }; // 예시 4 const DropdownOption = ({ name, eventHandler, }: { name: string; eventHandler?: MouseEventHandler<HTMLButtonElement>; }) => { return ( <li key={name}> <button onClick={eventHandler}>{name}</button> </li> ); }; const NotiOption = () => { const fetchNoti = async () => { const response = await fetch("http://localhost:3000/api/noti"); const data = await response.json(); return data; }; return <DropdownOption name="공지" eventHandler={fetchNoti} />; }; const PhotoOption = () => { const fetchPhoto = async () => { const response = await fetch("http://localhost:3000/api/photo"); const data = await response.json(); return data; }; return <DropdownOption name="사진" eventHandler={fetchPhoto} />; }; const LinkOption = () => { const createLink = (event: any) => { return event.target.value; }; return <DropdownOption name="링크" eventHandler={createLink} />; }; const ExitOption = () => { const exitChatroom = () => { window.location.href = "http://localhost:3000/"; }; return <DropdownOption name="채팅방 나가기" eventHandler={exitChatroom} />; }; const ShareOption = () => { const copyToClipboard = () => { navigator.clipboard.writeText(window.location.href); }; return <DropdownOption name="공유하기" eventHandler={copyToClipboard} />; }; const OPTIONS = [NotiOption, PhotoOption, LinkOption, ShareOption, ExitOption]; const DropdownMenu = () => { return ( <ul> {OPTIONS.map((item, index) => ( <Fragment key={`${index}-item`}>{item()}</Fragment> ))} </ul> ); }; export default function ChatroomHeader() { return ( <> <DropdownMenu /> </> ); } // 예시 5 // 다른 컴포넌트는 생략... const useShareLogic = () => { const [value, setValue] = useRecoilState(shareState); const copyToClipboard = () => { navigator.clipboard.writeText(window.location.href); setValue(true); }; return { value, copyToClipboard }; }; const ShareOption = () => { const { copyToClipboard } = useShareLogic(); return <DropdownOption name="공유하기" eventHandler={copyToClipboard} />; }; const OPTIONS = [NotiOption, PhotoOption, LinkOption, ShareOption, ExitOption]; const DropdownMenu = () => { return ( <ul> {OPTIONS.map((item, index) => ( <Fragment key={`${index}-item`}>{item()}</Fragment> ))} </ul> ); }; export default function ChatroomHeader() { const { value } = useShareLogic(); return ( <> <DropdownMenu /> {String(value)} </> ); }
익명
작성자
2023년 08월 28일
긴 댓글 너무나 감사합니다!! 사실 제가 취준생이고 아직 현업경험이없다보니 망망대해속에서 허우적대는기분이었거든용ㅎㅎ 댓글남겨주신거 정독하였구 링크도 다 정독해보겠습니당 너무 감사합니다!
작년 토스 컨퍼런스에서 공개된 Effective Component라는 주제의 영상입니다. 마침 예제에 dropdown이 있어서 한 번 보시면 도움이 될 것 같아서 공유 드립니다 https://youtu.be/fR8tsJ2r7Eg?si=8i8P9u4ZmriOLCDk
아마도 Dropdown.Menu에 원하는 ui를 직접 render할 수 있는 구조로 보여집니다 이런 방법이 마음에 들지 않으면 아예 DropdownMenu라는 완전체를 만들고 menus나 menuSections(divider처리를 위해) props를 만들어 넘기고 menu 객체안에 handler도 포함하거나 onPressMemu 같은걸로 DropdownMenu를 사용하는 부모에 menu의 id나 key 같은 것만 넘겨서 직접 처리하게하는 방법이 있을 것 같습니다. menu목록은 DropdownMenu안에 선언하고 filter prop 같은걸 만들어서 숨길수도 있을것 같네요
지금 가입하면 모든 질문의 답변을 볼 수 있어요!
현직자들의 명쾌한 답변을 얻을 수 있어요.
이미 회원이신가요?
지금 가입하면 모든 질문의 답변을 볼 수 있어요!