6개월차 주니어 혼자 에러 핸들링 시스템 구축하기

이은지·2023년 8월 13일
77
post-thumbnail

때는 바야흐로 2023년 1월...
마루180의 모 회사에 인턴으로 입사한 나는 입사 3일만에 기존의 코드에 의문을 제기했다.

"에러를 여기저기서 마구 던지는데 아무데서도 처리하고 있지 않아요.. 또 어디에선 콘솔에 찍고 끝내기도 하고요.. 저 던져진 에러는 대체 누가 처리하는건가요? 아니 애초에 에러를 콘솔에만 찍고 넘어가도 되는건가요?"

하지만 나 역시 에러 핸들링과 개발 전반에 대한 지식이 부족한 상태였다. 굉장히 찜찜하고 불안했지만 해결 방법을 모르니 가만히 있을 수밖에 없었다 🙄

그렇게 6개월이 지났고... TestFlight에서만 운영하던 앱을 앱스토어에 출시해야할 때가 다가오고 있었다. 여태까지는 고객들과 직접적으로 소통하고 있었기에 에러가 발생했을 때 그들로부터 에러를 제보받을 수 있었다. 하지만 앱스토어에 출시하게 되면 이야기가 달라진다. 에러를 제보할 수 있는 창구가 없고, 에러 대응이 이뤄지지 않는다면 많은 고객들이 서비스를 이탈할 거란 생각이 들었다.

한마디로 우리 서비스는 우리가 알지 못하는 다수의 사용자들에게 공개하기엔 불안한 서비스였다. 기존엔 이러한 불안정성이 큰 문제가 되지 않았다. 사용자의 모수가 적었고, 애초에 모수 증진이 팀의 목표가 아니었기 때문이다. 하지만 서비스가 GTM을 시도하는 단계로 넘어가면서, 서비스 안정성의 중요성이 커졌다.

그렇게 나는 장장 6개월만에 입사 3일차에 제기했던 에러 핸들링 문제를 직접 해결하게 되었다.

0. 목표 설정

시간이 한정되어 있었기 때문에, 안정성 의 기준을 최소한으로 설정할 필요가 있었다. 내가 정한 최소한의 기준은 단순했다.

앱에 크래시가 발생하지 않는다.
(여기서 크래시란 처리되지 못한 에러로 인해 앱이 꺼지는 현상을 지칭한다.)

이를 위해서는 코드 전반을 점검하고, well-working이 보장되는 코드 패턴을 도출 및 적용하는 작업이 필요했다.

우리 팀의 경우 코드 컨벤션이 없었다. 코드의 품질보다 개발의 속도가 더 중요했기 때문이다. 그 결과 우리 코드의 모습은 마치 누더기와 같았다. JavaScript와 라이브러리들의 활용법을 제대로 이해하지 못한 채 타입스크립트 컴파일러가 지적하는 에러들을 틀어 막기에 급급했다.

목표 달성을 위해 프로젝트의 대략적인 진행 순서를 다음과 같이 설정했다.

  1. 기존 코드를 점검한다.
  2. 코딩 컨벤션과 패턴을 도출한다.
  3. 2번을 반영한다.
  4. QA를 통해 모든 기능의 정상 작동 여부를 점검한다.
  5. (optional) 에러 로깅 시스템을 구축한다.

1. 코드 점검

나에게 막연한 불안함을 안겨줬던 지점들을 위주로 코드를 점검했다. 크게 세 가지가 있었다.

첫째, 서버 API 호출 시 발생할 수 있는 에러를 처리하는 방식

가장 큰 문제는 에러를 처리하는 주체가 불분명하다는 점이었다.

우린 fetchData라는 함수를 만들어 사용하고 있었는데, 이 함수는 서버가 에러를 반환하는 케이스를 전혀 고려하지 않고 있었다. 정상 응답과 에러를 분기하지 않고 무조건 파싱한 데이터를 반환했다.

