뒤로가기
후회하는 기술 선택들

September 30, 2024

frontendessay

기술 블로그에는 성공 이야기가 넘친다. "우리 팀이 X를 도입해서 성능을 50% 개선한 이야기", "Y 마이그레이션으로 개발 속도를 2배로 올린 경험". 다 좋은 글이다. 근데 솔직한 글은 별로 없다. 잘못된 선택에 대한 글. 후회하는 결정에 대한 글.

이 글은 내가 실무에서 내린 기술 선택 중, 돌이켜보면 잘못됐다고 생각하는 것들을 정리한 거다. 다른 사람의 비슷한 실수를 막을 수 있다면 좋겠고, 못 막더라도 최소한 "나만 그런 게 아니구나" 하는 위안이라도 됐으면 한다.

후회 1: CSS-in-JS가 SSR을 지옥으로 만들다#

2022년 초, 새 프로젝트의 스타일링 솔루션을 결정해야 했다. 선택지는 CSS Modules, Tailwind, 그리고 CSS-in-JS 계열의 Styled Components나 Emotion이었다.

나는 Emotion을 강하게 밀었다. 이유는 합리적이었다(고 생각했다). 컴포넌트와 스타일이 같은 파일에 있으니 응집도가 높고, props에 따라 동적 스타일링이 자연스럽고, TypeScript와 궁합이 좋고, 디자인 시스템의 테마를 런타임에 변경할 수 있다. 기술적으로 틀린 말은 아니었다.

문제는 SSR이었다.

프로젝트가 Next.js 기반이었는데, Emotion의 서버사이드 렌더링은 별도의 설정이 필요했다. 처음엔 공식 문서대로 설정하면 됐다. @emotion/cache를 만들고, CacheProvider로 감싸고, 서버에서 스타일을 추출해서 HTML에 주입하는 과정. 복잡하긴 했지만 돌아갔다.

지옥은 Next.js 13에서 App Router가 나오면서 시작됐다. Server Components와 Client Components의 경계에서 Emotion이 제대로 동작하지 않았다. 서버 컴포넌트에서는 런타임 CSS-in-JS를 쓸 수 없는데, 우리 컴포넌트의 절반이 Emotion에 의존하고 있었다. 모든 스타일링된 컴포넌트에 'use client'를 붙여야 했고, 그러면 서버 컴포넌트의 이점을 거의 활용할 수 없게 됐다.

FOUC(Flash of Unstyled Content) 문제도 있었다. 서버에서 렌더링된 HTML이 클라이언트에서 hydration되기 전에 스타일이 없는 상태로 잠깐 보이는 현상. 사용자 입장에서는 페이지가 로드될 때 한 순간 레이아웃이 깨져 보이는 거다. 이걸 고치려고 온갖 workaround를 적용했는데, 각 workaround가 또 다른 문제를 만들었다.

결국 프로젝트 시작 1년 반 만에 Tailwind로 마이그레이션을 결정했다. 300개 넘는 컴포넌트의 스타일을 전부 바꾸는 작업. 2달이 걸렸다. 처음부터 Tailwind를 골랐으면 그 2달이 필요 없었다.

배운 것: 스타일링 솔루션을 고를 때, "지금 이 코드를 짜기 편한가"보다 "렌더링 환경과의 호환성"을 먼저 봐야 한다. CSS-in-JS의 런타임 특성이 SSR/SSG 환경에서 어떤 제약을 만드는지, 프레임워크의 로드맵과 충돌하지 않는지를 먼저 확인하라. DX(Developer Experience)에 눈이 멀면 UX(User Experience)가 희생된다.

후회 2: 8개월 만에 방향이 바뀐 상태 관리 라이브러리#

2023년 중반, 프로젝트의 전역 상태 관리를 위해 Recoil을 도입했다. 당시 Recoil은 Meta(Facebook)에서 만든 라이브러리였고, React의 Concurrent Mode와 잘 맞도록 설계됐다는 점이 매력적이었다. atom 기반의 상태 관리가 직관적이었고, selector로 파생 상태를 만드는 패턴이 깔끔했다.

"Meta에서 만들었으니까 React와 함께 갈 거야"라는 암묵적 믿음이 있었다. 이게 함정이었다.

도입 후 8개월 즈음, Recoil의 업데이트가 눈에 띄게 줄었다. GitHub 이슈는 쌓이는데 PR 머지는 뜸해졌다. 핵심 메인테이너의 활동이 줄었다. 커뮤니티에서 "Recoil이 abandoned 된 거 아니냐"는 얘기가 돌기 시작했다. 결국 공식적으로 deprecated는 아니었지만, 사실상 유지보수가 멈춘 상태가 됐다.

