React DropdownList 코드 이해

props · ...props · ?. · ?? · useState · useEffect · useRef · useLayoutEffect · 위/아래 자동 펼침까지 한 페이지 정리

1. 컴포넌트 전체 목적

기본 <select> 대신 직접 만든 커스텀 드롭다운입니다. 버튼을 클릭하면 목록이 열리고, 항목을 선택하면 선택값이 표시됩니다. 바깥 클릭 시 닫히며 화면 아래 공간이 부족하면 목록을 위쪽으로 펼칩니다.

버튼 클릭 ↓ isOpen = true ↓ 화면 공간 계산 ↓ direction = 'down' 또는 'up' ↓ 항목 클릭 ↓ currentIndex 변경 ↓ onChange(item, index) 호출 ↓ 목록 닫기

2. props 받는 부분

export default function DropdownList({
  className,
  label = '부서명',
  items = ['항목', '항목', '항목'],
  selectedIndex = null,
  onChange,
  ...props
}) {
prop역할
className외부에서 추가할 CSS 클래스
label아직 선택하지 않았을 때 표시할 문구
items드롭다운에 표시할 항목 배열
selectedIndex선택된 항목의 index
onChange항목 선택 후 부모에게 값을 전달하는 함수
...props위에서 따로 꺼내지 않은 나머지 props

3. 유니코드 문자열

label = '\uBD80\uC11C\uBA85'
items = ['\uD56D\uBAA9', '\uD56D\uBAA9', '\uD56D\uBAA9']

위 코드는 실제로 다음과 같습니다.

label = '부서명'
items = ['항목', '항목', '항목']
특수기호가 아니라 자바스크립트의 유니코드 이스케이프 문자열입니다. 한글을 그대로 작성해도 됩니다.

4. ...props란?

<DropdownList
  label="부서 선택"
  items={['개발팀', '디자인팀', '기획팀']}
  selectedIndex={0}
  onChange={handleChange}

  id="departmentDropdown"
  title="부서를 선택하세요"
  data-type="department"
/>

label, items, selectedIndex, onChange는 미리 꺼냈으므로 나머지만 props 객체에 들어갑니다.

props = {
  id: 'departmentDropdown',
  title: '부서를 선택하세요',
  'data-type': 'department'
}

아래처럼 다시 펼치면:

<div
  className={cn(styles.dropdownList, className)}
  ref={dropdownRef}
  {...props}
>

실제 DOM에는 개념적으로 다음과 같이 들어갑니다.

<div
  id="departmentDropdown"
  title="부서를 선택하세요"
  data-type="department"
>
...props = 나머지 props를 한 번에 모았다가 다시 전달하는 문법

5. ...props 실제 사용

<DropdownList
  label="부서 선택"
  items={['개발팀', '디자인팀', '기획팀']}
  id="departmentDropdown"
  title="부서를 선택하세요"
  data-code="DEP001"
  onMouseEnter={() => console.log('마우스 진입')}
/>

id, title, data-code, onMouseEnter는 별도로 꺼내지 않았기 때문에 ...props에 들어가 최상위 div에 전달됩니다.

6. 상태값 useState

const [isOpen, setIsOpen] = useState(false)
const [currentIndex, setCurrentIndex] = useState(selectedIndex)
const [direction, setDirection] = useState('down')
state의미
isOpen드롭다운 열림 여부
currentIndex현재 선택된 항목의 index
direction목록이 아래 또는 위로 펼쳐질지

7. ?? nullish coalescing 연산자

const buttonText = selectedItem ?? label

??는 왼쪽 값이 null 또는 undefined일 때만 오른쪽 값을 사용합니다.

현재 코드에서는

selectedItem이 값이 있으면
→ selectedItem 사용

selectedItem이 null 또는 undefined이면
→ label 사용

따라서 다음 코드와 거의 같은 의미입니다.

let buttonText

if (selectedItem === null || selectedItem === undefined) {
  buttonText = label
} else {
  buttonText = selectedItem
}

예제

const selectedItem = '개발팀'
const buttonText = selectedItem ?? '부서명'

// 결과
buttonText === '개발팀'
const selectedItem = null
const buttonText = selectedItem ?? '부서명'

// 결과
buttonText === '부서명'
||와 차이:
??는 null, undefined만 없는 값으로 판단합니다.
||는 '', 0, false도 없는 값처럼 판단합니다.
0 ?? 10   // 0
0 || 10   // 10

'' ?? '기본값'   // ''
'' || '기본값'   // '기본값'

8. ?. Optional Chaining

?.는 앞의 값이 null 또는 undefined가 아닐 때만 뒤의 속성, 메서드, 함수를 실행합니다.

① 함수 호출에서 사용

onChange?.(item, index)

의미는:

onChange 함수가 존재하면
→ onChange(item, index) 실행

onChange가 undefined 또는 null이면
→ 아무것도 하지 않음

일반 if문으로 작성하면:

if (onChange) {
  onChange(item, index)
}

따라서 부모가 onChange를 전달하지 않아도 에러가 발생하지 않습니다.

<DropdownList
  items={['개발팀', '기획팀']}
/>

// onChange가 없어도 정상 동작

② 객체 / DOM 메서드에서 사용

dropdownRef.current?.contains(event.target)

의미는:

dropdownRef.current가 존재하면
→ contains(event.target) 실행

dropdownRef.current가 null 또는 undefined이면
→ contains()를 실행하지 않음
→ 결과는 undefined

일반적으로 풀어쓰면:

dropdownRef.current &&
dropdownRef.current.contains(event.target)

9. 이 코드 해석하기

if (!dropdownRef.current?.contains(event.target)) {
  setIsOpen(false)
}

이 코드는 처음 보면 복잡해 보이지만 세 단계로 나누면 쉽습니다.

1단계: 현재 DOM이 있는지 확인

dropdownRef.current

2단계: 클릭한 곳이 드롭다운 내부인지 확인

dropdownRef.current?.contains(event.target)

내부를 클릭했다면:

true

외부를 클릭했다면:

false

3단계: 앞에 !가 붙음

!true   // false
!false  // true

그래서 최종 의미는:

드롭다운 내부 클릭 contains(...) → true !true → false if 실행 안 함 → 그대로 열려 있음 드롭다운 외부 클릭 contains(...) → false !false → true if 실행 → setIsOpen(false) → 드롭다운 닫힘

조금 길게 풀어쓴다면 다음처럼 생각할 수 있습니다.

if (dropdownRef.current) {

  const isInside =
    dropdownRef.current.contains(event.target)

  if (!isInside) {
    setIsOpen(false)
  }
}
한 줄 해석: 클릭한 곳이 현재 DropdownList 내부가 아니면 드롭다운을 닫아라.

10. selectedIndex와 내부 상태 동기화

useEffect(() => {
  setCurrentIndex(selectedIndex)
}, [selectedIndex])

부모의 selectedIndex가 바뀌면 내부의 currentIndex도 같은 값으로 맞춥니다.

11. useRef와 외부 클릭 처리

const dropdownRef = useRef(null)

<div ref={dropdownRef}>

렌더링 후 dropdownRef.current에는 실제 div DOM이 들어갑니다.

useEffect(() => {
  function handleClickOutside(event) {
    if (!dropdownRef.current?.contains(event.target)) {
      setIsOpen(false)
    }
  }

  document.addEventListener('mousedown', handleClickOutside)

  return () =>
    document.removeEventListener(
      'mousedown',
      handleClickOutside
    )
}, [])

document 전체에서 mousedown을 감지해서 DropdownList 바깥을 클릭했는지 검사합니다. return 부분은 이벤트를 제거하는 cleanup입니다.

12. useLayoutEffect와 위/아래 자동 펼침

const gap = 5
const maxListHeight = 121
const listHeight =
  Math.min(items.length * 40, maxListHeight)

const rect =
  dropdownRef.current.getBoundingClientRect()

const spaceBelow =
  window.innerHeight - rect.bottom

const spaceAbove =
  rect.top
setDirection(
  spaceBelow < listHeight + gap &&
  spaceAbove > spaceBelow
    ? 'up'
    : 'down'
)

아래 공간이 부족하면서 위 공간이 더 크면 up, 아니면 down입니다.

13. 항목 선택과 onChange

function handleSelect(item, index) {
  setCurrentIndex(index)
  setIsOpen(false)
  onChange?.(item, index)
}
디자인팀 클릭 ↓ handleSelect('디자인팀', 1) ↓ currentIndex = 1 ↓ isOpen = false ↓ onChange가 있으면 onChange('디자인팀', 1) ↓ 부모에게 값 전달

14. items.map()으로 목록 생성

{items.map((item, index) => (
  <li key={`${item}-${index}`}>
    <button
      className={cn(
        styles.option,
        index === currentIndex &&
          styles.selected
      )}
      onClick={() =>
        handleSelect(item, index)
      }
    >
      <span>{item}</span>

      {index === currentIndex && (
        <span className={styles.check} />
      )}
    </button>
  </li>
))}

배열을 반복하여 항목을 만들고, 선택된 index에는 selected 클래스와 체크 표시를 추가합니다.

15. 부모 컴포넌트 실제 사용 예

import { useState } from 'react'
import DropdownList from './DropdownList'

export default function App() {
  const [departmentIndex, setDepartmentIndex] =
    useState(null)

  const departments = [
    '개발팀',
    '디자인팀',
    '기획팀'
  ]

  function handleDepartmentChange(item, index) {
    setDepartmentIndex(index)

    console.log('선택한 부서:', item)
    console.log('선택 index:', index)
  }

  return (
    <DropdownList
      label="부서 선택"
      items={departments}
      selectedIndex={departmentIndex}
      onChange={handleDepartmentChange}

      id="departmentDropdown"
      title="부서를 선택하세요"
      data-code="DEP001"
    />
  )
}

16. ?. 와 ?? 최종 비교

문법이름역할현재 코드 예
?.Optional Chaining앞의 값이 null/undefined가 아닐 때만 접근 또는 실행onChange?.(item, index)
dropdownRef.current?.contains(...)
??Nullish Coalescing왼쪽 값이 null/undefined이면 오른쪽 기본값 사용selectedItem ?? label
?. → "있으면 실행해 / 접근해" ?? → "없으면 이 기본값을 써"

17. 전체 핵심 요약

문법/기능기억할 내용
...props따로 꺼내지 않은 나머지 props를 모아서 DOM 등에 전달
selectedItem ?? labelselectedItem이 null/undefined이면 label 사용
onChange?.(...)onChange 함수가 존재할 때만 실행
ref.current?.contains(...)ref.current가 존재할 때만 contains 실행
!contains(...)내부가 아니라면 true가 되어 외부 클릭 처리
useState열림 여부, 선택 index, 방향 저장
useEffect값 동기화, document 이벤트 관리
useRef실제 DOM 참조
useLayoutEffectDOM 위치를 측정해 펼침 방향 결정
map()items 배열을 반복하여 UI 생성