const-tommy.dev
기록을 불러오는 중입니다
'헬로월드' 프로젝트의 메인 페이지는 사용자가 자신의 작업 환경을 직접 구성하는 개인화된 위젯 대시보드로 설계되었다. 고정된 레이아웃의 틀에서 벗어나 사용자에게 완전한 자유도를 제공하기 위해, 드래그 앤 드롭(DnD)을 활용한 위치 이동, 리사이징, 그리고 위젯 간의 유기적인 충돌 감지라는 복잡한 인터랙션 제어가 요구되었다.
본 포스팅에서는 이러한 고난도의 UX를 안정적으로 구현하기 위해 Zustand로 설계한 전역 상태 아키텍처와, 그리드 시스템 기반의 DnD 레이아웃 엔진을 구축하며 겪은 기술적 도전과 해결 과정을 기록한다.
그림 1. DnD 기반의 가변형 위젯 대시보드 인터랙션 데모
메인 페이지가 위젯 기반의 대시보드로 구성됨에 따라, 프런트엔드 단에서는 단순한 UI 구현을 넘어선 '동적인 물리 법칙'의 정립이 필요하였다. 사용자에게 자연스러운 조작감을 제공하기 위해서는 다음과 같은 기술적 과제가 선행되어야 했기 때문이다.
이러한 고빈도 인터랙션을 효율적으로 처리하기 위해 시스템의 핵심 엔진으로 Zustand를 채택하였다. Zustand는 셀렉터(Selector)를 통한 정밀한 상태 구독을 지원하여 불필요한 리렌더링을 방지하며, 복잡하게 얽힌 위젯 레이아웃 데이터를 실시간으로 동기화하기에 가장 적합한 가벼우면서도 강력한 상태 관리 도구라고 판단했기 때문이다.
위젯 시스템의 핵심은 레이아웃 데이터를 담는 Single Source of Truth(단일 진실 공급원) 를 구축하는 것이다. 단순히 상태를 정의하는 것에 그치지 않고, 사용자가 레이아웃을 수정하다가 취소할 수 있는 '스냅샷' 기능과 브라우저를 새로고침해도 유지되는 '지속성'을 확보하는 데 초점을 맞추었다.
일반적인 상태 관리와 달리, 본 프로젝트에서는 사용자가 '편집 모드'에 진입했을 때의 원본 상태를 저장해두는 Snapshot 패턴을 적용하였다.
originalWidgets에 보관된 스냅샷으로 즉시 복구된다. 이는 복잡한 UX 제어에서 사용자에게 심리적 안전장치를 제공한다.persist 미들웨어를 통해 사용자가 공들여 배치한 레이아웃을 로컬 스토리지에 자동 저장한다. 새로고침 시에도 INITIAL_WIDGETS가 아닌 사용자의 마지막 설정값이 우선시되도록 설계하였다.// @/store/widget/index.ts 중점 로직
export const useWidgetStore = create<State & Action>()(
persist(
(set, get) => ({
widgets: INITIAL_WIDGETS,
originalWidgets: [], // 수정 취소를 위한 스냅샷 보관소
isEditMode: false,
editModeOpen: () =>
set({ isEditMode: true, originalWidgets: [...get().widgets] }), // 편집 시작 시 현재 상태 복사
cancelChanges: () =>
set((state) => ({
isEditMode: false,
widgets: state.originalWidgets.length > 0 ? state.originalWidgets : state.widgets,
originalWidgets: [],
})),
// ... 생략
}),
{ name: 'widget-layout', partialize: (state) => ({ widgets: state.widgets }) }
)
);위젯 시스템은 드래그 중 고빈도로 상태가 변경된다. 불필요한 리렌더링을 방지하기 위해 스토어를 직접 컴포넌트에서 호출하는 대신, useShallow를 적용한 커스텀 훅 레이어를 한 단계 더 두었다.
상태와 액션의 분리: useWidgetState와 useWidgetActions로 역할을 분리하여, 위젯의 위치 정보만 필요한 컴포넌트가 스토어의 모든 액션 함수까지 구독하여 불필요하게 렌더링되는 현상을 방지하였다.
Shallow 비교: 얕은 비교(Shallow Comparison)를 통해 위젯 배열 내부의 속성이 실제로 변했을 때만 컴포넌트를 업데이트하도록 정밀하게 제어하였다.
드래그 앤 드롭(DnD) 인터랙션은 사용자 경험의 핵심이다. 단순히 요소를 옮기는 시각적 효과를 넘어, 위젯이 정해진 공간 내에서 유기적으로 움직이는 그리드 시스템(Grid System) 엔진을 구축하였다.
복잡한 리스트 재배치와 센서 제어를 위해 dnd-kit 라이브러리를 채택하였다. 이는 위젯의 이동 거리와 속도에 따라 민감하게 반응하는 센서 설정과, 이동 시 데이터의 인덱스를 안전하게 교체해주는 arrayMove 유틸리티를 활용하기 위함이다.

