AI 수학 학습 프로젝트 런칭 후기
✏️

AI 수학 학습 프로젝트 런칭 후기

Description
next.js 프로젝트를 런칭하고 든 마구잡이 생각 모음
Tags
Published
Published February 8, 2022

0. 무엇을 했지?

기억력이 좋지 않은 미래의 저를 위해 프로젝트에 대한 회고와 경험을 남겨보려고 합니다.
작년에 이직한 후 진행 중이던 프로젝트에 갑자기(?) 투입되어 (이제부터 담당자는 너야~) 고군분투하다가 드디어 이번 주(2022년 2월 2번째 주)에 서비스가 출시 되었습니다. 빡빡한 일정으로 인해 많은 날을 잠자는 시간, 밥 먹는 시간, 화장실 가는 시간 빼고 모니터 앞에 있었던 것 같습니다. 되돌아보니 딱히 웅장한 결과물은 없지만 길지 않은 시간 동안 참 많은 것을 경험하고 배운 것 같습니다.
그래도 어느 정도 짬은 찼는지 이번 프로젝트에서는 단순히 페이지를 찍어내는 역할 보다는 프로젝트의 구조를 설계하고 새로운 기술이나 패턴들을 가장 앞에서 삽질을 해가며 정립시켜놓고 동료들에게 "우리 함께 이렇게 하면 좋을 거 같아요~" 하는 역할이 더 의미가 깊었던 것 같습니다.

1. Next.js 참 좋다.

이전부터 Vercel과 Next.js의 큰 팬이었는데 기회가 되어 이번 프로젝트에는 Next.js를 사용하였습니다. 프로덕션 레벨에서는 처음 사용하였는데 "왜 Next.js를 사용하여야 하는가?"보다 "왜 사용하지 않으려고 하는가?"라고 생각할 정도로 아주 만족도가 높았습니다.
보통 Next.js 하면 떠오르는게 SSR 이라서 SSR를 하지 않는 다면 관심을 갖지 않는 경우도 많이 보았습니다. SPA도 어짜피 코드스플리팅하여 Lazy loading하고 Pre-rendering 하고 이것저것 최적화 할꺼면 그냥 SSG로 서빙하는게(꼭 SPA여야 되는 환경이 아니라면) SEO 에서의 이점, 성능최적화 뿐만 아니라 verbose 한 코드도 줄여서 훨씬 좋고 깔끔하지 않을까 생각했습니다.
이번 프로젝트는 SSG로 서빙하였습니다. CSR로 프로젝트를 진행하다가 페이지별로 동적인 OG 스펙이 추가된다거나 하면 당황스러운데 그런 메타 데이터를 깔끔하게 처리할 수 있는 것도 참 좋았습니다.
표현하자면 Next.js라는 프레임워크를 사용함으로써 제한이 되는 게 아니라 역설적으로 오히려 새로운 무기가 생기는 느낌이었습니다. 어떤 부분을 스태틱으로 미리 만들어 놓을지, 어떤 것을 동적으로 렌더링할지에 대해 명확하게 고민을 할 수 있었고 웹 문서에 대한 본질에 대해서 좀 더 생각할 수 있다랄까요.
결론은 Next.js 짱짱맨! (Remix 도 한번 경험 해보고 싶네요!)

2. Stale-Whie-Revalidation

