react-resizable-panels 이슈 기여기
react-split을 react-resizable-panels로 마이그레이션하는 과정에서 발견한 버그를 이슈로 정리하고 라이브러리 개선에 기여한 경험을 공유합니다.

사내 서비스에는 위 이미지처럼 여러 문서를 한 화면에 열고 가로와 세로로 나누어 볼 수 있는 스플릿 뷰 기능이 있습니다. 해당 기능을 통해 원고를 작성하면서 다른 문서를 참고하거나 여러 자료를 함께 비교하는 등 한정된 화면을 효율적으로 활용할 수 있습니다. 스플릿 뷰는 많은 사용자가 이용하는 핵심 기능 중 하나였기에 그만큼 좋은 사용성을 제공하는 것이 중요했습니다.
패널을 열 때마다 깜빡이는 화면
당시 스플릿 뷰에는 패널을 추가하거나 제거하면 이미 열어 둔 패널들이 모두 함께 깜빡이는 문제가 있었습니다. 열어 둔 패널이 적을 때는 문제가 크게 느껴지지 않았지만 화면 분할이 많아지거나 네트워크가 불안한 환경에서는 그 불편함이 크게 느껴졌습니다.

깜빡임의 원인을 찾기 위해 패널을 렌더링하는 컴포넌트를 살펴봤습니다.
const splitViewKey = panelIds.join("-");
<SplitView key={splitViewKey}>
{panelIds.map((panelId) => (
<EditorPanel key={panelId} panelId={panelId} />
))}
</SplitView>
위 코드는 실제 구조를 단순화한 모습입니다.
코드를 살펴보니 패널을 추가하거나 제거할 때마다 splitViewKey가 달라지고 이에따라 컴포넌트의 key가 달라진다는 것을 알 수 있었습니다. React는 key가 달라진 컴포넌트를 기존 컴포넌트와 다른 것으로 인식하기 때문에 패널의 변화에 따라 SplitView 컴포넌트가 새로 마운트 되고 그 아래에 있던 모든 패널 또한 다시 만들어졌습니다.
이를 통해 깜빡임의 원인이 패널 조작에 따라 key가 바뀌면서 발생하는 재마운트라는 것을 알 수 있었습니다. 그렇다면 여기에 key가 필요한 이유는 무엇일까요? 그 이유를 자세히 알아보기 위해 key를 제거한 뒤의 동작을 살펴봤습니다.
key가 필요했던 이유
컴포넌트에서 key를 제거하자 예상대로 기존 패널들은 더 이상 재마운트되지 않았습니다.
문제가 해결된 것처럼 보였지만 패널을 조작하자 또 다른 문제가 발생했습니다. 패널을 추가하자 새 패널 사이에 gutter가 생기지 않거나 패널 크기가 올바르게 조절되지 않았고 오른쪽으로 패널을 추가한 경우에는 react-split 내부에 오류가 발생해 전체 워크스페이스가 오류 화면으로 전환되기도 했습니다.

처음에는 key를 사용한 방법이 전체 재마운트를 일으키는 불필요한 코드라고 생각했지만 막상 제거해 보니 이 코드가 동적인 패널을 구현하기 위한 우회 방법이었다는 것을 알 수 있었습니다. 어떤 목적으로 key를 사용했는지는 이해했지만 근본적으로 왜 key가 필요한 것인지에 대한 의문은 여전히 남아있었습니다. 이 의문에 대한 답을 찾기 위해 react-split의 내부 동작을 살펴보았습니다.
react-split 내부에서 찾은 진짜 이유
react-split은 처음 마운트될 때 현재 DOM의 children을 이용해 Split 인스턴스를 생성합니다.
componentDidMount() {
const { children, gutter, ...options } = this.props;
// gutter 생성 및 __isSplitGutter 표시 로직은 생략합니다.
this.split = Split(this.parent.children, options);
}
componentDidUpdate(prevProps) {
const {
children,
minSize,
sizes,
collapsed,
...options
} = this.props;
const otherProps = [
"maxSize",
"expandToMin",
"gutterSize",
"gutterAlign",
"snapOffset",
"dragInterval",
"direction",
"cursor",
];
let needsRecreate = otherProps
.map((prop) => this.props[prop] !== prevProps[prop])
.reduce((changed, current) => changed || current, false);
// minSize 비교 로직은 생략합니다.
// minSize가 변경된 경우에도 needsRecreate가 true가 됩니다.
if (needsRecreate) {
options.minSize = minSize;
options.sizes = sizes || this.split.getSizes();
this.split.destroy(true, true);
options.gutter = (index, direction, pairB) => pairB.previousSibling;
this.split = Split(
Array.from(this.parent.children).filter(
(element) => !element.__isSplitGutter,
),
options,
);
} else if (sizes) {
// sizes만 변경되면 this.split.setSizes(...)를 호출합니다.
}
// collapsed만 변경되면 this.split.collapse(...)를 호출합니다.
}
componentDidUpdate를 살펴보면 Split 인스턴스를 다시 만드는 조건에 children이 포함되지 않는다는 것을 확인할 수 있습니다. maxSize, gutterSize, direction 같은 일부 옵션이 바뀌면 인스턴스를 다시 만들지만 children의 추가와 제거는 재생성 조건에 들어 있지 않았습니다.
동적으로 패널이 변경될 때 변경된 children이 DOM에 렌더링되지만 Split 인스턴스는 재생성되지 않아 이전 인스턴스를 그대로 사용하게 됩니다. 결국 새로운 패널 구조를 반영하려면 Split 인스턴스를 다시 만들어야 했고 기존 구현에서는 컴포넌트의 key를 바꾸는 방법으로 이 과정을 강제로 실행하고 있었습니다.
이 문제를 해결하기 위해 기존처럼 key를 사용해 우회하기보다 동적으로 패널을 추가, 삭제할 수 있는 다른 라이브러리를 사용하기로 결정했습니다. 비교 끝에 react-resizable-panels를 선택했고 마이그레이션을 진행하였습니다. 모든게 해결된 것처럼 보였지만 또 다른 문제가 기다리고 있었습니다.
사라지지 않는 separator 상태
마이그레이션을 마치고 여러 문서를 열고 닫으면서 스플릿 뷰가 제대로 동작하는지 테스트를 진행했습니다. 그러다 패널 크기를 조절한 뒤 에디터로 마우스를 옮겼을 때 이상한 점을 발견했습니다. 마우스가 resize handle을 벗어났음에도 separator의 색상이 hover 상태로 남아있던 것이었습니다.
재현 조건을 찾아보던 중 separator에 마우스를 올린 다음 iframe으로 구현된 에디터 안으로 이동한 경우에만 해당 문제가 발생한다는 것을 알게 되었습니다.

