본문으로 건너뛰기
█ █  ▄▀█  ▀█  ▀█  ▀█  █
█▀█  █▀█  █▄  █▄  █▄  █
⚡ no css · no framework · no problem ⚡

타입을 다 닫았는데 props는 왜 자꾸 늘어날까

· 타입이 줄여주는 것과 못 줄이는 것, props 개수에 대하여


TL;DR union type으로 잘못된 조합을 다 닫아도 props 개수는 안 줄어듭니다. 개수를 줄이는 건 타입이 아니라 의존 설계입니다. prop마다 "이 값, 부르는 쪽이 정할 이유가 있나"를 물어서 없으면 지웁니다. 깊이는 컴파일러가 지켜주지만 폭은 아직 이 질문으로만 지켜집니다.

전에 union type으로 props를 설계하는 글을 썼습니다. 그 뒤로도 쭉 그렇게 일해왔습니다. 상태는 discriminated union으로 닫고, 불가능한 조합은 타입으로 막고. 요즘은 코드를 에이전트가 짜니까 리뷰할 때도 이 기준으로 봤습니다. 타입이 멀쩡하면 통과시켰습니다.

그러다 리뷰에서 props가 열 개를 훌쩍 넘는 컴포넌트를 만났습니다. 타입은 전부 멀쩡했습니다. 하나하나 열어보면 union으로 잘 닫혀 있고, optional 남발도 없고, 컴파일 에러도 없고. 근데 이 컴포넌트를 부르는 코드는 스크롤을 내려야 다 보였습니다. 리뷰 시간의 대부분을 값이 어디서 와서 어디로 가는지 추적하는 데 쓰고 있었습니다.

타입을 닫았으니 문제없다고 생각했습니다. 지금 돌아보면 늘어나는 타입들이 기계적인 게이트만 통과하고 있는 정도였습니다. 각 칸은 정확한데 칸이 계속 늘어나는 상태. 뭐지, 뭘 놓치고 있는 거지 싶었습니다.

깊이와 폭

제가 해온 방식은 이런 거였습니다. status가 두 가지, data가 두 가지면 조합은 네 가지인데 실제로 유효한 건 두 가지뿐이라서 union으로 묶으면 나머지 두 가지가 타입 에러가 된다. 경우의 수를 줄여서 예측 가능하게 만든다.

그런데 이 줄이기는 잘못된 조합을 줄인 겁니다. prop의 개수는 건드린 적이 없습니다. union을 아무리 잘 설계해도 부르는 쪽이 채워야 할 칸이 20개면 20개입니다. 시그니처는 여전히 길고, 값이 어디서 오는지도 여전히 추적해야 합니다.

저는 이걸 깊이와 폭으로 나눠서 생각하게 됐습니다. 타입은 깊이(값과 조합의 경우의 수)를 닫습니다. 폭(부르는 쪽이 채워야 할 칸의 개수)은 못 닫습니다. 컴파일러는 칸 안의 값을 검사하지 칸이 많다는 것 자체는 문제 삼지 않습니다. 애초에 타입 시스템 입장에서 props 20개는 아무 문제가 없는 코드입니다.

그러니까 "타입은 다 닫혀 있는데 이 컴포넌트는 손대기 싫다"는 상태는 모순이 아니었습니다. 깊이는 닫혔고 폭이 열려 있는 것. 폭은 타입의 일이 아니었습니다.

타입 문제가 아니라 의존성 문제

그럼 폭은 누가 잡나 싶어서 props가 뭔지부터 다시 생각해봤습니다.

컴포넌트 입장에서 props는 시그니처에 드러난 의존성 목록입니다. 백엔드에서 의존성 주입(DI)이라고 부르는 것과 같은 구조입니다. 그렇게 보면 props가 20개라는 건 드러난 의존성이 20개라는 말이고 이건 타입 문제가 아니라 의존 설계 문제입니다. 질문이 "이 prop의 타입이 정확한가"에서 이렇게 바뀝니다.

이 값, 부르는 쪽이 정할 이유가 있나?

