안녕하세요, 개발자 Sean입니다.
오늘은 플랫폼 상단 탭의 Drag and Drop 기능 리펙터링을 진행했습니다.
베타 테스트용으로 급하게 만드느라 기존의 드래그 앤 드롭은 html5에서 기본적으로 지원하는 내장 기능을 이용해 구현해 놓은 상태였습니다.
하지만 ui/ux가 다소 엉성(?)하고 이를 해결하려면 여러 부수적인 코드를 많이 만들어야 하는 까닭에
많은 사람들이 이용해 안정성을 입증하면서 개발자의 의도대로 커스터마이징이 용이한 React-dnd를 사용하기로 했습니다.
완성된 동작은 아래와 같습니다.
하지만 사용하는 개발자 수에 비해 어딘가 부실한 설명으로 이해하는데 시간이 다소 걸렸습니다. 따라서 많이 부족하지만 다른 분들의 시간을 아껴드리기 위해 간단하게나마 설명을 추가한 포스팅을 올리려고 합니다.
일단 React DnD는 html5의 drag and drop api와 redux를 내부적으로 사용하고 있습니다. 따라서 기술적으로는 html의 api를 따르기 때문에 복잡하지는 않지만 나름의 제약사항이 있는 그런 라이브러리입니다. 내부적으로 redux를 사용하고 있어서인지 패턴도 비슷합니다.
우선 가장 먼저 해줘야 할 것은 드래그엔 드롭을 사용할 컴포넌트의 상위 컴포넌트를 DndProvider로 감싸는 것입니다.
//Analytics.js
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const Analytics = () => {
return (
<DndProvider backend={HTML5Backend}>
<Wrapper className="content-wrapper">
<SubCard>
<Tabs />
<TabPanels />
</SubCard>
</Wrapper>
</DndProvider>
);
};
export default Analytics;
위 코드에서 backend에 HTML5 Backend를 전달하는 부분이 보이실 겁니다. 이것은 html5의 드래그엔드랍 api를 사용하기 위해 작성했습니다. React DnD는 기본 설정으로 html5를 backend로 사용한다고 하니 아마 넣어주지 않아도 될 겁니다만 혹시나 모르니 일단 넣었습니다.
상위 컴포넌트를 provider로 감쌌으니 이제는 본격적으로 코드를 만들어 보겠습니다.
// TabItems.js
import { useDrag, useDrop } from 'react-dnd';
export const TabItem = () => {
const {tabList, selectedTab} = useSelector((state) => state.analytics);
const {userId} = useSelector((state) => state.user);
const dispatch = useDispatch();
const ref = useRef(null);
const [{isDragging}, drag] = useDrag({
type: 'tab',
item: {from: idx, to: null},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
end: async (item, monitor) => {
if (item.from === item.to) return;
if (item.from !== item.to) {
const arr = _.cloneDeep(tabList);
let remove = arr.splice(item.from, 1);
let add = arr.splice(item.to, 0, remove[0]);
let tabOrder = arr.map(el => el = el.id);
let filters = {
tabOrder: `[${tabOrder}]`,
userId: userId,
}
const result = await changeAnalyticsTabOrder(filters);
dispatch(saveAnalyticsTabList(result.data));
}
}
})
const [{isOver}, drop] = useDrop({
accept: 'tab',
collect: monitor => ({
isOver: !!monitor.isOver(),
}),
hover(item, monitor) {
if (!ref.current) return;
if ( idx === item.index) return;
if ( idx !== item.index) {
item.to = idx
}
},
});
drag(drop(ref));
return (
<Stack
direction='row'
justifyContent='space-evenly'
alignItems='center'
position='relative'
sx={{
margin: '0 4px',
padding: '0 8px',
textTransform: 'none',
minHeight: '40px',
border: selectedTab === idx ? '1px solid rgba(0, 0, 0, 0.15)' : '1px solid #ffffff',
borderRadius: selectedTab === idx ? '8px 8px 0 0' : '8px',
color: selectedTab === idx ? '#1890ff' : 'rgba(0, 0, 0, 0.85)',
borderBottom: selectedTab === idx ? '1px solid #ffffff' : '',
backgroundColor: selectedTab === idx ? isOver ? '#F5F5F5':'#ffffff' : isOver ? '#ebebeb':'#F5F5F5',
'&:hover': {
backgroundColor: selectedTab === idx ? '':'#ebebeb',
},
}}
>
<Tab
ref={label === 'New' ? () => {} : ref}
disableRipple
icon={icon}
iconPosition={iconPosition}
label={label}
key={idx}
onClick={onClick}
sx={{
textTransform: 'none',
minHeight: '40px',
opacity: selectedTab === idx ? 1 : 0.5,
'&:hover': {
color: '#40a9ff',
opacity: 1,
},
'&.Mui-focusVisible': {
backgroundColor: '#d1eaff',
},
}}
/>
<ClearIcon fontSize='4px' sx={{display: idx === tabList.length ? 'none':'', cursor: 'pointer'}} onClick={() => openModal(idx)} />
</Stack>
)
}
export default TabItem;
리펙토링을 하기 이전의 코드라 상당히 복잡하네요. 추가할 기능들이 몇 가지 남아있어 코드가 더러운 점 양해 바랍니다 ㅠㅠ

