Santos의 개발블로그

거 딱 죽기 딱 좋은 Error 네!! 본문

Language & Framework & Library/JavaScript

거 딱 죽기 딱 좋은 Error 네!!

Santos 2024. 2. 2. 12:20

신년이 되면 항상 하는 일이 있다. 산타클로스 보따리만한 계획을 들고와 하나씩 적어보면서 다짐하고, 열정을 다지며, 목표를 설정하는 그런 거대하고 웅장하지만, 실행하기 어려운 그런 일들 말이다. 

 

그래서 그런지 나는 차선의 계획을 또 세우는 편이다. 이 계획을 달성하지 못했을 때의 상실감과 무력감을 조금 덜 느끼기 위한 나를 위한 최선의 가드레일이라 해야할까, 어떻게 보면 나만을 위한 합리화라고 느낄 수 있을 것 같다. 내가 세운 가드레일은 꽤나 잘 작동한다. 항상 내가 생각했던대로 인생은 움직이지 않기에 갑자기 불타오른 마음을 진정시키기에는 차선의 가드레일만한 것은 없다고 생각한다. 

 

우리가 만드는 PERSO 제품의 생애주기도 마찬가지인 것 같다. 예상한대로 움직여주면 참 좋으련만, 항상 Error는 도처에 존재하고, 이내 누군가(사용자, 개발자)에게는 실망감과 아쉬움을 안겨주고는 한다. 

 

그렇기에 PERSO 제품에게도 다양한 차선의 계획이 필요하다. 커다란 교통사고를 방지할 수 있는 가드레일 말이다. 

 

이 글은 PERSO 제품 인생의 차선의 계획의 구조화를 그린 여정글 그렸다. Error 라는 예상치 못한 커다란 벽을 만났을 때 흐름이 끊기지 않도록 상실감과 안타까운 감정을 감쌀 수 있는 그런 견고한 가드레일을 만들기 위한 스토리를 풀어보았다.

글을 간단하게 보고 싶다면

 -  목적성이 없는 try, catch 구문들로 인해 생긴 여러가지 이슈들을 매니징하는 함수를 지정하고 MVC 패턴을 사용하여 나은 방향으로 가져가기 위한 노력을 했다.

- 다채로운 Error 코드들에 따른 이후 동작들에 대한 관리포인트를 잡기 위해, 대수적 효과에 근거한 Error처리 방법을 구상했다.

- 공통적으로 사용하던 error와 관련된 hook의 비대화를 막을 수 있도록 코드를 구조화하였다.

꼭꼭 숨어라, Error 보일라

만약 A라는 함수에서 내 정보에 관련된 값을 가지고 오는 요청을 했다고 가정해 보자.

try, catch 문 내부에서 호출을 위한 코드를 명시하고, 상태 코드에 따라 알맞은 흐름을 따라가는 코드를 작성하면 된다. 

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

const A = async () => {
  try {
    const response = await axios.get(apiUrl);
    return response;
  } catch (error) {
    console.error('Error:', error.message);
  }
};

A();

하지만 비즈니스 로직의 복잡도가 올라가면서 코드의 중복이 생겨, A함수에서 사용하던 API 요청 코드를 분리하였다.

이제는 B라는 함수에서 A 함수를 호출하고 반환 값을 사용해 유의미한 코드를 만들어 나가려 한다.

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

const A = async () => {
  try {
    const response = await axios.get(apiUrl);
    return response;
  } catch (error) {
    console.error('Error:', error.message);
  }
};

const B = async () => {
  const data = await A();
    // data를 가지고 유의미한 로직들
};

B();

오케이. 좋아. 나쁘지 않다. 

그런데 사용자 정보를 가공하여 유의미한 값을 만들어 C라는 함수의 인자 값으로 보내야 하는 로직이 필요해졌다.

A 함수를 호출하고 받은 반환 값을 C 함수 인자 값으로 보내는 코드를 만들고 테스트를 해 보았다.

"잘 동작하네" 라고 생각하고 QA 서버에 올렸다. 결과는 Reject....

다시 한 번 코드를 디버깅해보니 A 함수에서 API 호출시 Error가 났을 때 catch문 내부에서 동작을 제어했음에도 불구하고, C 함수 인자 값으로 보내는 함수는 실행이 되고 있었던 것..

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

