지난 글에서는 드래그 앤 드롭을 한 단계 더 자연스럽게 만들기 위해, 자리 바꾸기가 아니라 끼워 넣기 방식으로 생각해야 한다는 점을 정리했습니다. 여기까지 오면 바로 다음 문제가 보입니다. 드롭은 분명 되는데, 사용자가 기대한 위치와 실제 들어간 위치가 묘하게 다르다는 점입니다. 어떤 때는 항목이 앞에 들어가고, 어떤 때는 뒤에 들어가야 더 자연스럽게 느껴지는데, 지금 구조만으로는 그 차이를 설명하기가 어렵습니다.

이 지점에서 필요한 것이 바로 마우스 위치입니다. 단순히 “어느 항목 위에 놓았는가”만 알아서는 부족합니다. 같은 항목 위에 드롭하더라도, 사용자는 그 항목의 위쪽에 놓았는지 아래쪽에 놓았는지에 따라 앞에 넣기를 기대할 수도 있고, 뒤에 넣기를 기대할 수도 있기 때문입니다. 즉, 이제부터는 target 항목 하나만 보는 것이 아니라, target 항목 + 현재 커서가 그 항목의 어디쯤에 있는가를 함께 봐야 합니다.

이번 글에서는 왜 드래그 앤 드롭이 여기서부터 더 자연스럽게 느껴지기 시작하는지, 마우스 위치를 이용해 before와 after를 어떻게 나누는지, 그리고 사용자가 지금 어디로 드롭하려는지 한눈에 보이게 만드는 드롭 표시선은 왜 중요한지 차근차근 정리해보겠습니다.

이번 글에서 먼저 잡아둘 핵심
겉으로 보이는 문제 실제로 일어나는 일 가장 먼저 봐야 할 방향
드롭은 되는데 어떤 때는 앞, 어떤 때는 뒤로 가야 할지 감이 없다 target 항목만 보고 있고, 마우스가 그 항목의 위쪽인지 아래쪽인지는 안 보고 있다 before와 after를 나눌 기준을 먼저 정한다
드롭은 되지만 결과가 여전히 어색하다 마우스 위치와 삽입 규칙이 연결되지 않았다 위쪽이면 앞, 아래쪽이면 뒤 같은 규칙을 코드로 고정한다
사용자는 놓을 자리를 모르겠고, 개발자는 왜 이상한지 모르겠다 드롭 표시선이 없어서 현재 의도가 눈에 안 보인다 before와 after에 맞는 시각적 표시를 따로 둔다
드래그 중 화면이 자꾸 불안정하다 dragover마다 전체를 다시 그리면서 상태가 흔들리고 있다 첫 버전에서는 표시선 업데이트를 가볍게 유지한다

이번 글의 핵심 한 줄
자연스러운 드래그 앤 드롭은 target 항목만 아는 것으로 끝나지 않는다. 커서가 그 항목의 위쪽인지 아래쪽인지까지 같이 읽어야 한다.


목차

  1. 왜 이제는 마우스 위치를 같이 봐야 할까
  2. target 항목만 알아서는 부족한 이유
  3. 위쪽 절반과 아래쪽 절반은 어떻게 판단할까
  4. before·after를 실제 순서 변경으로 바꾸는 흐름
  5. 드롭 표시선은 왜 필요한가
  6. 초보자가 자주 하는 실수 6가지
  7. 마무리

왜 이제는 마우스 위치를 같이 봐야 할까

자리 바꾸기 단계에서는 사실 마우스가 항목의 어디쯤에 있는지까지는 크게 중요하지 않았습니다. B를 D 위에 놓았다면, 그냥 B와 D의 자리를 맞바꾸는 식으로 설명할 수 있었기 때문입니다. 하지만 끼워 넣기로 넘어오면 이야기가 달라집니다. 같은 D 위에 드롭하더라도, 사용자는 D 에 넣고 싶었을 수도 있고 D 에 넣고 싶었을 수도 있습니다.

