제로부터 시작하는 프론트엔드 에러 시스템 구축기
Xperience

제로부터 시작하는 프론트엔드 에러 시스템 구축기

에러 타워 디펜스 한 판 하실래요?

#Error#ErrorBoundary#Axios#Sentry
13
2,576

0) 들어가며

지난 포스팅 <에러를 알아야 핸들링할 수 있다> 에서는 프론트엔드에서 마주하는 다양한 에러의 유형을 살펴보았습니다. 이번에는 한 단계 더 나아가, 실제 프로덕션 환경에 프론트엔드 에러 처리 시스템을 도입하며 고군분투했던 과정을 공유하려 합니다.

⚠️ 들어가기 전에
에러 처리 방식에 정답은 없습니다. 프로젝트 규모, 팀의 컨벤션에 따라 최적의 구조는 달라질 수 있습니다. 코드 그 자체보다는 시스템을 구축해 나가는 흐름과 의사결정 과정을 위주로 봐주시면 감사하겠습니다.

1) 에러 시스템 = 타워 디펜스?

에러 시스템이 없는 서비스는 위와 같은 모습이다 (..)
에러 시스템이 없는 서비스는 위와 같은 모습이다 (..)

에러 시스템의 존재 의미는, 다양한 지점에서 쏟아지는 여러 종류의 에러를 적절한 위치에서 효율적으로 처리하는 것에 있습니다. 전 이걸 딱 보자마자 디펜스 게임 같다는 생각이 들었는데요, 실제로 에러를 몬스터로 치환해 보면 디펜스 게임의 각 필수 요소에 들어맞는 걸 알 수 있습니다.

  • 다양한 몬스터 (에러): 외부 모듈 장애, 백엔드 500 에러, 네트워크 에러 등
  • 스폰 지역 (에러 발생 지점): API 통신, 렌더링 과정, 이벤트 핸들러 내부 등
  • 타워 건설 위치 (에러 처리 시점): 컴포넌트 내부의 try-catch, 에러 바운더리, 혹은 글로벌 에러 핸들러 등
  • 타워 종류 (에러 핸들링 방식): 토스트 알림, 모달 팝업, 인증 만료 리디렉션, 모니터링 로깅 등

에러 시스템 없이 서비스를 운영하는 건, 디펜스 게임에서 “어떤 몬스터가 어디서 얼마나 나올지 모르지만, 일단 보이는 대로 막아볼게요!” 라고 외치는 것과 같습니다.

서비스의 규모가 커질수록 이 방식은 한계에 부딪혔고, 저는 팀원들과 함께 본격적으로 에러 타워 디펜스를 시작하기로 했습니다.

2) 현 상황 분석 (As - Is)

가장 먼저 팀 개발 환경의 에러 처리 실태를 분석하고 의논하는 시간을 가졌습니다. 그동안 명확한 전략이나 컨벤션이 부재했던 탓에, 팀원 모두가 여러 기술적 부채를 체감하고 있었습니다.

2-1) 파편화된 에러 수집

비동기 요청이나 외부 모듈 사용처에서 try-catch 문을 사용하고는 있었지만, 단순 크래시 방지 수준이었습니다. 에러가 상위로 전파되지 못하고 중간에 예상치 못한 catch 블록에서 의문사하거나 적합하지 않은 핸들러에 잡혀 UI 상태 업데이트가 누락되는 경우가 빈번했습니다.

2-2) 일관성 없는 UX

같은 서비스 내에서도 에러를 사용자에게 알리는 방식이 개발자마다, 시기마다 제각각이었습니다. 곳곳에 alert(), toast.error(), console.error()가 혼재했고, 유저에게 적절한 피드백을 주지 않는 경우도 있었습니다.

2-3) 다국어(i18n) 처리의 복잡성

