Sangcho.log
dev

API 에러를 도메인별로 쪼개기 — 상속 계층이 만드는 다섯 개의 함정

17 min read|

프론트엔드에서 API 에러를 다루는 가장 흔한 출발점은 "모든 실패를 하나의 에러 클래스로 감싸기"입니다. HTTP 클라이언트의 모든 reject를 CommonError로 wrap하면 호출부에서 error instanceof CommonError로 안전하게 좁힐 수 있고, as ErrorResponse 같은 duck-typing 캐스팅이 사라집니다. 저희도 그렇게 시작했습니다.

문제는 그 다음입니다. 에러가 한 종류면 "이 에러가 어느 도메인에서 왔는가" 를 알 수 없습니다. 폴더 API가 던지는 S4221(폴더 없음)과 프로젝트 API가 던지는 S4022(접근 권한 없음)이 똑같이 CommonError로 도착하니, 호출부는 error.code 문자열을 직접 비교하는 수밖에 없습니다. 코드 문자열은 타입이 아니라 관습이고, 어느 API가 어떤 코드를 던지는지는 아무 곳에도 적혀 있지 않습니다.

이 글은 11개 API 도메인에 각각의 에러 클래스를 도입하면서 만난 함정들 — 그중 셋은 컴파일도 통과하고 런타임 에러도 없이 조용히 잘못 동작하는 종류였습니다 — 을 정리한 기록입니다.

설계 — 마커 클래스 + 래퍼 + 순수 resolver

구조는 세 겹입니다.

① 에러 클래스 계층 (프레임워크 무의존 패키지)
   CommonError ─ DomainError ─┬─ FolderError
                              ├─ StudioError
                              ├─ SpaceMemberError
                              └─ … 11개

② 도메인 소유 부여 (fetcher 래핑)
   runFolderApi(promise)  →  reject를 FolderError로 재-throw

③ 처리 서술 → 발화 분리
   resolveFolderErrorAction(code)  : 순수 함수, "무엇을 할지"만 서술
   useDomainErrorAlert(클래스, resolver) : 실제 alert/toast/공통위임 발화

DomainError는 필드가 하나도 없는 마커입니다. code/message/status는 전부 CommonError에서 상속하고, 존재 이유는 단 하나 — 호출부에서 instanceof FolderError로 도메인을 구분하는 것.

fetcher에 소유를 부여하는 래퍼는 세 줄입니다.

export const runDomainApi = <E extends DomainError, T>(
  ErrorClass: DomainErrorClass<E>,
  p: Promise<T>
): Promise<T> =>
  p.catch((e) => {
    throw toDomainError(ErrorClass, e);
  });

// 도메인별 특화는 한 줄
export const runFolderApi = <T>(p: Promise<T>): Promise<T> => runDomainApi(FolderError, p);

호출부는 그대로 두고 fetcher만 감싸면 됩니다.

const { data } = await runFolderApi(new StudioApi().getMeta(spaceSeq, folderSeq, REQUEST));

처리 정책은 순수 함수로 서술하고, 발화는 훅이 담당합니다. 이 분리 덕에 정책은 React 없이 단위 테스트할 수 있습니다.

export type DomainErrorAction =
  | { kind: 'override' }                                        // 호출부가 주입한 처리
  | { kind: 'alert'; titleKey?: string; messageKey: string }     // 도메인 특화 알럿
  | { kind: 'toast'; messageKey: string }
  | { kind: 'common' };                                          // 공통 핸들러로 위임

export const resolveFolderErrorAction = (code: string, hasOverride: boolean): DomainErrorAction => {
  if (hasOverride) return { kind: 'override' };
  switch (code) {
    case FOLDER_ERROR_CODE.MAX_COUNT_EXCEEDED:  // S4223
      return { kind: 'alert', messageKey: 'ws_folder_error_max_count' };
    case FOLDER_ERROR_CODE.CYCLIC_REFERENCE:    // S4226
      return { kind: 'alert', messageKey: 'ws_folder_error_cyclic_move' };
    case FOLDER_ERROR_CODE.HAS_LOCKED_PROJECT:  // S4227
      return { kind: 'toast', messageKey: 'error_workspaceproject_deleteerror' };
    default:
      return { kind: 'common' };
  }
};

여기까지는 깔끔합니다. 함정은 전부 그 다음에 나왔습니다.

함정 1 — 서브클래스의 instanceof가 조용히 false가 된다

CommonError는 ES5 트랜스파일 환경에서 프로토타입 체인이 끊기는 걸 막으려고 생성자 끝에서 프로토타입을 고정하고 있었습니다.

