1) 컴포넌트 기반 아키텍처
컴포넌트 기반 아키텍처는 웹 애플리케이션의 복잡한 UI를 재사용 가능한 단위로 분할하는 방식을 말한다. 컴포넌트는 자체적으로 상태와 속성을 가지고 있으며, 독립적으로 작동한다. 각 컴포넌트는 특정 기능이나 UI의 한 부분을 책임진다.
컴포넌트 기반 아키텍처를 설계할 때는 구성 요소 간의 의존성을 최소화하고, 각 컴포넌트는 한 가지 책임만 진다는 단일 책임 원칙을 염두에 두어야 한다. 즉, 컴포넌트 복잡도를 낮추고 재사용성을 높여야 한다.
2) JSX 문법
리액트에서는 JSX(JavaScript XML) 문법을 사용한다. JSX는 자바스크립트를 확장한 문법으로 HTML과 유사한 형태이며, 컴포넌트 렌더링 로직과 마크업을 한 곳에서 관리할 수 있다. JSX를 작성할 때는 다음과 같은 규칙을 따라야 한다.
3) 가상 DOM(Virtual DOM)
가상 DOM(Document Object Model)은 실제 DOM을 추상화한 DOM을 말한다. 리액트에서는 컴포넌트가 처음 렌더링될 때 가상 DOM 트리를 생성하고, 이후 상태나 속성이 변경되면 비교(Diffing)와 조정(Reconciliation) 절차를 통해 변경된 부분만 실제 DOM에 반영한다. 리액트는 이렇게 가상 DOM을 이용하여 불필요한 DOM 조작을 최소화한다.
4) Props와 State
Props와 State는 속성과 상태를 나타내며, 리액트 컴포넌트에서 데이터를 관리하는 두 가지 주요 개념이다.
Props는 Properties의 약자로 부모 컴포넌트에서 자식 컴포넌트로 전달되는 데이터를 말한다. Props는 일반적으로 읽기 전용(read-only)이며, 자식 컴포넌트에서 직접 수정할 수 없다.
State는 컴포넌트 내부에서 관리되는 상태 데이터를 말한다. 컴포넌트 내부에서 직접 수정할 수 있으며, State가 변경되면 컴포넌트는 자동으로 다시 렌더링(Re-rendering)된다. 리액트에서는 다양한 방법으로 State를 관리한다. 그 중에서 가장 기본적인 방법이 useState라는 리액트 훅(React Hook)을 사용하는 방법이다.
컴포넌트 내에서 다양한 리액트 기능을 사용할 수 있게 해주는 일종의 함수 API이다.
훅을 사용하면 컴포넌트 로직을 간결하게 작성할 수 있을 뿐만 아니라, 컴포넌트 간에 상태를 공유하거나 불필요한 렌더링을 방지하여 성능을 최적화하는 등의 역할을 수행할 수 있다.
대표적으로 자주 사용하는 훅으로는 useState, useRef, useEffect, useMemo, useReducer 등이 있다.
1. useState
useState는 컴포넌트에서 상태를 관리하기 위한 훅이다. useState를 사용하면 상태 값과 상태를 업데이트하는 함수를 받는다.
2. useRef
useRef는 컴포넌트 내에서 특정 값을 저장하고 참조할 수 있게 해준다. useRef로 생성한 ref 객체는 컴포넌트 생명주기 동안 유지되며, 값이 변경되어도 컴포넌트가 다시 렌더링되지 않는다.
주로 DOM 엘리먼트에 직접 접근해야 하거나 이전 값을 저장해야 할 때 사용된다.
useRef를 사용하여 저장한 값은 current 속성으로 접근할 수 있다.
아래 예제에서 useRef(null)는 초기 값이 null인 ref 객체를 생성하고, 이 ref 객체를 input 엘리먼트에 연결하면 inputEl.current를 통해 해당 엘리먼트에 접근할 수 있다.
import React, { useRef } from 'react';
const TextInputWithFocusButton = () => {
const inputEl = useRef(null); // 초기값이 null인 ref 객체 생성
const onButtonClick = () => {
inputEl.current.focus(); // current 속성을 통해 접근
};
return (
<>
<input ref={inputEl} tyle="text" /> // ref 객체를 input 엘리먼트에 연결
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
export default TextInputWithFocusButton;
3. useEffect
useEffect는 컴포넌트이 Side Effect(부수 효과)를 처리하기 위해 사용하는 리액트 훅이다.
useEffect는 컴포넌트가 렌더링된 후에 실행되며, 두 개의 인자를 받는다. 첫 번째 인자는 Side Effect 함수이고, 두 번째 인자는 의존성 배열이다. 의존성 배열에 특정 값을 넣으면 그 값이 변경될 때마다 Side Effect 함수가 실행된다. 만약 의존성 배열을 빈 배열로 넣으면, 컴포넌트가 마운트될 때만 Side Effect 함수가 실행되며, 의존성 배열을 생략하면 컴포넌트가 업데이트될 때마다 실행된다.
4. useMemo
useMemo는 계산량이 많은 함수의 반환 값을 메모이제이션(Memoization)하여 불필요한 중복 계산을 방지하는 리액트 훅이다. useMemo도 두 개의 인자를 받는다. 첫 번째 인자는 메모이제이션 할 함수이고, 두 번째 인자는 의존성 배열이다. useMemo는 의존성 배열에 있는 값이 변경되지 않는 한, 이전에 계산된 값을 재사용한다.
5. useReducer
useReducer는 useState와 같이 컴포넌트의 상태를 관리하기 위한 훅이다. useState는 컴포넌트 내에 상태를 업데이트하는 로직을 두어야 하는 반면, useReducer는 상태 업데이트 로직을 컴포넌트 외부에 둘 수 있다. 이를 통해 중복되는 상태 업데이트 로직을 한 곳에 모아 관리할 수 있다. 특히 여러 개의 상태를 관리해야 하거나, 프로젝트 규모가 큰 경우에 유용하게 사용할 수 있다.
import React, { useReducer } from 'react';
const initialState = { count: 0 };
// 상태와 액션 객체를 인자로 받는 reducer 함수
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }; // 새로운 상태를 반환
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error('Unsupported action type');
}
}
const Counter = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
// 액션 객체를 인자로 받는 dispatch 함수
<button onClick={() => dispatch({ type: 'increment' })}+</button>
<button onClick={() => dispatch({ type: 'decrement' })}-</button>
</div>
);
}
export default Counter;
위 예시 코드는 Counter 컴포넌트의 상태를 업데이트하기 위한 useReducer 사용 예시이다. useReducer를 사용하기 위해서는 Reducer 함수와 Dispatch 함수가 필요하다. Reducer 함수는 state와 action 객체를 인자로 받아 새로운 상태를 반환하며, Dispatch 함수는 action 객체를 인자로 받아 Reducer 함수를 호출하게 된다.
컴포넌트 간 데이터 전달 방법
기본적으로 리액트에서 컴포넌트 간 데이터를 전달하는 방법은 props를 사용하는 것이다. 보통 props를 이용하여 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달한다. 하지만, 실무에서는 자식에서 부모 컴포넌트로 데이터를 전달해야 하는 경우도 있다.
비록 리액트에서는 단방향 데이터 흐름(one-way data flow)을 권장하고 있지만, 이런 경우에는 부모 컴포넌트에서 콜백 함수를 props로 전달하고, 자식 컴포넌트에서 해당 함수를 호출하는 방식으로 처리하기도 한다. 이때 useCallback 훅을 사용하여 부모 컴포넌트가 렌더링될 때, 불필요하게 자식 컴포넌트가 렌더링되지 않도록 하는 것이 중요하다.
웹 애플리케이션의 규모가 커지면 props를 통한 데이터 전달이 복잡해진다. 이런 경우에는 리액트에서 기본적으로 제공하는 ContextAPI를 이용하기도 한다. ContextAPI는 컴포넌트 트리 상위에서 데이터를 제공하고 하위의 어떤 컴포넌트에서든 해당 데이터에 접근할 수 있게 해준다.
이를 통해 props 드릴링(drilling)이 없이도 데이터를 전달할 수 있다. 또한 리덕스(Redux), 몹엑스(MobX), 주스탠드(Zustand) 같은 상태 관리 라이브러리를 사용하면 전역 상태를 관리할 수 있어 컴포넌트 간 데이터 전달을 더욱 용이하게 할 수 있다.
1) 불필요한 렌더링 방지
리액트 애플리케이션 성능을 최적화하기 위해서는 먼저 불필요한 렌더링을 방지하는 것이 중요하다. 예를 들어, 함수형 컴포넌트에서 특정 props의 변화에만 컴포넌트가 렌더링되게 하려면 React.memo를 사용하는 방법이 있다.
참고로 React.memo는 고차 컴포넌트(Higher-Order Component)이다. 고차 컴포넌트란 컴포넌트를 인자로 받아 새 컴포넌트를 반환한다. 따라서 위 예시와 같이 부모 컴포넌트에서 자식 컴포넌트와 관련없는 count 상태 값이 변경되더라도, 자식 컴포넌트에 대한 불필요한 렌더링이 발생하지 않는다.
이와 같이 React.memo는 특정 props의 변경에만 렌더링되도록 조건을 설정할 수 있다. 다만 React.memo는 얕은 비교(Shallow Equal)를 하기 때문에, props가 함수이거나 객체인 경우에는 자식 컴포넌트의 렌더링이 발생할 수 있다. 이를 해결하기 위해서 아래 코드처럼 useCallback이나 useMemo와 같은 훅을 사용하기도 한다.
useCallback과 useMemo를 사용하면 콜백 함수나 객체를 메모이제이션하여 부모 컴포넌트에서 새로운 함수나 객체 주소가 할당되더라도, 자식 컴포넌트의 렌더링을 방지할 수 있다. 다만 useCallback이나 useMemo와 같은 메모이제이션 훅은 추가적인 메모리가 필요하므로, 무분별하게 사용하면 오히려 성능이 저하될 수도 있다.
2) 코드 스플리팅(Code Splitting)과 레이지 로딩(Lazy Loading)
코드 스플리팅(Code Splitting)은 번들링된 자바스크립트 코드를 여러 개의 작은 조각 단위(chunk)로 분할하는 것을 말한다. 일반적으로 리액트 애플리케이션은 모든 코드를 하나의 큰 번들로 빌드하여 배포하는데, 이는 초기 로딩 시간을 길어지게 할 수 있다.
반면 코드 스플리팅을 하면 필요한 코드만 동적으로 로드하여 초기 번들 크기를 줄이고, 로딩 속도를 개선할 수 있다. 리액트에서 코드 스플리팅을 구현하기 위해서는 React.lazy() 함수와 Suspense 컴포넌트를 이용한 레이지 로딩(Lazy Loading)을 이용하기도 한다.
import React, { Suspense } from 'react';
// React.lazy()를 사용하여 컴포넌트 로딩을 지연함
const LazyComponent = React.lazy(() => import('./LazyComponent'));
const MyComponent = () => {
return (
<div>
// Suspense 컴포넌트로 레이지 로딩된 컴포넌트가 로드되는 동안 fallback UI를 보여줌
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</div>
);
}
export default MyComponent;
위 예제 코드에서는 React.lazy()의 동적 임포트를 통해 컴포넌트 로딩을 지연하고, Suspense 컴포넌트로 로딩 중 폴백(fallback) UI를 보여주어 레이지 로딩을 구현하고 있다. 이처럼 레이지 로딩을 적용하면, 애플리케이션의 초기 로딩 속도와 성능을 최적화할 수 있다.