최근 프로젝트에서 사용하던 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으로 바꾸면서 코드가 크게 달라진 건 아니다.
다만 그동안 그냥 지나쳤던 부분들을 한 번 더 보게 됐다.
경고를 없애는 것 자체가 목적이라기보다 왜 이런 경고가 나오는지 보면서 의존성과 구조를 다시 정리하는 과정이 꽤 의미 있었던 것 같다.
결과적으로 코드는 조금 더 명확해졌고, 불필요한 추측도 줄어들었다!
'WORK' 카테고리의 다른 글
| 네이버랩스 Robot Web App Developer 합격 후기 (서류~최종면접) (0) | 2025.10.14 |
|---|
댓글