하루에 세 번 남의 것을 뜯어본 이야기

26년 08월 24일

하루에 세 번 남의 뜯어보기

어제는 내 코드를 거의 안 고치고 남의 코드를 뜯어본 하루였다.

법원경매 분석 서비스를 개발 중인데, 디자인 시스템이 KRDS와 shadcn/Radix 조합에서 HeroUI v3로 전면 교체된 커밋이 올라왔다.

겉보기엔 마이그레이션이 끝난 것 같지만 문서는 전부 옛 체계를 설명하는 상태였고 나는 이 커밋 이후의 실제 모습을 확인하는 작업부터 시작했다.

그러다 보니 하루 종일 외부 의존성을 뜯어보는 일이 됐다.

디자인 시스템 확인, 데이터 캐싱 감사, GitHub Actions 빌링 사건까지 셋 다 결국 “내가 갖다 쓴 것의 실체를 직접 검증했나”라는 같은 질문으로 수렴했다.

그 과정을 정리해본다.

HeroUI v3로 갈아탄 실체 확인

커밋을 열어보니 packages/ui가 기존 KRDS/shadcn/Radix를 완전 폐기하고 HeroUI v3 얇은 래퍼 구조로 재작성돼 있었다.

컴포넌트 카탈로그는 atoms 42, molecules 34, organisms 10.

대부분 HeroUI 프리미티브를 감싸기만 하고 Phosphor 아이콘 기본값을 주입하는 게 전부라서 예외가 Pagination, PropertyCard, GalleryCarousel 3종뿐이었다.

테마 소스는 heroui-theme.css 파일 하나로 통일됐고 이건 HeroUI v3 Theme Builder에서 생성된 것이다.

여기서 관건은 토큰 커스터마이징이었다.

Theme Builder Export를 통째로 붙여넣으면 안 되고 필요한 변수만 선별해서 이식해야 한다.

Builder가 내보내는 Inter 폰트 설정은 의도적으로 배제하고 기존 Pretendard 체인을 유지했고 Base 중립색은 채도를 0.010.02에서 0.00090.0060까지 낮춰서 파란기 없는 거의 순수한 무채색으로 만들었다.

radius는 0.25rem에서 0.125rem으로 줄여서 좀 더 샤프한 인상을 잡았다.

hover 같은 파생 토큰은 건드리지 않았는데 @heroui/styles가 color-mix()와 calc()로 자동 계산해주기 때문이다.

Biome 포매터와 끝자리 0

Export 원문에 oklch(97.02% 0.0030 259.02) 같은 값이 있으면 Biome 포매터가 0.0030을 0.003으로 정규화한다.

diff에 50줄 가까운 노이즈가 생기는 셈이라 원문과의 정합성 검증은 텍스트 일치가 아니라 값 비교로 해야 한다.

사소하지만 몰라두면 diff가 지저분해지는 함정이다.

팩트체크 3종으로 환각 구현 막기

이 전환의 핵심 결정은 상단 내비게이션을 좌측 사이드바 콘솔로 바꾸는 앱 셸 재설계였다.

여기서 AI 에이전트가 존재하지 않는 컴포넌트를 구현하는 환각을 막기 위해 3가지를 직접 확인했다.

첫째, @heroui/react 3.2.4에 Sidebar 컴포넌트가 없다는 걸 export 전수 확인으로 실증했다.

사이드바는 네이티브 aside에 HeroUI atom들을 조합한 커스텀 organism으로 직접 만들어야 한다.

둘째, HeroUI Finances 템플릿의 사이드바가 좌측에 있다는 것.

이건 JS 렌더링이라 정적 fetch가 안 돼서 격리된 Playwright로 접근성 스냅샷을 뜨고 getBoundingClientRect()로 aside x=0, width=240px를 확인했다.

셋째, topbar 높이 h-14는 유지하기로 해서 기존 sticky 오프셋과 IntersectionObserver rootMargin이 그대로 유효하다는 것.

