본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

커스텀 인풋과 키보드 — 입력 표면과 공유 상태를 나누는 실험

2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.

한글 입력을 직접 다뤄보고 싶어 커스텀 인풋과 키보드 프로토타입을 만들기 시작했다. 이 글에서는 스타일을 분리하려던 첫 시도와 키보드를 여러 인풋에서 공유하도록 바꾼 방향을 기록한다.

Shadow DOM으로 분리하려던 것

처음에는 인풋 주변의 스타일과 렌더링을 독립된 영역으로 묶고 싶었다. 이때 Shadow DOM이 도움이 될 수 있지만, 네이티브 input에 직접 attachShadow()를 호출하는 방식은 사용할 수 없다. 지원하는 host 요소를 두고 그 안에 렌더링해야 한다. MDN attachShadow

아래는 div를 host로 사용하는 스타일 격리 예시다.

import { useEffect, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';

export function ShadowWrapper({ children, css }: {
  children: ReactNode;
  css?: string;
}) {
  const [host, setHost] = useState<HTMLDivElement | null>(null);
  const [root, setRoot] = useState<ShadowRoot | null>(null);

  useEffect(() => {
    setRoot(host ? (host.shadowRoot ?? host.attachShadow({ mode: 'open' })) : null);
  }, [host]);

  return (
    <div ref={setHost}>
      {root && createPortal(
        <>{css && <style>{css}</style>}{children}</>,
        root,
      )}
    </div>
  );
}

Shadow DOM은 스타일과 DOM 경계를 제공한다. IME를 우회하거나 조합 이벤트의 의미를 바꾸는 기능은 아니다. 인풋을 안에 넣어도 입력과 포커스 처리는 별도로 확인해야 한다.

키보드를 인풋마다 만들 필요는 없었다

첫 시도에서는 인풋마다 키보드를 연결하려 했다. 하지만 동시에 조작하는 입력 대상이 하나라면 키보드도 공유할 수 있었다.

그래서 키보드는 Provider에서 하나로 관리하고, 인풋이 포커스를 받을 때 현재 대상과 연결하는 구조로 방향을 바꿨다. 인풋의 값과 선택 영역은 각 입력이 갖고, 키보드는 어느 대상에 입력을 보낼지 알아야 한다.

공유되는 키보드와 개별 인풋의 수명도 다르다. 인풋이 사라지면 활성 대상을 해제하고, 다른 인풋으로 이동할 때 이전 대상의 조합 상태를 어떻게 정리할지 정해야 한다.

직접 렌더링할 때 늘어나는 책임

네이티브 입력 표면까지 대체하면 캐럿과 선택 영역도 직접 다뤄야 한다. 글자별 위치를 확인하는 실험은 도움이 되지만 split("")은 UTF-16 코드 단위를 나누므로 이모지와 결합 문자를 사용자가 보는 한 글자와 다르게 나눌 수 있다.

문자 분할뿐 아니라 줄바꿈, 비례 글꼴, 양방향 텍스트, 복사·붙여넣기와 실행 취소까지 영향을 준다. 네이티브 input을 유지할 수 있는 범위에서는 브라우저가 제공하는 동작을 활용하는 것이 더 작은 선택일 수 있다.

이번 프로토타입에서 얻은 것은 모든 입력 문제의 해결책보다 책임을 나눌 기준이었다. 스타일의 격리, 현재 입력 대상, 조합 규칙, 화면에 그리는 캐럿은 각각 다른 문제였다.

다음 단계에서는 공유 키보드와 인풋의 연결을 구체화하려 한다. 실제 제품에 적용할 범위는 접근성과 여러 입력 환경을 확인하며 정해야 한다.

Assisted by AI

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →