# 서론
1일차 미션에서 '같이 실행되지 않는 코드 분리하기'의 기본기를 다졌다면, 2일차는 이를 실제 데이터 패칭과 상태 관리가 얽힌 복잡한 실무 코드에 적용해 보는 시간이었다.
별 거 아닌 미션 같았지만 실제로 진행하면서는 지금까지 가지고 있던 나의 많은 고정 관념이 깨지면서 '분리 기준'에 대해서 다시 생각해볼 수 있었다. 머리로는 분리해야 한다는 것을 알면서도 막상 코드를 찢어내는 과정에서 기존의 고정관념들과 강하게 충돌했다.
"공통 UI는 부모가 들고 있어야 하지 않나?"
"코드가 완전히 중복되는데 진짜 이게 맞나?"
같은 의문들이 꼬리를 물었고, 이를 해결하는 과정에서 React 렌더링 최적화와 우아한 TypeScript 설계에 대해 깊이 배울 수 있었다.
# 뒤엉킨 회원과 비회원 로직
2일차 미션으로 마주한 코드는 결제/예약 확인 페이지의 레거시 컴포넌트(LegacyOrderView)였다.
가장 치명적인 문제는 회원(Member)인 경우와 비회원(Guest)인 경우의 로직을 단 하나의 파일에서 전부 처리하고 있다는 점이었다.
## [Before Code] 리팩토링 전
import React, {useState, useEffect} from 'react';
interface OrderDetail {
orderId: string;
amount: number;
productName: string;
}
interface LegacyOrderViewProps {
isGuest: boolean;
guestPhoneNumber?: string;
memberId?: string;
}
export function LegacyOrderView({isGuest, guestPhoneNumber, memberId}: LegacyOrderViewProps) {
const [order, setOrder] = useState<OrderDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 문제 1: 비회원 전용 상태와 회원 전용 상태가 한 컴포넌트에 공존함
const [guestAuthToken, setGuestAuthToken] = useState<string | null>(null);
const [memberPoints, setMemberPoints] = useState<number>(0);
// 문제 2: useEffect 내부에 같이 실행되지 않는 비즈니스 로직(데이터 패칭)이 섞여 있음
useEffect(() => {
async function fetchOrderData() {
setIsLoading(true);
try {
if (isGuest && guestPhoneNumber) {
const response = await fetch(`/api/guest/orders?phone=${guestPhoneNumber}`);
const data = await response.json();
setOrder(data.order);
setGuestAuthToken(data.token);
} else if (!isGuest && memberId) {
const response = await fetch(`/api/member/orders/${memberId}`);
const data = await response.json();
setOrder(data.order);
setMemberPoints(data.points);
}
} catch (error) {
console.error('주문 조회 실패', error);
} finally {
setIsLoading(false);
}
}
fetchOrderData();
}, [isGuest, guestPhoneNumber, memberId]);
if (isLoading) return <div>로딩 중...</div>;
if (!order) return <div>주문 내역이 없습니다.</div>;
return (
<div style={{padding: '20px', border: '1px solid #ccc'}}>
<h2>주문 상세 내역</h2>
<p>주문 상품: {order.productName}</p>
<p>결제 금액: {order.amount}원</p>
{/* 문제 3: 서로 다른 조건의 UI가 삼항 연산자로 산만하게 묶여 있음 */}
{isGuest ? (
<div className='guest-action-box' style={{marginTop: '16px', color: '#ff4d4f'}}>
<p>⚠️ 비회원 주문입니다. (인증 토큰: {guestAuthToken ?? '없음'})</p>
<button onClick={() => alert('비회원 전용 영수증 출력')}>비회원 영수증 발행</button>
</div>
) : (
<div className='member-action-box' style={{marginTop: '16px', color: '#3d9c48'}}>
<p>✨ 회원 주문 혜택</p>
<p>적립 예정 포인트: {memberPoints} P</p>
<button onClick={() => alert('회원 전용 쿠폰 발급')}>포인트 더블 적립 쿠폰 받기</button>
</div>
)}
</div>
);
}
코드를 읽는 개발자는 회원일 때의 맥락과 비회원일 때의 맥락을 동시에 뇌내 메모리에 올려두어야 했다. 더 큰 문제는 렌더링 관점이었다.
비회원이 이 페이지를 보고 있을 때도 쓸모없는 memberPoints 상태가 메모리를 차지하고 있고, 반대의 경우도 마찬가지였다.
# 리팩토링 과정에서 마주한 3가지 병목과 해결책
병목 1. 상태를 자식으로 내릴 수가 없다 (데이터 흐름의 착각)
처음에는 삼항 연산자로 묶여 있던 하단의 UI 박스만 GuestActionBox와 MemberActionBox로 분리하려고 했다. 하지만 곧바로 막혔다.
'<h2>주문 상세 내역</h2>' 같은 공통 레이아웃이 부모에 남아있어야 한다고 생각했고, 그러다 보니 데이터를 패칭하는 useEffect와 로딩 상태 역시 부모에서 도저히 떼어낼 수 없었다.
- 배운 점: 공통 레이아웃을 부모에 남겨둬야 한다는 것은 고정관념이었다. 상위 컴포넌트는 오직 '어떤 경로(회원/비회원)를 보여줄 것인가'만 결정하는 라우터 역할만 맡기고, 컴포넌트를 통째로 두 세계관(GuestOrderSection, MemberOrderSection)으로 찢어야 했다.
- 이렇게 두 컴포넌트로 완전히 격리하면 React Fiber 트리 상에서도 서로 완전히 남남이 된다. 한쪽의 상태가 변경되어도 다른 쪽은 렌더링 연산에 전혀 휘말리지 않게 되는 구조적 이점을 챙길 수 있다.
병목 2. TypeScript의 깐깐한 타입 에러
부모를 라우터로 바꾸고 자식 컴포넌트에게 각각 guestPhoneNumber와 memberId를 넘기려 하자 타입스크립트 에러가 발생했다. 부모의 인터페이스에서는 해당 값들이 옵셔널(?)이었기 때문에, 필수 값으로 기대하는 자식 컴포넌트에 넘겨줄 수 없다는 것이었다.
- 배운 점 (토스 스타일의 타입 설계): 단순히 !를 붙여서 타입 단언을 하거나 부모에서 방어 로직을 덕지덕지 붙이는 대신, 비즈니스 규칙 자체를 타입 시스템에 녹여내는 방식을 배웠다.
- '구별된 유니온(Discriminated Unions)'을 사용하여 비회원일 때는 폰 번호가 필수, 회원일 때는 멤버 ID가 필수임을 명시했다. 그리고 자식 컴포넌트의 Props는 Extract와 Omit 유틸리티 타입을 조합하여 부모 타입으로부터 안전하게 파생시켰다.
type LegacyOrderViewProps =
| {
isGuest: true;
guestPhoneNumber: string; // 비회원일 땐 필수!
memberId?: never; // 회원 ID는 절대 들어올 수 없음!
}
| {
isGuest: false;
memberId: string; // 회원일 땐 필수!
guestPhoneNumber?: never; // 비회원 전화번호는 절대 들어올 수 없음!
};
type GuestSectionProps = Omit<Extract<LegacyOrderViewProps, {isGuest: true}>, 'isGuest'>;
type MemberSectionProps = Omit<Extract<LegacyOrderViewProps, {isGuest: false}>, 'isGuest'>;
실제로 토스 스타일의 코드를 작성해볼 수 있어서 좋았다.
병목 3. 맹목적 중복 제거의 함정
컴포넌트를 둘로 나누고 나니 <h2>주문 상세 내역</h2>이나 결제 금액을 보여주는 마크업이 양쪽 파일에 동일하게 중복되었다. 개발자로서 코드 중복을 보는 순간 거부감이 들었고, "나중에 제목 텍스트가 바뀌면 두 번 고쳐야 하는 것 아닌가?"라는 단점이 먼저 보였다.
- 배운 점: 현재 UI가 같은 것은 도메인 목적이 같아서가 아니라 '우연히 지금 비슷한 것'뿐이다. 비회원은 가입 유도를 위해, 회원은 개인화된 혜택을 위해 앞으로 완전히 다른 방향으로 요구사항이 변할 가능성이 높다.
- 이를 무리하게 하나의 공통 컴포넌트로 묶어두면 결국 내부에 isGuest ? A : B 같은 분기문이 다시 생겨나 결합도가 폭발하게 된다. 한쪽을 수정할 때 다른 쪽에 영향을 주는 결합 비용보다, 차라리 분리된 중복 코드를 유지하는 것이 변경 가능성 면에서 훨씬 낫다는 아키텍처의 트레이드오프를 배웠다.
실제로 이 병목3에 대해선 심도 깊게 질문을 했었고 다음과 같은 구체적인 답변을 받았다.
이 답변을 두 번 정도 곱씹어 읽어보며 충분히 납득이 되었고, 암묵적으로 알고 있다고만 생각했던 중요한 분리 기준을 다시 한 번 새길 수 있었다.