모든 서비스들이 다국어를 지원하고 있었는데, 번역에 대한 컨벤션이 없어 시간이 지날수록 서비스마다 번역 방법에 대한 차이가 생겨났고, 이를 합의하는 과정이 필요해졌습니다.

  • 파편화된 리소스: i18n의 번역 키와 번역 값의 JSON 구조, 파일 관리 방식이 서비스마다 상이했습니다.
  • 책임의 모호함: API 통신 상 에러가 발생했을 경우, 에러 메시지의 번역 시점이 백엔드 응답 시점인지, 프론트엔드 렌더링 시점인지 약속되지 않았습니다.
  • 동적인 에러 메시지: 백엔드 에러 코드를 번역 키로 쓸지, 헤더에 언어 정보를 담아 백엔드에서 완성된 메시지를 내려줄지에 대한 합의가 필요했습니다.

2-4) 제각각인 API 에러 스키마

모노레포 내의 여러 서비스가 제각각의 에러 응답 규격을 가지고 있었습니다.

서비스에러 응답 필드message 번역 여부문제점
A 서비스messageX사용자 노출 불가
B 서비스status, message△ (일부)일관성 부족
C 서비스error_code, messageX (클라이언트 처리)프론트엔드 로직 비대화

이처럼 백엔드에서 던져주는 에러 응답 규격이 통일되지 않으니, 공통 패키지를 통한 추상화가 불가능했습니다. 모노레포 환경임에도 불구하고, 서비스가 늘어날수록 에러를 파싱하는 중복 코드가 양산되었습니다.

2-5) 반복되는 보일러플레이트

앞선 2-4의 결과로, 각 프로젝트의 Axios 인터셉터와 에러 바운더리에는 비슷한 듯 미묘하게 다른 파싱 로직들이 쌓여갔습니다. 이는 곧 유지보수 비용의 증가로 이어졌고, 휴먼에러를 발생시키는 주범이 되었습니다.

2-6) 디버깅 컨텍스트의 부재

모니터링 도구로 Sentry를 사용 중이었지만, 그저 에러의 발생만 알려줄 뿐이었습니다. 어떤 사용자가 어떤 경로로 들어와 어떤 데이터를 입력하다 크래시가 터졌는지 알 수 없어, 재현을 위해 수동으로 플로우를 따라하는 비효율이 반복되었습니다.

2-7) 노이즈가 된 모니터링

명확한 필터링이나 사용 규칙 없이 처음 도입된 Sentry는 아래의 문제들로 인해 슬랙에 무의미한 알림들을 보내왔고, 이로 인해 팀원들이 알림 채널을 알림 중지하거나 읽씹하는 경우가 반복되었습니다.

  1. 심각도 구분 부재: 사소한 네트워크 지연이나, 단순 사용자 취소까지 모두 에러로 날라왔습니다.
  2. i18n으로 인한 그룹핑 부재: 같은 에러임에도 유저의 언어 설정에 따라 Sentry에서 다른 이슈로 분류되었습니다.
  3. 영양가 없는 에러: 에러 발생 그 자체만 확인할 수 있을 뿐, 디버깅을 위한 컨텍스트가 부족했습니다.

3) 에러 시스템 1단계 - 정규화

에러 타워 디펜스의 스테이지를 클리어하기 위해선 어떤 종류의 몬스터들어디서 출몰하는지 파악하고, 이동 경로의 전략적 요충지효과적인 타워를 건설해야 합니다.

주어진 환경에서 시스템을 조금씩 잡아나가기 위해 우리는 먼저 몬스터들의 정체부터 확실히 하기로 했습니다.

3-1) 백엔드 에러 스키마 통일

가장 시급한 문제는 적의 정체를 식별하기 어렵다는 점이었습니다. 서비스마다, 심지어 같은 서비스 코드 내에서도 API 에러 스키마가 제각각이었습니다.

“여기 에러 code 무조건 있는 거죠?” “음… 아마도.. 근데 아닐 수도 있어요. 일단 옵셔널로..”

