리펙토링... 그것은 필수 불가결한 것

devAnderson·2023년 3월 20일
3

TIL

목록 보기
98/103

🏋️‍♀️ 리펙토링의 필요성에 대한 고찰

현재 앱 내에 작성되었던 기존 코드들을 리펙토링하는 과정을 거치고 있었다.
그 중에서 좀 많이 도전적이 되었던 코드가 있었는데, 그 줄수만 장장 1191줄에 달하는 내용이었다.

우선 글을 시작하기 앞서, 나는 결코 해당 코드를 부정하지 않는다.

이 세상에 이유없는 코드는 없다.

예전에 어떤 컬럼을 읽은 적이 있는데, 개발자로서 성장하기 위해서는 항상 마음가짐에 품어야 하는 것이 바로 그 당시에는 그렇게 코드를 짜게 된 이유가 있다 라는 것을 염두하고 이해해야 한다는 것이다.

그리고 그렇게 바라보는 것과 바라보지 않는 것은 협업에서 아주 큰 차이를 보인다는 예시를 읽었다. (이 해당 내용들을 리펙토링하면서 나 역시 경험한 일이었다.)

다만, 해당 내용을 쭉 살펴보면, 분명 필요에 의해서 존재하는 코드들이지만 정리를 해야 가독성이 높아질 것이라는 생각은 분명하게 들었다. 그래서, 모든 코드의 내용을 살펴봐서 "다이어트"를 해줄 수 있는 내용들을 압축해보았고, 그 결과는 아래와 같다.

코드의 압축과 최적화 과정은 그저 코드 줄 수의 줄어듬에 준하지 않고, 결과적으로 데이터를 랜더링 하는 시간 역시 압축시키는 쾌거를 가져왔다.

예전의 리펙토링 전 코드에서는 해당 리스트를 보기 위해서 3~4초의 시간이 걸렸었다. 지금은 거의 즉시 데이터 리스트를 랜더링할 수 있었다.

해당 리펙토링 과정을 위해 여러모로 고민했던 흔적이 많이 남아 있었고, 이를 기록으로 남기면 좋을 것 같아서 이 글을 쓰게 되었다.


🏋️‍♀️ 1. 모듈 다시 내보내기

일단 해당 스크린에서 사용되는 코드들의 20%에 해당하는 부분은 전부 "모듈" 을 가져오는 데 이용되고 있었다.

모듈의 import 자체가 많다는 것은 상당히 핵심 코드를 보기까지 내려가야 되는 문제를 발생했다. 즉, 핵심 코드를 보기도 전에 이미 좀 압도당하는 기분이었다.

저 위의 import가 끝이 아니었다... 아래에 내려가면 계속해서 import가 존재한다.

이런 import문을 어떻게 하면 깔끔하게 정리할 수 있을까 하고 검색을 하던 차에 코어 자바스크립트 홈페이지에서 그 힌트를 발견할 수 있었다.

모듈 내보내기란, 가져온 모듈에 대해서 어떤 모듈을 내보낼 지를 결정한 뒤 다시 내보내는 기술을 의미한다.

처음에는 이게 왜 필요할까 싶었는데, 설명을 들어보니 webpack과 같은 모듈 번들러가 어떤 모듈을 합치고 어떤 모듈은 사용하지 않아 버릴 지에 대한 판단을 쉽게 내려 최적화된 번들링(용량 축소) 와 불필요한 모듈을 제거하는 트리 쉐이킹에 도움이 된다고 한다.

덧붙여, 개발자가 보기에도 필요한 모듈만 가져올 수 있기 때문에 가독성이 좋은 코드가 될 수 있다(라고 희망한다)

여튼, 모듈 내보내기를 위해 따로 module이라는 폴더를 만들고, 해당 파일에서 필요한 모듈들을 가져온 뒤, 한번에 모듈 객체로 묶어서 export하도록 만들었다.


가져오는 측에서는 default를 Modules로 별칭한 후 사용하였다.



🏋️‍♀️ 2. Container vs Presentational

수많은 역사적인 디자인 패턴들 가운데에는, Container component와 Presentational 컴포넌트라는 개념이 존재한다.

비록, 이 패턴의 창안자인 "Dan Abramov" 는 현재 와서는 훅을 이용한 관심사 분리를 하는 것을 추천하며 해당 패턴에 대해서 필요성이 없을 경우 무차별적으로 도입하는 것을 경계한다. 우선 두 개념의 구분을 나누면 아래와 같다. (상세 설명 블로그 참조)

  • Container : State를 관리하며, 자식들에게 해당 변동되는 상태값을 전달하는 컴포넌트
  • Presentational : 받아온 Props를 이용하여 View를 그리는 것에 전념하는 컴포넌트

해당 디자인 패턴을 여러모로 사용해본 결과, 내게 있어서는 Context API와 결합하는 방식의 디자인이 가장 깔끔하고 유연한 개발을 가능하게 하는 듯 하여 주로 애용중이다.

