hongsoohyuk
HomeGuestbookProjectInstagramBlogAI Chat

© 2026 hongsoohyuk. All rights reserved.

GitHubLinkedIn
Blog/32만줄 레거시 프로젝트 코드 품질 부검 — 안티패턴 사례집

32만줄 레거시 프로젝트 코드 품질 부검 — 안티패턴 사례집

FrontendSoftware Architecture

Last edited: March 16, 2026

개요

32만줄 규모의 레거시 React 프로젝트를 분석하면서 발견한 소프트웨어 원칙 위반 사례를 정리했다. "그냥 코드가 더럽다"가 아니라, 어떤 원칙을 어떻게 위반하고 있는지 정량적 지표와 코드 예시로 증명하는 것이 목적이다.

배경

대상 프로젝트의 정량적 프로필:

  • 전체 규모: 835개 파일, 317,971줄
  • 500줄 초과 파일: 197개 (23.6%)
  • 1,000줄 초과 파일: 79개 (9.5%)
  • 2,000줄 초과 파일: 19개
  • 5,000줄 초과 파일: 2개
  • 테스트 커버리지: 0% (실질 테스트 없음)
  • ErrorBoundary: 0개

아래 모든 코드 예시는 실제 코드의 구조적 패턴만 추출한 것으로, 비즈니스 로직은 제거되었다.

1. 단일 책임 원칙 (SRP) 위반

1-1. God Component — 1파일 14컴포넌트, 5,236줄

Dialog 기능을 담당하는 한 파일이 14개의 컴포넌트를 포함하고 있었다. 그 중 가장 큰 컴포넌트 하나만 1,805줄이었다.

javascript
// dialog.jsx — 5,236줄, 14개 컴포넌트

export const AddItemDialog = ({ open, onClose, initialValues }) => {
  // useState 13개
  const [isLoading, setIsLoading] = useState(false);
  const [fileErrors, setFileErrors] = useState([]);
  const [actionErrors, setActionErrors] = useState(null);
  const [windowSize, setWindowSize] = useState({ width: window?.innerWidth });
  const [downloadFiles, setDownloadFiles] = useState([]);
  const [detailsDownloadFiles, setDetailsDownloadFiles] = useState([]);
  const [cloneData, setCloneData] = useState(null);
  const [cloneItem, setCloneItem] = useState(null);
  const [isEdit, setIsEdit] = useState(false);
  const [q, setQ] = useState('');
  const [target, setTarget] = useState(null);
  const [addedItems, setAddedItems] = useState([]);
  const [selectedFiles, setSelectedFiles] = useState([]);

  // useEffect 4개
  useEffect(() => { /* 데이터 동기화 */ }, [open, initialValues]);
  useEffect(() => { /* 리사이즈 리스너 */ }, [open]);
  useEffect(() => { /* 클린업 */ }, []);
  useEffect(() => { /* 디바운스 */ }, [open]);

  // API 호출 7개 — 컴포넌트 안에서 직접
  const submitMutation = useSubmitMutation();
  const fileUploadMutation = useFileUploadMutation();
  const { data: details } = useGetDetailsQuery(id);
  const { data: listData } = useInfiniteGetListQuery(q);
  const { data: targetDetails } = useGetTargetDetailsQuery(targetId);
  const { data: downloadData } = useGetDownloadFiles(id);
  const { data: items } = useGetItems(id);

  // 이벤트 핸들러 17개
  const handleCloseDialog = useCallback(() => { /* ... */ }, []);
  const handleSearch = useCallback(() => { /* ... */ }, []);
  const handleSelectTarget = useCallback(() => { /* ... */ }, []);
  const handleSetFile = useCallback(() => { /* ... */ }, []);
  const handleSubmit = useCallback(() => { /* ... */ }, []);
  const handleResize = useCallback(() => { /* ... */ }, []);
  // ... +11개 더

  // JSX 1,200줄 + 인라인 스타일 136개
  return (
    <Dialog fullScreen open={open}>
      <Box style={{ paddingTop: 40, paddingLeft: 40, paddingRight: 40 }}>
        <Grid style={{ width: '60%', backgroundColor: theme?.palette?.primary[100] }}>
          {/* ... 1,200줄의 중첩된 JSX ... */}
        </Grid>
      </Box>
    </Dialog>
  );
};
// 이것이 "하나의" 컴포넌트 (1,805줄)

