blog

react-doctor 이슈 212개를 0개로 만들기까지

Front-end / React#React#react-doctor#Biome#리팩터링#접근성#성능

React Doctor는 보안·성능·버그 가능성·유지보수성·접근성 관점에서 React 코드베이스를 스캔하고 0~100점의 점수를 매기는 도구다. 회사 프론트엔드 프로젝트에 처음 돌렸을 때 결과는 이슈 212개, 49점이었다. 이 글은 이를 0개, 100점으로 만든 기록이다.

원칙은 두 가지였다.

  • 억제보다 근본 수정: disable 주석은 확실한 오탐이거나 영구적으로 옳은 패턴에만 쓴다. 판단이 서지 않는 항목은 억제하지 않고 계속 보이게 둔다.
  • 동작은 바꾸지 않는다: 렌더링 결과와 상태 전이 타이밍이 달라지지 않도록 보수적으로 고친다.

1. 도구 자체의 함정부터

disable 주석이 조용히 실패하고 있었다

react-doctor-disable-next-line <rule>: <사유> 형식으로 달았는데 재검증해 보니 진단이 그대로 남아 있었다. 당시 버전의 파서가 콜론 뒤 사유까지 포함한 문자열 전체를 규칙 ID와 비교해 매칭에 실패하고 있었다. 사유 뒤에 규칙 이름을 쉼표로 한 번 더 적어야 인식됐다.

// 억제되지 않음
// react-doctor-disable-next-line react-doctor/exhaustive-deps: 별칭 추적 못 하는 오탐

// 억제됨 (당시 버전 기준 우회)
// react-doctor-disable-next-line react-doctor/exhaustive-deps: 별칭 추적 못 하는 오탐, react-doctor/exhaustive-deps

재검증 없이 넘어갔다면 아무것도 억제되지 않은 채 "해결했다"고 착각했을 것이다. 도구 버전에 따라 동작이 다를 수 있으니, 억제 주석을 단 뒤에는 반드시 다시 실행해 진단이 사라졌는지 확인한다.

Biome 주석과 같은 줄을 다툴 때

react-doctor와 Biome 모두 억제 주석이 대상 코드 바로 윗줄에 있어야 인식한다. 한 useEffect를 두 도구가 동시에 지적하면 어느 주석을 위에 두든 하나가 실패했다. Biome 2.x의 범위 억제 문법으로 해결했다.

// biome-ignore-start lint/correctness/useExhaustiveDependencies: pcId·hasTask·isCompleted 변경 시에만 반응하도록 의존성 생략
// react-doctor-disable-next-line react-doctor/no-cascading-set-state: 컨텍스트 전환 시 탭 상태를 한 번에 초기화, react-doctor/no-cascading-set-state
useEffect(() => {
  resetTaskViewState();
}, [pcId, hasTask, isCompleted]);
// biome-ignore-end lint/correctness/useExhaustiveDependencies: 위와 같음

2. 보안: 상태를 바꾸는 GET 핸들러

/api/auth/logout의 GET 핸들러가 쿠키를 지우고 있었다. GET은 다른 사이트의 <img src="...">로도 보낼 수 있으므로, 사용자를 강제로 로그아웃시키는 CSRF가 가능했다.

이 경로는 서버의 401 처리에서 redirect()로만 들어오는 내부 경로라 POST로 바꾸기 어려웠다. 대신 Fetch Metadata 헤더로 다른 사이트에서 온 요청을 걸렀다.

export const GET = async (request: NextRequest) => {
  const loginUrl = new URL(COMMON_ROUTES.LOGIN, request.url);
  if (request.headers.get('sec-fetch-site') === 'cross-site') {
    return NextResponse.redirect(loginUrl);
  }

  (await cookies()).delete('token');
  return NextResponse.redirect(loginUrl);
};

같은 사이트에서 이동해 오면 Sec-Fetch-Site가 same-origin(주소창 직접 입력은 none)이고, 다른 사이트에서 유도한 요청은 cross-site다. 이 헤더를 보내지 않는 오래된 브라우저에서는 걸러지지 않으므로, 가능하다면 상태를 바꾸는 동작은 POST로 만드는 것이 원칙이다.

3. 진짜 버그와 오탐 구분하기

경고를 기계적으로 고치지 않고, 코드를 읽고 판단했다.

진짜 버그: 매 렌더 새 배열 기본값

// Before - 값이 없을 때마다 새 배열이 만들어져 useMemo 의존성이 매번 바뀜
const selectedValues = (filters[filterKey] as string[]) ?? [];