class CommonError extends Error {
  constructor(payload) {
    super(payload.message ?? '');
    Object.setPrototypeOf(this, CommonError.prototype); // ← 이게 문제
  }
}

extends Error의 고전적인 함정을 막는 관용구인데, 서브클래스를 만드는 순간 이게 정확히 반대로 작동합니다. new FolderError()를 해도 부모 생성자가 프로토타입을 CommonError.prototype으로 되돌려 버리니 instanceof FolderErrorfalse가 됩니다. 타입 에러도, 런타임 에러도 없습니다. 그냥 도메인 분기가 전부 안 타는 상태가 됩니다.

해결은 new.target — 실제로 new된 클래스를 가리키는 값입니다.

export class DomainError extends CommonError {
  constructor(payload: CommonErrorPayload = {}) {
    super(payload);
    Object.setPrototypeOf(this, new.target.prototype); // 실제 생성 클래스로 재설정
  }
}

// 덕분에 파생 클래스는 생성자 없이 한 줄로 끝난다
export class FolderError extends DomainError {}

DomainError가 한 번 new.target으로 처리해 두면 아래 11개 파생 클래스는 본문이 비어도 instanceof가 성립합니다. 이건 테스트로 못을 박아야 하는 종류의 동작입니다.

it('instanceof 체인: FolderError ⊂ DomainError ⊂ CommonError', () => {
  const e = new FolderError({ code: 'S4221', status: '404' });
  expect(e).toBeInstanceOf(FolderError);
  expect(e).toBeInstanceOf(DomainError);
  expect(e).toBeInstanceOf(CommonError);
  expect(e.code).toBe('S4221'); // payload 상속 확인
});

함정 2 — "공통 먼저, 아니면 도메인"을 instanceof로 못 만든다

가장 자연스러워 보이는 라우팅 코드입니다.

// WRONG: else가 영원히 죽어 있다
if (error instanceof CommonError) {
  handleCommonError(error);
} else if (error instanceof StudioError) {
  // 절대 도달하지 않음
}

StudioError extends DomainError extends CommonError이므로 도메인 에러도 첫 조건에서 true입니다. 상속 계층을 만든 대가로, 클래스로는 "공통이냐 도메인이냐"를 가를 수 없게 됩니다. 애초에 그 질문의 축이 클래스가 아니기 때문입니다. 도메인은 "어느 API가 던졌나"이고, 공통이냐 아니냐는 "공통 핸들러가 이 코드를 아는가"입니다.

그래서 판정 기준을 클래스가 아니라 공통 핸들러의 반환값으로 옮겼습니다.

// handleCommonError: 아는 코드면 처리하고 true, 모르는 코드면 false
const delegateToCommon = () => {
  if (error instanceof CommonError && handleCommonError(error)) return;
  callToast({ type: 'danger', content: tKey(options?.fallbackKey ?? 'error_workspaceunknownerror') });
};

if (!(error instanceof ErrorClass)) return delegateToCommon(); // 남의 도메인/일반 에러
const action = resolve(error.code ?? '', Boolean(overrides?.[error.code ?? '']));

부수 효과로 handleCommonErrorboolean 반환이 계약이 됩니다. 호출부 전체가 이 short-circuit에 의존합니다.

// 이 컨벤션이 전 호출부 표준 — void로 바꾸면 미처리 에러가 조용히 사라진다
if (error instanceof CommonError && handleCommonError(error)) return;

실제로 이 계약을 어긴 코드가 있었습니다. 다운로드 훅이 if (error instanceof CommonError) { handleCommonError(error); return; } — 무조건 return — 이라, 공통 핸들러가 못 잡은 코드일 때 뒤에 있던 "다운로드 실패" 알럿이 스킵되어 조용히 종료됐습니다. 사용자에겐 "눌렀는데 아무 일도 안 일어남"입니다.

함정 3 — 로컬 소유라고 글로벌에서 빼면 에러가 사라진다

이 프로젝트의 쿼리 에러는 전역 파이프라인을 타고 있었습니다.

fetch !ok → CommonError → QueryCache.onError → setGlobalError
              → ErrorWatcher(전역 1곳) → alert/toast/로그인 리다이렉트

도메인 에러를 도입하며 첫 커밋에 넣은 판단은 이랬습니다 — "도메인 에러는 로컬 핸들러가 소유하니까, 글로벌 자동 알럿에서 제외해야 이중 알럿이 안 난다." 논리적으로 맞아 보였고, 실제로 이중 알럿도 막았습니다.

