테이블 셀 복사 시 공백이 포함되는 이유
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 요소의 특성이 만들어낸 함정이다.
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의 형태가 어떻게 달라지는지 눈으로 확인할 수 있다.
아래 테이블에서 텍스트를 드래그해보세요. Range 객체가 실시간으로 표시됩니다.
| 주문번호 | E-20260330-HKFq |
|---|---|
| 상태 | 배송완료 |
Range의 형태가 다르면 — 복사할 때 직렬화 결과도 달라진다.
3단계: Clipboard 직렬화 — 공백이 끼어드는 순간
Ctrl+C를 누르면 브라우저는 선택 영역을 두 가지 형식으로 클립보드에 쓴다.
text/html: 선택 영역의 HTML 마크업text/plain: HTML을 평문으로 변환한 결과
문제는 text/plain 변환 과정이다. 브라우저는 선택 영역을 평문으로 변환할 때, 테이블 셀 경계에서 탭이나 공백 같은 구분자를 삽입한다. 같은 행의 <td> 사이에 \t를 넣어서 엑셀에 붙여넣으면 열이 나뉘는 것도 이 동작 덕분이다.
그런데 cross-boundary Range에서는 이게 문제가 된다.
- Range가
<th>에서 시작한다 <th>블록 경계를 넘으면서 → 구분자 삽입<td>블록 경계에 진입하면서 → 구분자 또 삽입<span>내부 TextNode에 도달 →"E-20260330-HKFq"- 결과 조합 →
" 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> 시맨틱이 필수가 아닐 때 |