이는 프론트엔드의 방어 로직을 복잡하게 만들었고, 결과적으로 FE와 BE 양쪽의 유지보수성을 떨어뜨리는 주범이었습니다. 백엔드 측에서도 이러한 비일관성에 불만이 많았고, 긴밀한 협의 끝에, 우리는 단 하나의 통일된 에러 응답 규격을 사용하기로 합의했습니다.

interface ApiErrorResponse { code: string; // 에러 고유 식별자 (ex. INVALID_SOLUTION_TYPE) message: string; // 사용자에게 보여줄 메시지 (번역 적용됨) }

거창하지도 않고, 누군가에겐 아주 당연한 객체지만, 이 합의의 효과는 강력했습니다.

  • code: 고유 식별자가 보장되므로, 서비스 언어가 바뀌어도 Sentry에서 동일한 이슈로 트래킹할 수 있습니다.
  • message: API 응답에 대한 다국어 번역의 책임을 백엔드로 완전히 위임하여 프론트엔드의 비즈니스 로직이 한결 가벼워졌습니다.

3-2) 통합 커스텀 에러 정의

백엔드 스키마가 통일되었으니, 이제 프론트엔드 내부에서 통용될 통합 커스텀 에러(AppError) 를 정의할 차례입니다. 앞선 분석 단계(As-Is)에서 도출한 문제점들을 해결하기 위해 다음과 같은 속성들을 담았습니다.

  • code: 에러의 고유 식별자
  • severity: 에러의 심각도 (INFO, WARNING, ERROR, CRITICAL)
  • context: 디버깅을 위한 추가 정보
  • i18nKey, i18nParams: 번역 키와 파라미터
  • originalError: 원본 에러 객체 (스택 트레이스 보존)
  • _isAppError: 타입 가드용 플래그

외부에서 어떤 형태의 몬스터(에러)가 들어오든, 우리 서비스 내부에서는 AppError 라는 통일된 규격으로 변이시켜 관리하고자 했습니다.

packages/errors/src/errors.ts
export class AppError extends Error { public readonly _isAppError = true; // ... (필드 생략) constructor(options: AppErrorOptions) { super(options.message ?? options.i18nKey ?? options.code ?? "UNKNOWN_ERROR"); // ... (초기화 로직) // 타겟 환경(ES5 등)에 따라 프로토타입 체인이 끊기는 문제 방지 Object.setPrototypeOf(this, new.target.prototype); if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } } isRetryable(): boolean { return false; } isSeriousError(): boolean { return ["ERROR", "CRITICAL"].includes(this.severity); } }

또한, 백엔드 API 에러 처리를 위한 ApiError와 네트워크 이슈를 위한 NetworkErrorAppError를 상속받아 구체화했습니다.

ApiError는 기본적으로 아래와 같은 전략으로 작성되었습니다.

  • 번역 및 식별: 백엔드 메시지(번역 O)를 최우선으로 하되, 메시지가 비어있을 경우 HTTP 상태 코드에 매핑된 프론트엔드 다국어 키를 Fallback으로 사용
  • 자동 심각도 분류: 500번대 에러는 ERROR, 400번대는 WARNING으로 자동 분류하여 모니터링 노이즈를 줄입니다.
통합 커스텀 에러 상속 다이어그램
통합 커스텀 에러 상속 다이어그램

3-3) 에러 정규화 함수 구현

이제 다양한 야생의 몬스터들(AxiosError, Fetch Response, Error, unknown)을 우리가 만든 AppError로 변환해 주는 마법의 주문, normalizeError가 필요합니다.

