웹 애플리케이션에서 모달, 팝오버, 툴팁과 같은 UI 요소들의 z-index를 관리하는 것은 생각보다 까다로운 작업입니다. 특히 여러 컴포넌트가 동적으로 생성되고 제거되는 상황에서는 더욱 그렇습니다. 이 글에서는 React의 Context API와 useRef를 활용하여 z-index를 효과적으로 관리하는 방법을 살펴보고, 이 접근 방식의 장단점과 실제 구현 시 고려해야 할 성능 최적화 전략까지 상세히 알아보겠습니다.
z-index 관리 시스템은 크게 세 부분으로 구성됩니다:
먼저 TypeScript를 활용하여 타입 안정성이 보장된 Context를 정의합니다:
export interface ZIndexContextType {
zIndexes: number[];
addZIndex: (index: number) => void;
removeZIndex: (index: number) => void;
}
const ZIndexContext = createContext<ZIndexContextType>({
zIndexes: [],
addZIndex: () => {},
removeZIndex: () => {},
});
이 Context는 현재 사용 중인 z-index 값들의 배열과 이를 관리하는 두 함수를 제공합니다. TypeScript를 사용함으로써 개발 시점에서 타입 관련 오류를 잡을 수 있습니다.
다음으로 z-index 값들의 상태를 관리할 Provider를 구현합니다:
function ZIndexProvider({ children }: ZIndexProviderProps) {
const [zIndexes, setZIndexes] = useState<number[]>([]);
const addZIndex = useCallback((zIndex: number) => {
setZIndexes(prevIndexes => [...prevIndexes, zIndex]);
}, []);
const removeZIndex = useCallback((zIndex: number) => {
setZIndexes(prevIndexes => {
const index = prevIndexes.findIndex(usedZIndex => usedZIndex === zIndex);
return prevIndexes.filter((_, i) => i !== index);
});
}, []);
const value = {
zIndexes,
addZIndex,
removeZIndex,
};
return <ZIndexContext.Provider value={value}>{children}</ZIndexContext.Provider>;
}
여기서는 useCallback을 활용하여 함수들을 메모이제이션하고, 불필요한 리렌더링을 방지합니다.
가장 핵심적인 부분인 ZIndexer 컴포넌트는 useRef를 활용하여 안정적인 z-index 관리를 구현합니다:
function ZIndexer({ children }: ZIndexerProps) {
const [zIndex, setZIndex] = useState<number>(-1);
const { zIndexes, addZIndex, removeZIndex } = useContext(ZIndexContext);
const currentZIndex = useMemo(() => {
const hasLastIndex = getHasLastIndex(zIndexes);
return getNextZIndex({ hasLastIndex, zIndexes });
}, [zIndexes]);
const currentZIndexRef = useRef(currentZIndex);
currentZIndexRef.current = currentZIndex;
useEffect(() => {
const currentZIndex = currentZIndexRef.current;
const updateZIndex = () => {
setZIndex(currentZIndex);
addZIndex(currentZIndex);
};
const cleanup = () => {
removeZIndex(currentZIndex);
};
updateZIndex();
return cleanup;
}, [addZIndex, removeZIndex]);
return children({ zIndex });
}
이 구현 방식에는 몇 가지 중요한 고려사항이 있습니다. 특히 Context API를 사용할 때 발생할 수 있는 성능 이슈와 그 해결 방안을 살펴보겠습니다.
Context를 구독하는 모든 컴포넌트는 zIndexes 배열이 변경될 때마다 리렌더링됩니다. 이를 최적화하기 위해 Context를 분리하는 방법을 고려할 수 있습니다:
const ZIndexValuesContext = createContext<number[]>([]);
const ZIndexActionsContext = createContext<ZIndexActions>({
addZIndex: () => {},
removeZIndex: () => {},
});
function OptimizedZIndexer({ children }: ZIndexerProps) {
// 필요한 값만 구독
const zIndexes = useContext(ZIndexValuesContext);
const { addZIndex, removeZIndex } = useContext(ZIndexActionsContext);
// ... 나머지 로직
}
이렇게 Context를 분리함으로써, 액션만 필요한 컴포넌트가 값의 변경으로 인해 불필요하게 리렌더링되는 것을 방지할 수 있습니다.
대규모 애플리케이션에서는 배열 대신 Set을 사용하여 메모리 사용량을 최적화할 수 있습니다:
function ZIndexProvider({ children }: ZIndexProviderProps) {
const [zIndexSet, setZIndexSet] = useState<Set<number>>(new Set());
const addZIndex = useCallback((zIndex: number) => {
setZIndexSet(prevSet => new Set(prevSet).add(zIndex));
}, []);
const removeZIndex = useCallback((zIndex: number) => {
setZIndexSet(prevSet => {
const newSet = new Set(prevSet);
newSet.delete(zIndex);
return newSet;
});
}, []);
}
여러 컴포넌트가 동시에 마운트되거나 언마운트될 때 발생할 수 있는 race condition을 방지하기 위해 트랜잭션 개념을 도입할 수 있습니다:
function ZIndexProvider({ children }: ZIndexProviderProps) {
const [pendingOperations, setPendingOperations] = useState<Operation[]>([]);
useEffect(() => {
if (pendingOperations.length > 0) {
const batch = pendingOperations.slice();
setPendingOperations([]);
setZIndexes(prevIndexes => {
return batch.reduce((acc, operation) => {
return processOperation(acc, operation);
}, prevIndexes);
});
}
}, [pendingOperations]);
}
프로젝트의 요구사항에 따라 다음과 같은 대안적 접근도 고려해볼 수 있습니다:
function ZIndexManager() {
useEffect(() => {
document.documentElement.style.setProperty('--modal-z-index', '1000');
document.documentElement.style.setProperty('--tooltip-z-index', '1100');
}, []);
return null;
}
const zIndexLevels = {
modal: 1000,
tooltip: 1100,
popover: 1200,
} as const;
const StyledModal = styled.div`
z-index: ${zIndexLevels.modal};
`;
이 시스템을 실제로 적용하는 방법을 살펴보겠습니다:
function Modal() {
return (
<ZIndexer>
{({ zIndex }) => (
<div style={{ zIndex }} className="modal">
모달 내용
</div>
)}
</ZIndexer>
);
}
function App() {
return (
<ZIndexProvider>
<div>
<Modal />
<Tooltip />
<Popover />
</div>
</ZIndexProvider>
);
}
Context API와 useRef를 활용한 z-index 관리 시스템은 강력하면서도 유연한 솔루션을 제공합니다. 하지만 이를 효과적으로 사용하기 위해서는 성능 최적화와 잠재적인 문제점들을 신중히 고려해야 합니다. 특히 대규모 애플리케이션에서는 Context 분리, 메모리 최적화, 동시성 처리 등의 추가적인 최적화 전략을 적용하는 것이 중요합니다.
이 패턴을 적용하면 다음과 같은 이점을 얻을 수 있습니다:
단, Context 구독으로 인한 리렌더링 이슈는 주의 깊게 관리해야 하며, 프로젝트의 규모와 요구사항에 따라 대안적 접근 방식도 고려해볼 필요가 있습니다.
context api를 활용한 예시를 제시해주셔서 쉽게 잘 읽을 수 있었습니다 ^^
다만 궁금한게 z-index를 js 상태값(동적인 값)으로 처리한 이유가 궁금합니다!
z-index는 아주아주 특수한 경우가 아니면 바뀌지 않기 때문에 그냥 상수로 놓고 export,import 해오면 되지 않을까 생각해서요! 오히려 동적으로 두면 다른 사이드 이펙트를 유발하거나 유지보수에 문제를 일으킬 값이라고 생각합니다. 혹시 동적인 불가피하게 처리한 예시가 있다면 궁금하네요!