“왜 iframe으로 이동할 때만 hover 상태가 사라지지 않는 걸까요?”
문제의 원인을 찾기 위해 react-resizable-panels에서 이벤트를 어떻게 처리하고 있는지 찾아봤습니다.
iframe에서만 문제가 발생한 이유
react-resizable-panels 내부를 살펴보면 상위 컴포넌트인 Group이 마운트 될 때 해당 컴포넌트에 속한 ownerDocument에 pointermove 이벤트를 등록합니다. 이후 pointermove 이벤트가 발생할 때마다 마우스의 위치가 separator의 유효 영역에 포함되는지 확인하고 그에 따라 상태를 표현하는 interactionState를 변경하고 있었습니다.
// mountGroup.ts
ownerDocument.addEventListener("pointermove", onDocumentPointerMove);
// onDocumentPointerMove.ts
export function onDocumentPointerMove(event: PointerEvent) {
const { interactionState, mountedGroups } = read();
const hitRegions = findMatchingHitRegions(event, mountedGroups);
if (hitRegions.length === 0) {
if (interactionState.state !== "inactive") {
update({
interactionState: {
state: "inactive",
},
});
}
} else {
update({
interactionState: {
hitRegions,
state: "hover",
},
});
}
}
코드를 자세히 살펴보면 onDocumentPointerMove에서 마우스가 separator의 유효 영역에 있을 때는 interactionState가 hover로 바뀌고 영역을 벗어나면 다시 inactive로 돌아가는 것을 확인할 수 있습니다. 이 동작에서 중요한 것은 마우스가 separator를 벗어난 뒤에 부모 document에서 pointermove 이벤트를 받아야 한다는 것입니다. 문제는 여기서 발생했습니다.
iframe은 부모 페이지 안에 렌더링되어 있더라도 별개의 document와 이벤트 영역을 가집니다. 따라서 separator 위에 있던 마우스가 iframe 안으로 이동하더라도 이후의 pointermove 이벤트는 iframe의 document에서 발생하기 때문에 부모 document에 등록된 onDocumentPointerMove는 더 이상 실행되지 않았습니다.
결과적으로 마우스가 iframe 영역으로 들어갈 때 interactionState는 부모 document가 마지막으로 저장한 hover에 머물렀습니다. Separator 컴포넌트는 이 상태를 data-separator 속성으로 표현하고 이에 따른 스타일을 적용하고 있었기 때문에 화면에는 hover 상태가 남게 되었던 것입니다.
우회 방법 적용하기
원인을 찾은 뒤에는 우선 서비스에서 발생하는 문제를 해결하고자 했습니다. 결국 문제는 iframe에 마우스가 옮겨졌을때 어떻게 상태를 Separator에 전달하는가 였고, 이 문제를 iframe 위의 마우스 상태를 감지하는 하나의 전역 상태를 만들어 해결해보기로 했습니다.
iframe 위에 마우스가 있는지를 isMouseOverEditor라는 상태로 관리하고 이 상태가 활성화되면 Separator에 전달하는 hover와 active 스타일을 제거하는 방식으로 구현 했습니다.
const processedClassName = isMouseOverEditor
? className
?.replace(/\[&\[data-separator=['"]hover['"]\]\]:[^\s]+/g, "")
.replace(/\[&\[data-separator=['"]active['"]\]\]:[^\s]+/g, "")
.trim()
: className;
return <Separator {...props} className={processedClassName} />;
해당 방법을 적용하자 iframe으로 마우스를 옮겼을 때 화면에 남아 있던 hover 상태의 색상이 사라지는 것을 확인할 수 있었습니다. 겉으로는 문제가 해결된 것 처럼 보였지만 라이브러리 내부의 interactionState는 여전히 hover였으므로 문제가 완전히 해결되었다고 보기는 어려웠습니다. 근본적인 문제를 해결하기 위해 라이브러리에 해결방안을 제시하고 문제를 해결하고자 했습니다.
재현 가능한 환경 만들기
우선 해당 라이브러리의 오픈소스 기여 가이드에 따라 이슈를 생성했습니다. 이슈를 생성하는 과정에서 글로만 문제를 설명 하기 보다 시각적으로 확인할 수 있는 환경이 있으면 좋겠다는 생각이 들었고 곧 바로 재현 가능한 환경을 만들었습니다.

먼저 react-resizable-panels에 이미 구현되어 있던 웹 어플리케이션에 /test/iframe-separator 경로를 만들고 패널 세 개로 구성된 재현 화면을 만들었습니다. 두 패널에는 iframe을 넣고 나머지 하나에는 일반 HTML을 넣어 동작의 차이를 한 화면에서 비교할 수 있도록 구성했습니다.
이슈 제보하기
재현 환경과 함께 기대 동작, 실제 동작, 환경, 추정되는 원인, 해결방안 등을 담아 이슈를 제보했습니다.

이슈에서 처음 제안한 해결 방법은 제품에 적용한 것과 유사한 방법으로 document 안의 iframe마다 mouseenter 이벤트 리스너를 등록하는 것이었습니다. 마우스가 iframe에 들어가는 순간 interactionState를 hover에서 inactive로 바꾸면 부모 document에서 pointermove가 멈추더라도 상태를 변경할 수 있다고 생각했습니다.
해당 방법은 모든 iframe에 이벤트를 연결해야하는 문제가 있었지만 당시 해결방안을 완전히 생각할 시간이 부족해 우선 가장 간단한 방법으로 해결 방안을 제안하였습니다. 이후 조금의 시간이 지나 메인테이너로부터 해당 이슈에 대한 의견을 전달받을 수 있었습니다.

메인테이너는 제안한 해결 방법이 라이브러리의 minimum hit area 처리와 충돌할 수 있다고 이야기 했습니다. 화면에 보이는 separator가 작더라도 라이브러리는 최소한의 주변 영역을 hover로 인식하기 때문에 마우스가 iframe에 들어왔다는 이유만으로 상태를 inactive로 바꾸면 아직 유효 영역 안에 있는 hover까지 해제될 수 있다는 이야기 였습니다.
결국 다른 해결방안을 고민할 시간이 필요했습니다. 이슈 제보의 목적은 문제 정의와 PR 제출을 허락받는 것이었기에 이후 해당 문제를 다시 살펴보고 적절한 해결방안을 제시하고자 했습니다.
해결된 문제
그렇게 해당 코멘트를 확인하고 잠든 사이 메인테이너는 해당 문제를 조사하여 해결방안을 제시하고 문제를 해결했습니다.

문제를 직접 해결할 기회를 놓친 것은 아쉬웠으나 다른 한편으로 빠르게 문제가 해결되어서 다행이다 라는 생각이 들었습니다. 메인테이너가 적용한 해결방법은 다음과 같았습니다.
메인테이너는 마우스가 iframe으로 이동할 때 부모 document에서 pointerout 이벤트가 발생하고 relatedTarget으로 이동한 대상이 iframe인지 확인할 수 있다는 것을 발견했습니다. 이 방법이라면 모든 iframe에 이벤트 리스너를 하나씩 등록하지 않고 충돌을 우회하면서도 document의 이벤트 하나로 iframe 진입을 구분할 수 있었습니다.
export function onDocumentPointerOut(event: PointerEvent) {
if (event.relatedTarget instanceof HTMLIFrameElement) {
const { interactionState } = read();
switch (interactionState.state) {
case "hover": {
update({
interactionState: {
state: "inactive",
},
});
}
}
}
}
이후 해당 방법을 적용한 수정 사항은 PR #648에서 병합됐고 이슈를 제보한 당일 react-resizable-panels@4.5.7에 포함되었습니다.
마치며
문제를 직접 해결하지 못한 것은 아쉽지만 문제를 발견하고 우회 방법을 적용하며 하나씩 원인을 추적해나가는 과정에서 많은것을 배울 수 있었습니다. 또한 문제를 정확히 정의하고 그것을 이해하기 쉬운 형태로 제보하는 과정도 중요한 기여라는 것을 깨달을 수 있었습니다.
만약 해당 문제에 대한 우회 방법만을 적용하고 넘어갔더라면 이러한 경험을 할 수 없었을 것입니다. 이번 과정을 통해 문제의 원인을 깊게 파고들고 정의하고 해결하는 것이 중요하다는 것을 다시 한번 느낄수 있었습니다.