const A = async () => {
  try {
    const response = await axios.get(apiUrl);
    return response;
  } catch (error) {
    console.error('Error:', error.message);
  }
};

const C = (data) => {
  setTimeout(() => {
      some(data);
    },1000);
};

const B = async () => {
  const data = await A();
  C(data);
};

B();

그래서 B 함수에서도 try, catch 문을 사용하여 Error가 났을 때 제어할 수 있는 로직을 추가하였다.

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

const A = async () => {
  try {
    const response = await axios.get(apiUrl);
    return response;
  } catch (error) {
    console.error('Error:', error.message);
  }
};

const C = (data) => {
  setTimeout(() => {
      some(data);
    },1000);
};

const B = async () => {
  try {
   const data = await A();
    C(data);
  } catch (error) {
    console.error('Error:', error.message);
  }
};

B();

하지만 나는 몰랐다. 이때부터 callback 지옥이 아닌 try, catch 지옥이 시작 되고 있었음을....

어떤 함수에는 목적성이 없는 try, catch 문이 남발 되어 있고, 해당 구문이 필요한 함수에는 명시 되어 있지 않았다. 

 

더 큰 문제는 이슈가 생겨 디버깅을 할 때 관리 포인트를 잡지 못하는 일이 부지기수였던 것이다. 트랙킹이 제대로 되지 않으니, 시간을 효율적으로 쓰지 못하게 되고 이는 또 다른 이슈에 엮여 꼬리에 꼬리를 무는 상황이 반복되었다. 촉박했던 일정 속에서 소중한 리소스를 무방비 상태로 허비하면서 특단의 조치가 필요하다는 생각을 하였다. 

다채로운 Error 코드들, 그러나 형태는 다른...

PERSO 라는 서비스는 동영상 편집 기능을 메인으로 가져간다. 

하나의 페이지에서 이루어지는 다양한 유저 인터랙션들이 존재하고, 각 인터랙션들을 팀에서 정의한 정책에 의거하여 동작하도록 설계되어 있다. 

 

예를 들어 이미지를 업로드한다고 가정했을 때, "유저가 우리가 정의한 정책을 따라 올바르게 동작을 한다"라는 확률은 그렇게 높지 많은 않다. 이미지의 확장자가 다르다거나 사이즈가 너무 크거나 등등 세부적인 룰들이 존재한다.

 

그렇기에 우리가 정의한 룰에 따라 사용자의 행동이 끊기지 않게 권유하기 위해서는 요청에 대한 응답을 주어야 한다.

 

성공은 성공

그러나 실패는 다양한 케이스들이 존재한다. 다양한 케이스들에 대한 실패들을 정의하기 위해서는 개발자들끼리 협의한 암구호 같은 코드가 필요했고, 서비스와 정책에 따라 Error 코드의 양이 많아질 수밖에 없었다. 

 

문제는 많은 Error 코드를 넘겨주는 형태가 호출하는 형식에 따라 천차만별인 것이다. JSONRpc, Polling, http 명세에 따른 호출 방식에 따라 응답을 받는 형태가 다 달랐다. 받은 데이터의 안을 들여다 보지 않으면, 밖에서는 알 수 없는 베일에 쌓여있는 응답들이 끊임없이 들어왔다. 받는 형식이 많아질수록 응답 값을 해부 해야하는 로직들도 많아졌다.

 

경찰이 도둑을 잡기 위해서 꼭 거쳐야하는 것이 무엇인 줄 아는가?

행동에 대한 분석이다. 어떤 케이스들이 있었음을 가정하고, 사실화 해 나가는 과정속에서 확실하게 알 수 있는 건 행동에 대한 케이스가 적거나 단순할 때 사실화에 대한 객관적인 판단도 좁힐 수 있는 것이다.

 

관리 포인트가 늘어나는 것을 지양하는 우리 팀에서는 그냥 지나치기에는 너무 많이 자라버린 빌런의 모습을 하고 있었다.

Error를 처리하는 hook, 그러나..

응답을 받은 데이터를 정말 힘들게 해부하고 꺼낸 Error 코드에 따라 우리는 사용자를 위한 유의미한 화면을 그려내야 했다. 

에러에 따른 다른 컴포넌트 (Alert, Confirm, Popover 등등) 를 노출해야 했고, 알맞은 워딩들로 안내해야 했으며, 적절한 인터랙션을 이어나가야 했다.

 