리뷰에서 잡힌 건 반대편이었습니다. 경계가 없는 소비처가 있었습니다. 검색 모달은 useQuery로 검색 결과를 받는데 로컬 에러 핸들러도, ErrorBoundary도 없습니다. 검색 fetcher를 runFolderApi로 감싸는 순간 이 쿼리의 인증 만료·서버 오류가 글로벌에서도 제외되고 로컬에도 주인이 없어 통째로 삼켜집니다. 화면은 영원히 "결과 없음" 또는 로딩 상태로 고착됩니다. 에러 로그도, 알럿도, 리다이렉트도 없습니다.

첫 수정은 국소적이었습니다 — 검색 쿼리만 도메인 래핑에서 빼기. 하지만 그건 증상 대응입니다. 같은 형태의 소비처가 언제든 또 생기고, 그때마다 "이 쿼리는 경계가 있나?"를 사람이 기억해야 합니다. 그래서 정책 자체를 뒤집었습니다.

queryCache: new QueryCache({
  onError: (error, query) => {
    // 쿼리 에러는 도메인 에러 포함 모두 글로벌(ErrorWatcher)로 위임한다.
    // 로컬 ErrorBoundary가 특정 코드(S4221 등)를 처리해도, 글로벌 handleCommonError는
    // 그 코드를 매칭하지 않아 이중 알럿이 나지 않는다.
    if (error instanceof CommonError) setGlobalError(error);
  },
}),

핵심은 이중 알럿 우려가 애초에 과장이었다는 점입니다. 글로벌 핸들러는 자기가 아는 공통 코드만 처리하고 모르는 코드엔 false를 반환합니다(함정 2). 도메인 특화 코드는 글로벌이 손대지 않으니 로컬과 겹치지 않고, 반대로 도메인 fetcher에서 튀어나온 인증 에러는 글로벌이 확실히 받습니다.

정리하면 — 도메인 클래스의 역할은 호출부 instanceof 분기용으로 한정하고, 글로벌 파이프라인의 커버리지는 건드리지 않는 것. 에러 처리에서 "제외"는 언제나 silent-fail 쪽으로 기울고, 조용한 실패는 이중 알럿보다 비쌉니다.

함정 4 — 에러 코드의 소속은 화면이 아니라 래퍼가 정한다

폴더 삭제가 실패하는 코드 S4227(편집 중인 프로젝트를 포함한 폴더는 삭제 불가)를 처음엔 resolveStudioErrorAction에 넣었습니다. 프로젝트(studio) 얘기니까요. 결과는 아무 토스트도 뜨지 않음이었습니다.

폴더 삭제 API는 runFolderApi로 감싸여 있으니 던지는 건 FolderError입니다. useStudioErrorAlertinstanceof StudioError만 매칭하고, 형제 서브클래스는 서로 배타적입니다(FolderError instanceof StudioErrorfalse). 그 case는 컴파일도 되고 린트도 통과하는 완벽한 dead code였습니다.

판정 규칙은 하나입니다.

판정 대상 잘못된 기준 올바른 기준
이 코드가 어느 resolver에 속하나 화면·기능의 의미상 소속 그 API가 어느 runXxxApi로 감싸여 있는가

에러 코드를 추가할 때 여는 파일은 화면 컴포넌트가 아니라 fetcher입니다.

함정 5 — 권한 에러를 도메인에서 가로채면 리다이렉트가 사라진다

S5080(스페이스 접근 권한 없음) 같은 인증·권한 코드는 공통 핸들러가 알럿을 띄우고 워크스페이스로 리다이렉트합니다. 이걸 도메인 resolver가 "친절한 토스트"로 처리하면 리다이렉트가 조용히 없어집니다. 사용자는 접근 권한 없는 화면에 그대로 남습니다.

그래서 권한류 코드는 resolver에 넣지 않고 default: { kind: 'common' }으로 흘립니다. 도메인 resolver는 "공통이 모르는 코드"만 다룹니다.

부수로 알게 된 사실 하나 — 기존 호출부 switch에 있던 권한 코드 case들은 이미 그 앞의 handleCommonError가 먼저 가로채고 있어서 애초에 도달하지 않는 코드였습니다. 삭제가 정답이었습니다.

선언적 resolver의 한계와 탈출구 두 개

{ kind: 'alert', messageKey }는 정적 t(key)만 담습니다. 런타임 변수를 끼우는 메시지(t.rich), 알럿 스타일 분기, 호출 문맥에 따라 달라지는 처리는 표현할 수 없습니다. 여기서 억지로 스키마를 늘리면 resolver가 미니 DSL이 됩니다. 탈출구를 두 개 두는 편이 낫습니다.