우리 프로젝트에는 이미 Recoil로 작성된 상태 로직이 40개 넘는 atom과 20개 넘는 selector로 구성되어 있었다. 이걸 다른 라이브러리로 바꾸려면 상당한 리팩토링이 필요했다. Jotai가 API가 비슷해서 마이그레이션 비용이 상대적으로 적었기 때문에 Jotai로 옮겼지만, 그래도 2주가 걸렸고 그 과정에서 몇 가지 미묘한 동작 차이 때문에 버그가 생겼다.

배운 것: "큰 회사에서 만들었다"는 게 장기 지원의 보장이 아니다. 라이브러리를 평가할 때 봐야 하는 건 이런 것들이다.

  • 메인테이너의 수와 활동 빈도. 한 명이 관리하는 라이브러리는 그 사람이 번아웃되거나 이직하면 끝이다.
  • GitHub 이슈 대비 PR 머지 비율. 이슈만 쌓이고 PR이 안 머지되면 위험 신호다.
  • 릴리스 주기의 일관성. 3개월 간격으로 꾸준히 나오던 릴리스가 갑자기 6개월 이상 멈추면 뭔가 벌어지고 있는 거다.
  • 마이그레이션 용이성. 혹시 이 라이브러리가 죽으면 빠져나오기가 얼마나 쉬운가. 이 관점에서 Zustand나 Jotai 같은 미니멀한 API를 가진 라이브러리가 유리하다. 추상화 레이어가 얇을수록 탈출이 쉽다.

후회 3: 아무도 이해 못한 커스텀 훅 라이브러리#

이건 기술 선택이라기보다 아키텍처 결정에 가깝다. 근데 가장 큰 후회다.

프로젝트에 반복되는 패턴이 보였다. API 호출 + 로딩 상태 + 에러 처리 + 캐싱 + 리트라이 로직이 여러 컴포넌트에서 비슷하게 구현되어 있었다. "이걸 추상화해서 재사용 가능한 훅으로 만들면 되겠다"고 생각했다.

그래서 useQuery(TanStack Query 이전이었다), useMutation, useInfiniteScroll, useOptimisticUpdate, useFormState, useWebSocket 같은 커스텀 훅들을 만들었다. 각 훅은 제네릭 타입으로 유연하게 설계했고, 옵션 객체로 동작을 커스터마이즈할 수 있게 했다.

문제는 "유연하게"와 "커스터마이즈"라는 단어에 있었다.

useQuery를 예로 들면, 이런 식이었다:

typescript
const { data, isLoading, error, refetch, invalidate } = useQuery({
  key: ['users', userId],
  fetcher: () => api.getUser(userId),
  staleTime: 5 * 60 * 1000,
  cacheTime: 30 * 60 * 1000,
  retry: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
  onSuccess: (data) => { /* ... */ },
  onError: (error) => { /* ... */ },
  enabled: !!userId,
  refetchOnWindowFocus: true,
  refetchInterval: 60 * 1000,
  select: (data) => data.profile,
  placeholderData: previousData,
});

TanStack Query를 써본 사람이라면 "이거 그냥 TanStack Query 아닌가?"라고 생각할 거다. 맞다. 나는 TanStack Query를 직접 만들고 있었던 거다. 근데 테스트도 제대로 안 되어 있고, edge case 처리도 부실하고, 한 명(나)만 전체 구조를 이해하고 있는 버전의 TanStack Query를.

새 팀원이 와서 이 코드를 보면 이랬다. "이 useQuery가 뭔가요? 어디서 import 하는 건가요? React Query인가요?" 아니, 우리가 만든 거다. "문서 있어요?" 주석은 있는데 문서는... 없다. "이 staleTime이랑 cacheTime의 차이가 뭔가요?" 설명하다 보면 결국 30분이 지나 있었다.

TanStack Query v4가 나왔을 때, 마이그레이션을 결정했다. 내가 만든 커스텀 훅들을 걷어내고 TanStack Query로 교체하는 작업. 이때 깨달은 게 있다. 내 커스텀 훅은 TanStack Query의 기능 중 30%를 불완전하게 재구현한 것이었다. 나머지 70%는 — 캐시 무효화 전략, Suspense 지원, SSR 지원, DevTools, 자동 가비지 컬렉션 — 내가 미처 구현하지 못한 것들이었다.

