Biome 마이크레이션 후 린터 룰 적용기

    최근 프로젝트에서 사용하던 ESLint와 Prettier를 제거하고 Biome으로 마이그레이션했다.
    기존에는 린팅과 포맷팅을 위해 11개의 라이브러리를 조합해 사용하고 있었지만, 이를 하나의 도구로 대체하면서 설정과 의존성을 단순화했다.

    이 글에서는 마이그레이션 이후 실제 코드에 린터 룰을 적용하며 구조를 개선했던 사례들을 정리했다.
    공개 가능한 형태로 정리하기 위해, 회사의 비즈니스 로직이 드러날 수 있는 변수와 도메인 용어는 모두 일반화했다.


    1. useExhaustiveDependencies (warn)

    의도
    React Hook의 의존성 배열 누락/과잉을 감지하여 stale closure 버그를 방지한다.


    케이스 A: 누락된 의존성 추가

    문제 코드

    const getItemId = (item) => item[idKey];
    
    const visibleItems = useMemo(
      () =>
        items.filter((it) =>
          !excludedIds.includes(getItemId(it))
        ),
      [items, excludedIds, idKey]
    );

    개선 코드

    const getItemId = useCallback(
      (item) => item[idKey],
      [idKey]
    );
    
    const visibleItems = useMemo(
      () =>
        items.filter((it) =>
          !excludedIds.includes(getItemId(it))
        ),
      [items, excludedIds, getItemId]
    );

    정리

    함수 참조는 매 렌더마다 새로 생성된다.
    따라서 idKey만 deps에 넣는 것으로는 충분하지 않다.
    useCallback으로 함수를 안정화하고 함수 자체를 의존성에 추가해야 한다.


    케이스 B: 불필요한 의존성 제거

    문제 코드

    const paginationInfo = useMemo(
      () => ({
        paginationState,
        setPaginationState,
        manualPagination: false
      }),
      [paginationState, setPaginationState]
    );

    개선 코드

    const paginationInfo = useMemo(
      () => ({
        paginationState,
        setPaginationState,
        manualPagination: false
      }),
      [paginationState]
    );

    정리

    useState의 setter는 React가 참조 안정성을 보장한다.
    따라서 deps에 포함할 필요가 없다.


    케이스 C: 마운트 1회 실행 – biome-ignore

    문제 코드

    useEffect(() => {
      const page = Number(searchParams.get("page") || "1");
    
      if (Number.isFinite(page) && page >= 1) {
        setPaginationState((prev) => ({
          ...prev,
          pageIndex: page - 1
        }));
      }
    }, [searchParams]);

    개선 코드

    // biome-ignore lint/correctness/useExhaustiveDependencies:
    // 마운트 시 URL page 파라미터를 1회만 읽어 초기 페이지를 맞춤
    useEffect(() => {
      const page = Number(searchParams.get("page") || "1");
    
      if (Number.isFinite(page) && page >= 1) {
        setPaginationState((prev) => ({
          ...prev,
          pageIndex: page - 1
        }));
      }
    }, []);

    정리

    이 로직은 마운트 시 1회만 실행되어야 한다.
    searchParams를 deps에 넣으면 URL 변경 시마다 pagination이 리셋되어 버그가 된다.
    이 경우 ignore는 정당하며, 이런 경우 반드시 주석으로 이유를 남긴다.


    케이스 D: 콜백을 useCallback으로 감싸기

    문제 코드

    const applyFilter = (id: string) => {
      filterColumn?.setFilterValue(id || undefined);
      tableInstance.setPage(0);
    };
    
    const actions = useMemo(
      () => [
        {
          onClick: () => applyFilter("")
        }
      ],
      [selectedValue, optionList]
    );

    개선 코드

    const applyFilter = useCallback(
      (id: string) => {
        filterColumn?.setFilterValue(id || undefined);
        tableInstance.setPage(0);
      },
      [filterColumn, tableInstance]
    );
    
    const actions = useMemo(
      () => [
        {
          onClick: () => applyFilter("")
        }
      ],
      [selectedValue, optionList, applyFilter]
    );

    정리

    applyFilter를 deps에 넣으면 actions가 매 렌더마다 재계산된다.
    따라서 applyFilter 자체를 useCallback으로 감싸 의존성 체인을 안정화해야 한다.


    2. noArrayIndexKey (warn)

    의도
    배열 index를 key로 사용하면 재정렬/삽입/삭제 시 잘못된 컴포넌트 재사용이 발생한다.


    케이스 A: 고유 식별자 사용

    문제 코드

    {sections.map((_, idx) => (
      <Section key={idx} />
    ))}

    개선 코드

    {sections.map((section) => (
      <Section key={section.key} />
    ))}

    케이스 B: 고유 식별자가 없는 경우 – 2-pass 패턴

    문제 코드

    {roleList.permissions.map((perm, idx) => (
      <div key={idx}>...</div>
    ))}

    개선 코드

    {roleList.permissions
      .map((perm, idx) => ({
        ...perm,
        _key: perm.name + "-" + idx,
        _actions: perm.actions.map((action, aIdx) => ({
          name: action,
          _key: action + "-" + aIdx
        }))
      }))
      .map((perm) => (
        <div key={perm._key}>
          {perm._actions.map((action) => (
            <span key={action._key}>{action.name}</span>
          ))}
        </div>
      ))}

    정리

    Biome은 map의 index가 key 표현식에 포함되면 경고를 발생시킨다.
    첫 번째 map에서 key를 계산하고, 두 번째 map에서 key만 사용하는 2-pass 패턴으로 우회했다.


    케이스 C: 정적 스켈레톤

    문제 코드

    {[...Array(5)].map((_, i) => (
      <div key={i} style={{ top: (i + 1) * 20 + "%" }} />
    ))}

    개선 코드

    {[1, 2, 3, 4, 5].map((n) => (
      <div key={n} style={{ top: n * 20 + "%" }} />
    ))}

    또는

    {Array.from({ length: 10 }, (_, i) => "skeleton-row-" + i).map(
      (rowKey) => (
        <tr key={rowKey}>...</tr>
      )
    )}

    3. noNonNullAssertion (warn)

    의도
    non-null assertion은 타입 안전성을 무력화한다. 런타임에 null이면 크래시가 발생한다.


    케이스 A: API 응답 타입 분리

    문제 코드

    const res = await http.post<ApiResult<{ url: string; objectKey: string }>>(
      "/uploads/presigned-url",
      { /* ... */ }
    );
    
    const { url, objectKey } = res.data.data!;

    개선 코드

    export interface UploadUrlResult {
      url: string;
      objectKey: string;
    }
    
    const res = await http.post<ApiResult<UploadUrlResult>>(
      "/uploads/presigned-url",
      { /* ... */ }
    );
    
    const { url, objectKey } =
      res.data.data as UploadUrlResult;

    정리

    non-null assertion은 “여기엔 반드시 값이 있다”는 확신을 타입 시스템 밖에서 강제한다.
    공개용 예시에서는 타입을 분리해 의도를 명시하고, 인라인 타입을 줄이는 편이 읽기 좋다.


    케이스 B: Map.get 구조 개선

    문제 코드

    if (!childrenByParent.has(parentId)) {
      childrenByParent.set(parentId, []);
    }
    
    childrenByParent.get(parentId)!.push(childId);

    개선 코드

    const list = childrenByParent.get(parentId) ?? [];
    
    if (!childrenByParent.has(parentId)) {
      childrenByParent.set(parentId, list);
    }
    
    list.push(childId);

    정리

    assertion을 없애려면 “있을 수밖에 없는” 구조를 코드로 표현하는 게 가장 깔끔하다.


    케이스 C: has() 직후 as 단언

    문제 코드

    if (byId.has(rowKey)) {
      nextRows.push(byId.get(rowKey)!);
    }

    개선 코드

    if (byId.has(rowKey)) {
      nextRows.push(byId.get(rowKey) as RowModel);
    }

    정리

    as도 런타임 안전을 보장하진 않지만, 어떤 타입으로 간주하는지 명시적이다.
    읽는 사람이 의도를 더 빠르게 이해할 수 있다.


    Biome으로 바꾸면서 코드가 크게 달라진 건 아니다.
    다만 그동안 그냥 지나쳤던 부분들을 한 번 더 보게 됐다.

    경고를 없애는 것 자체가 목적이라기보다 왜 이런 경고가 나오는지 보면서 의존성과 구조를 다시 정리하는 과정이 꽤 의미 있었던 것 같다.
    결과적으로 코드는 조금 더 명확해졌고, 불필요한 추측도 줄어들었다!

    반응형

    댓글