React Utils 코드 설명

이 페이지는 src/utils 폴더의 cn.js, focusTrap.js, pageScrollLock.js를 React 입문자가 이해하기 쉽도록 풀어쓴 원페이지 설명서입니다.

1. cn.js

cn은 CSS 클래스 이름을 편하게 합쳐주는 함수입니다. React에서 조건에 따라 클래스명을 넣거나 빼야 할 때 문자열을 직접 이어 붙이지 않아도 됩니다.

한 줄 요약 문자열, 배열, 객체를 받아서 최종 className 문자열로 바꿉니다. 주로 쓰는 곳 <button className={cn(...)}>처럼 조건부 스타일을 적용하는 곳 반환값 공백으로 연결된 클래스 문자열

원본 코드

export function cn(...inputs) {
  return inputs
    .flatMap((input) => {
      if (!input) return []

      if (Array.isArray(input)) {
        return cn(...input)
      }

      if (typeof input === "object") {
        return Object.entries(input)
          .filter(([, value]) => Boolean(value))
          .map(([key]) => key)
      }

      return String(input)
    })
    .join(" ")
}

코드 흐름

1...inputs로 여러 값을 받습니다.
2false, null, 빈 문자열 같은 값은 버립니다.
3배열이면 다시 cn을 호출해서 안쪽 값도 처리합니다.
4객체이면 값이 true인 key만 클래스명으로 사용합니다.

사용 예시

import { cn } from "./utils/cn"

function Button({ active, disabled, children }) {
  return (
    <button
      className={cn(
        "btn",
        active && "btn-active",
        disabled && "btn-disabled",
        { "btn-clickable": !disabled }
      )}
      disabled={disabled}
    >
      {children}
    </button>
  )
}

예를 들어 active가 true이고 disabled가 false이면 결과는 "btn btn-active btn-clickable"이 됩니다.

2. focusTrap.js

createFocusTrap은 모달이나 팝업이 열렸을 때 키보드 포커스가 그 안에서만 돌도록 만드는 함수입니다. 접근성에서 중요한 기능입니다.

한 줄 요약 Tab 키를 눌렀을 때 포커스가 모달 밖으로 빠져나가지 않게 막습니다. 주로 쓰는 곳 모달, 드로어, 팝업 메뉴처럼 화면 위에 떠 있는 UI 반환값 이벤트를 정리하고 이전 포커스로 되돌리는 cleanup 함수

핵심 개념

코드 흐름

1모달이 열릴 때 현재 포커스 위치를 저장합니다.
2첫 번째 포커스 가능 요소로 포커스를 보냅니다.
3keydown 이벤트로 Tab 이동을 감시합니다.
4모달이 닫히면 이벤트를 제거하고 이전 포커스를 복원합니다.

사용 예시

import { useEffect, useRef } from "react"
import { createFocusTrap } from "./utils/focusTrap"

function Modal({ open, onClose }) {
  const modalRef = useRef(null)

  useEffect(() => {
    if (!open || !modalRef.current) return

    const cleanupFocusTrap = createFocusTrap(modalRef.current)

    return () => {
      cleanupFocusTrap()
    }
  }, [open])

  if (!open) return null

  return (
    <div className="modal-backdrop">
      <div ref={modalRef} className="modal" role="dialog" aria-modal="true">
        <h2>알림</h2>
        <p>Tab 키를 눌러도 포커스는 이 모달 안에서만 이동합니다.</p>
        <button onClick={onClose}>닫기</button>
      </div>
    </div>
  )
}
입문자가 기억할 점: useEffect에서 만든 이벤트나 기능은 컴포넌트가 사라질 때 정리해야 합니다. 그래서 이 함수는 cleanup 함수를 반환합니다.

3. pageScrollLock.js

lockPageScroll은 모달이나 메뉴가 열렸을 때 배경 페이지가 스크롤되지 않도록 막는 함수입니다. 닫을 때는 원래 스크롤 위치와 body 스타일을 되돌립니다.

한 줄 요약 화면 위 UI가 열려 있는 동안 body 스크롤을 잠급니다. 주로 쓰는 곳 모달, 모바일 전체 메뉴, 바텀시트 반환값 스크롤 잠금을 해제하는 cleanup 함수

핵심 변수

코드 흐름

1처음 잠글 때 현재 스크롤 위치와 body 스타일을 저장합니다.
2body에 opened 클래스를 추가합니다.
3position: fixed, overflow: hidden으로 배경 스크롤을 막습니다.
4cleanup 때 스타일을 복원하고 원래 스크롤 위치로 돌아갑니다.

사용 예시

import { useEffect } from "react"
import { lockPageScroll } from "./utils/pageScrollLock"

function MobileMenu({ open, onClose }) {
  useEffect(() => {
    if (!open) return

    const unlockPageScroll = lockPageScroll()

    return () => {
      unlockPageScroll?.()
    }
  }, [open])

  if (!open) return null

  return (
    <aside className="mobile-menu">
      <button onClick={onClose}>닫기</button>
      <nav>
        <a href="/products">상품</a>
        <a href="/support">고객센터</a>
      </nav>
    </aside>
  )
}
왜 lockCount가 필요할까요? 모달 안에서 또 다른 팝업이 열릴 수 있습니다. 첫 번째 팝업이 닫혔다고 바로 스크롤을 풀면 두 번째 팝업 뒤의 페이지가 움직일 수 있기 때문에, 열린 잠금 개수를 세고 모두 닫혔을 때만 해제합니다.

같이 쓰는 예시: 접근성 있는 모달

실제 모달에서는 createFocusTrap과 lockPageScroll을 함께 쓰는 경우가 많습니다. 포커스는 모달 안에 가두고, 배경 페이지는 스크롤되지 않게 합니다.

import { useEffect, useRef } from "react"
import { cn } from "./utils/cn"
import { createFocusTrap } from "./utils/focusTrap"
import { lockPageScroll } from "./utils/pageScrollLock"

function AccessibleModal({ open, size = "md", onClose, children }) {
  const modalRef = useRef(null)

  useEffect(() => {
    if (!open || !modalRef.current) return

    const cleanupFocusTrap = createFocusTrap(modalRef.current)
    const unlockPageScroll = lockPageScroll()

    return () => {
      cleanupFocusTrap()
      unlockPageScroll?.()
    }
  }, [open])

  if (!open) return null

  return (
    <div className="modal-backdrop">
      <section
        ref={modalRef}
        className={cn("modal", {
          "modal-sm": size === "sm",
          "modal-md": size === "md",
          "modal-lg": size === "lg",
        })}
        role="dialog"
        aria-modal="true"
      >
        {children}
        <button onClick={onClose}>닫기</button>
      </section>
    </div>
  )
}