실무에서 흔히 보이지만 개인적으로 싫어하는 프론트엔드 패턴들
많은 프로젝트에서 당연하게 사용되지만, 개인적으로 피하고 싶은 프론트엔드 패턴들을 실무 경험을 바탕으로 정리합니다.

시작하며
실무에서 흔히 볼 수 있고, 도입 시점에는 합리적으로 보이는 패턴들이 있습니다. 팀 내에서도 자연스럽게 합의되고, 처음에는 코드가 깔끔해진 것처럼 느껴집니다. 하지만 프로젝트가 성장하고 요구사항이 변하면서, 그 패턴이 오히려 변경을 어렵게 만드는 경우를 여러 번 겪었습니다.
이 글에서는 여러 프로젝트를 거치며 개인적으로 싫어하게 된 패턴들을 정리합니다. "이건 틀렸다"는 것이 아닌, 지극히 주관적인 의견입니다.
1. yarn patch
yarn patch는 외부 패키지의 코드를 직접 수정해서 사용할 수 있게 해주는 기능입니다. 라이브러리에 버그가 있거나, 원하는 동작이 살짝 다를 때 빠르게 문제를 해결할 수 있어서 매력적으로 보입니다.
patch 자체가 나쁜 것은 아닙니다. 하지만 저는 patch가 첫 번째 선택지가 되는 것이 문제라고 생각합니다. "라이브러리가 안 되니까 patch 하자"가 너무 쉽게 결정되는 경우를 많이 봤습니다.
patch는 특정 버전의 코드 구조에 의존합니다. 라이브러리가 내부 구현을 바꾸면 patch가 깨지고, 그 패키지는 사실상 버전이 고정됩니다. 한 패키지가 고정되면 그것에 의존하는 다른 패키지들도 연쇄적으로 묶이게 됩니다.
# 처음에는 간단한 한 줄 수정
yarn patch @some-library@2.3.1
# 6개월 후...
# - @some-library 3.0이 나왔지만 patch가 깨져서 업그레이드 불가
# - @some-library에 의존하는 @another-library도 함께 묶임
# - 보안 패치가 나와도 적용할 수 없는 상태실제로 patch가 적용된 프로젝트를 너무 빈번하게 만났고, 개인적으로 검토해보면 충분히 대안이 있는 경우가 대부분이었습니다. 물론 patch 없이는 사용이 어려운 경우도 있을 수 있다고 생각합니다. 하지만 그 시점이라면 그 라이브러리 선택 자체를 다시 생각해볼 신호일 수도 있습니다.
2. Monorepo에서 의존성을 최상위로 올리는 것
Monorepo를 도입하면서 흔히 하는 실수가 있습니다. "어차피 같은 프로젝트니까" 하고 공통 의존성을 최상위 package.json으로 끌어올리는 것입니다. 특히 React를 Web과 React Native에서 함께 사용할 때 이런 패턴을 많이 봅니다.
monorepo/
├── package.json ← React를 여기에 설치
├── packages/
│ ├── web/ ← React 사용
│ └── mobile/ ← React Native에서 React 사용
겉보기에는 중복을 줄인 효율적인 구조입니다. 하지만 React와 React Native는 JSX 문법과 hooks 정도만 공유할 뿐, 렌더링 타겟, 스타일링 방식, 빌드 체인, 생태계가 완전히 다릅니다.
문제는 한쪽이 버전을 올리고 싶을 때 터집니다. 예를 들어 Web에서 React 19의 새로운 기능을 쓰고 싶은데, React Native 쪽이 아직 React 19를 공식 지원하지 않는 상황을 생각해보겠습니다. 최상위에 React가 묶여 있으니 Web도 올릴 수 없습니다. 결국 양쪽 모두 구버전에 갇히게 됩니다.
이것은 yarn patch와 본질적으로 같은 문제입니다. 서로 다른 생명주기를 가진 것들을 하나로 묶으면, 가장 느린 쪽에 전체가 종속됩니다.
monorepo/
├── package.json ← 공통 빌드 설정, 린트 규칙 정도만
├── packages/
│ ├── web/
│ │ └── package.json ← React 19, Web 전용 의존성
│ ├── mobile/
│ │ └── package.json ← React 18, RN 전용 의존성
│ └── shared/
│ └── package.json ← 순수 로직, 타입 정의만
플랫폼이 다르면 의존성도 분리해야 합니다. 진짜 공유할 수 있는 것은 순수 로직과 타입 정의 정도입니다. 컴포넌트까지 공유할 수 있을 거라는 기대는 대부분 환상으로 끝납니다.
3. 역할별 폴더 구조
프로젝트 초기에 가장 많이 보이는 구조가 있습니다.
src/
├── hooks/
│ ├── useAuth.ts
│ ├── useCart.ts
│ ├── useProduct.ts
│ ├── useOrder.ts
│ └── ... (50개 이상)
├── constants/
│ ├── auth.ts
│ ├── cart.ts
│ ├── product.ts
│ └── ...
├── utils/
│ ├── formatPrice.ts
│ ├── validateEmail.ts
│ ├── parseToken.ts
│ └── ...
├── types/
│ ├── auth.ts
│ ├── cart.ts
│ └── ...
"hook이니까 hooks 폴더에", "상수니까 constants 폴더에". 역할별로 분류하면 직관적으로 보입니다. 하지만 프로젝트가 커지면 이 구조는 빠르게 무너집니다.
장바구니 기능 하나를 파악하려면 hooks/useCart.ts, constants/cart.ts, utils/formatPrice.ts, types/cart.ts를 네 군데서 찾아야 합니다. 장바구니 기능을 삭제하고 싶을 때는 더 심각합니다. utils/formatPrice.ts가 장바구니에서만 쓰이는 건지, 주문 쪽에서도 쓰이는 건지 일일이 확인해야 합니다.
기능(feature) 기준으로 묶으면 이 문제가 사라집니다.
src/
├── features/
│ ├── auth/
│ │ ├── useAuth.ts
│ │ ├── constants.ts
│ │ ├── types.ts
│ │ └── validateEmail.ts
│ ├── cart/
│ │ ├── useCart.ts
│ │ ├── constants.ts
│ │ ├── types.ts
│ │ └── formatPrice.ts
│ └── order/
│ ├── useOrder.ts
│ └── types.ts
관련된 코드가 한 곳에 모여 있으니 기능을 파악하기 쉽고, 삭제할 때도 폴더째 지우면 됩니다. 이것이 Colocation 원칙입니다. 함께 변경되는 코드는 함께 배치해야 합니다.
4. 과도한 공통 컴포넌트화
"이 두 컴포넌트 비슷하게 생겼는데, 하나로 합치면 되겠다."
이 판단이 처음에는 맞을 수 있습니다. 하지만 기획이 한두 번 바뀌면서 props가 하나씩 추가되기 시작합니다.
// 처음에는 깔끔했던 공통 카드 컴포넌트
<Card title="..." description="..." />
// 6개월 후...
<Card
title="..."
description="..."
variant="compact"
showImage={true}
imagePosition="left"
showBadge={true}
badgeText="NEW"
showFooter={false}
showAuthor={true}
authorPosition="bottom"
onClick={handleClick}
onHover={handleHover}
isHighlighted={isNew}
highlightColor="blue"
maxLines={3}
truncateDescription={true}
actionButton={<Button>...</Button>}
secondaryAction={<Link>...</Link>}
// ... props 20개 이상
/>props가 늘어날 때마다 컴포넌트 내부에는 조건부 렌더링이 쌓입니다. if (variant === 'compact' && showImage && imagePosition === 'left') 같은 조건문이 난무하게 되고, 하나를 수정하면 다른 variant가 깨지는 상황이 반복됩니다.
형태가 비슷하다고 해서 같은 컴포넌트일 필요는 없습니다. 중요한 것은 형태가 아니라 맥락입니다. 상품 목록의 카드와 마이페이지의 카드는 비슷하게 보여도, 변경되는 이유와 시점이 다릅니다. 이런 것들을 하나로 합치면 한쪽의 변경이 다른 쪽에 영향을 주는 커플링이 생깁니다.
복사-붙여넣기를 두려워하지 않아야 합니다. 저는 컴포넌트 통합의 기준을 코드가 아니라 디자인 시스템(Figma) 에 둡니다. 기획적으로 동일한 컴포넌트로 정의된 것이 아니라면, 형태가 90% 같더라도 각자의 맥락에서 독립적으로 존재하게 둡니다. 세 줄의 중복 코드가 props 20개짜리 추상화보다 낫습니다.
5. 무분별한 Barrel Files
모든 폴더에 index.ts를 두고 내부 모듈을 re-export 하는 패턴입니다.
// features/auth/index.ts
export { useAuth } from "./useAuth"
export { AuthProvider } from "./AuthProvider"
export { LoginForm } from "./LoginForm"
export { SignUpForm } from "./SignUpForm"
export type { User, AuthState } from "./types"
// 사용하는 쪽
import { useAuth, LoginForm } from "@/features/auth"경로가 짧아지니 깔끔해 보입니다. 하지만 이 패턴에는 세 가지 실질적인 문제가 있습니다.
첫째, 순환 참조의 온상입니다. features/auth/index.ts가 features/user/index.ts를 참조하고, features/user/index.ts가 다시 features/auth/index.ts를 참조하면 순환이 발생합니다. barrel file은 폴더의 모든 모듈을 하나로 묶기 때문에, 직접적으로 관련 없는 모듈 간에도 순환 참조가 생길 수 있습니다.
둘째, 개발 환경의 성능에 영향을 줍니다. LoginForm만 필요한데 import { LoginForm } from "@/features/auth"로 가져오면, 번들러에 따라 SignUpForm, AuthProvider 등도 함께 평가될 수 있습니다. 프로덕션 빌드에서는 트리 셰이킹으로 차이가 없다고 이야기하는 경우가 많지만, 개발 환경에서는 불필요한 모듈 평가가 HMR 속도와 초기 로딩에 분명한 차이를 만듭니다. 이 부분은 별도 포스트에서 자세히 다룰 예정입니다.
셋째, 에디터에서 "Go to Definition"이 한 단계 더 거칩니다. useAuth의 정의로 이동하면 index.ts의 re-export 줄로 가게 되고, 거기서 다시 한번 이동해야 실제 구현에 도달합니다. 작은 불편함이지만, 하루에 수십 번 반복되면 생산성에 영향을 줍니다.
// barrel file 대신 직접 import
import { useAuth } from "@/features/auth/useAuth"
import { LoginForm } from "@/features/auth/LoginForm"경로가 조금 길어지는 대신, 순환 참조 위험이 줄고, 번들이 최적화되고, 코드 네비게이션이 정확해집니다. 외부에 공개할 인터페이스가 명확한 라이브러리를 만드는 경우가 아니라면, barrel file은 득보다 실이 큽니다.
6. 조금이라도 명확한 방법이 있다면
안티패턴 이야기와는 조금 결이 다르지만, 개인적으로 선호하는 컨벤션 몇 가지를 덧붙입니다. 공통점은 조금이라도 더 명확한 방법이 있다면 그쪽을 선택한다는 것입니다.
kebab-case 파일 네이밍
파일과 폴더 이름에 kebab-case를 사용합니다. UserProfile.tsx 대신 user-profile.tsx로 작성합니다.
macOS는 파일명 대소문자를 구분하지 않지만 Linux는 구분합니다. UserProfile.tsx를 userProfile.tsx로 변경하면, macOS에서는 같은 파일로 인식하지만 Linux CI 환경에서는 다른 파일이 되어 빌드가 깨집니다. git도 파일명 대소문자 변경을 자연스럽게 추적하지 못합니다. kebab-case를 쓰면 이 문제가 원천적으로 사라집니다. 터미널에서 cd나 rm을 할 때도 대소문자를 신경 쓸 필요가 없어 CLI 환경에서도 쾌적합니다.
가독성 측면에서도 이점이 있습니다. 모든 문자가 소문자이기 때문에 l(소문자 L)과 I(대문자 i), 1(숫자) 같은 헷갈리는 문자들 사이에서 혼동이 줄어듭니다. UserProfileList.tsx보다 user-profile-list.tsx가 한눈에 읽힙니다.
아이콘 import 시 Icon 접미사 강제
// 이것이 아이콘인지 컴포넌트인지 한눈에 알기 어려움
import { Loader2, ChevronRight, Eye } from "lucide-react"
// 아이콘임이 명확함
import { Loader2Icon, ChevronRightIcon, EyeIcon } from "lucide-react"Eye만 보면 이것이 아이콘인지 일반 컴포넌트인지 맥락 없이는 알 수 없습니다. EyeIcon은 읽는 즉시 아이콘이라는 것을 알 수 있습니다.
파일명과 함수명을 충분히 길게
// 짧지만 모호함
const fmt = (d: Date) => ...
const handleClick = () => ...
// 길지만 명확함
const formatDateToKorean = (date: Date) => ...
const handlePostListItemClick = () => ...짧은 이름은 작성할 때는 편하지만, 읽을 때는 매번 문맥을 파악해야 합니다. 이름이 길어지더라도 그 자체로 의미가 전달되는 쪽이 유지보수에 유리합니다. 코드는 쓰는 시간보다 읽는 시간이 압도적으로 많기 때문입니다.
마치며
위에서 언급한 패턴들은 모두 도입하는 시점에는 모든 것이 합리적으로 보이고, 코드가 더 정돈된 것처럼 느껴지기까지 합니다.
하지만 이런 선택들이 만드는 보이지 않는 커플링은 프로젝트가 성장할수록 감당할 수 없는 비용으로 돌아옵니다. 결국 나중에는 코드 한 줄을 고치기 위해 수십 개의 파일을 뒤지거나, 라이브러리 버전 하나를 올리지 못해 보안 취약점을 방치하는 상황에 직면하게 됩니다.
진짜 좋은 설계는 당장의 타이핑 횟수를 줄이는 것이 아닙니다. 6개월 뒤 요구사항이 바뀌었을 때 어디를 고쳐야 할지 명확하고, 그 수정이 엉뚱한 곳을 터뜨리지 않는 구조가 진짜 좋은 설계입니다.
코드의 미학(Aesthetics)보다 변경의 용이성(Maintainability) 에 더 무게를 두는 것. 그것이 제가 여러 프로젝트를 거치며 내린 개인적인 결론입니다.