사이드 패널 UI/UX 개선기스택 형태의 패널로 개선, 그리고 Proxy를 활용한 참조되는 프로퍼티 추적으로 성능 최적화작성일 2025.07.05페이지가 생성된 시간 2026.02.03 00:47:07
개요
기존 우패널은 하나의 컴포넌트만 렌더링 가능했지만, 스택 형태로 여러 컴포넌트를 렌더링하고 맨 위 컴포넌트만 UI상으로 노출하도록 구조를 변경했다.
이때 Proxy를 활용해 참조되는 프로퍼티만 추적하는 useTrackedProps 훅으로 성능을 최적화했는데, 이는 TanStack Query의 trackedQuery 패턴에서 인사이트를 얻었다.
문제: 우패널의 단일 컴포넌트 제약
AS-IS: 하나의 컴포넌트만 렌더링
기존 구현에서는 하나의 우패널만 렌더링할 수 있었다. 새로운 패널이 열리면 기존 패널이 닫히는 방식이었다.

문제점
- 컴포넌트 상태 유지 불가: 패널 전환 시 기존 상태가 사라짐
- 복귀 불편: 이전 패널로 돌아가려면 처음부터 다시 열어야 함
- UX 제약: 사용자가 여러 패널을 동시에 관리할 수 없음
해결책: 스택형 우패널 구조
아키텍처 변경
변경 전

변경 후

