useCallback 은 React 의 성능 최적화를 위해 자주 사용하는 hook 입니다.
useCallback(fn, deps)
deps (의존성 배열) 가 변하지 않으면 fn 함수를 재생성하지 않고 이전 함수를 그대로 사용합니다. 이는 리렌더링이 빈번한 컴포넌트에서 새로운 함수를 만들지 않아 성능 최적화에 유용합니다. 다만 불필요하게 남용하는 경우에는 오히려 불필요한 메모리 사용으로 인해 필요한 경우에만 사용하는 편이 좋습니다.
주로 다음과 같은 상황에서 사용하는 편이 좋습니다.
React.memo 로 최적화된 자식 컴포넌트에 함수를 props 로 전달할 때useEffect useMemo 등의 의존성 배열에 함수를 넣어야 할때Context Provider 에서 함수를 value 로 내려줄 때// Child
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click</button>;
});
// Parent
const Parent = ({ value }) => {
const handleClick = useCallback(() => {
console.log(value);
}, [value]);
return <Child onClick={handleClick} />;
}
React 에서는 부모 컴포넌트가 리렌더링 시 자식 컴포넌트도 리렌더링 됩니다. 하지만 React.memo 로 구성한 자식 컴포넌트는 props 가 같으면 리렌더링을 방지할 수 있습니다. 그러므로 useCallback 으로 만든 함수를 props 으로 전달한다면 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다.
즉 useCallback 은 리렌더링으로 인한 함수의 재성성을 막고 기존 함수를 재사용하여 성능 최적화하는데 사용합니다.
커스텀 훅은 React에서 제공하는 기본 훅 (useState, useEffect 등) 을 조합해 재사용 가능한 로직을 만드는 함수입니다.
// front/hook/useInput.ts
import { SetStateAction, useCallback, useState } from 'react';
type ReturnType<T = any> = [T, React.Dispatch<SetStateAction<T>>, (event: any) => void];
const useInput = <T = any>(initialData: T): ReturnType<T> => {
const [value, setValue] = useState(initialData);
const handler = useCallback((event: any) => {
setValue(event.target.value);
}, []);
return [value, setValue, handler];
};
export default useInput;
//
제네릭을 사용하여 사용할 상태의 타입을 외부에서 지정하여 커스텀 훅이 어떤 타입이든 유연하게 대응 할 수 있습니다.
Redux사용 시Redux Saga및Redux Thunk를 사용하여 컴포넌트에서 비동기 로직을 분리할 수 있습니다. 다만 해당 로직이 한 컴포넌트에서만 사용된다면 코드를 분리하는 것이 오히려 코드가 길어지는 등의 단점이 있을 수 있습니다.
API
POST /users
- 회원가입
- body: { email: string(이메일), nickname: string(닉네임), password: string(비밀번호) }
- return: 'ok'
const onSubmit = useCallback(
(event: React.ChangeEvent<HTMLFormElement>) => {
event.preventDefault();
if (!missmatchError && nickname) {
console.log('서버로 회원가입하기');
axios
.post('http://localhost:3095/api/users', { email, nickname, password })
.then((response) => { // 성공
console.log(response);
})
.catch((error) => { // 실패
console.log(error.response);
})
.finally(() => {}); // 성공, 실패 상관없이 무조건 실행
}
},
[email, nickname, password, passwordCheck, missmatchError],
);
axios 라이브러리를 다운 받아 POST 요청을 진행하였습니다. axios 는 브라우저에 내장된 fetch 와 달리 기능이 더 풍부하여( 인터셉터 등) 사용성이 더 편합니다.
http 요청은 원래 서로 주소가 다르면 CORS 에러를 발생시킵니다.
// Express
// 여러 도메인 + 쿠키 인증 가능
app.use(
cors({
origin: true, // 들어온 요청의 origin 값을 그대로 Access-Control-Allow-Origin 넣어서 허용
credentials: true, // 쿠키/세션/Authorization 헤더 포함 요청 허용
})
);
// 쿠키/세션 인증 불가, 단순 공개 API에 적합
app.use(
cors({
origin: *, // 모든 origin 허용
// credentials: true 를 쓸 때는 origin: "*" 못 씀
})
);
백엔드에서 CORS 를 직접 허용할 수 있습니다.
// webpack.config.ts
devServer: {
proxy: [{ context: ['/api'], target: 'http://localhost:3095', changeOrigin: true }],
},
// http 요청
axios.post('/api/users', { email, nickname, password })
또는 프록시를 통해 서버로 전달하는 origin을 변경하여 해결할 수 있습니다. ( 개발 서버 전용 )
웹서비스의 로그인 방식은 크게 세션 기반 인증 토큰 기반 인증 두가지가 있습니다.
클라이언트 가 POST 요청으로 자격 증명을 보내면 서버 는 해당 정보를 가지고 사용자의 로그인을 결정합니다. 여기서 세션 기반 인증 방식은 서버 가 세션 저장소를 두고 세션ID 생성 후 클라이언트에게 쿠키 로 전달합니다. 이후 클라이언트 는 쿠키 를 저장하여 서버 에게 요청 시 받은 쿠키 를 붙여서 요청합니다. 서버 는 클라이언트 에서 온 쿠키 를 확인하여 로그인된 유저인지 확인합니다. 서버 에서 세션 을 삭제하면 사용자는 로그아웃됩니다.
이와 달리 JWT와 같은 토큰 기반 인증 방식은 서버 가 로그인 결정 후 JWT 와 같은 토큰 를 발급하여 클라이언트 에게 전달합니다. 클라이언트 는 해당 토큰 을 저장하여 서버 에 요청 시 해당 토큰 을 붙여 요청합니다. 서버 는 토큰 을 서버만 알고 있는 키 를 이용하여 검증합니다. 클라이언트에서 토큰 을 삭제하면 사용자는 로그아웃됩니다.
세션 기반 인증 방식과 달리 토큰 기반 인증 방식은 서버 의 세션 저장소 를 사용하지 않아 서버 부담이 적습니다.
SWR 은 React 에서 사용할 수 있는 Data Fetching 라이브러리 입니다. 캐시 기반 데이터 페칭 라이브러리로 수동으로 캐시를 수정 및 재검증을 통한 새 데이터를 불러오는 등 사용자 경험에 유리합니다.
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((res) => res.json());
export default function Profile() {
const { data, error, isLoading } = useSWR("/api/user", fetcher);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Failed to load</div>;
return <div>Hello {data.name}!</div>;
SWR 은 브라우저 포커스 갱신에 따른 재검증 및 네트워크 회복시 재검증 등 최신화된 데이터를 가져오는 전략이 다양합니다.
useSWR(key, fetcher, options)
또한 다양한 옵션을 사용하여 구성할 수 있습니다.
무엇보다 mutate 를 사용하여 캐시를 수동으로 업데이트 하거나 데이터를 재검증할 수 있습니다.
// 수동 업데이트
const { data, error, mutate } = useSWR('http://localhost:3095/api/users', fetcher);
const onSubmit = useCallback(
(event: React.ChangeEvent<HTMLFormElement>) => {
event.preventDefault();
setLogInError(false);
axios
.post('http://localhost:3095/api/users/login', { email, password }, { withCredentials: true })
.then((response) => {
mutate(response.data, false); // 변경할 데이터, 재검증 여부
})
.catch((error) => {
setLogInError(error.response.status === 401);
})
.finally(() => {});
},
[email, password, mutate],
);
// 재검증
const { data, error, mutate } = useSWR('http://localhost:3095/api/users', fetcher);
const onSubmit = useCallback(
(event: React.ChangeEvent<HTMLFormElement>) => {
event.preventDefault();
setLogInError(false);
axios
.post('http://localhost:3095/api/users/login', { email, password }, { withCredentials: true })
.then((response) => {
mutate(); // 재검증
})
.catch((error) => {
setLogInError(error.response.status === 401);
})
.finally(() => {});
},
[email, password, mutate],
);
이러한 방식은 Optimistic UI (낙관적 UI) 로써 서버에서 새로운 데이터를 전달 받지 않고 클라이언트에서 먼저 UI를 변경하여 빠른 사용자 경험을 제공할 수 있습니다.
// Bound Mutate
import useSWR from 'swr';
const { data, error, mutate } = useSWR(key, fetcher);
mutate(data, options)
// Global Mutate
import useSWR, {mutate} from 'swr';
mutate(key, data, options)
mutate 를 전역으로 사용한다면 key 넣어주어야 합니다.
const { data, error, mutate } = useSWR('http://localhost:3095/api/users', fetcher);
const { data, error, mutate } = useSWR('http://localhost:3095/api/users#1', otherFetcher);
위처럼 키를 구분하여 다은 fetcher 를 사용할 수 도 있습니다.
//React Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const fetchUser = () => fetch('/api/user').then(res => res.json());
function Profile() {
const queryClient = useQueryClient();
const { data, error, isLoading } = useQuery(['user'], fetchUser);
const mutation = useMutation(
newName => fetch('/api/user', { method: 'POST', body: JSON.stringify({ name: newName }) }),
{
onSuccess: () => queryClient.invalidateQueries(['user']), // 캐시 무효화 후 refetch
}
);
if (isLoading) return <div>로딩중...</div>;
if (error) return <div>에러!</div>;
return (
<>
<div>{data.name}</div>
<button onClick={() => mutation.mutate('홍길동')}>이름 변경</button>
</>
);
}
React Query 또한 데이터 패칭 라이브러리이지만 GET 기반의 읽기 에 특화된 SWR 과 달리 CRUD/복잡한 서버 상태 관리에 최적화 되어있습니다.
브라우저의 CORS 정책 때문에 오리진이 다른 경우 양쪽 다 허용 설정이 있어야 쿠키(세션) 같은 credential 이 오갑니다.
// fetch
fetch("http://localhost:3095/api/user", {
method: "GET",
credentials: "include", // 쿠키 포함
})
// axios
axios.get("http://localhost:3095/api/user", {
withCredentials: true, // 쿠키 포함
})
위와 같은 설정을 클라이언트에서 해주지 않으면 브라우저가 쿠키를 붙이지 않습니다.
app.use(
cors({
origin: true, // 또는 클라이언트 주소, Access-Control-Allow-Origin
credentials: true, // Access-Control-Allow-Credentials: true
})
);
서보 또한 설정을 해주어야 Access-Control-Allow-Credentials: true 로 되어 브라우저가 응답에서 오는 쿠키를 버리지 않습니다.
React 에서 공용 레이아웃을 쓰면서 화면마다 다른 내용을 보여주고 싶을 때는 레이아웃 컴포넌트를 만들고, 자식 컴포넌트를 props 로 전달하면 됩니다.
// front/layouts/Workspace
import React from 'react';
const Workspace = ({ children }: { children: React.ReactNode }) => {
return (
<div>
<button >로그아웃</button>
{children}
</div>
);
};
export default Workspace;
// front/pages/Channel
import Workspace from '@layouts/Workspace';
import React from 'react';
const Channel = () => {
return (
<Workspace>
<div>Channel</div>
</Workspace>
);
};
export default Channel;
react-router 의 Navigate 를 이용하여 렌더링 시점에서 특정 경로로 강제로 리다이렉트해줄 수 있습니다.
React 에서 lazy + Suspense 로 하는 로딩은 JS 번들 단위를 불러올 때의 로딩입니다.
즉, 컴포넌트 코드 자체가 아직 다운로드되지 않았을 때 보여주는 fallback UI 입니다.
하지만 그 컴포넌트가 실제로 렌더링된 이후 데이터를 또 불러와야 한다면 별도의 로딩 상태를 관리해야 합니다.
import React from 'react';
import useSWR from 'swr';
import fetcher from '@utils/fetcher';
const SingUp = () => {
const { data, error, mutate } = useSWR('http://localhost:3095/api/users', fetcher);
if (data === undefined) {
return <div>loading...</div>; // 데이터 받기 전 UI
}
return (
<div>
// 데이터 불러온 후 UI
</div>
);
};
export default SingUp;
학습은 역시 반복이 중요한 걸 느꼈습니다. 이전에 SWR을 사용할 때에는 무턱대고 사용했지만 한번 더 사용하니 필요한 상황에 더 알맞게 사용할 수 있게 되었습니다. CORS 오류 또한 발생하면 단수한 해결책만 찾아 해결했지만, 역시나 이유를 더 알고 학습하니 이 후 발생될때에도 유연하게 해결할 수 있을것 같습니다.
캐시 기반 데이터 패칭 라이브러리를 사용할 때, 최신성을 보장하면서도 대규모 트래픽으로 인한 과도한 API 요청을 어떻게 효율적으로 처리할 수 있을까?