시작은 중복 제거였다

서비스 레이어 코드를 보면 반복되는 패턴이 있었다.

async function runEditProfileProcess() {
  try {
    const response = await User.editProfile(formData);
    const responseData = await Response.getServerResponseData(response);
    Display.displayUserProfile(responseData);
  } catch (err) {
    handleServerException(err);
  }
}

async function runChangePasswordProcess() {
  try {
    const response = await User.changePassword(formData);
    await Response.getServerResponseData(response);
    MessageUtil.alertMessage(...);
  } catch (err) {
    handleServerException(err);
  }
}

try → API 호출 → getServerResponseData → catch handleServerException. 파일 전반에 걸쳐 이 구조가 반복됐다. 중복을 제거하고 싶었고, invoke라는 추상화가 자연스러운 해결책처럼 보였다.

// request-handler.js
export async function invoke(requestFn) {
  try {
    return await requestFn();
  } catch (err) {
    handleServerException(err);
  }
}

그리고 도메인 레이어도 getServerResponseData까지 포함하도록 변경했다.

// 변경 후 도메인
async function getUserInfo() {
  const headers = await createAuthHeaders();
  const response = await apiRequest(...);
  return getServerResponseData(response); // 파싱까지 담당
}

// 변경 후 서비스
const userInfo = await invoke(() => User.getUserInfo());
Display.displayUserProfile(userInfo);

코드가 간결해졌다. 그런데 문제가 생겼다.


놓친 것: 흐름 제어

invoke는 예외를 handleServerException으로 처리하고 undefined를 반환한다. 호출부는 예외가 발생했는지 알 수 없으니 이후 로직이 그대로 실행된다.

async function runChangePasswordProcess() {
  await invoke(() => User.changePassword(formData));
  // 예외가 발생해도 여기가 실행된다
  MessageUtil.alertMessage('변경 완료');
  modal.hide();
}

600/700 에러 코드는 페이지 이동 없이 메시지만 표시하기 때문에, 실제로 에러가 발생했음에도 성공 메시지가 뜨고 모달이 닫히는 상황이 생긴다. 이걸 해결하려고 여러 방법을 시도했다.

시도 1: sentinel 값으로 체크

const responseData = await invoke(() => User.changePassword(formData));
if (!responseData) return;
MessageUtil.alertMessage(...);

반환값이 없는 함수에서 "반환값이 있어야 정상"이라는 어색한 뉘앙스가 생긴다.

시도 2: invoke에서 예외를 다시 throw

export async function invoke(requestFn) {
  try {
    return await requestFn();
  } catch (err) {
    handleServerException(err);
    throw err; // 다시 던지기
  }
}

그러면 호출부에서 catch가 다시 필요해지고, invoke로 추상화한 의미가 사라진다.

시도 3: unhandledrejection 전역 억제

임시 처방에 불과하고, 진짜 처리해야 할 예외까지 묻어버릴 위험이 있다.

어떤 방법을 써도 깔끔하게 해결되지 않았다.


왜 해결이 안 됐나

근본 원인은 "예외 처리"와 "흐름 제어"를 하나의 추상화로 묶으려 했기 때문이다. 원래 try-catch가 하는 일은 두 가지다.

  1. 예외를 어떻게 처리할 것인가 → handleServerException
  2. 예외 발생 시 이후 로직을 멈출 것인가 → catch 블록으로 자연스럽게 차단

invoke는 1번은 처리하지만 2번을 처리하지 못한다. 중복처럼 보였던 try-catch가 사실은 흐름 제어라는 중요한 역할을 하고 있었던 것이다.


되돌아보면

이번 리팩토링을 돌아보면 유효한 변경과 잘못된 변경이 섞여 있었다.

유효했던 변경: 도메인 레이어에 getServerResponseData 통합

원래 구조에서 호출부는 Response 객체를 받아서 직접 파싱해야 했다.

const response = await User.getUserInfo();
const userInfo = await Response.getServerResponseData(response); // 매번 필요

도메인 레이어가 파싱까지 담당하면 호출부가 내부 구현(Response 객체)을 알 필요가 없어진다. getUserInfo()를 호출하면 파싱된 데이터가 오는 게 자연스러운 인터페이스다. 이 변경은 유지한다.

잘못된 변경: invoke 추상화

try-catch 반복을 없애려다 흐름 제어를 잃었다. invokerequest-handler.js는 되돌린다.


최종 구조

// 도메인 — 파싱까지 담당, 실패 시 ResponseError throw
async function getUserInfo() {
  const headers = await createAuthHeaders();
  const response = await apiRequest(...);
  return getServerResponseData(response);
}

async function changePassword(data) {
  const headers = await createAuthHeaders();
  const response = await apiRequest(...);
  return getServerResponseData(response);
}

// 서비스 — 흐름 제어 + 예외 처리
async function runChangePasswordProcess() {
  try {
    await User.changePassword(formData);
    MessageUtil.alertMessage('변경 완료'); // 예외 발생 시 실행되지 않음
    modal.hide();
  } catch (err) {
    handleServerException(err);
  }
}

try-catch가 반복되는 건 여전하다. 하지만 이 반복은 중복이 아니라 각 함수의 흐름을 제어하는 필요한 코드다.


배운 것

중복처럼 보이는 코드가 항상 중복은 아니다

코드의 형태가 같다고 해서 역할도 같은 건 아니다. try-catch 패턴이 반복됐지만, 각 함수마다 예외 발생 후 흐름을 제어하는 독립적인 역할을 하고 있었다.

추상화 전에 "무엇을 제거하려 하는가"를 더 깊이 봐야 한다

단순히 형태의 중복을 제거하는 게 목적이었는데, 그 형태 안에 담긴 역할을 제거해버렸다. 추상화는 코드의 형태가 아니라 역할을 기준으로 판단해야 한다.

좋은 설계는 문제가 생겼을 때 드러난다

원래 구조가 왜 그렇게 설계됐는지는 리팩토링을 시도하고 문제를 만나면서 비로소 명확해졌다. 이번 시도가 실패했지만, 덕분에 원래 설계의 이유를 더 깊이 이해하게 됐다.