사용자가 위젯을 드래그하여 특정 위치에 놓았을 때, widgets 배열의 순서를 실시간으로 재구성한다. 스토어의 widgetsReorder 액션은 기존 인덱스와 새로운 인덱스를 계산하여 배열을 원자적으로 교체한다.
// @/store/widget/index.ts 내 재배치 로직
widgetsReorder: (oldIndex, newIndex) => {
const currentWidgets = get().widgets;
if (oldIndex !== -1 && newIndex !== -1) {
// dnd-kit의 유틸리티를 활용한 불변 배열 순서 교체
set({ widgets: arrayMove(currentWidgets
사용자가 조작 중인 위젯이 그리드 칸에 '착' 달라붙는 느낌을 주기 위해 다음과 같은 장치를 마련하였다.
위젯 시스템은 복잡한 배열 조작과 좌표 연산이 얽혀 있어, 작은 로직 수정이 전체 레이아웃의 붕괴로 이어질 위험이 크다. 이를 방지하기 위해 Vitest를 기반으로 한 고강도 테스트 체계를 구축하였다.
단순히 모든 파일의 수치를 높이는 것보다, 시스템의 안정성을 결정짓는 핵심 로직(Core Logic)의 완결성에 집중하였다.
use-widget-grid 훅과 전역 상태를 제어하는 store/widget은 라인 커버리지 100% 를 달성하여 시스템의 뼈대를 완벽히 보호하였다.widget 및 widget-dashboard 컴포넌트 역시 100% 검증하여, 하위 위젯들이 어떤 형태로 구현되더라도 기초적인 레이아웃 환경은 견고함을 유지하도록 설계하였다.
그림 3. 시스템의 핵심 레이어(Store, Hooks, Base)에 집중한 테스트 커버리지 결과
순수 UI 렌더링 비중이 높은 개별 위젯들은 추후 시각적 회귀 테스트나 E2E 테스트로 보완하되, 현재 단계에서는 시스템의 '뇌'에 해당하는 로직들을 완벽히 격리 검증하는 데 주력하였다.
대시보드 시스템은 수많은 위젯의 위치와 레이아웃 정보를 실시간으로 계산한다. 드래그 앤 드롭 중 발생하는 고빈도 리렌더링 속에서 불필요한 연산 부하를 줄이고 부드러운 UX를 유지하기 위해 useMemo를 도입하였다.
useMemo를 통해 결과 배열의 참조 주소를 일정하게 유지함으로써, 하위 컴포넌트(motion.div)들이 "데이터가 변하지 않았음"을 인지하고 불필요한 리렌더링을 원천 차단하도록 설계하였다.성능 최적화의 실효성을 검증하기 위해 인위적인 연산 부하(Heavy Loop)를 주입하여 비교한 결과, 압도적인 성능 차이를 확인하였다.
| 지표 | Before (최적화 ❌) | After (최적화 ✅) | 개선율 |
|---|---|---|---|
| 렌더링 시간 | 9.4ms | 4.5ms | 52.1% 개선 |
| 처리 요소 수 | 67개 | 67개 | - |
[Before] 최적화 전 (9.4ms)
[After] 최적화 후 (4.5ms)
분석: 동일한 컴포넌트 구조임에도
useMemo를 통한 연산 격리만으로 약 2배 이상의 성능 향상을 보인것을 확인이 가능하다.
특히 데이터 규모를 130개로 2배 이상 늘린 극한의 상황에서도 4.4ms의 안정적인 수치를 기록하였다. 이는 규모가 확장되더라도 연산 비용을 일정하게 유지할 수 있는 확장성(Scalability) 을 확보했음을 시사한다.
연산 시간의 단축보다 더 본질적인 성과는 불필요한 연산의 완전한 차단이다. React Profiler를 통해 이를 시각적으로 입증하였다.

그림 5. 최적화된 컴포넌트가 렌더링을 생략하는 모습 (Did not client render)
WidgetDashboard가 리렌더링되었음에도 불구하고, 자식 위젯들은 렌더링을 시도조차 하지 않았음(Skipped) 을 의미한다.useMemo와 useShallow가 데이터의 참조 무결성을 완벽히 유지했음을 보여주는 실증적 데이터다.사용자가 위젯을 드래그하는 동안 시스템은 밀리초($ms$) 단위로 수많은 위치 변경 이벤트를 감지한다. 본 프로젝트는 최종 저장을 '저장 버튼'을 통해 수동으로 수행하는 방식을 채택하고 있으나, 드래그 도중 발생하는 중간 상태값들을 매번 처리하거나 무거운 내부 로직을 실행하는 것은 불필요한 메인 스레드 점유를 야기한다.
브라우저의 메인 스레드는 자바스크립트 실행과 화면 렌더링을 동시에 처리하는 싱글 스레드 구조를 가진다. 부드러운 인터랙션을 제공하기 위해서는 초당 60프레임을 유지해야 하며, 모든 작업은 16.7ms의 프레임 예산(Frame Budget) 내에 완료되어야 한다.
디바운싱 없이 매 프레임 상태를 동기화할 경우 연산 누적으로 인해 이 예산을 초과하게 된다. 이는 브라우저가 다음 화면을 그리는 작업을 뒤로 미루게 만들어 결과적으로 화면이 뚝뚝 끊기는 Jank(장크) 현상을 초래하여 사용자 경험을 저해한다.
사용자의 드래그 조작이 진행되는 동안에는 즉각적인 UI 피드백에만 집중하고, 조작이 완전히 멈춘 시점(300ms 이후)에만 최종 드래프트 상태를 확정(Internal Sync) 하도록 디바운싱(Debouncing)을 적용하였다. 이를 통해 편집 중 발생하는 불필요한 연산 낭비를 차단하였다.
실제로 디바운싱이 의도대로 동작하여 불필요한 연산을 차단하고 있는지 확인하기 위해 위젯 이동 이벤트(MOVE)와 내부 상태 확정 로직(SAVE/SYNC)의 실행 횟수를 측정하였다.
| 구분 | 인터랙션 발생 (MOVE) | 상태 확정 실행 (SYNC/SAVE) | 연산 절감률 |
|---|---|---|---|
| 실행 횟수 | 40회 이상 | 1회 (조작 종료 시) | 약 98% 절감 |
그림 6. 연속적인 이동 이벤트 중 단 1회의 최종 확정 연산만 수행된 모습 (Log Timeline)
[MOVE] 로그에도 불구하고, 최종적으로 단 1회의 [SAVE] 연산만 수행됨을 확인하였다.본 프로젝트는 복잡한 드래그 앤 드롭 인터랙션이 포함된 대시보드 시스템에서 코드의 안정성과 인터랙션 성능을 확보하는 데 집중하였다.
useMemo 적용으로 렌더링 시간을 9.4ms에서 4.5ms로 약 52% 단축하였으며, 데이터 규모 확장 시에도 안정적인 프레임을 유지함을 확인하였다.기능 구현 단계에서 정밀한 성능 측정이 병행될 때 최적화의 방향성이 명확해짐을 체감하였다. 특히 React Profiler와 브라우저 콘솔 데이터를 통해 가설을 검증하는 과정은 성능 병목 구간을 해결하는 데 결정적인 역할을 하였다.
확보된 안정적인 레이아웃 엔진을 기반으로 향후 서버 사이드 영속화 및 실시간 데이터 스트리밍 위젯 추가를 검토할 예정이다. 수치 기반의 성능 분석을 개발 프로세스의 표준으로 삼아 기술적 완결성을 지속적으로 높여 나갈 계획이다.