fetchData를 호출하는 컴포넌트 내 함수들에선 아무 의미없는 try-catch문을 남발하고 있었다. 심지어는 그 패턴도 제각각이었다. 한 곳에성 콘솔에 에러를 찍고, 다른 곳에선 에러를 rethrow했다. rethrow한 에러는 처리되지 않고 있었다. (2중 try-catch문도 있었다 😬)

요약하자면, 어떤 함수도 에러를 처리하지 않고 있다는 (놀라운) 사실을 현란한 try-catch문들이 가리고 있었다.


둘째, 화면을 그리기 위한 데이터가 없는 상태를 처리하는 방식

아무런 처리 없이 쿼리 데이터의 프로퍼티 값에 접근하면 타입스크립트는 타입 에러를 낸다. 쿼리 데이터가 undefined일 수 있기 때문이다. 이 사실을 명확히 인지하지 않고 그저 타입스크립트의 에러를 해결하기 위해 매번 쿼리 데이터 뒤에 물음표를 붙였다. (옵셔널 체이닝을 사용했다.)

그 누구보다 남용하고 있었던... 😇

옵셔널 체이닝을 사용하면 접근을 시도한 프로퍼티가 존재하지 않더라도 undefined를 반환하므로 에러가 발생하진 않았다. 하지만 데이터가 없는 상태를 안전하게 처리하지 못하고 있다는 불안감이 항상 있었다. (옵셔널 체이닝의 역할에 대해 정확히 모르고 있기도 했었다.)

UX 관점에서는 사용자에게 적절한 로딩 경험을 제공하지 않는다는 문제점이 있었다. 데이터가 없는 상태일 때 그냥 빈 View 컴포넌트를 리턴하고 있었다.


셋째, Mutate call 후 데이터 갱신(refetch)이 필요한 케이스를 처리하는 방식

데이터를 refetch 해야하는 시점을 제대로 파악하고 있지 않았다. useFocusEffect를 이용해 무조건 데이터를 새로 가져오는 방식으로 때우고 있었다. 그 결과 필요한 refetch를 누락하는 경우가 몇 번 있었다. 똑같은 실수로 인한 동일 패턴의 에러가 반복적으로 리포트 됐었다. 유저의 입장에선 치명적인 에러로 인식될 수 있는 지점이었으므로 반드시 해결해야 했다.

2. 해결

에러 핸들링

내 1순위 목표는 서버 API 호출 시 발생하는 모든 에러를 한 곳에서 처리하는 것이었다. 에러를 모두 한 곳으로 모으고 싶었다. 깔때기처럼. 어디서 발생했든 최소한 한 곳에서는 처리를 했다는 확신을 가질 수 있게끔!

이를 위해, 서버 API 호출용 유틸 함수 request를 새롭게 구현하고 아래와 같은 규칙을 정했다.

  • API 호출 방식을 통일한다.
    • useQuery, useMutation을 사용한다.
    • 모든 API를 유틸 함수로 만들고, 모든 유틸 함수는 request를 사용한다.
  • 에러를 한 곳에서만 처리한다.
    • try-catch문의 사용을 제한한다.
      • 사용 O
        • request
      • 사용 X
        • API 유틸 함수
        • mutate를 호출하는 컴포넌트 내 함수
    • useQuery, useMutation의 onError 옵션을 사용하지 않는다.
      • 에러를 화면별로 다르게 처리해야 하는 케이스가 아직 존재하지 않기 때문에 onError 옵션을 사용할 필요 없다.
// 서버 API 호출용 유틸 함수 📁src/services/api.ts

const request = async (url: string, requestOptions?: RequestInit) => {
  try {
    const response = await fetch(`${API_ENDPOINT}${url}`, {
      ...requestOptions,
      headers: {
        'Content-Type': 'application/json;charset=utf-8',
        'Cache-Control': 'no-cache',
      },
      credentials: 'include',
    });

    if (response.ok) {
      const data = await response.json();
      return data;
    }
    else {
      const parsedResponse = await response.json();
      throw new Error(parsedResponse.message);
    }
  } catch (e) {
    console.error('Request error: ', e);
  }
}

// API 유틸 함수: request 함수를 호출한다. 📁src/apis/album/index.ts