이러한 역할을 담당할 Commander가 필요했고, useError라는 이름을 가진 사용자 Hook을 만들어 중요한 중책을 할당하였다.

처음에는 참 잘했다. Error 코드를 처리하는 Hook은 어려 컴포넌트들에게 Import를 당하며 꽤나 괜찮은 퍼포먼스를 보여 주었다. 

 

케이스들이 많아지면서 코드의 양이 늘어나고 점점 커져간 파일

그러나 항상 문제는 눈 앞에 다가왔을 때 제대로 인지를 하게 된다. 

 

3살짜리 말을 어눌하게 하는 귀여운 어릴적 모습을 보다가 징그럽게 빨리 커버린 내 지금 모습을 보면 한숨이 나올 때가 있다.

한순간에 성장은 useError Hook에게도 일어났다. 기능이 늘어남에 따라 Error를 조작해야 하는 코드들을 늘어나고 파일은 점점 비대해져 갔다. 인자를 넘기는 값들도 다양해져 갔으며, 이 후 일어날 상황들에 대한 짐작을 할 수 있엇다.

 

성장이 너무 빨라 어떻게 카테고리를 지어 파일을 쪼갬에 있어서도 명확한 판단이 서질 않았다. 현재 청년인 useError Hook이 결혼을 하고 고 아이를 낳기 전에, 확실한 결단이 필요했다.

그래서 우리는 결심했다.

스노우 볼이 더욱 커지기 전에 우리는 결단을 내려야 했다. 

항상 내가 짠 코드는 더럽고, 비효율적이고, 만족하지 못하는 수준이라 자책하지만, 조금이라도 개선 될 수 있다면 움직이는게 당연하다고 생각했다. 

 

우리가 결심한 건 총 3가지이다. 

 

1. 숨어버린 Error를 쉽게 찾을 수 있도록 하기

2. 호출하는 형식에 따라 천차만별인 것을 구조화하고, Error 코드 규칙을 정하기

3. 너무나 비대해져버린 Error hook을 알맞게 조절하기

 

꼭꼭 숨어버린 Error 가 가질 수 있는 여러 행동의 케이스를 적게 만들어 제 시간안에 찾을 수 있는 방법을 어떻게 가져갔을까?

한번 살펴보자.

내가 싼 똥은 내가 치운다.

말 그래도 내가 싼 똥은 내가 치운다라는 컨벤션을 가져가기로 결정하였다. 즉, B함수에서 호출이 시작되었다면, A 또는 C라는 함수에서 error를 catch해야 하는 구문을 사용해야 하더라도, B에서 코드의 운전을 시작할 수 있는 흐름을 만들어 나가는 것이다. 

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

const A = async () => {
    const response = await axios.get(apiUrl);
    return response;
};

const C = (data) => {
  setTimeout(() => {
      some(data);
    },1000);
};

// B에서 호출이 되었으니 B함수만 try,catch로 묶어준다.
const B = async () => {
  try {
   const data = await A();
    C(data);
  } catch (error) {
    console.error('Error:', error.message);
  }
};

B();

이런 컨벤션을 세운 이유는 다음과 같다. 

 

1. 사공이 많으면 배가 산으로 가듯, 모든 일을 매니징할 수 있는 함수가 필요했다.

2. 디버깅을 위한 코드의 흐름을 한눈에 볼 수 있는 중심축이 필요했다. 

3. catch로 떨어졌을때 매니징을 하는 함수에서 관리할 수 있는 포인트가 필요했다.

 

하지만 만약 A함수에서 이미 try, catch 문을 만들고 다른 여러 함수에서 A함수를 호출하고 있는 경우에는 해당 컨벤션을 가져가야만 할까? 리팩토링을 하는데 리소스를 많이 잡아 먹는 건 아닐까? 라는 애매모호한 질문에 봉착하게 된다.

const axios = require('axios');

const apiUrl = 'https://api.example.com/data';

// 가장 먼저 선언된 함수
const A = async () => {
	try {
      const response = await axios.get(apiUrl);
      return response;
    } catch (error) {
      console.error('Error:', error.message);
  }
};

const C = (data) => {
  setTimeout(() => {
      some(data);
    },1000);
};

const D = async () => {
   const data = await A();
   setData(data);
};

const F = async () => {
   const data = await A();
   setData(data);
};