그리고 이번 리펙토링에도 해당 디자인 패턴을 적극 활용하여, 페이지에서 랜더링 되어야 하는 모든 presentational component에 필요한 데이터를 API 요청하여 받아오고, Context로 전달하는 Container Component의 구조를 구현하여 한 페이지에 존재했던 모든 API를 관심사에 따라 분리하였다.

이렇게 분리하였을 경우 장점은, 책임을 분산시킬 수 있기 때문에 필요한 에러 핸들링 역시 간편하게 해결할 수 있다는 점이었다.

이렇게 구조를 작성할 경우, 확실하게 상태와 데이터 요청, 에러 핸들링을 Container Component에서, View를 그리는 것을 자식들인 Presentational Component에서 담당하게 만들어 조금 더 보기 쉽고 핸들링이 쉬운 코드를 작성할 수 있었다.

🏋️‍♀️ 3. React-Query + Graphql

네트워크 요청을 통해 데이터를 받아오는 과정은 몹시 중요하지만, 필요에 따라서는 이 네트워크 요청 자체가 불필요 할 수 있다.

그 케이스라 함은, 데이터를 요청했을 때 이 값이 그렇게 쉽게 바뀌지 않아 결국 기존 데이터와 동일할 경우를 일컫는다.

이 경우, 어차피 같은 데이터를 쓸 것인데 굳이 네트워크 요청이 중간에 껴 있는 것은 불필요한 지연시간을 늘리는 것과 같아진다.

이에 따라, 네트워크 요청을 캐싱하는 방법을 사용했어야 하는데 현재 서비스되는 앱의 내에서는 "Apollo-client"를 사용중이었다.

Apollo client가 나쁜 수단은 아니었지만, 캐싱에 있어선 유독 리엑트의 리랜더링에 영향을 쉽게 받는 상황을 보여줄 때가 많았는데, 이것은 UI를 그려야 하는 입장에서 몹시 스트레스를 받는 일이었다.

거기에 더해서 받아온 데이터에 대해 정교하게 캐싱하여 관리하고, "신선한" 데이터라면 그대로 계속해서 이용하고 네트워크 요청을 하지 않도록 컨트롤하고 싶었다.

따라서, react-native 내에 React-query를 도입하기로 하였다

React-query의 client를 제공해줄 Provider은 HOC(고차 컴포넌트) 형태로 감싸고 있다.

고차 컴포넌트란?
공식 문서 내의 설명은 아래와 같다.

설명을 조금 더 읽어보면, 고차 컴포넌트는 특정한 wrapping component를 props로 받아 이를 리턴하지만, wrapped component는 수정하지 않고 특정 작업을 수행한 결과물을 props로 내려서 전달하는 것을 알 수 있다.

사실, 이렇게 HOC형태로 컴포넌트를 구성할 때의 장점은 추상화된 로직을 여러 컴포넌트에서 같이 사용할 수 있는 재사용성에 있음이라 짐작하지만, 이 케이스의 경우 App 자체가 이미 codePush로 감싸져 있기 때문에 불가피하게 사용한 감이 있다.
(codePush을 도입한 좌충우돌 이야기는 추후 블로깅 예정이다.)

이렇게 전역에서 Provider을 설정하여 다른 리 랜더링에 의해 client가 초기화되는 것을 막은 후, react-query를 사용할 기초적인 세팅을 완료하였다.

다만, 한가지 문제(?) 라고 한다면 회사의 서버가 graphql + prisma 기반으로 되어있다는 점이다.

React-query에서 rest를 통한 graphql요청을 날리려면 이것저것 커스터마이징이 필요하다. 왜냐하면, 일반적인 body의 형태가 아니기 때문이다.

JSON.stringify를 통해 스트링화되는 객체값 내부의 프로퍼티에는 "query"라는 존재가 있다. 이 스트링 값이 해석되면서 graphql 서버에서 필요한 처리가 이루어지는 것인데, 이런식으로 일일이 query를 만들어주는 것은 몹시 귀찮다.

그리고 이미 팀원들은 기존에 Apollo client에서 사용되던 gql방법에 익숙하기 때문에 react-query에서 graphql을 쓰는 것을 도입하기 위해서 불편한 방법을 사용하라고 하면 거부감이 들 가능성이 높을 것이었기에(?)다른 방법을 찾아보고 있었다.

추가적으로 미들웨어기능을 붙여 혹시 모를 auth에러와 같은 케이스에서 중간 처리를 시도하고 싶었다.

이에 따라 구글링을 해본 결과 가장 최적의 라이브러리를 찾았는데, 그 이름하야 graphql-request 이었다.


//! Graphql-request

