개발새발 로그

DropDownBox 기능을 구현하면서 본문

React

DropDownBox 기능을 구현하면서

이즈흐 2025. 1. 4. 19:56

📖들어가며..

DropDownBox기능을 구현해보려고 한다.

해당 기능을 구현하면서의 과정은 아래와 같다.

 

1. 합성컴포넌트 패턴과 Context API를 이용한 구현

2. 합성컴포넌트 패턴에 맞는 HeadLess 컴포넌트로 구현

3. HeadLess 컴포넌트와 Custom Hook을 이용한 구현

4. HTML select를 이용한 구현

5. 보통 DropDown 사용하는 방식

 

1번부터 기능을 구현하면서 해당 방식의 단점을 개선해가며 기능을 구현해보았다.

이렇게 기능을 구현하면서 어떤 기능이 더 적합한지 알아보고, 기록해보려고 한다

 

 


 

 

1. 합성컴포넌트 패턴과 Context API를 이용한 구현

1-1. 합성 컴포넌트 패턴으로 설계하기

DropDownBox를 구현할 때 합성 컴포넌트 패턴을 이용해서 설계하고,

각각 Trigger, List, Item에서 필요한 상태들을 Context API를 이용해서 관리한다.

 

실제로 List 아이템이 몇개가 있는지 

어떤 아이템이 현재 focus 되어 있는지

어떤 아이템이 현재 select 되어 있는지

에 대한 정보가 DropDown이라는 합성 컴포넌트 사이에서는 다 공유가 되어야 하기 때문에 Context API를 사용한다.

Props로 전달해도 되지만 그것보다는 Context로 만드는 것을 선택했다.

 

Context는 아래와 같이 구성하면 될 것이다.

이 방식은 React 공식문서를 활용했다.

 

 


2-1. DropDownBox 합성 컴포넌트 역할

DropDownBox의 아이템들의 정보

 

이를 관리하기 위한 Context는 아래와 같다.

위와 같은 데이터를 각각의 컴포넌트들이 골라서 사용할 것이다.

 

🛠️2-1. DropDownTrigger 컴포넌트

DropDownBox를 클릭하는 부분인 컴포넌트다.

해당 컴포넌트에서는 toggle 메서드로 isOpen 상태를 제어합니다.

그리고 선택된 아이템이 보이도록 selectedItem 데이터를 보여줍니다.

🛠️2-2. DropDownContainer 컴포넌트

DropDownTrigger 컴포넌트와 DropDownList 컴포넌트를 감싸는 컴포넌트다.

해당 컴포넌트에서는 onKeyDown에 키보드 이벤트를 등록한다.

해당 키보드 이벤트 함수는 우리가 사용하는 키보드 이벤트에 맞춰 매핑한 후에 적절한 함수를 수행하게 한다.

🛠️2-3 DropDownList 컴포넌트

DropDownItem컴포넌트를 나열하고, isOpen에 따라 보이거나 안보이도록 한다.

🛠️2-4 DropDownItem 컴포넌트

각 아이템들이 키보드 이벤트로 인해 focus된 아이템을 알려주는 focusIndex

현재 선택된 아이템을 알려주는 selectedIndex

scrollIntoView를 위한 itemRef

각 아이템 클릭시 바뀌도록 하는 selectIndex setter함수

들을 활용해서 각 아이템을 구성했다.

 

 

 


3. 키보드 이벤트로 focus, select 기능 구현하기 위한 매핑함수

이제 DropDownBox를 클릭해서 나오는 List들을 키보드로 focus하거나 select할 수 있도록 기능을 구현해보려 한다.

 

여기서 설명한 매핑함수는 이후 handleKeyDown이라는 함수로 DropDownBox의 Container 컴포넌트onKeyDown으로 적용할 것이다.

이제 매핑함수를 보자

up, down, enter, escape로 된 키보드 이벤트를 각각 매핑하고,

해당 이벤트가 일어났을 때 실행할 함수들을 넣어준다.

이때 각 함수들에 필요한 매개변수가 있기 때문에 해당 map을 사용할 때

