프로젝트를 진행하면서 2개 이상의 컴포넌트에서 같은 로직이 사용되기 때문에 커스텀 훅으로 로직을 분리했던 경우가 많았다.
이러한 경우에 항상 헷갈렸던 내용을 정리하기 위해서 블로그 글을 작성한다.
예를 들어서 useInput과 같은 커스텀 훅을 만들어 본다고 한다면 input에서 사용하는 change event와 value를 다루는 로직은 동일 로직으로 분류하고 이를 커스텀 훅으로 만들 수 있을 것이다.
import {ChangeEvent} from 'react';
export default function useInput():[string,(e:ChangeEvent<HTMLInputElement>) => void] {
const [value,setValue] = useState('');
const handleChangeValue = (e:ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}
return [value,handleChangeValue];
}
위와 같이 만들고 나면 하나의 컴포넌트에서 여러 개의 커스텀 훅을 사용할 수 도 있고 여러 컴포넌트에서 하나의 useInput을 사용할 수 있으며 혹은 둘 다 해당할 수 있다.
import useInput from './useInput';
function A() {
const [text,handleText] = useInput();
const [name,handleName] = useInput();
return <form>
<input type="text" value={name} onChange={handleName} />
<input type="text" value={text} onChange={handleText} />
</form>
}
function B() {
const [comment,handleComment] = useInput();
return <form>
<input type="text" value={comment} onChange={handleComment}/>
</form>
}
여기서 만약에 동일 로직인데 동일한 인스턴스(혹은 상태)를 참조해야 한다는 상황이 생기면 어떻게 풀어나가야 하는지에 대해서 항상 막연하게 useRef를 사용하면 되지 않을까?라는 생각을 했다라는 점이다.
useRef는 랜더링이 필요하지 않은 값을 참조할 수 있게 해준다. 그래서 DOM에 직접적으로 접근하거나 할 때 주로 사용한다. 중요한 것은 서로 다른 컴포넌트간에 같은 값이 공유되는 것은 아니다라는 점이다.
말로 표현할려고 하니까 살짝 어려운 감이 있는데 해당 내용을 useInput을 통해서 얘기를 해본다면
A 컴포넌트에서 사용하는 text와 name은 useInput에서는 value이다. 같은 value이지만 text가 변하더라도 name은 변하지 않는다.
useRef도 마찬가지고 useState도 마찬가지로 새롭게 생겨날 뿐이지 상태값이 공유되지 않는다. 그렇기에 커스텀 훅 내에서 공유해야만 하는 상황이 있다면 공유할 수 있도록 해줘야 한다는 것이다.
여러가지 방법들이 있는데 결국 상태를 어떻게 관리할 것이냐에 대한 물음이다. 상태를 관리하는 여러 방법들을 통해서 공유될 수 있도록 하면 된다. 대표적으로 Context API를 이용하거나 Redux, Zustand와 같은 상태 관리 라이브러리를 이용하여 구현할 수 있다. 하나하나 한번 해보면서 서로 어떤 차이점들이 있는지 알아보자. 실제로 프로젝트에서 사용했던 코드를 기반으로 만들 예정이고, 시나리오는 mediasoup을 이용하여 WebRTC 기술을 이용할 때 필요한 device이다. 이 device는 클라이언트 마다 하나의 인스턴스만 있으면 되기 때문에 로직을 분리한다 하더라도 device에 대한 내용은 공유가 제대로 되고 있어야 한다.
Context API는 주로 다음과 같은 패턴으로 구현을 한다.
import {PropsWithChildren} from 'react';
const initialValue = {
someState: undefined,
handleSomeState: () => {},
};
const SomeContext = createContext(initialValue);
export default function SomeProvider({children}:PropsWithChildren) {
const [someState,setState] = useState();
const handleSomeState = () => {};
const value = {someState,handleSomeState};
return <SomeContext.Provider value={value}>{children}</SomeContext.Provider>
}
export const useSomeContext = () => {
return useContext(SomeContext);
}
이렇게 Context Provider를 구현한 뒤에 사용할 로컬 컴포넌트들 트리의 상위에 선언하여 사용한 뒤에 로컬 컴포넌트들 내부에서 useSomeContext를 불러와서 value에 들어갈 값을 가져오면 된다.
실제로 device에 대한 내용을 Context API를 이용해서 구현해보면 다음과 같을 것이다.
import { RtpCapabilities, TransPortParams } from "@/types/conference.types";
import { Device } from "mediasoup-client";
import { PropsWithChildren, createContext, useContext, useMemo } from "react";
const initialValue = {
device: new Device(),
};
const DeviceContext = createContext(initialValue);
export default function DeviceProvider({ children }: PropsWithChildren) {
const device = new Device();
const value = useMemo(
() => ({
device,
}),
[]
);
return (
<DeviceContext.Provider value={value}>{children}</DeviceContext.Provider>
);
}
export const useDevice = () => {
const { device } = useContext(DeviceContext);
const loadDevice = async (rtpCapabilities: RtpCapabilities) => {
if (device && device.loaded) return;
try {
await device?.load({ routerRtpCapabilities: rtpCapabilities });
} catch (error) {
console.error("load device error", error);
}
};
const createSendTransportWithDevice = (params: TransPortParams) =>
device.createSendTransport(params);
const createRecvTransportWithDevice = (params: TransPortParams) =>
device.createRecvTransport(params);
const getRtpCapabilitiesFromDevice = () => device.rtpCapabilities;
return {
loadDevice,
createSendTransportWithDevice,
createRecvTransportWithDevice,
getRtpCapabilitiesFromDevice,
};
};
Redux 혹은 Zustand와 같이 상태 관리 라이브러리를 이용해서 해당 상태 값을 외부 스토어에 저장을 시켜놓으면 해당 값을 공유할 수 있다. Redux는 보일러 플레이트가 많기 때문에 Zustand를 이용해서 해당 코드를 다시 바꿔보자면
import {
AppData,
RtpCapabilities,
TransPortParams,
} from "@/types/conference.types";
import createSelectors from "@/zustand/config/createSelector";
import { Device } from "mediasoup-client";
import { Transport } from "mediasoup-client/lib/types";
import { create } from "zustand";
interface DeviceState {
device: Device;
loadDevice: (rtpCapabilities: RtpCapabilities) => Promise<void>;
createSendTransportWithDevice: (
params: TransPortParams
) => Transport<AppData>;
createRecvTransportWithDevice: (
params: TransPortParams
) => Transport<AppData>;
getRtpCapabilitiesFromDevice: () => RtpCapabilities;
}
const deviceStore = create<DeviceState>()((_, get) => ({
device: new Device(),
loadDevice: async (rtpCapabilities: RtpCapabilities) => {
const { device } = get();
if (device && device.loaded) return;
try {
await device?.load({ routerRtpCapabilities: rtpCapabilities });
} catch (error) {
console.error("load device error", error);
}
},
createSendTransportWithDevice: (params: TransPortParams) => {
const { device } = get();
return device.createSendTransport(params);
},
createRecvTransportWithDevice: (params: TransPortParams) => {
const { device } = get();
return device.createRecvTransport(params);
},
getRtpCapabilitiesFromDevice: () => {
const { device } = get();
return device.rtpCapabilities;
},
}));
const useDevice = createSelectors(deviceStore);
export default useDevice;
여기에서 이처럼 비슷한 모양으로 만들 수 있다. createSelectors는 zustand에서 자동으로 셀렉터를 만들어주는 것이다. 자세한 내용은 공식 문서에서 찾을 수 있다.
커스텀 훅은 어차피 함수이다. 서로 다른 값이 생기는 이유도 다른 컴포넌트에서 함수를 호출하면서 그 내용이 공유되는 것이 아니라 그냥 새롭게 생겨나기 때문이다. 그렇기 때문에 클로저를 이용하면 같은 인스턴스를 참조할 수 있을 것이다.
const device = new Device();
export default fuction useTodos() {
const loadDevice = () => {};
const createSendTransportWithDevice = () => {};
...
}
우선 device라는 같은 인스턴스를 다른 컴포넌트에서도 잘 참조하고 있다. 그렇기 때문에 사실 어떠한 방법을 이용해도 상관 없다고 생각한다. 그래서 각 구현을 직접 작성해보면서 느낀점을 작성해보자면..
별도 라이브러리 설치할 필요없이 바로 사용할 수 있었으며, eslint와 typescript를 이용해서 구현을 했는데 type 설정하는 것이 생각보다 에러가 많이 발생했었다.
별도로 설치가 필요했고 외부 스토어 개념을 통해서 좀 더 명확하게 device라는 인스턴스를 외부로 빼낸다는 느낌이 강하게 들었다. 하나의 스토어에 액션을 넣을 것인지 아니면 분리해서 관리할 것인지에 대한 고민을 좀 했었는데 응집도를 높이는 편이 좋을 것이라 판단해서 다 넣어놨는데 현재와 같은 경우라면 별도로 분리하는 편도 좋았을 것이라고 생각한다.
매우 간단하게 동일한 상태값을 공유할 수 있었다. 현재의 경우에는 별도로 device를 컨트롤하는 부분이 없기 때문에 문제가 없었지만 만약에 상태값을 변경을 해야한다면 분명히 오류가 발생할 여지가 크다고 판단된다.
위 예제의 경우에는 클로저를 사용해도 문제없이 동작할 것 같다. 그렇지만 잠재적 문제가 있는데 사용할 필요보다는 context api와 zustand를 사용하는 편이 더 좋을 것 같다. 현재 프로젝트에서는 이미 zustand를 사용하고 있는데 context api는 최적화가 까다롭다는 문제도 있기 때문에 zustand를 사용해서 관리하는 것이 좋을 것 같다.