//요청 미들웨어. 모든 graphql-request를 사용한 호출 시 request중간에 이 함수가 먼저 실행된다.
async function requestMiddleware(request: any) {
  const base = {
    ...request,
    headers: { ...request.headers },
  }

  try {
    const walletToken = await AsyncStorage.getItem('walletToken')
    const testExpiredToken =
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXRVSUQiOiI4ODI4ZDUwZDNjNTM1ZGE5NGZlODdmMTIzOGZhNmJiMDIyMDY2OTU5YjQzZGE3Y2NmYWIwYjMzYjM3N2YxYWFlIiwicGFzc3dvcmQiOiJjMzQ2MmU5NzkxNzdhYjY3MTQzNTk5ZDIyZTY0MzNmN2RlY2I3MWVlZjg3Mzg2OTBmMThmYWI4NzYwOWZlODkxIiwiaWF0IjoxNjc2MjU1MDg0LCJleHAiOjE2NzYyNTUxNDR9.978JXDaxETeacpbZtzb2RLEatFM8X2x_cmxa24rMvvs'

    if (walletToken) {
      return {
        ...base,
        headers: {
          ...base.headers,
          authorization: `Bearer ${walletToken}`,
        },
      }
    }

    return base
  } catch (err) {
    console.log('토큰 가져오기 실패')
    return base
  }
}

//응답 미들웨어. 모든 graphql-request를 사용한 호출 시 응답을 가져오기 전에 먼저 실행된다.
interface GraphQLErrorInstance {
  extensions: { code: string }
}
async function responseMiddleware(res: any) {
  const errors = (res?.response?.errors as any[]) ?? []
  const isJWTError = (error: Error & Partial<GraphQLErrorInstance>) => {
    if (
      error instanceof Object &&
      error.extensions &&
      error.extensions.code &&
      typeof error.extensions.code === 'string'
    ) {
      if (
        error.extensions.code.includes('TokenExpiredError') ||
        error.extensions.code.includes('NOT_AUTHENTICATED_USER')
      ) {
        return true
      }
    }

    return false
  }

  if (errors.some(isJWTError)) {
    try {
      await getNewToken()
    } catch (err) {
      console.error('getNewToken error: ', err)
    }
  }
}

//해당 초기화된 client가 react-query에서 사용된다.
export const graphqlClient = new GraphQLClient(uri, {
  requestMiddleware,
  responseMiddleware,
})

위의 코드는 react-query에서 grahpl 요청 시 사용될 client를 초기화하는 내용이다.

요청 중간의 middleware에서 auth token을 헤더에 붙여 보내며, 응답 미들웨어에서 요청이 에러가 났다면 토큰을 재활성화하여 메모리에 심는 과정을 거친다.

react-query의 특성 상, default로 3번의 요청을 하기 때문에, 설령 첫 요청에서 auth token이 만료되어 에러가 났더라도 해당 최신화 가정을 통해 다시 요청을 날릴 것이므로 조금 더 안정화된 요청을 할 수 있게 되었다.

여튼, 좀 많이 돌아가긴 했는데

결국 Container Component 내에서 네트워크 요청을 할 때, react-query를 도입하였고 이 때에 네트워크 요청들을 커스텀 훅 단위로 묶어 독립적으로 관리하였다

훅 내부에서는 useQuery의 호출 결과를 리턴하도록 설정하였다.

React-Query에서 useQuery를 쓸 때에 첫째 인자는 캐시 키값, 두번째는 네트워크 요청을 하는 fetcher함수, 그리고 마지막으로 config 객체가 들어가게 되는데

지금 위에 보이는 fetcher의 내에서 쓰이는 grahpqlClient.request가 바로 방금 상단에서 초기화에 대해서 설명했을 때 언급한 그 친구다.

네트워크 요청을 날릴 때, 로직 상 wallet의 UID는 거의 변경 가능성이 없기 때문에, 매번 이것을 요청해서 받아오지 않고 캐싱 기간을 하루로 잡아 추가적인 네트워크 요청을 하지 않고 그대로 캐싱값을 쓰도록 만들었다.

이렇게 해 준다면, 혹여 네트워크 에러로 인해 발생할 불필요한 핸들링도 없어지므로 너무 좋았다.


🏋️‍♀️ 5. 마무리

위 내용과 같은 코드 리펙토링 뿐만 아니라, 성능 개선에 큰 도움이 되었던 백엔드 로직 최적화 역시 다루려고 하였지만 작성하다보니 내부 로직이 너무 노출이되어 적지를 못했다.(추후 기회가 생기면 다시 도전해 작성해보려고 한다)

이 과정을 통해 산발되어 있던 코드들의 더미들을 깔끔하게 정리하고 나니 성능적인 최적화도 이루어지고, 코드도 더욱 읽기 쉽게 되어서 너무 좋았다.

리펙토링을 통해 많은 것을 배울 수 있는 기회도 되었던 것 같아 뿌듯함을 느끼며 이만 줄인다.

profile
자라나라 프론트엔드 개발새싹!

0개의 댓글