어떤 매개변수가 필요할지 모르니 사용시에 모든 매개변수를 넣어줘야 한다.

 

여기서 추가로 설명할 점은

Up 또는 Down을 수행할 때 focus가 반복순회할 수있도록 계산을 추가한 점이다.

해당 계산은 이전에 캐러셀을 만들 때 했던 방식이다.

 

그리고 각 이벤트에 e.preventDefault() 가 존재하는데

특히 Enter를 수행했을 때 자동으로 DropDownBox의 List가 닫히게 된다.

이건 실제로 DropBoxTrigger 부분에 Focus가 되어있는 상태이고,

Enter 이벤트를 수행했을 때 DropBoxTrigger에 Focus가 되어있는 상태니까 등록되어있던 onClick 이벤트를 수행하게 되는 것이다.

그래서 Enter시에 List가 닫히는 것을 막으려면 e.preventDefault()를 수행해줘야 한다.

 

 


4. Context Provider에서 관리하는 상태와 기능

이제 위에서 합성 컴포넌트 패턴의 각 컴포넌트에서 사용하는 상태들을 모두 정의해줘야한다.

우리는 Context API를 사용하기 때문에 Context Provider 부분에서 해당 상태들을 관리할 것이다.

 

위와 같이 필요한 모든 상태들을 정의했다.

그리고 Provider에 각각 해당되는 데이터 또는 setter함수를 넣어줬다.

 

toggle함수는 아래와 같이 만들어주면 된다.

매개변수의 force는 이후 재사용성을 위해 옵셔널하게 넣어줬다.

 

그리고 handleKeyDown 함수는 위에서 만들어줬던 매핑 함수를 이용한다.

event 객체에서 현재 어떤 키보드 이벤트인지 알기 위해 Key 값을 꺼내고 해당 Key값으로 매핑한다.

그리고 매핑되어 나온 함수를 실행하는 것이다.

 

✨키보드 이벤트로 focus할 때 스크롤도 같이 이동해야한다.

현재 키보드 이벤트로 focus 아이템을 이동시킬 때 스크롤은 자동으로 이동하지 않을 것이다.

이를 해결하기 위해서 scrollInToView()를 사용할 것이다.

 

그럼 먼저 각 아이템 DOM 정보를 ref에 저장해야한다.

Context를 이용해 ref를 관리한다.

이후 아래와 같이 ref를 가져와서 각 아이템의 DOM 정보를 저장한다.

그러면 ref에는 각 아이템 DOM 정보가 배열로 저장될 것이다.

그리고 다시 Provider에서는 useEffect를 통해 focusedIndex 상태를 의존성 배열에둔다.

이를 통해서 만약 focus 아이템이 변경되면 위 로직을 수행하는 것이다.

scollIntoView를 통해 focus가 변할 때마다 focus가 된 아이템이 보이도록 스크롤이 움직인다.

 

 

✨바깥을 클릭했을 때 닫혀야 한다.

window에 클릭 이벤트를 등록했다.

once 옵션을 활용해서 한 번사용되면 없어지도록했다.

 

 

 

 


 

 

 

⭐2. 합성컴포넌트 패턴에 맞는 HeadLess 컴포넌트로 구현

 

하지만 현재 위와 같이 진행하면 합성 컴포넌트의 장점을 활용하지 못하게 된다.

각각의 컴포넌트들이 UI 부분까지 모두 담당하고 있기 때문이다.

위 방식은 합성컴포넌트를 활용하지 못하고 있다.

합성 컴포넌트 패턴을 제대로 활용하기 위해 HeadLess컴포넌트 방식을 적용해보려고 한다.

 

🤔HeadLess 컴포넌트 적용

이제 합성 컴포넌트의 활용을 위해서는 각 합성 컴포넌트의 children을 활용해야한다.

하지만 Context API를 사용하고, 각 컴포넌트에 필요한 이벤트나, ref와 같은 것을 등록하려면 합성 컴포넌트 패턴으로 하기에는 어려울 수 있다.

 

