////////
Search

2. FE 복잡도 이해

FE 복잡도 이해: 당신은 어떤 '얽힘'과 싸우고 있습니까?

앞서 우리는 '변경'이라는 리트머스 시험지를 통해, "깔끔하게 분리했다"고 믿었던 코드가 사실은 여러 책임과 의존성으로 복잡하게 '얽혀(Complected)' 있음을 확인했습니다. '분리'만으로는 복잡도가 낮아지지 않았던 이유는 바로 이 '얽힘'을 풀지 못했기 때문입니다.
'얽힘'은 추상적인 개념이 아닙니다. 이제 그 얽힘이 프론트엔드 개발 현장에서 구체적으로 어떤 '얼굴'들로 나타나는지 명확히 인식해야 합니다. 그래야만 우리가 어떤 적과 싸우고 있는지 알고, 올바른 무기(추상화 원칙)를 선택할 수 있습니다.
프론트엔드 개발자를 특히 괴롭히는 대표적인 '얽힘'의 유형들을 살펴봅시다.

1. 상태 관리의 카오스: 뒤섞인 책임과 생명주기

프론트엔드 복잡도의 가장 큰 근원은 상태입니다. 문제는 "상태"라는 단어가 너무 많은 것을 포괄한다는 데 있습니다. 프론트엔드에서 다루는 상태는 그 출처, 생명주기, 변경 주체에 따라 명확히 구분하여 접근해야 합니다. 이들을 구분하지 않고 뒤섞어 관리하는 순간, '얽힘'이 시작됩니다.
다양한 프론트엔드 상태들을 다음과 같이 분류해 볼 수 있습니다:
a) 서버 동기화 상태 (Server Cache State):
정체: 서버 API로부터 가져와 캐싱되는 데이터 (e.g., TanStack Query, SWR 등으로 관리). 진실의 원천(Source of Truth)은 서버입니다. 컴포넌트 생명주기와는 별개로 존재하며, 여러 컴포넌트에서 공유될 수 있습니다.
흔한 얽힘: 이 서버 상태를 로컬 상태(useState)로 복사하여 수정하려 할 때 발생합니다. 서버 데이터가 업데이트되면 로컬 상태와 동기화가 깨지고, '다중 진실 소스' 문제가 발생하며 '시간과 상태'가 얽힙니다. "데이터를 수정했는데 왜 반영이 안 되지?" 혹은 "새로고침하니 수정이 날아갔어요!"의 주범입니다.
b) 영속적인 클라이언트 상태 (Persistent Client State):
정체: 브라우저 저장소(LocalStorage, Cookie, IndexedDB)에 저장되어 브라우저 세션 간 유지되는 사용자 설정, 테마, 인증 토큰 등.
흔한 얽힘: 컴포넌트가 이 상태들에 직접 접근(localStorage.getItem)하면, 해당 컴포넌트는 브라우저 환경 및 특정 저장소 '구현'에 강하게 '얽힙니다'. 서버 렌더링(SSR) 환경이나 테스트 환경에서 코드를 재사용할 수 없게 됩니다. 또한, 이 상태들이 서버와 동기화되어야 하는 요구사항(여러 기기 설정 동기화)이 생기면 구조 변경이 불가피해집니다.
c) 라우팅 상태 (URL State):
정체: URL 경로, 쿼리스트링, 해시 등에 저장되는 상태 (e.g., 현재 페이지, 필터/정렬 값). URL 자체가 진실의 원천이며, 사용자가 직접 조작 가능하고 공유될 수 있습니다. 생명주기는 일반적으로 페이지 이동과 같습니다.
흔한 얽힘: QueryString 서사에서 봤듯이, 이 URL 상태를 로컬 상태(useState)와 어설프게 동기화하려 할 때 발생합니다. 파싱/직렬화 로직, 상태 업데이트 순서가 복잡하게 얽히고, '시간과 상태' 얽힘, '다중 진실 소스' 문제가 동시에 터집니다.
d) 폼 상태 (Form State):
정체: 사용자가 입력 중인 폼 데이터. 일반적으로 컴포넌트 지역 상태이며, 제출되거나 취소되기 전까지 유효한 임시 상태입니다. 생명주기가 짧고 해당 폼 컴포넌트에 국한됩니다.
흔한 얽힘: 이 임시적인 폼 상태를 "prop 내리기 귀찮다"는 '쉬움(Easy)'을 좇아 전역 상태(Redux/Jotai)에 넣을 때 발생합니다. 폼의 짧은 생명주기와 전역 상태의 긴 생명주기가 충돌하며, 불필요한 리렌더와 복잡한 상태 초기화/클린업 로직을 유발합니다. 이는 '제어와 뷰', '시간과 상태'의 얽힘입니다. (대부분의 Form 라이브러리는 이를 지역적으로 관리합니다.)
e) 일시적인 UI 상태 (Ephemeral UI State):
정체: 모달 열림/닫힘, 아코디언 확장 여부, 호버/포커스 상태 등 특정 컴포넌트 내부에 국한된 매우 짧은 생명주기의 임시 상태 (useState). 외부에서는 알 필요가 없는 경우가 많습니다.
흔한 얽힘: 이 상태들을 외부에서 제어해야 할 필요성(e.g., URL 해시에 따라 특정 아코디언 열기)이 생겼을 때, 혹은 너무 많은 UI 상태가 하나의 컴포넌트에 집중될 때 발생합니다. Uncontrolled 컴포넌트를 Controlled로 바꾸거나, 상태를 상위로 끌어올리는 과정에서 '제어와 뷰'의 책임이 모호해지고 코드가 얽히기 쉽습니다.
핵심: 상태 유형마다 적합한 관리 방식과 생명주기가 다릅니다. 이들을 명확히 구분하고 각 상태의 '진실의 원천'을 하나로 유지하는 것이 '얽힘'을 푸는 첫걸음입니다. 단순히 '편리함(Easy)'을 기준으로 상태 관리 전략을 선택하면 결국 풀기 어려운 복잡성(Complected)으로 이어집니다.