즉, 이제부터는 드롭이 단순히 “이 항목 위에 놓였다”로 끝나지 않습니다. 그 항목의 어느 쪽에 가까웠는지까지 봐야 합니다. 보통 세로 목록이라면 가장 단순한 기준은 이것입니다. 항목의 위쪽 절반에 커서가 있으면 before, 아래쪽 절반에 있으면 after로 보는 방식입니다.

왜 위치 정보가 필요해지는가
상황 target만 알면 생기는 문제 위치까지 알면 해결되는 점
B를 D 쪽으로 끌어다 놓음 D 앞에 넣을지 뒤에 넣을지 애매하다 커서가 D의 위쪽이면 before, 아래쪽이면 after로 정할 수 있다
사용자가 미세하게 위쪽을 노려 드롭함 결과가 항상 같은 방향으로 들어가서 어색하다 사용자 의도와 결과가 더 잘 맞는다

이건 작은 차이처럼 보이지만 체감은 꽤 큽니다. 드래그 앤 드롭이 갑자기 “쓸 만하다”는 느낌을 주는 지점도 바로 여기입니다. 사용자가 마우스를 어디에 두느냐에 따라 결과가 자연스럽게 달라지기 시작하기 때문입니다.

핵심 포인트
끼워 넣기에서 target 항목은 도착지일 뿐이고, 실제 삽입 방향은 커서가 그 항목의 위쪽인지 아래쪽인지가 결정한다.


target 항목만 알아서는 부족한 이유

이 문제를 더 직관적으로 보려면 같은 예시를 두 가지로 나눠보면 됩니다. A, B, C, D, E 목록이 있다고 해보겠습니다. 여기서 B를 D 쪽으로 옮긴다고 해도, 사용자가 어떤 자리를 원하느냐는 두 가지로 갈릴 수 있습니다.

같은 target이라도 결과는 달라질 수 있다
드롭 의도 사용자가 기대하는 결과
D 위쪽 절반에 드롭 A, C, B, D, E
D 아래쪽 절반에 드롭 A, C, D, B, E

둘 다 target은 D입니다. 그런데 결과는 완전히 다릅니다. 이 말은 곧, target id 하나만으로는 자연스러운 드롭을 설명할 수 없다는 뜻입니다. 추가로 필요한 정보가 하나 더 있습니다. 그게 바로 dropPosition, 즉 before인지 after인지에 대한 정보입니다.

그래서 이제부터 드래그 상태를 기억할 때는 보통 두 가지를 함께 생각하게 됩니다.

  • draggedId: 지금 움직이는 항목이 누구인가
  • dropPosition: 대상 항목의 앞에 넣을 것인가, 뒤에 넣을 것인가

그리고 target id는 여전히 필요합니다. 결국 필요한 정보는 세 가지입니다. 누가 움직였는가, 누구를 기준으로 놓았는가, 그 기준의 앞인가 뒤인가. 이 셋이 모여야 삽입이 자연스러워집니다.

쉽게 정리하면
드롭은 “어느 항목 위에 놓았는가”가 아니라 “어느 항목의 앞 또는 뒤에 넣을 것인가”로 해석해야 자연스럽다.


위쪽 절반과 아래쪽 절반은 어떻게 판단할까

이제 실제로 마우스 위치를 어떻게 읽는지 보겠습니다. 세로 목록 기준에서는 생각보다 단순합니다. 현재 드래그 중인 항목이 지나가고 있는 대상 요소의 위치와 높이를 알면, 그 요소의 가운데 선을 계산할 수 있습니다. 그리고 현재 마우스의 Y좌표가 그 가운데보다 위에 있는지 아래에 있는지를 보면 됩니다.

처음 버전에서는 보통 아래처럼 생각하면 충분합니다.

