동적 높이 조정이 가능한 멀티라인 입력 필드 구현 기법 분석

고정 높이 입력 필드의 한계와 동적 조정의 필요성

웹 애플리케이션에서 댓글 작성, 메모 편집, 메시지 입력 등 다량의 텍스트를 다루는 기능은 매우 흔하다. 그러나 전통적인 <input> 또는 고정된 높이의 <textarea>를 사용할 경우, 사용자가 많은 텍스트를 입력해도 영역이 확장되지 않아 강제 스크롤이 발생하거나 콘텐츠가 가려지는 문제가 빈번하다. 특히 모바일 디바이스에서는 초기 화면을 깔끔하게 유지하면서도 입력량이 증가하면 자연스럽게 영역이 확장되는 '호흡하는 입력창'이 사용자 경험(UX)을 크게 향상시킨다.

이러한 요구사항을 해결하기 위해 여러 접근 방식이 존재하지만, 가장 안정적이고 성능에도 부담이 적은 방법은 브라우저 표준 DOM API를 활용하는 것이다. 아래에서는 <textarea> 요소를 기반으로 자바스크립트를 결합하여 구현하는 표준적인 패턴과 주요 프론트엔드 프레임워크에서의 적용 방법을 다룬다.

네이티브 DOM 조작을 통한 기본 구현 패턴

자동 높이 조정의 핵심 로직은 단순하다. 요소의 콘텐츠가 차지하는 실제 높이를 의미하는 scrollHeight 속성을 활용한다. 텍스트가 변경될 때마다 현재 CSS 높이를 auto로 초기화한 후, 다시 scrollHeight 값을 픽셀 단위로 적용하면 된다. 이 과정에서 패딩과 테두리가 크기 계산에 포함되도록 box-sizing: border-box를 적용하고, 사용자가 수동으로 크기를 변경하지 못하도록 resize: none을 설정하는 것이 중요하다.

<textarea id="dynamicInput" placeholder="내용을 입력하세요..."></textarea>

<style>
  #dynamicInput {
    width: 100%;
    min-height: 48px;
    padding: 12px;
    font-size: 1rem;
    line-height: 1.6;
    border: 1px solid #c1c1c1;
    border-radius: 8px;
    resize: none;
    overflow-y: hidden;
    box-sizing: border-box;
    transition: height 0.2s ease;
  }
</style>

<script>
  const inputElement = document.getElementById('dynamicInput');

  const adjustHeight = (target) => {
    target.style.height = 'auto';
    target.style.height = `${target.scrollHeight}px`;
  };

  inputElement.addEventListener('input', (e) => adjustHeight(e.target));
  window.addEventListener('DOMContentLoaded', () => adjustHeight(inputElement));
</script>

위 예시는 별도의 빌드 도구나 라이브러리 없이 순수 자바스크립트로 구현된 기본 형식이다. input 이벤트 리스너를 통해 실시간으로 DOM의 스크롤 높이를 재계산하며, 초기 렌더링 시점에도 한 번 실행하여 기본값이 존재할 경우에도 정상적으로 확장되도록 처리했다.

Vue 3 Composition API 환경에서의 적용

컴포넌트 기반 아키텍처에서는 로직을 재사용 가능한 훅 또는 컴포넌트 내부 로직으로 분리하는 것이 일반적이다. Vue 3의 <script setup> 문법을 활용하면 템플릿 참조를 통해 쉽게 구현할 수 있다. 텍스트 변경 시 DOM 업데이트가 완료된 시점에 높이 조정이 이루어지도록 nextTick을 활용하는 것이 핵심이다.

<template>
  <textarea
    ref="contentBox"
    :value="modelValue"
    class="smart-textarea"
    placeholder="메모를 작성하세요"
    @input="onContentChange"
  />
</template>

<script setup>
import { ref, nextTick, watch, onMounted } from 'vue';

const props = defineProps({
  modelValue: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue']);

const contentBox = ref(null);

const onContentChange = (event) => {
  emit('update:modelValue', event.target.value);
  syncHeight();
};

const syncHeight = () => {
  nextTick(() => {
    const el = contentBox.value;
    if (el) {
      el.style.height = 'auto';
      el.style.height = `${el.scrollHeight}px`;
    }
  });
};

watch(() => props.modelValue, () => syncHeight());
onMounted(() => syncHeight());
</script>

<style scoped>
.smart-textarea {
  width: 100%;
  min-height: 50px;
  padding: 14px;
  line-height: 1.5;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  resize: none;
  overflow-y: hidden;
  box-sizing: border-box;
  font-family: system-ui, -apple-system, sans-serif;
}
</style>

React Hooks 환경에서의 적용

React에서는 상태 변경과 부작용 사이클을 분리하는 것이 중요하다. 입력값을 상태로 관리하되, 실제 높이 조정은 상태 업데이트가 반영된 렌더링 직후에 수행되도록 useEffect 훅을 활용한다. 불필요한 리렌더링이나 스크롤 포지션 끊김을 방지하기 위해 참조 객체를 적절히 사용한다.

import { useState, useRef, useEffect } from 'react';

export function AutoExpandTextarea({ initialValue = '', placeholder = '내용을 입력하세요...' }) {
  const [content, setContent] = useState(initialValue);
  const textRef = useRef(null);

  const updateDimensions = () => {
    const element = textRef.current;
    if (!element) return;
    element.style.height = 'auto';
    element.style.height = `${element.scrollHeight}px`;
  };

  useEffect(() => {
    updateDimensions();
  }, [content]);

  const handleInputChange = (e) => {
    setContent(e.target.value);
  };

  return (
    <textarea
      ref={textRef}
      value={content}
      placeholder={placeholder}
      onChange={handleInputChange}
      className="expandable-input"
    />
  );
}

/* 스타일은 전역 CSS 또는 CSS Modules/Styled Components에 정의 */
/* .expandable-input { width: 100%; min-height: 44px; padding: 10px; resize: none; overflow: hidden; box-sizing: border-box; border: 1px solid #ccc; border-radius: 6px; line-height: 1.4; } */

세 프레임워크 패턴 모두 동일한 DOM 계산 원리를 공유하지만, 각 라이브러리의 생명주기(Lifecycle)와 상태 관리 철학에 맞게 이벤트 바인딩과 사이드이펙트 처리를 구분하여 작성했다. 실제 프로덕션 환경에서는 최대 높이(Max-Height) 제한이나 초기 렌더링 지연(Jumping) 현상을 보완하기 위해 CSS 트랜지션과 함께 초기 스크롤 위치를 기억하는 로직을 추가로 적용하는 것을 권장한다.

태그: textarea auto-resize scrollHeight Vue.js React

9월 30일 19:22에 게시됨