테이블 셀 복사 시 공백이 포함되는 이유

테이블 셀 복사 시 공백 문제 — Cross-Boundary Selection Range

PM이 티켓을 올렸다. 내용이 이랬다 — "그냥 드래그하면 잘 되는데, 대충 드래그하면 안 됩니다."

처음에 이게 뭔 소린지 했다. 복사가 되거나 안 되거나지, "대충" 드래그하면 안 된다니?

그래서 직접 해봤다. 테이블에서 주문번호 셀의 텍스트를 복사하는 시나리오였다. 텍스트 위에 커서를 정확히 올려놓고 드래그하면 깔끔하게 복사된다. 그런데 왼쪽 사이드 헤더(th)부터 스윽 긁으면 — 앞에 공백이 붙는다.

기대값"E-20260330-HKFq"
실제값"··E-20260330-HKFq"
↑ 어디서 온 공백?

고작 공백 두 칸이다. 그런데 이걸 다른 시스템에 붙여넣으면 조회가 안 된다. PM이 왜 이걸 티켓으로 올렸는지 이해가 됐다.

아래에서 직접 확인해보자.

직접 복사해보기

❶ 공백이 포함되는 경우

왼쪽 라벨 텍스트 오른쪽 빈 영역에서 드래그를 시작해서 오른쪽 값까지 선택한 뒤 ⌘C 또는 Ctrl+C

❷ 깨끗하게 복사되는 경우

오른쪽 값 텍스트 위에서 직접 드래그하여 선택한 뒤 ⌘C 또는 Ctrl+C

주문번호E-20260330-HKFq
상태배송완료
결제일2026-03-30
수령인홍길동

이 공백이 대체 어디서 오는 걸까? 3단계로 추적해보자.


1단계: DOM 구조 — 보이지 않는 빈 영역

먼저 테이블 셀의 DOM 구조를 뜯어봤다.

<td>
  <span>E-20260330-HKFq</span>
</td>

span은 inline 요소다. 텍스트 너비만큼만 차지한다. 그 말은 — td 안에 span이 차지하지 않는 빈 공간이 생긴다는 뜻이다.

이 빈 영역은 어떤 DOM 노드에도 속하지 않는다. span의 영역도 아니고, 별도의 텍스트 노드도 아니다. td 직속이지만, 클릭해도 아무 텍스트 노드에 히트하지 않는 unowned area다.

만약 span 대신 div(block 요소)였다면? div는 부모의 전체 너비를 채우니까 이런 빈 영역 자체가 생기지 않는다. inline 요소의 특성이 만들어낸 함정이다.

<td> 내부 영역 구분
<td>
pad
E-20260330-HKFq
unowned
pad
</td>
padding
<span>
unowned area

inline span은 텍스트 너비만 차지합니다. 오른쪽 빈 영역(unowned area)은 <td>에 직접 속하며, 이 영역부터 드래그하면 공백이 포함됩니다.

여기까지는 "빈 영역이 있네" 수준이다. 이게 어떻게 공백으로 연결되는 걸까?


2단계: Selection API — 드래그 시작점이 모든 걸 결정한다

브라우저에서 텍스트를 드래그하면 내부적으로 Range 객체가 생성된다. 이 객체에는 4개의 핵심 프로퍼티가 있다.

const range = document.getSelection()?.getRangeAt(0);

range.startContainer; // 드래그 시작 노드
range.startOffset;    // 시작 위치 오프셋
range.endContainer;   // 드래그 끝 노드
range.endOffset;      // 끝 위치 오프셋

여기서 결정적인 건 드래그 시작점이다. 브라우저는 마우스 좌표를 DOM 노드에 매핑하는 hit testing을 수행하는데, 시작점이 어디냐에 따라 Range의 형태가 완전히 달라진다.

아래 테이블에서 직접 드래그해보자. 시작점에 따라 Range의 형태가 어떻게 달라지는지 눈으로 확인할 수 있다.

Selection Range 탐색기

아래 테이블에서 텍스트를 드래그해보세요. Range 객체가 실시간으로 표시됩니다.

주문번호E-20260330-HKFq
상태배송완료
테이블에서 텍스트를 선택하면 Range 정보가 여기에 표시됩니다

Range의 형태가 다르면 — 복사할 때 직렬화 결과도 달라진다.


3단계: Clipboard 직렬화 — 공백이 끼어드는 순간

Ctrl+C를 누르면 브라우저는 선택 영역을 두 가지 형식으로 클립보드에 쓴다.

  • text/html: 선택 영역의 HTML 마크업
  • text/plain: HTML을 평문으로 변환한 결과

문제는 text/plain 변환 과정이다. 브라우저는 선택 영역을 평문으로 변환할 때, 테이블 셀 경계에서 탭이나 공백 같은 구분자를 삽입한다. 같은 행의 <td> 사이에 \t를 넣어서 엑셀에 붙여넣으면 열이 나뉘는 것도 이 동작 덕분이다.

그런데 cross-boundary Range에서는 이게 문제가 된다.

  1. Range가 <th>에서 시작한다
  2. <th> 블록 경계를 넘으면서 → 구분자 삽입
  3. <td> 블록 경계에 진입하면서 → 구분자 또 삽입
  4. <span> 내부 TextNode에 도달 → "E-20260330-HKFq"
  5. 결과 조합 → " E-20260330-HKFq"

