hook 구현 마무리
useState, useEffect, useRef를 구현하고, 추가로 useMemo, useCallback까지 적용해봤다. 이 과정에서 한 가지 문제가 있었는데, React Craft
는 실제 React의 내부 코드를 참고한 것이 아니라, 겉으로 보이는 동작을 내가 임의로 구현하는 방식이었다는 점이다. 즉, React의 내부 로직을 정확히 모른 채 기능을 흉내 내는 데 초점이 맞춰져 있었다.
hook을 구현 해보며 얻은것이 뭐가 있었을까 생각을 해봤을 때, 구현 능력을 올리는 것 말고, 가장 큰 영향력을 준것은 다음과 같았다.
React 내부 코드 탐색의 동기 부여
- 실제 React와 완전히 동일하게 동작하도록 만들려면 React의 내부 구현을 직접 탐색해야 한다는 필요성을 깨닫게 되었다.
특히, 마지막으로 구현해본 hook이 useCallback 이었고 다음과 같은 문제가 있었다.
const [initialUsername, setInitialUsername] = useState("");
const [initialPassword, setInitialPassword] = useState("");
// const dep = [username(), password()];
const dep = [isLoggedIn()];
useEffect(() => {
setInitialUsername(username());
setInitialPassword(password());
}, dep);
const handleLogin = useCallback(() => {
if (initialUsername() && initialPassword()) {
console.log(`🟢 로그인 성공! ID: ${username()}, PW: ${password()}`);
setIsLoggedIn(true);
} else {
console.log("🔴 아이디와 비밀번호를 입력하세요!");
}
}, dep);
기존은 의존성이 변경되지 않는 한 useCallback은 메모된 값을 줘야하는데, username, password의 경우 계속 업데이트 되어 결국 useCallback은 최신 값을 가지고 또 계산을 하는 것이었다. 그렇게 되었던 이유는 useCallback을 내부적으로 useEffect처럼 구현을 했었던 것이 가장 큰 문제였다.
const [initialUsername, setInitialUsername] = useState("");
const [initialPassword, setInitialPassword] = useState("");
// const dep = [username(), password()];
const dep = [isLoggedIn()];
useEffect(() => {
setInitialUsername(username());
setInitialPassword(password());
}, dep);
그래서 실제 react의 useCallback과 유사하게 동작하려면 위와같이 추가 코드가 필요했고, 이 또한 유사하게 동작을 한다고 하더라고 완벽한 구현은 아니었다. 그렇다면, useCallback을 다시 제대로 구현해보는 방법이 있는데, 이는 또 문제가 있는데, 바로 클로저 문제와 메모이제이션 전략에 대한 깊은 이해가 필요하다는 점이다.
클로저 문제
useCallback의 핵심은 의존성 배열을 기반으로 콜백을 메모이제이션하는 것이다. 하지만 잘못 구현하면 클로저 문제로 인해 예상과 다른 동작을 할 수 있다. 예를 들어, 다음과 같이 의존성이 변경되지 않으면 이전 상태의 변수를 계속 참조하는 문제가 발생할 수 있다.
function useCallback(callback, deps) {
const ref = useRef();
if (!ref.current || !areDepsEqual(ref.current.deps, deps)) {
ref.current = { callback, deps };
}
return ref.current.callback;
}
위 코드에서는 deps가 변경되지 않으면 callback이 갱신되지 않는다. 하지만 callback 내부에서 상태를 참조할 경우, 이전 상태를 계속 참조하는 클로저 문제가 발생할 수 있었다. 어쩔 수 없이 어떻게 useCallback이 구현되어있는지 볼 수 밖에 없었다.
실제 React 코드의 동작과, useCallback 마무리
React의 실제 useCallback이 구현된 코드를 본다면 다음과 같았다
마운트 할 때와 update될때로 구분이 되어있었는데,
- 훅이 처음 마운트될 때 콜백과 의존성 배열을 memoizedState에 저장하고 콜백을 반환
- 의존성 배열이 이전과 동일하면 기존 콜백을 그대로 사용하고, 다르면 새로운 콜백과 의존성 배열을 memoizedState에 저장
위의 방식으로 리액트의 useCallback 훅의 내부 동작 방식이 구현되어 있었다.이 방식은 결국 내부 hook들의 요소와 연관이 되어있을 수 밖에 없었고, 지금까지의 React Craft
의 단순 구현으로는 너무 돌아가는 느낌이 들었다. 그래도, 일단 useEffect처럼 구현한 useCallback을 고쳐야하니, 다음과 같이 코드를 수정했다.
기존의 코드는 정말 동작을 했다고 한다면 ▼
const useCallbackStore = [];
function useCallback(callback, dependencies) {
const currentIndex = stateStore.stateIndex;
stateStore.stateIndex++;
if (!useCallbackStore[currentIndex]) {
useCallbackStore[currentIndex] = {
dependencies: undefined,
callback: undefined,
};
}
const { dependencies: prevDependencies, callback: prevCallback } =
useCallbackStore[currentIndex];
let hasChanged = !prevDependencies;
if (!hasChanged) {
for (let i = 0; i < dependencies.length; i++) {
if (dependencies[i] !== prevDependencies[i]) {
hasChanged = true;
break;
}
}
}
if (hasChanged) {
useCallbackStore[currentIndex] = { dependencies, callback };
return callback;
}
return prevCallback;
}
클로저의 개념을 생각해서, 한 번 적용해본 코드는 다음과 같이 변경할 수 있었다. ▼
const useCallbackStore = [];
function useCallback(callback, dependencies) {
const currentIndex = stateStore.stateIndex;
stateStore.stateIndex++;
if (!useCallbackStore[currentIndex]) {
useCallbackStore[currentIndex] = {
dependencies: undefined,
callback: undefined,
cleanup: undefined,
};
}
const {
dependencies: prevDependencies,
callback: prevCallback,
cleanup: prevCleanup,
} = useCallbackStore[currentIndex];
let hasChanged = !prevDependencies;
if (!hasChanged) {
for (let i = 0; i < dependencies.length; i++) {
if (dependencies[i] !== prevDependencies[i]) {
hasChanged = true;
break;
}
}
}
if (hasChanged) {
if (prevCleanup) prevCleanup();
const cleanup = callback();
useCallbackStore[currentIndex] = { dependencies, callback, cleanup };
return callback;
}
return prevCallback;
}
기존코드가 단순하게, dependencies를 비교하여 callback을 갱신할지 결정했다면, 변경한 부분은 새로운 callback이 등록되기 전에 이전 callback의 cleanup을 진행했다. 즉, 이전 callback이 남아있지 않도록 정리하는 기능을 도입했다. 또한 추가로 mount, update에 대해서도 분기처리를 하여
useCallbackStore[currentIndex]
가 없으면 마운트- 의존성 배열이 변경되었으면 업데이트
- 변경되지 않았다면 메모된 값 유지
초기에 useCallback이 최초 실행해서 마운트
콘솔이 출력되고, 이후에는 메모
된 값을 사용하다가 의존성이 변경되면 업데이트
콘솔이 출력된다.
리액트의 실제 useCallback에서는 더 엮여있는 구현이 많았기 때문에 완벽까지는 아니지만, 이전과 달리 실제 코드를 보고 동작을 확인해보며 나만의 구현을 적용할 수 있었다.
결론과 앞으로의 방향성
지금까지는 훅들이 간단한 구현이라도, 잘 동작했자만 useCallback을 구현해보면서 react 소스코드를 보니 실제 많이 다르다는 것을 확실하게 알게 되었다. 따라서 다음 react craft는 리액트의 동작과정을 실제 소스코드를 보고 분석하며 그것의 핵심적인 부분을 약식으로 구현해보며 학습하는 방향으로 가야할 것 같다.
즉, 내 마음대로 JavaScript로 구현하는 것은 쉽지 않으며, 완벽한 재현보다는 React의 내부 동작 방식을 학습하는 과정이 더 가치 있다는 결론을 내리게 되었다.