2. 의존성 지옥: 암묵적 결합과 불분명한 계약

프론트엔드 코드는 명시적인 import 외에도 보이지 않는 수많은 암묵적 의존성으로 가득 차 있습니다. 이 의존성들이 명확히 관리되지 않으면, 컴포넌트들은 서로 거미줄처럼 얽혀 재사용, 테스트, 변경을 극도로 어렵게 만듭니다.
a) useContext의 함정 (암묵적 의존성):
정체: React Context API는 props를 깊게 내리지 않고 값을 전달하는 편리한 방법을 제공하지만, 많은 경우 '암묵적인 의존성 주입(DI)' 메커니즘으로 작동합니다. 이는 상태 관리 도구라기보다는, 특정 값이나 기능을 컴포넌트 트리에 '주입'하는 방식에 가깝습니다.
얽힘: CheckoutButton 예제처럼, 컴포넌트가 내부에서 useAuth(), useCart()를 직접 호출하면, 이 컴포넌트는 해당 Context의 존재뿐 아니라 그 내부 구현(특정 함수, 상태 구조)에 강하게 '얽힙니다'. Props만 봐서는 이 컴포넌트가 무엇을 필요로 하는지 알 수 없으며, 테스트하려면 모든 Provider를 설정해야 합니다. 이는 **'구현과 의존성의 얽힘'**의 대표적인 사례이며, 컴포넌트의 독립성을 심각하게 저해합니다.
b) 불분명한 인터페이스 (약한 계약):
정체: 컴포넌트의 props나 함수의 인자는 외부와의 **'계약'**입니다. 이 계약은 필요한 것만 명확하게 요구해야 합니다. 너무 많은 것을 요구하거나(Fat Interface), 반대로 필요한 것을 명시하지 않으면(Implicit Dependency) '얽힘'이 발생합니다.
얽힘: 예를 들어, 상품 상태를 계산하는 함수(computeGoodsStatus)가 상태 계산에 필요한 startDate, endDate, status 세 가지 필드만 요구하는 대신, API 응답 전체 객체를 타입으로 요구한다고 가정해 봅시다. 이 경우, 이 함수는 자신의 책임과 무관한 수많은 정보(상품 이름, 설명 등)와 '얽히게' 됩니다. 이는 **'인터페이스 격리 원칙'**을 위반하며, 다른 종류의 데이터(e.g., 번들 상품)에는 이 함수를 재사용할 수 없게 만듭니다. props를 단순히 "데이터 전달 통로"로만 생각할 때 이런 실수가 발생하기 쉽습니다.
c) 부적절한 의존성 주입(DI) 전략:
정체: 의존성을 주입하는 방식(Props, Context, Module Alias, HOC 등)은 각각 장단점이 있습니다. 모든 상황에 맞는 만능 해결책은 없으며, 상황에 맞지 않는 방식을 선택하면 '얽힘'을 풀기보다 오히려 가중시킬 수 있습니다.
얽힘: 모든 것을 props로만 주입하면 깊은 컴포넌트 트리에서 'prop drilling'이라는 고통이 발생하여 개발 생산성을 저해할 수 있습니다. 반대로 모든 것을 Context로 주입하면 위에서 본 '암묵적 의존성' 문제가 심화되어 코드의 예측 가능성과 테스트 용이성을 해칩니다. 정적인 Module Alias는 런타임 유연성이 필요한 경우(e.g., SSR에서 요청별 상태 격리) 문제를 일으킵니다. 어떤 DI 전략을 선택하느냐는 '얽힘'의 형태를 결정하는 중요한 설계 결정이며, 각 방식의 트레이드오프를 이해해야 합니다.
핵심: 의존성은 가급적 숨기지 말고 명시적으로 드러내야 합니다. 명확한 계약(인터페이스)을 통해 필요한 최소한만 요구하고, 상황에 맞는 적절한 방식으로 주입해야 합니다. '쉬움(Easy)'을 위해 암묵적 의존성에 기대거나 인터페이스 설계를 소홀히 하면 결국 풀기 어려운 '얽힘'으로 돌아옵니다.