주니어로서 클라이언트 작업을 할 때 이런저런 생각을 하다 보면 가렵지만 애써 못 본 척 외면했던 부분 중 하나가 서버와 클라이언트의 데이터 상태가 같음을 보장하지 못하는 것이었습니다. WebSocket이나 MQTT처럼 커넥션이 유지되고 Realtime인 경우는 제외하고 일반적인 웹에서의 요청과 응답은 HTTP로 이루어져 실시간성이 없으므로 브라우저에 렌더링 되어 유저가 보고 있는 데이터와 실제 서버의 데이터가 달라, 그 사이에서 생길 수 있는 오류나 유저 경험의 저하도 항상 고려 하여야 하는 것 같습니다.
어떤 경우는 굳이 지금 당장 요청에 대한 응답이 렌더링에 크게 영향이 없는데도 서버와의 싱크를 보장하기 위해 응답을 기다리는 동안 프로그래스를 띄운다던지 하여 앱을 Blocking 하는 경우도 보았습니다. 극단적인 예를들면 Todo앱을 만들었고 Todo Item을 추가 할때마다 서버에 요청을 보내고 Todo List를 업데이트 한다고 해보겠습니다. 서버와의 데이터 싱크를 보장하기 위해서 Todo Item의 추가 요청에 대한 성공 응답이 올 때까지 기다린 후에 Todo List에 추가하여 랜더링 한다면 매끄러운 사용이 불가능하여 유쾌하지 못한 사용자 경험을 줄 것입니다. 반대로 클라이언트에서 서버 요청과 응답에 상관없이 클라이언트의 데이터를 기준으로 렌더링한다면 요청이 실패한다든지, 서버의 Shared Ownership으로 인해 서버 상태가 변경된 경우들의 싱크를 다시 맞추기 위해서는 엄청나게 복잡한 처리가 필요할 것 입니다.
notion image
또한 서버에서 fetching 해온 데이터와 또 그 데이터를 사용하는 여러 컴포넌트가 생기다보니 스토어 레이어에서 요청을 하고 응답 받은 데이터를 업데이트하고 각 컴포넌트 에서는 그 데이터를 바라보는 패턴으로 코드가 작성 되는 경우가 많았습니다. 이렇게 된다면 결과적으로 데이터의 상태가 서버의 데이터 상태와 스토어의 데이터상태로 두벌이 됩니다. 저는 이것이 마치 단일 진실 공급원(SSOT; Single Source Of Truth)을 해치는 것 같은 구조라는 느낌을 받았습니다. 스토어의 여러 액션들도 하는 것은 단지 데이터를 패칭하고 업데이트 하는 것 밖에 없는데 보일러플레이트 코드가 늘고 이게 진짜 전역으로 관리 되어야 하는 상태인가? 하는 의문이 들었습니다.
notion image
그러다 Stale-White-Revalidation 전략을 알게 되었고 "그래 이거지"라는 생각이 들었습니다. react-query의 overview를 보면 이러한 가려운 부분들을 시원하게 긁어주는 느낌을 받았습니다.
사실 이번 프로젝트에서는 react-query가 아니라 swr을 사용하였는데 다음 프로젝트부터는 좀 더 기능이 풍부한 react-query를 사용해 볼 예정입니다.

3. Suspense (서스펜스)

이전에 컴포넌트 레이지 로딩을 위해서 서스펜스를 사용해 보았던 경험은 있었지만, 데이터 Fetching을 위한 Suspense 처음이었습니다. swr의 서스펜스 모드와 엮어서 서스펜스를 사용하였는데 사실 이 두 조합만으로 "그동안 해오던 건 뭐였을까…?" 라는 회의감도 살짝 들긴 했습니다. 코드양이 줄고 깔끔해지는 건 물론이고 비동기를 좀 더 선언적으로 또 상태에 따른 랜더링을 상위로 위임하다 보니 컴포넌트가 본연의 역할에 좀 더 집중할 수 있게 된 것 같습니다. 마치 예전에 콜백지옥에서 프로미스와 async/await이 등장했을 때의 느낌?
어떤 기준과 범위로 서스펜스를 랩핑하는게 좋을까? 어떤 패턴이 좋을까? 라는 고민이 많이 되었습니다. 지금도 어떤 패턴이 정답인지는 모르겠지만 (특히 이건 하기 나름, 스펙 나름 일 거라) 이런 고민이 왠지 싫지 않습니다. 그 고민 자체가 좀 더 나은 유저 경험을 위해 집중할 수 있다는 것이기 때문인 것 같습니다.차후 react 의 concurrent mode 도 공부하여야 겠다는 생각이 들었습니다.

4. ErrorBoundary(에러 바운더리)

