안녕하세요! 오늘은 우리 리액트 개발자들이 가장 많이 고민하고, 또 가장 어려워하는 주제인 react component 설계에 대해 수다를 좀 떨어보려고 해요.
처음 리액트를 배울 때는 “오, 그냥 조각조각 나눠서 붙이면 되는 거 아냐?” 하고 가볍게 생각하죠. 그런데 프로젝트가 커지다 보면 어느새 컴포넌트 하나가 수천 줄이 되어 있고, props는 끝도 없이 내려가고… 나중엔 내가 짠 코드인데도 “이거 어디서부터 손대야 하지?” 하는 순간이 꼭 오더라고요. 저도 예전에 ‘신메뉴 알림 팝업’ 하나 고치려다가 메인 페이지 전체가 깨지는 걸 보고 멘탈이 나갔던 기억이 있네요. (웃음)
오늘은 제가 그동안 삽질하며 배운 경험들을 토대도, 어떻게 하면 컴포넌트를 예쁘고 튼튼하게 설계할 수 있을지 친한 동료에게 말하듯이 편하게 풀어볼게요. 특히 ‘추상화’라는 무서운 단어를 아주 쉽게 설명해 드릴 테니 끝까지 함께해주세요!
1. 컴포넌트라는 녀석, 도대체 정체가 뭘까?
우리가 react component 설계를 시작할 때 가장 먼저 마주하는 개념이 바로 ‘추상화’예요. 말이 좀 어렵죠? 쉽게 생각하면 **”복잡한 내부 사정은 숨기고, 겉으로 보이는 사용법만 깔끔하게 정리하는 것”**이라고 보면 돼요.
예를 하나 들어볼게요. 우리가 어떤 앱을 만들든 꼭 들어가는 ‘사용자 프로필 이미지와 접속 상태(Status Badge)’ 컴포넌트를 만든다고 쳐요.
// 내부 사정: 이미지가 로딩 중일 때 처리, 에러 났을 때 대체 이미지, 접속 상태에 따른 색상 변경 등등...
const UserAvatar = ({ src, status, size }) => {
const [isError, setIsError] = useState(false);
// 상태에 따른 색상 정하기 (이런 게 일종의 추상화된 로직이죠!)
const statusColor = status === 'online' ? 'green' : 'gray';
return (
<div className={`avatar-container ${size}`}>
<img
src={isError ? '/default-user.png' : src}
onError={() => setIsError(true)}
/>
<span className="status-badge" style={{ backgroundColor: statusColor }} />
</div>
);
};
이 UserAvatar를 쓰는 사람은 내부에서 useState가 어떻게 돌아가는지, onError 처리를 어떻게 하는지 알 필요가 없어요. 그냥 “이미지 주소랑 상태만 넘기면 알아서 그려주겠지?” 하고 믿고 쓰는 거죠. 이게 바로 컴포넌트 설계의 핵심인 ‘선언적 추상화’예요.
우리는 이렇게 작은 UI 조각부터 시작해서, 더 큰 페이지 단위까지 “무엇을 보여줄 것인가”에 집중해서 단위를 나누어야 해요. 여기서 중요한 건 **”얼마나 적절한 단위로 나누느냐”**겠죠?
2. 책임(Responsibility) : 하나만 잘하기도 바쁘다!
객체지향 원칙 중에 SRP(단일 책임 원칙)라는 게 있어요. “하나의 컴포넌트는 하나의 일만 해야 한다”는 건데, 프론트엔드에서는 이게 더 직관적으로 와닿아요. 바로 **”이 컴포넌트가 수정되어야 하는 이유는 오직 하나여야 한다”**는 뜻이거든요.
제가 초보 시절에 했던 실수가 뭐냐면, **’상품 좋아요(Like) 버튼’**을 만드는데 거기다 클릭하면 서버로 데이터를 보내는 로직, 애니메이션 효과, 폰트 스타일, 심지어 특정 페이지에서만 보여주는 예외 처리까지 다 때려 넣은 거예요.
나중에 “버튼 색깔만 좀 바꿔주세요”라는 요청이 왔는데, 그 안에 얽힌 서버 로직 때문에 테스트 코드를 다 다시 짜야 하는 상황이 오더라고요.
프론트엔드에서 책임이란 스타일, 데이터 흐름, 인터랙션을 모두 포함해요. 컴포넌트가 수행하는 행동(Action)이 너무 많아지면 응집도는 낮아지고 결합도는 높아져서 ‘변경’에 취약한 코드가 됩니다. 그래서 우리는 컴포넌트를 나눌 때 “얘가 지금 너무 많은 걸 알고 있나?”라고 스스로 질문해봐야 해요.
3. 범용적인 컴포넌트 설계: “너는 우리 서비스가 뭔지 몰라도 돼”
리액트 앱을 만들다 보면 컴포넌트는 크게 두 종류로 나뉘게 됩니다.
- 아무 데서나 다 쓸 수 있는 ‘공통 컴포넌트’ (버튼, 인풋, 모달 등)
- 우리 서비스의 특정 데이터에 꽉 묶여 있는 ‘비즈니스 컴포넌트’ (결제 폼, 피드 리스트 등)
3.1 도메인 지식을 지우자
공통 컴포넌트를 만들 때는 얘가 “우리 앱이 배달 앱인지, 쇼핑몰인지” 모르게 만드는 게 베스트예요.
예를 들어 ‘별점(Star Rating)’ 컴포넌트를 만든다고 해볼게요.
“이건 우리 식당 리뷰용이니까 restaurantId를 props로 받아야지!”라고 설계하면 어떻게 될까요? 나중에 영화 평점 기능을 만들 때 이 컴포넌트를 못 써요. 대신 value와 onChange 같은 범용적인 props를 받도록 설계하면 어디서든 재사용할 수 있겠죠.
3.2 합성과 조합 (Composition)
때로는 컴포넌트 하나가 너무 거대해지는 걸 막기 위해 ‘합성’을 사용해야 해요.
만약 **’여행 패키지 카드’**를 만드는데, 어떤 곳은 이미지가 크게 나오고, 어떤 곳은 텍스트 위주로 나온다면? Props로 isTextType={true} 이런 식으로 넘기기 시작하면 나중에 if문 지옥에 빠집니다.
대신 카드 내부를 쪼개서 조립해보세요.
const TravelCard = ({ children }) => <div className="card">{children}</div>;
TravelCard.Thumbnail = ({ url }) => <img src={url} />;
TravelCard.Body = ({ title, desc }) => (
<div><h3>{title}</h3><p>{desc}</p></div>
);
// 사용할 때는 이렇게 조립!
<TravelCard>
<TravelCard.Thumbnail url="..." />
<TravelCard.Body title="파리 7일 여행" desc="..." />
</TravelCard>
이걸 react component 설계 패턴 중 하나인 ‘컴파운드 패턴’이라고도 부르는데, 구조가 유연해져서 요구사항 변경에 아주 강력하게 대처할 수 있어요.
4. 도메인과 로직의 분리: 커스텀 훅의 마법
비즈니스 로직(API 호출, 데이터 가공 등)은 컴포넌트의 가독성을 해치는 주범이죠. 이때 우리를 구해주는 게 바로 **커스텀 훅(Hook)**입니다.
만약 **’현재 판매 중인 아이템 목록’**을 보여주는 컴포넌트가 있다고 쳐요.
여기서 서버에서 데이터를 가져오고(fetch), 에러 처리하고, 실시간 재고 확인까지 다 하면 코드가 너무 길어지겠죠?
// 컴포넌트는 그냥 "나 이거 그려줘!"라고만 말함
const InventoryList = () => {
const { items, isLoading, error } = useInventory(); // 비즈니스 로직을 훅으로 쏙!
if (isLoading) return <Loading />;
return (
<ul>
{items.map(item => <ItemRow key={item.id} {...item} />)}
</ul>
);
};
이렇게 하면 장점이 뭘까요?
- 컴포넌트에는 UI 코드만 남아서 눈에 확 들어와요.
useInventory라는 로직은 나중에 관리자 페이지의 재고 현황에서도 똑같이 쓸 수 있어요.- 테스트하기도 훨씬 쉬워지죠.
5. 데이터 결합도 조절하기: 통합과 분리의 예술
이 부분이 제가 오늘 가장 강조하고 싶은 내용이에요. 설계할 때 불필요한 결합도를 낮추기 위해 데이터를 통합할 수도 있고, 반대로 분리를 통해 결합도를 낮출 수도 있어요.
처음 들으면 “합치는 게 결합도를 낮춘다고? 쪼개는 게 낮추는 거 아냐?” 하고 헷갈릴 수 있는데, 상황에 따라 정답이 달라요. 제 경험을 빌려 설명해 드릴게요.
5.1 데이터를 통합하여 결합도를 낮추는 경우
여러 개의 데이터가 언제나 세트로 움직인다면, 차라리 하나로 묶는 게 유지보수에 유리해요.
예를 들어 **’항공권 예약 정보’**라고 해볼까요?
출발지, 목적지, 출발시간, 도착시간은 항공권이라는 하나의 도메인 안에서 항상 같이 다녀요. 이걸 각각 따로 props로 넘기면, 나중에 ‘좌석 등급’ 정보가 추가될 때 관련된 모든 컴포넌트의 인터페이스를 다 수정해야 하거든요.
이럴 땐 TicketInfo라는 객체로 묶어서 하나만 넘겨주세요. 데이터 구조가 바뀌어도 컴포넌트끼리 주고받는 ‘통로(props)’는 그대로니까 결합도가 낮아지는 효과가 있어요.
5.2 데이터를 분리하여 결합도를 낮추는 경우
반대로 서로 상관없는 데이터들이 하나의 거대한 객체(God Object)에 묶여 있을 때가 문제예요.
‘앱의 모든 설정’이라는 전역 상태가 있다고 쳐보죠. 그 안에 다크모드 여부와 로그인한 유저의 카드 정보가 같이 들어있다면? 다크모드 하나 바꿨는데 결제 정보를 보여주는 컴포넌트까지 리렌더링되는 비효율이 발생해요. 또, 결제 정보가 필요 없는 컴포넌트조차 그 거대한 객체에 의존하게 되죠.
이럴 땐 관심사에 따라 상태를 쪼개야 해요. react component 설계를 잘한다는 건, 컴포넌트가 “자기가 꼭 필요한 것만 알게” 만드는 거예요. 남의 집 사정까지 다 알게 되면 그만큼 코드가 꼬이게 되거든요.
6. 마치며: 설계에 정답은 없지만 ‘나쁜 코드’는 있다
긴 글 읽어주셔서 정말 감사해요! 3000자가 넘는 내용을 다루다 보니 이것저것 이야기가 많았네요. 사실 저도 아직 완벽하게 설계하는 건 아니에요. 어떤 날은 “와, 나 오늘 코드 진짜 잘 짰다” 하다가도 일주일 뒤에 보면 “누가 이딴 식으로 짰어?” 하고 자책하기도 하거든요. (그게 저라는 게 함정이지만요!)
오늘 우리가 나눈 이야기를 정리해볼까요?
- 추상화는 어렵게 생각하지 말자. 내부의 복잡함을 숨기고 사용하기 편하게 만드는 것이다.
- 범용 컴포넌트는 도메인을 모르게, 비즈니스 컴포넌트는 로직을 훅으로 분리해서 깔끔하게 유지하자.
- 데이터 설계는 상황에 따라 합치거나 쪼개서 컴포넌트 간의 불필요한 의존성을 끊어내자.
초보 개발자분들께 꼭 드리고 싶은 말은, 처음부터 완벽한 설계도를 그리려고 너무 애쓰지 마세요. 일단 만들고, 불편함이 느껴질 때 리팩토링하면서 배우는 게 훨씬 빨라요. 다만 “이 컴포넌트가 수정되어야 하는 이유가 지금 몇 개인가?”라는 질문만은 계속 던져보셨으면 좋겠어요.
저도 아직 모르는 게 많아서 공부 중이지만, 우리가 함께 고민하다 보면 어느새 시니어 개발자 같은 멋진 코드를 짜고 있지 않을까요? 궁금한 점이 있거나 “내 코드는 이런데 어떻게 하면 좋을까요?” 같은 고민이 있다면 언제든 댓글이나 메신저로 편하게 물어봐 주세요! 우리 같이 성장해봐요.
자, 그럼 오늘도 즐거운 코딩 하시고, 여러분의 react component 설계가 한층 더 단단해지길 응원하겠습니다! 화이팅! 😊