// After - 모듈 상수로 참조 고정
const EMPTY_SELECTED_VALUES: string[] = [];
const selectedValues = (filters[filterKey] as string[]) ?? EMPTY_SELECTED_VALUES;

진짜 버그: updater 안에서 다른 setState 호출

// Before - updater는 여러 번 호출될 수 있어 안쪽 setState가 중복 실행될 위험
setNewDraft((prev) => {
  const normalized = normalize(prev);
  setSelectedState((s) => ({ ...s, ...normalized }));
  return { ...prev, ...normalized };
});

// After - 계산은 밖에서, 두 setState는 각각 순수한 updater로
const normalized = normalize(newDraft);
setNewDraft((prev) => ({ ...prev, ...normalized }));
setSelectedState((prev) => (prev?.kind !== 'new' ? prev : { ...prev, ...normalized }));

오탐: lazy initializer 안의 forEach

useState(() => {...})의 초기화 함수는 마운트 시 한 번만 실행된다. 그 안의 forEach를 "렌더마다 호출되는 콜백"으로 오인한 경우라 사유와 함께 억제했다.

오탐: 다른 파일의 Suspense를 못 보는 규칙

nextjs-no-use-search-params-without-suspense 규칙은 파일 단위로만 Suspense를 확인한다. 부모 page.tsx가 커스텀 SuspenseBoundary(내부에 <Suspense>가 있는 래퍼)로 감싸고 있어도 알아채지 못한다. 처음에는 진단이 찍히지 않는 파일에 주석을 달아 억제가 안 되고 있었고, 재검증에서 실제 진단 위치(page.tsx의 렌더 줄)를 확인하고 옮겼다.

4. Effect 체인과 상태 동기화 정리

You Might Not Need an Effect에 나오는 패턴들이 실제로 있었다.

effect가 effect를 부르는 체인

// Before - effect1이 initialTaskSeq를 바꾸면 effect2가 다시 실행
useEffect(() => { setInitialTaskSeq(undefined); /* ... */ }, [pcId, hasTask, isCompleted]);
useEffect(() => { setInitialTaskSeq(latestTask?.taskSeq); }, [latestTask, initialTaskSeq]);

// After - 함수형 업데이트로 바꾸고 initialTaskSeq를 의존성에서 제거
useEffect(() => {
  if (!latestTask) return;
  setInitialTaskSeq((prev) => (!prev || latestTask.taskSeq > prev ? latestTask.taskSeq : prev));
}, [latestTask]);

prop을 state로 복사

// Before - 첫 렌더에 이전 값이 잠깐 보임 (effect는 렌더 이후 실행)
useEffect(() => setEditedMemo(memo), [memo]);

// After - 렌더 중 상태 조정 패턴
const [prevMemo, setPrevMemo] = useState(memo);
if (memo !== prevMemo) {
  setPrevMemo(memo);
  setEditedMemo(memo);
}

state 변화를 감지해 다른 state 리셋

// Before
useEffect(() => { setCurrentPage(1); }, [searchTerm]);

// After - 값을 바꾸는 이벤트에서 함께 처리
const handleSearchChange = (value: string) => {
  setSearchTerm(value);
  setCurrentPage(1);
};

남겨 둔 effect

URL 딥링크 값과 비동기로 불러오는 무한 스크롤 데이터를 맞춰 선택 상태를 정하는 effect는 "외부 시스템과의 동기화"에 해당해 유지했다. 폼 값이 사용자 입력뿐 아니라 reset()으로도 바뀌는 경우처럼, 이벤트 핸들러 하나로 모든 변경 경로를 잡을 수 없는 effect도 유지했다.

5. 성능

레이아웃 속성 애니메이션 → transform

height, left, width를 애니메이션하면 매 프레임 레이아웃을 다시 계산한다. transform은 컴포지터에서 처리되어 훨씬 가볍다.

// Before
<m.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} />

// After
<m.div
  initial={{ scaleY: 0, opacity: 0 }}
  animate={{ scaleY: 1, opacity: 1 }}
  exit={{ scaleY: 0, opacity: 0 }}
  className="origin-top overflow-hidden"
/>

다만 두 방식의 결과가 완전히 같지는 않다. scaleY는 내용을 눌러서 보여주고(텍스트가 찌그러져 보임), 요소가 차지하는 레이아웃 공간은 처음부터 최종 크기라 아래 요소가 부드럽게 밀려나지 않는다. 아코디언처럼 아래 콘텐츠가 함께 움직여야 하는 UI라면 height 애니메이션이 맞는 선택일 수 있다. 짧고 작은 영역이었기 때문에 transform으로 바꿨다.