normalizeError 플로우
normalizeError 플로우
export const normalizeError = (error: unknown): AppError => { if (isAppError(error)) return error; if (isAxiosError(error)) return errorConverters.fromAxios(error); if (isHttpError(error)) return errorConverters.fromHttp(error as HttpError); // ... Fetch 및 일반 Error 처리 로직 return new AppError({ code: "UNKNOWN_ERROR", message: String(error), severity: "ERROR", i18nKey: ERROR_KEYS.UNKNOWN, }); };

❗️여기서 주의할 점은 Fetch API입니다.

우리 팀은 주력으로 Axios를 쓰지만, 레거시 코드나 특정 라이브러리와의 호환성을 위해 Fetch 대응 로직도 확보해두었습니다. 하지만 fetch의 응답을 파싱하는 response.json()은 비동기이므로, 동기 함수인 normalizeError에서는 본문을 파싱할 수 없었습니다. 그렇다고 정규화 함수를 비동기로 만들게 되면 Error Boundary나 동기적인 렌더링 사이클에서 사용할 수 없게 되고, 정규화 함수부터 시작하는 async Hell이 발생할 수 있었습니다.

따라서 fetch API를 직접적으로 호출하는 구간을 위해 normalizeErrorAsync를 별도로 두어, try-catch에서 상황에 맞게 사용할 수 있도록 유연성을 확보하는 전략을 세웠습니다.

fetch API 용 비동기 에러 정규화 함수
export const normalizeErrorAsync = async (error: unknown): Promise<AppError> => { if (isFetchResponse(error)) { return await errorConverters.fromFetch(error); } return normalizeError(error); };

4) 에러 시스템 2단계 - 핸들링

4-1) 몬스터 스폰 지점 파악하기

몬스터들을 일정하게 변이(정규화)시켰으니, 이제 스폰 포인트를 파악해야 합니다.

서비스에서 발생할 수 있는 에러의 출처는 크게 3가지로 좁혀졌습니다.

  1. 개발자가 throw하는 에러: 비즈니스 로직 상의 예외.
  2. HTTP 통신 중 발생하는 에러: 서버와의 통신 에러.
  3. 외부 라이브러리 및 런타임 에러: 완벽히 예측이 불가능한 에러.

1. 개발자가 직접 throw하는 에러

개발자가 스폰 지점을 명시해놓은 만큼, 에러 발생 지점이 명확하고 예측 가능합니다.

보통 필수 파라미터나 조건 검증 등 비즈니스 로직 상의 예외나, 외부 모듈 사용 전 개발자가 직접 예외 케이스를 작성할 때 사용합니다.

async function uploadFile(file: File) { if (file.size > MAX_FILE_SIZE) { throw new AppError({ code: "OVERSIZE_FILE", i18nKey: "error.oversize_file", i18nParams: { maxSize: MAX_FILE_SIZE }, context: { fileSize: file.size, maxSize: MAX_FILE_SIZE } }) } // ... }

2. HTTP 통신 중 발생하는 에러

서버와 통신하는 HTTP 클라이언트는 서비스 전역에서 사용됩니다.

우리 서비스는 Axios를 사용하고 있었으므로, Axios Interceptor가 에러들을 맞닥뜨릴 수 있는 최전선이었습니다.

3. 외부 라이브러리 및 런타임 에러

외부 라이브러리 및 런타임 에러는 정확한 발생 시점을 예측할 수 없습니다.

undefined 참조 에러(TypeError)나 외부 라이브러리 내부 에러 등은 제어할 수 없고, 아무리 코드를 잘 짠다고 해도, 설령 AI가 코드를 작성한다고 해도 모든 곳에는 버그와 실수가 있을 수 있기 때문에 항상 에러는 발생한다고 생각하고 대비해야 합니다.

이 예측 불가능한 몬스터들은 결국 넥서스 앞의 쌍둥이 포탑, 최후방의 방어선인 Global Error Boundary에서 막아내야 합니다.

4-2) 방어선 최전방에서 정규화하기

디펜스 게임의 핵심은 적이 스폰되자마자 그 정체를 파악하는 것입니다. 그래야 화염 속성 몬스터에게 화염이 아닌 물 대포 타워를 준비할 수 있으니까요.

에러 처리도 마찬가지입니다. 우리는 에러가 발생하는 최전방에서 즉시 normalizeError를 호출하여 몬스터를 변이시켰습니다.

