컴포넌트의 관심사 분리에 대한 고민

Sharlotte ·2023년 4월 6일
1

동기

현재 내 포트폴리오의 /component 디렉토리에 있는 일부분만 펼친 사진이다. depth를 한단계씩 내리고 내려도 눈이 어지러울 지경에 다다랐다. 종속성에 의존한 컴포넌트의 엄격한 분리는 한계에 도달했다.
처음엔 마냥 종속성을 기준으로 해당 컴포넌트에만 쓰이는 컴포넌트를 하위 디렉토리에 분리하는게 정답이라고 생각했었다. 그런데 정말 정답일까? 이러한 엄격한 분리는 몇가지 문제와 고질적인 불편함을 선사해주었다.

경험한 몇가지 단점들

컴포넌트 이름을 찾기가 힘들다

아래 사진을 보다시피 디렉토리의 이름이 곧 모듈의 이름이므로 컴포넌트 스크립트는 index.tsx로 처리해서 임포트에서 간편함을 챙길 수 있는건 사실이다. 그러나

이건 도를 넘었다. index의 무분별한 과다 사용이 초례한 도배는 가장 위 첫 사진처럼 애당초 index가 뭔지 알려고 디렉토리를 찾아야 하는 수고가 부가적으로 발생해버린다.
또한 디렉토리 depth가 너무 커서 디렉토리 트리 창을 보지 않고 바로 검색창을 여는 일이 빈번해졌다. 이름을 까먹는 순간 지옥행인거다.

파일 수가 너무 많다

재사용을 염두해두지 않고 막연하게 관심사 분리를 위하여! 를 외치며 무작정 분리에 분리를 거듭하니 재사용을 할 수 없는 파일 수가 무의미하게 불어나서 파일의 가치가 급락했다. 그 자체만으로 중요한 것도 아닌게 재사용도 못한다면 무슨 의미가 있단 말인가?

분리가 무의미한 경우도 있다

아래 이미지는 index와 style만 있어서 파일명을 먼저 보는 사람들 입장에선 스트레스가 여간 적지 않을 것이다. 디렉토리의 분리가 생각보다 심각하게 남용되고 있었다.

애당초 /components란 컴포넌트들을 모아둔 디렉토리의 명칭이고, 컴포넌트란 재사용을 목적으로 만들어지는게 일반적이다. 어딘가에 종속되는건 어디까지나 특이케이스여야 하지 합리화되어 눈감아줘야 할 관례가 아니다. 물론 규모가 커질수록 디렉토리는 커질 수 밖에 없다. 하지만 이건 고작 한 사람에 대한 포트폴리오다. 겨우 포트폴리오가 이정도 사이즈를 가지고 있는건 대단한게 아니라 잘못된 것이다.

해결법

종속성이 아닌 재사용성을 기준으로 분리하라

재사용성이 매우 낮은 파일들이 부지기수로 늘어난 이유는 그것이 특정 컴포넌트에 종속된 컴포넌트 트리를 마음대로 나눠버렸기 때문이다. 그러면 왜 나눴는가? 그것은 초보때 잦은 재랜더링은 성능 저하를 초례한다 라는 정보에서 재랜더링은 최대한 줄여야 한다 라는 오해와 고정관념을 받았기 때문이다. 잦은 재랜더링은 안좋은게 맞다, 아니 정확히는 뭐든지 잦으면 안좋다. 성능 테스트조차 안해보고 멋대로 나누니 이사단이 난거다. 성능 최적화를 할려면 컴포넌트 자체에 집중해야지 훅 기준으로 컴포넌트 나누기 가지고 성능이 나아질 리가 없다. 그 훅이 원인이 아니라면.

의문: 스타일 분리는 어떻게 해결하는가?

컴포넌트만 관심사 분리 대상이 아니다. 그것이 CIJ든 CSS든간에 산더미같은 css 덩어리들도 처리할 필요가 있다. 컴포넌트와 밀접히 두면서 다른 파일로 두어 관심사 분리를 유도하는 방법을 생각하던 와중에 Nest.js에서 겪어본 MVC패턴을 차용한 app.controller.ts가 생각나서 스타일 파일을 <component>.styled.tsx 로 명명해봤다.

생각보다 나쁘진 않아보인다.

side note: vscode에서 스니펫 유용하게 쓰기


파일 -> 기본 설정 -> 사용자 코드 조각 구성
File -> Preferences -> User Snippets

주석에서 겁나 친절하게 알려주는 덕분에 딱히 더 찾아볼 것도 없이 바로 이행했다.

위 사진을 보다시피 탭은 \t 백스페이스 이스케이프 문자를 쓰자. \n 개행 이스케이프 문자는 어차피 body가 줄 단위로 구분된 배열이기 때문에 안써도 괜찮다.