주니어로서 프론트엔드 개발을 하면서 또 하나 가려웠던 부분은 에러 핸들링이었습니다. 그중 API 호출에서 발생하는 에러와 예외를 처리하는 게 참 까다롭다고 느껴졌습니다. 예측 가능한 에러에 대한 핸들링은 API을 호출한 컴포넌트에서 처리가 되어야 하는데 그러다 보니 너무 중복되는 코드가 발생하게 됩니다. 그러면 자연스럽게 짱구를 굴려서 중복이 되고 공통적으로 발생하는 에러를 axios와 같은 API 레이어에서 공통으로 핸들링하게 됩니다. 예를 들면 401 에러가 발생하면 로그인 페이지로 이동하는 로직을 axios 인스턴스의 에러 헨들러에 추가하는 식으로 말이죠. 하지만 A, B, C 페이지에서는 401 에러가 발생하면 로그인 페이지로 이동해야 하지만 D페이지 에서는 다르게 동작해야 한다면 어떨까요? 저는 fetcher에 파라미터로 에러를 passThrough 할 것인지를 받아서 글로벌하게 처리하거나 가장 아랫단에서 처리한다거나, 에러 핸들러에서 path와 여러 가지를 검증하여 각 상황의 에러에 매칭시켜 복잡하게 처리 한다든지 하는 코드들도 많이 본 것 같습니다. 바깥쪽부터 하나씩 깎으면서 들어오자니 예외가 발생하고, 그렇다고 안쪽 API호출을 발생시킨 포인트에서 처리하자니 마땅히 깔끔하게 처리할 방법이 없는 딜레마 같았습니다.
에러 바운더리를 도입하면서 부수적으로 위와 같은 고민도 많이 해결되었습니다. 현재는 앱의 최상단에 공통적인 글로벌 에러와 예측 불가능한 에러에 대해 핸들링을 하게 바운더리를 설정하고, 적절한 위치에 바운더리를 설정하였습니다. 에러가 발생하면 컴포넌트 레벨에서 발생한 에러를 직접 처리할지, 상위 컴포넌트 에러 바운더리로 전파할지 선언적으로 명시하면서 필요에 따라 바깥쪽으로 에러를 전파 시킬 수 있는 구조로 바꾸었습니다. 예를 들면 (글로벌 하게 핸들링 되어야 하는 에러라고 가정) 401 에러가 발생한다면 선언적으로 계속 상단의 컴포넌트로 던지는 식으로 구성하여 모든 에러 바운더리에서 ignore에서 계속 던지다 보면 결국 앱 최상단의 글로벌 에러 핸들러에 의해 에러가 핸들링 되게 됩니다. 만약 특정 상황에서는 401 에러에 대해서 다른 처리가 필요하다면 직접 혹은 가까운 에러 바운더리에서 꿀꺽하고 처리해버리면 됩니다. 마치 자바스크립트의 try / catch 와 비슷한 느낌입니다.
조금 더 나아가면 컴포넌트 레벨에서 에러가 터져 앱이 죽는 상황을 적절히 바운더리로 막고 유연하게 리커버리 할 수 있게 하여 더 좋은 유저 경험을 줄 수 있을 거 같습니다. 이 부분은 기술적으로 구현이 어려운 것 보다 아직 해본 적이 없기도 하고 크게 고민을 해본 적이 없어서 어떤 단위로, 또 어떤 패턴으로 처리하면 좋을지 앞으로 고민해 보아야겠습니다.

5. MUI5, Tailwind

MUI5, Tailwind 좋아요!
어쩌다 보니 그동안 Antd, Quasar, Vuetify 등등 나름 짧은 시간 동안 많은 UI프레임워크와 라이브러리를 사용했습니다. 그러다 보니 정말 별거 아닌 UI 컴포넌트 하나하나가 얼마나 복잡하고 많은 노력으로 결실로 이루어져 있는지 알게 되었고 그래서 대책 없이 디자인 시스템이나 공통 컴포넌트를 만들려는 사람이 있으면 "🖐️멈춰!" 를 외치곤 하였습니다.
그러다 버전 5로 업데이트된 MUI5를 프로젝트에 적용하게 되었는데 처음에는 다른 라이브러리 보다 뭔가 쓸데없이 코드가 장황해지거나 지저분해지는 경우도 많은 것 같고 특별한 것은 없다고 생각하였습니다. 하지만 얼마 지나지 않아서 개발을 잘 모르는 제가 보더라도 정말 무지 매우 잘 만든 라이브러리 라는걸 금방 알게 되었습니다. 다른 프레임워크나 라이브러리들은 제공해주는 컴포넌트를 커스텀 하는 게 몹시 힘든 경우가 많았습니다. 근데 MUI5는 컴포넌트를 Compose하는 구조로 구성하고 sx 같은 props 를 제공한다거나 Emotion을 기본적으로 사용해서 css-in-js 스타일로 사용한다거나 다양한 셀렉터와 옵션들을 제공하는 것과 같이 코드가 조금 지저분 해질 수 는 있어도 "아 이거 커스텀 하느니 그냥 새로 만드는 게 낫겠다." 라는 생각은 단 한 번도 들지 않았던 것 같습니다.
히스토리는 조금 복잡하지만 분리되어 있던 패키지를 통합하면서 기존에 tailwind 와 twin.macro로 작업 되어 있던 코드들도 MUI로 컨버팅을 하였습니다. (사실 MUI와 tailwind를 같이 쓰려고 했는데 MUI를 써보니 굳이 그럴 필요가 없겠다 싶었습니다) 가끔 tailwind를 선호하지 않는 분들은 class들이 너무 지저분해진다. 라는 말씀하시기도 하는데 twin.macro와 함께 css-in-js로 작성하면 해결할 수 있는 문제라서 딱히 공감은 하지 못했습니다.
tailwind 뿐만 아니라 대부분(?)의 UI 프레임워크는 확장성과 빠른 사용성을 위해 어느정도 Utility class를 제공합니다. 예를들면 mt-lg 라면 margin-top 을 라지 사이즈로 한다든지 말이죠. 개인적으로도 이런 유틸리티 클래스를 사용하면 코드양도 줄고 깔끔하게 작성할 수 있어서 선호하는데요. 문제는 가끔씩 커스텀이 필요해질 때 일관성이 깨지거나 귀찮아지는 경우가 발생했던 것 같습니다. tailwind는 JIT로 이러한 경우에도 쉽게 사용할 수 있었습니다. mt-lg 뿐만아니라 mt-[13px] 와 같은 경우도 쉽게 대응할 수 있었습니다. 당연하겠지만 저는 스타일링에 있어서 변하지 않는 포지셔닝, 사이징, 스페이싱을 제외하고 변할 수 있는 포지셔닝, 사이징, 스페이싱은 컴포넌트를 사용하는 상위 컴포넌트에게 위임하고 하위 컴포넌트는 그 역할에만 충실한 구조가 가장 이상적이라고 생각합니다. 유연한 utility first의 tailwind는 이러한 패턴을 깔끔하게 구현할 수 있는점이 좋았습니다.
디자인 가이드,시스템이 잘 갖추어져 있다든지, 기존의 라이브러리를 확장해서 사용하는 방식이 아닌 아예 쌩으로 컴포넌트를 만들어야 하는 상황이라면 Utility First인 tailwindHeadless UI의 조합으로 만들어 나가는 것이 좋은 선택지 같다고 생각이 되었습니다.

