🎬

React Suspense와 Role & Fault tolerance

Created
7/26/2021, 10:01:00 AM
Tags
Frontend
Subtitle
React Suspense를 왜 써야 하는가에 대한 고찰
해당 글은 Suspense의 용도를 역할과 내결함성이라는 관점에서 살펴봅니다. 또한 안타깝게도 해당 글은 거장의 의견이 아닌 주인장의 뇌를 통한 의견입니다. 틀린 견해 일수도 있으니 적당히 걸러들으시면 이 글을 더욱 유익하게 즐길 수 있습니다. :)

1. 배경

배열의 요소들을 모두 합하는 sumArray함수를 하나 만들었다.
const sumArray = (arr) => { let sum = 0; for(let i=0; i<arr.length; i++) { sum += arr[i]; } return sum } sumArray([1,2,3]) // 6
JavaScript
그런데 sumArray(arr) 라는 함수를 살펴봤을 때 꽤 문제가 많다.
sumArray(undefined) // Cannot read property 'length' of undefined
JavaScript
예외 상황에 어플리케이션이 죽어버리거나
sumArray(1) // 0 sumArray([1,"2",3]) // 123 sumArray([]) // 0 sumArray('12345') // 012345
JavaScript
혹은 예상할 수 없는 결과를 반환하기 때문이다.
각각의 예외 마다 여러 상황에 대처하는것은 물론 좋은 방법이지만, 간단한 예시이므로
1.
인자가 배열이 아닐때는 null을 리턴한다.
2.
인자에 문자열이 포함되어도 null을 리턴한다.
라는 예외만 처리해보자.
const sumArray = (arr) => { let sum = 0; // 검증 로직 if(!Array.isArray(arr)) return sum if(arr.some((el)=>typeof el !== 'number')) return sum for(let i=0; i<arr.length; i++) { sum += arr[i]; } return sum } sumArray(1) // null sumArray([1,"2",3]) // null sumArray([]) // null sumArray('12345') // null sumArray(undefined) // null
JavaScript
위의 검증 로직을 통해 더 이상 예외상황시에 어플리케이션이 죽어버리지 않는다!
또한 런타임에서도 일관된 값을 리턴해서 분기처리에 도움을 준다!
const result = sumArray(1) if(result) { return `총 합은 :${result} 입니다.` } else { return "잘못된 값입니다." }
JavaScript
그런데 sumArray(arr) 인터페이스는 정말 개선 되었을까?
인터페이스만 보고도 예외 상황에서 null 을 리턴 한다는 것을 알 수 있을까?

2. 내 결함성(Fault tolerance)이란

내결함성이란 시스템의 일부 구성 요소가 작동하지 않더라도 계속 작동할 수 있는 기능을 말합니다. 애플리케이션 구성 요소의 내장된 중복 기능이라고 볼 수 있습니다. 참고 : https://ausg.github.io/aws-certification-study/docs/module4/script3
위와 같은 검증 로직을 내 결함성 이라고 부르도록 하자.
어떤 함수가 내 결함성이 종속되어 있다는 것은 결함에 대한 처리를 하는 역할을 함수가 가지고 있다는 것이 된다.
다시 풀어 설명하면
1.
함수가 가져야 할 기능
2.
함수가 결함에 대한 처리를 해야 하는 기능
두 가지 역할을 가지고 있는 셈이다.
자, 이제 sumArray 함수가 예외 상황에서 왜 null 을 리턴하는 것을 인지하기 힘든지 깨달았다.
함수가 두 가지 역할을 하는데 함수명은 그렇지 않기 때문이다. 자 변경하자.
const trySumArrayCatchNull = (arr) => {
JavaScript
< 어지럽네? >

3. 불필요한 내결함성

많은 공감을 얻기 위해 굳이 어지러운 함수명을 보여드렸다.
다행히도 이렇게 써야 한다고 말하려는 것은 아니다.
내결함성의 특징을 반복하자.
어떤 함수가 내 결함성이 종속되어 있다는 것은 결함에 대한 처리를 하는 역할을 함수가 가지고 있다는 것이 된다.
상황에 맞게 유연하게 예외를 대처하는 것이 불가능한, 즉 함수 사용자에게는 아무런 권한이 없는 함수라는 의미이다.
자 그럼 내 결함성을 제거해보자.
const sumArray = (arr) => { let sum = 0; // 검증 로직 if(!Array.isArray(arr)) throw 'Not array' if(arr.some((el)=>typeof el !== 'number')) throw'Not number among element' for(let i=0; i<arr.length; i++) { sum += arr[i]; } return sum }
JavaScript
let result try { result = sumArray(undefined) } catch { result = null // null이든 뭐든 처리 가능 }
JavaScript
해당 코드는 함수 밖의 사용자에게 예외에 대한 처리를 이관했다 라고 이해할 수 있다.
아 근데, try catch 너무 역겨운데요. 복잡해요. 함수가 여러 call stack을 실행할 수도 있는데 매번 처리해줘야 하나요?
예제는 다음과 같다.
function A() { return B() } function B() { throw 'B' } function C() { throw 'C' } function D() { throw 'D' } A();
JavaScript
자 어디서 try-catch 를 시도하는 것이 가장 적절한 것인지 고민하는 것은 꽤 의미있어 보인다.
try-catch 가 코드를 더럽히는 것도 문제지만, 일관성없는 try-catch 는 어플리케이션에 복잡도를 높힐 것이다.
예를 들어 어떤 함수는 2번째 레벨에서 예외처리를 하고 또 다른 함수는 5번째에 예외처리를 한다면 어떻게 복잡해질지 상상해보자.
여러 Best Practice들을 살펴보면 try-catch 가 최상위까지 버블링 되는 성질 때문에 의미있게 나눈 Layer에 배치하는게 적절하다고 한다. 하나의 예를 들면 MVC패턴에서 각각의 레이어 사이에 상태가 전달되는 경계가 가장 적절하다는 것이다.
결론을 내보자면 다음과 같다. 1. 함수의 역할 이상의 역할을 하려고 하지 말자. 2. 사용자에게 양도하자. 3. 예외 처리는 일관성있게 처리하자. ( Best Practice: 레이어 레벨 )

3-5. React Data Fetch

import useFetch from '어떤 Data Fetch 라이브러리 혹은 State Hook' function Profile() { const { data, loading, error } = useFetch('/api/user') if (error) return <div>failed to load</div> if (loading) return <div>loading to load</div> if (!data) return <div>loading...</div> return <div>hello {data.name}!</div> }
JavaScript
대부분의 React 어플리케이션은 다음과 같이 생겼다.
눈치 챘는가? Profile 컴포넌트는 error에 대한 내결함성이 있을 뿐 아니라 loading이라는 비동기 데이터 요청에 대한 특수한 처리를 하는 역할도 하고 있다.
< 근데 이 아이...? >

4. React Suspense

내가 하고 싶은 것은 다음과 같다.
// PSEUDO CODE 입니다. 아래 코드는 동작하지 않습니다! import useFetch from '어떤 Data Fetch 라이브러리 혹은 State Hook' function Profile() { const { data } = useFetch('/api/user') return <div>hello {data.name}!</div> } try { Profile() } catch { Error() } loading { Loading() }
JavaScript
Profile은 data를 가져와 render할 뿐이다. 이외에 loading, error는 상위로 역할을 위임한다.
loading 부터 하나씩 해결해보자.

4-1. Loading Waterfall 이슈

예제 코드

function User() { const [user, setUser] = useState(null); useEffect(() => { fetchUser().then(u => setUser(u)); }, []); if (user === null) { return <p>Loading profile...</p>; } return ( <> <h1>{user.name}</h1> <Posts /> </> ); } function Posts() { const [posts, setPosts] = useState(null); useEffect(() => { fetchPosts().then(p => setPosts(p)); }, []); if (posts === null) { return <h2>Loading posts...</h2>; } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); }
JavaScript

실행 결과

해당 컴포넌트는 워터폴 이라고 불리는 이슈를 가진다.
User 컴포넌트의 data가 loading이 끝나기 전까지 Posts 컴포넌트는 호출 될 수 없다.
때문에 Posts 내부의 data fetch는 User 컴포넌트의 data fetch에 의존성을 가져서 병렬적인 로딩이 불가능하다.
이 문제를 해결하기 위해 하나의 컴포넌트에 모든 fetch함수를 상위 컴포넌트인 User에 등록하여 문제를 해결해야 했다.

개선된 코드

function User() { const [user, setUser] = useState(null); const [posts, setPosts] = useState(null); useEffect(() => { fetchUser().then(u => setUser(u)); fetchPosts().then(p => setPosts(p)); }, []); if (user === null) { return <p>Loading profile...</p>; } return ( <> <h1>{user.name}</h1> <ProfileTimeline posts={posts} /> </> ); } // 자식 컴포넌트들은 더 이상 불러오기를 발동시키지 않습니다 function Posts({ posts }) { if (posts === null) { return <h2>Loading posts...</h2>; } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); }
JavaScript
이는 언듯 해결된 것 같아 보이지만 데이터 요청이 더 많아진다면 User 컴포넌트에 복잡성은 더욱 커질 것이다.
또한 User 컴포넌트가 Posts 의 데이터 호출까지 역할을 일임하는 것은 좋지 않아 보인다.

4-2. Loading을 위한 Suspense

개선된 코드

const resource = fetchProfileData(); function ProfilePage() { return ( <Suspense fallback={<h1>Loading profile...</h1>}> <ProfileDetails /> <Suspense fallback={<h1>Loading posts...</h1>}> <ProfileTimeline /> </Suspense> </Suspense> ); } function ProfileDetails() { // 아직 로딩이 완료되지 않았더라도, 사용자 정보 읽기를 시도합니다 const user = resource.user.read(); return <h1>{user.name}</h1>; } function ProfileTimeline() { // 아직 로딩이 완료되지 않았더라도, 게시글 읽기를 시도합니다 const posts = resource.posts.read(); return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); }
JavaScript
상위에서 Suspense 컴포넌트가 loading에 대한 처리를 위임받았다.
이 때 fetch하는 함수가 Promise 상태가 Pending일 경우 이를 throw하여 상위 콜스택으로 전달 할 수 있다.
Try Catch와 똑같다. 상위 콜스택으로 무언가 넘기기 위해 throw를 던졌다는 것을 상기하자.

Suspense를 이용한 개선된 코드

덕분에 각각의 컴포넌트의 역할이 적절히 분리되었다. 또한 컴포넌트가 서로의 데이터 요청에 대한 의존성도 완전히 제거되었다 :)

5. 내결함성과 ErrorBoundary

내결함성에 대한 이야기는 이미 살펴봤기 때문에 ErrorBoundary의 사용법만 살펴보자.
function ProfilePage() { return ( <Suspense fallback={<h1>Loading profile...</h1>}> <ProfileDetails /> <ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}> <Suspense fallback={<h1>Loading posts...</h1>}> <ProfileTimeline /> </Suspense> </ErrorBoundary> </Suspense> ); } class ErrorBoundary extends React.Component { state = { hasError: false, error: null }; static getDerivedStateFromError(error) { return { hasError: true, error }; } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } }
JavaScript
ErrorBoundary 컴포넌트는 현재 Class Component 형식으로만 작성할 수 있다.
getDerivedStateFromError 메서드가 버블링된 error를 인자로 받는다.
ErrorBoundary의 적절한 위치에 대해서는 다음 포스트를 참고할 수 있다.

실행 코드

6. 마무리

일하다 보면 괜히 잘 모르고 내 역할이 아닌데도 불구하고 어중간하게 하다가 실수할 때가 있다.
예를 들면 "키보드는 어디서 받아야 해요?" 와 같은 질문들이다.
이는 총무팀의 역할이기 때문에 내가 굳이 나섰다가 무슨 일이 생길지 모른다.
그러니 위임하자.