prefix는 이와 같이 자동완성에 뜬다. 스니펫 객체의 키도 여기에 쓰이는듯 하다. 엔터를 치면 $ 순서에 따라 커서가 자동이동한다.

이처럼 기본값을 두면스니펫을 사용할 때 알아서 넣어진다.

문제: The anchorEl prop provided to the component is invalid.

세가지 메뉴를 리팩토링하다보니 흥미롭게도 특정 부분이 공통됨을 발견했다. 기본적으로 이들은 버튼에 의해 토글된다는 특징을 갖고 있다. 그래서 난 아래 코드와 같이 MenuWrapper라는 유틸리티 래퍼 컴포넌트를 만들었다. MenuProps를 확장 가능하게 만들어 외부에서 flexible한 menu 커스터마이징을 할 수 있도록 가능성을 열어뒀다.

사용법은 아래와 같이 IconDrawer에서 주어진 onClick 리스너를 처리하고 목적에 따라 props를 추가한다.

문제는 MUI가 이 anchorEL이 잘못된 컴포넌트를 갖고 있다고 말한다는 것이다. 정확히the achorEl prop - provided to the component - is invalid. 라고 19줄의 속성을 콕 집어 말했다. 에러 뿐만이 아니라 실제 동작 자체도 비정상적이게 이뤄진다.

가설 1 - 외부 요인에 의한 Element 갱신

설명을 보기 전에 콘솔에 warn을 띄운 위치를 되돌아보자.

간단히 코드 리딩을 하자면 resolved된 anchorEL의 Rect를 가져왔는데 크기가 0 * 0이면 레이아웃에 위치하지 않은 것으로 판단하고 경고를 띄우는 것이다.

resolvedAnchorEL는 (() => Element) | Element 형태인 anchorEL 타입을 Element로 resolve한 상태다.

그럼 어째서 크기가 0 * 0이고 해당 Element가 레이아웃에 위치하고 있지 않는가? 심지어 눈 앞의 UI에서 엘리먼트가 떡하니 있음에도 불구하고?

여기서 가설의 주제가 등장한다. 재랜더링, 정확힌 재조정 과정에서 VDOM이 이 anchorEL교체해야 할 노드로 판단하고 기존 엘리먼트를 지우고 새로운 엘리먼트를 만들어 넣은 것이다.
그럼 옛 anchorEL를 참조하고 있던 Menu 컴포넌트는 레이아웃에 위치하지 않은 지워진 엘리먼트를 참조하고 있으므로 위 에러가 터진 것이다.

그럼 anchorEL이 정확히 무엇인지 추적할 필요가 있다. 위 코드에서 anchorELonClick 리스너에서 currentTarget를 할당받는다. 이 currentTarget는 클릭한 대상 엘리먼트를 가리키므로 IconDrawer prop를 할당한 Function Component를 찾으면 된다.

이번엔 HeaderMenu 컴포넌트를 예로 들겠다.

이처럼 Function Component가 매마다 새로 생성된다. VDOM이 재조정 절차에서 이것을 새로 생성시키지 않게 만들려면 두가지 실험이 필요하다.
1. Function Component를 개별로 선언하여 불필요한 재선언 방지
2. 이것도 아니라면 React.memo를 통해 컴포넌트를 통째로 메모라이징

...그리고 첫 실험에서 성공했다! 결국 원인은 매마다 새로운 FC를 만들어 참조중이던 anchorEL이 새 anchorEL과 근본적으로 다른 컴포넌트여서 재조정 과정에서 교체가 되어버린 것이다.

컴포넌트가 하나 더 늘어난게 불편하지만 구조상 합리적이라고 느껴졌고 이틀 내내 발목을 잡던 억까를 해결했으니 충분히 만족스럽다.

추가 의문: 익전의 재랜더링에 의한 갱신은 올바른 가설인가?
여기에 올바른 예시가 하나 있다.

테마 색 선택 메뉴에 쓰이는 컴포넌트인데 theme를 얻기 위해 useTheme를 쓴 것을 볼 수 있다. 이 useTheme는 테마 context의 훅이라서 테마가 바뀌면 해당 컴포넌트도 재랜더링한다. 의문인 가설에 의하면 오류가 여전히 발생해야 하지만 놀랍게도 아무런 일도 일어나지 않았다.

의문: 종속된 컴포넌트들은 어떻게 나눠야 하는가?

컴포넌트 트리에서 멋대로 땐게 아니라 실제로 나눠야 할 필요가 있는 컴포넌트들도 있기 마련이다.
이 외에도 너무 많은 의문이 있어 이 컨벤션이 맞는지 의문이 들 지경이다.
수많은 코드 조각을 어떻게 헨들링해야 할지 좋은 선례가 필요할 것 같다.
이 의문은 나중으로 미뤄두기로..

profile
샤르르르

0개의 댓글