3. 제어권 혼돈: 누가 상태를 책임지는가?

UI 컴포넌트는 종종 내부 상태를 가지면서 외부와 상호작용합니다. 이때 **"상태를 변경하고 관리하는 최종 책임(제어권)이 누구에게 있는가?"**가 불분명하면 '얽힘'이 발생하여 예측 불가능한 동작을 유발합니다.
a) Uncontrolled vs Controlled 혼란:
정체: 내부 상태(useState)를 스스로 관리하는 컴포넌트(Uncontrolled)는 해당 컴포넌트만 사용할 때는 '쉽지만', 외부에서 상태를 읽거나 제어하기 어렵습니다. 외부 props를 통해 상태 값(value)과 변경 함수(onChange)를 제어받는 컴포넌트(Controlled)는 외부 제어가 용이하여 '단순하지만(Simple)', 상태 관리 로직이 부모로 전파되어 보일러플레이트가 발생합니다.
얽힘: '모달 제어' 사례처럼, 어떤 컴포넌트는 Uncontrolled, 어떤 컴포넌트는 Controlled 방식으로 구현되거나, 심지어 두 방식이 어설프게 혼합되면 '제어 로직(Control)'과 '뷰 렌더링(View)'이 복잡하게 얽힙니다. "왜 이 입력창은 초기값이 설정되지 않지?" (Controlled인데 value만 주고 onChange 안 줌) 혹은 "왜 부모 상태를 바꿔도 모달이 안 닫히지?" (Uncontrolled로 구현됨) 같은 문제는 이 얽힘에서 비롯됩니다. 상태의 '진실의 원천'이 어디인지 불분명해지는 것입니다.
b) 분산된 제어 로직:
정체: 하나의 사용자 인터랙션 흐름(e.g., 복잡한 필터링 UI, 다단계 폼)을 제어하는 로직이 여러 컴포넌트, 여러 useEffect, 심지어 전역 상태 업데이트 로직 등에 책임이 불분명하게 분산되어 흩어져 있는 경우입니다.
얽힘: SearchResults 예제처럼, URL 변경, 필터 값 변경, 검색어 입력이 서로를 트리거하며 상태를 변경할 때, '제어 흐름' 자체가 얽혀 예측 불가능한 동작이나 무한 루프를 유발할 수 있습니다. 각 부분은 '분리'된 것처럼 보이지만, 실제로는 서로에게 암묵적으로 영향을 주며 복잡하게 얽혀있습니다. 어디서 상태 변경이 시작되었고 그 결과가 어떻게 전파되는지 추적하기 어렵습니다.
핵심: 상태를 소유하고 제어하는 책임 소재(Owner of Control)를 명확히 설계해야 합니다. Uncontrolled와 Controlled 패턴의 트레이드오프를 이해하고 일관성 있게 적용하며, 관련된 제어 로직은 책임있는 곳에서 응집력 있게 관리해야 합니다. '쉬움(Easy)'을 위해 제어 책임을 방치하면 예측 불가능하고 디버깅하기 어려운 '얽힘'이 발생합니다.
이제 여러분은 프론트엔드 코드에서 마주치는 복잡함의 구체적인 '이름'과 '형태'를 알게 되었습니다. 상태 관리의 카오스, 의존성 지옥, 제어권 혼돈. 이것들이 바로 우리가 '단순함(Simple)'을 향해 나아가기 위해 풀어야 할 '얽힘'의 실체입니다.
다음 단계는 이 얽힘들을 푸는 '보편 원칙'들을 배우고, 그것을 프론트엔드 맥락에 맞게 '번역'하는 방법을 익히는 것입니다.
네, 좋습니다! 앞서 이론적으로 살펴본 프론트엔드의 '3대 얽힘' 유형들을 실제 코드 예시를 통해 직접 식별해보는 연습 문제를 만들어 보겠습니다. 이전 챕터와 동일한 구성으로, 각 문제 상황을 통해 어떤 복잡성이 숨어 있는지 진단하는 데 초점을 맞추겠습니다.