불변으로 유지할 값을 먼저 식별하니 마이그레이션 리스크가 결정적으로 줄었다.

스펙을 쓰기 전에 실존 export, 템플릿 좌표, 불변값 이 3가지를 팩트체크하는 걸 습관으로 들여야겠다.

TanStack Query 캐싱 감사

같은 날 리스트 페이지가 TanStack Query로 적절히 캐싱되고 있는지 전체 감사를 돌았다.

잘 된 부분부터 말하면 queryKey 분리는 깔끔했다.

tRPC가 입력값 전부를 queryKey로 직렬화해주니 페이지별, 필터별 캐시가 자연스럽게 분리됐고 2페이지에서 1페이지로 돌아갈 때 즉시 렌더된다.

keepPreviousData로 페이지 전환 깜빡임도 없었고 모바일 무한스크롤과 PC 페이지네이션이 같은 queryOptions를 공유해서 캐시 창고도 하나로 합쳐져 있었다.

하드 로드마다 2중 fetch

그런데 SSR에서 1페이지를 조회해 initialData prop으로 넘기는 구조에 구멍이 있었다.

이 데이터가 쿼리 캐시로 들어가지 않는다.

하이드레이션 직후 캐시가 비어 있으니 useQuery가 캐시 없음으로 판단해서 같은 1페이지를 클라이언트에서 또 요청한다.

서버에서 장을 봐 와 테이블엔 차렸는데 창고엔 안 넣은 상태다.

폴백이 깜빡임을 마스킹해주는 바람에 화면상 증상은 없었지만 새로고침할 때마다 동일 데이터가 서버 1회 클라이언트 1회씩 2중으로 조회되고 있었다.

prefetchQuery와 HydrationBoundary로 SSR 프리페치를 캐시에 시딩하는 게 개선 방향인데 아직 제안만 해둔 상태다.

페이지네이션 먹통과 침묵 폴백

/sold 페이지에서 페이지네이션을 클릭해도 목록이 안 바뀌는 버그도 잡았다.

코드 정적 분석으로는 전부 정상이라 dev 서버를 띄우고 Playwright로 실제 재현해서 원인을 찾았다.

직접 원인은 Turbopack dev 플레이크로 tRPC 라우트가 빈 바디 500을 반환한 것.

재시작하면 재현이 안 되는 성질이라 잡기 어려웠다.

그런데 진짜 앱 결함은 따로 있었다.

쿼리 실패 시 pageData?.items ?? initialData?.items ?? [] 폴백이 SSR 1페이지 목록을 계속 렌더해서 에러 표시도 재시도도 없이 침묵하는 구조였다.

SSR은 서버에서 페이지를 직접 조회하니 새로고침하면 정상이고 그래서 새로고침 전까진 안 바뀌는 이상한 증상이 된 것이다.

SSR 렌더는 정상인데 클라이언트 데이터만 갱신이 안 되면 코드가 아니라 API 호출 실패를 의심해야 한다.

수정은 에러 카드 노출과 isPlaceholderData 게이트로 했다.

쿼리 실패 시 목록을 불러오지 못했다는 카드와 다시 시도 버튼을 띄우고 keepPreviousData로 이전 페이지 placeholder가 살아있는 상태의 실패에만 카드가 뜨도록 게이트를 걸었다.

검증은 Playwright 3시나리오로 돌렸다.

서버를 내린 뒤 페이지를 클릭해 에러 카드를 확인하고 서버를 복구하면 자동 재시도로 렌더가 돌아오는 것까지 봤다.

라우트 차단 도구 없이 서버 kill과 restore만으로 실패와 복구 경로를 실증할 수 있다.

양방향 무한스크롤 폐기

모바일 무한스크롤은 방향을 정리했다.

전에 설계했던 ?page=N 기반 양방향 복구는 버그 노출면이 커서 전면 폐기했다.