export const getAlbums = async (designerId: string): Promise<TAlbum[]> => {
  return await request(`/nest/designers/${designerId}/lookbooks`)
}

// useQuery: API 유틸 함수를 호출한다. 📁src/queries/album.ts
export const useGetAlbums = () => {
  const designerId = useDesignerIdContext();
  return useQuery<TAlbum[], Error>([...albumQueryKeys.list, designerId], () => getAlbums(designerId));
}

데이터 없는 상태 처리하기

이 문제에 대해서는 React Query가 제공하는 타입 추론을 적극 활용했다. React Query의 메인테이너인 tkdodo씨의 글이 큰 도움이 됐다.

(한정된 시간 내에 안정적으로 리팩토링해야 했기에 했기에 React Query에 대해서는 전반적으로 tkdodo의 글을 많이 참고했다. React Query and TypeScript 글 링크)

I rarely use destructuring when working with React Query. … (중략) … Keeping the whole object will keep the context of what data it is or where the error is coming from. It will further help TypeScript to narrow types when using the status field or one of the status booleans, which it cannot do if you use destructuring

React Query가 지원하는 타입 추론을 활용하기 위해서 쿼리를 구조분해할당 하지 않고 사용하기로 했다. 또한 쿼리 인스턴스의 status 필드를 사용해, 로딩 시와 에러 발생 시 각각 LoadingIndicator와 ErrorMessage 컴포넌트를 리턴하도록 했다.