src/lib/https.ts
http.interceptors.response.use( (response) => response, (error) => Promise.reject(normalizeError(error)) // 발생 즉시 정규화 );

‘어차피 모든 에러가 handleError라는 종착지로 모이는데, 거기서 한 번만 변환하면 효율적이지 않을까?’ 라고 생각할 수도 있습니다. handleError의 최상단에는 정규화되지 않은 에러가 들어올 때를 대비해 normalizeError를 호출하는데 중복 호출을 막고 로직을 한곳에 모으는 것이 더 깔끔해 보일 수 있으니까요. (절대 제 이야기가 아닙니다.)

handleError: “왼손은 정규화 오른손은 핸들링”
handleError: “왼손은 정규화 오른손은 핸들링”

하지만 이렇게 에러 정규화를 미루게 되면 몇 가지 문제가 발생합니다.

  1. 중간 처리의 어려움: 에러가 최후방에서만 변환되므로 컴포넌트나 React Query 레벨에서 에러를 직접 핸들링하려 할 때마다 정규화가 필요합니다.
  2. 타입의 혼재: API 함수 내부에서 던진 AppError와 네트워크 실패로 인한 AxiosError가 혼재되어, 호출부에서 AxiosError | AppError 두 가지 타입을 모두 고려해야 합니다.
  3. 방어 로직 중복: 위 이유로 인해 타입 가드를 활용한 방어 로직을 매번 작성해야 합니다.

따라서 우리는 애플리케이션 내부로 진입하는 모든 에러는 반드시 정규화하여 AppError 규격을 따른다는 규칙을 세웠고, 덕분에 useQuery<Data, AppError>와 같이 제네릭을 사용하여 컴포넌트 레벨에서도 에러 타입을 확신하고 다룰 수 있게 되었습니다.

4-3) 에러 처리 타워 설계: handleError

에러 처리 로직을 총괄하는 handleError 함수를 작성합니다. 이곳에서 로깅, 인증 처리, 토스트 알림 등 핵심 로직을 수행합니다.

export function handleError(error: unknown, options: ErrorHandlerOptions = {}) { const appError = normalizeError(error); // 멱등성 보장 if (IS_DEV) { 콘솔에로깅(appError); } else { 모니터링에로깅(appError); } if (appError.isAuthError()) { return 인증만료후리디렉션(); } if (options.showToast) { const message = getErrorMessage(appError); toast.error(message); } }

4-4) 최후방 방어선에 타워 세우기

handleError를 사용하여 에러를 개별 컴포넌트에서 처리하는 것도 좋지만, 시스템 레벨에서 에러를 빈틈없이 커버할 수 있도록 에러의 각 종착지를 파악하여 최후방에 에러 핸들러를 설치해두는 것이 중요합니다.

1. React Query의 최후방

데이터 패칭 에러는 QueryCache의 전역 설정(onError)에서 일괄 처리합니다. 이미 Interceptor에서 AppError로 변환되었으므로, 여기서는 복잡한 로직 없이 handleError로 넘기기만 하면 됩니다.

const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { handleError(error); }, }), mutationCache: new MutationCache({ onError: (error) => { handleError(error); }, }), ...

2. 렌더링 최후방 (Error Boundary)

렌더링 도중 발생하는 런타임 에러는 Error Boundary가 담당합니다. 이 곳은 Axios를 거치지 않은 순수 ErrorTypeError 가 잡히는 곳이므로, 여기서 한 번 더 normalizeError를 호출하여 표준 규격으로 변환해줍니다.

export function ErrorBoundaryProvider({children}: {children: React.ReactNode}) { const handleErrorBoundary = (error: Error, errorInfo: React.ErrorInfo) => { const normalizedError = normalizeError(error); Object.assign(normalizedError, { context: { ...normalizedError.context, componentStack: errorInfo.componentStack ?? "", isErrorBoundary: true, }, }); handleError(normalizedError); }; return ( <ReactErrorBoundary FallbackComponent={ErrorFallback} onError={handleErrorBoundary}> {children} </ReactErrorBoundary> ); }