6파일에서 111줄 추가 210줄 삭제, 위쪽 gap 스켈레톤과 스크롤 위치 URL 영속화 같은 복잡한 로직을 싹 걷어냈다.

모바일은 항상 1페이지부터 단방향으로만 적재하고 ?page를 읽지도 쓰지도 않는다.

새로고침하면 처음부터가 표준 모바일 UX라는 판단이다.

기능을 되살리려다가 더 단순한 표준으로 되돌리는 게 더 나은 답인 경우가 있다.

GitHub Actions 빌링 사건과 OpenWiki 자체화

어제의 가장 큰 사건은 CI가 전부 실패한 것이었다.

빌드 에러가 아니라 입장권이 없어서 job 시작 자체가 거부된 것.

무료 2,000분을 소진해서였다.

진단은 실패한 run의 steps 필드가 비어 있는 것부터 시작했다.

job 시작 후 3초 만에 종료되고 체크아웃조차 없으면 빌링 거부를 의심하면 된다.

월 전체 run 집계를 돌려보니 위키를 자동 갱신하던 job이 평균 21.1분을 매일 소비해서 월 630분, 무료 한도의 31.5%를 혼자 쓰고 있었다.

배포 워크플로는 개발할 때만 도는 변동 비용인데 이놈은 코드가 안 바뀌어도 매일 고정으로 도는 구조였다.

품질이 나쁘지 않았지만 세션 기록을 뒤져보면 정작 위키를 열어 작업에 활용한 흔적이 없었다.

매일 도는 LLM 문서 자동갱신은 로봇이 찍어내는 사내 신문이나 다름없다.

발행 성실성과 실제 독해는 별개 지표라는 걸 데이터로 확인한 셈이다.

그래서 이 job을 아예 자체화하기로 했다.

CLI 해부와 pi 커맨드 4종

위키를 갱신하던 openwiki CLI의 실체를 뜯어봤더니 거대한 프롬프트 4개와 자잘한 결정적 후처리 스크립트가 전부였다.

MIT 라이선스라 npm 패키지 dist에서 프롬프트 원문을 추출할 수 있었고 그 핵심 계약을 증류해서 wiki-scan, wiki-update, wiki-lint, wiki-ask 4개의 pi 커맨드로 재고용했다.

정찰병, 집필가, 교정자, 사서 네 역할이다.

연속성은 .last-update.json 파일의 gitHead 책갈피로 승계했다.

증분 파이프라인의 상태를 별도 저장소가 아니라 산출물 자체에 심는 설계인데 파일이 위키 커밋에 함께 들어가니 CI 시절 기록과 로컬 실행이 같은 책갈피를 공유한다.

결과적으로 Actions 사용량 0, OpenRouter 토큰 비용 0, 별도 CLI 없음의 3중 제로가 됐다.

만능 알바를 해고하고 업무 매뉴얼을 뜯어 4개의 전문 역할로 재고용한 셈이다.

유료 의존 서비스가 장애를 일으키면 그때가 의존 자체를 재검토할 기회다.

진단, 집계, 분석, 자체화로 이어지는 경로가 정석이라는 걸 몸으로 배웠다.

남의 뜯어보기에서 배운 것

하루를 돌아보면 세 작업 모두 결국 검증이었다.

디자인 시스템은 문서가 아니라 실제 exports를 열어 확인해야 하고, 캐싱은 화면이 정상처럼 보여도 2중 fetch가 있는지 네트워크를 봐야 하고, CI 실패는 빌드 에러가 아니라 입장권 문제일 수 있다.

화면이 정상이면 검증을 생략하게 되는데, 그 아래에서 조용히 낭비가 일어나고 있던 것이다.

앞으로 외부 의존성을 새로 끌어올 때는 한 번씩 뜯어보고 쓰는 걸 원칙으로 삼으려 한다.