handleFolderError(error, {
  // ① 코드별 콜백 주입 — 문맥 의존 처리
  overrides: {
    [FOLDER_ERROR_CODE.NOT_FOUND]: () =>
      openAlert({ /* … */ onClickClose: () => router.replace(ROOT_PROJECT_PATH) }),
  },
  // ② 아무도 못 잡은 에러의 호출부별 fallback 메시지
  fallbackKey: 'ws_workspaceall_videomore_delete_fail',
});

overrides가 있으면 resolver가 { kind: 'override' }를 먼저 반환하므로, 같은 코드라도 호출 위치에 따라 다르게 처리됩니다. 반대로 제네릭 팩토리에 억지로 태우면 안 되는 경우도 있습니다 — 제어 흐름용 센티넬을 재-throw해야 하는 훅은 팩토리를 쓰지 말고 CommonError 분기만 수동 처리하고 나머지는 그대로 다시 던지는 게 맞습니다.

곁가지: 잘못된 폴더로 진입했을 때

이 리팩터링의 실제 발단은 버그였습니다. 삭제된 폴더나 다른 스페이스 소속 폴더의 URL(/project/[folderSeq])로 진입하면 목록 API가 200 + 빈 배열을 반환합니다. react-query 입장에선 성공이니 "폴더가 비어 있어요, 프로젝트를 만들어 보세요" CTA가 뜹니다. 접근 권한도 없는 폴더에서요.

도메인 에러가 갖춰지자 해법이 단순해졌습니다. 단건 조회(getMeta)로 폴더 존재를 검증하는 렌더 없는 게이트를 하나 두고, 실패(S4221)를 상위 ErrorBoundary가 받게 하는 것입니다.

const FolderAccessGuard = () => {
  const { folderSeq } = useParams<{ folderSeq?: string }>();
  const seq = parseFolderSeq(folderSeq);
  return seq == null ? null : <FolderMetaProbe folderSeq={seq} />;
};

// useSuspenseQuery는 조건부 호출 불가 → seq 확정 시에만 마운트되는 자식에서 호출
const FolderMetaProbe = ({ folderSeq }: { folderSeq: number }) => {
  useFolderMetaQuery(folderSeq);
  return null;
};

여기서 마지막 함정이 하나 더 나왔습니다. 폴백 컴포넌트가 useEffect에서 알럿을 띄우는데, 알럿 스토어 갱신 → 폴백 재렌더 → 알럿 재발화가 물려 Maximum update depth exceeded가 났습니다. 폴백은 자기가 몇 번 렌더될지 모르니 발화를 한 번으로 못 박는 가드가 필요합니다.

const firedRef = useRef(false);
useEffect(() => {
  if (firedRef.current) return;
  firedRef.current = true;
  handleFolderError(error, { overrides: { /* NOT_FOUND → 알럿 후 루트 이동 */ } });
}, [error, handleFolderError, /* … */]);

서버 쪽에선 같은 getMeta를 prefetch해 둡니다. 유효한 폴더면 hydration에서 캐시가 맞아 네트워크 왕복이 없고, 무효한 폴더면 클라이언트에서 곧바로 S4221이 던져집니다. 검증 게이트를 넣고도 정상 경로의 비용은 0입니다.

정리 — 도메인 에러를 추가할 때의 체크리스트

72개 파일, +1393/-844 규모였지만 실제로 어려웠던 건 코드량이 아니라 조용히 틀리는 다섯 지점이었습니다.

  • instanceof가 죽지 않았는지 테스트로 못 박기extends Error 계층의 프로토타입 고정은 서브클래스를 배신합니다.
  • 공통↔도메인 라우팅은 클래스가 아니라 핸들러의 boolean으로 — 상속 계층이 있으면 instanceof로 상하위를 가를 수 없습니다.
  • "로컬 소유니까 글로벌에서 제외"를 의심하기 — 경계 없는 소비처에서 에러가 통째로 삼켜집니다. 이중 알럿보다 silent fail이 비쌉니다.
  • 에러 코드의 소속은 runXxxApi 감싸임으로 판정 — 화면 의미로 넣으면 dead code가 되고, 아무 신호도 나지 않습니다.
  • 권한·인증 코드는 도메인에서 가로채지 말고 공통으로 흘리기 — 리다이렉트가 사라집니다.
  • 선언적 resolver의 한계는 overrides/fallbackKey로 빼고, 억지로 스키마를 늘리지 않기.

세 함정(1·4·5)의 공통점은 실패가 무음이라는 것입니다. 타입 체커도 린터도 통과하고, 예외도 안 납니다. 그래서 에러 처리 리팩터링에서 가장 값싼 투자는 "이 분기가 실제로 도달하는가"를 확인하는 순수 함수 단위 테스트였습니다. 정책을 resolveXxxErrorAction이라는 순수 함수로 떼어 놓은 이유가 결국 그것이었습니다.