React에서의 querySelector, 잘 쓰고 있는 걸까?(feat. Ref)
들어가기
Vanilla JS를 사용하면서, DOM을 조작하기 위해 정말 많은 DOM Selector들을 쓰곤 했다(querySelector, getElementById, getElementByClassName 등). 하지만 React를 사용하게 되면서 querySelector를 쓰는 일은 거의 사라졌다. React에서 제공하는 Virtual DOM을 사용하게되면서 굳이 Real DOM에 직접 접근할 상황이 필요하지 않았기 때문이다.
하지만 이런 상황이 아예 오지 않는 것은 아니다. 리액트를 사용하는 프로젝트에서도 Real DOM을 직접 선택해야하는 상황이 종종 발생하곤 한다. input에 focus를 주거나, 특정 DOM의 크기, 스크롤 위치 등을 설정해야 할 때 등이 있다. React에서는 Real DOM을 직접 건드리는 DOM Selector들을 자주 사용하는 것을 지양하라고 제안한다. 그렇게 querySelector 등의 DOM Selector들을 자주 사용하지 않게되다보니 점점 이들을 사용하는 게 어색해졌다. 어느 곳에서도 사용하면 안될 것 같은 느낌이 들고 알 수 없는 거부감이 생겼다. 그래서.. 뭔가 이전에 친했던 querySelector와 점점 건강하지 못한 관계(?)가 되는 것 같아 이런 알 수 없는 거부감을 떨쳐내고자 글을 쓰게 되었다.
🙅♀️ 왜 React에서는 DOM API 사용이 지양될까?
React에서 document.querySelector를 사용하게되면, 실제 DOM의 요소를 가져오게 된다. 하지만 React는 Virtual DOM을 통해 Real DOM을 그리기 때문에, React가 제어하고있는 Virtual DOM 안에 있는 요소가 더 신뢰할만하다. DOM API로 Real DOM에 있는 친구를 집었는데, 이게 현재 Virtual DOM을 통해 Real DOM에 존재하는 친구인지 아닌지 확신할 수 없다는 것!
React를 사용하게되면, 가장 중요한 개념 중 하나가 바로바로 State이다. React 내부에서 데이터는 컴포넌트 내의 State으로 조작된다. 즉, React가 State를 컨트롤(제어)하고 있다. 만약 이러한 React 시스템을 벗어나 DOM을 직접적으로 건드리게되면 이 내용들은 React가 제어하는 영역에서 벗어나게 되고, 이렇게 React의 제어를 벗어나게 되면, React에서 제공하는 이점들을 사용할 수 없게 된다.
또 이렇게 React가 제어하는 State와 제어하지 않는 State을 혼용해서 사용해 데이터를 조작할 경우, 위에 언급했듯이 React의 Lifecycle에 맞추어 DOM Element를 가져오지 못해 가져온 DOM Element를 신뢰할 수 없어지는 문제가 발생한다. 이렇게 데이터를 어디에서 어떻게 조작하고 있는지 예측하기 어렵기 때문에 디버깅 또한 어려워진다.
그렇기 때문에 React에서 DOM Selector를 사용하고싶으면 React의 Lifecycle과 함께 동작하는 Ref를 사용하는 것이 더 바람직하다. Babble 프로젝트에서도 DOM Selector를 사용해야 하는 상황이 오면 React에서 제공하는 ref를 사용해 상황을 해결했다.
🙋 그러면 Ref는 무엇이고, 어떻게 사용해야 할까?
Refs provide a way to access DOM nodes or React elements created in the render method. - reactjs.org
ref는 reference의 줄임말이다. 네이버 영어사전을 찾아보면 아래와 같은 뜻을 찾을 수 있다. 무언가 참고하거나 참조하다. 라는 뜻으로 해석할 수 있다. 우리가 어떤 DOM Node나 React Element에 접근할 때 사용할 수 있다.
Ref를 console에 찍어보면, 아래와 같이 { current: null } 이 찍히는 것을 볼 수 있다. Ref는 일반 객체로 State과 마찬가지로 Component가 mount될 때 반영되고 unmount되면 다시 null이 된다.
Babble 팀에서 사용한 수많은 ref들 중, 가장 기억에 남는 건 아무래도 검색 창에서 사용한 ref이다. 검색 창이 focus되면 자동완성 창이 나와야 했기에 ref를 사용했다. 자동완성 검색을 구현하면서 3개의 ref를 사용했는데, 구성은 아래와 같다.
<div class='container' ref={containerRef}>
<input type='search' ref={inputRef}/>
<ul ref={autoCompleteRef}>
<li>...</li>
</ul>
</div>
input과 autocomplete을 container로 감싸고, 감싼 input에 focus Event가 들어오면 autocomplete을 보여주도록 했다. containerRef는 focus되었을 때 border 색깔을 바꾸어주려고 넣었다. 사실 이렇게 구현하면서도 호에에 Ref를 무려 3개나 써야한다구..?! 라고 생각이 들긴 했다.😅 괜한 거부감이랄까..! 그래도 글을 쓰면서 이런 쓸데없는 거부감은 많이 줄긴 했다. 헤헷
나중에 리팩토링을 하면서 더 좋은 방법을 모색해보고, 찾게되면 또 포스팅하겠다.
😞 그럼 querySelector는 아예 쓰면 안돼?
사실 처음에는 querySelector를 쓰면 안된다길래, 정말 모든 곳에서 사용하는 것을 지양하라는 줄 알았다. 하지만 생각해보면, document.querySelector가 Real DOM에서 원하는 친구를 콕 찝어서 가져오는 것이 문제지, 우리가 Virtual DOM 상에서 찝어온 친구 안에서 무언가를 찾으려 할 때 target.querySelector를 사용하는 것은 큰 문제가 아니다. 사용해도 된다!
그런데, 이 방법에도 아니나 다를까 단점이 존재한다. 허헛💩
아직 쉽사리 정답을 내리지 못한 문제이기는 한데, 최대한 생각을 풀어 써내려가 보겠다.
우아한 테크코스 미션 중에, 리뷰어로부터 아래와 같은 피드백을 받은 경험이 있다.
closest, querySelector 등을 사용하게되면, 결국 구조에 종속적인 코드를 짜게 된다. 구조에 종속적이지 않게 가져올 수 있는 방법을 생각해봐라.
먼저, 잠깐 closest와 querySelector에 대해 간단히 알아보고 넘어가자. 두 속성은 아래와 같은 특징이 있다.
element.closest → 현재 Element에서 우리가 특정한 속성을 가진 가장 가까운 조상을 반환함
element.querySelector → 현재 Element에서 우리가 특정한 조상을 가진 child(children)을 반환함
어떤 특정한 Element에 원하는 이벤트를 붙이거나, 내용을 가져와야 하는 등의 조작이 필요할 때에 정-말 유용하게 쓰인다. 매번 원하는 요소를 특정해 콕 찝어 가져오지 않고도 사용할 수 있으니 말이다.
그런데, 그러면 왜 도데체 리뷰어께서는 이런 피드백을 주셨을까?
명령형 프로그래밍과 선언적 프로그래밍에 관한 내용을 바탕으로 한 번 해석해보았다.
명령형 프로그래밍
명령형 프로그래밍은, 말 그대로 명령하듯이 프로그래밍을 하는 것이라고 할 수 있다.
어떤 방법(How)으로 프로그래밍 할 것인지에 충실하다.
'A 해줘', 'A 했어? 그럼 B 해줘', 'A에 문제 있어? 그럼 C로 처리해줘.' 이런 식으로 하나 하나 나열하고 떠먹여주는 프로그래밍이다.
array를 받아 모든 요소에 2를 곱해주는 예시를 하나 들어보자. 명령형 프로그래밍으로는 아래와 같이 만들 수 있다. 컴퓨터가 어떤 행동을 취해야하는지 세세히 나와있는 것을 볼 수 있다.
function double(array) {
const result = [];
for (let i=0; i<array.length; i++) {
result.push(array[i] * 2_
}
return result;
}
선언적 프로그래밍
반면 선언적 프로그래밍은 다르다. 구체적으로 어떻게 할 지에 대해 전부 명시하지 않고, 그 의도에 집중한다. 내가 무엇을(What) 원하는 지가 중요하다. 아래와 같이 만들 수 있다. 어떻게 컴퓨터가 array를 순회하는 지에 대한 내용은 명시되어있지 않다. 그러나 유저가 무엇을 하려고 하는 지에 대해서는 명확히 알 수 있다.
function double(array) {
return array.map(x => x * 2);
}
그러면 React에서는 어떤 프로그래밍 방식을 사용해야할까?
React의 공식 페이지에 접속하면, React의 특징으로 선언형, 컴포넌트 기반, 어디에든 사용할 수 있음을 제시하고 있다.
이렇게 React는 선언형 프로그래밍을 권장하고 있다. 실제로 React의 Lifecycle에 맞추어 잘 사용하다보면, (나도 모르게) 선언적인 방식으로 UI를 짜는 것을 발견할 수 있다. 내가 직접 DOM을 조작하지 않아도 무엇을 할 것인지에 대한 코드만 작성해놓으면 된다.
이렇게 선언형으로 코드를 작성하면 우선적으로 코드의 가독성이 높아진다. 어떻게 코드가 돌아가는지 줄줄 적혀있는 코드보다는 추상화된 코드가 몇 줄 적혀있다면 훨씬 가독성이 높아질 것이기 때문이다.
또, 어떤 상황을 특정해놓은 것이 아니기때문에 다른 곳에서도 재사용하기 쉽다는 장점이 있다. 명령형 코드가 A부터 F까지 가는 길을 'A → B → C → D → E → F 순서로 가!' 라고 적어놓았다면 선언형 코드는 'A부터 F로 가! 어떻게 가든 그건 너가 정하면 돼' 라고 해놓은 것이기 때문에 훨씬 더 유연하게 재사용할 수 있다.
위의 내용을 기억하며 다시 본론으로 돌아가보자. 우리가 querySelector, closest로 작성한 코드는 어떤 코드일까?
어떤 한 요소를 콕 찝어 가져와 '이 요소에 이 이벤트를 붙여줘!' '이 요소에 이런 속성을 부여해줘!' 라고 말하는 것은 명령형에 더 가까워보인다. 어떤 컴포넌트를 만들면서 이렇게 구조에 종속적인 코드를 넣게 되면, 이 코드는 결국 재사용하기 힘든, 명령형 컴포넌트가 될 것이다.
아마 리뷰어께서는 이런 부분을 알려주시려고 한게 아닐까? 허헛
여기에서 우리는 한 발짝 더 나아가 고민해보았다.
그러면 이렇게 명령형 컴포넌트를 만들지 않기 위해서는 어떤 노력을 해야할까? 우리가 이렇게 closest와 querySelector를 혼용해 사용할 수 밖에 없었던 이유는 무엇일까?
우리는 세부 로직을 짜기 전, markup과 css를 만들 때 이러한 로직을 어떻게 짤 것인지에 대한 고민을 충분히 하지 못해서 이렇게 짜게 된 것 같다는 결론을 내렸다.
아래와 같은 순서로 작업을 진행했는데, 각 과정을 진행하면서 다른 과정에 미칠 영향을 크게 생각하지 못한 것 같다.
1. 페이지 디자인
2. 디자인에 맞는 markup 짜기 (작은 컴포넌트 구현)
3. 로직 붙이기 (페이지 단위)
markup을 짤 때에도, 이 markup에 어떤 로직이 들어갈 것인지, 어떻게 로직을 붙이면 좋을 지에 관한 고민을 더 하고 들어가면 이렇게 명령형으로 프로그래밍하는 일이 조금 더 줄어들지 않을까 싶다. 현업에서는 publisher와 front end 개발자가 따로 있는 경우가 많다고 들었는데, 이런 부분을 어떻게 해결하고 있는 지 궁금해지기도 했다. 호홋
마치며
이 글이 React에 관련한 글이다보니(React가 선언형 프로그래밍을 지향하는 라이브러리이다보니), 뭔가 내용이 선언형 프로그래밍으로 작성하면 좋은 점에 대해서만 나열해놓은 느낌이다. 적재 적시를 잘 판단해 직접 명령형, 선언형으로 코드를 작성해보고 주관있게 코드를 작성하는 것이 중요하다고 생각한다. 주관을 가지자! 아뵤아뵤~~!
https://reactjs.org/docs/hooks-reference.html#useref
https://reactjs.org/docs/refs-and-the-dom.html
https://boxfoxs.tistory.com/430