

경기대 식당 정보 서비스.. 이름하여 "기룡아 밥먹자" 는 개발을 시작했을 때부터 지금까지 이어온 정말 의미있는 프로젝트이다. 이 프로젝트를 하면서 기술, 사용자, 갈등까지 많은 것을 겪고 배웠다. 지금의 나를 있게 한 프로젝트라고 해도 과언이 아니라 생각한다.
기능 구현에만 급급했던 지난 날을 되돌아보며, 이 프로젝트의 시작부터 끝까지를 회고해 보려고 한다.
24년 10월쯤 교내 동아리를 같이 하고 있던 선배로부터 연락을 받았다. 우리 학교 교내 식당이 많은데, 메뉴를 알 길이 없어서 불편하지 않냐. 웹 프론트 공부 시작하는 참이라고 들었는데 서비스 하나 같이 만들 생각이 없냐고.
연락을 받았을 당시 굉장히 공감했다. 학교 교내식당에 맛있는 메뉴를 많이 팔고 있다는 사실을 2학년 즈음에나 알았고, 심지어 그때까지도 알지 못하는 교내 식당이 한 곳 더 있었다는 것을 알게 된 참이었기 때문이다.
교내식당 메뉴 파악을 통해 학생들이 더 나은 가격, 더 나은 접근성으로 더 나은 한 끼를 해결할 수 있으면 좋겠다. 이것이 기룡아 밥먹자의 시작점이었다.
이렇게 프로젝트에 참여하게 되었다. 하지만 교내식당의 데이터들을 받아오는 것에는 무리가 있었고, 스크래핑을 통해 가져올 수 있는 기숙사 식당 식단을 보여주는 방식을 우선 개발하기로 했다. 기숙사 식당의 식단을 알려주는 학교 사이트가 기존에 존재하긴 했지만, 가독성이 좋지 않고 접근성도 좋지 않았기 때문이다.

이렇게 생긴 기숙사 공식 사이트에 들어간 뒤 식단 안내 전체보기를 누르면 다음과 같이 기숙사 식단표가 나오게 되는 구조였다.

사실 여느 평범한 고등학교 식단표와 다르지 않은데, 글자 크기가 너무 작고 뭔가 한 눈에 들어오지 않는다고 생각했다. 홈페이지에 들어간 뒤 식단 페이지로 이동해야만 식단을 확인할 수 있는 것도 불편하다 생각했고, 개선할 수 있을 것 같았다.
그래서 피그마를 통해

페이지의 기본적인 디자인을 구성했다.
지금 와서 보면 정말 사용자 디자인에 대해 하나도 모르던 상황에서 진행했던 것들이라 폰트 크기도 그렇고, weight 도 그렇고 정말 학생들이 진행하는 프로젝트 티가 많이 난다. 귀엽다 귀여워 ㅎㅎ
실제로 주변 동아리, 그리고 관련 도메인에서 어느 정도 경험을 쌓았던 선배들, 그리고 약간의 AI 조언을 받아서


