사이드 패널 UI/UX 개선기
스택 형태의 패널로 개선, 그리고 Proxy를 활용한 참조되는 프로퍼티 추적으로 성능 최적화
작성일 2025.07.05
페이지가 생성된 시간 2026.02.03 00:47:07

1

개요

기존 우패널은 하나의 컴포넌트만 렌더링 가능했지만, 스택 형태로 여러 컴포넌트를 렌더링하고 맨 위 컴포넌트만 UI상으로 노출하도록 구조를 변경했다.

이때 Proxy를 활용해 참조되는 프로퍼티만 추적하는 useTrackedProps 훅으로 성능을 최적화했는데, 이는 TanStack Query의 trackedQuery 패턴에서 인사이트를 얻었다.


문제: 우패널의 단일 컴포넌트 제약

AS-IS: 하나의 컴포넌트만 렌더링

기존 구현에서는 하나의 우패널만 렌더링할 수 있었다. 새로운 패널이 열리면 기존 패널이 닫히는 방식이었다.

panel1.png

문제점

  1. 컴포넌트 상태 유지 불가: 패널 전환 시 기존 상태가 사라짐
  2. 복귀 불편: 이전 패널로 돌아가려면 처음부터 다시 열어야 함
  3. UX 제약: 사용자가 여러 패널을 동시에 관리할 수 없음

해결책: 스택형 우패널 구조

아키텍처 변경

변경 전 panel1.png

변경 후 panel111.png

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에서도 부드러운 사용자 경험을 제공할 수 있다.


참고자료

©2024 dlwl98
github
PostsAbout