export const CsvUploadDialog = () => { /* 821줄 */ };
export const AutomationDialog = () => { /* 312줄 */ };
export const ConnectDialog = () => { /* 411줄 */ };
export const DeleteDialog = () => { /* 89줄 */ };
// ... 총 14개

위반: 한 파일이 UI 렌더링 + 폼 상태 관리 + API 호출 + 비즈니스 로직 + 스타일링 + 에러 처리 + 윈도우 리사이즈를 전부 담당.

1-2. God Utility — 109개 export, 16개 도메인이 뒤섞인 유틸 파일

1,688줄의 유틸리티 파일이 날짜, 문자열, 색상, 트리 탐색, 세션 스토리지, CSV 파싱, JSX 렌더링, 라우팅, 검증까지 한번에 담당하고 있었다.

javascript
// helper.js — 1,688줄, 109개 export

// === 날짜 포맷팅 ===
export const formatDate = (date) => moment(date).format('YYYY.MM.DD');
export const formatDateTime = (date) => moment(date).format('YYYY.MM.DD HH:mm:ss');

// === 갑자기 JSX가 등장 (React 의존성) ===
export const CurrencySymbol = (value) => (
  <span style={{ fontSize: 11 }}>{value}</span>
);

// === 6개의 구조적으로 동일한 상태 변환 함수 ===
export const translateUserRole = (status) => {
  const list = [
    { status: 'OWNER', t: 'owner' },
    { status: 'ADMIN', t: 'admin' },
  ];
  return fp.pipe(fp.filter({ status }), fp.head, fp.get('t'))(list);
};
export const translateItemState = (status) => {
  const list = [
    { status: 'ACTIVE', t: 'active' },
    { status: 'INACTIVE', t: 'inactive' },
  ];
  return fp.pipe(fp.filter({ status }), fp.head, fp.get('t'))(list);
  // 동일 패턴 6번 반복. 제네릭 함수 하나면 충분.
};

// === 세션 스토리지 (갑자기 브라우저 API) ===
export const setSessionStorage = ({ name, value }) =>
  sessionStorage.setItem(name, JSON.stringify(value));

// === 재귀 트리 탐색 (갑자기 자료구조) ===
export const recursionCostParent = (data, target) => { /* orgUnitId 기준 */ };
export const recursionParent = (data, target) => { /* id 기준 */ };
// 프로퍼티명만 다른 거의 동일한 함수 2개

// === 이름 오타 + 단위 오류 ===
export const hoursToSecounds = (hours) => hours * 60 * 60 * 1000;
// "Secounds" 오타, 실제론 밀리초 반환

// === 네비게이션 + 토스트 (2개의 사이드이펙트) ===
export const goToErrorPage = (error, history, showToast) => {
  if (error.status === 403) history.push('/error/403');
  else showToast(error.message);  // SRP 위반
};

2. DRY 원칙 위반

2-1. handleSearch — 100+개 파일에서 복붙

동일한 로직의 두 가지 변형이 100개 이상의 파일에 복붙되어 있었다.

javascript
// Variant A — 36개 파일에서 발견
const handleSearch = useCallback((e) => {
  const { key, target: { value } } = e;
  if (fp.isEqual('Enter', key)) {
    setQ(value);
    setPage(0);
  }
}, []);

// Variant B — 29개 파일에서 발견
const handleSearch = useCallback((e) => {
  if (e.key !== 'Enter') return false;
  const value = fp.get('target.value', e);
  setResultKeyword(value);
}, []);

// 있어야 할 모습:
const useEnterSearch = (setter, pageSetter) => {
  return useCallback((e) => {
    if (e.key !== 'Enter') return;
    setter(e.target.value);
    pageSetter?.(0);
  }, [setter, pageSetter]);
};

2-2. handleInvalidateQueries — 59개 파일에서 복붙

javascript
// 59개 파일에서 거의 동일한 코드
const handleInvalidateQueries = useCallback(() => {
  queryClient.invalidateQueries(QUERY_KEY_1);
  queryClient.invalidateQueries(QUERY_KEY_2);
}, [queryClient]);