function AlbumScreen() {
	const albumsQuery = useGetAlbums();

	if (albumsQuery.isLoading) {
    return <LoadingIndicator />
  }

  if (albumsQuery.isError) {
    return <ErrorMessage message={albumsQuery.error.message} />
  }

	return (
		<View>
			{albumsQuery.data.map((album) => <View>{album.title}</View>}
			// 타입 추론 덕에 옵셔널 체이닝을 사용하지 않아도 TypeScript 에러가 발생하지 않는다. 
		</View>
	)
}

mutate 후 데이터 갱신

우선 현재 앱에 사용되는 API들을 나열하고, refetch 해야하는 API를 확인했다. 무모하고 시간이 오래 걸리는 짓이긴하지만 다행히 몇 스프린트 전에 내가 정리해둔 리스트가 있었다. (그 당시에도 코드가 불안해서 에러 핸들링을 개선해보려다가 시간이 없어 관뒀었다.) 내가 백엔드 코드를 거의 다 알고 있기도 하고, 아직 복잡한 기능이 그리 많지 않았기에 할 수 있었다.

대략 60개 정도였다.

그리고 부분적으로 도입했던 React Query를 전면 도입함으로써 쿼리 클라이언트를 통한 refetch가 가능하도록 만들었다. useMutation의 onSuccess에서 갱신이 필요한 쿼리를 refetch 했다.

3. 예외 케이스 발생

예외 케이스는 생각보다 빨리 등장했다. 여러 개의 API를 연달아 요청해야 하는 경우!

유저가 모든 데이터를 기입하고 예약 생성 버튼을 누르면 다음과 같은 일련의 작업들이 수행되어야 했다.

  1. 이름, 전화번호로 고객 조회를 요청한다.
  2. 고객이 없을 경우 고객 생성을 요청한다.
  3. 1,2에서 받은 고객 id와 나머지 데이터로 상품 생성과 예약 생성을 요청한다.
  4. 예약에 대한 알림 생성을 요청한다.

문제는 1번이었는데, 조회한 이름, 전화번호와 매치되는 고객이 없는 경우 서버에서 404 에러를 반환했다. (다른 에러와 달리) 이 경우 에러 응답을 받았다고 요청을 멈추는 게 아니라 그 뒤의 일련의 작업들을 진행해야 했다. 이런 케이스는 앱 전체에 한 개 뿐이었으므로, request 함수에 고객 조회 API에서 404 에러를 리턴하는 경우 에러를 던지지 않고 함수를 종료하는 코드를 추가했다.

또한 1~4에서 발생한 에러를 한 곳에서 처리하기 위해 mutateAsync와 try-catch문을 사용했다. mutateAsync는 mutate와 달리 프로미스를 리턴한다. 즉, mutateAsync를 사용하면 에러를 catch문에서 잡을 수 있다.

1~4 각각의 catch블록에서 에러를 만들어서 던지도록 하고, 1~4를 차례로 호출하는 예약 생성 함수의 catch블록에서 모든 에러를 처리하게끔 했다.

const createReservationAndExit = async () => {
    try {
      const customerId = customerData?.id || (await createOrGetCustomer()).id;
      const productId = productData?.id || (selectedProductTemplate && (await createProductMutation.mutateAsync({ customerId, data: { productTemplateId: selectedProductTemplate.id } })).id);
      const { id } = await createReservationMutation.mutateAsync(data);
      await createNotifications(id);
      showSuccessToast({ text: '등록되었습니다.' });
    } catch (e) {
      showErrorToast();
    }
  }

const createOrGetCustomer = async () => {
    try {
      const customer = await getCustomer(customerData.phone_number, customerData.name);
      if (customer) {
        return customer;
      }
      const newCustomer = createCustomerMutation.mutateAsync(data);
      return newCustomer;
    } catch (e) {
      throw new Error(`고객 생성 실패, ${e}`);
    }
  }

4. 에러 로깅하기

에러가 발생했을 때 재빠르게 대응하기 위해서는, 에러의 발생 사실을 재빠르게 알 수 있어야 한다. (우리는 애초에 에러 발생 사실을 알 수 있는 방법 조차 없었지만) 따라서 에러를 로깅할 필요가 있었다.

처음엔 sentry 도입을 고려했다. 하지만 아직 서비스의 복잡도가 낮고 규모가 작다는 점, 비용을 지불해야 한다는 점을 고려했을 때 sentry를 도입하는 건 합리적이지 않다고 판단했다. 너무 오버스펙이었다.

그래서 그냥 슬랙 봇을 사용했다! 에러가 발생했을 때 팀 슬랙의 에러 리포트 채널로 메세지를 발송하는 코드를 추가했다. 모든 에러가 한 곳에서 처리되도록 만들었기 때문에, 그 깔때기 부분(request 함수의 catch블록)에 코드 몇 줄만 추가하면 됐다.

후기

에러 핸들링 개선이라는 방대하고 모호한 태스크를 손에 잡히는 한 조각으로 만들고, 이를 완결 지었다는 뿌듯함이 가장 크다.

우리 팀은 보통 스프린트 1개 단위로 1개의 신기능을 만든다. 내 주장으로 팀이 새로운 기능 하나를 만들 수 있는 시간을 앱 안정성 강화에 쓰기로 결정한만큼 이 프로젝트를 꼭 잘 끝내고 싶었다.

프로젝트가 유야무야로 끝나버리는 것을 방지하기 위해, 스프린트 초반에는 프로젝트의 목적과 목표를 구체적으로 설정하는데에 많은 시간을 쏟았다. CTO님과 많은 이야기를 나누면서 이번 스프린트 내에 현실적으로 개선 가능한 범위를 파악했다. 이를 바탕으로 프로젝트 원페이저를 작성해 팀에 공유했다.

비개발자 입장에서도 앱 안정성 강화의 필요성을 납득할 수 있도록 프로젝트 시작 전 이 주제에 대해 PM님과 여러 차례 대화를 나눴다. 프로젝트 진행 중에는 모든 팀원들이 프로젝트의 진척 정도를 체감할 수 있도록 매일 체크인을 작성했다. (이는 해당 프로젝트가 가졌던 모호함이라는 성격에 숨지 않겠다는 다짐이기도 했다.)

당시 작성했던 프로젝트 원페이저
직전 스프린트 쿨다운 때 CTO님과 나눴던 에러에 대한 논의들. 아주 유익한 빌드업이었다.

열심히 작성했던 체크인 ✅

결과적으로도 유의미한 개선이 있었다. 슬랙으로 리포트를 받아봄으로써 앱에서 발생하는 에러에 빠르게 대응할 수 있게 되었다. 기존에는 에러 발생 사실을 인지조차 못했다는 걸 감안하면 나름 큰 발전이라고 생각한다. 앱의 안정성에 대한 최소한의 믿음이 생겼고, 앱에서 에러가 발생하더라도 바로 파악할 수 있겠다는 자신감을 얻었다.

사각지대에 있었던 개발 지식들을 채우는 과정도 재미있었다. React Query의 올바른 사용법, 자바스크립트 에러, try-catch문, 옵셔널 체이닝 등. (여담이지만 tkdodo씨 블로그 진짜 잘되어있다 시리즈 글 짱 재밌으니까 리액트 쿼리에 대한 이해도를 높이고 싶다면 한 번씩 읽어보기를 권한다.) 방금 나열한 키워드들에 대해 지식의 공백이 존재한다는 사실을 늘 인지하고 있었지만 늘 공부를 뒷전으로 미뤄뒀었는데, 이 프로젝트 하면서 그 공백을 많이 메꿨다.


사실 이 팀에 들어온 후에, 모호한 주제에 시간을 쏟았다가 잘 마무리 짓지 못한 적이 몇 번 있었다 ㅎㅎ 실행력이 좋지만 마무리가 약하다는 게 내 약점이기도 하고. 내 DRI로 진행한 첫 장기(?) 프로젝트이자 내 약점을 극복하기 위해 다방면으로 노력했던 프로젝트였어서 무척 뿌듯하고 기억에 남는다.

초기 스타트업에서 첫 커리어를 시작한지 반 년이 넘었다. 그간 느낀 초기 스타트업의 가장 큰 장점은, 프로덕트가 고도화될 때마다 배우고 성장할 수 있다는 점이다. 반년 전만 해도 우리 프로덕트엔 코딩 컨벤션이나 에러 핸들링이 크게 중요하지 않았고, 리액트 쿼리가 필요하지도 않았다. NextJS도 그랬고. 대략 8개의 스프린트를 지나는 동안 프로덕트가 고도화됐고, 자연스럽게 각각의 필요성이 대두됐다. 그리고 사람은… 자기가 필요해서 배울 때 가장 빠르게 학습한다.

tech-driven이 아닌 product-driven하게 새로운 지식을 공부하는 일이 엄청 재밌다. 여기서 커리어를 시작하기로 한 내 선택이 대기업에 대한 타협안으로 남지 않도록 여기서만 배울 수 있는 것들을 120% 흡수해야겠다고 다시 한 번 다짐하며 글을 마무리한다 🤓


👉🏻 틈새 홍보

밀도 있는 성장을 원하는 주니어 개발자이신가요? 저희 크래쉬컴퍼니에 지원해보세요 🥳 티타임도 환영합니다~! 아래 링크들을 확인해보세요.

profile
교육학과 출신 서타터업 프론트 개발자 👩🏻‍🏫

13개의 댓글

comment-user-thumbnail
2023년 8월 13일

기존 코드 관행에 의문을 제시하고 개선 방향점 및 코딩 컨벤션 수립 그리고 책임감 있는 마무리까지 넘 멋지네요~
더 나은 코드 퀄리티를 위해 고민하는 모습 너무 좋습니다.
제 업무에도 적용할 수 있는 포인트가 몇개 보여서 잘 읽고 갑니다.
좋은글 감사합니다.

1개의 답글
comment-user-thumbnail
2023년 8월 13일

잘읽었어요 😄

1개의 답글
comment-user-thumbnail
2023년 8월 13일

글 잘 봤습니다.

1개의 답글
comment-user-thumbnail
2023년 8월 16일

잘 읽었어요 !!

1개의 답글
comment-user-thumbnail
2023년 8월 17일

좋은 글 감사해요!!

1개의 답글
comment-user-thumbnail
2023년 9월 2일

현업에서는 에러 핸들링을 어떻게 할까 궁금했는데 정말 좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2023년 9월 6일

혹시 글의 내용과 별개이지만, useQuery의 key들은 어떻게 관리하고 계신지 궁금합니다!!!

답글 달기