개발자
현재 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// 예시 1
2"use client";
3import Dropdown from "@/components/ui/Dropdown/Dropdown";
4import Divider from "@/components/ui/Divider";
5
6const CustomDropdown = () => {
7 const copyToClipboard = async () => {
8 try {
9 await navigator.clipboard.writeText(window.location.href);
10 alert("복사 성공");
11 } catch {
12 alert("복사 실패");
13 }
14 };
15 const CHAT_ROOM_OPTIONS = [
16 {
17 name: "공지",
18 },
19 {
20 name: "사진",
21 },
22 { name: "링크" },
23 { name: "공유하기", event: copyToClipboard },
24 { name: "채팅방 나가기" },
25 ];
26
27 return (
28 <Dropdown>
29 <Dropdown.Trigger>
30 <DotsIcon className="w-6 h-6 tablet:w-7 tablet:h-7" />
31 </Dropdown.Trigger>
32 <Dropdown.Menu>
33 <li className="block tablet:hidden">
34 <Dropdown.Item>인원</Dropdown.Item>
35 <Dropdown.Item>스케줄</Dropdown.Item>
36 <Divider type="horizontal" />
37 </li>
38
39 {CHAT_ROOM_OPTIONS.map((option) => (
40 <Dropdown.Item key={option.name} event={option.event}>
41 {option.name}
42 </Dropdown.Item>
43 ))}
44 </Dropdown.Menu>
45 </Dropdown>
46 );
47};
48
49import Link from "next/link";
50import ArrowLeftIcon from "@/public/arrow-left.svg";
51import FlexBox from "@/components/ui/FlexBox";
52import DotsIcon from "@/public/tabler_dots.svg";
53
54export default function ChatRoomHeader({ title }: { title: string }) {
55 return (
56 <FlexBox
57 as="header"
58 justify="between"
59 align="center"
60 className=" p-8 h-14 tablet:h-24 w-full gap-4 bg-white border-b-[1px]"
61 >
62 <FlexBox className="gap-1">
63 <Link href="/community">
64 <ArrowLeftIcon className="w-7 h-7" />
65 </Link>
66 <p className="text-xl font-bold">{title}</p>
67 </FlexBox>
68 <CustomDropdown />
69 </FlexBox>
70 );
71}
72
73// 예시 2
74// 설명에 불필요한 부분은 생략했습니다
75"use client";
76
77import { MouseEventHandler } from "react";
78
79const DropdownOption = ({
80 name,
81 eventHandler,
82}: {
83 name: string;
84 eventHandler?: MouseEventHandler<HTMLButtonElement>;
85}) => {
86 return (
87 <li>
88 <button onClick={eventHandler}>{name}</button>
89 </li>
90 );
91};
92
93const copyToClipboard = () => {
94 navigator.clipboard.writeText(window.location.href);
95};
96
97const OPTIONS = [
98 {
99 name: "공지",
100 },
101 {
102 name: "사진",
103 },
104 { name: "링크" },
105 { name: "공유하기", eventHandler: copyToClipboard },
106 { name: "채팅방 나가기" },
107];
108
109const DropdownMenu = () => {
110 return (
111 <ul>
112 {OPTIONS.map((item, index) => (
113 <DropdownOption
114 key={`${index}-item`}
115 name={item.name}
116 eventHandler={item.eventHandler}
117 />
118 ))}
119 </ul>
120 );
121};
122
123export default function ChatroomHeader() {
124 return (
125 <>
126 <DropdownMenu />
127 </>
128 );
129}
130
131// 예시 3
132const copyToClipboard = (setter: Dispatch<SetStateAction<boolean>>) => {
133 navigator.clipboard.writeText(window.location.href);
134 setter(true);
135};
136
137const OPTIONS = [
138 {
139 name: "공지",
140 },
141 {
142 name: "사진",
143 },
144 { name: "링크" },
145 { name: "공유하기", eventHandler: copyToClipboard },
146 { name: "채팅방 나가기" },
147];
148
149const DropdownMenu = () => {
150 const [isCopied, setIsCopied] = useState(false);
151
152 return (
153 <ul>
154 {OPTIONS.map((item, index) => (
155 <DropdownOption
156 key={`${index}-item`}
157 name={item.name}
158 eventHandler={
159 item.eventHandler ? () => item.eventHandler(setIsCopied) : undefined
160 }
161 />
162 ))}
163 <p>isCopied: {String(isCopied)}</p>
164 </ul>
165 );
166};
167
168// 예시 4
169const DropdownOption = ({
170 name,
171 eventHandler,
172}: {
173 name: string;
174 eventHandler?: MouseEventHandler<HTMLButtonElement>;
175}) => {
176 return (
177 <li key={name}>
178 <button onClick={eventHandler}>{name}</button>
179 </li>
180 );
181};
182
183const NotiOption = () => {
184 const fetchNoti = async () => {
185 const response = await fetch("http://localhost:3000/api/noti");
186 const data = await response.json();
187 return data;
188 };
189 return <DropdownOption name="공지" eventHandler={fetchNoti} />;
190};
191
192const PhotoOption = () => {
193 const fetchPhoto = async () => {
194 const response = await fetch("http://localhost:3000/api/photo");
195 const data = await response.json();
196 return data;
197 };
198 return <DropdownOption name="사진" eventHandler={fetchPhoto} />;
199};
200
201const LinkOption = () => {
202 const createLink = (event: any) => {
203 return event.target.value;
204 };
205 return <DropdownOption name="링크" eventHandler={createLink} />;
206};
207
208const ExitOption = () => {
209 const exitChatroom = () => {
210 window.location.href = "http://localhost:3000/";
211 };
212 return <DropdownOption name="채팅방 나가기" eventHandler={exitChatroom} />;
213};
214
215const ShareOption = () => {
216 const copyToClipboard = () => {
217 navigator.clipboard.writeText(window.location.href);
218 };
219 return <DropdownOption name="공유하기" eventHandler={copyToClipboard} />;
220};
221
222const OPTIONS = [NotiOption, PhotoOption, LinkOption, ShareOption, ExitOption];
223
224const DropdownMenu = () => {
225 return (
226 <ul>
227 {OPTIONS.map((item, index) => (
228 <Fragment key={`${index}-item`}>{item()}</Fragment>
229 ))}
230 </ul>
231 );
232};
233
234export default function ChatroomHeader() {
235 return (
236 <>
237 <DropdownMenu />
238 </>
239 );
240}
241
242// 예시 5
243// 다른 컴포넌트는 생략...
244
245const useShareLogic = () => {
246 const [value, setValue] = useRecoilState(shareState);
247 const copyToClipboard = () => {
248 navigator.clipboard.writeText(window.location.href);
249 setValue(true);
250 };
251
252 return { value, copyToClipboard };
253};
254
255const ShareOption = () => {
256 const { copyToClipboard } = useShareLogic();
257 return <DropdownOption name="공유하기" eventHandler={copyToClipboard} />;
258};
259
260const OPTIONS = [NotiOption, PhotoOption, LinkOption, ShareOption, ExitOption];
261
262const DropdownMenu = () => {
263 return (
264 <ul>
265 {OPTIONS.map((item, index) => (
266 <Fragment key={`${index}-item`}>{item()}</Fragment>
267 ))}
268 </ul>
269 );
270};
271
272export default function ChatroomHeader() {
273 const { value } = useShareLogic();
274
275 return (
276 <>
277 <DropdownMenu />
278 {String(value)}
279 </>
280 );
281}
익명
작성자
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 같은걸 만들어서 숨길수도 있을것 같네요
지금 가입하면 모든 질문의 답변을 볼 수 있어요!
현직자들의 명쾌한 답변을 얻을 수 있어요.
이미 회원이신가요?
지금 가입하면 모든 질문의 답변을 볼 수 있어요!