// 있어야 할 모습:
const useInvalidateQueries = (...queryKeys) => {
  const queryClient = useQueryClient();
  return useCallback(() => {
    queryKeys.forEach(key => queryClient.invalidateQueries(key));
  }, [queryClient, queryKeys]);
};

2-3. 디렉토리 통째로 복붙 — ~70% 동일한 2개 모듈

javascript
src/pages/groups/                    src/pages/workspaces/groups/
├── index.jsx          (487줄)       ├── index.jsx          (419줄)
├── details/index.jsx  (428줄)       ├── details/index.jsx  (446줄)
├── details/users.jsx  (516줄)       ├── details/users.jsx  (375줄)
├── add/users.jsx      (560줄)       ├── add/users.jsx      (578줄)
└── details/apps.jsx   (8줄)         └── details/apps.jsx   (8줄) ← 100% 동일

차이점은 거의 import 경로와 변수명뿐:

javascript
// src/pages/groups/index.jsx
import { getGroups } from '@reducers/group';
const GroupList = () => { /* ... */ };

// src/pages/workspaces/groups/index.jsx (70% 동일)
import { getWorkspacesGroups } from '@reducers/workspace';
const WorkspaceGroupList = () => { /* 거의 같은 코드 */ };

3. 계층 구조 (Layer Architecture) 위반

3-1. Component 계층이 Feature 계층을 import (역방향 의존성)

올바른 의존성 방향은 pages → features → components이다. 실제로는 component가 feature를 import하는 역방향 의존성이 11곳에서 발견되었다.

javascript
// src/components/layout/default.jsx
import { LnbDrawer } from '@features/lnb';
import { DetailDrawer } from '@features/drawer';

// src/components/list/index.jsx
import { AssignType } from '@features/apps/assign-type';
import { IntegrationBadge } from '@features/integrations';

// src/components/skeleton/details.jsx
import { LicenseTab } from '@features/legacy-license';
import { FlowEditor } from '@features/flows';

3-2. Presentation 계층에서 Redux dispatch

javascript
// src/components/catalog/add/authentication.jsx
import { useDispatch } from 'react-redux';
import { getAppsMetaData } from '@reducers/app';
import { setCreateApp } from '@reducers/session';

const AuthenticationStep = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(getAppsMetaData(params));
  }, []);
  const handleSubmit = () => {
    dispatch(setCreateApp(payload));
  };
};

3-3. 하드코딩된 환경변수 URL이 JSX에 직접 삽입

javascript
<a href={`${process.env.REACT_APP_API_SERVER}/resources/${id}/download/${fileId}`}>
  다운로드
</a>

const eventSource = new EventSource(
  `${process.env.REACT_APP_API_SERVER}/sse/subscribe?token=${token}`
);

4. 상태 관리 아키텍처 부재

4-1. 3개의 상태 관리 라이브러리 동시 사용

36개 파일에서 Redux와 Recoil이 같은 컴포넌트 안에서 동시에 사용되고 있었다.

javascript
import { useDispatch, useSelector } from 'react-redux';
import { useRecoilValue, useRecoilState } from 'recoil';

const ItemList = () => {
  const dispatch = useDispatch();
  const reduxState = useSelector(stateSelector);
  const hostnames = useRecoilValue(stateEnvHostnames);
  const [selected, setSelected] = useRecoilState(sessionSelectedItems);
  const { data } = useGetListQuery(params);  // React Query
  const [q, setQ] = useState('');            // 로컬 state까지 4중

  const handleSubmit = () => {
    dispatch(updateAction(payload));           // Redux
    setSelected([]);                           // Recoil
    queryClient.invalidateQueries(KEY);       // React Query
    setPage(0);                                // 로컬 state
  };
};

4-2. Prop Drilling — 33개 props를 받는 컴포넌트

javascript
const AllAssignedUsers = ({
  useGetQuery, useGetDetailsQuery, useGetQueryKey,
  useGetDetailQueryKey, filterQueryParams, totalElements,
  q, page, setPage, workspaceId, appId, appDetails,
  integrationStatus, integrationApiResourceTypes,
  integrationActions, userIds, includeInternalizedUser,
  setOpenAssignDialogActive, setOpenUnAssignDialogActive,
  setAssignUserData, setUnAssignData, setCheckUsers,
  checkUsers, setCheckableUserData, assignUserDetails,
  setAssignUserDetails, setIndividualSelectItem,
  assignedUserIds, setAssignedUserIds, isSubTypeAll,
  isIntegratedApiProtocolWeb, isLastActiveDataMissingApp,
}) => {
  // 33개 prop + 내부 useState 19개 = 52개의 상태
  // JSX 최대 중첩: 26단계
};