"있다"면 그 prop은 남을 자격이 있습니다. 구체 값 여러 개가 사실 하나의 개념이면 콜백 하나나 children으로 묶을 수 있습니다. 개수만 줄어 보이게 객체에 담는 눈속임 말고, 부르는 쪽이 생각할 단위가 실제로 줄어드는 경우에만요. 결정권은 그대로 부르는 쪽에 남습니다.

"없다"면 지웁니다. 부르는 쪽이 정하지도 않는 값을 손에 들고 아래로 넘기기만 하고 있었다는 뜻이라서 소유권을 옮기면 됩니다.

그 컴포넌트에서 제일 크게 줄어든 게 이 경우였습니다. layout에서 계산한 값을 화면 곳곳의 컴포넌트에 props로 내려꽂고 있었는데, 중간에 낀 컴포넌트는 그 값을 쓰지도 않으면서 넘기기만 했습니다. 부르는 쪽마다 물어보면 답이 전부 "아니요"였습니다. 이 값을 정하는 건 최상단 layout 하나뿐이고, 중간의 부르는 쪽들은 정하지 않고 나르기만 했으니까요. 그래서 props에서 지우게 했습니다.

어디로 옮기느냐는 그다음 문제입니다. 컴포넌트가 직접 가져오게 할 수도 있고 composition으로 중간자가 값을 들고 있지 않게 구조를 바꿀 수도 있습니다. context도 있습니다. 이번에는 제일 적게 고쳐도 되는 context로 옮겼습니다. 중간 컴포넌트 시그니처가 한 줄씩 짧아졌습니다.

variant나 onConfirm 같은 건 그대로 뒀습니다. 어떤 모양으로 띄울지, 확인 누르면 뭘 할지는 부르는 쪽마다 다릅니다. 정할 이유가 있는 값까지 숨길 이유는 없었습니다.

옮긴 값은 숨습니다

다만 context가 공짜는 아니었습니다. 옮기면 시그니처는 깨끗해지는데 의존이 사라진 게 아니라 숨은 겁니다. 컴포넌트가 뭘 읽는지 알려면 파일을 열어봐야 하고 테스트하려면 provider를 몇 겹씩 감싸야 합니다. props가 모호하면 UI를 예측할 수 없다고 생각해왔는데, 다 숨기면 모호한 걸 넘어서 아예 안 보이게 됩니다. 그래서 context는 답이라기보다 트레이드오프에 가깝습니다. "정할 이유가 없는 값"이라는 진단이 먼저고 어디로 치울지는 각자의 비용을 두고 고르는 선택입니다.

기준은 "최대한 뺀다"가 아니라 아까 그 질문 그대로입니다. 부르는 쪽이 정하는 의존은 드러나 있어야 하고, 아닌 의존만 빠져야 한다. 그러니까 정확히는, 폭이 넓은 것 자체가 문제라기보다 정할 이유 없는 값이 자리를 차지하고 있는 게 문제였습니다.

이 질문이 항상 깔끔하게 갈리지는 않습니다. "지금은 정할 이유가 없는데 곧 생길 것 같은" 값이 제일 애매한데 저는 일단 지금 기준으로 자릅니다. 이유가 생기면 그때 드러내는 게 미리 드러내놓고 안 쓰는 것보다 쌌습니다. 미리 만들어둔 optional prop을 나중에 실제로 쓴 기억도 거의 없습니다.

깊이는 컴파일러가 지키고 폭은 질문이 지킨다

요즘 저는 코드를 직접 짜는 일이 거의 없어서, 이 질문을 에이전트가 코드 짤 때 참조하는 체크리스트에 넣어뒀습니다. 잘 지켜질 때도 있고 아닐 때도 있습니다. 타입과 달리 이 질문은 어겨도 빌드가 깨지지 않으니까요.

그래서 깊이와 폭은 지켜지는 방식이 다릅니다. 깊이는 컴파일러가 지켜줍니다. 폭은 아직 질문으로만 지켜집니다. props가 늘어난 코드를 볼 때마다 같은 자리로 돌아옵니다. 이 값, 부르는 쪽이 정할 이유가 있는지..


댓글 0개

아직 댓글이 없어요.

GitHub에서 댓글 남기기 · GitHub 계정으로 로그인 후 이슈에 코멘트를 남기면 여기에 표시돼요.