이렇게 생긴 페이지를 만들게 되었다.
메뉴에 대한 리뷰는 홈 페이지에서만 확인할 수 있었다.
초기에는 정말 돌아가게만 만들자. 돌아가게 만들어서 우리가 사용하자. 이런 가벼운 마음가짐이었다. 그때는 이 프로젝트가 이렇게 장기적으로 진행될 줄도 몰랐다. 하지만 만들수록 허점이 보였고 계속 해결해 나가다 보니 지금까지 오게 되었다. 진짜 마음으로 낳은 자식이 아닐 수 없다..
초기 버전은 Javascript, React 그리고 Axios로 이루어졌다.
보여지기만 하면 된다는 생각이었고, 상태 관리는 모두 전역 상태인 Zustand를 사용해서 구현했다.
범 페이지, 그리고 범 컴포넌트적으로.. 사용자가 어떤 날짜를 선택했는지, 리뷰를 볼 경우 사용자가 어떤 음식을 선택했는지에 대한 정보가 Props로 전달될 필요 없이 전역 상태 훅에서 단지 꺼내오기만 하면 된다는 점이 간편했다. 심지어 사용법도 일반 useState 와 크게 다르지 않았기에 웹 개발 초보였던 나에게는 더할 나위 없는 선택이었다.
그래서 초기 플로우는 이랬다.
사용자가 메인 페이지에서 리뷰를 확인할 음식을 선택하면 전역 상태에 해당 음식 ID가 저장된다. 그리고 리뷰 페이지로 이동한다. 리뷰 페이지에서는 useEffect 를 통해 전역 상태에서 유저가 선택한 음식 ID를 가져오고, 그 음식 ID를 기반으로 리뷰를 패칭한다.
하지만 그렇게 MVP 개발을 완료하고 나니 문제가 바로 보였다. 리뷰를 확인하고 다시 홈으로 이동 후 다른 리뷰를 확인하려고 했을 때 이전에 선택했던 메뉴의 리뷰 데이터가 잠시 보이는 현상이었다. 사용자가 서비스를 사용하다 이런 현상을 마주하면 어떤 생각이 들까? 라는 생각부터 들었다.
인터넷이 느려서 이전에 선택했던 리뷰 데이터가 더 오래 화면에 머물게 된다면? 내가 사용자였다면 아예 서비스를 사용하는 것을 중단해버릴 수도 있겠다. 정말 치명적인 오류라 생각했다.
홈에 들어왔을 때 전역 상태를 초기화시킴으로써 문제를 해결할 수 있었지만, 이후에는 리뷰 페이지가 깜빡거리는 현상이 발생했다. 더 멋지게 말하면 Flickering 현상이라 할 수 있을지도 모르겠다. 사용자가 선택한 음식의 id가 전역 상태에 즉시 동기적으로 반영되지 않고 렌더링 사이클을 거치면서 반영되며 일시적으로 없는 데이터에 대한 리뷰 데이터를 렌더링하려고 하는 것이 문제였다.
Flickering은 이전 이슈에 비해 상대적으로 덜 중요해 보일 수 있다. 하지만 실제 사용자 경험에서는 작은 깜빡임 하나도 충분히 불편함으로 이어질 수 있기 때문에, 그냥 넘길 수는 없었다. 이 서비스를 통해 사람들이 꼭 더 나은 선택을 할 수 있도록 만들고 싶다는 생각이 들었다.
이에 선택한 음식 id를 slug로 전달하는 방식으로 변경하여, 초기 렌더링 시점부터 해당 음식 정보를 기반으로 렌더링이 이루어지도록 했다. Slug에 대해서 이때 처음 알게 되었다. 결론부터 말하자면 좋았다.
페이지 진입 시점에 이미 어떤 데이터를 보여줘야 하는지가 명확해졌으며, 전역 상태를 기다릴 필요가 없어져 초기 렌더링부터 일관된 UI를 유지할 수 있었다. 자연스럽게 Flickering 현상도 사라졌다.
또 하나 좋았던 것은 구조가 단순해졌다는 것이다. 상태 흐름을 추적하지 않아도 URL만 보면 현재 어떤 데이터가 필요한지 바로 알 수 있었다. 디버깅도 수월해졌다. 이때 나는 일시적인 편의를 위해 전역 상태를 남용하는 것이 오히려 상태 흐름을 복잡하게 만들고 예측 불가능한 버그를 만들어 개발 비용을 오히려 증가시킨다는 점을 확실하게 느낄 수 있었다.
이렇게 리뷰 기능을 정돈하고 나니 또 다른 것이 보였다.
개발하며, 그리고 배포에서도 자꾸 cannot read property of undefined와 같은 오류가 발생하는 것이었다. 특정 값이 존재한다고 생각하고 접근했는데, 실제로는 아직 데이터가 준비되지 않은 상태에서 렌더링이 먼저 일어나면서 생기는 문제였다.
특히 비동기적으로 데이터를 받아오는 구조이다 보니, API 응답이 오기 전까지는 undefined 상태가 자연스럽게 발생하곤 했으며 자주 변경되는 백엔드 속성명과 속성 타입도 이런 오류의 발생에 한몫했다.
초반에는 이런 부분들을 일일이 조건문과 optional chaining으로 방어적으로 처리하며 넘어갔지만 점점 이런 방식이 실제 사용자들을 만났을 때 마주할 수 있는 오류들을 모두 통제하고 대응할 수 있을까? 하는 생각이 들어 불안해졌다. 사용자가 우리 서비스를 사용하다 런타임 에러를 만나게 되면 어떡하지, 개발자인 나도 어떤 상황에서 어떤 오류가 났는지 모르게 되는 것이 아닐까..
주변에서 TypeScript, Type, 이런 이야기를 할 때 JavaScript 만으로도 충분히 모두 가능한데 왜 그걸 또 배워야 해? 같은 회의적인 (진짜 애송이 같은 마인드야!!) 생각을 가졌었는데, TypeScript 정말 필요하구나. 타입 안정성이라는 것이 정말 중요한 것이구나. 생각이 바뀌었다.
그렇게 TypeScript를 통해 응답 DTO, 리뷰, 음식을 모두 타입으로 정의했다. 타입 자동완성은 진짜 편했다. 이전보다 훨씬 서비스가 안정되었다는 것을 정말 몸소 체감할 수 있었던 것 같다.
PWA는 Progressive Web App으로, 사용자가 웹사이트를 애플리케이션처럼 설치할 수 있도록 하는 기술이다. 도입 방법은 매우 간단한데, 프로젝트 루트에 manifest.json 설정 파일을 작성한 뒤 프로젝트 메타데이터에 적용시켜 두기만 하면 된다.
우리 서비스는 애플리케이션은 아니지만, 이 PWA 웹앱 세팅 그리고 웹앱 설치 방법을 안내해 둠으로서 사용자들이 편리하게 접근할 수 있지 않을까 생각했다.
이렇게 25년 3월 신학기, 서비스 기본 기능이 돌아가는 초기 버전을 배포하고 홍보했다.


