토스의 첫 개발자 컨퍼런스인 SLASH 21에서 3일 차 세션 중 하나인 '프론트엔드 웹 서비스에서 우아하게 비동기 처리하기'를 보고 유익한 내용이 많아 정리해서 글을 쓰게 됐다.
세션에서 박서진 프론트엔드 개발자님이 제시한 좋은 코드의 특징은 다음과 같다.
1. 성공, 실패의 경우를 분리해 처리할 수 있다.
2. 비즈니스 로직을 한눈에 파악할 수 있다.
웹 프론트엔드에서 비동기 처리를 좋은 코드로 작성하는 것은 웹 프론트엔드의 어려운 과제 중 하나다. 특히 리액트에서 더 어려워진다.
컴포넌트에서 비동기 처리가 사용되면 성공하는 경우와 실패하는 경우가 섞여서 처리해야 하고 비동기 처리가 두 개 이상이 되면 비동기 처리의 각각의 상태에 따라 경우들이 생기고 전부 처리하기 위해서 컴포넌트가 매우 복잡해진다.
리액트에서 비동기 처리를 수행하는 좋은 코드가 되기 위해서는 위와 같은 형태가 되어야 한다.
비동기 처리의 수행 결과를 마치 비동기 처리가 아닌 함수를 사용한 것과 같이 사용하고 에러 처리는 외부에 위임할 수 있어야 한다.
React 16.6 버전에 추가된 React Suspense 컴포넌트를 활용하면 위와 같이 비동기 처리를 우아하게 수행할 수 있다. (자세한 내용은 링크에서 확인할 수 있다)
<ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</ErrorBoundary>
비동기 처리가 로딩 중일 때는 가장 가까운 Suspense 컴포넌트의 fallback으로, 에러가 발생한 경우에는 가장 가까운 ErrorBoundary의 fallback으로 화면을 표시하기 때문에 그 안에 있는 컴포넌트는 비동기 처리를 동기 처리와 같이 생각하고 성공한 경우에만 집중해 코드를 작성할 수 있다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
ErrorBoundary 컴포넌트는 컴포넌트 내에서 생명주기 메서드인 getDerivedStateFromError()나 componentDidCatch() 중 하나 이상을 정의하면 생성할 수 있다.(자세한 내용은 https://ko.reactjs.org/docs/error-boundaries.html를 참고)
try-catch 구문이 컴포넌트 단계에 적용됐다고 생각하면 쉽게 이해할 수 있을 것이다.
또한 Suspense 컴포넌트는 좋은 코드를 작성하는데 좋을 뿐만 아니라 useEffect Hook이나 componentDidUpdate 단계에서 비동기 처리를 수행할 때 발생하는 경쟁 상태(Race Condition)를 쉽게 해결할 수 있다.
경쟁 상태는 컴포넌트와 비동기 처리가 가지고 있는 각각의 생명주기를 동기화하는 과정에서 발생한다. 특히 각자 고유한 생명주기를 가지고 있는 비동기 처리들이 한 컴포넌트 내에 존재한다면 동기화하는 것이 쉽지 않을 것이다.
function ProfilePage({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(id).then(u => setUser(u));
}, [id]);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<div>
<h1>{user.name}</h1>
<ProfileTimeline id={id} />
</div>
);
}
function ProfileTimeline({ id }) {
const [posts, setPosts] = useState(null);
useEffect(() => {
fetchPosts(id).then(p => setPosts(p));
}, [id]);
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
useEffect를 사용해 렌더링 후에 비동기 처리를 하는 코드.
이 경우에 다음 프로필로 전환된 후에 직전 프로필에서 보낸 요청이 돌아오는 경우 상태를 덮어쓰게 된다는 문제점이 발생한다.
const initialResource = fetchProfileData(0);
function App() {
const [resource, setResource] = useState(initialResource);
return (
<>
<button onClick={() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
}}>
Next
</button>
<ProfilePage resource={resource} />
</>
);
}
function ProfilePage({ resource }) {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails resource={resource} />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline resource={resource} />
</Suspense>
</Suspense>
);
}
function ProfileDetails({ resource }) {
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline({ resource }) {
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
다음은 Suspense를 사용한 코드인데 가장 중요한 차이는 바로 State를 설정하는 위치다.
//UseEffect
useEffect(() => {
fetchUser(id).then(u => setUser(u));
}, [id]);
//Suspense
<button onClick={() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
}}>
useEffect는 setUser를 비동기 처리의 callback으로 전달하는 반면 Suspecnse에서는 setResource의 인자로 비동기 처리의 결과를 전달하는 형태.
Suspense는 요청을 하고 즉시 State를 설정하기 때문에 마치 동기 처리를 하는 것과 같이 실행되어 경쟁 상태가 발생하지 않는다. 다만 요청의 응답이 왔을 때 추가적으로 콘텐츠를 주입할 뿐이다.
다만 아직 Suspense 컴포넌트는 실험단계에 있기 때문에 공식문서에서 서비스나 제품에 사용하는 것을 권장하지 않는다.
"기능들이 안정된 뒤에 배우고 싶으시다면, 이와 관련된 작업은 잠시 잊고 계시다가 Suspense의 생태계가 보다 성숙한 뒤에 돌아오는 것이 좋을 겁니다."
만약 지금 실무에서 Suspense의 장점을 적용하고 싶다면 GraphQL과 함께 사용하는 Relay를 사용하면 된다고 안내하고 있다.(https://relay.dev/docs/getting-started/step-by-step-guide/)
이번 글에서는 세션에서 소개한 Suspense에 대해 정리해봤고 다음 글에서는 세션에서 소개한 대수적 효과(Algebraic Effects)에 관한 글을 정리해 보려고 한다.
참고자료
[세션영상] https://toss.im/slash-21/sessions/3-1
프론트엔드 웹 서비스에서 우아하게 비동기 처리하기
API를 호출하거나 네이티브 앱과 통신할 때 프론트엔드 웹 서비스에서는 반드시 비동기 작업이 일어나게 됩니다. 일상처럼 다루고 있지만 정작 UI에서 다루기 힘든 비동기 프로그래밍. React Suspens
toss.im
[React Suspense 공식 문서] https://ko.reactjs.org/docs/concurrent-mode-suspense.html
데이터를 가져오기 위한 Suspense (실험 단계) – React
A JavaScript library for building user interfaces
ko.reactjs.org
'웹 개발' 카테고리의 다른 글
주요 렌더링 경로 최적화(Optimizing the Critical Rendering Path) (0) | 2021.07.28 |
---|---|
Lazy Loading (0) | 2021.07.20 |
Intersection Observer API (0) | 2021.07.14 |
프로그래머스 <2021 네이버웹툰 개발 챌린지> 2차 과제 후기 (0) | 2021.07.13 |