2026. 3. 23. 01:00ㆍ카테고리 없음
React에서 부모-자식 컴포넌트 간 통신의 핵심은 콜백 props 패턴입니다. 이 글에서는 전체 동작 원리를 단계별로 살펴보고, JavaScript의 "함수는 값이다"라는 핵심 개념부터 TypeScript 타입 시스템, 성능 최적화까지 깊이 있게 다룹니다.
전체 흐름 한눈에 보기

이 흐름을 이해한 다음, JavaScript의 "함수는 값이다"라는 핵심 개념을 봐야 합니다.

1. 출발점: "함수는 값이다"
이 패턴을 이해하려면 JavaScript의 핵심 성질 하나를 먼저 받아들여야 합니다. JavaScript에서 함수는 숫자나 문자열처럼 값(value) 으로 취급됩니다. 변수에 담을 수 있고, 다른 변수에 대입할 수 있고, 함수의 인자로 넘길 수 있습니다.
// 함수를 변수에 담는다
const greet = () => console.log("안녕!");
// 다른 변수에 대입 — "복사"가 아니라 "같은 함수를 가리키는 참조"를 복사
const alsoGreet = greet;
alsoGreet(); // "안녕!" 출력 — 완전히 동일한 함수가 실행됨
React의 콜백 props는 이 성질을 그대로 이용합니다. 부모가 함수를 만들고, 그 함수를 가리키는 참조값을 props라는 이름의 객체에 실어서 자식에게 전달합니다.
2. 렌더링 시점: 함수 참조가 아래로 흐른다
컴포넌트가 렌더링될 때 React는 두 단계를 거칩니다.
첫 번째로, 부모 컴포넌트 함수가 실행됩니다. 이 시점에 handleClick이라는 이름의 함수가 생성되어 메모리 어딘가에 저장됩니다.
const Parent = () => {
// 렌더 실행 시 이 함수가 메모리에 생성됨
const handleClick = () => {
console.log("부모가 실행됨!");
};
// JSX를 반환할 때, props 객체 안에 함수 참조를 담음
return <Child onClick={handleClick} />;
};
두 번째로, React가 <Child onClick={handleClick} />를 처리할 때 { onClick: handleClick }이라는 props 객체를 만들어 Child 함수에 넘깁니다. 이 onClick의 값은 새로 만든 함수가 아니라, 방금 생성된 handleClick과 동일한 메모리 주소를 가리키는 참조입니다. 함수 본체가 복제되지 않습니다.
3. 자식 컴포넌트: 받은 참조를 버튼에 연결한다
자식 컴포넌트 입장에서는 누가 이 함수를 만들었는지 전혀 모릅니다. 그냥 props.onClick이라는 이름으로 "호출 가능한 무언가"가 왔을 뿐입니다.
interface ChildProps {
label: string;
onClick: () => void; // 호출 가능한 함수라는 계약
}
const Child = ({ label, onClick }: ChildProps) => {
// 받은 함수 참조를 그대로 버튼의 onClick 이벤트 핸들러로 등록
return <button onClick={onClick}>{label}</button>;
};
```
여기서 `onClick={onClick}`은 "버튼이 클릭되면 이 함수를 호출하라"고 브라우저에 등록하는 것입니다. 아직 함수가 실행된 게 아닙니다.
---
### 4. 클릭 이벤트: 역방향으로 실행이 올라간다
사용자가 버튼을 클릭하는 순간, 실행 흐름이 역전됩니다.
```
사용자 클릭
→ 브라우저가 SyntheticEvent 생성
→ 등록된 onClick 핸들러(= props.onClick) 호출
→ props.onClick은 사실 부모의 handleClick을 가리킴
→ 부모의 handleClick 실행
자식은 단지 "전달받은 함수를 실행"했을 뿐인데, 그 함수가 사실 부모 스코프에 있는 함수라서 부모의 상태나 로직에 접근할 수 있습니다. 이것이 콜백 패턴의 핵심입니다.
4. 클릭 이벤트: 역방향으로 실행이 올라간다
사용자가 버튼을 클릭하는 순간, 실행 흐름이 역전됩니다.
사용자 클릭
→ 브라우저가 SyntheticEvent 생성
→ 등록된 onClick 핸들러(= props.onClick) 호출
→ props.onClick은 사실 부모의 handleClick을 가리킴
→ 부모의 handleClick 실행
자식은 단지 "전달받은 함수를 실행"했을 뿐인데, 그 함수가 사실 부모 스코프에 있는 함수라서 부모의 상태나 로직에 접근할 수 있습니다. 이것이 콜백 패턴의 핵심입니다.
5. 왜 부모 스코프에 접근되는가 — 클로저
handleClick은 Parent 함수 안에서 정의된 함수입니다. JavaScript의 클로저(closure) 규칙에 의해, handleClick은 자신이 생성된 스코프의 변수들을 기억합니다. 따라서 자식에서 handleClick이 호출되더라도, 그 함수는 여전히 부모의 state나 다른 변수들을 참조할 수 있습니다.
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
// 클로저: setCount와 count를 기억하고 있음
// 자식에서 이 함수가 호출돼도 부모의 상태에 접근 가능
setCount(count + 1);
};
return <Child onClick={handleClick} />;
};
handleClick이 자식에게 넘어가도 setCount에 대한 참조를 잃지 않습니다. 이 덕분에 자식이 버튼을 클릭했을 때 부모의 상태가 업데이트될 수 있습니다.
6. TypeScript와의 시너지 — 인터페이스로 계약을 명시한다
TypeScript를 사용할 때 이 패턴은 더욱 강력해집니다. props 인터페이스에 onClick의 타입을 선언하면, 잘못된 함수를 전달했을 때 컴파일 단계에서 오류를 잡을 수 있습니다.
// 매개변수나 반환값이 있는 경우도 타입으로 명시
interface ButtonProps {
label: string;
onClick: () => void; // 인자 없음, 반환값 없음
onSelect?: (id: number) => void; // 선택적, id를 받음
onConfirm?: (value: string) => boolean; // boolean 반환
}
const Child = ({ label, onClick, onSelect }: ButtonProps) => {
// TypeScript가 onClick을 함수로 보장해 주므로 안전하게 호출
return <button onClick={onClick}>{label}</button>;
};
// 부모에서 타입이 맞지 않으면 컴파일 에러
const Parent = () => {
const handleClick = () => {}; // () => void ✓
return <Child label="클릭" onClick={handleClick} />;
};
7. useCallback — 함수 참조 안정화
부모가 리렌더링될 때마다 handleClick은 새로운 함수 객체로 재생성됩니다. 이는 자식 컴포넌트가 React.memo로 감싸져 있어도 props가 바뀐 것으로 인식되어 불필요한 리렌더링을 유발할 수 있습니다.
const Parent = () => {
const [count, setCount] = useState(0);
const [other, setOther] = useState("");
// useCallback 없이: other가 바뀌어도 handleClick이 새로 생성됨
// → Child가 불필요하게 리렌더링
const handleClick = () => {
setCount(c => c + 1);
};
// useCallback 사용: 의존성 배열이 바뀌지 않으면 같은 참조 유지
const stableHandleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // count를 직접 읽지 않고 함수형 업데이트를 써서 의존성 없음
return <MemoizedChild onClick={stableHandleClick} />;
};
const MemoizedChild = React.memo(({ onClick }: { onClick: () => void }) => {
return <button onClick={onClick}>클릭</button>;
});
useCallback을 무조건 붙이는 건 오히려 코드 복잡도만 높입니다. React.memo와 함께 사용하거나, 해당 함수가 useEffect의 의존성 배열에 들어가야 하는 경우에만 선택적으로 쓰는 것이 좋습니다.
8. 이벤트 객체 전달 — SyntheticEvent
자식이 이벤트 관련 정보(클릭 위치, 키 입력 등)를 부모에 함께 넘겨야 한다면, 함수 시그니처에 이벤트 객체를 포함시킵니다.
interface InputProps {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const SearchInput = ({ onChange }: InputProps) => (
<input type="text" onChange={onChange} />
);
const Parent = () => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log("입력값:", e.target.value); // 자식의 input 값을 부모가 읽음
};
return <SearchInput onChange={handleChange} />;
};
또는 이벤트 객체 대신 가공된 값만 부모에게 전달하도록 자식에서 래핑하는 게 더 깔끔할 때도 있습니다.
interface SearchInputProps {
onSearch: (value: string) => void; // 이벤트 객체 대신 순수한 string
}
const SearchInput = ({ onSearch }: SearchInputProps) => (
<input
type="text"
onChange={(e) => onSearch(e.target.value)} // 자식이 추출해서 전달
/>
);
정리
콜백 props 패턴의 동작 원리를 한 문장으로 요약하면 이렇습니다. 부모가 자신의 스코프에서 함수를 만들고, 그 함수의 참조를 props로 내려보내면, 자식이 이벤트 발생 시 그 참조를 실행한다. 실행 시점에 클로저가 작동하므로 함수는 부모 스코프의 변수(상태, 다른 함수 등)에 자유롭게 접근할 수 있습니다. TypeScript의 타입 시스템이 이 계약을 컴파일 타임에 보장해 주고, useCallback이 성능 최적화를 지원합니다.