PWA 웹앱 설치 방법을 기재해 두는 것도 잊지 않았다.
에브리타임에서 우리는 폭발적인 반응을 얻었다.
그래서 우리는 많은 사용자들이 우리 서비스를 사용하고, 리뷰도 많이 남겨줄 것이라고 생각했다.
하지만 이는 정말 우물 안 개구리식 사고방식이었다. 사용자는 우리가 원하는 방식으로 움직여주지 않았다.
초기 방문자는 우리가 생각했던 것보다 꽤나 많은 수치인 100명 정도였다. 하지만 그 방문자 수는 유저의 참여로 이어지지 못했다. 실제로 리뷰를 남긴 유저는 5명도 채 되지 않았다. 그렇게 1주일, 2주일 시간이 흐르면서 많았던 방문자 수도 큰 폭으로 줄었다. 우리 서비스에 대한 관심은 그렇게 꺼져갔다...
그런데 정말 큰일은 배포 2주 후에 일어났다.
2주 동안 운영했고, 사용자도 별로 없었던 서비스의 AWS 비용이 68달러나 나오게 된 것이다.


10만원이 조금 넘는 금액이 발생했다.
당시 나는 프론트 1명, 백엔드 2명으로 이루어진 팀에서 프론트만을 맡아 개발하고 있던 입장에서 원인도 알지 못한 채 터무니없이 많이 나온 금액을 동일하게 부담해야 한다는 것이 당황스러웠다.
이유를 설명해달라고 했지만, 당시의 나로서는 그 내용을 온전히 이해하기 어려웠다. 그래서 더 답답하게 느껴졌다.
그리고 솔직히 말하면, 감정적으로도 불편함이 있었다. 문제 자체보다도 그 상황을 전달하는 방식 때문이었다. 프로젝트 초반에 발생하는 비용을 균등하게 나누기로 한 것은 맞지만, 원인에 대한 설명이나 이후 개선 방향에 대한 공유 없이 금액만 전달받는 과정은 쉽게 납득하기 어려웠다.
돌이켜보면 우리 모두 비용에 대한 경험이 부족한 상태에서 프로젝트를 시작했고, 그 과정에서 시행착오가 발생한 것은 자연스러운 일이었다. 다만 팀 단위로 문제를 해결해나가야 하는 상황에서, 원인과 맥락이 충분히 공유되지 않았던 점이 아쉬움으로 남았다.
비용을 정산하기는 했지만 당시에 이런 대화를 나누면서 감정적인 부분이 커져 결국 서비스를 중단하게 되었다.
이 사건은 나에게 진짜 강렬하게 남았다. 감정적인 부분이 아니라 프론트에서 불필요한 요청을 많이 보내서 서버비가 많이 나오지 않았을까? 하는 생각도 문득 들었기 때문이다. 서버 로그도 볼 줄 모르고, 과금 체계도 모르지만... 나도 서버비가 많이 나오게 한 원인 중 하나였을 수도 있겠다는 생각에 팀원들에게 미안한 마음이 많이 들었다.
그렇게 끝나는 줄로만 알았는데, 여름방학 쯤 팀에게서 다시 연락이 왔다.
새로운 인원 한 명과 함께, 이번에는 교내 식당 메뉴까지 모두 지원하는 방향으로 다시 해보자는 것이었다.

