토스 SLASH 21 — 진유림님의 실무에서 바로 쓰는 Frontend Clean Code 영상을 보고 정리한 학습 기록입니다.
코드를 짜다 보면 "돌아가긴 하는데… 이게 맞나?" 싶을 때가 있다. 특히 React 컴포넌트가 비대해지기 시작하면 어디서부터 손을 대야 할지 막막해진다.
진유림님의 발표를 보면서 그동안 막연하게 느꼈던 "좋은 컴포넌트란 무엇인가"에 대한 기준이 조금 더 명확해졌다. 핵심은 세 가지다.
하나씩 Before/After 코드와 함께 정리해본다.
기능을 추가하다 보면, 하나의 기능에 대한 코드가 여기저기 흩어지기 쉽다. 예를 들어 "팝업을 여는 버튼"과 "팝업 자체"가 서로 멀리 떨어져 있는 경우다.
function QuestionPage() {
const [popupOpened, setPopupOpened] = useState(false);
// ... 수십 줄의 다른 로직들 ...
async function handleClick() {
await submitAnswer();
setPopupOpened(true); // 팝업 열기
}
// ... 또 수십 줄의 다른 로직들 ...
return (
{/* 중간에 다른 UI들 */}
제출하기
{/* 또 다른 UI들 */}
{popupOpened && (
<Popup
title="제출 완료"
description="답변이 등록되었습니다."
onConfirm={() => setPopupOpened(false)}
/>
)}
);
}
무엇이 문제인가?
popupOpened 상태 선언, setPopupOpened(true) 호출, <Popup> 렌더링이 각각 다른 위치에 있다.// 팝업과 관련된 모든 것을 하나로 뭉친 컴포넌트
function SubmitPopupButton({ onSubmit }: { onSubmit: () => Promise }) {
const [popupOpened, setPopupOpened] = useState(false);
async function handleClick() {
await onSubmit();
setPopupOpened(true);
}
return (
<>
제출하기
{popupOpened && (
<Popup
title="제출 완료"
description="답변이 등록되었습니다."
onConfirm={() => setPopupOpened(false)}
/>
)}
</>
);
}
// 사용하는 쪽
function QuestionPage() {
return (
{/* 다른 UI들 */}
);
}
개선 포인트:
onSubmit이라는 핵심 데이터만 전달하면 된다.명령형: "팝업을 열어라" → setPopupOpened(true)
선언형: "제출하면 팝업이 뜨는 버튼이다" → <SubmitPopupButton onSubmit={...} />
props로는 "무엇을(what)" 전달하고, "어떻게(how)" 는 컴포넌트 내부에 숨긴다.
| 구분 | 설명 | 예시 |
|---|---|---|
| 뭉쳐야 할 것 | 당장 몰라도 되는 세부 구현 | 팝업 열기/닫기 로직, 애니메이션 |
| 드러내야 할 것 | 코드 파악에 필수적인 핵심 정보 | 어떤 데이터를 제출하는지, 어떤 API를 호출하는지 |
커스텀 hooks에 모든 코드를 숨기는 것은 지양해야 한다. 핵심 데이터는 밖에서 전달하고, 나머지를 뭉쳐서 사용하는 것이 올바른 응집이다.
하나의 함수나 컴포넌트가 여러 가지 일을 동시에 하면, 이름만 보고는 무슨 일이 일어나는지 예측할 수 없다.
function QuestionPage() {
async function handleSubmitClick() {
const response = await submitAnswer(); // 1) API 호출
logEvent("submit_answer", { id: response.id }); // 2) 로그 전송
if (response.needsReview) {
showReviewPopup(); // 3) 팝업 표시
}
navigateTo("/result"); // 4) 페이지 이동
}
return 제출하기;
}
무엇이 문제인가?
handleSubmitClick이라는 이름만 보면 "제출 클릭 핸들러"인데, 실제로는 API 호출 + 로그 + 팝업 + 네비게이션 4가지를 한다.// 로그만 책임지는 기능성 컴포넌트
function LogClick({
eventName,
children,
}: {
eventName: string;
children: React.ReactElement;
}) {
function handleClick() {
logEvent(eventName);
}
return React.cloneElement(children, {
onClick: (...args: unknown[]) => {
handleClick();
children.props.onClick?.(...args);
},
});
}
// API 호출만 책임지는 함수
async function submitAnswer(): Promise {
const response = await api.post("/answers", { answerId });
return response.data;
}
// 사용하는 쪽 — 각 컴포넌트가 자기 역할만 수행
function QuestionPage() {
async function handleSubmit() {
const response = await submitAnswer(); // API 호출만!
if (response.needsReview) {
showReviewPopup();
}
navigateTo("/result");
}
return (
제출하기
);
}
개선 포인트:
LogClick → 로그만 찍는다. 한 가지 일.submitAnswer → API 호출만 한다. 한 가지 일.도메인이 복잡해지면 영어 이름이 길어져서 오히려 가독성이 떨어진다. 이때 한글 변수명을 쓰면 주석을 달아둔 것 같은 효과가 난다.
// Before — 영어 이름이 너무 길어 오히려 복잡한 경우
const isSpecialMemberAndHasNotCompletedOnboarding =
user.memberType === "special" && !user.onboardingCompleted;
const shouldShowBannerForSpecialMemberOnboarding =
isSpecialMemberAndHasNotCompletedOnboarding && !bannerDismissed;
// After — 한글 네이밍으로 도메인 맥락을 명확하게
const 특별회원_온보딩_미완료 =
user.memberType === "special" && !user.onboardingCompleted;
const 온보딩_배너_노출 = 특별회원_온보딩_미완료 && !bannerDismissed;
한글 네이밍은 반드시 써야 하는 것은 아니지만, 조건이 복잡해서 영어 변수명이 오히려 혼란을 줄 때 유용한 선택지다.
지하철 노선도를 떠올려보자. 실제 지하철 구조는 복잡하지만, 노선도는 역 이름과 호선 번호만 남기고 나머지를 모두 숨긴다. 코드에서의 추상화도 같다 — 핵심 개념을 뽑아내고 디테일은 숨기는 것이다.
function PaymentPage() {
return (
{/* 높은 추상화 수준 */}
{/* 갑자기 낮은 추상화 수준 — 세부 구현이 드러남 */}
{coupon.name}
{coupon.discountType === "percent"
? `${coupon.value}% 할인`
: `${coupon.value.toLocaleString()}원 할인`}
{coupon.isExpired ? "만료됨" : "사용 가능"}
{/* 다시 높은 추상화 수준 */}
);
}
무엇이 문제인가?
ProductSummary, PaymentButton은 높은 추상화 수준인데, 중간에 쿠폰 UI만 날것의 구현이 노출된다.function PaymentPage() {
return (
);
}
// 세부 구현은 같은 수준의 별도 컴포넌트로 분리
function CouponSection({ coupon }: { coupon: Coupon }) {
const 할인_텍스트 =
coupon.discountType === "percent"
? `${coupon.value}% 할인`
: `${coupon.value.toLocaleString()}원 할인`;
const 사용가능_여부 = coupon.isExpired ? "만료됨" : "사용 가능";
return (
{coupon.name}
{할인_텍스트}
{사용가능_여부}
);
}
개선 포인트:
PaymentPage만 보면 전체 흐름이 한눈에 들어온다: 헤더 → 상품요약 → 쿠폰 → 결제버튼CouponSection만 들여다보면 된다.같은 파일 안에서 이런 것들이 섞여 있다면 추상화 수준을 정리할 때다:
| 높은 추상화 | 낮은 추상화 |
|---|---|
<ProductSummary /> | product.items.map(item => ...) |
<PaymentButton /> | style={{ padding: "16px" }} |
submitOrder() | fetch("/api/orders", { method: "POST" }) |
| 원칙 | 핵심 질문 | 해결 방법 |
|---|---|---|
| 응집도 | 관련 코드가 흩어져 있진 않은가? | 같은 맥락의 코드를 하나의 컴포넌트로 뭉치기. 핵심 데이터는 props로, 세부 구현은 내부에. |
| 단일 책임 | 이 컴포넌트/함수가 한 가지 일만 하는가? | 기능성 컴포넌트로 분리. 이름이 역할을 정확히 설명하는지 확인. |
| 추상화 | 같은 파일 안에서 추상화 수준이 섞여 있진 않은가? | 추상화 단계를 비슷하게 정리. 세부 구현은 별도 컴포넌트로 분리. |
진유림님의 발표에서 가장 인상 깊었던 문장은, 클린 코드란 짧은 코드가 아니라 "찾고 싶은 로직을 빠르게 찾을 수 있는 코드" 라는 점이다.
컴포넌트를 쪼갠다고 무조건 좋은 게 아니라, 왜 쪼개는지 — 응집도, 단일 책임, 추상화 수준 — 이 기준이 있어야 의미 있는 분리가 된다. 앞으로 코드를 짤 때 이 세 가지 렌즈로 한 번 더 돌아보는 습관을 들여야겠다.