// B에서 호출이 되었으니 B함수만 try,catch로 묶어준다.
const B = async () => {
  try {
   const data = await A();
    C(data);
  } catch (error) {
    console.error('Error:', error.message);
  }
};

D();
F();
B();

이런 문제를 해결하기 위해 (물론 다른 이슈를 해결하기 위한 목적이 더 컸다....) 우리는 Service와 View, 그리고 Controller를 따로 두어 관심을 가지고 있는 것끼리 묶어 두는 패턴을 적용해 나가기로 했다.

해당 패턴에 대한 소개 글이 아니다보니 정말 간단하게 설명해보면 아래와 같다. 

 

1. Contoller는 하나의 View를 조작할 수 있는 이벤트들을 다양한 Service를 호출하여 만든 지정된 함수에 연결하면서 플러그와 같은 역할을 한다.

2. Service에서는 원하는 데이터를 만들어 내기 위한 연산을 하고, 상태를 관리 및 조작하는 역할을 한다. 

3. View에서는 유의미한 데이터를 적재적소에 노출하고, 유저와 인터랙션 할 수 있는 화면을 그리는 역할을 한다.

 

우리는 Service와 View를 연결을 담당하는 역할을 하는 Controller를 매니징 함수로 지정하고, 코드의 구심점이 되어 움직일 수 있도록 하였다. 다시 말해 Error를 매니징하는 핵심 함수들은 Controller에 위치시키도록 규칙을 정한 것이다.  

 

정리하자면, 하나의 함수가 품고 있는 다양한 함수들의 행동을 예측하기 위해서는 단순하고도 명확한 규칙이 필요했기 때문에 Service와 View, 그리고 Controller의 역할을 수행하도록 구조화하고 Controller에서 try, catch를 매니징하였다.

우리는 해당 규칙을 토대로 다음 주제에서 언급할 Error Class의 밑거름을 만들 수 있었다. 

Error Class를 통해 프라이빗 컬러를 정해주다. 

호출하는 형식에 따라 천차만별인 것을 구조화하는 문제를 해결하는 것은 생각보다 어렵지만은 않았다. 단지 FE팀과 BE팀의 커뮤니케이션속에서 나오는 여러 생각들과 의견들을 잘 조율하고 실행하기만 하면 되었다.

 

문제는 "다채로운 컬러를 지닌 Error 코드들을 잘 요리해서 tracking이 용이하고 흐름을 이어갈 수 있는 Error 처리 방법을 구상하는 것"이었다.

흐름을 유지한채로 Error 처리를 하고 싶었던 이유는 Dan Abramov - Algebraic Effects for the Rest of Us 글을 읽고 나서였다.

function getName(user) {
  let name = user.name;
  if (name === null) {
    // 2. 여기서 효과를 수행
    name = perform 'ask_name';
    // 5. 그리고 여기로 돌아옴, name값은 핸들 블락에서 넣은 Arya Stark
  }
  // 6. 마지막으로 값을 리턴함
  return name;
}
​
function makeFriends(user1, user2) {
  user1.friendNames.add(getName(user2));
  user2.friendNames.add(getName(user1));
}
​
const arya = { name: null };
const gendry = { name: 'Gendry' };
  try {
    // 1. 함수 실행(try-handle 문에서 먼저 실행)
    makeFriends(arya, gendry);
  } handle (effect) {
    // 3. 효과를 수행하면 Handle 구문으로
    if (effect === 'ask_name') {
      // 4. try, catch와는 다르게 값을 전달하면서 기존 try문 내부의 코드를 이어서 실행
      resume with 'Arya Stark';
    }
  }

해당 글에서 Dan은 다시 돌아오는 try-catch문이라는 말로 대수적 효과를 설명한다.

 

위 예제의 대수적 효과 문법인 try-handle 블럭은 try-catch와 다르게 Exception을 던지고 블럭을 나가는 대신에 handle문에 명시된 특정 효과를 수행하고 로직을 계속 이어나가게 된다. resume 키워드는 효과가 수행된 곳으로 다시 돌아갈 수 있고, 핸들러를 통해 무언가 전달을 할 수도 있다.

[사전적 정의]
대수적효과: 컴퓨터 효과의 접근 방식 중 하나로, 특정 연산 집합이 불순한 부수 효과를 불러 일으키는 것, 특정 연산(Effects)과 그 연산이 일으키는 부수효과(Effect Handlers)로 이루어져 있음, 즉 부수효과는 특정 연산이 발생하는 것에 대응해 호출되는 로직 모두를 포함함(특정 값을 리턴, 특정 동작을 실행)

