개요
기획서나 디자인 시안을 보다 보면 정말 흔하게 등장하는 UI가 있다. 바로 긴 텍스트 줄이기다. 보통은 3줄 정도 보여주고, 넘치면 말줄임표(...) 처리를 한다. 여기까지는 CSS의 -webkit-line-clamp 속성만 쓰면 3초 만에 해결된다. 하지만 요구사항이 아래와 같다면 이야기가 달라진다.
"3줄 넘어가면 말줄임표 하고, 그 '바로 옆'에 더보기 텍스트 버튼을 넣어주세요. 버튼 누르면 펼쳐지고요."
단순히 ...으로 끝나는 게 아니라, 인터랙션이 가능한 버튼이 텍스트 흐름에 자연스럽게 붙어야 한다. '그냥 position: absolute로 띄우면 되는 거 아닌가?' 싶지만, 막상 해보면 생각보다 까다로운 문제들이 발생한다. 오늘은 이 '더보기' 버튼을 자연스럽게 넣기 위해 삽질했던 과정과 최종 해결책을 공유한다.
시행착오들
첫 번째 시도 텍스트 위에 덮어 씌우기 (Overlay)
가장 먼저 든 생각은 단순했다. CSS line-clamp로 3줄을 자르고, 우측 하단에 position: absolute로 '더보기' 버튼을 배치하는 것이다.
하지만 이 방식은 치명적인 단점이 있다. 버튼이 투명한 배경을 가지면 뒤에 있는 글자가 비쳐 보이고, 불투명한 배경을 가지면 글자가 댕강 잘려 나간 채로 버튼 뒤에 숨겨진다. ▼

글자가 '더보기' 버튼을 피해서 줄바꿈이 되는 게 아니라, 그냥 버튼이 그 위를 덮어버리는 구조이기 때문에 시각적으로 매우 불안정하다.
그라데이션으로 자연스럽게 가리기
두 번째로 생각한 건 그라데이션이다. 버튼 왼쪽에 투명도(Transparent)가 들어간 그라데이션을 줘서 글자가 자연스럽게 사라지는 느낌을 주는 것이다. 많은 서비스(유튜브 댓글, 앱스토어 설명 등)에서 사용하는 방식이라 꽤 자연스럽다. ▼

하지만 이번 디자인 요구사항은 명확하게 텍스트 끝에 붙을 것이었다. 그라데이션으로 뭉개지는 느낌이 아니라,
문장이 딱 끝나고 ... 더보기
가 이어서 나오는 깔끔한 텍스트 UI를 원했다. 그래서 이 방식도 패스했다.
Ghost Element와 이진 탐색 (Binary Search)
결국 CSS만으로는 해결할 수 없다는 결론에 도달했다. 텍스트가 정확히 어디서 3줄을 넘어가는지 계산해서 잘라내야 한다. 이를 위해 Ghost Element를 사용했다.
- 사용자 눈에는 보이지 않지만, 실제 텍스트와 폰트, 너비, 줄 간격(Line-height)이 완전히 동일한 div 하나를 만든다.(이게 Ghost다)
- 이 Ghost에 텍스트를 넣어보면서 높이를 측정한다.
- 높이가 3줄(여기서는 72px)을 넘지 않는 최대 글자 수를 찾는다.
성능을 위한 이진 탐색
단순히 글자를 하나씩 줄여가며(loop) 높이를 잴 수도 있다. 하지만 글자가 1,000자라면? 1,000번의 리플로우(Reflow)가 발생할 수 있다. 이는 심각한 성능 저하를 일으킨다. 그래서 이진 탐색(Binary Search)을 적용했다.
0~1000자 사이의 중간값(500자)을 넣어보고, 넘치면 0~499자 사이를 보고, 안 넘치면 500~1000자 사이를 보는 식이다. 이렇게 하면 몇 번의 연산만으로 3줄에 딱 맞는 글자 수를 찾아낼 수 있다. ▼
// 스타일 상수 (줄 높이 24px * 3줄 = 72px)
const LINE_HEIGHT = 24;
const MAX_HEIGHT = 72;
const SUFFIX = "... ";
useLayoutEffect(() => {
if (!ghostRef.current || !content) {
return;
}
const ghost = ghostRef.current;
// 1. Ghost 초기화: 일단 전체 텍스트를 넣어본다.
ghost.innerText = content;
// 2. 전체 높이가 3줄(72px) 이하라면 자를 필요 없음 -> 종료
if (ghost.scrollHeight <= MAX_HEIGHT) {
setTruncatedText(content);
setIsOverflowing(false);
return;
}
// 3. 3줄이 넘어가는 경우: 이진 탐색 시작
setIsOverflowing(true);
let start = 0;
let end = content.length;
let optimalLength = content.length;
while (start <= end) {
const mid = Math.floor((start + end) / 2);
// 핵심: 텍스트 뒤에 "... 더보기"가 들어갈 공간까지 고려해서 넣어본다.
ghost.innerText = content.slice(0, mid) + SUFFIX + "더보기";
if (ghost.scrollHeight <= MAX_HEIGHT) {
// 높이가 72px 이하면, 더 넣을 수 있다는 뜻
optimalLength = mid;
start = mid + 1;
} else {
// 높이가 넘치면, 글자를 줄여야 함
end = mid - 1;
}
}
// 4. 최종 결정된 길이로 텍스트 자르기
setTruncatedText(content.slice(0, optimalLength));
}, [content, isExpanded]);
여기서 `useEffect`가 아니라 `useLayoutEffect`를 사용한 이유는 깜빡임(Flicker) 때문이다. 브라우저가 화면을 그리기(Paint) 전에 글자 자르기 계산을 끝내야 사용자가 긴 글자 -> 짧은 글자로 변하는 과정을 목격하지 않는다.
실제 렌더링 영역과 Ghost 영역
이제 계산된 `truncatedText`를 보여주기만 하면 된다. 중요한 건 Ghost Element는 화면에 보이면 안 되지만, 크기 계산은 되어야 하므로 `display: none`이 아닌 `opacity: 0, z-index: -1, position: absolute` 등으로 숨겨두어야 한다는 점이다. (스타일링은 Tailwind-CSS를 사용했다.) ▼
return (
<div className="relative">
{/* 1. 실제 보여지는 텍스트 영역 */}
<p className={cn("text-sm ... leading-[24px]")}>
{isExpanded ? content : truncatedText}
{/* 더보기 버튼: 텍스트 바로 뒤에 인라인으로 붙는다 */}
{isOverflowing && !isExpanded && (
<>
<span className="text-gray-800">{SUFFIX}</span>
<button onClick={() => setIsExpanded(true)} ... >
더보기
</button>
</>
)}
</p>
{/* 2. 계산용 Ghost Element (사용자 눈엔 안 보임) */}
<p
ref={ghostRef}
className={cn(
"absolute top-0 left-0 z-[-1] opacity-0 pointer-events-none w-full", // 화면에서 숨김
"text-sm ... leading-[24px]", // ★중요: 실제 텍스트와 스타일이 100% 일치해야 함
)}
aria-hidden="true"
/>
</div>
);
그러면 이렇게 자연스럽게 더보기를 보여줄 수 있다. ▼