솔직히 많이 반가웠다. 에브리타임에서 학생들이 보여줬던 내 서비스에 대한 관심은 서비스를 개발하는 개발자로서 잊을 수 없는 맛이었다. 그리고 이번에 다시 시작하면 불필요한 요청을 꼭 줄여야겠다 생각했다.
다만 이전 코드를 다시 마주했을 때 불필요한 요청보다도 놀랐던 것은 수많은 useEffect 그리고 조건문들이었다.
데이터를 가져오는 타이밍에 따른 UI 렌더링을 useEffect와 useState만으로 처리하고 있었고, 오류 처리 역시 useEffect 내부의 조건문들에 의존하고 있었다. 이로 인해 데이터 흐름을 한눈에 파악하기 어려웠고, 상태에 따라 어떤 UI가 렌더링되는지 예측하기도 쉽지 않았다.
특히 문제가 되었던 부분은 데이터 상태를 명확하게 구분하기 어려웠다는 점이다. 우리 서비스의 백엔드 응답 구조상 요청에 성공했지만 데이터가 없는 경우에도 null을 반환했기 때문에, 데이터가 null인 상태가 단순하지 않았다. 로딩 중인 상태, 요청에는 성공했지만 데이터가 없는 상태, 요청에 실패한 상태가 모두 null로 표현될 수 있었기 때문이다.
그래서 (!data)와 같은 조건 분기로는 UI를 세분화하여 처리하는 데 한계가 있었고, 결국 조건문이 계속해서 늘어나는 구조로 이어졌던 것이다.
이 문제를 해결하는 과정에서 TanStack Query를 알게 되었다. TanStack Query를 도입하면서 데이터의 상태를 명시적으로 구분할 수 있게 되었고, 로딩 중, 에러 발생, 데이터 없음, 데이터 존재와 같은 상태를 각각 독립적으로 다룰 수 있게 되었다.
또한 파일 구조도 개선했는데, 이는 필수적인 조치였다. 기존에는 API 통신 함수들이 하나의 파일에 뒤섞여 있거나, 어떤 함수는 실행 결과를 반환하고, 어떤 함수는 또 다른 함수를 반환하는 등 구조에 일관성이 없었다.
이를 해결하기 위해 API 레이어를 순수하게 데이터를 반환하는 함수 형태로 정리하고, 그 위에 useQuery를 래핑한 커스텀 훅을 구성했다. 이를 통해 데이터 요청, 캐싱, 상태 관리를 하나의 패턴으로 통일할 수 있었다.
그러고 나니 또 하나 걸리는 점이 생겼다. 초기 진입 시 불필요한 로딩 화면이 반복적으로 발생하고 있었다.
식단 데이터는 자주 변경되는 데이터가 아니었다. 당시에는 기숙사 식단만 제공하고 있었기 때문에, 실제 데이터 변경 주기는 일주일에 한 번 정도에 불과했다. 그럼에도 불구하고 사용자가 서비스에 접속할 때마다 매번 API 요청이 발생했고, 그 과정에서 불필요한 로딩 상태가 계속해서 노출되고 있었다.
이 문제는 단순한 UX 이슈를 넘어서 성능과도 직결되어 있었다. 사용자 입장에서는 이미 변하지 않은 데이터를 다시 받아오기 위해 기다려야 했고, 그로 인해 화면에 의미 있는 콘텐츠가 표시되기까지의 시간이 지연되고 있었다. 이때 "화면에 의미 있는 콘텐츠가 표시되기까지의 시간" 은 성능 면의 지표로, FCP(First Contentful Paint) 라고도 하는 데이터였다.