6. 최적화

최적화라는 말은 굉장히 추상적이어서 "무엇을", "어떻게" 최적화 할 것인가가 중요한 것 같습니다. 그리고 수많은 것들 아니, 거의 모든 것들에는 최적화할 수 있는 요소가 존재하고 환경마다 목적이 다르고 투자할 수 있는 리소스도 다르므로 제가 내린 결론은 야그니 정신을 받들어 "최소한의 기본적인 최적화는 실행하고 필요에 따라 혹은 여유에 따라 큰 파이들을 줄여나가자" 였던것 같습니다.
notion image
퇴사자로 인해 프로젝트 중간에 갑자기 인수인계를 받게 되고 스펙이 바뀌고 패키지가 합쳐지면서 기존 코드보다 아래와 같이 개선 되었습니다.
  • FCP: 5.6s -> 0.6s (약 89% 개선)
  • TTI: 9.3s -> 3.7s (약 60% 개선)
  • LCP: 6.5s -> 3.4s (약 48% 개선)
사실 기술 스택 자체가 달라져서 유의미한 비교는 불가능하지만 할 수 있는 최소한의 최적화만 작업 하였던것 같습니다. (사실 Next.js로 전환한게 점수 획득에 가장 큰 영향을...)
https://web.dev/fast/
한가지 아쉬웠던것은 수학 문제를 랜더링 하는 부분에서 어쩔수 없는 거대한 외부 레거시 스크립트에 디팬던시를 갖게 되는데 그 부분은 블랙박스이기도 하고 제가 핸들링 할 수 없는 부분이라 어떻게 우아하게 해결할 수 있을지 고민을 해보아야겠습니다.

7. 마치며

테스트, 디자인시스템, CI/CD, SEO등 더 하고싶고 남기고 싶은 말들은 많지만 너무 길어질거 같아서 기록은 여기까지만 하려고 합니다.
다른 분야도 마찬가지겠지만 프론트엔드는 하나를 알게되면 더 알아야 되는것이 두개가 나오고, 그 두개를 알게되면 더 알아야 하는것들이 네개가 나오는 "과연 내가 이 모든걸 정복할 수 있을까? 나는 항상 부족하구나" 라는 겸손을 알려주는것 같습니다.😂
그래도 이제 어느 정도 대부분을 찍어보기도 하고 맛보기도 하였으니 프론트엔드 영역에서도 그래픽스라든지 아니면 좀 더 코어 쪽으로 깊게 파보아야 하는가 하는 고민과 함께 기록을 마칩니다.