배운 것: 직접 만들기 전에 이 질문을 해야 한다. "이 문제를 이미 잘 해결한 라이브러리가 있는가?" 있다면, 그 라이브러리가 내 요구사항의 80% 이상을 충족하는가? 충족한다면, 나머지 20%를 위해 전체를 직접 만드는 건 거의 항상 나쁜 선택이다. 그 20%는 라이브러리 위에 얇은 래퍼를 만들어서 해결하는 게 낫다.

후회 4: GraphQL, 필요 없었는데#

이건 좀 논쟁적일 수 있다. GraphQL은 좋은 기술이다. 근데 "우리 프로젝트에" 필요했냐고 물으면, 아니었다.

2023년에 어드민 대시보드를 만들 때 GraphQL을 도입했다. 이유는: "여러 화면에서 같은 데이터를 다른 형태로 보여줘야 해서, REST로는 over-fetching이 심하다." 맞는 말이었다. 근데 over-fetching이 실제로 성능 문제를 일으키고 있었냐? 아니었다. 어드민 대시보드는 내부 사용자만 쓰는 도구였고, 네트워크 조건이 일정했고, 데이터 양이 크지 않았다.

GraphQL을 도입하면서 추가된 복잡성은 상당했다. 스키마 정의, 리졸버 구현, 코드 생성 파이프라인 설정, Apollo Client 설정, 캐시 정규화 이해, N+1 문제 대응. 백엔드 개발자 한 명이 GraphQL에 익숙하지 않아서 학습에 2주를 썼다. 프론트엔드에서도 Apollo Client의 캐시 동작을 이해하는 데 시간이 걸렸다.

REST API + TanStack Query 조합이면 같은 결과를 훨씬 적은 복잡성으로 달성할 수 있었다. 필요한 필드만 가져오고 싶으면 API에 fields 쿼리 파라미터를 추가하면 된다. 완벽하진 않지만, 어드민 대시보드에는 충분하다.

배운 것: 기술 도입의 이유가 "이론적으로 이게 더 적합해서"가 아니라 "실제로 문제가 발생하고 있어서"여야 한다. over-fetching이 성능 문제를 일으키고 있는가? 그 문제의 규모가 GraphQL 도입의 복잡성을 정당화하는가? "언젠가 필요해질 거야"는 지금 도입하는 이유가 되면 안 된다.

기술 선택의 체크리스트#

이런 후회를 반복하지 않으려고 나름의 체크리스트를 만들었다. 새로운 기술을 도입하기 전에 스스로 묻는 질문들이다.

1. 이 기술이 해결하는 문제가 실제로 존재하는가? "over-fetching이 문제다"가 아니라 "over-fetching 때문에 페이지 로딩이 3초 이상 걸린다"처럼 구체적이어야 한다. 문제가 구체적이지 않으면, 기술이 해결하는 것도 모호하다.

2. 기존 도구로 해결할 수 없는가? 새 라이브러리를 추가하기 전에, 이미 쓰고 있는 도구로 해결할 방법이 없는지 10분만 생각해본다. 놀랍게도, 많은 경우 있다.

3. 팀에서 이 기술을 유지보수할 수 있는 사람이 몇 명인가? 한 명만 아는 기술은 리스크다. 그 사람이 휴가 가면 아무도 못 고친다. 최소 2명 이상이 이해할 수 있어야 한다.

4. 이 기술이 2년 뒤에도 유지보수되고 있을 가능성은? GitHub 활동, 메인테이너 구성, 스폰서/기업 지원 여부를 확인한다. 100%는 아무도 모르지만, 패턴은 읽을 수 있다.

5. 이 기술에서 빠져나오는 비용은? 도입 비용만 보지 말고, 탈출 비용도 계산한다. 나중에 다른 걸로 바꿔야 할 때, 얼마나 어려울 것인가. 추상화가 깊을수록 탈출이 어렵다.

후회는 나쁜 게 아니다#

돌아보면, 이 후회들이 없었으면 지금의 판단력도 없었을 거다. CSS-in-JS 경험이 있어서 제로 런타임 솔루션의 가치를 제대로 이해한다. Recoil 경험이 있어서 라이브러리의 장기 안정성을 평가하는 눈이 생겼다. 커스텀 훅 라이브러리를 만들어봤기 때문에 TanStack Query의 설계가 얼마나 정교한지 체감한다.

다만, 그 후회의 비용을 프로덕션이 아니라 사이드 프로젝트에서 치를 수 있었으면 더 좋았을 거다. 이 글을 읽는 누군가는, 내 후회에서 배워서 본인의 프로덕션에서의 후회를 하나라도 줄일 수 있었으면 좋겠다.

기술 선택에 정답은 없다. 근데 덜 후회하는 선택은 있다. 그리고 그건 대부분의 경우, 덜 흥미진진한 선택이다.