추상화 수준과 관심사의 분리
같은 기능, 다른 설계
Section titled “같은 기능, 다른 설계”최근 앱이 백그라운드에서 포그라운드로 전환될 때 데이터를 다시 가져오는 훅을 만들었다. 처음엔 이렇게 작성했다.
export const useRefetchOnForeground = ({ refetch,}: { refetch: () => void;}) => { const appState = useRef(AppState.currentState);
useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { refetch(); } appState.current = nextAppState; });
return () => { subscription.remove(); }; }, [refetch]);};동작은 완벽했다. 앱이 포그라운드로 돌아올 때 데이터를 다시 가져온다. 하지만 이 AppState와 refetch는 전혀 관련이 없는 로직이라는 생각이 들었다.
그래서 다시 작성했다.
export const useAppForeground = ({ onForeground,}: { onForeground: () => void;}) => { const appState = useRef(AppState.currentState);
useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { onForeground(); } appState.current = nextAppState; });
return () => { subscription.remove(); }; }, [onForeground]);};거의 같은 코드다. 하지만 근본적으로 다른 코드다.
무엇이 달라졌는가
Section titled “무엇이 달라졌는가”1. 이름에서 드러나는 차이
Section titled “1. 이름에서 드러나는 차이”// 첫 번째 버전useRefetchOnForeground;
// 두 번째 버전useAppForeground;useRefetchOnForeground: “refetch한다”는 비즈니스 로직이 이름에 포함됨useAppForeground: “포그라운드로 전환된다”는 타이밍만 표현됨
이름 하나의 차이가 왜 중요할까?
이름은 훅이 책임지는 범위를 보여준다.
useRefetchOnForeground는 “refetch”라는 구체적인 행동까지 관여하겠다는 선언이고,
useAppForeground는 “타이밍”만 제공하겠다는 선언이다.
2. 재사용성의 차이
Section titled “2. 재사용성의 차이”// 첫 번째 버전: refetch 용도로만 사용 가능useRefetchOnForeground({ refetch: getData });
// 두 번째 버전: 모든 용도로 사용 가능useAppForeground({ onForeground: () => analytics.track("app_foreground") });useAppForeground({ onForeground: refreshToken });useAppForeground({ onForeground: checkNewMessages });useAppForeground({ onForeground: refetch });첫 번째 버전은 “refetch”라는 단어가 이름에 박혀있어서, 다른 용도로 쓰기 어색하다. 두 번째 버전은 “포그라운드 전환”이라는 본질만 다루기 때문에, 어떤 용도로든 자연스럽다.
추상화 수준이란 무엇인가
Section titled “추상화 수준이란 무엇인가”구체적 vs 추상적
Section titled “구체적 vs 추상적”| 추상화 수준 | 코드 예시 | 특징 |
|---|---|---|
| 구체적 | useRefetchOnForeground | 특정 용도에 최적화됨, 재사용성 낮음 |
| 추상적 | useAppForeground | 범용적, 재사용성 높음 |
추상화는 불필요한 세부사항을 제거하고 본질만 남기는 것이다.
useRefetchOnForeground의 본질은 무엇인가? → “포그라운드 전환 감지”- “refetch”는 본질인가? → 아니다. 그건 사용자가 결정할 선택지다
관심사의 분리란 무엇인가
Section titled “관심사의 분리란 무엇인가”Single Responsibility Principle
Section titled “Single Responsibility Principle”// 첫 번째 버전: "언제" + "무엇을"이 섞여있음useRefetchOnForeground({ refetch });
// 두 번째 버전: "언제"만 다루고, "무엇을"은 사용자에게 위임useAppForeground({ onForeground: refetch });첫 번째 버전은 두 가지 관심사를 가진다:
- 포그라운드 전환 감지 (타이밍)
- refetch 실행 (액션)
두 번째 버전은 하나의 관심사만 가진다:
- 포그라운드 전환 감지 (타이밍)
“무엇을 할지”는 사용자가 결정한다.
왜 관심사를 분리해야 하는가
Section titled “왜 관심사를 분리해야 하는가”관심사가 섞이면 변경의 이유가 여러 개가 된다.
// useRefetchOnForeground가 변경되어야 하는 이유:// 1. 포그라운드 전환 감지 로직이 바뀔 때// 2. refetch 방식이 바뀔 때
// useAppForeground가 변경되어야 하는 이유:// 1. 포그라운드 전환 감지 로직이 바뀔 때변경의 이유가 하나만 있으면, 코드는 더 안정적이다.
훅의 파생 - 상태처럼, 훅도 파생되어야 한다
Section titled “훅의 파생 - 상태처럼, 훅도 파생되어야 한다”상태는 파생된다
Section titled “상태는 파생된다”우리는 이미 상태를 파생시켜 사용한다:
const [count, setCount] = useState(0);
// count로부터 파생된 상태들const isEven = count % 2 === 0;const doubled = count * 2;const message = count > 10 ? "많음" : "적음";count가 바뀌면 관련된 모든 파생 상태가 자동으로 업데이트된다.
하나의 원천(count)만 관리하면, 나머지는 자동으로 따라온다.
훅도 파생되어야 한다
Section titled “훅도 파생되어야 한다”같은 원리가 훅에도 적용되어야 한다.
// 기본 훅 (원천)export const useAppForeground = ({ onForeground,}: { onForeground: () => void;}) => { const appState = useRef(AppState.currentState);
useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { onForeground(); } appState.current = nextAppState; });
return () => { subscription.remove(); }; }, [onForeground]);};
// useAppForeground로부터 파생된 훅들export const useRefetchOnForeground = (refetch: () => void) => { useAppForeground({ onForeground: refetch });};
export const useTrackOnForeground = (track: () => void) => { useAppForeground({ onForeground: track });};
export const useRefreshTokenOnForeground = (refresh: () => void) => { useAppForeground({ onForeground: refresh });};이제 포그라운드 감지 로직이 바뀌면?
useAppForeground 하나만 수정하면 모든 파생 훅이 자동으로 업데이트된다.
왜 이게 중요한가
Section titled “왜 이게 중요한가”만약 처음부터 useRefetchOnForeground만 만들고,
나중에 useTrackOnForeground, useRefreshTokenOnForeground를 각각 따로 만든다면?
// 안티패턴: 각 훅이 독립적으로 구현됨export const useRefetchOnForeground = (refetch: () => void) => { const appState = useRef(AppState.currentState); useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { refetch(); } appState.current = nextAppState; }); return () => subscription.remove(); }, [refetch]);};
export const useTrackOnForeground = (track: () => void) => { const appState = useRef(AppState.currentState); useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if ( appState.current.match(/inactive|background/) && nextAppState === "active" ) { track(); } appState.current = nextAppState; }); return () => subscription.remove(); }, [track]);};
// ... 같은 로직 반복이제 포그라운드 감지 로직을 수정해야 한다면? 모든 훅을 하나하나 찾아서 수정해야 한다.
하지만 useAppForeground를 먼저 만들고 파생시켰다면?
useAppForeground 하나만 수정하면 끝이다.
유지보수의 일원화
Section titled “유지보수의 일원화”// useAppForeground의 로직이 바뀌어야 한다면export const useAppForeground = ({ onForeground,}: { onForeground: () => void;}) => { useEffect(() => { // 새로운 감지 로직 const subscription = AppState.addEventListener("change", (nextAppState) => { if (nextAppState === "active") { // 조건 변경 onForeground(); } }); return () => subscription.remove(); }, [onForeground]);};
// 이 한 줄의 수정으로// useRefetchOnForeground// useTrackOnForeground// useRefreshTokenOnForeground// 모두 자동으로 업데이트됨상태가 count로부터 파생되듯이,
훅도 useAppForeground로부터 파생되어야 한다.
그래야 흐름이 일원화되고, 유지보수가 쉬워진다.
내가 놓친 것
Section titled “내가 놓친 것”문제 해결에만 집중했다
Section titled “문제 해결에만 집중했다”처음 훅을 만들 때 내 사고 흐름:
앱이 포그라운드로 돌아올 때 ↓데이터를 다시 가져와야 한다 ↓"useRefetchOnForeground 만들자!" ↓구현 완료파생 가능성을 고려하지 못했다
Section titled “파생 가능성을 고려하지 못했다”올바른 사고 흐름:
앱이 포그라운드로 돌아올 때 ↓"어떤 일이 일어나야 하는가?" (타이밍 파악) ↓"그건 사용하는 곳마다 다를 수 있다" (관심사 분리) ↓"타이밍만 제공하는 기본 훅을 만들자" (원천) ↓useAppForeground 완성 ↓"필요하다면 이걸로 파생 훅을 만들 수 있다" (파생) ↓useRefetchOnForeground = useAppForeground를 활용나는 문제를 해결하는 데 집중했지만, 파생 가능성을 고려하지 못했다.
상태를 설계할 때는 자연스럽게 파생을 고려하면서, 왜 훅을 설계할 때는 파생을 고려하지 않았을까?
1. 순수 함수처럼, 훅도 조합 가능해야 한다
Section titled “1. 순수 함수처럼, 훅도 조합 가능해야 한다”순수 함수는 다른 함수로부터 조합된다:
const add = (a, b) => a + b;const multiply = (a, b) => a * b;
// 조합const addThenMultiply = (a, b, c) => multiply(add(a, b), c);같은 원리로, 훅도 다른 훅으로부터 조합되어야 한다:
// 기본 훅const useAppForeground = ({ onForeground }) => { /* ... */};
// 조합const useRefetchOnForeground = (refetch) => useAppForeground({ onForeground: refetch });순수 함수를 만들 때 “이 함수는 재사용 가능한가?”를 고민하듯, 훅을 만들 때도 “이 훅은 조합 가능한가?”를 고민해야 한다.
2. 본질을 파악하는 능력
Section titled “2. 본질을 파악하는 능력”useRefetchOnForeground → useAppForeground로 가는 과정은
“이 코드의 본질이 무엇인가?”를 묻는 과정이다.
- 본질: 포그라운드 전환 감지
- 세부사항: refetch
본질을 파악하면, 더 나은 추상화가 보인다. 그리고 그 추상화로부터 필요한 모든 것을 파생할 수 있다.
상태가 파생되듯이, 훅도 파생되어야 한다.
우리는 이미 상태를 설계할 때 파생을 자연스럽게 활용한다. 이제 훅을 설계할 때도 같은 원칙을 적용하자.
// 기본 훅 (원천)const useAppForeground = ({ onForeground }) => { /* ... */};
// 파생 훅들const useRefetchOnForeground = (refetch) => useAppForeground({ onForeground: refetch });const useTrackOnForeground = (track) => useAppForeground({ onForeground: track });훅을 순수함수처럼 작성하여 추상화 수준을 끌어 올려보자.