ContentEditable div의 커맨드박스를 구현한 이후 2주 뒤, 다른 모듈에서 커맨드박스를 띄워야한다는 개발건이 들어오게 되었 습니다. UI가 달라서 별도로 구현해야할지 혹은 기존에 구현했던 것을 모듈화하여 재사용을 해야할지 고민하였습니다.
하지만 해당 모듈에서 구현된 텍스트에디터는 textarea를 기반으로 만들어졌고 UI 역시 다른 부분이 많아 해당 모듈의 전용 커맨드박스를 새롭게 구현하게 되었습니다.
1) 본격적으로 개발하기 전 공용 Ref 분리하기 (텍스트에디터 ref, 커맨드박스 상태)
- “Textarea ↔ CommandBox를 같은 화면 상태로 묶고, 조건부로 CommandBox를 Mount
const textAreaRef = useRef<TextareaImperativeHandleRef>(null)
const hashIndexRef = useRef<number | null>(null)
const [isCommandBoxOpened, setIsCommandBoxOpened] = useState(false)
;<ChatInputTextarea
textAreaRef={textAreaRef}
hashIndexRef={hashIndexRef}
commandBoxOpened={isCommandBoxOpened}
onOpenCommandBox={() => setIsCommandBoxOpened(true)}
/>
{
isCommandBoxOpened && (
<ChatInputCommandBoxContainer
isOpened={isCommandBoxOpened}
setIsOpened={setIsCommandBoxOpened}
textEditorRef={textAreaRef}
hashIndexRef={hashIndexRef}
locale={defaultLanguage}
onSetTextInput={next => actions.onSetTextInput(next)}
/>
)
}
우선 과거에는 textAreaRef 가 ChatInputTextarea 내부에 존재하고 있었습니다.
하지만 커맨드박스에서도 textarea에 입력하는 #을 감지하여 커맨드박스 오픈 여부를 결정해야했기 때문에 해당 textAreaRef 를 상위로 올려 단일 책임 분리하도록 했습니다.
ChatInputTextarea는 입력, # 감지, submit 차단에 집중하고ChatInputCommandBoxContainer는 검색, 리스트, 키보드 내비, 템플릿 적용, 위치 계산에 집중합니다.
그리고 hashIndexRef라는 ref를 둔 이유는 #가 찍히는 위치는 렌더링을 유발할 상태가 아니라 “즉시 참조해야하는 커서 기반 인덱스”라서 state보다는 ref가 더 안전하고 불필요한 렌더를 줄일 수 있습니다. (특히 빠른 입력/연속 이벤트)
그렇다면 여기서 ref, state 이유 말고 왜 index를 저장해야하는지 의문이 들 수 있습니다. 해당 커맨드박스를 새롭게 구현하게 된 이유이기도 한데요, 이는 textarea라서 입니다.
좀 더 자세하게 말해보자면,
- textarea는 DOM 내부 노드로 caret 위치를 얻거나 특정 문자 좌표를 얻는 API(Selection, Range)가 없음 → 결국 “문자열 인덱스 기반” + “미러 div로 좌표 계산”이 필요해집니다.
만약 텍스트에디터가 contentEditable 이었다면 hashIndexRef 와 같은 문자열 인덱스 대신에 Range/Selection anchor를 저장하는 형태로 구현했을 것입니다.
const editorRef = useRef<HTMLDivElement>(null)
const triggerRangeRef = useRef<Range | null>(null) // ref를 Range 타입으로
const [isCommandBoxOpened, setIsCommandBoxOpened] = useState(false)
그리고 ChatInputTextarea (텍스트에디터)에서 onTextInput 함수 내부에서 Selection, Range을 활용해 # 위치를 저장하였을 것입니다. 해당 로직은 ChatInputTextarea 에서 자세하게 살펴보겠습니다.
2) 텍스트에디터에서 커맨드박스 트리거(’#’) 찾기
- ‘#’ 입력시 커맨드박스 오픈 상태를 변경하고, 이후 입력값을 검색어 키워드 처리하기
이제 텍스트에디터 내부 로직을 변경해보도록 하겠습니다. 우선 제가 전달받은 커맨드박스 요구사항들 중 텍스트에디터와 관련된사항들은 아래와 같습니다.
- 텍스트에디터에서 #을 입력시 커맨드박스가 표시
- 커맨드박스가 열리고 ‘#’ 이후에 키워드 입력시 해당 키워드가 포함된 템플릿만 표시되도록 검색어 처리
- 템플릿을 선택하면 해당 템플릿 콘텐츠가 텍스트에디터에 붙여넣기 되며
#{검색어}는 삭제처리
이를 위해서 ChatInputTextarea 에 onChangeTextInput 라는 텍스트입력 onChange 용 핸들러 함수를 수 정했습니다.
참고로, onKeydown이 아닌 onChange 핸들러로 #을 감지한 이유는, keydown은 **“문자가 value에 반영되기 전”**에 이벤트가 전달되기 때문에 caret 혹은 문자의 위치가 흔들릴 수 있기 때문입니다. (특히 IME/조합 입력, Textarea DS 내부처리 등)
기존의 onChangeInput 은 아래와 같았습니다.
const onChangeTextInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
actions.onSetTextInput(e.target.value)
}
단순하게 사용자가 입력한 내용을 jotai로 전역상태 관리하고 있는 함수였습니다.
하지만, 사용자가 ‘#’이라는 텍스트를 입력하는 순간, 커맨드박스를 오픈해야하기에 해당 함수 역시 보다 복잡하게 변경되어야 했고, 아래와 같이 수정하였습니다.
// 이전 value 기억 (onChange에서 "# 신규 입력" 감지용)
const prevValueRef = useRef<string>(baseInputText ?? "")
const onChangeTextInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = e.target.value
const caret = e.target.selectionStart ?? nextValue.length
const prevValue = prevValueRef.current
// store 업데이트
actions.onSetTextInput(nextValue)
// ref 갱신 (중요!)
prevValueRef.current = nextValue
if (commandBoxOpened) return
const idx = caret - 1
if (idx < 0) return
// 방금 #이 들어온 자리인지 체크 (길이 비교 X)
const justTypedHash = nextValue[idx] === "#" && (prevValue[idx] ?? "") !== "#"
if (justTypedHash) {
hashIndexRef.current = idx
onOpenCommandBox()
}
}
커맨드박스의 경우 사용자가 지금 막 ‘#’을 눌러서 새 ‘#’이 삽입된 순간을 감지해야합니다.
이 말은 즉, 해당 ‘#’이 커서의 바로 앞에 있는 순간인 caret의 index - 1 이라는 것입니다.
따라서 저는 1)커서의 바로 앞 인덱스(caret - 1)의 문자가 ‘#’인지, 그리고 2) 해당 인덱스의 직전 문자가 ‘#’인지를 확인해서 정말 해당 ‘#’가 새로 생긴 것인지를 판단했습니다.
어떤 분들의 경우 문자열의 길이 기반으로, “문자열 길이가 1 늘었고, 그 늘어난 위치의 값이 ‘#’이면 방금 ‘#’을 입력했을 것이다.”라는 추론을 하여 문자열 길이를 통해 비교할 수도 있을 것입니다.
하지만, 문자열 길이로 트리거 여부를 판단하게 되었을 때 아래 문제가 발생할 수 있습니다.
길이 비교 방식의 한계
- abc 에서 b를 드래그하여 #을 입력하는 경우
- prev: “abc” (length: 3)
- next: “a#c” (length: 3)
- → 사용자는 #을 새로 입력했지만 길이비교 방식은 잡을 수 없습니다.
- 그 외에도, IME(한글조합), 자동완성, 모바일 키보드와 같은 경우도 문제가 생길 수 있습니다.
- 한번의 onChange에서 여러 글자가 바뀜
- caret, selectionStart 타이밍이 미묘하게 흔들림
- 중간 조합 상태에서 length 규칙 깨질 수 있음
물론, 지금 로직만으로는 붙여넣기를 하고 그 중간에 ‘#’이 들어있으면 문제가 되겠지만, 저희 커맨드박스 오픈 정책에서는 사용자가 입력한 ‘#’만을 판단하기로 했기 때문에 현재 로직으로 충분했습니다.
3) ‘커맨드박스’를 만들어보자
3-1. 커맨드박스 Portal 띄우기
가장 처음 이미지로 보여드렸듯이 커멘드박스는 입력창 위에 떠야하는 overlay 형태입니다. 상위 레이아웃이 overflow hidden 혹은 stacking context가 있으면 잘릴 수 있습니다.
따라서 portal을 body로 올려 커맨드박스를 항상 입력창 위에 띄울 수 있도록 했습니다.
우선 아래는 완성된 커맨드박스의 컴포넌트 형태입니다. 가장 상위로 감싼 CommandBoxWithPortal 이 바로 Portal 입니다.
<CommandBoxWithPortal>
<ChatInputCommandBoxWrapper
toolPositionProp={commandBoxPosition}
wrapperRefCallback={el => {
commandBoxWrapperRef.current = el
}}
>
<Suspense fallback={<ChatInputCommandBoxContainerSkeleton />}>
<CommandBoxList
locale={locale}
selectedIndex={selectedTemplateIndex}
onHoverIndexChange={handleHoverIndexChange}
commandBoxRefCallback={bindTemplateListRef}
onItemClick={applyTemplateItem}
onUseTemplateListQuery={handleUseTemplateListQuery}
/>
</Suspense>
</ChatInputCommandBoxWrapper>
</CommandBoxWithPortal>
이때 portal을 바로 만드는것이 아니고 children을 받는 컴포넌트 형태로 구성했습니다. (레이어드 아키텍쳐 구성을 위함)
interface PortalWrapProps {
children?: ReactNode
}
export function CommandBoxWithPortal({ children }: PortalWrapProps) {
const subDiv = useMemo(() => {
const div = document.createElement("div")
div.style.position = "fixed"
div.style.zIndex = "100"
div.style.width = "100%"
div.style.height = "100%"
div.style.top = "0"
div.setAttribute("data-mf-{모듈명}", "true")
div.id = "portal_wrap"
return div
}, [])
useEffect(() => {
document.body.appendChild(subDiv)
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = "unset"
subDiv.remove()
}
}, [subDiv])
return createPortal(
<div className="fixed h-screen w-screen">{children}</div>,
subDiv,
)
}
우선 커맨드박스 위치 계산을 브라우저 기준으로 하기위해 subDiv를 만들고 컴포넌트가 화면에 반영된 후 DOM에 영향을 주고 언마운트시 clean up을 통해 원상태로 되돌리는(unset) 구조 입니다.
해당 컴포넌트보다 가벼운 버전으로도 충분할 수 있지만, 현재 서비스의 모든 포탈구조가 다음과 같은 형태를 따르고 있어 컨벤션을 맞추기 위해 최대한 구조를 맞춰 작성했습니다.
3-2. 커맨드 박스의 위치 계산하기
커맨드 박스를 Portal로 띄워야하는데 어디에 띄워야할까요? 위치는 기획마다 다르겠지만 제가 요구받은 위치는 사용자가 입력한 ‘#’의 윗부분이었습니다.
커맨드 박스의 위치를 계산하는 Positioning을 레이어로 분리해서 책임을 별도로 구분하기로 했습니다. (Portal 처럼)
interface ChatInputCommandBoxWrapperProps {
toolPositionProp: CommandBoxPosition
wrapperRefCallback: (el: HTMLDivElement | null) => void
children: ReactNode
}
export function ChatInputCommandBoxWrapper({
toolPositionProp,
wrapperRefCallback,
children,
}: ChatInputCommandBoxWrapperProps) {
const [boxEl, setBoxEl] = useState<HTMLDivElement | null>(null)
const [boxPosition, setBoxPosition] = useState<CommandBoxPosition>(() => ({
left: toolPositionProp.left ?? 0,
top: toolPositionProp.top ?? 0,
}))
const handleWrapperRefCallback = useCallback(
(el: HTMLDivElement | null) => {
setBoxEl(el)
wrapperRefCallback(el)
},
[wrapperRefCallback],
)
useLayoutEffect(() => {
if (!boxEl) return
const updatePosition = () => {
const viewportWidth = document.documentElement.clientWidth
const nextTop = toolPositionProp.top ?? 0
const nextLeft = toolPositionProp.left ?? 0
// 우측 overflow 방지
const boxRect = boxEl.getBoundingClientRect()
const boxRight = nextLeft + boxRect.width
const safeLeft =
boxRight + COMMAND_BOX_RIGHT_SAFE_OFFSET > viewportWidth
? Math.max(
0,
viewportWidth - (boxRect.width + COMMAND_BOX_RIGHT_SAFE_OFFSET),
)
: nextLeft
setBoxPosition({ left: safeLeft, top: nextTop })
}
requestAnimationFrame(updatePosition)
}, [boxEl, toolPositionProp.left, toolPositionProp.top])
const commandBoxStyle: CSSProperties = {
position: "fixed",
zIndex: 9999,
left: `${boxPosition.left}px`,
top: `${boxPosition.top}px`,
transform: `translateY(calc(-100% - ${COMMAND_BOX_GAP}px))`, // 박스 자체를 위로 올려서 # 안 가리게
}
return (
<div
style={commandBoxStyle}
className="bg-pltt-origin-white100 shadow-pltt-medium rounded-pltt-md max-h-[403px] overflow-y-auto pointer-events-auto"
ref={handleWrapperRefCallback}
data-command-box-wrapper="true"
onMouseDown={e => e.stopPropagation()}
>
{children}
</div>
)
}
여기서 핵심은 useLayoutEffect 부분입니다. 우선 내부의 함수는 함수명으로 유추할 수 있듯이 포지션을 계산하는 함수입니다.
useLayoutEffect를 사용한 이유는 다들 예상하시겠지만, 위치 계산은 화면에 그리기 직전 flicker 없이 진행되어야하기 때문에 레이아웃 단계에서 해야합니다. 이를 위해 useEffect가 아닌 useLayoutEffect를 사용하여 내부에서 포지션을 계산했습니다.
updatePosition은 viewport와 상위에서 전달받은 기준 좌표(사용자가 입력한 #의 위치)를 비교하여 viewport overflow를 고려하여 포지션을 보정해주는 역할을 합니다.
이후 requestAnimationFrame을 사용하여 브라우저가 레이아웃 계산을 완료한 다음 프레임에서 위치 계산을 진행했습니다. (실제 DOM 계산 및 children에서 Suspense 사용하여 fallback 렌더링함 등의 이유)
3-3. 커맨드박스에 데이터 불러오기 (UI View 완성하기)
그럼 이제 커맨드박 스를 띄우고 위치를 보정하는 포지셔닝 Wrapper까지 완성했습니다. 기준좌표인 toolPositionProp 을 주지 않고 빈 div를 children에 넣어보면 # 부근에서 표시되는것을 확인할 수 있습니다.
그렇다면 이제 실제로 데이터를 불러오는 방식은 어떻게 진행되었을까요?
우선 CommandBoxList가 실제 커맨드박스 내부의 템플릿 데이터 리스트를 표시할 컴포넌트인데, Suspense를 감싸고 fallback을 통해 데이터가 로드될때까지 Skeleton UI를 표시해주었습니다.
<Suspense fallback={<ChatInputCommandBoxContainerSkeleton />}>
<CommandBoxList
locale={locale}
selectedIndex={selectedTemplateIndex}
onHoverIndexChange={handleHoverIndexChange}
commandBoxRefCallback={bindTemplateListRef}
onItemClick={applyTemplateItem}
onUseTemplateListQuery={handleUseTemplateListQuery}
/>
</Suspense>
사실 useSuspenseQuery를 쓰지 않고 useQuery를 사용했다면, 리턴 값인 loading 상태를 통해 CommandBoxList와 ChatInputCommandBox를 분리하지 않고 하나의 컴포넌트 안에서 조건부 렌더링으로 충분히 구현할 수도 있었습니다.
다만 이 커맨드박스는 부분 데이터 로딩이 반복적으로 발생하는 UI였고, 그 과정에서도 기존 리스트 UI와 키보드 탐색 흐름을 유지해야 했기 때문에, loading, fetchMore, selection, focus 같은 상태 전이를 컴포넌트 내부 분기 로직으로 직접 관리하는 위험을 줄이고 싶었습니다.
그래서 cursor 기반 데이터 조회 및 확장 로직 부분을 CommandBoxList 라는 별도의 조합 레이어로 분리하고, 이 레이어가 제공할 수 있는 기능만을 명시적인 인터페이스 형태로 상위에 노출하는 구조를 선택했습니다.
이를 통해 상위 컴포넌트는 키보드 입력(arrow down/up, 스크롤 등)과 선택 흐름을 중심으로 제어하고, 데이터 로딩 방식이나 내부 구현에 대한 의존 없이 일관된 방식으로 CommandBox를 관리할 수 있도록 했습니다. (내부에서 사용한 훅에 대해서는 이후 더 자세히 다루겠습니다.)
export type CommandBoxListQueryApi = {
templateItems: CommandBoxTemplateItem[]
fetchMoreTemplateData: () => void
setInputValue: (v: string) => void
reset: () => void
hasNextPage: boolean
}
interface CommandBoxListProps {
locale: LocaleEnum
selectedIndex: number
commandBoxRefCallback: (el: HTMLDivElement | null) => void
onItemClick: (item: CommandBoxTemplateItem) => void
onHoverIndexChange: (idx: number) => void
onUseTemplateListQuery: (query: CommandBoxListQueryApi) => void
}
export function CommandBoxList({
locale,
selectedIndex,
commandBoxRefCallback,
onItemClick,
onHoverIndexChange,
onUseTemplateListQuery,
}: CommandBoxListProps) {
/** 템플릿 데이터 무한스크롤 쿼리 훅 */
const {
templateItems,
fetchMoreTemplateData,
getTemplateDataByKeyword,
hasNextPage,
} = useChatTemplateData({
pageSize: 10,
})
/** 템플릿 검색어를 관리하는 훅 */
const { setInputValue, inputValue, reset } = useCommandBoxSearch({
search: getTemplateDataByKeyword,
})
/** 템플릿 검색어와 일치하는 템플릿 글자 하이라이트 훅 */
const highlightTitleMap = useHighlightedTemplateTitles({
templateItems,
keyword: inputValue,
})
useEffect(() => {
onUseTemplateListQuery({
templateItems,
fetchMoreTemplateData,
setInputValue,
reset,
hasNextPage,
})
}, [
templateItems,
fetchMoreTemplateData,
setInputValue,
reset,
onUseTemplateListQuery,
hasNextPage,
])
/** ChatInputCommandBox: 실제 커맨드박스 내 템플릿 리스트 및 preview UI 담당 컴포넌트 **/
return (
<ChatInputCommandBox
templateItems={templateItems}
locale={locale}
selectedIndex={selectedIndex}
highlightTitleMap={highlightTitleMap}
commandBoxRefCallback={commandBoxRefCallback}
onItemClick={(index: number) => {
const item = templateItems[index]
if (!item) return
onItemClick(item)
}}
onHoverIndexChange={onHoverIndexChange}
/>
)
}
왜 hook이 아닌 ref를 사용해 api를 등록했을까? 이건 “상태를 재사용하는 훅” 문제가 아니라, 하위 컴포넌트가 제공하는 동적 기능을 상위 이벤트 시스템에 ‘등록(register)’하는 문제였기 때문에 hook보다 ref + useEffect가 맞았습니다. React hook은 기본적으로
- 자기 내부 상태를 관리하거나
- 외부 시스템을 구독하거나
- 재사용 가능한 로직을 캡슐화 하는 용도로 보통 아래와 같은 형태로 사용되며, 호출한 쪽이 주도권을 가집니다.
const { data, fetchMore } = useSomething()하지만, 현재 구조에서는 주도권이 반대입니다. 지금 구조에서는:
- 데이터 로딩 로직 →
CommandBoxList(하위)- 이벤트 트리거 → 상위 (키보드, 스크롤) 즉, “하위가 기능을 만들어서, 상위에 제공해야 하는 구조”로, 이걸 hook으로 만들면 자연스럽지 않습니다. 또한 hook으로 만들면 상위가 데이터쿼리, 검색상태, fetch를 직접 소유하게되는 문제 외에도 Suspense boundary가 깨지며 키보드 이벤트에도 영향이 미칠 수 있습니다.
- 상위에서
useSuspenseQuery호출- 상위 전체가 suspend
4) 다양한 이벤트로 커맨드박스 제어하기 (feat. RxJS)
이제 커맨드박스의 기본적인 UI와 동작은 완성되었습니다. 하지만 실제로 사용 가능한 컴포넌트가 되기 위해서는 이벤트 처리가 남아 있습니다.
커맨드박스를 열어 템플릿을 선택해 에디터에 입력하는 과정에서, 사용자는 제한적인 뷰 안에서 여러 템플릿을 탐색해야 합니다.
일반적으로는 스크롤을 내려가며 리스트를 확인하고, 원하는 템플릿을 발견하면 클릭을 통해 선택할 수 있습니다. 하지만 많은 사용자들은 반복적인 마우스 클릭을 번거롭게 느끼기도 합니다.
커맨드박스를 연 직후 바로 키보드로 템플릿 순서를 이동하고 싶을 수도 있고, 엔터 키만으로 선택을 완료하고 싶을 수도 있으며, 입력과 동시에 검색을 통해 템플릿을 좁혀보고 싶을 수도 있습니다.
따라서 커맨드박스는 단순한 클릭 UI가 아니라, 키보드 이벤트, 마우스 이벤트, 스크롤 이벤트가 함께 작동하는 인터랙션 중심 UI로 설계될 필요가 있습니다.
이러한 다양한 이벤트를 하나의 흐름으로 안정적으로 제어하기 위해, 본 구현에서는 RxJS를 사용해 이벤트를 스트림 단위로 구성했습니다.