function getDropPosition(event, element) {

const rect = element.getBoundingClientRect();
const middleY = rect.top + rect.height / 2;

return event.clientY < middleY ? 'before' : 'after';
}

여기서 중요한 값은 세 가지입니다.

첫 버전에서 보면 좋은 좌표 정보
왜 필요한가
rect.top 현재 요소의 화면상 위쪽 위치 가운데 선을 계산하기 위한 시작점이 된다
rect.height 현재 요소 높이 위쪽 절반과 아래쪽 절반을 나누는 기준이 된다
event.clientY 현재 커서의 세로 위치 가운데보다 위인지 아래인지 판단하는 데 쓰인다

이 계산 자체는 어렵지 않습니다. 오히려 중요한 건, 세로 목록이라면 X축보다 Y축이 더 중요하다는 점입니다. 지금 우리가 원하는 것은 왼쪽/오른쪽 구분이 아니라 위/아래 구분이기 때문입니다. 그래서 첫 버전에서는 마우스 Y좌표만으로도 충분히 자연스러운 판단을 만들 수 있습니다.

보통 dragover 이벤트 안에서 이 값을 계산해 현재 대상과 위치를 업데이트합니다.

let draggedId = null;

let currentDropId = null;
let currentDropPosition = null;

function handleDragOver(event, todoId, element) {
event.preventDefault();

currentDropId = todoId;
currentDropPosition = getDropPosition(event, element);
}

이렇게 하면 지금 어떤 항목이 드롭 기준점인지, 그리고 그 기준점의 앞인지 뒤인지를 계속 추적할 수 있습니다.

첫 이해는 이 정도면 충분하다
현재 대상 요소의 가운데 선을 기준으로, 커서가 위에 있으면 before, 아래에 있으면 after로 본다. 세로 목록에서는 이 규칙만으로도 체감이 꽤 좋아진다.


before·after를 실제 순서 변경으로 바꾸는 흐름

이제 마우스 위치를 읽을 수 있게 됐으니, 그 결과를 실제 재배치로 연결해야 합니다. 여기서 중요한 건 지난 글에서 다뤘던 index 보정이 여전히 필요하다는 점입니다. 다만 이번에는 before와 after에 따라 삽입 위치가 한 번 더 달라집니다.

첫 버전의 기본 흐름은 이렇게 잡는 편이 무난합니다.

  1. 현재 custom 순서대로 정렬된 배열을 하나 만든다.
  2. dragged 항목의 index와 target 항목의 index를 찾는다.
  3. dragged 항목을 먼저 뺀다.
  4. source가 target보다 앞에 있었다면, removal 이후 target index를 한 칸 줄여서 본다.
  5. dropPosition이 before면 target 앞에, after면 target 뒤에 넣는다.
  6. 최종 배열 기준으로 order를 1부터 다시 붙인다.
  7. 저장하고 다시 그린다.
function moveTodoByDropPosition(draggedId, targetId, position) {

const ordered = [...todos].sort((a, b) => a.order - b.order);

const sourceIndex = ordered.findIndex(todo => todo.id === draggedId);
const targetIndex = ordered.findIndex(todo => todo.id === targetId);

if (sourceIndex === -1 || targetIndex === -1) return;
if (sourceIndex === targetIndex) return;

const next = [...ordered];
const removed = next.splice(sourceIndex, 1)[0];

const targetIndexAfterRemoval =
sourceIndex < targetIndex
? targetIndex - 1
: targetIndex;

const insertIndex =
position === 'before'
? targetIndexAfterRemoval
: targetIndexAfterRemoval + 1;

next.splice(insertIndex, 0, removed);

todos = next.map((todo, index) => {
return {
...todo,
order: index + 1
};
});

saveTodos();
renderTodos();
}

이 코드를 읽을 때는 계산식보다 의미를 먼저 보는 편이 좋습니다. 핵심은 딱 두 줄입니다.

  • targetIndexAfterRemoval: 먼저 하나를 뺀 뒤 target의 새 위치를 다시 계산한다
  • insertIndex: before면 그 위치에 넣고, after면 한 칸 뒤에 넣는다
같은 target이라도 before와 after는 다르게 계산된다
상황 결과 예시
A, B, C, D, E에서 B를 D 위쪽 절반에 드롭 A, C, B, D, E
A, B, C, D, E에서 B를 D 아래쪽 절반에 드롭 A, C, D, B, E
A, B, C, D, E에서 D를 B 위쪽 절반에 드롭 A, D, B, C, E
A, B, C, D, E에서 D를 B 아래쪽 절반에 드롭 A, B, D, C, E

즉, 이전 글의 index 보정 위에, 이번 글에서는 before냐 after냐에 따라 삽입 위치를 한 번 더 갈라주는 셈입니다. 이 흐름이 붙으면 드래그 결과가 훨씬 자연스러워집니다.

핵심 정리
before·after 판단은 화면 감각을 위한 정보이고, index 보정은 배열 계산을 위한 정보다. 둘이 같이 있어야 드롭 결과가 자연스럽고 정확해진다.


드롭 표시선은 왜 필요한가

여기서부터는 사용자 경험이 달라집니다. before와 after를 안쪽에서 잘 계산하고 있어도, 사용자가 현재 어느 위치로 들어갈지 알 수 없다면 드래그는 여전히 불안하게 느껴질 수 있습니다. 그래서 드롭 표시선이 중요합니다. 이 선 하나가 지금 “앞에 들어가는 중인지” “뒤에 들어가는 중인지”를 눈으로 바로 보여주기 때문입니다.

드롭 표시선은 사용자에게만 좋은 것이 아닙니다. 개발자에게도 꽤 유용합니다. 왜냐하면 지금 코드가 before로 판단하고 있는지 after로 판단하고 있는지를 눈으로 바로 확인할 수 있기 때문입니다. 즉, 이건 UI 장식이면서 동시에 디버깅 도구이기도 합니다.

드롭 표시선이 주는 효과
없을 때 있을 때
사용자가 어디로 들어갈지 감으로만 추측한다 앞에 들어가는지 뒤에 들어가는지 바로 보인다
개발자는 결과가 이상할 때 원인을 추측해야 한다 before·after 판단이 맞는지 눈으로 검증할 수 있다
드래그가 전체적으로 불안정하게 느껴진다 동작이 예측 가능해져서 훨씬 자연스럽다

첫 버전에서는 구현도 과하게 복잡할 필요가 없습니다. 현재 target 항목과 currentDropPosition만 알고 있으면, target의 위쪽이면 위에 선을, 아래쪽이면 아래에 선을 주면 됩니다.

function applyDropIndicator(element, position) {

clearDropIndicator(element);

if (position === 'before') {
element.style.borderTop = '2px solid #222';
} else {
element.style.borderBottom = '2px solid #222';
}
}

function clearDropIndicator(element) {
element.style.borderTop = '';
element.style.borderBottom = '';
}

중요한 점은 dragover 때마다 전체 목록을 통째로 다시 그리기보다, 현재 지나가고 있는 요소에만 가볍게 표시를 주는 편이 더 안정적이라는 점입니다. dragover는 매우 자주 발생하기 때문에, 매번 전체 렌더링을 돌리면 드래그가 버벅이거나 상태가 흔들릴 수 있습니다. 첫 버전에서는 표시선 업데이트를 가능한 한 단순하게 유지하는 편이 좋습니다.

실전 팁
드롭 표시선은 화려한 UI가 아니라 현재 해석 결과를 사용자와 개발자 모두에게 보여주는 기준선에 가깝다. 처음에는 얇은 선 하나만 있어도 충분하다.


초보자가 자주 하는 실수 6가지