그래서 사용하는 것이 HeadLess 컴포넌트이다.

HeadLess 컴포넌트란?
Headless 컴포넌트는 리액트에서 사용자 인터페이스(UI)와 비즈니스 로직을 분리하기 위해 설계된 컴포넌트 유형입니다.
UI를 렌더링하지 않고, 상태 관리나 로직만을 제공하며, 이를 사용하는 개발자가 원하는 UI를 자유롭게 구현할 수 있도록 돕습니다.

간단하게 말해서 데이터 로직(body)은 있지만 UI 로직(head)이 없는 컴포넌트이다.

현재 코드로 설명하자면 위처럼 컴포넌트에서 사용하던 데이터 로직을 Props로 바꿔주는 것이다.

그리고 해당 컴포넌트를 childrenNodeProps로 전달해주는 것이다.

 

쉽게 말해서 미리 UI가 정의된 컴포넌트들을 만들어서 내려주는 것이다.

DropDown기능을 사용하기 전에 UI가 정의된 컴포넌트 들을 내려줘야 한다.

 

 

🛠️createDropdown 함수

현재 아래와 같이 createDropdown은이라는 함수 내부에서 위에서 설명했던 로직을 작성했다.

해당 함수 내부에서 합성 컴포넌트들을 각각 정의하고 내보내주고 있다.

위에서 만든 기능들을 하나의 함수에서 만들도록 한다.코드는 거의 비슷하다.

각 컴포넌트에서 사용하는 DropDown 아이템의 타입은 제네릭으로 설정해주었다.

왜 이렇게 함수로 만들었는지는 아래에 설명하도록 하겠다.

 

 

그럼 실제로는 아래와 같이 사용한다.

childrenNode로 미리 만든 컴포넌트들을 내려준다.

 

🤔왜 createDropDown이라는 함수 내부에서 사용하게 했나요?

현재 DropDownBox에서 나열되는 아이템의 타입이 고정되어있는 것이 아니라 상황에 따라 변경되어야 한다.

그렇다면 제네릭을 사용해야 할 것이다.

item의 타입을 제네릭으로 설정

위처럼 제네릭을 썼을 때 createContext를 사용할 때 문제가 생기게 된다.

위처럼 제네릭을 받을 수단이 없기 때문에 우리가 위에서 미리 만든 타입을 사용할 수 없게 된다.

그래서 createContext에서도 제네릭을 사용할 수 있도록 createDropDown이라는 함수를 만들어

아래와 같이 제네릭을 만들어준 것이다.

그러면 아래와 같이 createDropDown함수를 호출해서 사용해야 된다.

이때 타입을 지정해주는 것이다.

 

🤔제네릭 사용할 때 <T,> 처럼 콤마를 사용하는 이유

리액트와 타입스크립트에서 제네릭(Generic)을 사용할 때 <T,>처럼 뒤에 쉼표(콤마)를 붙이는 이유는 JavaScript와 TypeScript의 문법적 모호성을 방지하기 위해서이다.

 

1. 제네릭과 JSX의 혼동 방지

- 리액트에서 사용하는 JSX는 HTML-like 문법을 사용합니다.

- 따라서 타입스크립트 컴파일러는 <T>를 JSX 태그로 잘못 해석할 수 있습니다.

- 예를 들어: const createDropdown = <T>() => { ... }

- 여기서 <T>를 JSX 태그(예: <T></T>)로 착각할 가능성이 있습니다.

- 따라서 타입스크립트에서는 이런 혼란을 방지하기 위해 <T,>처럼 쉼표를 추가하여 제네릭임을 명확히 할 수 있습니다.

2. 타입스크립트에서 제네릭 구문 명확화

- 타입스크립트는 <T,>를 보면 확실히 제네릭 타입 변수임을 이해합니다.

- 따라서 코드 가독성 안정성을 높이기 위해 제네릭 뒤에 쉼표를 붙이는 것이 일반적인 관례입니다.

- 이 관례는 특히 **다른 제네릭 타입(예: <T, K>)**을 함께 사용하는 경우에도 명확성을 유지하도록 도와줍니다.