3. 최후방의 최후방 (Window Event)

React Query도, Error Boundary도 막지 못하는 사각지대가 있습니다. 바로 이벤트 핸들러 내의 비동기 에러와 놓친 Promise Rejection입니다.

버튼의 onClick 핸들러 내부에서 async/await 없이 발생한 비동기 에러나, Promise가 리젝되었는데 아무도 catch하지 않은 경우, Error Boundary를 우회하여 콘솔에 붉은 줄만 남기고 사라집니다.

이 마지막 틈새까지 막아주기 위해, 브라우저의 최상위 객체인 window에 아래의 이벤트에 에러 핸들러 리스너를 달아 최후의 포탑을 세워줍니다.

  • error 이벤트(ErrorEvent): 스크립트 실행 중 발생하는 일반적인 에러를 잡습니다.
  • unhandledrejection 이벤트(PromiseRejectionEvent): try-catch.catch로 처리되지 않은 모든 Promise 에러를 잡습니다.
window.addEventListener("error", (event) => handleError(event.error)); window.addEventListener("unhandledrejection", (event) => handleError(event.reason));

4-5) 에러 처리 시스템 아키텍처

앞서 설계한 Interceptor, Error Boundary, 그리고 전역 이벤트 리스너가 시스템 내에서 어떻게 상호작용하는지 전체적인 에러 시스템 아키텍처를 시각화해보았습니다.

에러 발생 지점별 처리 흐름도
에러 발생 지점별 처리 흐름도

어떤 경로로 에러가 유입되든 결국 정규화 과정을 거쳐 handleError라는 하나의 종착지로 모이는 것을 확인할 수 있습니다.

5) 에러 시스템 3단계 - 모니터링

이제 우리는 도입부에서 구상했던대로, 다양한 지점에서 예고 없이 쏟아지는 여러 종류의 에러를 적절한 위치에서 효율적으로 처리하도록 에러 핸들링 시스템을 구축했습니다.

그럼 이제 디펜스가 끝난 걸까요? 안타깝지만 타워 디펜스는 막아도 막아도 계속 다음 웨이브가 등장합니다.

따라서 에러를 잘 막는 것(핸들링)만큼 중요한 것이, 적의 침입 경로를 분석하고 다음 웨이브를 대비하는 것, 즉 모니터링입니다.

이를 위해 기존에 단순히 에러 발생 확인 정도로만 사용했던 Sentry를 보다 전략적인 관제 시스템으로 업그레이드했습니다.

  1. 에러 분류: severitycode를 태깅하여, 단순 경고인지 즉시 대응이 필요한 장애인지 구분합니다.
  2. 그룹핑 고도화: /users/123 같은 동적 URL을 /users/:id로 패턴화하여 노이즈를 없애고 이슈의 진짜 빈도를 파악합니다.
  3. 상황 분류: CanceledErrorNetworkError 등 개발자가 통제할 수 없는 에러는 ignoreErrors로 과감히 무시해 알림의 SNR(신호 대 잡음비)을 높입니다.
  4. 디버깅 컨텍스트와 보안: 디버깅에 필요한 유저 행동 정보는 남기되, 토큰 같은 민감 정보는 자동 마스킹 처리하여 보안 사고를 방지합니다.

6) 도입 후 변화

에러 시스템(에타디) 도입은 단순한 코드 정리를 넘어, 팀의 일하는 방식 자체를 바꾸어 놓았습니다.

6-1) DX(개발자 경험)의 수직 상승

팀원들이 체감한 가장 큰 변화는 개발 생산성의 수직 상승이었습니다.

"이거 try-catch로 감싸나요?", "여기서 에러 나면 alert인가요 toast인가요?", "로그 찍어야 하나요?", “번역은요?”

이전에는 비즈니스 로직만큼이나 에러 처리에 에너지를 쏟아야 했고, 곳곳의 자잘한 ‘다름’들이 기술 부채가 될 것이라는 불안감이 있었습니다.