RightPanelManager 구현
class RightPanelManager {
private readonly rightPanelMap = new Map<string, RightPanelItem>();
public readonly openedRightPanelList = atom<PanelItem[]>([]);
// 맨 위 패널 ID 계산
public readonly topRightPanelId = atom((get) => {
const openedRightPanelList = get(this.openedRightPanelList);
const [visibleTopRightPanel] = openedRightPanelList
.filter(({ hidden }) => !hidden)
.at(0);
return visibleTopRightPanel?.id;
});
// 특정 패널이 맨 위인지 확인
public readonly isTop = (id: string) => atom((get) =>
get(this.topRightPanelId) === id
);
// 특정 패널이 열려있는지 확인
public readonly isOpened = (id: string) => atom((get) =>
get(this.openedRightPanelList).some(item => item.id === id)
);
// 특정 패널이 숨겨져 있는지 확인
public readonly isHidden = (id: string) => atom((get) =>
get(this.openedRightPanelList).some(
item => item.id === id && item.hidden
)
);
}
createRightPanelContext 구현
export const createRightPanelContext = ({ id, Component }: RightPanelItem) => {
const Context = createContext<RightPanelControl | null>(null);
const Provider = ({ children }) => {
const [unregister] = useState(() =>
rightPanelManager().register({ id, Component })
);
useEffect(() => () => unregister(), [unregister]);
return (
<Context.Provider value={useMemo(() =>
rightPanelManager().getControl(id), []
)}>
{children}
</Context.Provider>
);
};
function useContext() {
const context = React.useContext(Context);
if (!context) throw new Error('RightPanelContext is not found');
return useTrackedProps(
{
...context,
isOpen: rightPanelManager().isOpened(id).get(),
isTop: rightPanelManager().isTop(id).get(),
isHidden: rightPanelManager().isHidden(id).get(),
},
{
isOpen: rightPanelManager().isOpened(id).subscribe,
isTop: rightPanelManager().isTop(id).subscribe,
isHidden: rightPanelManager().isHidden(id).subscribe,
}
);
}
return [Provider, useContext] as const;
};
성능 최적화: Proxy 기반 속성 추적
trackedQuery 패턴이란?
TanStack Query(React Query) v5에서 도입된 trackedQuery 패턴은 쿼리 내에서 실제로 사용되는 데이터만 추적하여, 해당 데이터가 변경될 때만 리렌더링하는 최적화 기법이다.
// 사용하는 데이터만 추적
const isOpen = trackedQuery(...).isOpen;
// isTop, isHidden이 변경되어도 리렌더링되지 않음
Proxy란 무엇인가?
Proxy는 자바스크립트 ES6에서 도입된 기능으로, 객체의 기본 동작을 가로채고 재정의할 수 있는 래퍼 객체다. 프로퍼티 접근(get), 할당(set) 등의 연산을 가로채서 추가 로직을 실행할 수 있다.
const target = { name: 'John', age: 30 };
const proxy = new Proxy(target, {
get(obj, prop) {
console.log(`Accessing ${prop}`);
return obj[prop];
}
});
proxy.name; // "Accessing name" 로그 후 "John"
useTrackedProps 훅 구현
const useTrackedProps = <T extends object>(
initialProps: T,
subscribeMap: Partial<Record<keyof T, (callback: (value: T[keyof T]) => void) => VoidFunction>>
) => {
const propsRef = useRef(initialProps);
const unsubscribeMapRef = useRef(new Map<keyof T, VoidFunction>());
const [, forceUpdate] = useState({});
// cleanup
useEffect(() => {
const unsubscribeMap = unsubscribeMapRef.current;
return () => {
unsubscribeMap.forEach((unsubscribe) => unsubscribe());
unsubscribeMap.clear();
};
}, []);
// Proxy로 래핑하여 접근하는 프로퍼티만 구독
return new Proxy(propsRef.current, {
get(target, prop) {
const key = prop as keyof T;
// 구독 가능한 프로퍼티가 아니면 바로 반환
if (!(key in subscribeMap)) {
return Reflect.get(target, key);
}
// 이미 구독 중이면 바로 반환
if (!unsubscribeMapRef.current.has(key)) {
const unsubscribe = subscribeMap[key]!((value) => {
propsRef.current[key] = value;
forceUpdate({}); // 해당 프로퍼티만 변경 시 리렌더링
});
unsubscribeMapRef.current.set(key, unsubscribe);
}
return Reflect.get(target, key);
},
});
};
이 구현은 TanStack Query의 trackedQuery 패턴에서 인사이트를 얻었다. trackedQuery는 쿼리 함수 내에서 실제로 사용되는 데이터만 추적하여 최적화하는데, useTrackedProps도 같은 원리로 컴포넌트에서 접근하는 프로퍼티만 추적한다.
최적화 효과
// isOpen만 접근하면 isOpen만 구독
const { isOpen } = usePanelContext();
// isTop, isHidden이 변경되어도 이 컴포넌트는 리렌더링되지 않음
사용 예시
댓글 패널 추가하기
// comment-panel.tsx
export const [CommentPanelProvider, useCommentPanelContext, control] =
createRightPanelContext({
id: 'COMMENT_PANEL',
Component: () => (
<Suspense fallback={null}>
<CommentPanelView />
</Suspense>
),
});
// App.tsx
function App() {
return (
<CommentPanelProvider>
<ToggleCommentPanelButton />
</CommentPanelProvider>
);
}
function ToggleCommentPanelButton() {
const { isOpen, toggle } = useCommentPanelContext();
return (
<Button onClick={toggle} selected={isOpen}>
<Button.Icon iconName='comment' />
</Button>
);
}
결과
사용자 경험 개선
- 상태 유지: 패널 전환 시 기존 상태가 보존됨
- 빠른 복귀: 이전 패널로 즉시 돌아갈 수 있음
- 다중 패널: 여러 패널을 동시에 열고 닫을 수 있음
성능 최적화
- Proxy 기반 추적: trackedQuery 패턴으로 불필요한 리렌더링 제거
- 속성별 구독: 접근하는 프로퍼티만 구독하여 효율적 업데이트
기술적 배운 점
Proxy 활용 시나리오
- 속성 접근 추적: 어떤 데이터를 사용하는지 런타임에 감지
- 지연 로딩: 접근할 때만 데이터 로드
- 투명한 래핑: 사용자는 Proxy인지 알 수 없이 자연스럽게 사용
trackedQuery 패턴 인사이트
// TanStack Query trackedQuery
const result = queryClient.trackedQuery({
queryKey: ['data'],
queryFn: () => fetchData(),
});
// queryFn 내에서 사용하는 데이터만 추적
const name = result.data.name; // name만 구독
같은 원리로 useTrackedProps도 컴포넌트에서 접근하는 프로퍼티만 구독해서 최적화한다.
정리
우패널을 스택형 구조로 개선하고 Proxy 기반 속성 추적으로 성능을 최적화했다.
- 스택형 패널: 여러 패널을 동시에 열고 상태 유지
- trackedQuery 패턴: TanStack Query에서 인사이트를 얻어 Proxy 기반 속성 추적 구현
- 성능 최적화: 접근하는 프로퍼티만 구독하여 불필요한 리렌더링 제거
trackedQuery 패턴에서 얻은 인사이트를 Proxy로 구현한 것은 성능 최적화의 좋은 사례다. 실제로 사용하는 데이터만 추적하여 리렌더링을 최소화하면 복잡한 UI에서도 부드러운 사용자 경험을 제공할 수 있다.