전체 코드 ▼
const LINE_HEIGHT = 18;
const MAX_LINES = 3;
const MAX_HEIGHT = LINE_HEIGHT * MAX_LINES;
const SUFFIX = "... ";
interface CustomTextProps {
content: string;
}
function CustomText ({ content }: CustomTextProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [truncatedText, setTruncatedText] = useState(content);
const [isOverflowing, setIsOverflowing] = useState(false);
// 리사이즈 시 강제 리렌더링을 위한 state
const [, setTick] = useState(0);
const ghostRef = useRef<HTMLParagraphElement>(null);
useLayoutEffect(() => {
if (!ghostRef.current || !content) return;
const ghost = ghostRef.current;
ghost.innerText = content;
// 높이가 기준치 이하라면 원본 그대로 사용
if (ghost.scrollHeight <= MAX_HEIGHT) {
setTruncatedText(content);
setIsOverflowing(false);
return;
}
// 높이가 초과되면 자르기 계산 시작
setIsOverflowing(true);
// 확장 상태라면 계산할 필요 없음 (성능 최적화)
if (isExpanded) return;
// 이진 탐색 로직 (기존 로직 유지)
let start = 0;
let end = content.length;
let optimalLength = content.length;
while (start <= end) {
const mid = Math.floor((start + end) / 2);
ghost.innerText = content.slice(0, mid) + SUFFIX + "더보기";
if (ghost.scrollHeight <= MAX_HEIGHT) {
optimalLength = mid;
start = mid + 1;
} else {
end = mid - 1;
}
}
setTruncatedText(content.slice(0, optimalLength));
}, [content, isExpanded /* resize시 tick 변경됨 */]);
// 화면 크기 변경 감지
useEffect(() => {
const handleResize = () => setTick((prev) => prev + 1);
// Debounce를 적용하면 더 좋지만, 여기선 간단히 처리
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// 스타일 객체로 lineHeight 주입 (Tailwind JIT 오류 방지)
const textStyles = {
lineHeight: `${LINE_HEIGHT}px`,
};
const commonClass = "text-sm break-all whitespace-pre-wrap";
return (
<div className="relative">
<p className={commonClass} style={textStyles}>
{isExpanded ? content : truncatedText}
{isOverflowing &&
(isExpanded ? (
<button
onClick={() => setIsExpanded(false)}
className="ml-1 text-sm font-medium text-gray-400 hover:underline align-baseline"
>
접기
</button>
) : (
<>
<span>{SUFFIX}</span>
<button
onClick={() => setIsExpanded(true)}
className="text-sm font-medium text-gray-400 hover:underline align-baseline"
>
더보기
</button>
</>
))}
</p>
<p
ref={ghostRef}
className={cn(
commonClass,
"absolute top-0 left-0 z-[-1] opacity-0 pointer-events-none w-full",
)}
style={textStyles}
aria-hidden="true"
/>
</div>
);
}
마치며
단순히 CSS 한 줄로 처리할 수 있었던 것을 이렇게 복잡한 로직으로 구현해야 하나? 라는 의문이 들 수 있다. 하지만 Overlay 방식의 어색함이나 Gradient 방식의 디자인 불일치를 해결하고, 텍스트 흐름을 깨지 않으면서 정확한 위치에 버튼을 두는 경험은 사용자에게 훨씬 깔끔한 인상을 준다.
개발 리소스와 UX 디테일 사이에서 줄타기하는 과정이었지만, 결과적으로 어떤 화면 크기나 폰트 설정에서도 정확하게 동작하는 견고한 컴포넌트를 얻게 되었다. 가끔은 이런 Low-level한 DOM 조작이 가장 확실한 답이 될 때가 있다.
'Develop > Web' 카테고리의 다른 글
| [React] Spread Attributes를 조심해야 하는 이유 (0) | 2025.11.26 |
|---|---|
| [Issue] Firefox에서 새로고침 시 Input 값이 초기화되지 않는 문제 해결하기 (1) | 2025.11.20 |
| [Web] div 수프를 끓이지 말아야 하는 이유 (0) | 2025.11.02 |
| [React][Error] tailwind css 설치 오류 (4) | 2025.10.05 |
| [NextJs] 하이아크 홈페이지 모노레포 적용기 (0) | 2025.09.23 |