서론
캠스터디 앱을 만들면서 “한 화면에서 여러 창을 자유롭게 배치하는” UX가 꼭 필요했다.
웹캠/유튜브/화면공유/투두를 한 화면에 띄워놓고, 사용자가 원하는 위치로 드래그하고 원하는 크기로 리사이즈하는 느낌을 내고 싶었다.
- 드래그 & 리사이즈
- 위치/크기 상태 저장(새로고침해도 유지)
- 클릭한 창이 제일 앞으로(포커스/레이어링)
이 글은 내가 실제 커밋으로 구현해 나간 흐름(삽질 포함)을 정리한 트러블슈팅 + 구현기다.
목표: “창을 창답게” 만들기
내가 원한 동작은 이런 거였다.
- 창을 드래그해서 옮길 수 있다.
- 창을 리사이즈할 수 있다. (가능하면 비율 고정)
- 창을 클릭하면 그 창이 최상단으로 올라온다.
- 새 창을 추가하면 기존 창들 위로 올라온다.
- 드래그/리사이즈한 결과가 상태로 저장된다.
1) react-rnd로 드래그/리사이즈 뼈대 만들기
결론부터 말하면 react-rnd를 썼다.
드래그(react-draggable) + 리사이즈(re-resizable)를 합친 느낌이라 “창 UI” 만들기 편했다.
내 첫 구현은 이런 형태였다.
Rnd로 감싼다- 비율은
lockAspectRatio로 고정 - 화면 밖으로 못 나가게
bounds="window" - 상단 바만 드래그 가능하게
dragHandleClassName사용 - 드래그/리사이즈 시작 시
bringToFront(id)호출해서 포커스 처리
import { Rnd } from "react-rnd";import WindowControlButton from "./circle-button";import { useWindowStore } from "@/stores/window-state"; export default function AddWindow({ id, onOpenOption }) { const window = useWindowStore((s) => s.windows.find((w) => w.id === id)); const bringToFront = useWindowStore((s) => s.bringToFront); if (!window) return null; const { type, zIndex } = window; return ( <Rnd default={{ x: 100, y: 100, width: 320, height: 180 }} minWidth={240} minHeight={135} bounds="window" lockAspectRatio style={{ zIndex, position: "fixed" }} onDragStart={() => bringToFront(id)} onResizeStart={() => bringToFront(id)} dragHandleClassName="drag-handle" > <div className="w-full h-full border-2 rounded-2xl overflow-hidden bg-[#a0c878]"> <div className="drag-handle flex gap-2 px-3 py-2 cursor-move bg-[#255f38]"> <WindowControlButton type="close" onClick={() => { /* ... */ }} /> <WindowControlButton type="option" onClick={onOpenOption} /> </div> <div className="w-full h-full"> {/* camera / youtube / window-share ... */} </div> </div> </Rnd> );}여기까지 하면 “움직이긴 한다”… 근데 문제는 새로고침하면 위치/크기가 날아간다는 점이었다.
2) 위치/크기 저장: Uncontrolled -> Controlled로 바꾸기
Rnd는 default로 시작하는 “uncontrolled” 방식도 되지만,
나는 드래그/리사이즈 결과를 저장해야 했기 때문에 position/size를 상태로 끌어올리는 controlled 방식이 필요했다.
그래서 window-state에 아래 값을 넣었다.
x, y, width, heightzIndex
그리고 Rnd에는 position / size를 직접 꽂아주고,
onDragStop / onResizeStop에서 상태 업데이트하도록 바꿨다.
<Rnd position={{ x, y }} size={{ width, height }} bounds="window" lockAspectRatio style={{ zIndex, position: "fixed" }} onDragStop={(e, d) => updateWindowBounds(id, d.x, d.y, width, height)} onResizeStop={(e, dir, ref, delta, position) => { updateWindowBounds( id, position.x, position.y, ref.offsetWidth, ref.offsetHeight ); }}/>이렇게 바꾸니까:
- “지금 상태”가 store에 남고
- UI는 store를 그대로 반영하니까
- 새로고침/재실행에도 동일 레이아웃을 만들 수 있는 길이 열렸다.
3) zIndex 관리: “최상단” 규칙을 상태로 만든다
처음엔 그냥 z-index: 9999 같은 꼼수도 떠올랐는데,
그건 “모든 창이 항상 최상단”이 되어버리므로 의미가 없었다.
내가 원한 건 이거다:
- 새로 추가한 창이 가장 위
- 클릭한 창이 가장 위
- 다른 창들은 유지
그래서 아주 단순한 정책으로 갔다.
bringToFront(id)가 불리면- 현재 windows 중
maxZ를 구해서 - 해당 창의 zIndex를
maxZ + 1로 올린다.
bringToFront: (id) => set((state) => { const maxZ = Math.max(...state.windows.map((w) => w.zIndex || 0)); return { windows: state.windows.map((w) => w.id === id ? { ...w, zIndex: maxZ + 1 } : w ), }; }),그리고 포커스 트리거는 2군데에 걸었다.
- 드래그 시작할 때
- 리사이즈 시작할 때
- (추가로) 창 body 클릭했을 때도 포커스
<div className="w-full h-full" onClick={() => bringToFront(id)}> ...</div>4) zindex vs zIndex… 네이밍 삽질로 하루 날림 😇
- 일부 코드는
zindex(백엔드 DTO 네이밍) - 일부 코드는
zIndex
로 섞여 있었다.
결국 싹 정리했다:
- 타입:
Window.zIndex - DTO:
WindowPatchDto.zIndex - 서버 업데이트 payload도
zIndex - 프론트 계산도
zIndex
- windows.map((w) => w.zindex)+ windows.map((w) => w.zIndex) - style={{ zIndex: zindex }}+ style={{ zIndex: zIndex }}5) 레이아웃: WindowZone은 “relative + overflow-hidden”이 정답이었다
처음엔 flex flex-wrap로 창들을 감쌌는데,
창이 늘어나면 레이아웃이 밀리고 “창이 창 같지 않은 느낌”이 강했다.
그래서 최종적으로는:
<div className="relative w-full h-full overflow-hidden">{/* windows */}</div>이렇게 “캔버스 같은 공간”을 만들고, 그 위에 창들이 떠다니게 구성했다.
결과
- 창을 드래그/리사이즈해서 원하는 배치를 만들 수 있다.
- 클릭한 창이 앞으로 올라온다(포커스/레이어링).
- 위치/크기 상태가 store로 저장되어 유지된다.
zIndex네이밍 통일로 삽질을 줄였다.
이제야 “진짜 창” 느낌이 났다… (감격 🥹)
느낀 점 / 개선할 점
bringToFront가 매번maxZ를 구하니 O(n)이다. 창이 매우 많아지면 zIndex 카운터를 store에 따로 두는 게 더 낫겠다.- zIndex는 결국 stacking context 규칙을 잘 알아야 삽질이 줄어든다. 특히 position이 없는 요소엔 z-index가 의미 없어서
lockAspectRatio는 UX가 좋았는데, “유튜브/웹캠/화면공유” 같이 16:9 콘텐츠에 특히 잘 맞았다.