목차
에러 바운더리
리액트의 클래스 컴포넌트는 라이프사이클 메서드를 제공한다. 오류 처리에 사용되는 라이프사이클은 다음과 같다.
- getDerivedStateFromError: 자손 컴포넌트 오류 발생할 때 호출(error 제공)
- componentDidCatch: 자손 컴포넌트 오류 발생할 때 호출(error, info 제공)
오류 경계, 즉 에러 바운더리(Error Boundary)는 오류가 발생해도 앱이 멈추지 않고 대체 UI(에러 메시지 등)를 보여주기 위해 사용한다. 리액트 앱에서 코드에 오류가 나면 전체 앱이 안 보일 수 있기 때문이다. 변수 이름을 잘못 쓰거나, 예상치 못한 에러가 나면 앱 전체가 멈춘다. 그러면 사용자 경험이 크게 저하된다. 그래서 오류가 발생하면 대체 UI를 제공하는 기능을 처리하는 컴포넌트인 ErrorBoundary를 사용해야 한다. ErrorBoundary 컴포넌트는 클래스 컴포넌트로만(함수형은 불가능) 만들 수 있다.
ErrorBoundary 컴포넌트는 자식 컴포넌트에서 발생한 오류를 감지하고, 오류가 발생하면 지정된 대체(Fallback) UI를 렌더링한다. 그리고 앱 전체가 다운되지 않고, 일부만 대체 UI로 안전하게 처리한다.
ErrorBoundary 컴폰너트는 여러 개를 중첩해서 사용할 수 있다.
<ErrorBoundary fallback={GlobalError}>
<Header />
<Main>
<ErrorBoundary fallback={SectionError}>
<Section />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetError}>
<Widget />
</ErrorBoundary>
</Main>
<Footer />
</ErrorBoundary>
하지만 ErrorBoundary 컴포넌트는 리액트 컴포넌트 렌더링 중 발생한 오류만 잡을 수 있다. 비동기 코드(fetch, setTimeout)나 이벤트 핸들러에서 발생한 오류는 직접 처리해야 한다. 이럴 때는 catch에서 직접 오류를 처리해야 한다.
상태 유지와 key
리액트에서 상태(state)는 컴포넌트가 렌더링되는 위치에 따라 보존된다. 리액트는 동일한 위치에 동일한 컴포넌트가 렌더링되면, 이전 상태를 그래도 사용한다. 컴포넌트 타입이 다르더라도 구조(트리 안 위치)가 동일하면 상태를 보존한다. 하지만 만약 컴포넌트의 트리 구조(위치)가 달라지면, 리액트는 다른 컴포넌트로 인식하고 상태를 초기화한다.
동일 위치에 컴포넌트를 렌더링하지만 상태를 초기화하려고 할 때는 key 속성을 사용한다. 리액트는 컴포넌트의 key 속성이 다르면 완전히 새로운 컴포넌트로 인식해 상태를 초기화한다.
export default function App() {
const [isA, setIsA] = useState(true)
return (
<div>
<button
type="button"
onClick={() => setIsA((a) => !a)}
>
{isA ? 'A' : 'Z'}
</button>
{isA ? <Counter key="A" /> : <Counter key="B" />}
</div>
)
}
캡쳐 이벤트 리스너
기본족으로 리액트의 이벤트 핸들러는 버블 단계에서 작동한다. 이벤트가 하위(자손) 요소부터 시작해 최상위(루트) 요소로 흐른다. 리액트에서 캡쳐 이벤트를 사용하려면 이벤트 속성 이름 뒤에 Capture를 추가하면 된다.
<div
role="presentation"
onClickCapture={() => console.log('부모 div - Capture 단계')}
onClick={() => console.log('부모 div - Bubble 단계')}
>
<button
type="button"
onClickCapture={() => console.log('버튼 - Capture 단계')}
onClick={() => console.log('버튼 - Bubble 단계')}
>
이벤트 버블링
</button>
</div>
포털(Portal)을 사용하더라도 이벤트 흐름(캡쳐/버블)은 동일하게 작동한다.
복잡한 상태 관리
리액트를 사용하다보면 점점 상태 관리가 복잡해지는 순간이 찾아온다. 처음에는 useState 훅만으로도 충분했지만, 여러 값이 서로 얽혀있거나 상태 변화가 여러 곳에서 일어나면 코드가 점점 난해해진다.
Flux 아키텍처
Flux 아키텍처는 페이스북에서 만든 데이터 흐름 패턴으로, 복잡한 앱에서 데이터가 어디서 왔는지, 어떻게 바뀌는지 쉽게 알 수 있다. 하나의 방향으로만 데이터가 흐르기 때문이다. Flux 아키텍처의 주요 구성 요소는 액션, 디스패처, 스토어, 뷰다.
- 액션(Action): 사용자의 입력이나 이벤트를 나타냄
- 디스패처(Dispatcher): 모든 액션을 받아서 스토어에 전달하는 중간 관리자
- 스토어(Store): 실제 데이터와 상태를 저장. 액션을 받아서 데이터 변경
- 뷰(View): 사용자에게 보여지는 화면. 스토어에서 데이터를 받아와서 화면에 렌더링
useReducer 훅
리액트 앱의 상태 관리가 복잡해질 때 Flux 아키텍처의 원리를 활용하여 글로벌 상태를 관리하는 리덕스(Redux)와 유사한 방식으로 컴포넌트 내부 상태를 관리할 수 있는 useReducer 훅을 제공한다. 이 훅은 상태와 상태를 변경하는 로직을 분리해서 관리할 수 있게 해준다. 상태가 어떻게 변해야 하는지의 규칙을 하나의 함수(리듀서, Reducer)에 담고, 상태를 바꿀 때는 액션(Action)을 전달해 상태를 업데이트한다. 단, 리듀서 함수는 반드시 순수 함수여야 한다. 기존 state를 수정하면 안 되고, 항상 새로운 객체나 배열을 반환해야 한다.
useReducer 훅과 useState 훅을 비교해 보면 다음과 같다.
항목 | useState | useReducer |
코드 크기 | 코드가 간단하고 짧다. 상태가 단순할 때 적합하다. | 작성해야 할 코드가 더 많다. 여러 이벤트가 유사하게 상태를 바꿀 때 오히려 코드가 간결해질 수 있다. |
가독성 | 상태가 단순하거나 컴포넌트가 작을 때 읽기 쉽다. | 상태가 복잡하거나 컴포넌트가 커질수록 코드가 더 읽기 쉬워진다. |
디버깅 | 상태의 잘못된 위치와 이유를 파악하기 어렵다. | 모든 상태 업데이트와 액션이 리듀서에 모여 있어서 추적이 쉽다. |
테스트 | 상태 업데이트 로직이 컴포넌트에 묶여 있어 테스트가 어렵다. | 리듀서는 순수 함수라 별도로 내보내어 테스트하기 쉽다. |
선호도 | 직관적이고 익숙한 방식이다. | 구조적이고 명확한 방식이어서 선호도에 따라 선택할 수 있다. |
혼용 가능 | 다른 훅과 함께 자유롭게 사용할 수 있다. useReduce와 혼용하여 사용할 수 있다. |
useState와 함께 사용할 수 있다. 상황에 따라 적절히 선택하면 된다. |
상태가 단순하면 useState 훅 사용이 더 쉽고 간단하고, 상태가 복잡하거나 여러 이벤트가 비슷한 방식으로 상태를 바꾼다면 useReducer 훅이 더 명확하고 관리가 쉽다. 두 훅은 상황에 따라 자유롭게 섞어서 사용할 수 있다. 모든 컴포넌트에 리듀서를 강요할 필요는 없고, 필요한 곳에만 도입하면 된다.
오늘 하루를 돌아보며
오늘은 배운 내용은 충분히 이해되지 않았다. 아마 실습을 진행하지 않아서 그런 것 같다. 뭐든 직접 사용해 보고 실수도 해보면서 배우는 것 같다. 머리로만 이해하려고 하면 잘 안 들어오고, 실수하더라도 직접 시도해 봐야 이해된다. 틀리기도 하고 실수도 하면서 더 나은 방법, 맞는 방법을 알게 되는 것 같다. 내일 Todo List 실습을 할 것 같은데 그때 오늘까지 배운 내용을 복습하고 익히는 시간이 될 것 같아 기대된다.