드롭 위치와 표시선 단계로 넘어오면, 이벤트 자체보다 판단 기준상태 정리에서 흔들리는 경우가 많습니다. 아래 실수들은 특히 자주 보이는 편입니다.

실수 겉으로 보이는 증상 실제로 문제인 부분 어떻게 보는 게 좋은가
target id만 저장하고 before·after를 안 저장한다 항상 같은 방향으로만 들어간다 마우스 위치 정보가 재배치에 반영되지 않았다 target과 position을 함께 기록한다
가운데 선 계산 없이 감으로 나눈다 큰 항목과 작은 항목에서 드롭 감각이 들쭉날쭉하다 요소 높이를 기준으로 판단하지 않았다 요소 높이의 절반을 기준으로 먼저 단순하게 나눈다
dragover마다 전체를 다시 그린다 드래그가 버벅이거나 표시가 흔들린다 지나치게 무거운 갱신이 반복된다 첫 버전에서는 표시선 업데이트를 가볍게 유지한다
표시선을 지우지 않는다 드롭이 끝난 뒤에도 선이 남아 있다 dragleave, drop, dragend 이후 정리가 빠졌다 드롭 완료와 종료 시점에 표시 상태를 정리한다
before·after는 계산했지만 index 보정을 빼먹는다 방향은 맞는데 한 칸씩 어긋난다 source 제거 이후 target 위치 변화를 반영하지 않았다 이 글의 위치 판단과 지난 글의 index 보정을 같이 봐야 한다
자동 정렬 모드에서도 그대로 허용한다 표시선은 보이는데 결과가 기대와 다르게 보인다 custom 순서와 자동 정렬 규칙이 겹쳐 있다 첫 버전은 custom 모드에서만 드롭 재배치를 허용하는 편이 낫다

특히 세 번째 실수는 실제 체감이 큽니다. dragover는 드래그 중에 매우 자주 발생하는 이벤트라서, 여기에 무거운 렌더링을 붙이면 기능은 맞더라도 드래그 자체가 불편해질 수 있습니다. 처음에는 전체 구조를 갈아엎기보다 판단은 정확하게, 화면 업데이트는 가볍게 가져가는 편이 훨씬 낫습니다.

실전에서 가장 많이 흔들리는 부분
드롭 위치를 자연스럽게 만드는 핵심은 이벤트를 많이 아는 것이 아니라, target·position·index 보정·표시선이 각각 무슨 역할을 하는지 구분해서 보는 데 있다.


마무리

드래그 앤 드롭이 자연스러워지는 순간은 단순히 마우스로 움직일 수 있을 때가 아닙니다. 사용자가 지금 어디로 들어가는지 눈으로 알 수 있고, 개발자는 그 판단이 데이터 재배치로 정확히 이어진다는 확신이 생길 때 비로소 안정된 기능처럼 느껴집니다. 이번 단계에서 마우스 위치와 드롭 표시선을 함께 보는 이유도 바로 여기에 있습니다.

이번 글에서 꼭 가져가면 좋은 건 세 가지입니다.
첫째, target 항목만으로는 자연스러운 삽입을 설명할 수 없고 before·after가 함께 필요하다는 점.
둘째, 위쪽 절반과 아래쪽 절반이라는 단순한 기준만으로도 드롭 감각이 크게 좋아질 수 있다는 점.
셋째, 표시선은 보기 좋은 장식이 아니라 현재 드롭 해석을 사용자와 개발자 모두에게 보여주는 중요한 기준이라는 점입니다.

여기까지 오면 드래그 앤 드롭은 이제 단순한 이벤트 묶음이 아니라, 꽤 설명 가능한 구조로 보이기 시작합니다. 그다음부터는 기능 자체보다 왜 어떤 때 버벅이고, 왜 리스트가 길어질수록 드래그가 불안정해지는지 같은 문제가 남습니다. 이제는 구조뿐 아니라 성능과 이벤트 연결 방식까지 같이 봐야 할 단계입니다.