WORK

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

haeunkim.on 2026. 2. 16. 18:09

최근 프로젝트에서 사용하던 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으로 바꾸면서 코드가 크게 달라진 건 아니다.
다만 그동안 그냥 지나쳤던 부분들을 한 번 더 보게 됐다.

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