크루루 서비스는 기획 단계에서 부터 칸반 형식으로 지원자들을 보여주는 걸 선택했습니다.
우리 서비스의 핵심 가치 기능은 ‘지원자의 관리’라고 생각했습니다. 따라서 우리는 ‘지원자가 어떤 단계에 있는지 한 눈에 볼 수 있는 대시보드’가 우리 서비스의 핵심 기능이라고 생각했습니다.
초기 단계 우리는 시중에 서비스되고 있는 ATS(Applicatn Tracking Service)에서 칸반 형식을 채택하고 있는 것을 확인하였습니다. 따라서 검증된 방식이라 판단하였습니다.
따라서 칸반 보드 형식을 결정한다면 “한 눈에 볼 수 있는”의 기능을 충족시킬거라 생각하였습니다.
그러나 우리는 사용자 테스트(UT)에서 이러한 칸반 형식을 바탕으로 많은 사용자들이 ‘드래그’를 직접 해보려고 시도하는 과정을 지켜봤고, 우리는 ‘Drag & Drop 기능을 예상하였지만 작동하지 않았다’ 라는 피드백을 받았습니다.
우리는 이런 문제를 해결하기 위해 다음과 같은 선택지를 두고 고민하였습니다.
- 기존의 칸반 형식의 레이아웃을 없애고 새로운 형식으로 보여준다.
- Drag & Drop 기능을 구현해 넣는다.
기존 메인 MVP라고 생각했던 부분을 충분히 충족시킨다고 판단하여 우리는 칸반 형식을 버리는 대신 Drag & Drop기능을 탑재하기로 선택하였습니다.
Drag & Drop을 직접 구현하는 이유?
시중에는 여러 Drag & Drop 라이브러리가 나와있습니다. 대표적으로 atlassian에서 만든 react-beautiful-dnd 가 있겠습니다.
어떤 라이브러리를 사용해볼지, 혹은 라이브러리를 사용하지 않고 직접 구현할지에 대해서 조사를 하였습니다.
여러 블로그에서 DnD를 구현한 것을 보았고, 저는 충분히 직접 구현 가능한 부분이라 생각했습니다.
“바퀴를 재발명하지 마라” 라는 격언이 있지만, 학습 측면에서 HTML의 Drag Event에 대한 지식을 쌓을 수 있는 좋은 경험이라고 판단했습니다.
첫 번째 시도. HTML Drag Event로 구현하기.
Draggable 컴포넌트 구현
import { Children, cloneElement, Fragment, useState } from "react";
import S from "./style";
export interface DropFnProps {
draggedItemId: string;
targetIndex: number;
draggedIndex: number;
startId: string;
}
interface DroppableProps {
children: React.ReactNode;
onDrop: (prop: DropFnProps) => void;
}
export default function Droppable({ children, onDrop }: DroppableProps) {
const [curHoverIndex, setCurHoverIndex] = useState<number | null>(null);
const getTargetIndex = (e: React.DragEvent<HTMLDivElement>) => {
const target = e.target as EventTarget & HTMLElement;
const targetIndex = target.dataset["index"];
if (targetIndex) return Number(targetIndex);
return Number(target.parentElement?.dataset["index"]);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const targetIndex = getTargetIndex(e);
setCurHoverIndex(targetIndex);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const draggedItemId = e.dataTransfer.getData("text/plain");
const draggedIndex = Number(e.dataTransfer.getData("draggedIndex"));
const targetIndex = getTargetIndex(e);
const startId = e.dataTransfer.getData("startId");
onDrop({ draggedItemId, targetIndex, draggedIndex, startId });
setCurHoverIndex(null);
};
const childrenArray = Children.toArray(children);
return (
<S.Div onDragOver={handleDragOver} onDrop={handleDrop}>
{childrenArray.map((child, index) => {
return (
<Fragment key={index}>
<S.Gap data-index={index} isHover={curHoverIndex === index} />
{cloneElement(child as React.ReactElement)}
</Fragment>
);
})}
<S.Gap data-index={childrenArray.length} isHover={curHoverIndex === childrenArray.length} />
</S.Div>
);
}
Draggable 컴포넌트 구현
import { PropsWithChildren, useState } from "react";
import S from "./style";
interface DraggableProps extends PropsWithChildren {
id: string;
index: number;
droppableId: string;
}
export default function Draggable({ id, index, droppableId, children }: DraggableProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData("text/plain", id);
e.dataTransfer.setData("draggedIndex", String(index));
e.dataTransfer.setData("startId", droppableId);
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
return (
<S.Draggable
isHidden={isDragging}
id={id}
data-index={index}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</S.Draggable>
);
}
기본적인 구현 아이디어는 다음과 같습니다.
- dataTransfer를 사용하여 Draggable의 정보를 이벤트에 담는다.
- Droppable에서 dataTransfer를 이용해 받은 Draggable의 정보를 받는다.
- Drop 이벤트가 발생할 때, Droppbale에서 props으로 전달받은 onDrop 메서드를 실행시킨다.
// ...
const childrenArray = Children.toArray(children);
return (
<S.Div onDragOver={handleDragOver} onDrop={handleDrop}>
{childrenArray.map((child, index) => {
return (
<Fragment key={index}>
<S.Gap data-index={index} isHover={curHoverIndex === index} />
{cloneElement(child as React.ReactElement)}
</Fragment>
);
})}
<S.Gap data-index={childrenArray.length} isHover={curHoverIndex === childrenArray.length} />
</S.Div>
);
}
Droppable 위 코드를 통해서 Draggable을 새로 조작하였습니다. Draggable 요소 사이사이 Gap 요소를 넣어서, 애니메이션을 넣었습니다.
Gap 요소에서도 Drop이벤트가 발생할 경우, index 값을 전달시켜 어떤 Gap에서 이벤트가 발생했는지 알 수 있게 하였습니다.
문제
- 구현된 컴포넌트가 드래그 중에는 요소를 사라지게 할 수 없습니다. 즉 드래그 중 Draggable 요소에 Reflow가 발생한다면, 바로 dragEnd 함수가 실행되면서 Drag가 끊어지게 됩니다.
- 위 문제를 해결하기 위해 조사해본 결과, DragEvent가 아닌 MouseEvent를 이용해 구현하는 방법을 알게 되었습니다.
- Mouse Event를 사용하면, Drag Event에서 제공하는 e.transfer를 통해 상태를 공유할 수 없으므로, 전역 상태를 도입하여 Droppable과 Draggable에서 동일한 상태값을 참조할 수 있도록 하였습니다.
- 그리고 단순하게 “드래그 되어져 보이는 것”이 아닌, 실제 렌더링 상태값을 변경하는 것이 여러 상황에서 대응할 수 있는 D&D가 될 것으로 생각되어, 렌더링되어지는 요소들을 전역상태에서 관리하는 방식을 선택하였습니다.
다음 글에서는 해당 문제를 해결하기 위해서 Mouse Event와 전역상태를 도입한 D&D를 만드는 과정을 소개하겠습니다.