← posts/b.log()

blog92@web:~$ cat posts/frontend-antipatterns-1-how-to-see.md

FRONTEND8 min read

프론트엔드 안티패턴 (1) — 안티패턴을 보는 눈: 비용 곡선과 되돌리기 비용

안티패턴은 실수가 아니라 구조다. 비용 곡선, 되돌리기 비용, 지연된 비용이라는 세 축으로 판단하는 법과, 지연된 비용을 즉시 아프게 만드는 측정 장치.

안티패턴은 실수가 아니다

실수는 개인적이고 일회적입니다. 오타, 부호 반대, 잘못된 변수명. 코드 리뷰에서 잡히고, 한 번 고치면 끝납니다.

안티패턴은 다릅니다. 서로 모르는 회사의 서로 모르는 팀이 같은 형태의 코드를 쓰게 되는 데는 이유가 있습니다. 그 이유를 이해하지 않고 "이렇게 하지 마세요" 목록만 외우면, 다음 프로젝트에서 또 같은 코드를 씁니다. 그 순간에는 그게 가장 짧은 길로 보이기 때문입니다.

그래서 안티패턴의 정의에는 조건이 네 개 붙습니다.

  1. 흔하다 — 반복적으로 발견된다
  2. 처음엔 합리적으로 보인다 — 국소적으로는 옳은 선택이다
  3. 시간이 지나면 비용이 불균형하게 커진다
  4. 더 나은 대안이 알려져 있다

2번이 핵심입니다. 처음부터 나빠 보이는 코드는 안티패턴이 아니라 그냥 나쁜 코드입니다. 안티패턴은 유혹적이어야 합니다.


프론트엔드에서 안티패턴이 유독 잘 자라는 세 가지 이유

(1) 비용이 개발 환경에서는 보이지 않는다

백엔드는 부하 테스트를 하면 느려지는 게 숫자로 나옵니다. 프론트엔드는 최신 개발 장비, 고속 유선 네트워크, 캐시가 데워진 상태에서 언제나 빠릅니다.

실제 사용자 환경과의 격차는 작지 않습니다. 중급 모바일 기기의 JavaScript 실행 성능은 개발용 노트북의 5분의 1에서 10분의 1 수준이고, 모바일 네트워크의 왕복 지연은 사무실 유선의 10배가 넘습니다. 여기에 브라우저 확장, 백그라운드 탭, 배터리 절약 모드가 겹칩니다.

결과적으로 프론트엔드에는 "느려진 걸 팀 전체가 모르는 채로 1년이 지나는" 현상이 생깁니다. 서버 쪽에서는 응답 시간 대시보드가 있어서 이런 일이 잘 없습니다.

(2) 실패가 예외를 던지지 않는다

프론트엔드의 많은 실패는 조용합니다.

  • catch로 삼킨 에러 — 사용자는 성공한 줄 압니다
  • 스크린 리더 사용자가 버튼을 누를 수 없음 — 아무 로그도 남지 않습니다
  • 레이아웃이 200ms 밀림 — 측정하지 않으면 모릅니다
  • 번들이 매 스프린트 30KB씩 증가 — 누적되어야 체감됩니다

예외를 던지지 않는 실패는 측정 장치를 따로 붙이지 않으면 영원히 발견되지 않습니다. 이 편의 후반부가 그 장치에 대한 이야기입니다.

(3) 삭제가 어렵다

서버 코드는 접근 로그로 "이 엔드포인트 6개월째 호출 0회"를 확인하고 지울 수 있습니다. 프론트엔드 컴포넌트는 어디서 쓰이는지 정적으로 확신하기 어렵고, 지웠을 때 어느 화면이 깨질지 예측하기 어렵습니다.

그래서 안 지웁니다. 안 지우면 쌓이고, 쌓이면 다음 사람이 그중 하나를 복사합니다. 안티패턴이 확산되는 주된 경로가 이것입니다.


판단 축 1 — 비용 곡선이 선형인가 지수인가

