그냥 두고 볼 수 없었던 코드 — 20분 버그 수정이 일곱 파일의 리팩터링이 되기까지
리포트 대시보드의 페이지네이션 버그를 고치던 개발자가 필터 상태 중복, 클라이언트-서버 검증 로직 중복을 연달아 발견하고 수정하다가 서버 컴포넌트 전환 직전에 멈춘 경험담입니다. 변경 파일이 하나에서 일곱 개로 불어나는 순간, '이 변경이 제품에 필요한가, 아니면 내가 하고 싶은가'를 자문해 PR을 분리했다고 합니다. 저자는 변경의 크기가 아니라 정당화에 걸리는 시간과 그 정당화가 누구를 위한 것인지가 판단 기준이라고 말합니다.
본문
버그 리포트 자체는 단순했습니다. 리포트 대시보드에서 필터가 활성화된 상태로 날짜 범위를 바꾸면 페이지 번호가 초기화되지 않아, 이제 결과가 한 페이지뿐인 리포트의 4페이지에서 텅 빈 테이블을 보게 되는 문제였습니다. 원인도 빨리 찾았습니다. 페이지네이션 상태는 테이블 컴포넌트의 useState에, 필터 상태는 useSearchParams에 각각 존재하면서 한쪽이 다른 한쪽을 리셋해주는 장치가 없었던 것입니다. 저자는 search params가 바뀔 때 페이지를 1로 되돌리는 useEffect 하나를 추가해 20분 만에 수정을 끝냈습니다.
보이기 시작하면 멈추기 어려운 문제들
PR을 열기 전 diff를 다시 읽다가 저자는 ReportFilters.tsx에서 더 깊은 문제를 발견합니다. 필터 상태가 두 곳에 동시에 존재하고 있었던 것입니다. 실제 API 호출을 결정하는 URL의 useSearchParams와, 드롭다운 표시만을 위한 로컬 컴포넌트 상태가 따로 놀고 있었고, 충분히 빠르게 클릭하면 드롭다운은 하나의 필터를 보여주면서 테이블은 다른 필터로 조용히 데이터를 가져오는 상태가 됐습니다. 아무도 버그로 신고하지 않았고 아무도 그렇게 빨리 클릭해본 적이 없을 테지만, 한번 본 뒤에는 못 본 척하기 어려웠다고 합니다. 저자는 이를 과거에 추적했던 체크박스 race condition과 같은 형태, 즉 UI가 보여주는 것과 실제로 일어나는 일의 불일치로 분류합니다.
그래서 로컬 상태를 전부 걷어내고 URL params를 single source of truth로 만들어, 모든 읽기는 useSearchParams로, 쓰기는 router.replace로 통일했습니다. 같은 데이터에 대한 두 개의 진실 공급원이야말로 훗날 훨씬 큰 버그를 낳는 구조라는 판단이었습니다. 이어서 search params를 쿼리 객체로 바꾸는 필터 로직이 컴포넌트와 app/api/reports/route.ts의 서버 검증 쪽에 중복으로 존재하고, 새 필터 타입 추가 때 한쪽만 갱신되어 살짝 어긋나 있음을 발견하고는 공용 lib/filters.ts로 추출해 양쪽에서 import하도록 만들었습니다. 저자는 이것이 optional한 개선이 아니라 조용히 프로덕션 버그로 자랄 수 있는 drift라고 강조합니다.
일곱 개의 파일이라는 신호
여기까지가 버그 수정, 중복된 진실 공급원 제거, 클라이언트-서버 검증 격차 해소였고, 여기서 멈췄다면 평범한 화요일이었을 것이라고 저자는 말합니다. 하지만 라우트 핸들러를 다시 보며 리포트 데이터는 굳이 클라이언트에서 fetch할 필요가 없고, page props의 searchParams로 필터를 받아 직접 데이터를 가져오는 서버 컴포넌트로 만들 수 있다는 생각에 이릅니다. 클라이언트 fetch도, 로딩 스피너도, waterfall도 없는 구조였고 저자는 코드 예시까지 머릿속에 그린 뒤 실제로 작업을 시작했습니다.
절반쯤 변환했을 때 멈춘 이유는 작업이 잘못되어서가 아니라, 에디터의 git diff 패널에서 변경 파일 수가 하나에서 일곱 개로 불어 있는 것을 봤기 때문입니다. 구조적으로는 useEffect 안의 빠진 한 줄이었던 버그를 위해 일곱 파일. 저자를 멈추게 한 질문은 '이게 너무 많은 작업인가'가 아니라 '이 서버 컴포넌트 전환이 지금 제품에 필요한 것인가, 아니면 방법이 정확히 보이고 안 하고 두면 괴로우니까 내가 하고 싶은 것인가'였습니다.
정당화의 길이가 판단 기준이다
저자는 필터 상태 수정과 중복 로직 제거는 각각 한 문장으로 리뷰어에게 정당화할 수 있었지만, 서버 컴포넌트 재작성은 한 문단이 필요했고 그 문단 대부분이 현재 사용자에게 무엇이 고장 났는지가 아니라 최종 결과물이 얼마나 만족스러울지에 관한 것이었다고 지적합니다. 크기가 아니라 정당화에 걸리는 시간과 그 정당화가 누구를 위한 것인지가 신호라는 것입니다. 작은 변경도 방만할 수 있고 큰 변경도 필요할 수 있다고 덧붙입니다.
결국 저자는 작업을 둘로 나눴습니다. 버그 수정과 공용 필터 로직은 리뷰어가 5분 안에 완전히 이해할 수 있는 집중된 PR로 당일 머지했고, 서버 컴포넌트 재작성은 무엇을 제거하고 무엇을 단순화하는지, 왜 한 줄짜리 버그 수정 뒤에 몰래 타지 않고 독립적으로 평가받을 가치가 있는지를 설명하는 별도 브랜치로 보냈습니다. 저자가 남긴 통찰은 이렇습니다. 거의 너무 많이 ship할 뻔했다는 사실보다, 모든 단계가 그 안에서는 얼마나 합리적으로 느껴졌는지가 중요하다는 것입니다. 개별 커밋 하나하나는 누구도 이의를 제기하지 않았을 '당연히 할 만한' 일들이지만, 하나의 버그 리포트를 출발점으로 한 자리에 쌓이면 문제에 대한 응답이 아니라 파일을 본 상태 그대로 두지 못하는 자기 불편함에 대한 응답이 됩니다. 저자는 이 불편함 자체가 자신을 좋은 개발자로 만드는 능력이므로 없앨 것이 아니라, 변경을 보는 것과 ship하는 것 사이의 간극을 느끼는 것이 필요하다고 결론 내립니다.
원문: dev.to / 번역·요약: Trawling