the Rules of Hooks in a class-based architecture
[ data fetching on react.js ] 에서 나는 Fetcher ➠ Query ➠ DomainQuery로 이어지는 클래스의 상속 관계 끝에 뜬금없이 useAdaptor라는 훅을 끼워넣었다. 이는 일반적인 훅의 규칙을 따르기 위함이었다. useQuery나 useMutation와 같은 커스텀 훅을 호출하기 위해서는 반드시 추가적인 커스텀 훅이 필요하다는 것이 이 바닥의 상식이다.
그러나 나는 [ Breaking the Rules of Hooks ] 포스트를 통해 실제로는 커스텀 훅을 호출하기 위해 반드시 추가적인 커스텀 훅이 필요한 것은 아니며 ─ 각각의 규칙이 제시된 기저 이유를 깊게 이해한다는 전제 하에 ─ 클래스 내부에서도 훅을 사용할 수 있는 우회적인 방법이 존재한다는 사실을 알게되었다. 이는 훅이 호출되는 시점과 컨텍스트가 올바르기만 하다면, 꼭 전통적인 '훅 안의 훅' 형태를 취하지 않아도 된다는 것을 의미한다.
이 사실은 나로 하여금 클래스 상속 관계 끝에 억지로 커스텀 훅을 추가하는 대신, 랜더링 컨텍스트에 맞춰 유연하게 호출하는 방법을 고민하게 만들었고, 결과적으로 더 단순하고 직관적인 구조로 리팩터링할 수 있는 아이디어를 제공해주었다. 나는 TanstackQuery 레포지토리에 이 아이디어를 올려보았고 ─ 그 글은 대체로 그 누구의 흥미도 끌지 못한 듯하지만, 아주 친절한 메인테이너 ─ TkDodo가 간단한 피드백을 남겨주었다.
그는 나의 접근 방식이 일반적인 훅의 사용 패턴에서 벗어나긴 했지만, 리액트 컴포넌트 안에서 ─ 조건적으로 호출되지 않는다면 ─ 사용하는 데 문제 없을 거라고 이야기해주었다. 물론 이 방식이 널리 쓰이기엔 아직 낯설고 리스크도 존재하지만, 나로서는 이 작은 상호작용 하나가 충분히 의미 있는 수확이었다.
클래스 기반 훅의 장점과 단점
클래스 기반 훅은 기존의 함수형 훅과 비교해 상당한 이점을 제공한다. 가장 큰 장점은 로직의 캡슐화와 재사용성이다. 클래스는 상태와 메서드를 하나의 객체로 묶어두기 때문에, 복잡한 도메인 로직이나 상태 전이를 명확하게 구조화할 수 있다. 또한 여러 훅을 조합하거나 조건부로 호출해야 하는 상황에서도 클래스 내부에서 이를 분리된 메서드로 다룰 수 있어 가독성과 테스트 용이성이 높아진다.
이미 널리 알려진 객체지향의 여러 테크닉을 직접적으로 이식할 수 있다는 것 또한 클래스 기반 훅의 강점이다. 예를 들어, 상속, 다형성, 캡슐화와 같은 객체지향 개념을 활용하여, 공통적인 로직을 상속받거나, 다양한 하위 클래스에서 특정 로직을 다르게 처리하는 방식으로 재사용성을 극대화할 수 있다. 이를 통해 코드의 중복을 줄이고, 더 명확한 책임 분리를 할 수 있어 유지보수와 확장성 측면에서도 유리하다. 또한, 클래스 기반 설계는 기존의 객체지향적 사고 방식을 따르기 때문에, 객체지향을 잘 알고 있는 개발자에게는 더 직관적이고 친숙한 방식으로 느껴질 수 있다.
물론 여기에는 장점만 있는 것은 아니다. 첫째, 리액트의 함수형 컴포넌트와 훅 패러다임에 부합하지 않기 때문에 코드의 일관성이 떨어질 수 있다. 리액트는 본래 함수형 컴포넌트와 훅을 기반으로 동작하도록 설계되었기 때문에, 클래스 기반의 로직은 리액트의 동작 방식과 맞지 않아 예상치 못한 동작이나 복잡한 버그를 초래할 수 있다.
둘째, 상태 관리와 렌더링 최적화 측면에서 불필요한 복잡성을 유발할 수 있다. 클래스 컴포넌트에서는 this를 사용해야 하기 때문에, 상태 관리가 더 번거로워지고, 컴포넌트가 리렌더링될 때마다 불필요한 계산이 발생할 수 있다. 또한, 클래스는 함수형 훅에 비해 코드가 더 길고 복잡해질 수 있어, 유지보수 측면에서 불편할 수 있다. 결국, 클래스 기반 훅은 객체지향적 접근을 선호하는 개발자에게는 유리할 수 있지만, 리액트의 함수형 컴포넌트 패러다임에 익숙한 팀에서는 사용을 지양하는 것이 좋다.
개인적인 판단을 첨부한다면 이렇다. 나는 개인 프로젝트나 소규모 프로젝트라면 클래스 기반 훅을 적극적으로 사용해보는 편이다. 그러나 수백명의 개발자가 함께 쌓아 올려나가야 하는 종류의 프로젝트라면 절대 클래스 기반 훅을 쓰지 않을 것 같다.
클래스 기반 훅의 규칙
흔히 타입스크립트를 자바스크립트의 슈퍼셋(superset)이라고 부른다. 이는 타입스크립트가 자바스크립트의 모든 기능을 포함하면서, 타입 시스템을 추가하여 코드의 안정성을 높이고, 더 나은 개발 경험을 제공한다는 의미이다. 마찬가지로, 클래스 기반 훅의 규칙은 일반적인 훅의 규칙을 확장한 형태로, 훅의 규칙의 슈퍼셋이라고 할 수 있다. 즉, 클래스 기반 훅은 기존의 훅 규칙을 따르면서도 추가적인 기능과 구조적인 제약을 제공하여, 더 나은 상태 관리와 라이프사이클 제어를 가능하게 한다.
내가 생각하는 클래스 기반 훅의 규칙은 아래와 같다. 두 규칙 모두 '반드시' 따라야 하는 것은 아니지만, 클래스 기반 훅을 안전하게 작성하기 위해서는 가능하면 이 규칙을 따르는 것이 '무조건' 좋다.
- static 사용을 지양한다.
- 함수 선언문 대신 함수 표현식을 사용한다.
No Static
static 제한자는 멤버 변수와 메서드를 인스턴스 생성 시점과 관계 없이 평가되도록 한다. 따라서 클래스 기반 훅에 static을 사용할 경우 컴포넌트 내에서 평가되어야 할 것들이 리액트 밖에서 평가되게 될 위험이 생긴다.
예를 들어, 컴포넌트의 상태나 사이클 메서드에 의존하는 로직이 static으로 정의된 경우, 의도하지 않은 시점에 실행되거나 값이 잘못 초기화될 수 있다. 이런 문제를 피하려면, 클래스 기반 훅에서 static을 최소화하고, 상태나 메서드가 컴포넌트 인스턴스의 라이프사이클에 맞춰 평가되도록 해야 한다.
물론 언제나 이런 것은 아니다. static이 평가되는 시점을 극도로 예민하게 추적할 수 있다는 전제하에 ─ 여전히 매우 보수적으로 접근해야 하지만 그럼에도 불구하고 ─ static을 사용할 수도 있다. 하지만 개발자의 정신력에는 한계가 있고, 리액트와 같은 프레임워크의 라이프사이클을 정확히 추적하고 관리하는 것은 매우 복잡하고 실수의 여지가 크다. 결국 static을 남용하게 되면, 예기치 않은 동작이나 버그가 발생할 가능성이 높아지고, 코드의 유지보수성도 떨어진다.
따라서 'static 사용을 지양한다'고 썼지만, 가능하다면 'static을 사용하지 마세요'라고 받아들였으면 한다.
Yes Expression
이 문제는 this와 관련이 있다. 자바스크립트에서 this는 동적으로 결정되는 값이기 때문에 메서드가 호출되는 시점에 따라 다르게 동작할 수 있다. 이 때문에 함수 선언문으로 메서드를 작성하게 된다면 예기치 않은 동작이나 버그를 초래할 수 있으며, this가 예상치 못한 객체를 참조하게 될 수 있다.
특히 컴포넌트의 재사용성을 높이기 위해 이따금씩 Context API를 통해 로직만을 주입하는 경우가 있는데, 이때 클래스 기반 훅의 일부 메서드만 value로 제공할 경우 this가 해당 클래스의 인스턴스를 참조하는 대신, 전역 객체를 참조하는 문제가 발생하기도 한다.
때문에 클래스 기반 훅을 작성할 때는 함수 선언문 대신 함수 표현식을 사용하는 것을 권장한다. 함수 표현식은 this가 호출되는 시점의 컨텍스트를 자동으로 바인딩해주기 때문에, 클래스 기반 훅 내에서 메서드를 정의할 때 함수 선언문보다 더 안전하고 일관된 동작을 보장할 수 있다. 특히 화살표 함수는 자신만의 this를 가지지 않고, 외부 컨텍스트에서 this를 상속받아 사용하기 때문에 클래스 내에서 this가 예상한 대로 동작하도록 돕는다. 이를 통해 클래스 인스턴스의 상태나 메서드에 의존하는 로직이 의도치 않게 전역 객체를 참조하거나 다른 객체로 변경되는 문제를 예방할 수 있다.
따라서 클래스 기반 훅을 작성할 때는 함수 표현식을 사용하는 것이 코드의 안정성과 예측 가능성을 높이는 좋은 습관이 된다.
부록 1 : FiberNode에서의 클래스 기반 훅
[ 리액트 파이버를 통한 리랜더링 이해 ] 포스트를 작성하며 나는 컴포넌트 내에서 호출된 상태는 모두 memoizedState 프로퍼티를 통해 연결 리스트 형태로 조회할 수 있다는 사실을 확인했다. 그렇다면 클래스 기반 훅 내에서 호출된 상태 역시 다르지 않으리라 예상해볼 수 있을 것이다.
export default function Page() {
const [value, setValue] = useState(0);
const test = new useTest();
return (
<>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</>
);
}
class useTest {
private state = useState(100);
test = () => {
const [value, setValue] = this.state;
return (
<>
<p>Value: {value}</p>
<button onClick={() => setValue(value + 1)}>Increment</button>
</>
);
}
}
FiberNode의 memoizedState 프로퍼티를 살펴보면 클래스 기반 훅 내에서 호출된 useState가 예상했던 바와 같이 next 프로퍼티에 정상적으로 연결되어있는 모습을 확인할 수 있다.
부록 2 : 클래스 기반 훅을 사용한 데이터 페칭
부록 2는 포스트 서두에 언급한 [ data fetching on react.js ] 에 클래스 기반 훅을 적용하면 어떻게 변할지 간단히 정리한 것이다. 각각의 클래스가 어떤 역할을 나타내는지는 특별히 적지 않는다. 브라우저 창을 두 개 열어두고 비교해보면서 읽어보는 것을 추천한다.
class Fetcher
class Fetcher {
private instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 5 * 1000,
withCredentials: true,
});
public async doFetch<T>(config: AxiosRequestConfig): Promise<T> {
const { data } = await this.instance<T>({
...config,
});
return data;
}
}
class Query and Mutation
// @/api/base/query.ts
abstract class Query extends Fetcher {
abstract queryKey: QueryKey;
protected queryFn = <T>(url: string) => () =>
this.doFetch<T>({
method: "get",
url,
});
protected infiniteQueryFn = <T>(url: string) => ({ pageParam }: { pageParam: number }) =>
this.doFetch<T>({
method: "get",
url: `${url}&skip=${pageParam}`,
});
}
// @/api/base/mutation.ts
abstract class Mutation extends Fetcher {
mutationFn = <T>(url: string, method: "post" | "put" | "patch" | "delete", data?: any) =>
this.doFetch<T>({
method,
url,
data,
});
}
class useDomainQuery and useDomainMutation
어댑터는 이 수준에서 적용된다. 각각의 메서드 내에서 어댑터를 호출하지만, 필요하다면 고차 함수나
// @/api/book/book.query.ts
class useBookQuery extends Query {
queryKey = ["book"];
getBookByIsbn = (isbn13: string) => {
const query = useQuery({
queryKey: [...this.queryKey, isbn13],
queryFn: this.queryFn<Book>(`/book/detail?isbn=${isbn13}`),
enabled: !!isbn13,
});
return { ...query, data: new RepositoryAdaptor().Book(query.data) };
};
getBookList = (keyword: string) => {
const query = useInfiniteQuery({
queryKey: [...this.queryKey, keyword],
queryFn: this.infiniteQueryFn<GetBookListQuery>(`/book/search?keyword=${keyword}&take=10`),
initialPageParam: 1,
getNextPageParam: (lastPage: GetBookListQuery, allPages: any, lastPageParam: number) =>
lastPage.hasNext ? lastPageParam + 1 : undefined,
});
return { ...query, data: new RepositoryAdaptor().BookList(query.data) };
}
}
// @/api/auth/auth.mutation.ts
class useAuthMutation extends Mutation {
private queryClient = useQueryClient();
private router = useRouter();
changePassword = () =>
useMutation({
mutationFn: (password) => this.mutationFn<ChangePasswordMutation>("/auth/password", "post", password),
onSuccess: () => {
toast.success("비밀번호가 변경되었습니다.");
this.queryClient.invalidateQueries({ queryKey: ["user"] });
this.router.push("/signin");
},
onError: (error) => {
toast.error("비밀번호 변경에 실패했습니다.");
},
});
}
데코레이터를 사용하면 아래와 같이 간편하게 만들 수도 있다. 함수 표현식은 화살표 함수이기 때문에 메서드 데코레이터 대신 프로퍼티 데코레이터의 형태로 작성해주어야 한다. 여러 데코레이터에 대해서는 [ decorator ] 포스트를 참고하기 바란다.
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true
}
}
// @/decorator/AdaptWith.ts
function AdaptWith(adaptor: (data: any) => any) {
return function (target: any, propertyKey: string) {
let value: any;
// getter 함수는 메서드가 호출될 때마다 실행됨
const getter = function () {
return function (this: any, ...args: any[]) {
// "value"는 실제 원래 메서드 (예: getBookByIsbn)
const result = value.apply(this, args); // value에 원래 메서드가 있기 때문에 호출됨
if (!result || typeof result !== 'object') return result;
return {
...result,
data: Array.isArray(result.data)
? result.data.map(adaptor) // 배열이면 각 항목에 어댑터 적용
: adaptor(result.data) // 아니면 그냥 어댑터 한 번 적용
};
};
};
// setter는 메서드가 할당될 때 호출됨
const setter = function (newVal: any) {
value = newVal; // newVal은 실제 메서드 (예: getBookByIsbn)
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
// @/api/book/book.query.ts
class useBookQuery extends Query {
queryKey = ["book"];
@AdaptWith(RepositoryAdaptor.Book)
getBookByIsbn = (isbn13: string) => useQuery({
queryKey: [...this.queryKey, isbn13],
queryFn: this.queryFn<Book>(`/book/detail?isbn=${isbn13}`),
enabled: !!isbn13,
});
}
use case
여러 단계에 걸쳐 추상화가 적용되어 실제 컴포넌트에서는 아주 간단하게 이를 사용할 수 있다.
export default function Bookshelf() {
const { data } = new useBookQuery().getBookByIsbn()
return (
<>
{data.map(book => <BookCard {...book}>)}
</>
)
}
블로그의 정보
Ayden's journal
Beard Weard Ayden