5. 비기능적 품질 위반

5-1. 에러 처리 부재

javascript
// 전체 앱에 ErrorBoundary가 0개
// 렌더링 에러 발생 시 → 앱 전체 화이트 스크린 크래시
// console.error 404개가 "에러 처리"의 전부

5-2. 테스트 커버리지 0%

javascript
// 835개 파일 중 실제 테스트는 CRA 자동생성 보일러플레이트 1개
test('renders learn react link', () => {
  render(<App />);
  expect(screen.getByText(/learn react/i)).toBeInTheDocument();
});
// 실제 앱과 무관한 테스트.

5-3. 타입 안전성 부재

javascript
// TypeScript 없음. PropTypes는 있지만:
Dialog.propTypes = {
  data: PropTypes.object,     // 546곳 — 어떤 shape인지 알 수 없음
  config: PropTypes.object,
  options: PropTypes.any,     // 47곳 — 완전 무타입
};

5-4. 죽은 코드

javascript
// 버전 관리를 git이 아닌 파일명으로 하는 패턴
index.jsx      // 2,114줄 (현재)
index_bk.jsx   // 1,825줄 (백업) ← 이게 왜 repo에?
// 총 4,896줄의 죽은 코드

// 프로덕션 코드에 console.log 71개 + console.error 404개
// 디렉토리명 오타: app-instancess (s가 두 개) → 23개 파일에 영향

6. 인라인 스타일 남용

styled-components를 사용하는 프로젝트에서 4,569곳에 인라인 스타일이 존재했다.

javascript
<Box style={{ paddingTop: 4, paddingBottom: 4 }}>
  <Grid style={{ left: 345, right: 72, width: 'auto' }}>
    <Typography style={{ marginTop: 1, fontSize: 18, color: theme.palette.common.white }}>
      <IconButton style={{ minWidth: '32px', padding: 0, height: '32px', borderRadius: '50%' }}>
        <Box style={{ width: 20, height: 20, flexWrap: 'nowrap', marginRight: 8 }}>
          {/* 하나의 dialog 파일에만 136개의 inline style */}
        </Box>
      </IconButton>
    </Typography>
  </Grid>
</Box>

배운 점

  • 정량 지표가 먼저다. "코드가 더럽다"는 주관적이지만, "196개 파일이 500줄 초과", "handleSearch가 100곳에서 복붙" 같은 수치는 반박할 수 없다.
  • 레거시 프로젝트의 문제는 대부분 "한 번만 더"의 축적이다. helper.js에 함수 하나 더 추가, dialog.jsx에 컴포넌트 하나 더 추가 — 이것이 반복되면 5,236줄이 된다.
  • 구조적 원칙 위반은 번들러 성능에도 직결된다. 109개 export의 유틸리티 파일은 하나만 수정해도 이를 import하는 모든 모듈이 리빌드 대상이 된다. 코드 품질과 DX는 별개의 문제가 아니라 동전의 양면이다.

요약

원칙위반 사례규모
SRP (단일 책임)1파일 14컴포넌트, useState 29개, 핸들러 52개5,236줄
DRY (반복 금지)handleSearch 100+곳 복붙, 디렉토리 통째로 복사~4,142줄 중복
계층 구조components → features 역방향 의존 11곳아키텍처 붕괴
God Objecthelper.js 109개 export, 16개 도메인1,688줄
상태 관리Redux + Recoil + React Query 3중 사용36개 파일
Prop Drilling33개 props를 받는 컴포넌트JSX 26단계 중첩
에러 처리ErrorBoundary 0개앱 전체
테스트835파일 중 실제 테스트 0개0% 커버리지
타입 안전성PropTypes.object 546곳, PropTypes.any 47곳TypeScript 없음
죽은 코드_bk 파일 4,896줄 + console.log 476개프로덕션 포함