탭 인디케이터는 left/width 대신 x/scaleX를 썼다. 기준 너비를 1px(w-px)로 두고 scaleX에 실제 너비 값을 넣으면 같은 크기로 그려진다.

animate={{ x: indicator.left, scaleX: value ? indicator.width : 0, opacity: value ? 1 : 0 }}
className="h-0.5 w-px origin-left rounded-full bg-primary-600"

여러 번 순회하는 배열 체인

// Before - filter → map → filter, 세 번 순회
const filledParts = slots
  .filter(({ part }) => part !== null)
  .map(({ part }) => ({ hwSeq: parseInt(part?.id ?? '', 10) }))
  .filter(({ hwSeq }) => !Number.isNaN(hwSeq));

// After - 한 번 순회
const filledParts = slots.reduce<{ hwSeq: number }[]>((acc, { part }) => {
  if (!part) return acc;
  const hwSeq = parseInt(part.id, 10);
  if (!Number.isNaN(hwSeq)) acc.push({ hwSeq });
  return acc;
}, []);

배열이 작다면 성능 차이는 미미하고 체인이 더 읽기 쉬울 수 있다. 데이터 크기와 가독성을 함께 보고 판단한다.

6. 유지보수성

300줄 넘는 컴포넌트 → 커스텀 훅

no-giant-component에 걸린 컴포넌트 9개는 모두 상태·effect·핸들러가 한 함수에 몰려 있었다. 로직을 통째로 커스텀 훅으로 옮기고 컴포넌트는 JSX만 그리게 했다. 상태 초기값, 의존성 배열, 핸들러 로직은 한 글자도 바꾸지 않고 옮겨 동작 변화를 막았다.

const InstallTab = ({ pcId, latestTask, taskList }: InstallTabProps) => {
  const { control, register, handleSubmit, handleClickCancel, onSubmit } =
    useInstallTabState(pcId, latestTask, taskList);
  return <>{/* JSX */}</>;
};

boolean prop 4개 → 의미 단위 객체

무한 스크롤 목록 컴포넌트들이 hasNextPage, isFetching, isFetchingNextPage, isPending을 각각 prop으로 받고 있었다. 하나의 개념(쿼리 로딩 상태)이므로 공용 타입으로 묶었다.

type ListPanelProps = { queryState: InfiniteListQueryState };

호출부가 5곳이라 처음에는 위험하다고 미뤘지만, 다시 보니 5곳 모두 같은 패턴이라 안전하게 바꿀 수 있었다. 위험도는 호출부의 개수보다 패턴이 얼마나 다양한지로 판단해야 했다. 여러 useState를 useReducer로 묶는 기준은 별도 글로 정리했다.

7. 접근성과 기타

  • 아이콘만 있는 버튼(페이지네이션 이전·다음, 뒤로 가기)에 aria-label 추가
  • role="button" 요소의 aria-selected → aria-pressed (aria-selected는 option·tab·row 등 선택형 role 전용)
  • next/image의 fill 이미지에 sizes 추가 (없으면 필요보다 큰 이미지를 받음)
  • 생성·수정 mutation에 누락된 쿼리 무효화 추가
  • 렌더 중 ref.current = searchParams 대입 → useEffect로 이동
  • 배열 인덱스 key → 데이터의 고유 필드로 교체 (고유 필드가 없는 정적 목록만 사유와 함께 유지)
  • cva() variants 정의를 별도 모듈로 분리
  • 영어로 모호하게 적힌 ignore 사유(intentional dependency omission)를 구체적인 한국어 문장으로 교체

8. 결과와 교훈

시점이슈점수
시작212개49
최종0개100

타입 검사와 Biome 검사도 모두 통과한 상태로 마무리했다.

가장 시간이 많이 든 일은 이슈를 고치는 것이 아니라 "이게 진짜 문제인가"를 판단하는 것이었다.

  • 정당한 effect를 억지로 없애면 새 버그가 생긴다.
  • 오탐마다 억제 주석을 남발하면 나중에 진짜 문제가 생겨도 묻힌다.
  • 도구 자체의 한계(주석 문법, 파일 단위 휴리스틱)를 모르면 "고쳤다"고 착각한다.

그래서 "억제보다 근본 수정, 판단이 서지 않으면 억제하지 말고 노출"이라는 원칙이 결과적으로 맞았다.