Kareer-Client 기술 스택 선정기 #20
jeonghoon11
announced in
Skill
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
저희 Kareer 팀은 기술 스택을 선정할 때 서비스와 가장 적합한 기술 스택이 무엇인가에 대해 깊게 고민했어요.
기술 스택 선정에 반복해서 던졌던 질문은
이 세 가지에 깊게 고민하며 선정했습니다.
물론 기술 스택 선정은 정답이 존재하는 것이 아니라고 생각해요.
다만 어떤 기준을 세우고, 왜 그 선택을 했는지는 분명히 설명할 수 있어야 한다고 생각했습니다.
Karrer 팀이 어떤 기준으로 기술을 검토했고, 그 과정에서 어떤 선택을 했는지를
구조->운영->미래 대비->완성도의 흐름으로 정리해봤습니다.모노레포 + FSD : 프로젝트의 뼈대를 먼저 설계하다
왜 모노레포인가
Kareer 서비스는 MVP 단계에서는 웹 서비스를 우선 개발하지만 추후 스프린트에서는 데스크톱 앱으로의 확장도 계획하고 있어요. 이에 따라 웹과 데스크톱 앱이 동일한 디자인 시스템과 개발 기준을 공유할 수 있는 구조가 필요하다고 판단했어요.
디자인 시스템뿐만 아니라 TypeScript 설정, ESLint 및 Prettier 규칙 역시 각 애플리케이션마다 개별적으로 관리하기보다는 하나의 기준으로 통합 관리하는 것이 장기적으로 더 효율적이라고 생각했고 플랫폼이 확장될수록 설정 간 불일치가 발생할 가능성이 높아지고 이로 인해 유지보수 비용과 커뮤니케이션 비용이 증가할 수 있다고 판단했어요.
또한 디자인 시스템은 단순히 UI 컴포넌트를 모아두는 수준이 아니라, UI의 구조와 일관성을 먼저 설계한 뒤 그 위에 기능을 얹는 방식으로 운영하는 것이 중요하다는 생각을 하게 되었습니다.
디자인 시스템을 npm 패키지로 배포해 각 플랫폼에서 내려받아 사용하는 방식도 고민했지만 변경 사항 반영을 위한 배포 및 버전 관리 비용, 로컬 개발 환경에서의 수정,검증 흐름, 설정 파일(TypeScript, ESLint 등)까지 포함한 일관성 유지의 어려움 등을 고려했을 때 초기 단계의 서비스에는 적합하지 않다고 생각했고
이에 따라 ui, config 패키지와 web 애플리케이션을 하나의 레포지토리에서 관리하는 모노레포 구조를 선택했어요. UI 컴포넌트는 원자 단위로 설계해 Headless UI에 가깝게 구현하고, 특정 플랫폼이나 스타일에 강하게 의존하지 않도록 구성하고 이를 통해 웹과 데스크톱 앱 등 다양한 환경에서 재사용할 수 있는 확장성과 유연성을 확보하기로 했습니다.
또한 pnpm-workspace.yaml의 catalog 기능을 활용해 의존성 버전을 중앙에서 관리하는 구조를 채택했으며, 이를 통해 프로젝트 전반의 일관성을 유지하면서 변경 사항을 효율적으로 전파할 수 있다고 판단했습니다.
Turborepo 선택 이유
모노레포 구조를 선택한 이후 해당 구조를 실제로 효율적으로 운영하기 위해서는 전용 도구가 필요하다고 판단했어요.
별도의 도구 없이 모노레포를 구성할 경우 애플리케이션이나 패키지 중 일부만 수정되더라도 변경 여부와 관계없이 모든 패키지가 함께 빌드되는 구조가 되기 때문에 빌드 시간 증가와 불필요한 작업이 반복될 가능성이 있다고 보았습니다.
특히 디자인 시스템, 공통 설정 패키지, 애플리케이션이 함께 존재하는 구조에서는 변경된 영역만 정확히 감지하고 필요한 작업만 수행하는 빌드 전략이 필요하다고 판단했어요. 이에 따라 캐싱과 의존성 그래프를 기반으로 한 태스크 관리 기능을 제공하는 모노레포 전용 도구를 검토하게 되었습니다.
여러 선택지 중 Nx와 Turborepo를 중심으로 비교했으며 Nx는 기능 범위가 넓고 설정 옵션이 다양한 도구이지만 러닝 커브가 높고 초기 설정 부담이 큰 편이라고 판단했습니다. 모노레포 환경에 익숙하지 않은 팀원들이 있는 상황에서는 도구 자체에 대한 학습 비용이 프로젝트 초기에 부담으로 작용할 가능성이 있다고 보았어요.
반면 Turborepo는 기존 프로젝트 구조를 크게 변경하지 않아도 변경된 패키지 기준으로 빌드 범위를 제한하고, 캐싱과 병렬 빌드를 통해 빌드 시간을 효율적으로 관리할 수 있다고 판단했습니다. 설정이 비교적 단순해 팀 전체가 빠르게 적응할 수 있다는 점도 장점으로 보았어요.
또한 React 중심의 생태계와의 호환성이 좋아, 현재 사용 중인 기술 스택과 자연스럽게 결합할 수 있다고 판단했습니다. 결과적으로 모노레포 구조를 도입하되, 변경된 부분만을 기준으로 효율적인 빌드와 태스크 관리를 가능하게 하는 도구로 Turborepo를 채택하는 것이 합리적인 선택이라고 판단했습니다.
배포 & 운영 전략: 만드는 서비스가 아니라 운영 가능한 서비스
CloudFlare
저희 Kareer 서비스는 Next.js를 사용하지 않기로 결정했고, CSR 기반 SPA 구조를 선택했어요.
이 구조에서는 Next.js의 SSR 기능이나 Edge Function 최적화처럼 Vercel이 강점으로 제공하는 기능들을 실질적으로 활용하기 어렵다고 판단했습니다.
즉, 서비스 아키텍처 자체가 Vercel이 제공하는 핵심 가치와 잘 맞지 않았고, 그 결과 플랫폼이 제공하는 기능 대비 체감 효용이 낮아질 수 있다고 보았습니다.
Netlify 역시 CSR 기반 SPA 배포는 가능하지만, 팀 단위 협업이나 서비스 확장을 고려했을 때 구조적으로 유사한 제약이 있다고 판단했어요.
반면 Cloudflare Pages는 CSR 기반 정적 SPA 구조에 잘 맞는 배포 플랫폼이라고 느꼈습니다.
GitHub 연동만으로 자동 빌드 및 배포가 가능하고, Pull Request 단위로 Preview 환경이 자동 생성돼서 코드 리뷰나 피드백 과정이 훨씬 수월했어요.
개발자가 아닌 구성원도 URL만으로 결과물을 바로 확인할 수 있어서 협업 과정에서의 커뮤니케이션 비용도 줄일 수 있었습니다.
설정 과정도 비교적 직관적이고 단순해서 배포나 운영 관리에 대한 부담이 크지 않다는 점도 작게나마 도움이 된다고 느꼈어요.
또 하나 중요하게 본 부분은 글로벌 인프라였어요. Cloudflare Pages는 Cloudflare가 보유한 글로벌 네트워크를 기반으로 서비스되는데 전 세계 300개 이상의 엣지 서버(데이터 센터)를 통해 정적 파일이 사용자와 가장 가까운 위치에서 전달됩니다.
그 결과 TTFB(Time to First Byte)가 짧고 지역 간 응답 속도 편차도 적은 편이에요.
해외 사용자를 주요 타겟으로 하는 Kareer 서비스 특성을 고려했을 때 이 부분은 꽤 큰 장점이라고 판단했습니다.
물론 Vercel이나 Netlify에 비해 문서나 레퍼런스가 상대적으로 부족한 점은 있었어요. 다만 SSR이 필요 없는 CSR 기반 정적 SPA 구조라는 점을 고려했을 때 실제 프로젝트 진행에 있어 큰 제약으로 작용하지는 않는다고 판단했어요.
마지막으로 비용 측면에서도 Cloudflare Pages는 합리적인 선택이었어요. 핵심 기능이 무료로 제공되고 Organization Repository 사용에도 제한이 없으며
Free 플랜 기준으로 무제한 대역폭을 제공하기 때문에 글로벌 트래픽이 늘어나는 상황에서도 비용 부담이 상대적으로 적다고 보았습니다.
이런 점들을 종합적으로 고려했을 때 Cloudflare Pages는 아키텍처 적합성, 협업 경험, 글로벌 인프라, 그리고 비용 효율성 측면에서 Kareer 프로젝트의 목적과 요구사항에 가장 잘 맞는 배포 플랫폼이라고 판단했습니다.
Sentry 도입 이유: 사용자의 불편 사항을 즉각 캐치
개발 과정에서 마주하는 에러들은 비교적 대응이 수월한 편이에요. 로컬 환경이나 개발 서버에서는 에러가 바로 드러나고, 팀 내부에서 빠르게 공유하고 수정할 수 있기 때문입니다.
하지만 팀원들과 이야기를 나누면서 실제 사용자가 마주하는 수많은 예측 불가능한 에러는 어떻게 대응해야 할까라는 질문을 깊게 고민하게 되었어요.
그 과정에서 저희가 내린 결론은 하나였어요.
백엔드의 경우 서버 로그를 통해 에러를 비교적 즉각적으로 확인할 수 있어요. 반면 프론트엔드는 상황이 다르다고 생각했습니다.
사용자 환경, 브라우저, 네트워크 상태, 디바이스 조합에 따라 발생하는 에러들은 사용자가 직접 알려주지 않는 한 아예 인지조차 못 할 가능성이 크다고 보았습니다.
문제는 대부분의 사용자가 에러를 제보하지 않는다는 점이었어요.
에러를 겪은 사용자는 불편함을 느끼는 순간 이탈해버리고 그 원인은 서비스 내부에서는 조용히 묻혀버릴 수 있다고 생각했어요.
만약 같은 에러가
반복되고 있는데도 아무런 대응 없이 방치된다면 이는 결국 사용자 이탈의 주요 원인이 될 수 있다고 판단했어요.
그래서 저희 팀은 에러가 전혀 없는 서비스를 만드는 것도 중요하지만 에러가 발생했을 때 얼마나 빠르게 인지하고 대응할 수 있는지가 서비스의 신뢰도를 결정한다고 생각했고 사용자의 신고에 의존하지 않고 프론트엔드에서 발생하는 에러를 능동적으로 감지하고 추적할 수 있는 수단이 필요하다고 결론지었습니다.
이러한 배경에서 Sentry를 도입하기로 결정했어요.
CSS 라이브러리
Runtime vs Build-time
우선 저희는 Runtime CSS-in-JS를 후보군에서 제외했어요.
물론 컴포넌트와 스타일의 결합을 통한 높은 생상성과 props에 따른 유연한 동적 스타일링이 가능하다는 점에서 Runtime CSS-in-JS는 높은 생산성을 제공하는 방식이긴 합니다.
다만 저희는 이런 DX의 장점보다 그 이면에 존재하는 trade-off에 더 집중했어요.
Runtime CSS-in-JS는 스타일을 런타임에 연산하는 구조를 가지는데 이 과정이 초기 렌더링 시점의 JS 실행량을 늘리고 결과적으로 사용자 경험에 부정적인 영향을 줄 수 있다고 판단했어요.
특히 디자인 시스템이 존재하고 스타일 대부분이 정적으로 정의될 수 있는 구조라면 런타임에서 스타일을 계산하는 비용은 불필요하다고 보았습니다.
그래서 저희는 스타일 연산 과정을 빌드 타임으로 이동시키는 Zero-runtime 스타일링 라이브러리를 우선적으로 고려하게 되었어요.
Vanilla Extract vs Tailwind CSS
Build-time 스타일링을 전제로 두고 저희는 DX와 유지보수성 관점에서
Vanilla Extract와Tailwind를 고민하고 있었어요.먼저 Tailwind 사용을 희망하는 입장에서 가장 많이 나온 키워드는
생산성과일관성이었습니다.정해진 유틸리티 클래스 문법만 익히면 빠르게 UI를 구성할 수 있고, 팀 전체가 동일한 스타일 규칙을 공유할 수 있다는 점은 분명히 장점이라고 생각했어요.
반면 Tailwind 사용을 반대하는 입장에서 나온 키워드는
낮은 가독성,어려운 유지보수였스빈다.스타일이 TSX 내부에서 로직과 같은 공간을 차지하면서 컴포넌트의 핵심 로직을 파악하기 어려워지고 비표준 문법에 대한 러닝 커브나 조건부 스타일링이 복잡해질수록 가독성이 급격히 떨어진다는 점이 단점으로 느껴졌어요.
논의 끝에 저희는
Vanilla-Extract를 선택했습니다.Tailwind가 제공하는 Zero-runtime이라는 성능적인 이점은 그대로 가져가면서 Tailwind의 단점으로 느껴졌던
가독성과유지보수성을 Typescript를 통해 보완할 수 있다고 판단했기 떄문이에요.Zero-runtime의 성능, 타입 안정성, 디자인 시스템 친화성을 모두 만족하면서 디자인 시스템을 전제로 한 지속 가능한 프론트엔드 환경을 구축하기에 가장 적합한 선택지라고 판단햇습니다.
React Compiler
React 애플리케이션에서 성능 최적화를 위해서는 불필요한 리렌더링을 방지하는 것이 중요하다고 생각했어요.
이를 위해 보통 useMemo, useCallback 같은 훅을 적절한 위치에 직접 배치하게 됩니다.
하지만 어떤 컴포넌트나 함수를 메모이제이션해야 할지 판단하기도 쉽지 않고 평소 개발을 할 때 과도한 메모이제이션은 오히려 코드 복잡도를 높이고 가독성을 떨어뜨릴 수 있다고 느꼈어요.
React Compiler는 이런 문제를 해결하기 위한 접근 방식이라고 생각했어요.
빌드 타임에 컴포넌트를 분석해 필요한 부분만 자동으로 최적화된 코드로 변환해주기 때문에 개발자가 직접 useMemo나 useCallback을 작성하지 않아도 불필요한 리렌더링을 방지할 수 있구요.
저희는 메모이제이션 관련 보일러플레이트를 제거해 코드를 단순화하고 비즈니스 로직에 집중하기로 했어요.
Kareer 프로젝트는 FSD 아키텍처를 기반으로 여러 레이어에 걸쳐 비교적 복잡한 컴포넌트 구조를 가지고 있어요.
Dashboard, Roadmap, Eligibility 같은 페이지들은 사용자의 프로필 정보, 비자 상태, To-Do 리스트 등 다양한 상태를 실시간으로 반영해야 해요.
이런 환경에서 개발자가 직접 메모이제이션 포인트를 관리하게 되면 관리해야 할 지점이 늘어나고 실수로 인한 성능 이슈가 발생할 가능성도 높아진다고 판단했어요.
물론 React Compiler는 React 19와 함께 도입된 비교적 새로운 기술이라 레퍼런스가 충분하지 않고 팀원들 모두 사용 경험이 많은 상태는 아니었어요.
그럼에도 React 팀이 공식적으로 권장하는 기능이라는 점, 그리고 프로젝트 초기 단계에서 도입해두면 향후 성능 최적화 작업에 대한 부담을 줄이고 코드를 깔끔하게 유지할 수 있을 것이라 판단해 React Compiler를 도입하게 되었습니다.
Ky
HTTP 클라이언트 라이브러리는 단순히 요청을 보내는 도구가 아니라 번들 크기, 타입 안정성, 유지보수성까지 영향을 준다고 생각했어요.
Kareer 프로젝트에서는 이 세 가지 기준으로 Axios와 Ky를 비교한 뒤 라이브러리를 선정했습니다.
Axios는 가장 널리 사용되는 HTTP 클라이언트로 레퍼런스가 풍부하고 인터셉터 구조가 익숙하다는 장점이 있었어요.
다만 프로젝트 특성과 구조를 고려했을 때 저희에게 꼭 필요한 기능만 제공하는지는 다시 고민해볼 필요가 있다고 느껴졌습니다.
반면 Ky는 네이티브 Fetch API를 기반으로 한 가벼운 라이브러리로 불필요한 추상화 없이 웹 표준을 그대로 활용할 수 있다는 점이 매력적이었습니다.
특히 ky가 axios 대비 약 4배 정도로 번들이 가볍고 CSR 기반 글로벌 서비스를 지향하는 구조에서 초기 로딩 성능 측면의 이점이 있다고 판단했어요.
그리고 Typescript 친화적인 설계로 별도의 설정 없이도 타입 추론이 자연스럽게 동작해 HTTP 레이어에서도 타입 안정성을 유지할 수 있었어요.
사실 Axios와 Ky를 비교했을 때 실제 요청 차이는 체감할 수준은 아니라고 생각해요. 다만 kareer 프로젝트에서는 레거시 브라우저 지원이나 복잡한 인터셉터 구조보다는 번들 크기와 구조적 단순성이 더 중요한 요소였어요.
이런 기준을 종합적으로 고려했을 때 Ky는 저희 서비스의 기술 스택을 구성하는 데 있어 완성도를 높이는 선택이라고 판단했고 최종적으로 Ky를 HTTP 클라이언트로 선택하게 되었습니다.
FSD 폴더구조 도입 배경 & 문제 해결 과정
저희 팀은 프로젝트 초기 세팅 단계부터 기능 확장과 멀티 플랫폼(데스크탑 앱) 확장 가능성을 함께 고려해야 했어요.
기존에 많이 사용하는 역할별 구조에서는 다음과 같은 한계가 있다고 판단했습니다.
특히 기능이 추가되고 도메인 간 관계가 복잡해질수록 컴포넌트와 비즈니스 로직의 경계가 흐려지고 구조 자체가 개발 속도를 늦추는 요인이 될 수 있다고 판단했습니다.
그래서 저희팀은 역할이 아닌 기능과 책임을 기준으로 코드를 구성하는 FSD 아키텍처가 장기적으로 더 적합하다고 판단했어요.
모노레포 환경과 멀티 플랫폼 확장을 함께 고려하는 프로젝트에 잘 맞는 선택이라고 생각했었습니다.
apps/web/src
apps/web/src/ ├── app/ # 앱 진입점 및 전역 설정 ├── pages/ # 페이지 단위 구성 │ ├── dashboard/ │ ├── roadmap/ │ ├── eligibility/ │ └── my-page/ ├── widgets/ # 페이지를 구성하는 주요 UI 블록 │ ├── dashboard/ │ ├── roadmap/ │ ├── eligibility/ │ └── my-page/ ├── features/ # 사용자 행동 단위 기능 ├── entities/ # 비즈니스 엔티티 └── shared/ # 전역 공통 리소스 ├── components/ # 비즈니스 로직 없는 UI ├── utils/ ├── types/ ├── hooks/ ├── constants/ ├── config/ ├── apis/ ├── router/ ├── query/ └── layouts/packages
packages/ ├── kds-ui/ # 디자인 시스템 (도메인 비의존 UI) │ └── src/ │ ├── components/ # Button, Input, Card 등 │ └── styles/ # Vanilla Extract 스타일 └── configs/ # 공통 설정 패키지 ├── typescript-config/ ├── eslint-config/ └── prettier-config/FSD 구조 커스터마이징
FSD의 모든 레이어를 그대로 가져오되 팀의 개발 경험과 러닝커브를 고려해 구조를 일부 커스터마이징 했어요.
변경 이유
pages, widgets, features, entities, shared를 가져가는것 만으로도 팀원들의 러닝커브가 심할것이라고 생각했어요. 여기서 ui, model, lib 까지 가져온다면 더더욱 심할것이라고 생각했습니다. 따라서 FSD의 레이어 구조를 따르며 익순한 구조를 함께 사용하며 개발 과정에서의 부담을 줄이고자 했어요.
그러나 모노레포와 fsd를 함께 사용하니 의존성 문제가 발생하게 되었어요.
모노레포 환경에서 FSD를 적용하면서 apps/web/shared/ui와 @kds-ui 패키지가 동시에 존재하게 되었고, UI 컴포넌트를 어디에 두어야 하는가에 대한 기준이 필요해졌어요. 초기에는 "원자 컴포넌트 여부"를 기준으로 나누려 했지만 PR 리뷰 과정에서 중요한 문제가 드러났습니다.
FormField, SearchInput처럼 여러 UI 요소의 조합으로 이루어졌지만 특정 비즈니스 도메인에 의존하지 않는 컴포넌트들은 웹 뿐 아니라 추후 데스크톱 앱에서도 재사용 가능한 UI였어요. 이를 web의 shared/ui에만 두게 되면 데스크톱 앱 개발 시 동일한 UI를 다시 구현해야 하는 문제가 발생할 수 있다고 보았습니다.
그렇다면 shared/ui를 packages로 옮기는 방식도 고려해볼 수 있었지만 이 경우 widgets, pages, entities, features 레이어에 있는 코드들 역시 데스크톱 앱에서 동일하게 사용된다는 점이 문제로 이어졌어요. 결과적으로 packages/entities, packages/features와 같이 패키지 범위가 과도하게 확장될 가능성이 있다고 판단했습니다.
즉, UI 재사용을 위해 packages를 키우다 보면 패키지가 애플리케이션 구조 전체를 흡수하게 되는 구조적 위험이 존재했어요.
그래서 UI 분리 기준을 다음과 같이 정리했습니다.
추후 데스크탑 앱으로도 배포할 예정인 만큼 두 서비스 모두 동일한 ui를 가지고 있어요.
그래서 desktop이 web의 소스를 직접 import 해도 되는지에 대한 문제가 있었어요.
web과 desktop이 동일한 구조를 가진다고 해서 소스 레벨에서 의존하게 되면 web 수정이 desktop 빌드 실패로 이어질 수 있고 모노레포의 "독립 빌드, 배포" 장점이 사라질 수 있으며 배포 단위 간 경계가 모호해진다고 판단했어요.
이에 따라 최종적으로 선택한 전략은
이렇게 구현하기로 했어요. 이렇게 하면 web 수정 시 desktop 전체 빌드를 강제하지 않고 CI/CD 단계에서 빌드 순서만 보장하면 되며, 모노레포의 장점을 유지할 수 있다는 점에서 현재 프로젝트 단계에 가장 현실적인 선택이라고 판단했습니다.
All reactions