셀 경계를 두 번 넘었으니, 구분자도 두 번 들어간다. 이게 앞쪽 공백의 정체다.

브라우저별 차이

브라우저<td> 사이 구분자<th> → <td> 구분자
Chromium\t (탭)공백 또는 탭
Firefox\t (탭)공백
Safari\t (탭)공백

미묘하게 다르지만 공통점이 있다 — cross-boundary Range에서는 어떤 형태로든 구분자가 끼어든다.


해결

원인을 알았으니 해결책을 찾아보자.

근본적인 한계

먼저 알아야 할 게 있다. CSS만으로는 이 문제를 완전히 해결할 수 없다. 브라우저가 테이블 셀 경계에서 구분자를 삽입하는 건 Excel/스프레드시트 호환을 위한 의도적 설계이기 때문이다. display: block, user-select: all 같은 CSS 속성을 시도해봤지만, 셀 경계를 넘는 드래그에서는 여전히 공백이 포함됐다.

완전한 해결을 원한다면 JavaScript로 copy 이벤트를 가로채서 클립보드 내용을 직접 정제해야 한다.

table.addEventListener('copy', (e) => {
  const selection = window.getSelection();
  if (!selection) return;

  const cleaned = selection.toString()
    .split('\n')
    .map(line => line.split('\t').pop()?.trim() ?? '')
    .filter(Boolean)
    .join('\n');

  e.preventDefault();
  e.clipboardData?.setData('text/plain', cleaned);
});

선택된 텍스트에서 탭 구분자 앞부분(th 텍스트)을 잘라내고 값만 남기는 방식이다. 확실하지만, 테이블마다 이벤트 핸들러를 붙여야 한다.

내가 선택한 방법

그런데 솔직히 이 정도 이슈에 copy 이벤트 핸들러까지 붙이고 싶지 않았다. 우리 상황을 다시 보자. 이 테이블은 key-value 구조의 상세 정보 테이블이고, 사이드 헤더에는 "주문번호", "고객명" 같은 라벨이 들어있다. 이 라벨을 복사할 일이 있는가? 없다.

th {
  user-select: none;
}

th를 선택 불가능하게 만들었다. 사용자가 th부터 드래그를 시작해도 선택 영역에 th가 포함되지 않으니, 셀 경계를 넘는 cross-boundary Range가 만들어지지 않는다.

CSS 한 줄이다. copy 이벤트를 가로채는 것보다 훨씬 단순하고, "라벨은 복사할 필요가 없다"는 UX 판단이 뒷받침되니 사이드이펙트도 없다.

다른 선택지

상황에 따라 더 나은 방법이 있을 수 있다.

방법적합한 상황
user-select: none on th헤더를 복사할 필요가 없는 key-value 테이블
copy 이벤트 가로채기드래그 복사를 완벽하게 제어해야 할 때
복사 버튼 제공ID, 코드 등 정확한 값 복사가 중요할 때
CSS Grid로 대체<table> 시맨틱이 필수가 아닐 때

참고자료

  • HTML Living Standard — The innerText IDL attribute
  • Selection API (W3C)
  • Range — MDN Web Docs
  • CSS user-select — MDN Web Docs
  • Chromium Source — innerText implementation

  • 1단계: DOM 구조 — 보이지 않는 빈 영역
  • 2단계: Selection API — 드래그 시작점이 모든 걸 결정한다
  • 3단계: Clipboard 직렬화 — 공백이 끼어드는 순간
  • 브라우저별 차이
  • 해결
  • 근본적인 한계
  • 내가 선택한 방법
  • 다른 선택지
  • 참고자료
  • 1단계: DOM 구조 — 보이지 않는 빈 영역
  • 2단계: Selection API — 드래그 시작점이 모든 걸 결정한다
  • 3단계: Clipboard 직렬화 — 공백이 끼어드는 순간
  • 브라우저별 차이
  • 해결
  • 근본적인 한계
  • 내가 선택한 방법
  • 다른 선택지
  • 참고자료

관련 포스트

이 포스트와 관련된 다른 글들을 확인해보세요

테이블 셀 복사 시 공백이 포함되는 이유

테이블에서 텍스트를 복사할 때 앞에 공백이 붙는 현상의 원인을 Selection API, Range, Clipboard 직렬화 알고리즘으로 추적한 기록

2026년 3월 31일•3분
browserdomcss+3

프론트엔드 UI 스타일 카탈로그 — 67개 스타일 실전 가이드

Glassmorphism부터 Neubrutalism까지, 67개 UI 스타일을 카테고리별로 정리하고 CSS 구현 핵심과 장단점을 비교했다.

2026년 4월 8일•9분
ui-uxcssdesign+1

프론트엔드 UI/UX 체크리스트 — 실전 가이드

접근성부터 애니메이션까지, 프론트엔드 개발자가 챙겨야 할 UI/UX 규칙을 코드 예시와 인터랙티브 데모로 정리했다.

2026년 4월 8일•12분
ui-uxaccessibilityfrontend+3

Comments