컴퓨터효과: 컴퓨터 동작에 대한 기술, 함수가 리턴하거나 변수의 값을 집어 넣는 것 등등

참고사이트

 

정리하자면 보통 Exception이 발생하고 catch문이 실행되면 try-catch문의 로직은 그걸로 끝이난다.

하지만 대수적 효과를 지원하면 특정 Effect가 실행되도 Effect가 발생된 실행문 이후의 로직이 끝나지 않고 계속 진행되도록 개발자가 처리할 수 있다.

 

아직도 대수적 효과가 무엇인지에 대한 이론 적립은 정확하게 이뤄지지 않았다. 혹시 이 이론에 대해 잘 알고 계신 분이 있다면, 아래 댓글에 의견을 주시길 바란다. 

 

다시 원점으로 돌아가서 이러한 이론를 주축으로 흐름이 끊기지 않고 Tracking이 용이한 Error처리를 하고 싶었다. 서비스와 정책에 따라 구성된 각 Error의 다양한 후속 처리를 구별하여 고유의 성질을 가질 수 있는 로직을 가져가고 싶었다. 

 

이를 구성하기 위해 리액트의 Error Class를 상속 받아 커스텀한 ErrorBase Class를 가져갈 수 있었다. 

Error에서 사용되는 공통적인 속성들을 ErrorBase Class에 정의하고, 프라이빗 컬러가 뚜렷한 각 ErrorClass가 ErrorBase를 상속하여 원하는 handler을 할 수 있는 로직을 구성하였다.

 

그리고 각 API를 호출을 하거나 validation을 하는 함수를 호출할 때 매칭된 에러 클래스를 콜백 값으로 넣어 throw를 던져주는 공통함수에서 호출할 수 있도록 로직을 구성하였다. 

그런 다음 catch로 떨어지는 error의 instance를 확인하여 원하는 액션을 handle 할 수 있도록 하였다.

try{
	// 생략
}catch(error) {
   if (error instanceof CommonError) {
       // 액션 처리 (handle)
    }
     
     if (error instanceof SignError) {
       // 액션 처리 (handle)
     }
     
     if (error instanceof ValidationError) {
       // 액션 처리 (handle)
     }
}

아직 업데이트를 해야 할 부분이 상당하지만, 다채로운 컬러를 지닌 Error 코드들에 맞게 각 ErrorClass를 할당하여 고유의 성질을 가져감으로서 Error의 발자취를 easy하게 보기 위한 기틀을 만들었음은 분명하다.

 

앞서 해결해나가는 과정 속에서 마지막 문제의 답을 자연스럽게 찾을 수 있었다. 처음 호출한 코드에서 Error를 처리하고, 각 고유의 Class를 할당해 후의 로직을 만들어 나가면서 자연스럽게 useError라는 hook은 온데간데 없었다.

 

적절한 관심사의 분리는 우리에게 이득을 가져다주지만, 종속적인 관계에서의 관심분리는 독을 낮는다는 것을 확실하게 배울 수 있었다.

앞으로 무엇을 해야할까? 아직 남아 있는 일들..

서론에서 이따시만한 계획을 한 꾸러미 들고 와 펼쳐 놓는다는 이야기를 했다. 기존의 문제가 되었던 부분을 현재 해결했다고 생각하지는 않는다. 즉, 여전히 남아있는 일들이 도사린다는 말이다. 

 

Dan이 언급했던 Dan Abramov - Algebraic Effects for the Rest of Us  방법론을 완벽하게 공부하는 것이 첫 번째 목표가 될 것 같다. 다른 곳에서도 적용할 수 없는 부분이 없는지에 대한 의문점을 가지면서 진행 중인 계획이 될 것 같다. 

 

음.. 한가지 더 꼽자면, 공통 에러를 체크하고 주입하는 로직(이 글에서는 언급하지 않았다.)의 더 나은 코드적인 정리가 필요한 것 같다. 느낌적인 느낌으로 허술한 틈새가 있을 것이라 생각되며 오히려 Error가 터져주길 바라고 있다.

 

과연 PERSO 제품의 견고한 가드레일을 잘 만들었을까? 


거 딱 죽기 딱 좋은 Error네!!, end

Comments