같은 "나쁜 결정"이라도 비용이 늘어나는 모양이 다릅니다. 이게 가장 중요한 구분입니다.

선형 비용

text
중복된 코드 3곳 → 고칠 곳 3곳
중복된 코드 10곳 → 고칠 곳 10곳

성가시지만 예측 가능하고 감당 가능합니다. 그리고 결정적으로, 언제든 멈출 수 있습니다. 10곳까지 갔다가 "이제 추상화하자"고 판단하면 그 시점에 정리하면 됩니다.

지수 비용

text
잘못된 추상화 + 사용처 3곳 → 조건 분기 3개
잘못된 추상화 + 사용처 10곳 → 조건 분기 10개, 그 조합 확인 필요
잘못된 추상화 + 사용처 30곳 → 아무도 건드리지 않음

임계점을 넘으면 되돌릴 수 없습니다. "이 컴포넌트는 손대면 안 돼"라는 합의가 팀에 생기는 순간이 그 임계점입니다.

실용 규칙

불확실할 때는 선형 비용을 선택하라.

중복을 남겨두는 것이 잘못된 추상화보다 거의 언제나 쌉니다. Sandi Metz의 유명한 문장이 이걸 정확히 말합니다.

"duplication is far cheaper than the wrong abstraction." (중복은 잘못된 추상화보다 훨씬 싸다)

3편에서 이 원칙을 컴포넌트 설계에 구체적으로 적용합니다.


판단 축 2 — 되돌릴 수 있는가

모든 결정이 같은 무게를 갖지 않습니다. 일방통행 문과 양방향 문을 구분해야 합니다.

양방향 문 — 빨리 결정하고, 틀리면 되돌린다

결정되돌리는 비용
상태 관리 라이브러리 (Zustand ↔ Jotai)수일
CSS 방법론 (Tailwind ↔ CSS Modules)수주, 점진적 가능
폼 라이브러리수일
컴포넌트 내부 구현즉시

이런 결정에 2주를 쓰는 회의는 낭비입니다. 틀리는 비용보다 결정을 미루는 비용이 큽니다.

일방통행 문 — 늦게 결정하고, 최대한 신중하게

결정되돌리는 비용
URL 구조외부 링크·북마크·SEO가 전부 깨짐
공개 API / 컴포넌트 라이브러리의 prop 계약모든 소비자가 함께 바뀌어야 함
데이터 모델 (특히 ID 체계)마이그레이션 + 하위 호환 코드 영구 잔존
인증 방식전 사용자 재로그인, 보안 리뷰 재실시
렌더링 전략 (SSR ↔ SPA)아키텍처 전면 재작성

이 목록에 있는 것들은 안티패턴이 되면 영구적입니다. 그래서 여기는 시간을 써야 합니다.

판별 질문

이 결정이 틀렸다는 걸 6개월 뒤에 알았다면, 고치는 데 며칠이 드는가?

3일 이내면 양방향 문입니다. 지금 결정하고 넘어가세요. 한 달 이상이면 일방통행 문입니다. 프로토타입을 만들어서 검증하고 결정하세요.


판단 축 3 — 비용이 언제 나타나는가

이게 프론트엔드에서 특히 중요합니다.

즉시 아픈 비용 — 알아서 고쳐진다

타입 에러, 빌드 실패, 테스트 실패, 런타임 크래시. 이런 것들은 안티패턴이 되기 어렵습니다. 아프니까 바로 고칩니다.

지연된 비용 — 여기가 위험하다

항목언제 아픈가
번들 크기 증가6개월 누적 후, 사용자 이탈률로
접근성 결함민원, 감사, 또는 법적 요구 시
잘못된 추상화사용처가 임계점을 넘었을 때
테스트 부재대규모 리팩터링이 필요해졌을 때
문서 없는 "임시" 코드작성자가 퇴사한 뒤
성능 저하아무도 모르는 채로 계속

전략은 하나입니다. 지연된 비용을 즉시 아프게 만드는 것.


