Typescript

[Typescript] 이펙티브 타입스크립트 4 아이템28

sian han 2023. 3. 16. 08:57

※ 아이템28 유효한 상태만 표현하는 타입을 지향하기

 

웹애플리케이션을 만들 때 페이지의 상태관리 설계

interface State {
  pageText: string
  isLoading: boolean
  error?: string
}
declare let currentPage: string
function renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`
  } else if (state.isLoading) {
    return `Loading ${currentPage}...`
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`
}

페이지를 그리는 renderPage 함수를 작성할 때는 상태 객체의 필드를 고려해서 상태 표기를 분기해야 한다.

그런데 분기조건이 명확히 분리되어있지 않다. isLoading 이 true 이고 동시에 error 값이 존재하면 ? -> 오류가 발생한 상태인지 로딩중인 상태인지 명확하게 구분할 수 없다. 

 

 

interface State {
  pageText: string
  isLoading: boolean
  error?: string
}
declare let currentPage: string
function getUrlForPage(p: string) {
  return ''
}
async function changePage(state: State, newPage: string) {
  state.isLoading = true
  try {
    const response = await fetch(getUrlForPage(newPage))
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`)
    }
    const text = await response.text()
    state.isLoading = false
    state.pageText = text
  } catch (e) {
    state.error = '' + e
  }
}

 isLoading 은 우선 true 로 고정한다.

response 값이 ok 가 아니라면 예외를 던지고, ok 라면 다시 await 로 text() 를 받아온다.

isLoading 을 false 로 바꿔주고, pageText 는 text 로 바꾼다.

그리고 예외로 들어왔을 때는 error 를 문자로 변환하여 리턴한다.

 

이 changePage 에도 문제점이 있다.

  • 오류가 발생했을 때 isLoading 을 false 로 설정하는 로직이 빠져있다.
  • error 를 초기화하지 않았기 때문에, 페이지 전환중에 사용자는 로딩메세지 대신 과거의 오류 메세지를 보여주게 된다.
  • 페이지 로딩중에 사용자가 페이지를 바꿔버리면 
    • 새 페이지에 오류가 뜨거나
    • 응답이 오는 순서에 따라 두번째 페이지가 아닌 첫번째 페이지로 전환될 수 있다.
  • 상태값의 두가지 속성이 동시에 정보가 부족하거나, 두가지 속성이 충돌할 수 있다. State 타입은 isLoading 이 true 이면서 동시에 error 값이 설정되는 상태를 허용하고 있기 때문이다. 

 

무효한 상태가 존재해서는 안된다. 코드를 다음과 같이 변경해보자

interface RequestPending {
  state: 'pending'
}
interface RequestError {
  state: 'error'
  error: string
}
interface RequestSuccess {
  state: 'ok'
  pageText: string
}
type RequestState = RequestPending | RequestError | RequestSuccess

interface State {
  currentPage: string
  requests: { [page: string]: RequestState }
}

 

RequestPending, RequestError, RequestSuccess 세가지 인터페이스를 만들어놓고, 이 타입들로 RequestState 라는 유니온 타입을 만든다. 그리고 인터페이스 State 에서는 상태가 Pending인지, Error인지, Success인지 3가지 중 하나만 들어올 수 있도록 설계했다. 

 

위 코드에서는 각각의 상태를 명시적으로 알려주는 태그된 유니온이 사용되었다.

또한 페이지 내에서 발생하는 모든 요청의 상태를 포함해 모델링 되었다. 이것을 이용해 renderPage, changePage 함수를 다시 구현해보자.

function renderPage(state: State) {
  const { currentPage } = state
  const requestState = state.requests[currentPage]
  switch (requestState.state) {
    case 'pending':
      return `Loading ${currentPage}...`
    case 'error':
      return `Error! Unable to load ${currentPage}: ${requestState.error}`
    case 'ok':
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = { state: 'pending' }
  state.currentPage = newPage
  try {
    const response = await fetch(getUrlForPage(newPage))
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`)
    }
    const pageText = await response.text()
    state.requests[newPage] = { state: 'ok', pageText }
  } catch (e) {
    state.requests[newPage] = { state: 'error', error: '' + e }
  }
}

requests 에서 currentPage 해당하는 값을 받아서 requestState 변수에 할당한다. 

requestState 의 state 속성이 어떤 값이냐에 따라서 case를 나눌 수 있게 되었다. 

 

그리고 changePage 에서는 

request의 [newPage] 값에 상태와 페이지에 필요한 데이터들을 넣어준다 -> 페이지 별로 에러 구문이 따로 들어가게 됨

 

코드를 수정하며 이전코드에서 renderPage,changePage 의 모호함이 사라졌다. 

모든 요청은 하나의 상태로 맞아 떨어진다 -> 요청이 진행중인 상태에서 사용자가 페이지를 변경하더라도 문제가 없다. 

 

요약

  • 유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 된다
  • 유효한 상태만 표현하는 타입을 지향해야 한다. 코드가 길어지거나 표현하기 어렵지만 결국은 시간을 절야가고 고통을 줄일 수 있다