하지만 시스템 도입 이후, 우리는 "에러를 어떻게 처리할까"라는 고민을 내려놓고 "무엇을 구현할까"라는 개발의 본질에만 온전히 집중할 수 있게 되었습니다.

  • 코드 가독성 향상: try-catch, UI 제어 코드가 사라지고 순수 비즈니스 로직만 남아 코드가 깔끔해졌습니다.
  • 중앙 통제: 정책이 바뀌어도 handleError 함수 하나만 수정하면 전역에 반영됩니다.
  • 책임 명확화: 백엔드와의 에러 규격 합의로 이슈 원인 파악과 책임 소재가 명확해졌습니다.

6-2) 에러 선제 대응 및 운영 효율성 증가

이제 고객사로부터 문의가 접수되기 전에 개발팀이 먼저 슬랙 알림을 받고 움직입니다. 과거의 수동적이었던 장애 대응 프로세스가 획기적으로 단축되었습니다.

  • Before (10단계): 고객사 이슈 발견 → SE팀 문의 → SE팀 이슈 파악 → 전담 개발팀 이슈 인계 → 이슈 원인 파악 → 원인 분석 및 안내 → 패치 기간 안내 및 패치 시작 → 고객사 대기 → 패치 완료 → 배포 및 안내
  • After (6단계): 고객사 이슈 발견 → 이슈 원인 파악 → 원인 분석 → 패치 시작 → 패치 완료 → 배포 및 안내

이는 업무 효율을 넘어, 장기적으로 고객에게 안정적으로 관리되는 서비스라는 신뢰를 주는 핵심 포인트가 되었습니다.

6-3) AI Agent와의 협업 효율 증가, 컨텍스트 감소

예상치 못한 가장 큰 수확이었습니다.

에러 처리 패턴이 handleError(error) 혹은 throw new AppError(...)로 정형화되자, AI 도구들이 우리 팀의 에러 시스템을 이해하기 시작했습니다. 이전에는 AI가 제각각인 처리 방식을 이해하지 못해 엉뚱한 코드를 짜주곤 했지만, 명확한 규칙이 생기자 별다른 프롬프팅 없이도 완벽한 예외 처리 코드를 제안받을 수 있게 되었습니다. 시스템 구축이 AI의 컨텍스트 비용까지 절감시켜 준 셈입니다.

6-4) 모노레포 환경의 확장성

서비스마다 제각각이던 에러 처리 방식이 통일되면서, 서비스 간 컨텍스트 스위칭 비용이 획기적으로 줄었습니다.

공통 패키지로 에러 클래스를 관리하니 신규 서비스를 런칭할 때도 해당 서비스의 에러 핸들링 로직만 독립적으로 작성하면 되었고, 기존 개발자들의 리뷰 시간과 신규 입사자의 온보딩 비용 또한 낮출 수 있었습니다.

7) 마치며

당장 기능 개발하기도 바쁜 서비스 초기에는 에러 시스템 구축이 '오버 엔지니어링'처럼 느껴질 수 있습니다. 하지만 서비스 규모가 커지고 팀원이 늘어날수록, 체계 없는 에러 처리는 걷잡을 수 없는 기술 부채가 되어 돌아옵니다.

그렇기 때문에 “에러 시스템 굳이 지금 안 만들어도 될 것 같은데?” 싶은 시점이 가장 적은 비용으로 도입할 수 있는 골든타임이라고 생각합니다. 나중에 필요성을 느낄 때는 이미 늦어 많은 코드 덩어리들을 수정하고, 또 다른 개발자들과 여러 차례 소통을 해야하는 등 비용이 많이 들기 때문입니다.

이제 에러 처리에 대한 고민은 든든한 타워들과 새로운 관제 시스템에 맡겨두고, 우리는 더 멋진 기능을 만드는 데 집중하려 합니다. 이 글이 여러분만의 에러 디펜스를 클리어하는 데 작은 도움이 되기를 바랍니다.

에러 타워 디펜스, CLEAR! 🎉