좋은 코드는 예쁜 코드가 아니라 변경하기 쉬운 코드다
좋은 코드가 뭐냐고 물으면 보통 "깔끔한 코드", "예쁜 코드" 같은 답이 먼저 나온다. 그런데 토스 기술 블로그를 읽으면서 계속 마주친 기준은 달랐다. 좋은 코드는 나중에 요구사항이 바뀌었을 때 쉽게 고칠 수 있는 코드라는 것. 스타일이 아니라 변경 비용의 문제로 보는 관점이다.
좋은 코드의 기준: 변경하기 쉬운 코드
이 "변경 용이성"은 네 개의 하위 기준으로 나뉜다.
- 가독성: 읽고 이해할 수 있는가
- 예측 가능성: 이름만 보고 동작을 짐작할 수 있는가
- 응집도: 같이 바뀔 코드가 함께 붙어 있는가
- 결합도: 수정했을 때 영향 범위가 좁은가
여기에 선언적 코드라는 도구 하나를 더하면 다섯 가지가 된다. 하나씩 정리해본다.
선언적으로 쓴다는 것
선언적 코드는 '어떻게'보다 '무엇을'에 가깝게, 추상화 레벨을 한 단계 올려서 쓰는 코드다. sum(nums)라고 쓰면 어떻게 더하는지는 안 보이고 "합을 구한다"는 의도만 남는다. 반대로 반복문을 풀어 쓰면 구현이 다 드러나는 대신 읽는 사람이 매번 그 로직을 다시 해석해야 한다.
다만 선언적 코드가 항상 정답은 아니다. 인터페이스가 좁고 변경 비용이 낮은 추상화는 잘 된 것이지만, 억지로 공통화하다 보면 prop이 폭발하면서 오히려 복잡도가 늘어난다. 결국 중요한 건 이분법이 아니라, 지금 이 코드에 맞는 추상화 레벨을 고르는 판단이다.
가독성 - 맥락을 줄이고 자연스럽게 읽히게
가독성의 핵심은 한 함수나 컴포넌트가 동시에 들고 있는 맥락을 6~7개 이하로 유지하는 것이다.
같이 실행되지 않는 흐름은 쪼갠다
아래 예시처럼 뷰어용 분기와 관리자용 분기, 거기에 애니메이션 처리까지 한 컴포넌트에 몰려 있으면, 그 컴포넌트를 읽는 사람은 실행되지도 않을 코드까지 머릿속에서 같이 추적해야 한다.
tsx// Before: 한 컴포넌트가 viewer/admin 둘 다 책임 + 애니메이션까지 들고 있음 function SubmitButton() { const isViewer = useRole() === 'viewer'; useEffect(() => { if (isViewer) return; showAnimation(); }, [isViewer]); return isViewer ? <TextButton disabled>Submit</TextButton> : <Button type="submit">Submit</Button>; } // After: 분기는 한 곳에서만, 각 컴포넌트는 한 흐름만 책임 function SubmitButton() { const isViewer = useRole() === 'viewer'; return isViewer ? <ViewerSubmitButton /> : <AdminSubmitButton />; } function ViewerSubmitButton() { return <TextButton disabled>Submit</TextButton>; } function AdminSubmitButton() { useEffect(() => { showAnimation(); }, []); return <Button type="submit">Submit</Button>; }
조건식과 매직 넘버에 이름을 붙인다
if (user.role === 'admin' && !user.suspended && user.emailVerified)는 조건이 무엇을 의미하는지 매번 다시 읽어야 한다. 이름을 붙이면 코드 자체가 설명이 된다.
tsx// Before if (user.role === 'admin' && !user.suspended && user.emailVerified) { /* ... */ } // After const canManageTeam = user.role === 'admin' && !user.suspended && user.emailVerified; if (canManageTeam) { /* ... */ }
위에서 아래로 읽히게 한다
중첩 삼항이나 파일·함수 사이를 계속 오가야 이해되는 구조는, 지금 당장은 문제없어 보여도 나중에 요구사항이 바뀌었을 때 "여기서 뭘 고쳐야 하지"를 찾는 시간을 늘린다.
예측 가능성 - 이름만 보고 동작을 알 수 있게
같은 계열 함수인데 반환 형태가 다르면, 이름만 보고는 어떻게 써야 할지 알 수 없다. 함수·훅 이름, 파라미터, 반환값만 보고 동작을 짐작할 수 있어야 한다.
tsx// Before: 같은 계열인데 반환 형태가 달라서 헷갈림 function useUser() { return useQuery(['user'], fetchUser); // Query 객체 통째로 반환 } function useServerTime() { const { data } = useQuery(['serverTime'], fetchServerTime); return data; // 값만 반환 } // After: 같은 패턴으로 맞춰서 이름만 보고도 감이 옴 function useUser() { return useQuery(['user'], fetchUser); } function useServerTime() { return useQuery(['serverTime'], fetchServerTime); }
응집도 - 같이 바뀔 코드는 붙여둔다
결제 기능을 고치는데 관련 코드가 hooks/, components/, utils/에 흩어져 있으면 매번 여러 폴더를 오가야 한다. 도메인 단위로 묶어두면 한 폴더만 보면 된다.
text// 권장 src/ domains/ payment/ components/ hooks/ utils/ index.ts // → 결제 기능이 바뀌면 payment 폴더만 보면 됨 // → hooks/, components/ 폴더를 여기저기 오가며 찾을 필요가 없음
결합도 - 영향 범위를 좁게 만든다
한 훅이나 컴포넌트가 여러 관심사를 동시에 안고 있지 않아야, 수정했을 때 영향 범위가 좁고 예측 가능하다.
관심사를 하나씩 쪼갠다
쿼리 파라미터, 폼 상태, 서버 상태를 한 훅에 다 몰아넣으면 그중 하나만 바뀌어도 훅 전체의 영향 범위를 다시 확인해야 한다. 공통화는 변경 방향이 확실해졌을 때만 하고, 그 전에는 중복을 의도적으로 남겨도 된다.
tsx// Before: 쿼리파람 + 폼 상태 + 서버 상태 다 섞인 훅 function usePageState() { const [searchParams, setSearchParams] = useSearchParams(); const [form, setForm] = useState({}); const { data, refetch } = useQuery(['items'], fetchItems); return { page: Number(searchParams.get('page') ?? 1), setPage: (page: number) => setSearchParams({ page: String(page) }), form, setForm, data, refetch, }; } // After: 필요한 관심사만 가져다 쓸 수 있게 분리 function usePageQueryParam() { const [searchParams, setSearchParams] = useSearchParams(); const page = Number(searchParams.get('page') ?? 1); const setPage = (next: number) => setSearchParams({ page: String(next) }); return { page, setPage }; } function useItems() { return useQuery(['items'], fetchItems); } function useFormState<T>(initial: T) { const [form, setForm] = useState<T>(initial); return { form, setForm }; }
네 기준은 서로 충돌한다
가독성·예측 가능성·응집도·결합도를 동시에 다 만족시키기는 어렵다. 공통화를 많이 할수록 응집도는 올라가지만 결합도도 같이 올라갈 수 있고, 반대로 중복을 남기면 결합도는 낮아지지만 같은 로직이 여러 곳에 흩어진다.
그래서 매번 "같이 안 바꾸면 바로 버그가 나는가?"를 기준으로 삼는다. 그렇다면 응집도를 우선하고, 아니라면 가독성과 결합도를 우선한다. 요구사항이 아직 갈라지지 않았는데 미리 공통화부터 하면, 나중에 요구사항이 갈라졌을 때 오히려 그 추상화가 발목을 잡는다.
반복되는 패턴은 선언적 추상화로 감춘다
같은 원리를 서비스 전반에 반복적으로 나타나는 패턴에도 적용할 수 있다. 상태·이벤트·라이프사이클을 처리하는 로직은 추상화 안에 감추고, 화면 코드에는 "무엇을 하고 싶은지"만 남긴다.
자주 쓰이는 선언적 패턴
오버레이
모달이나 바텀시트를 쓸 때마다 isOpen 상태와 onClose 핸들러를 직접 관리하면 화면 코드가 금방 지저분해진다. 오버레이 패턴을 쓰면 "열어라"만 선언하면 된다.
tsx// Before: 화면에서 직접 isOpen을 관리 const [isSheetOpen, setIsSheetOpen] = useState(false); return ( <> <button onClick={() => setIsSheetOpen(true)}>바텀시트 열기</button> <BottomSheet open={isSheetOpen} onClose={() => setIsSheetOpen(false)}> 내용 </BottomSheet> </> ); // After: 오버레이 추상화를 통해 "열어라"만 선언 const overlay = useOverlay(); return ( <button onClick={() => { overlay.open(({ isOpen, close }) => ( <BottomSheet open={isOpen} onClose={close}> 내용 </BottomSheet> )); }} > 바텀시트 열기 </button> );
노출 감지
IntersectionObserver를 직접 다루는 대신, "이 영역이 보이면 로그를 남겨라"만 선언한다.
tsx<ImpressionArea onImpressionStart={() => logImpression('home_banner')}> <HomeBanner /> </ImpressionArea> // → 실제 IntersectionObserver 생성/해제, threshold 계산은 ImpressionArea 안으로 숨기고 // 화면에서는 "이 영역이 보이면 로그를 남겨라"만 선언
비동기 상태
로딩·에러·성공 상태를 if문으로 처리하는 대신 Suspense와 ErrorBoundary로 분리하면, "어느 컴포넌트가 어느 상태를 책임지는지"가 코드 구조 자체에 드러난다.
tsx// Before: 한 컴포넌트 안에 로딩·에러·성공 세 맥락이 다 섞여 있음 function ProfileContainer() { const { data, isLoading, error } = useProfileQuery(); if (isLoading) return <Spinner />; if (error) return <ErrorView />; return <Profile data={data} />; } // After: 상태별 책임을 컴포넌트 구성으로 분리 <ErrorBoundary fallback={<ErrorView />}> <Suspense fallback={<Spinner />}> <Profile /> {/* 내부에서 useProfileQuery + Suspense 사용 */} </Suspense> </ErrorBoundary>
정리
결국 다섯 기준(선언적 코드, 가독성, 예측 가능성, 응집도, 결합도)은 전부 같은 질문으로 되돌아간다. 요구사항이 바뀌었을 때, 이 코드는 얼마나 쉽게 고칠 수 있는가. 코드 리뷰를 할 때도 이 순서(가독성 → 예측 가능성 → 응집도 → 결합도 → 선언 레벨)로 체크리스트를 두면, "왠지 별로다" 같은 감이 아니라 "여기는 응집도가 낮다", "여기는 예측 가능성이 깨진다"처럼 구체적으로 짚을 수 있다.
참고
이 글은 토스 프론트엔드 코드 스타일을 참고해 제 언어로 정리한 글입니다.
댓글
불러오는 중...