실전 연습: 내 코드 속 '얽힘' 유형 진단하기

이론 학습을 마쳤으니, 이제 여러분의 눈으로 직접 코드 속 '얽힘'의 구체적인 유형을 찾아낼 차례입니다. 다음 예시 코드들을 보고, 각 코드가 어떤 종류의 '얽힘'을 가장 두드러지게 보여주는지 진단해 보세요. 질문에 답하면서 각 복잡성의 '얼굴'과 '이름'을 명확히 연결하는 연습을 해봅시다.

연습 1: 상태가 뒤섞인 사용자 프로필 편집 폼 /

사용자 프로필 정보(이름, 이메일)를 서버에서 불러와 수정하고 저장하는 간단한 폼 컴포넌트입니다. 서버 상태는 useQuery로, 폼 입력 상태는 useState로 관리합니다.
import React, { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // 가상의 API 함수들 const fetchUserProfile = async () => { console.log('Fetching user profile...'); await new Promise(res => setTimeout(res, 500)); // Simulate network delay return { id: 1, name: '김프롱', email: 'frontend@example.com', /* other fields */ }; }; const updateUserProfile = async (profileData) => { console.log('Updating user profile:', profileData); await new Promise(res => setTimeout(res, 500)); // 서버 업데이트 성공했다고 가정 return { ...profileData, updatedAt: new Date().toISOString() }; }; function UserProfileEditForm() { // (A) 서버 상태 관리 (useQuery) const { data: serverProfile, isLoading, isError } = useQuery({ queryKey: ['userProfile'], queryFn: fetchUserProfile, }); // (B) 폼 입력 관리를 위한 로컬 상태 (useState) const [name, setName] = useState(''); const [email, setEmail] = useState(''); // (C) 서버 상태 -> 로컬 상태 "동기화" 로직 useEffect(() => { if (serverProfile) { setName(serverProfile.name); setEmail(serverProfile.email); console.log('Syncing server data to local form state'); } }, [serverProfile]); // serverProfile 데이터가 변경될 때마다 로컬 상태 업데이트 const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: updateUserProfile, onSuccess: (updatedProfile) => { // (D) 뮤테이션 성공 시 서버 상태 캐시 "수동" 업데이트 queryClient.setQueryData(['userProfile'], (oldData) => ({ ...oldData, ...updatedProfile, // Update cache with mutation result })); console.log('Profile updated successfully!'); // TODO: 사용자에게 성공 피드백 (e.g., 토스트 메시지) }, onError: (error) => { console.error('Update failed:', error); // TODO: 사용자에게 실패 피드백 + 롤백? }, }); const handleSubmit = (event) => { event.preventDefault(); mutation.mutate({ name, email }); // 로컬 상태값으로 뮤테이션 실행 }; if (isLoading) return <div>Loading profile...</div>; if (isError) return <div>Error loading profile.</div>; return ( <form onSubmit={handleSubmit}> <div> <label>Name:</label> <input type="text" value={name} onChange={(e) => setName(e.target.value)} /> </div> <div> <label>Email:</label> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> </div> <button type="submit" disabled={mutation.isPending}> {mutation.isPending ? 'Saving...' : 'Save Profile'} </button> </form> ); }
TypeScript
복사
질문:
1.
이 컴포넌트에는 어떤 두 가지 종류의 상태(서버 동기화 상태, 폼 상태)가 공존하고 있나요? (힌트: 주석 A, B)
2.
서버 상태(serverProfile)와 로컬 폼 상태(name, email) 사이의 동기화는 어떻게 이루어지고 있나요? (힌트: 주석 C) 만약 useQuery가 백그라운드에서 데이터를 리เฟช(refetch)하여 serverProfile이 변경되면 어떤 일이 발생할까요? 사용자가 입력 중이던 내용은 어떻게 될까요?
3.
프로필 저장 성공 시(onSuccess), 서버 상태 캐시를 어떻게 업데이트하고 있나요? (힌트: 주석 D) 이 방식의 잠재적인 문제점은 무엇일까요? (만약 업데이트 로직이 복잡하거나 여러 쿼리에 영향을 준다면?)
4.
이 코드는 상태 관리의 카오스(뒤섞인 책임과 생명주기)구현과 의존성의 얽힘(특정 라이브러리 useQuery의 캐시 관리 방식에 의존) 중 어떤 것을 더 두드러지게 보여주나요? 혹은 둘 다 해당되나요? 그 이유는 무엇인가요?

연습 2: 전역 인증 상태에 의존하는 헤더

사용자의 로그인 상태에 따라 다른 메뉴를 보여주는 헤더 컴포넌트입니다. 인증 상태는 AuthContext를 통해 전역적으로 관리됩니다.
import React, { useContext } from 'react'; // 가상의 AuthContext // { user: { name: string } | null, login: () => void, logout: () => void } const AuthContext = React.createContext(null); // ✨ 전역 인증 상태에 의존하는 헤더 ✨ function AppHeader() { // (A) Context로부터 직접 인증 상태와 함수를 가져옴 const { user, login, logout } = useContext(AuthContext); console.log('Rendering Header, user:', user?.name); return ( <header style={{ display: 'flex', justifyContent: 'space-between', padding: '10px', borderBottom: '1px solid #ccc' }}> <h1>My App</h1> <nav> {user ? ( // (B) 로그인 상태일 때 보여줄 UI 및 로그아웃 기능 직접 호출 <> <span>Welcome, {user.name}!</span> <button onClick={logout} style={{ marginLeft: '10px' }}>Logout</button> </> ) : ( // (C) 로그아웃 상태일 때 보여줄 UI 및 로그인 기능 직접 호출 <button onClick={login}>Login</button> )} </nav> </header> ); } // 사용 예시 (App 컴포넌트 어딘가에 AuthProvider가 있다고 가정) function Layout({ children }) { return ( <div> <AppHeader /> <main>{children}</main> </div> ); }
TypeScript
복사
질문:
1.
AppHeader 컴포넌트는 화면에 메뉴를 그리는 책임 외에, 로그인/로그아웃이라는 '액션'을 직접 트리거하는 책임까지 가지고 있습니다. (힌트: 주석 B, C) 이로 인해 어떤 문제가 발생할 수 있을까요? (만약 로그인/로그아웃 로직이 복잡해지거나, 다른 곳에서도 필요하다면?)
2.
AppHeader는 작동하기 위해 암묵적으로 AuthContext의 존재와 그 내부 구조(user, login, logout)에 강하게 의존하고 있습니다. (힌트: 주석 A) 이 컴포넌트를 **스토리북(Storybook)**에서 다양한 상태(로그인/로그아웃)로 보여주거나, 단위 테스트하려면 어떻게 해야 할까요?
3.
만약 인증 시스템을 Context API 대신 다른 방식(e.g., Zustand, 서버 세션)으로 변경한다면 AppHeader 컴포넌트는 얼마나 많이 수정되어야 할까요?
4.
이 코드는 구현과 의존성의 얽힘을 명확하게 보여줍니다. AppHeader의 **'구현'(UI 렌더링 + 액션 호출)**이 AuthContext라는 특정 **'의존성'**과 어떻게 얽혀있는지 설명해 보세요.

연습 3: 제어 방식이 혼합된 데이터 테이블 /

정렬(클릭 시 상태 변경)과 필터링(외부 입력) 기능을 가진 데이터 테이블입니다. 정렬 상태는 테이블 내부(useState)에서 관리하고, 필터링된 데이터는 외부(props)에서 받아옵니다.
import React, { useState, useMemo } from 'react'; // 가상의 데이터 타입 // type DataItem = { id: number; name: string; value: number }; // ✨ 제어 방식이 혼합된 데이터 테이블 ✨ function SortableFilteredTable({ filteredData }) { // (A) 필터링된 데이터는 외부에서 제어 (Controlled) // (B) 정렬 상태는 내부에서 자체 관리 (Uncontrolled) const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'ascending' }); // (C) 내부 정렬 상태와 외부 필터링 데이터를 조합하여 최종 표시 데이터 계산 const sortedData = useMemo(() => { let sortableItems = [...filteredData]; if (sortConfig !== null) { sortableItems.sort((a, b) => { if (a[sortConfig.key] < b[sortConfig.key]) { return sortConfig.direction === 'ascending' ? -1 : 1; } if (a[sortConfig.key] > b[sortConfig.key]) { return sortConfig.direction === 'ascending' ? 1 : -1; } return 0; }); } return sortableItems; }, [filteredData, sortConfig]); // 외부 데이터와 내부 상태 모두에 의존 // 테이블 헤더 클릭 시 정렬 상태 변경 로직 const requestSort = (key) => { let direction = 'ascending'; if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') { direction = 'descending'; } setSortConfig({ key, direction }); // 내부 상태 업데이트 console.log(`Sorting by ${key}, ${direction}`); }; return ( <table> <thead> <tr> {/* 헤더 클릭 시 내부 정렬 함수 호출 */} <th onClick={() => requestSort('name')}>Name {sortConfig.key === 'name' ? (sortConfig.direction === 'ascending' ? '🔼' : '🔽') : ''}</th> <th onClick={() => requestSort('value')}>Value {sortConfig.key === 'value' ? (sortConfig.direction === 'ascending' ? '🔼' : '🔽') : ''}</th> </tr> </thead> <tbody> {/* (D) 최종 계산된 데이터를 렌더링 */} {sortedData.map((item) => ( <tr key={item.id}> <td>{item.name}</td> <td>{item.value}</td> </tr> ))} </tbody> </table> ); } // 사용 예시 function DataDisplayPage() { const allData = [ /* ... raw data from API or somewhere ... */ ]; const [filterTerm, setFilterTerm] = useState(''); // 외부에서 필터링 로직 수행 const filteredData = useMemo(() => { return allData.filter(item => item.name.toLowerCase().includes(filterTerm.toLowerCase())); }, [allData, filterTerm]); return ( <div> <input type="text" placeholder="Filter by name..." value={filterTerm} onChange={(e) => setFilterTerm(e.target.value)} /> {/* 필터링된 데이터만 테이블에 전달 */} <SortableFilteredTable filteredData={filteredData} /> </div> ); }
TypeScript
복사
질문:
1.
이 테이블 컴포넌트에서 데이터 필터링의 '제어권'은 누구에게 있나요? 데이터 정렬의 '제어권'은 누구에게 있나요? (힌트: 주석 A, B)
2.
만약 부모 컴포넌트(DataDisplayPage)에서 초기 정렬 상태를 설정하거나, 외부 버튼 클릭으로 정렬을 변경하고 싶다면 현재 구조에서 가능한가요? 왜 어렵거나 불가능한가요?
3.
sortedData를 계산하는 useMemo는 **외부에서 받은 filteredData*와 내부 상태인 sortConfig 모두에 의존합니다. (힌트: 주석 C) 만약 filteredData가 매우 자주 변경된다면(e.g., 실시간 검색), 정렬 계산이 불필요하게 반복될 수 있습니다. 이처럼 외부 제어(필터)와 내부 제어(정렬) 로직이 얽히면 어떤 문제가 발생할 수 있을까요?
4.
이 코드는 제어와 뷰의 얽힘을 명확하게 보여줍니다. 필터링(Controlled)과 정렬(Uncontrolled)이라는 서로 다른 제어 방식이 하나의 컴포넌트 안에 혼재하면서 어떤 혼란과 제약을 만들어내는지 설명해 보세요.
이 연습 문제들을 통해 여러분 코드 속에 숨어있는 다양한 '얽힘'의 유형들을 더 명확하게 식별하고, 왜 그것들이 문제가 되는지 구체적으로 이해하는 데 도움이 되었기를 바랍니다.

실전 연습: 정답 및 해설

연습 1: 디바운스된 검색 입력창

// ... (DebouncedSearchInput 코드 생략) ... useEffect(() => { // ... 디바운스 로직 ... const timerId = setTimeout(async () => { // ... API 호출 ... setResults(fakeData); // 상태 업데이트 // ... }, 500); return () => { clearTimeout(timerId); }; }, [searchTerm]); // ...
TypeScript
복사
질문 1 (책임):useEffect 안에는 여러 책임이 뒤섞여 있습니다.
디바운싱 로직: setTimeout으로 API 호출 지연 및 clearTimeout으로 이전 타이머 취소.
로딩 상태 관리: setIsLoading(true/false) 호출.
API 호출: fetch (또는 가상 호출) 실행.
결과 상태 업데이트: setResults 호출.
(암묵적) 에러 처리: try...catchconsole.error.
질문 2 (시간 축 얽힘):
searchTerm이 빠르게 변경될 때마다 useEffect가 재실행됩니다. 클린업 함수(clearTimeout)가 이전 타이머를 취소하지만, API 호출 자체는 비동기이므로 이전 호출의 결과가 나중 호출의 결과보다 늦게 도착할 수 있습니다 (race condition). 또한, 로딩 상태(isLoading)를 언제 false로 설정해야 할지 애매합니다. 클린업에서 false로 하면 입력 중 계속 로딩 상태가 깜빡일 수 있고, finally에서만 하면 언마운트 시 로딩 상태가 해제되지 않을 수 있습니다. 시간 축에 따른 상태 변경(로딩, 결과)과 부수 효과(타이머, API 호출) 관리가 복잡하게 얽혀있습니다.
질문 3 (얽힘 유형):
이 코드는 **시간과 상태의 얽힘 (Complecting Time + State)**을 가장 명확하게 보여줍니다. 디바운싱(시간 제어), API 호출(비동기), 상태 업데이트(로딩, 결과)라는 시간 축 위에서 발생하는 여러 로직들이 하나의 useEffect 안에서 복잡하게 상호작용하며 얽혀있기 때문입니다.

연습 2: 테마를 사용하는 버튼

// ... (ThemedButton 코드 생략) ... function ThemedButton({ children, onClick }) { const { theme } = useContext(ThemeContext); // (A) 암묵적 의존성 const buttonStyle = { // ... theme에 따라 스타일 결정 ... }; return <button style={buttonStyle} onClick={onClick}>{children}</button>; } // ...
TypeScript
복사
질문 1 (암묵적 의존성):
명시적인 props (children, onClick) 외에, ThemedButton은 ThemeContext의 존재와 그 컨텍스트가 theme이라는 값을 제공한다는 내부 구조에 암묵적으로 의존하고 있습니다.
질문 2 (재사용성 문제):
다른 프로젝트에서 재사용하려면, 그 프로젝트에도 정확히 동일한 구조의 ThemeContext와 ThemeProvider가 설정되어 있어야 합니다. 그렇지 않으면 useContext(ThemeContext)가 null이나 undefined를 반환하여 에러가 발생하거나 버튼 스타일이 깨질 것입니다.
질문 3 (테스트 설정):
단순히 props만 넘겨서는 안 됩니다. 테스트 환경에서 ThemedButton을 올바르게 렌더링하고 테스트하려면, ThemeContext.Provider로 감싸고 테스트하려는 테마 값({ theme: 'dark' } 등)을 value로 제공해야 합니다.
질문 4 (얽힘 유형):
이 코드는 **구현과 의존성의 얽힘 (Complecting Implementation + Dependency)**을 가장 명확하게 보여줍니다. 버튼의 구현(스타일 계산 방식)이 ThemeContext라는 특정 외부 의존성의 구체적인 구현(내부 구조 및 useContext 사용 방식)과 강하게 결합(얽혀) 있기 때문입니다. 버튼 자체는 테마가 어떻게 제공되는지 몰라야 이상적입니다.

연습 3: 직접 상태를 관리하는 토글 스위치

// ... (UncontrolledToggle 코드 생략) ... function UncontrolledToggle({ onChange }) { const [isOn, setIsOn] = useState(false); // (A) 내부 상태 관리 // ... } // ...
TypeScript
복사
질문 1 (제어 주체):
on/off 상태를 소유하고 제어하는 주체는 UncontrolledToggle 컴포넌트 자신입니다. (내부 useState)
질문 2 (외부 제어 불가):
불가능합니다. 부모 컴포넌트는 UncontrolledToggle의 내부 isOn 상태에 접근하거나 변경할 방법이 없습니다. 이 컴포넌트는 외부로부터 상태 제어를 받지 않는 **'Uncontrolled Component'**이기 때문입니다.
질문 3 (외부 상태 동기화 어려움):
SettingsPage는 토글의 현재 상태를 직접 알 수 없습니다. onChange 콜백은 상태가 '변경되었을 때' 그 **'결과 값'**만 알려줄 뿐입니다. 부모가 토글 상태와 동기화되어야 하는 다른 UI를 관리하려면, 부모 스스로 onChange 콜백을 이용해 별도의 상태(useState)를 만들어 관리해야 합니다. 이는 상태의 중복 관리로 이어질 수 있습니다.
질문 4 (얽힘 유형):
이 코드는 **제어와 뷰의 얽힘 (Complecting Control + View)**을 보여줍니다. 토글의 상태를 관리하는 제어(Control) 로직 (useState, handleClick)과 토글의 모양을 보여주는 뷰(View) 로직(JSX)이 하나의 컴포넌트 안에 강하게 결합되어 있습니다. 이로 인해 외부에서 상태를 제어하거나 읽는 것이 어려워집니다. 제어의 책임이 컴포넌트 내부에 캡슐화되어 외부와의 협력이 제한됩니다. (이 경우, Controlled Component 패턴을 사용하면 제어 책임을 외부로 분리할 수 있습니다.)
이제 각 코드 예시가 어떤 종류의 '얽힘'을 나타내는지 더 명확해지셨기를 바랍니다!