### AI 답변
결론부터 말하면, 맞어. "지금 단계에서는 의도적으로 다른 페이지(독립된 경로)처럼 간주하는 것"이 정답이야.
이유를 토스 프론트엔드 파운데이션의 기준을 토대로 차근차근 짚어줄게.
⚖️ '변경의 단점' vs '결합의 위험'
우리 크루의 말대로 한쪽 UI(예: <h2>주문 상세 내역</h2>)가 바뀔 때 다른 쪽도 직접 고쳐줘야 하는 번거로움(중복 수정의 비용)이 분명 존재해. 하지만 반대로 이 둘을 공통화했을 때 지불해야 하는 결합 비용을 비교해 봐야 해.
1. "UI의 유사성"과 "도메인의 유사성"을 분리하기
지금은 회원과 비회원의 주문 상세 UI 뼈대가 <h2>와 <p> 태그 몇 개로 완전히 똑같아 보이지? 하지만 이건 "우연히 지금 UI가 비슷하게 생겼을 뿐"이지, 비즈니스 목적(도메인)이 같은 건 아니야.
- 비회원 주문: 비회원은 구매를 지속하도록 '회원가입 유도 혜택 배너'가 들어가거나, 본인 인증 프로세스가 UI에 추가될 확률이 높아.
- 회원 주문: 회원은 등급별 쿠폰, 포인트 사용 내역, 배송지 관리 같은 '고도화된 개인화 기능'들이 덕지덕지 붙게 될 거야.
만약 초기에 UI가 같다는 이유로 이 구조를 하나로 묶어버리면(공통화), 나중에 기획자가 *"비회원 화면에만 로그인 유도 버튼 하나 추가해 주세요"*라고 했을 때 공통 컴포넌트 내부에 isGuest ? <Button/> : null 같은 조건문이 다시 기어들어 오기 시작해. 그러다 보면 결국 리팩토링 전의 누더기 코드로 회귀하게 되지.
2. 가독성과 인지 부하 측면
frontend-fundamentals 문서에서 핵심으로 짚은 문장이 있지.
"네 기준은 항상 동시에 좋아지지 않는다. 공통화와 추상화는 응집도를 높일 수 있지만 가독성을 낮출 수 있고, 중복 허용은 결합도를 낮출 수 있지만 응집도를 낮출 수 있다. 그래서 '무조건 추상화'가 아니라, 현재 변경 가능성과 위험을 기준으로 선택해야 한다."
UI가 한쪽이 바뀔 때 다른 쪽도 고치는 비용보다, 한 곳을 고쳤을 때 전혀 상관없는 다른 쪽 경로에 버그가 터지는 위험(결합도 폭발)이 훨씬 치명적이라는 게 토스 파운데이션의 관점이야.
🛠️ 그럼에도 중복 UI 수정을 우아하게 해결하는 법
그렇다면 미래에 디자인 시스템이 바뀌어서 "제목 크기를 h2에서 h3으로 전부 바꿔주세요"라는 요구사항이 오면 어떡할까?
그때는 두 도메인 세계관(GuestOrderSection, MemberOrderSection)을 합치는 게 아니라, 오직 UI만 책임지는 순수한 공통 컴포넌트(UI 레고 블록)를 하위에 만들어서 조립하는 방식으로 해결해야 해.
예를 들면 이런 구조지.
// 도메인 맥락(회원/비회원, 데이터 패칭)이 전혀 없는 순수한 껍데기 UI 컴포넌트
interface OrderLayoutProps {
title: string;
productName: string;
amount: number;
children: React.ReactNode; // 회원/비회원 각각의 액션 박스가 들어갈 자리
}
const OrderLayout = ({ title, productName, amount, children }: OrderLayoutProps) => {
return (
<div style={{ padding: '20px', border: '1px solid #ccc' }}>
<h2>{title}</h2>
<p>주문 상품: {productName}</p>
<p>결제 금액: {amount}원</p>
{children}
</div>
);
};
이렇게 순수 UI만 분리해 두면:
- GuestOrderSection과 MemberOrderSection은 여전히 비즈니스 로직과 데이터 패칭을 독립적으로, 서로 엮이지 않고 수행할 수 있어.
- 공통 UI 레이아웃이 바뀔 때는 OrderLayout 한 곳만 고치면 해결되지.
[After Code] 리팩토링 후 최종 코드
실행 경로를 완전히 분리하고, 타입스크립트의 유틸리티 타입을 활용해 무결점의 구조를 만들어낸 최종 코드다.
import React, {useState, useEffect} from 'react';
interface OrderDetail {
orderId: string;
amount: number;
productName: string;
}
// 1. 구별된 유니온을 활용한 타입 설계
// 비즈니스 룰: 비회원은 번호 필수, 회원은 ID 필수
type LegacyOrderViewProps =
| {
isGuest: true;
guestPhoneNumber: string;
memberId?: never;
}
| {
isGuest: false;
memberId: string;
guestPhoneNumber?: never;
};
// 2. Extract와 Omit을 활용해 자식 컴포넌트의 Props 타입 추출
type GuestSectionProps = Omit<Extract<LegacyOrderViewProps, {isGuest: true}>, 'isGuest'>;
type MemberSectionProps = Omit<Extract<LegacyOrderViewProps, {isGuest: false}>, 'isGuest'>;
// ==========================================
// 3. 비회원 전용 컴포넌트
// ==========================================
const GuestOrderSection = ({guestPhoneNumber}: GuestSectionProps) => {
const [guestAuthToken, setGuestAuthToken] = useState<string | null>(null);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function fetchOrderData() {
setIsLoading(true);
try {
const response = await fetch(`/api/guest/orders?phone=${guestPhoneNumber}`);
const data = await response.json();
setOrder(data.order);
setGuestAuthToken(data.token);
} catch (error) {
console.error('주문 조회 실패', error);
} finally {
setIsLoading(false);
}
}
fetchOrderData();
}, [guestPhoneNumber]);
if (isLoading) return <div>로딩 중...</div>;
if (!order) return <div>주문 내역이 없습니다.</div>;
return (
<div style={{padding: '20px', border: '1px solid #ccc'}}>
<h2>주문 상세 내역</h2>
<p>주문 상품: {order.productName}</p>
<p>결제 금액: {order.amount}원</p>
<div className='guest-action-box' style={{marginTop: '16px', color: '#ff4d4f'}}>
<p>⚠️ 비회원 주문입니다. (인증 토큰: {guestAuthToken ?? '없음'})</p>
<button onClick={() => alert('비회원 전용 영수증 출력')}>비회원 영수증 발행</button>
</div>
</div>
);
};
// ==========================================
// 4. 회원 전용 컴포넌트
// ==========================================
const MemberOrderSection = ({memberId}: MemberSectionProps) => {
const [memberPoints, setMemberPoints] = useState<number>(0);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function fetchOrderData() {
setIsLoading(true);
try {
const response = await fetch(`/api/member/orders/${memberId}`);
const data = await response.json();
setOrder(data.order);
setMemberPoints(data.points);
} catch (error) {
console.error('주문 조회 실패', error);
} finally {
setIsLoading(false);
}
}
fetchOrderData();
}, [memberId]);
if (isLoading) return <div>로딩 중...</div>;
if (!order) return <div>주문 내역이 없습니다.</div>;
return (
<div style={{padding: '20px', border: '1px solid #ccc'}}>
<h2>주문 상세 내역</h2>
<p>주문 상품: {order.productName}</p>
<p>결제 금액: {order.amount}원</p>
<div className='member-action-box' style={{marginTop: '16px', color: '#3d9c48'}}>
<p>✨ 회원 주문 혜택</p>
<p>적립 예정 포인트: {memberPoints} P</p>
<button onClick={() => alert('회원 전용 쿠폰 발급')}>포인트 더블 적립 쿠폰 받기</button>
</div>
</div>
);
};
// ==========================================
// 5. 최상위 부모 컴포넌트 (라우터 역할)
// ==========================================
export function LegacyOrderView({isGuest, guestPhoneNumber, memberId}: LegacyOrderViewProps) {
// 타입스크립트의 Type Narrowing 덕분에 guestPhoneNumber가 확실한 string으로 추론됨
if (isGuest) {
return <GuestOrderSection guestPhoneNumber={guestPhoneNumber} />;
}
return <MemberOrderSection memberId={memberId} />;
}
# 2일차 학습의 진정한 의미
무조건적인 중복 제거가 항상 답은 아님을 체감했다.
공통화의 단점(가독성 저하, 결합도 상승)과 중복 허용의 단점(수정 누락) 사이에서, 현재 컴포넌트의 요구사항이 얼마나 다르게 변할 것인가를 기준으로 적절한 트레이드오프를 선택해야 함을 배웠다.
단순한 컴포넌트 분리가 아니라, 변경에 유연하게 대응할 수 있는 아키텍처의 기준을 확립할 수 있던 미션이었다.

'개발 공부 > Self Study' 카테고리의 다른 글
| [MTFFM] 5일차: 복잡한 조건에 이름 붙이기 (0) | 2026.05.24 |
|---|---|
| [MTFFM] 4일차: 로직 종류에 따라 합쳐진 함수 쪼개기 (0) | 2026.05.23 |
| [MTFFM] 3일차: 추상화와 리액트 렌더링 생명주기의 함정 (0) | 2026.05.21 |
| [MTFFM] 1일차: 같이 실행되지 않는 코드 분리하기 (0) | 2026.05.20 |
| [MTFFM] 0일차: Make Toss Frontend Fundamentals Mine (0) | 2026.05.20 |