해당 글은 "패턴으로 익히고 설계로 완성하는 리액트" 1장을 읽고 작성된 글입니다.
서론

"그게 어제의 최선이었다."
이번 글의 시작은 위 문구로 시작해보고 싶어서 이미지를 가져와 봤다.
나는 개발을 하면서 여러 코드를 작성하고 지우고 고친다.
코드를 잘 작성하기 위해 많은 것들을 배우지만, 나중에 보면 왜 이렇게 작성했을까.. 라는 생각이 많이 든다.
코드를 잘 작성한다는 것은 뭘까?
모든 개발자들이 아마 코드를 잘 작성하고 싶어할 것이다.
다만 이에 대해선 사람마다 기준이 다 다를 것이다.
이번 글에서 소개하는 리액트 안티패턴을 알아둔다면, 어떤 코드를 피해야 하는 지 본인 만의 기준을 만들 수 있을 거라 생각한다.
안티패턴은 기술적 오류는 아니지만, 차후 문제를 일으키거나 확장을 어렵게 할 수 있다.
우선 안티패턴 이전에, 리액트로 최신 프런트엔드 애플리케이션을 구축하는데 따르는 어려움에 대해서 알아보자.
복잡한 상태관리
과거 HTML만을 통해서 만들던 웹과는 달리, 현대에서는 보기편한 UI, 유저 친화적인 UX 등 웹을 통해 다양한 가치를 제공하기 위해 웹은 복잡해졌다.
드롭다운 메뉴, 아코디언, 대화형 카드 등 다양한 UI 요소들이 서로 상호작용 하려면 각 요소들의 상태를 관리해야하고 개발자는 이러한 복잡한 상태를 효율적으로 관리해야 한다.
상태 관리의 이해
상태는 로컬 상태만 있는 것이 아니다.
애플리케이션의 데이터는 대부분 네트워크를 통해 서버에서 데이터를 가져오는데, 이런 데이터를 서버 상태라고 한다.
서버 상태는 주의해서 다루지 않으면 프런트엔드 개발을 어렵게 만든다.
아래는 서버 상태를 다룰 때의 주의해야 할 사항들이다.
| 비동기 특성 | 원격 소스에서 데이터를 가져오는 것은 일반적으로 비동기 작업이다. 특히 여러 원격 데이터를 동기화할 때 시간의 순서가 중요하다. |
| 오류 처리 | 원격 소스 연결은 때로는 실패하거나 서버에서 오류를 응답할 수 있다. |
| 로딩 상태 | 원격 소스에서 데이터가 도착하기를 기다리는 동안 애플리케이션은 '로딩 중' 상태를 효과적으로 다뤄야 한다. 일반적으로 로딩 표시기 또는 실패 시 대체 UI(fallback UI)를 표시한다. |
| 일관성 | 프런트엔드 상태를 백엔드와 동기화하는 것을 의미한다. 실시간 애플리케이션이나 여러 사용자가 데이터를 변경하는 애플리케이션의 경우 특히 다루기가 어렵다. |
| 캐싱 | 일부 서버 상태를 로컬에 저장하면 성능을 향상시킬 수 있지만, 데이터 불일치와 무효화 같은 문제가 생길 수 있다. |
| 업데이트 및 낙관적 UI | 사용자가 상태를 변경하면 서버 호출이 성공했을 때 UI를 응답이 오기 전에 미리 업데이트 하여 더 나은 사용자 경험을 제공할 수 있다. 하지만 서버 응답은 실패할 수 있으므로 성공 이전의 상태로 되돌릴 수 있는 방법이 필요하다. |
이는 일부에 불과하다.
단순 정적인 데이터에서 원격으로 데이터를 불러오는 방식으로 수정하기만 해도 코드의 양이 증가한다.
데이터가 잘 불러와지면 좋겠지만, 비동기 호출은 예외 상황도 고려해야 하므로 Fallback UI나 오류 시나리오 등 여러 고민을 해야한다.
이처럼 프런트엔드의 복잡도를 늘리는 요인으로 예외 흐름(unhappy path)이 있다.
예외 흐름 탐색하기
보통 UI를 개발할 때 모든 상황이 완벽하게 흘러가는 정상 흐름(happy path)를 우선시해서 개발하기 마련이다.
하지만 예외 흐름을 무시하고 개발한다면, UI 컴포넌트는 생각지도 못한 에러를 맞이할 수도 있다.
1. 다른 컴포넌트에서 발생한 오류
애플리케이션을 개발할 때, 서드 파티 컴포넌트를 사용한다고 가정해보자.
이 컴포넌트에서 에러가 발생하면, UI 화면을 망가뜨리거나 심하게는 전체 어플리케이션이 멈춰버릴 수도 있다.
이런 끔찍한 일들이 벌어질수도 있기 때문에, 애플리케이션은 이러한 예외 흐름에 대해서 준비되어 있어야 한다.
대체 디자인, 로딩 표시기, 에러 상황시 안내 문구 등이 필요하다.
다만 이러한 처리들을 하다보면, UI 로직에는 예외 처리에 대한 로직이 붙게되고 그럼 자연스럽게 코드는 복잡해질 것이다.
2. 예측하지 못한 사용자 행동
아무리 UI를 완벽하게 설계 하더라도 사용자는 예상치 못한 행동을 통해 에러를 일으킬 수도 있다.
이러한 엣지 케이스에 대비할 수 있는 UI를 설계하면 좋겠지만, 유효성 검사와 안전 장치를 추가적으로 구현하다 보면 UI 코드는 더 복잡해질 것이다.
위 내용을 통해 리액트로 최신 프런트엔드 애플리케이션을 구축하는데 따르는 어려움에 대해서 알아봤다.
리액트는 문제 해결을 위한 명확한 가이드를 제공해주지 않는다.
- 리액트는 어떤 식으로 접근 방식을 채택해야 하는지
- 코드를 어떻게 구조화하고 상태를 관리해야 하는지
- 어떻게 코드의 가독성을 높이고 유지보수성을 높일 수 있는지
- 기존에 많이 쓰이는 패턴이 어떻게 도움이 될 수 있는지..
따라서 당장 눈앞의 문제만 해결하다 보면 결국 안티패턴으로 가득한 코드가 되고 말 것이다.
그럼 이제 본론이였던 리액트 안티패턴들을 살펴보자.
리액트의 일반적인 안티패턴 살펴보기
1. Prop Drilling
모든 컴포넌트가 필요한 데이터에 접근할 수 있도록 상태를 관리하는 것은 어려울 수 있다.
prop이 부모 컴포넌트에서 자식 컴포넌트로 전달될 때 여러 개의 중간 컴포넌트를 거쳐서 전달하게 되는 Props Drilling 현상이 나타나기 때문이다.
이러한 방식은 복잡도를 높이고 유지보수성을 떨어뜨린다.
또한, 여러 개의 prop이 여러 컴포넌트를 통해 전달되면 데이터 흐름에 대한 이해를 방해하고 디버깅 하기 어려워 질 수 있다.
이에 대한 잠재적인 솔루션으로는 Context API를 사용하는 것이다.
그렇게 되면 컴포넌트 트리의 모든 단계마다 prop을 명시적으로 전달하지 않아도, 컴포넌트 간에 데이터와 함수를 공유할 수 있다.
2. 컴포넌트 내 데이터 변환
리액트에서 컴포넌트 중심의 접근 방식은 작업과 문제를 다루기 쉬운 단위로 나눠서 유지보수성을 높여준다.
그러나 개발자들이 자주 하는 실수 중 하나는 컴포넌트 내부에 복잡한 데이터 변환 로직을 작성하는 것이다.
아래 사례를 봐 보자.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => {
// 컴포넌트 안에서 데이터 변환
const transformedUser = {
name: `${data.firstName} ${data.lastName}`,
age: data.age,
address: `${data.addressLine1}, ${data.city}, ${data.country}`,
};
setUser(transformedUser);
});
}, [userId]);
return (
<div>
{user && (
<>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<p>Address: {user.address}</p>
</>
)}
</div>
);
}
UserProfile 컴포넌트는 fetch를 통해 가져온 원격 데이터를 컴포넌트 내에서 변환하여 UI 구조에 맞게 수정하고 있다.
이렇듯 컴포넌트 내부에서 직접 데이터를 변환하면 다음과 같은 문제들이 따를 수 있다.
- 명확하지 않음: 데이터 가져오기와 변환, 렌더링 작업이 하나의 컴포넌트 안에서 이루어지므로 어떤 역할을 하는 컴포넌트 인지 알기 어려움.
- 재사용성이 떨어짐: 다른 컴포넌트에서 유사한 변환이 필요한 경우, 로직의 중복이 발생함.
- 테스트하기 어려움: 테스트를 하려면 변환 로직을 고려해야 하므로 테스트 코드가 더 복잡해짐.
해당 안티패턴의 솔루션은 데이터 변환 로직을 컴포넌트 분리하는 것이 좋다.
유틸리티 함수나 custom hook을 이용해, 보다 명확하고 모듈화 된 구조로 바꿔주자.
데이터 변환로직을 외부로 분리하면, 컴포넌트는 렌더링과 비즈니스 로직에 집중할 수 있으므로 더욱 유지보수가 쉬운 코드를 만들 수 있다.
3. 뷰 영역의 복잡한 로직
최신 프런트엔드 프레임워크의 장점 중 하나는 관심사를 명확하게 분리할 수 있게 해준다는 것이다.
설계상 컴포넌트는 비즈니스 로직을 신경 쓰지 않고 보여지는 것에 집중해야 한다.
하지만 개발자들은 View 컴포넌트 안에서 비즈니스 로직을 다루는 함정에 종종 빠진다.
이는 깔끔한 관심사 분리를 방해하고, 테스트와 재사용을 어렵게 한다.
간단한 예시를 통해 알아보자.
function PricelistView({ items }: {{ items: Item[] }) {
// 뷰 내부의 비즈니스 로직
const filterExpensiveItems = (items: Item[]) => {
return items.filter((item) => item.price > 100);
};
const expensiveItems = filterExpensiveItems(items);
return (
<div>
{expensiveItems.map((item) => (
<div key={item.id}>
{item.name}: ${item.price}
</div>
))}
</div>
);
}
위 PricelistView 컴포넌트는 데이터를 표시하는 것 뿐만 아니라 가공하는 작업도 수행한다.
이렇게 되면 아래와 같은 문제들이 생긴다.
- 재사용성: 다른 컴포넌트에서 유사한 필터가 필요한 경우, 로직이 중복됨.
- 테스팅: 렌더링 뿐만 아니라 비즈니스 로직도 테스트해야 하므로 단위 테스트가 복잡해진다.
- 유지보수성: 애플리케이션이 커지면서 더 많은 로직이 추가되기 때문에 유지보수하기 어려워진다.
이에 대한 솔루션으로는 관심사 분리 원칙을 지키고 계층화된 아키텍처를 통해 비즈니스 로직과 프레젠테이션 계층을 분리하면 좋다.
코드의 각 부분이 명확한 책임을 가지게해 모듈화되고 관리하기 쉬운 코드베이스를 구축할 수 있다.
4. 테스트 부족
만약 테스트 코드를 작성하지 않는다면, 에러가 발생하기 전까진 잠재적인 문제들을 확인하기가 어렵다.
따라서 TDD(Test-Driven Development)를 추천한다.
TDD는 오류를 조기에 발견할 수 있게 하고, 잘 구조화되고 유지보수 가능한 코드를 작성할 수 있게 해준다.
나중에 기능을 추가하거나 확장해야할 때, TDD로 검증된 코드들이 있다면 안정적으로 할 수 있을 것이다.
5. 중복된 코드
중복된 코드는 코드베이스를 부풀릴 뿐 아니라, 잠재적인 문제를 가져올 수 있다.
또한 중복된 코드에서 버그가 발생하면 개선이 필요할 때, 각각의 중복된 코드 모두를 변경해야 하므로 오류가 발생할 가능성이 높아진다.
동일한 필터링 로직이 반복되는 두 컴포넌트를 살펴보자.
function AdminList({ users: { isAdmin: boolean; name: string }[] }) {
const filteredUsers = props.users.filter((user) => user.isAdmin);
return <list items={filteredUsers} />;
}
function ActiveList({ users: { isActive: boolean; name: string }[] }) {
const filteredUsers = props.users.filter((user) => user.isActive);
return <list items={filteredUsers} />;
}
중복된 코드에 대한 솔루션은 중복 배제 원칙이 도움이 된다.
공통 로직을 유틸리티 함수나 고차 컴포넌트로 모아서 관리하면, 유지보수하기 편하고 가독성이 높은 코드가 되며 오류 발생 가능성이 줄어든다.
6. 너무 많은 기능을 가진 컴포넌트
리액트는 재사용 가능한 모듈형 컴포넌트를 만들도록 권장한다.
그러나 기능이 늘어나면 컴포넌트는 맡게 되는 책임이 늘어나면서 다루기 힘들어진다.
const OrderContainer= ({
testID,
orderData,
basketError,
addCoupon,
voucherSelected,
validationErrors,
removeLine,
editLine,
hideOrderButton,
hideEditButton,
}: OrderContainerProps) => {
//...
}
이 컴포넌트는 단일 책임 원칙에 위배된다.
해당 컴포넌트에 대한 솔루션은 핵심 기능을 분석하고 부가적인 지원 로직들을 더 작고 집중된 컴포넌트나 훅으로 분리해야 한다.
후기
1장을 읽으면서 든 생각은 대부분 잘 지키고 있지만, 단일 책임 원칙이나 비즈니스 로직 분리를 명확하게 해주지 못하고 있는게 아닌가 하는 생각이 문득 들었다.
요즘 좋은 코드를 작성하기 위한 기준을 스스로 찾고 내재화 할 수 있도록 노력중인데 좋은 책을 발견한 것 같아 기쁘다.
안티패턴에 대해서 알아보는 파트에서 뭔가 toss의 Frontend Foundamental 이 떠올랐는데 이번 글이 흥미로웠거나 추가적으로 공부해보고 싶다면 큰 도움이 될 것 같다.
'개발' 카테고리의 다른 글
| Next.js App Router에서 MSW를 세팅해보자 🔨 (aka. 프론트 병목 줄이기) (8) | 2025.08.24 |
|---|---|
| 2. 프레임워크가 렌더링에 주는 이점과 React Batching (2) | 2025.03.16 |
| 1. 브라우저 렌더링 과정 (0) | 2025.03.16 |
| 입력 값이 바뀜에 따라 요청이 계속 보내진다면? (Throttling & Debouncing) (4) | 2025.01.12 |
| "NET::ERR_CERT_DATE_INVALID" 에러 & SSL 인증서 재발급 (2) | 2024.09.13 |