그리고 이 문제는 앞서 겪었던 불필요한 API 요청 문제와도 연결되어 있었다. 캐싱을 알게 되었고 필요성을 느꼈다. 상태 관리를 위해 들였던 Tanstack Query에서 캐싱도 지원하고 있어 staleTime을 통해 적용해 주었다. 네트워크 탭에서 매번 수행되었던 요청이 사라졌다! staleTime 하나만으로 돈을 아끼게 되었다는 생각도 들었고 뭔가 뿌듯했었다.. ㅎㅎㅎㅎ
다만 캐싱과 FCP는 해결해야 하는 영역이 달랐다. FCP 개선을 위해 당시 사용하고 있던 React Router를 심층적으로 살펴보며 loader 함수라는 개념을 알게 되었다. loader 함수는 특정 라우트가 렌더링되기 전에 비동기적으로 데이터를 미리 가져오는 메커니즘이다.
이를 적용하면서, 기존처럼 컴포넌트 마운트 이후에 데이터를 요청하는 구조가 아니라 라우트 진입 시점에 데이터를 선제적으로 준비할 수 있게 되었다. 그 결과, 화면 렌더링 시점에는 이미 필요한 데이터가 존재하는 상태가 되었고, 불필요한 로딩 UI를 줄이면서 FCP 또한 개선할 수 있었다.
그런데! 또 다른 문제가 생겼다. 기숙사 식단 데이터의 캐시를 언제 갱신할 것인가에 대한 문제였다.
단순히 staleTime만으로는 원하는 시점에 데이터가 갱신되지 않았다. 식단 데이터는 일주일에 한 번, 스크래퍼를 통해 갱신되는 구조였는데, 캐시의 만료 시점이 이 주기와 어긋나는 순간 문제가 발생했다.
예를 들어 staleTime이 길게 설정된 상태에서 스크래퍼가 새로운 데이터를 반영하더라도, 클라이언트에서는 여전히 이전 주의 데이터를 캐시로 사용하게 되는 상황이 발생했다.
즉 데이터의 실제 변경 시점과 캐시 갱신 시점이 분리되어 있어서 이로 인해 오래된 데이터가 사용자에게 노출되는 문제가 생겼던 것이다..
정말 치명적인 문제였다. 성능 최적화를 위해 도입한 캐싱이 오히려 데이터 정합성을 깨뜨릴 수 있는 상황이었기 때문이다.
클라이언트 레벨에서 캐시를 제어하는 방식도 고려했지만 이 방식에는 한계가 있었다. 우리는 클라이언트의 시점에서 캐시를 갱신하는 것이 아니라 백엔드에서 스크래퍼가 주기적으로 실행된 이후에 맞춰 캐시를 갱신할 필요가 있었다.
즉 캐시 갱신의 기준이 사용자 접근 시점이 아니라 서버의 데이터 갱신 시점에 맞춰져야 했고, 이를 클라이언트에서 제어하는 것은 구조적으로 적절하지 않았다.
이러한 이유로 Next.js로의 마이그레이션을 고려하게 되었고, ISR을 활용하면 서버에서 데이터 갱신 시점을 기준으로 캐시를 제어할 수 있을 것이라 생각했다. 또한 SSR을 통해 FCP까지 함께 개선할 수 있다는 점에서 적합한 선택이라는 생각이 들었다.
마이그레이션을 결정한 이후 일단 Tanstack Query, Loader 함수를 적용한 2차 버전을 배포한 후

더 나은 서비스를 위한 설문조사를 진행했다.

2차 버전 배포는 그닥 큰 반응을 얻지 못했으며 설문조사를 통해 에브리타임에 주기적으로 올라오는 기숙사 식단 사이트 캡쳐 글이 기룡아 밥먹자보다 더 유용하다는 충격적인 의견도 받았다.
이에 기숙사 식단에 더해 교내 식당 데이터가 꼭 필요하겠다고 의견이 좁혀져, 이전까지 다루기 어려웠던 교내 식당 데이터를 팀원들이 직접 수기로 모아 CSV 형태로 정리하는 방식으로 확보했다
기술적으로도 프론트엔드 기술 스택을 Next.js로 이전하기로 결정하면서, 그동안 마음에 들지 않았던 디자인도 전면적으로 손보았다.