지연된 비용을 즉시 아프게 만드는 장치들

문서와 코드 리뷰로는 안 됩니다. 사람은 놓치고, 바쁘면 통과시킵니다. CI를 빨갛게 만드는 것만 실효가 있습니다.

1. 번들 크기 예산

yaml
# .github/workflows/ci.yml
- name: 번들 크기 검사
  run: npx size-limit
json
// .size-limit.json
[
  {
    "name": "초기 진입 번들",
    "path": ".next/static/chunks/main-*.js",
    "limit": "180 KB"
  },
  {
    "name": "공용 프레임워크 청크",
    "path": ".next/static/chunks/framework-*.js",
    "limit": "140 KB"
  }
]

한도를 현재 크기보다 약간 높게 잡고 시작하세요. 그러면 새로 들어오는 무거운 의존성만 걸립니다. 처음부터 이상적인 숫자를 잡으면 기존 코드가 다 걸려서 결국 검사를 끄게 됩니다.

2. 접근성 자동 검사

javascript
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
 
export default [
  {
    plugins: { 'jsx-a11y': jsxA11y },
    rules: {
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/anchor-is-valid': 'error',
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-static-element-interactions': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
    },
  },
];

린터가 잡는 건 접근성 문제의 30~40% 정도입니다. 나머지는 포커스 순서, 컨텍스트, 스크린 리더 흐름처럼 기계가 판단할 수 없는 영역이라 수동 검사가 필요합니다. 그래도 자동으로 잡히는 것을 자동으로 잡는 것이 출발점입니다.

3. 성능 회귀 감시

yaml
- name: Lighthouse CI
  uses: treosh/lighthouse-ci-action@v11
  with:
    urls: |
      https://preview-${{ github.event.number }}.example.com/
      https://preview-${{ github.event.number }}.example.com/dashboard
    budgetPath: ./lighthouse-budget.json
json
// lighthouse-budget.json
[{
  "path": "/*",
  "timings": [
    { "metric": "largest-contentful-paint", "budget": 2500 },
    { "metric": "cumulative-layout-shift", "budget": 0.1 },
    { "metric": "total-blocking-time", "budget": 300 }
  ],
  "resourceSizes": [
    { "resourceType": "script", "budget": 350 },
    { "resourceType": "image", "budget": 500 }
  ]
}]

실험실 측정(Lighthouse)만으로는 부족합니다. 실제 사용자 지표(RUM)도 함께 수집해야 합니다.

typescript
// app/web-vitals.ts
'use client';
import { useReportWebVitals } from 'next/web-vitals';
 
export function WebVitals() {
  useReportWebVitals((metric) => {
    // 실제 사용자 기기에서의 측정값
    navigator.sendBeacon('/api/vitals', JSON.stringify({
      name: metric.name,       // LCP, CLS, INP, FCP, TTFB
      value: metric.value,
      rating: metric.rating,   // good | needs-improvement | poor
      path: window.location.pathname,
    }));
  });
  return null;
}

실험실 수치가 좋은데 사용자가 느리다고 하면, 실험실 조건이 현실과 다른 것입니다. 그 격차 자체가 정보입니다.

4. 의존성 그래프 검사

javascript
// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    { name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
    {
      name: 'no-orphans',
      severity: 'warn',
      comment: '아무도 참조하지 않는 모듈 — 삭제 후보',
      from: { orphan: true, pathNot: ['\\.d\\.ts$', '(^|/)app/'] },
      to: {},
    },
  ],
};

앞서 말한 "삭제가 어렵다"는 문제를 정면으로 겨냥합니다. 고아 모듈 목록이 있으면 삭제할 수 있습니다.

5. 타입 안전성 회귀 방지

json
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
javascript
// any의 확산을 막는다
rules: {
  '@typescript-eslint/no-explicit-any': 'error',
  '@typescript-eslint/no-unsafe-assignment': 'error',
  '@typescript-eslint/no-unsafe-member-access': 'error',
}