그런데 사실 위 코드에서 세 가지 개념만 이해하면 라이브러리를 활용해 웬만한 드래그 앤 드롭 기능은 쉽게 구현 가능합니다.
1. useDrag
2. useDrop
3. Ref
useDrag
const [{isDragging}, drag] = useDrag({
type: 'tab',
item: {from: idx, to: null},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
end: async (item, monitor) => {
if (item.from === item.to) return;
if (item.from !== item.to) {
const arr = _.cloneDeep(tabList);
let remove = arr.splice(item.from, 1);
let add = arr.splice(item.to, 0, remove[0]);
let tabOrder = arr.map(el => el = el.id);
let filters = {
tabOrder: `[${tabOrder}]`,
userId: userId,
}
const result = await changeAnalyticsTabOrder(filters);
dispatch(saveAnalyticsTabList(result.data));
}
}
})
우선 useDrag부터 살펴보겠습니다. useDrag는 드래그하는 컴포넌트에 적용할 행위를 정하고 행위마다 어떤 상태 값을 반환할지를 정한다고 이해하시면 편합니다. 저는 이 부분이 굉장히 Redux와 비슷하다고 생각합니다. ㅎㅎ
여하튼 위 코드에서 useDrag 훅 안에 객체를 인자로 넘겨주고 있습니다. 객체 안에는 type, item, collect, end가 키값으로 있습니다.
type은 애플리케이션에서 드래그하고 드롭할 컴포넌트를 특정 짓는 스트링을 의미합니다. 예를 들어 '사과'와 '바나나' 이렇게 두 개의 type이 있다면 사과 type의 드래그가 시작되더라도 바나나 type 드래그엔드랍 컴포넌트에게 영향을 미치는 것을 방지해 애플리케이션의 확장 가능성을 높여주는 기능을 할 수 있습니다. 서로 엉키는 걸 방지할 수 있겠죠?
item은 콜백 함수에 넘겨주는 프로퍼티로 활용하기 위해 추가했습니다. 넣지 않아도 상관없는 키값입니다. 저의 경우 item 값에 from 값과 to 값을 넣어 호버링이 발생했거나 드래그가 끝났을 때 상태 값 변화를 위해 사용했습니다.
collect는 아주 유용합니다. collect에는 콜백 함수를 value로 전달하는데요, 여기서 여러 가지 상태 값을 설정하고 반환해 사용할 수 있습니다. 위 코드를 예로 들자면, 드래깅이 시작되면 monitor.isDragging()의 값은 true로 변하게 됩니다. 그렇게 되면 isDragging값은 true가 되고 이 값은 const [{isDragging}]에 반환되어 로직에 활용할 수 있게 되는 것입니다.
end은 의미 그래도 드래그가 종료되었을 때 실행할 콜백 함수를 정의하는 부분입니다. 저의 겨우 드래드가 끝나면 탭의 순서 변경을 요청하는 api 콜을 실행하도록 설정했습니다.
눈치채셨겠지만 useDrag에서 반환하는 배열의 첫 번째 엘리먼트는 {isDragginga}과 같은 collect에서 정의된 상태 값들입니다. 그렇다면 두 번째 엘리먼트인 drag는 무엇일까요? 이 부분은 아래 ref 설명에서 설명드리도록 하겠습니다.
useDrop
const [{isOver}, drop] = useDrop({
accept: 'tab',
collect: monitor => ({
isOver: !!monitor.isOver(),
}),
hover(item, monitor) {
if (!ref.current) return;
if ( idx === item.index) return;
if ( idx !== item.index) {
item.to = idx
}
},
});
useDrag가 드래그와 관련된 것이라면 useDrop은 드롭되는 컴포넌트에게 적용할 로직과 상태 값을 얻기 위해 사용하는 훅입니다.
여기서도 키값들이 여러 개 보이네요.
accept는 적용할 type을 정의하는 부분입니다. useDrag 부분에서 type을 설정했고 useDrop에서는 어떤 type의 드롭을 허용할 것인지 설정한다고 보시면 됩니다. 저의 경우 drag 타입이 'tab'이었으니 drop에서 accept를 'tab'으로 설정했습니다.
collect는 useDrag와 마찬가지로 상태 값을 위해 사용합니다.
hover는 어떤 아이템이 해당 컴포넌트 위를 호버링 할 때 호출되는 함수입니다. 저의 경우 tab의 인덱스 값을 변경하는데 hover 함수를 이용했습니다. hover에 복잡한 함수를 넣는 것은 지양하시길 바랍니다. 콘솔을 찍어보시면 실행 횟수가 엄청납니다 ㄷㄷ. 가급적 코스트가 많이 드는 실행이나 함수는 useDrag에서 drag가 끝났을 때 한 번만 호출될 수 있도록 하는 것이 좋습니다. 혹시나 hover에 코스트가 높은 실행이 필요하다면 throttle을 적용하시기를 권장드립니다.
Ref
useDrop useDrag 모두 두 번째 인자로 레퍼런스 함수를 반환합니다.
문서에 따르면 레퍼런스 함수는 정확하게는 connect 함수라고 하는데, 이를 DOM에 붙이면 해당 DOM을 리스닝하게 됩니다.
drag(drop(ref));
...
return (
...
<Tab
ref={label === 'New' ? () => {} : ref}
저의 경우 drag(drop(ref))과 같이 문서와는 다소 다르게 사용을 하였는데요, 이는 제가 구현하는 tab 컴포넌트는 drag 컴포넌트이면서 drop 컴포넌트이기도 하기 때문에 구글링 결과 위와 같이 기능을 구현하신 분이 있어 따라 적었습니다.
내부적으로 어떻게 구현되는지 알아보려고 하지만 시간 관계상 더 깊게 들어가 보진 못했습니다.
다음에 관련해서 깊이 알게 되면 추가로 포스팅을 해야겠습니다 ㅠㅠ

'Front-End > React' 카테고리의 다른 글
[React] 모바일 청첩장에 벚꽃 배경 만들기 (0) | 2023.01.10 |
---|---|
[React] 최초 렌더링에서 useEffect 실행 생략하기 (0) | 2022.12.14 |
[React] 리엑트 코딩 팁 (0) | 2022.11.16 |
[React] Single Responsibility Principle 리펙토링 하기 (0) | 2022.11.01 |
[React] 리엑트에 SOLID 원칙 적용시키기 (0) | 2022.10.31 |