Last edited: March 16, 2026
32만줄 규모의 레거시 React 프로젝트를 분석하면서 발견한 소프트웨어 원칙 위반 사례를 정리했다. "그냥 코드가 더럽다"가 아니라, 어떤 원칙을 어떻게 위반하고 있는지 정량적 지표와 코드 예시로 증명하는 것이 목적이다.
대상 프로젝트의 정량적 프로필:
아래 모든 코드 예시는 실제 코드의 구조적 패턴만 추출한 것으로, 비즈니스 로직은 제거되었다.
Dialog 기능을 담당하는 한 파일이 14개의 컴포넌트를 포함하고 있었다. 그 중 가장 큰 컴포넌트 하나만 1,805줄이었다.
// 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,688줄의 유틸리티 파일이 날짜, 문자열, 색상, 트리 탐색, 세션 스토리지, CSV 파싱, JSX 렌더링, 라우팅, 검증까지 한번에 담당하고 있었다.
// 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 위반
};동일한 로직의 두 가지 변형이 100개 이상의 파일에 복붙되어 있었다.
// 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]);
};// 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]);
};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 경로와 변수명뿐:
// 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 = () => { /* 거의 같은 코드 */ };올바른 의존성 방향은 pages → features → components이다. 실제로는 component가 feature를 import하는 역방향 의존성이 11곳에서 발견되었다.
// 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';// 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));
};
};<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}`
);36개 파일에서 Redux와 Recoil이 같은 컴포넌트 안에서 동시에 사용되고 있었다.
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
};
};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단계
};// 전체 앱에 ErrorBoundary가 0개
// 렌더링 에러 발생 시 → 앱 전체 화이트 스크린 크래시
// console.error 404개가 "에러 처리"의 전부// 835개 파일 중 실제 테스트는 CRA 자동생성 보일러플레이트 1개
test('renders learn react link', () => {
render(<App />);
expect(screen.getByText(/learn react/i)).toBeInTheDocument();
});
// 실제 앱과 무관한 테스트.// TypeScript 없음. PropTypes는 있지만:
Dialog.propTypes = {
data: PropTypes.object, // 546곳 — 어떤 shape인지 알 수 없음
config: PropTypes.object,
options: PropTypes.any, // 47곳 — 완전 무타입
};// 버전 관리를 git이 아닌 파일명으로 하는 패턴
index.jsx // 2,114줄 (현재)
index_bk.jsx // 1,825줄 (백업) ← 이게 왜 repo에?
// 총 4,896줄의 죽은 코드
// 프로덕션 코드에 console.log 71개 + console.error 404개
// 디렉토리명 오타: app-instancess (s가 두 개) → 23개 파일에 영향styled-components를 사용하는 프로젝트에서 4,569곳에 인라인 스타일이 존재했다.
<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>| 원칙 | 위반 사례 | 규모 |
|---|---|---|
| SRP (단일 책임) | 1파일 14컴포넌트, useState 29개, 핸들러 52개 | 5,236줄 |
| DRY (반복 금지) | handleSearch 100+곳 복붙, 디렉토리 통째로 복사 | ~4,142줄 중복 |
| 계층 구조 | components → features 역방향 의존 11곳 | 아키텍처 붕괴 |
| God Object | helper.js 109개 export, 16개 도메인 | 1,688줄 |
| 상태 관리 | Redux + Recoil + React Query 3중 사용 | 36개 파일 |
| Prop Drilling | 33개 props를 받는 컴포넌트 | JSX 26단계 중첩 |
| 에러 처리 | ErrorBoundary 0개 | 앱 전체 |
| 테스트 | 835파일 중 실제 테스트 0개 | 0% 커버리지 |
| 타입 안전성 | PropTypes.object 546곳, PropTypes.any 47곳 | TypeScript 없음 |
| 죽은 코드 | _bk 파일 4,896줄 + console.log 476개 | 프로덕션 포함 |