8편에서 as와 any가 만드는 거짓 안정감을 다룹니다.


판단 축 4 — "왜"에 답할 수 있는가

기술적 지표로 잡히지 않는 마지막 축입니다.

typescript
useEffect(() => {
  setTimeout(() => { scrollToTop(); }, 0);   // 왜 0ms 타임아웃이?
}, [page]);

이 코드에 대해 팀의 누구도 "왜"에 답하지 못한다면, 그 코드는 이미 아무도 건드릴 수 없는 상태입니다. 지우면 뭐가 깨질지 모르니 남겨두고, 남겨두니 다음 사람도 모릅니다.

코드베이스의 건강은 "왜"에 답할 수 있는 코드의 비율로 측정할 수 있습니다.

그래서 이런 코드에는 주석이 필요합니다. 무엇을 하는지가 아니라 왜 필요한지를 적는 주석입니다.

typescript
useEffect(() => {
  // 라우트 전환 직후 브라우저가 이전 스크롤 위치를 복원하는 것과 경쟁한다.
  // 매크로태스크로 미뤄서 복원 이후에 실행되도록 함. (이슈 #412)
  const id = setTimeout(scrollToTop, 0);
  return () => clearTimeout(id);
}, [page]);

이제 다음 사람이 지울지 말지 판단할 수 있습니다. 브라우저 동작이 바뀌었는지 확인만 하면 되니까요.


이 시리즈가 다루지 않는 것

Magic Number, God Object, Long Method, Feature Envy, Shotgun Surgery 같은 범용 코드 스멜은 다루지 않습니다. 축이 다르기 때문입니다.

범용 코드 스멜프론트엔드 안티패턴
원인설계 실패환경의 제약
언어 의존성없음브라우저·JS 런타임 전제
서버 코드에도?그대로 나타남존재할 수 없음
해법추출, 이동, 캡슐화환경 특성에 맞는 구조 변경

다만 범용 스멜이 프론트엔드에서 특이한 모양으로 변형되는 지점은 짚고 갑니다.

범용 스멜프론트엔드 변형다루는 편
God ObjectGod Component — 700줄짜리 페이지 컴포넌트3편
Feature Envy자식이 부모 상태를 계속 캐묻는 구조2편
Primitive Obsessionstring ID, boolean 4개 상태 머신2·8편
Shotgun Surgery토큰 없이 색 바꾸기, prop 하나에 파일 12개6·7편
Cargo Cult대형 서비스가 쓴다는 이유로 도입한 스택9편

요약

안티패턴의 네 조건

  1. 흔하다 / 2. 처음엔 합리적이다 / 3. 비용이 불균형하게 커진다 / 4. 대안이 있다

네 가지 판단 축

축질문실용 규칙
비용 곡선선형인가 지수인가불확실하면 선형을 택하라
되돌리기6개월 뒤 고치는 데 며칠 드는가3일 이내면 지금 결정하라
비용의 시점즉시 아픈가 지연되는가지연된 비용에 측정 장치를 붙여라
설명 가능성왜 이렇게 됐는지 아는가모르면 주석이 아니라 조사가 필요하다

즉시 할 수 있는 것 — 측정 장치 다섯 개

번들 크기 예산 / 접근성 린트 / Lighthouse + RUM / 의존성 순환 검사 / strict 타입

이 다섯 개를 CI에 넣는 데 하루가 걸리지 않습니다. 그리고 이후의 모든 편에서 다룰 안티패턴의 상당수가 이 장치들에 걸려서 애초에 들어오지 못합니다.


다음 편

2편 — 상태와 데이터 흐름의 안티패턴

프론트엔드에서 피해가 가장 큰 영역입니다. useEffect의 네 가지 오용을 하나씩 해부하고, 왜 사람들이 계속 그렇게 쓰게 되는지(생명주기 멘탈 모델의 잔재)를 밝힙니다. 파생 상태, 성급한 전역화, boolean 네 개로 만든 상태 머신까지.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..