두 군데에서 같은 코드를 봤을 때
PR 리뷰를 하다가 두 파일에서 비슷한 코드를 본다. 5줄쯤. fetch가 있고, 에러 처리가 있고, 결과를 set한다. 거의 똑같다. 머릿속에서 자연스러운 충동이 일어난다.
이거, 함수로 빼야겠는데.
이 충동이 코드베이스를 망가뜨린다. 항상은 아니지만, 자주.
이 글은 그 충동에 대한 부검이다. 프론트엔드는 지난 10년 동안 같은 종류의 실수를 적어도 세 번 했다. 그 세 번을 따로 보자.
HOC hell, 2016-2018
React 16 시절. 고차 컴포넌트, 그러니까 HOC가 React 커뮤니티의 표준 패턴이었다. 인증 로직은 withAuth, 라우팅은 withRouter, 테마는 withTheme, 다국어는 withTranslation. 이름만 보면 깔끔하다. 관심사 분리. 좋은 원칙이다.
문제는 여러 개를 한꺼번에 적용했을 때였다.
export default withAuth(
withRouter(
withTheme(
withTranslation(
connect(mapState, mapDispatch)(MyPage)
)
)
)
);React DevTools에서 컴포넌트 트리를 펼치면, 한 페이지에 도달하기 위해 거치는 wrapper가 30층까지 쌓여 있었다. props가 어디서 내려오는지 추적하려면 다섯 단계를 거슬러 올라가야 했다. displayName을 직접 붙이지 않으면 디버깅이 거의 불가능했다.
이 추상은 잘못된 게 아니라, 너무 일찍 일반화된 것이었다. withAuth와 withRouter는 HOC라는 같은 형태를 가졌지만, 실제로 다루는 문제는 달랐다. 인증은 비동기 상태고 라우팅은 컨텍스트다. 이 둘을 같은 wrapper 패턴으로 강제 통일한 결과가 30층 트리였다.
Hooks가 2018년 말에 React Conf에서 발표되고 2019년 2월에 16.8로 안정화된 후, 우리는 이 추상을 1-2년에 걸쳐 해체했다. withAuth(Component)는 useAuth()가 됐고, withRouter는 useRouter()가 됐다. wrapper가 사라졌다. 코드 양이 줄어든 건 아니다. 단지 추상이 깨졌을 뿐이다.
모든 상태는 Redux로, 2017-2019
비슷한 시기에 또 다른 충동이 있었다. "모든 상태는 single source of truth로 관리해야 한다." 이 슬로건이 Redux의 전성기와 만나면서, 모든 상태를 Redux에 넣는 시대가 왔다.
체크박스의 toggle 상태도 Redux. 모달의 open/closed도 Redux. form input의 한 글자 한 글자도 onChange 액션으로 dispatch. 단순한 boolean 토글 하나를 위해 우리는 세 개의 파일을 만들었다. action types 파일, action creator 파일, reducer 파일. selector를 분리하면 네 개. redux-saga나 redux-thunk까지 끼면 다섯 개.
이게 잘못이라는 신호는 일찍부터 있었다. Redux 창시자 Dan Abramov가 2016년에 직접 You Might Not Need Redux를 썼다. 그런데 우리는 한참을 무시했다. 추상이 너무 정연하게 보였기 때문이다. action → reducer → state → selector. 화이트보드에 그리기 좋고, 발표 슬라이드에 넣기 좋다. 이 정연함이 함정이었다.
useState로 되돌리는 데 2년이 걸렸다. Context, Zustand, Jotai가 차례로 등장하면서 "이건 Redux가 풀 일이 아니었구나"라는 합의가 천천히 자리잡았다. Redux 자체도 Redux Toolkit으로 보일러플레이트를 줄이는 방향으로 갔다. 같은 도구가 살아남은 게 아니라, 그 도구를 쓰던 충동이 줄어든 것이다.
추상의 비용은 보통 추상을 만든 사람이 치르지 않는다. 만든 사람이 다른 회사로 옮긴 뒤, 남은 사람이 자기 시간으로 푼다.
커스텀 훅을 너무 잘게 쪼갠다, 2020-
이번 부검은 살아 있는 환자다.
Hooks가 안정화된 후, 우리는 모든 로직을 hook으로 분해하기 시작했다. 좋은 시작이었다. 그런데 이 분해가 어느 순간 기계적으로 이뤄지기 시작했다.
useUser();
useUserProfile();
useUserAvatar();
useUserName();
useUserOrganization();한 페이지에서 같은 user를 다섯 번 fetch한다. 각 hook이 자기 loading state, 자기 error state, 자기 cache key를 가진다. 다섯 개의 spinner가 서로 다른 시점에 사라진다.
React Query와 SWR이 이 잘못을 가린다. 같은 key의 query는 한 번만 호출되니까 네트워크 탭에서는 깨끗해 보인다. 그런데 코드는 깨끗하지 않다. useUser와 useUserProfile의 차이를 한 문장으로 설명할 수 있는 사람이 팀에 없다. 둘 다 결국 /api/me를 호출한다.
이 추상은 아직 부검 단계가 아니다. 진단 단계다. 5년 후에 누군가 다시 합칠 것이다.
잘못된 추상의 공통 구조
세 사례를 나란히 놓으면 같은 패턴이 보인다.
첫째, 두 인스턴스의 공통점이 사실은 우연이었다. withAuth와 withRouter가 HOC라는 같은 형태를 가진 건 우연이었지 본질이 아니었다. user 토글과 사이드바 토글이 둘 다 boolean인 것도 우연이었다.
둘째, 세 번째 use case가 등장하면서 추상이 깨진다. 두 사례에는 맞아 보이던 추상이, 세 번째 사례에서 "여기서는 인자를 하나 더 받아서 분기 처리하면 됩니다"가 된다. Sandi Metz가 이 패턴을 정확히 부검했다. 추상이 점점 if문 덩어리가 된다. 더 일반화된 함수가 아니라, 더 복잡한 함수가 된다.
셋째, 그러나 이미 코드베이스 전체가 의존하고 있다. 잘못을 깨달은 시점에는, 그 추상이 50군데에서 import되고 있다. 되돌리는 데는 만드는 데 든 시간보다 한참 더 든다.
세 단계가 추상을 부채로 만든다. 그리고 이 부채는 보통 만든 사람의 것이 아니다.
Don Roberts에서 Sandi Metz까지
이 패턴은 새 통찰이 아니다. 이름은 1990년대부터 있었다.
Don Roberts와 Ralph Johnson이 1996년에 정리한 "Evolving Frameworks" 패턴 언어에 Three Examples 라는 항목이 있다. 좋은 추상을 만들려면 세 개의 실제 사례가 필요하다는 것. Martin Fowler가 1999년 Refactoring에서 이걸 받아 Rule of Three 로 정착시켰다.
처음에는 그냥 한다. 두 번째에는 중복을 보고 움찔하지만 그래도 중복으로 둔다. 세 번째에 리팩토링한다.
Sandi Metz는 2014년 RailsConf 발표 "All the Little Things"에서, 그리고 2016년 1월 블로그 글 The Wrong Abstraction에서 이 원칙을 더 날카롭게 다듬었다. 잘못된 추상을 발견했을 때 어떻게 해체하는지의 절차까지 함께 제시한 글이다. 핵심은 한 문장이다.
Duplication is far cheaper than the wrong abstraction.
Kent C. Dodds는 2019년 ConnectTech 키노트 AHA Programming에서 이걸 새 이름으로 다시 포장했다. AHA는 Avoid Hasty Abstractions의 약자다. DRY와 WET 사이에서, 패턴이 명확해질 때까지 기다리자는 입장.
세 사람의 30년에 걸친 합창은 같은 한 가지를 말한다. 추상은 늦을수록 좋다.
그래서 언제 추상하나
답은 단순하다. 세 번째 사례가 등장한 후. 그것도 셋이 진짜 같은 것임이 확인된 후.
"진짜 같은 것"의 판단 기준은 형태가 아니다. 형태가 같은 건 자주 우연이다. 같은 도메인 의미를 갖는지가 기준이다. 세 곳에서 일어나는 일이 왜 일어나는지를 같은 한 문장으로 설명할 수 있어야 한다. 설명할 수 없으면 셋은 그냥 닮은 세 가지 다른 것이다.
실무 신호는 이렇다. "너무 늦지 않았을까"라는 의심이 들 때, 보통 정확한 시점이다. 충동적 추상은 항상 빠르게 일어나고, 신중한 추상은 항상 좀 늦은 것 같다.
빠른 추상은 거의 항상 잘못된 추상이다
이 글에서 부검한 세 사례 모두 충동적으로 빨리 추상화됐고, 그 빠름이 비용으로 돌아왔다. 두 번째 사례까지 보고 "이거 빼야겠다"고 결정한 추상은 세 번째 사례에서 깨질 가능성이 매우 높다. 깨질 때는 이미 늦어 있다.
추상은 잘 보이지 않는 것을 드러내는 도구지, 아직 보이지 않는 것을 미리 표시하는 도구가 아니다. 두 사례를 보고 만든 추상은 보통 두 사례의 우연한 형태를 영구화한다.
다음에 두 군데에서 비슷한 코드를 봤을 때, 한 번 더 생각해보자. 두 번 쓰고 세 번째까지 기다리는 게 보통 더 싸다. 그리고 그 세 번째가 같은 도메인 의미를 갖는지 한 문장으로 설명해본 다음에 추상하자.
좋은 추상은 항상 늦게 온다. 빠른 것은 추상이 아니라 충동이다.