Skip to content

추상화 수준과 관심사의 분리

최근 앱이 백그라운드에서 포그라운드로 전환될 때 데이터를 다시 가져오는 훅을 만들었다. 처음엔 이렇게 작성했다.

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]);
};

거의 같은 코드다. 하지만 근본적으로 다른 코드다.

// 첫 번째 버전
useRefetchOnForeground;
// 두 번째 버전
useAppForeground;
  • useRefetchOnForeground: “refetch한다”는 비즈니스 로직이 이름에 포함됨
  • useAppForeground: “포그라운드로 전환된다”는 타이밍만 표현됨

이름 하나의 차이가 왜 중요할까?

이름은 훅이 책임지는 범위를 보여준다. useRefetchOnForeground는 “refetch”라는 구체적인 행동까지 관여하겠다는 선언이고, useAppForeground는 “타이밍”만 제공하겠다는 선언이다.

// 첫 번째 버전: refetch 용도로만 사용 가능
useRefetchOnForeground({ refetch: getData });
// 두 번째 버전: 모든 용도로 사용 가능
useAppForeground({ onForeground: () => analytics.track("app_foreground") });
useAppForeground({ onForeground: refreshToken });
useAppForeground({ onForeground: checkNewMessages });
useAppForeground({ onForeground: refetch });

첫 번째 버전은 “refetch”라는 단어가 이름에 박혀있어서, 다른 용도로 쓰기 어색하다. 두 번째 버전은 “포그라운드 전환”이라는 본질만 다루기 때문에, 어떤 용도로든 자연스럽다.

추상화 수준코드 예시특징
구체적useRefetchOnForeground특정 용도에 최적화됨, 재사용성 낮음
추상적useAppForeground범용적, 재사용성 높음

추상화는 불필요한 세부사항을 제거하고 본질만 남기는 것이다.

  • useRefetchOnForeground의 본질은 무엇인가? → “포그라운드 전환 감지”
  • “refetch”는 본질인가? → 아니다. 그건 사용자가 결정할 선택지
// 첫 번째 버전: "언제" + "무엇을"이 섞여있음
useRefetchOnForeground({ refetch });
// 두 번째 버전: "언제"만 다루고, "무엇을"은 사용자에게 위임
useAppForeground({ onForeground: refetch });

첫 번째 버전은 두 가지 관심사를 가진다:

  1. 포그라운드 전환 감지 (타이밍)
  2. refetch 실행 (액션)

두 번째 버전은 하나의 관심사만 가진다:

  1. 포그라운드 전환 감지 (타이밍)

“무엇을 할지”는 사용자가 결정한다.

관심사가 섞이면 변경의 이유가 여러 개가 된다.

// useRefetchOnForeground가 변경되어야 하는 이유:
// 1. 포그라운드 전환 감지 로직이 바뀔 때
// 2. refetch 방식이 바뀔 때
// useAppForeground가 변경되어야 하는 이유:
// 1. 포그라운드 전환 감지 로직이 바뀔 때

변경의 이유가 하나만 있으면, 코드는 더 안정적이다.

훅의 파생 - 상태처럼, 훅도 파생되어야 한다

Section titled “훅의 파생 - 상태처럼, 훅도 파생되어야 한다”

우리는 이미 상태를 파생시켜 사용한다:

const [count, setCount] = useState(0);
// count로부터 파생된 상태들
const isEven = count % 2 === 0;
const doubled = count * 2;
const message = count > 10 ? "많음" : "적음";

count가 바뀌면 관련된 모든 파생 상태가 자동으로 업데이트된다.

하나의 원천(count)만 관리하면, 나머지는 자동으로 따라온다.

같은 원리가 훅에도 적용되어야 한다.

// 기본 훅 (원천)
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 하나만 수정하면 모든 파생 훅이 자동으로 업데이트된다.

만약 처음부터 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 하나만 수정하면 끝이다.

// 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로부터 파생되어야 한다.

그래야 흐름이 일원화되고, 유지보수가 쉬워진다.

처음 훅을 만들 때 내 사고 흐름:

앱이 포그라운드로 돌아올 때
데이터를 다시 가져와야 한다
"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 });

순수 함수를 만들 때 “이 함수는 재사용 가능한가?”를 고민하듯, 훅을 만들 때도 “이 훅은 조합 가능한가?”를 고민해야 한다.

useRefetchOnForegrounduseAppForeground로 가는 과정은 “이 코드의 본질이 무엇인가?”를 묻는 과정이다.

  • 본질: 포그라운드 전환 감지
  • 세부사항: refetch

본질을 파악하면, 더 나은 추상화가 보인다. 그리고 그 추상화로부터 필요한 모든 것을 파생할 수 있다.

상태가 파생되듯이, 훅도 파생되어야 한다.

우리는 이미 상태를 설계할 때 파생을 자연스럽게 활용한다. 이제 훅을 설계할 때도 같은 원칙을 적용하자.

// 기본 훅 (원천)
const useAppForeground = ({ onForeground }) => {
/* ... */
};
// 파생 훅들
const useRefetchOnForeground = (refetch) =>
useAppForeground({ onForeground: refetch });
const useTrackOnForeground = (track) =>
useAppForeground({ onForeground: track });

훅을 순수함수처럼 작성하여 추상화 수준을 끌어 올려보자.