3. TSX 파일에서만 유효

- <T,> 형식은 리액트의 JSX(혹은 TSX) 파일에서 사용하는 경우에만 필요합니다.

- 만약 일반 TypeScript 파일(.ts)에서 작업한다면 JSX와의 충돌 가능성이 없으므로 <T>만 사용해도 충분합니다.

 

 


 

 

다시 돌아와서 실제 각 컴포넌트의 데이터 로직은 아래와 같이 주입된다.

여러 컴포넌트 중 Trigger 컴포넌트 예시

children의 타입은 아래와 같이 구성되어야 한다.

하지만 문제는 

만약 props의 타입을 일치시켜주지 않는다면 기능이 제대로 작동하지 않게된다.

toggle메서드를 props에서 지운 상태지만 어떤 부분에서도 오류가 발생하지 않고, 기능만 작동하지 않게 된다.

 

 

 

 


 

⭐3. HeadLess 컴포넌트와 Custom Hook을 이용한 구현

HeadLess로 만들면서 들 수 있는 생각이 "굳이 Context로 관리하는 게 좋을까?" 이다.

Context를 없애고 Hook으로 관리하고,

HeadLess 컴포넌트로 만들어둔 컴포넌트들을 컴포넌트 형식으로 정의하는 게 아닌 

해당 컴포넌트에서 쓸 데이터들을 Hook으로 만듦어서 원하는 곳에 사용하게끔 하는 것이 낫지않을까? 라고 생각이 들 수 있다.

 

그러면 기존에 만든 기능들을 hook으로 변경해볼 수 있다.

useDropdown이라는 hook으로 만들고,

해당 훅 내에서 각 컴포넌트마다 필요한 값이나 함수들을 내려주는 특정 함수를 만든다.

 

그리고 그 함수들을 아래와 같이 각각 해당되는 컴포넌트에 할당하는 것이다.

문제는 DDList 부분에서 data에 직접 접근해서 나열해야한다는 점이 사용하기에 불편할 수 있겠다라는 생각이 들었고,

해당 기능을 사용하기에 직관적이지 못하다는 느낌도 들었다.

 

그저 이렇게 기존에 Context를 사용하던 기능을 hook으로 변경할 수 있구나 라는 배움이 있었다.

 

 


⭐4. 가장 보편적이고, 좋은 HTML select

사실 가장 사용하기 편하고, 좋은 것은 HTML의 select를 이용하는 것이다.

key이벤트도 내장되어있고, 알아서 브라우저 화면에 맞춰서 렌더링 위치도 변경된다.

 

사용법도 정말 간단하다.

이렇게만 구성하면 아래와 같이 만들어진다.

단점은 option 내부의 디자인을 커스텀할 수 없다는 점이다.

이 단점말고는 모든 것이 장점이다.

그래서 만약 option 내부의 디자인이 요구사항에 없다면 해당 기능을 사용하는 것이 맞다고 생각이 든다.

 

 


🤔5. 과연 지금까지 만든 DropDown이 옳은 방식일까?

현재까지 만든 DropDown을 보면서 든 생각이

"뭔가 사용하기 복잡해보인다" 라는 생각이 들었다.

그래서 보통 어떻게 사용하는지 검색해보며 찾아봤는데

보통은 아래와 같이 만드는 것 같았다.

그래서 나도 위와 같은 틀에 맞춰서 기능을 구현해보려고 한다.

위처럼 그저 data를 내려줘서 구현할 수 있었다.

이 방법이 가장 사용하기 간단한 것 같긴 했다.

단점으로는 UI를 커스텀할 때 DropDown 컴포넌트 내부를 수정해야한다는 것이었다.

 

 


 

 

📘마치며..

이렇게 DropDown 기능을 구현해보면서 다양한 접근 방법을 시도해보았다.

실제로는 HTML Select의 기능을 리액트로 구현해보면서 기능을 좀 더 깊게 알 수 있었다.

 

728x90
반응형
LIST