오늘 다룬 주제는 시점 이동 줄이기였다.
시점 이동 줄이기의 핵심은 다음과 같았다.
"코드를 읽는 사람이 지금 보고 있는 맥락 안에서 최대한 많은 것을 이해할 수 있게 만들기"
즉, 읽는 사람의 시선이 위아래로 계속 튀지 않게 만드는 것이었다.
# 시점 이동이라는 인지 부하
5일차에서는 복잡한 조건식에 이름을 붙이는 연습을 했다.
const canApplySpecialVipCoupon = ...
이런 식으로 도메인 의미가 담긴 상수를 만들면, 읽는 사람은 조건식 내부를 매번 해석하지 않아도 된다.
오늘 공부한 시점 이동 줄이기도 같은 방향에 있었다.
코드를 읽다가 JSX에서 이런 코드를 만났다고 해보자.
<form onSubmit={handleNicknameSubmit}>
이때 handleNicknameSubmit이 컴포넌트 한참 위에 있거나, 심지어 외부 파일에 있으면 읽는 사람은 다시 위로 올라가야 한다.
그리고 함수 내용을 확인한 뒤 다시 JSX로 내려와야 한다.
이게 한두 번이면 괜찮은데, 상태, 핸들러, 헬퍼 함수가 전부 위에 몰려 있으면 컴포넌트를 읽는 흐름이 계속 끊긴다.
기술적으로는 “선언부와 구현부를 분리했다”고 말할 수 있지만, 읽는 사람 입장에서는 계속 맥락을 복구해야 하는 코드가 된다.
# Before Code
이번 예제는 마이페이지에서 닉네임을 수정하고, 회원 등급 혜택을 보여주는 컴포넌트였다.
처음 코드에서는 상태와 함수가 모두 컴포넌트 상단에 모여 있었다.
//🎯 리팩토링 미션
//오직 '닉네임 변경 섹션'에서만 사용되는 상태와 핸들러, 그리고 오직 '혜택 안내 섹션'에서만 사용되는 헬퍼 함수의 물리적 위치를 배치 원칙에 맞게 조정해 보세요.
//필요한 경우 컴포넌트를 의미 있는 서브 컴포넌트로 쪼개어, 부모 컴포넌트(MyProfileManager)의 선언부 인지 부하를 줄이고 시점 이동을 원천 차단해 보세요.
import React, {useState} from 'react';
interface ProfileProps {
userLevel: 'BRONZE' | 'SILVER' | 'GOLD';
initialNickname: string;
}
export const MyProfileManager: React.FC<ProfileProps> = ({userLevel, initialNickname}) => {
// 🚨 1. 온갖 상태와 함수들이 최상단에 모여 있음
const [nickname, setNickname] = useState(initialNickname);
const [isEditing, setIsEditing] = useState(false);
// 닉네임 변경 폼 제출 핸들러 (오직 닉네임 변경 영역에서만 사용됨)
const handleNicknameSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsEditing(false);
alert(`닉네임이 ${nickname}으로 변경되었습니다.`);
};
// 등급별 혜택 문구 캐싱 함수 (오직 하단 혜택 안내 영역에서만 사용됨)
const getBenefitMessage = (level: string) => {
switch (level) {
case 'GOLD':
return '전 상품 무료 배송 + 5% 즉시 적립';
case 'SILVER':
return '2만원 이상 무료 배송 + 2% 즉시 적립';
default:
return '일반 적립 0.5%';
}
};
return (
<div className='p-6 border rounded-lg bg-white max-w-md mx-auto'>
<h1 className='text-xl font-bold mb-6'>마이페이지 관리</h1>
{/* 👤 닉네임 변경 섹션 */}
<section className='mb-8 p-4 bg-gray-50 rounded'>
<h2 className='text-lg font-semibold mb-2'>프로필 수정</h2>
{isEditing ? (
<form onSubmit={handleNicknameSubmit} className='flex gap-2'>
<input
type='text'
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className='border p-1 rounded'
/>
<button type='submit' className='bg-blue-500 text-white px-3 py-1 rounded'>
저장
</button>
</form>
) : (
<div className='flex justify-between items-center'>
<p>
닉네임: <span className='font-medium'>{nickname}</span>
</p>
<button onClick={() => setIsEditing(true)} className='text-blue-500 hover:underline'>
수정
</button>
</div>
)}
</section>
{/* 🎁 등급별 혜택 안내 섹션 */}
<section className='p-4 border-t'>
<h2 className='text-lg font-semibold mb-2'>회원 등급 혜택</h2>
<p className='text-sm text-gray-600 mb-2'>
현재 회원님의 등급은 <span className='font-bold text-blue-600'>{userLevel}</span>입니다.
</p>
<div className='bg-blue-50 p-3 rounded text-blue-800 text-sm font-medium'>{getBenefitMessage(userLevel)}</div>
</section>
</div>
);
};
# 컴포넌트를 나누면서 생긴 고민
나느 가장 먼저 NicknameSection과 BenefitSection으로 컴포넌트를 나눴다.
<NicknameSection initialNickname={initialNickname} />
<BenefitSection userLevel={userLevel} />
이렇게 하니 부모 컴포넌트는 훨씬 읽기 쉬워졌다.
export const MyProfileManager: React.FC<ProfileProps> = ({userLevel, initialNickname}) => {
return (
<div className='p-6 border rounded-lg bg-white max-w-md mx-auto'>
<h1 className='text-xl font-bold mb-6'>마이페이지 관리</h1>
<NicknameSection initialNickname={initialNickname} />
<BenefitSection userLevel={userLevel} />
</div>
);
};
이제 부모는 “마이페이지가 어떤 섹션들로 구성되어 있는지”만 보여준다.
닉네임 수정이 내부적으로 어떤 상태를 쓰는지, 혜택 문구를 어떻게 계산하는지는 몰라도 된다.
# Partial 대신 Pick을 쓴 이유
타입을 나누는 과정에서도 고민이 있었다.
처음에는 이렇게 썼다.
const NicknameSection = ({initialNickname}: Partial<ProfileProps>) => {
...
};
동작은 할 수 있지만, 마음에 걸렸다.
NicknameSection은 ProfileProps 전체 중에서 initialNickname만 필요하다.
그런데 Partial<ProfileProps>를 쓰면 userLevel까지 포함된 타입을 느슨하게 열어두는 느낌이 된다.
그래서 Pick을 사용했다.
type NicknameSectionProps = Pick<ProfileProps, 'initialNickname'>;
type BenefitSectionProps = Pick<ProfileProps, 'userLevel'>;
이렇게 하면 ProfileProps를 단일 진실 공급원으로 유지하면서도, 각 컴포넌트가 실제로 필요한 props만 명확하게 표현할 수 있다.
initialNickname의 타입이 나중에 바뀌어도 NicknameSectionProps는 자동으로 따라간다.
이런 게 타입 레벨에서의 SSoT라는 생각이 들었다.
# After Code
//🎯 리팩토링 미션
//오직 '닉네임 변경 섹션'에서만 사용되는 상태와 핸들러, 그리고 오직 '혜택 안내 섹션'에서만 사용되는 헬퍼 함수의 물리적 위치를 배치 원칙에 맞게 조정해 보세요.
//필요한 경우 컴포넌트를 의미 있는 서브 컴포넌트로 쪼개어, 부모 컴포넌트(MyProfileManager)의 선언부 인지 부하를 줄이고 시점 이동을 원천 차단해 보세요.
import React, {useState} from 'react';
interface ProfileProps {
userLevel: 'BRONZE' | 'SILVER' | 'GOLD';
initialNickname: string;
}
type NicknameSectionProps = Pick<ProfileProps, 'initialNickname'>;
type BenefitSectionProps = Pick<ProfileProps, 'userLevel'>;
const NicknameSection: React.FC<NicknameSectionProps> = ({initialNickname}) => {
const [isEditing, setIsEditing] = useState(false);
const [nickname, setNickname] = useState(initialNickname);
const handleNicknameSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsEditing(false);
alert(`닉네임이 ${nickname}으로 변경되었습니다.`);
};
return (
<section className='mb-8 p-4 bg-gray-50 rounded'>
<h2 className='text-lg font-semibold mb-2'>프로필 수정</h2>
{isEditing ? (
<form onSubmit={handleNicknameSubmit} className='flex gap-2'>
<input
type='text'
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className='border p-1 rounded'
/>
<button type='submit' className='bg-blue-500 text-white px-3 py-1 rounded'>
저장
</button>
</form>
) : (
<div className='flex justify-between items-center'>
<p>
닉네임: <span className='font-medium'>{nickname}</span>
</p>
<button onClick={() => setIsEditing(true)} className='text-blue-500 hover:underline'>
수정
</button>
</div>
)}
</section>
);
};
const BenefitSection: React.FC<BenefitSectionProps> = ({userLevel}) => {
const getBenefitMessage = (level: BenefitSectionProps['userLevel']) => {
switch (level) {
case 'GOLD':
return '전 상품 무료 배송 + 5% 즉시 적립';
case 'SILVER':
return '2만원 이상 무료 배송 + 2% 즉시 적립';
default:
return '일반 적립 0.5%';
}
};
return (
<section className='p-4 border-t'>
<h2 className='text-lg font-semibold mb-2'>회원 등급 혜택</h2>
<p className='text-sm text-gray-600 mb-2'>
현재 회원님의 등급은 <span className='font-bold text-blue-600'>{userLevel}</span>입니다.
</p>
<div className='bg-blue-50 p-3 rounded text-blue-800 text-sm font-medium'>{getBenefitMessage(userLevel)}</div>
</section>
);
};
export const MyProfileManager: React.FC<ProfileProps> = ({userLevel, initialNickname}) => {
return (
<div className='p-6 border rounded-lg bg-white max-w-md mx-auto'>
<h1 className='text-xl font-bold mb-6'>마이페이지 관리</h1>
<NicknameSection initialNickname={initialNickname} />
<BenefitSection userLevel={userLevel} />
</div>
);
};
# 배운 점
오늘 배운 내용은 단순히 “함수를 가까이 둬라”가 아니었다.
조금 더 정확히 말하면,
어떤 코드가 어떤 맥락에서만 의미를 가진다면, 그 맥락 안에 가둬라.
에 가까웠다.
isEditing은 닉네임 수정 섹션의 상태다.
그러니 닉네임 수정 섹션 안에 있어야 한다.
getBenefitMessage는 회원 등급 혜택을 보여주기 위한 함수다.
그러니 혜택 섹션 안에 있어도 충분하다.
이렇게 배치하면 부모 컴포넌트는 훨씬 단순해지고, 각 하위 컴포넌트는 자기 역할 안에서 닫힌다.
읽는 사람도 “이 상태가 어디서 오지?”, “이 함수가 어디서 쓰이지?”를 계속 추적하지 않아도 된다.
예전에는 컴포넌트 상단에 상태와 핸들러가 모여 있는 코드가 정리된 코드라고 생각했다.
그런데 오늘 돌아보니, 정리된 코드처럼 보이는 것과 읽기 쉬운 코드는 다를 수 있겠다는 생각이 든다.
기술적 종류별로 모으는 것보다, 도메인 맥락별로 모으는 것이 더 읽기 쉬운 경우가 많다.

오늘의 결론은 이렇다.
좋은 배치는 코드를 짧게 만드는 것이 아니라, 읽는 사람의 시선 이동을 줄이는 것이다.
생각보다 사소한 주제처럼 보였는데, 상태의 주인, 컴포넌트 분리, props 타입 설계까지 이어져서 꽤 재밌었다.
역시 가독성은 단순히 예쁜 코드의 문제가 아니라, 동료의 머릿속 실행 비용을 줄이는 문제에 가깝다.
'개발 공부 > Self Study' 카테고리의 다른 글
| [MTFFM] 8일차: 왼쪽에서 오른쪽으로 읽히게 하기 (0) | 2026.05.29 |
|---|---|
| [MTFFM] 7일차: 삼항 연산자 단순하게 하기 (0) | 2026.05.28 |
| [MTFFM] 5일차: 복잡한 조건에 이름 붙이기 (0) | 2026.05.24 |
| [MTFFM] 4일차: 로직 종류에 따라 합쳐진 함수 쪼개기 (0) | 2026.05.23 |
| [MTFFM] 3일차: 추상화와 리액트 렌더링 생명주기의 함정 (0) | 2026.05.21 |