시작: 어색함을 느끼는 것에서 시작했다

리팩토링을 하다 보면 "이게 맞나?" 싶은 감각이 먼저 올 때가 있다. 코드가 동작은 하지만, 구조적으로 뭔가 어색한 느낌. 이번 고민도 그렇게 시작됐다.

common.js라는 파일 안에 이런 함수가 있었다.

async function runGetUserInfoProcess() {
  try {
    const response = await User.getUserInfo();
    return await Response.getServerResponseData(response);
  } catch (err) {
    handleServerException(err);
  }
}

"공용 기능을 제공하는 모듈"이라고 명시한 파일 안에, 회원 정보를 조회하는 로직이 들어있는 게 어색하다는 생각이 들었다. 그런데 막상 옮기려고 하니 어디로 옮겨야 할지가 명확하지 않았다.


이 함수, 어디 있어야 하지?

domain/user.js는?

user.js를 열어보니 완전히 순수한 API 호출 레이어였다.

async function getUserInfo() {
  const headers = await createAuthHeaders();
  return await apiRequest(Config.backendServerPath('/user'), 'GET', headers);
}

fetch를 실행하고 Response 객체를 반환하는 것이 전부였다. ResponsehandleServerException 같은 것들을 전혀 모르는 레이어였다. 여기에 파싱과 예외 처리를 추가하면 레이어가 올라가는 셈이라 어울리지 않았다.

그냥 common.js에 두되 export만 제거하면?

그런데 실제 사용처를 확인해보니 runGetUserInfoProcess가 외부에서도 직접 호출되고 있었다.

// profile.js
const userInfo = await Common.runGetUserInfoProcess();
setSelectedCountry('profile-edit-country', userInfo.countryCode);

export를 제거하는 것도 답이 아니었다. 이 함수는 진짜로 공용이었던 것이다. 다만 이름이 어색했을 뿐이었다. run~Process라는 네이밍이 "어떤 플로우의 한 단계"처럼 읽혀서 어색하게 느껴진 것이지, 위치 자체가 틀린 건 아니었다.


더 근본적인 문제: 패턴의 반복

profile.js를 보니 동일한 패턴이 계속 반복되고 있었다.

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);
  } catch (err) {
    handleServerException(err);
  }
}

try → API 호출 → getServerResponseData → catch handleServerException. 파일 전반에 걸쳐 이 구조가 반복되고 있었다. 추상화할 여지가 보이는 지점이었다.


executeRequest 추상화

export async function executeRequest(requestFn) {
  try {
    const response = await requestFn();
    return await Response.getServerResponseData(response);
  } catch (err) {
    handleServerException(err);
  }
}

이 추상화가 있으면 호출부는 이렇게 단순해진다.

const userInfo = await executeRequest(() => User.getUserInfo());
const responseData = await executeRequest(() => User.editProfile(formData));

추상화할 때 주의할 점

이런 추상화를 했다가 되돌리게 되는 경우는 보통 세 가지다.

1. 에러 처리를 다르게 해야 하는 케이스가 생길 때

// 이런 케이스가 생기면 executeRequest를 못 쓰게 된다
catch (err) {
  if (err.status === 401) {
    showLoginError(); // 특수 처리
  } else {
    handleServerException(err);
  }
}

2. 추상화에 옵션이 추가되기 시작할 때

// 이런 모양이 나오기 시작하면 위험 신호
executeRequest(() => User.login(data), {
  onError: (err) => { /* ... */ },
  skipGlobalHandler: true
})

이렇게 되면 추상화가 복잡해지고, 결국 "그냥 직접 쓰는 게 낫겠다"가 된다.

3. 혼용

추상화를 쓰는 코드와 쓰지 않는 코드가 섞이면 더 나쁘다. "이 코드는 왜 executeRequest를 안 쓰지?"라는 의문이 생기고, 의도적인 차이인지 빠뜨린 건지 알 수 없게 된다.

💡 일관성이 추상화 자체보다 더 중요하다. 혼용하면 차라리 추상화 안 하는 게 낫다.


예외를 내부에서 처리할 것인가, 호출부로 던질 것인가

추상화 방식을 정하면서 중요한 설계 결정이 하나 있었다. 예외를 executeRequest 내부에서 처리할 것인가, 아니면 호출부로 던질 것인가.

호출부로 던지는 구조는 설계적으로 더 올바른 방향이다. executeRequest가 "요청 실행과 응답 파싱"이라는 하나의 책임만 갖게 되고, 에러를 어떻게 처리할지는 호출부가 결정하는 구조가 된다.

그런데 현재 코드베이스에서 handleServerException이 어떻게 생겼는지를 보면 이야기가 달라진다.

async function handleServerException(err) {
  switch (err.code) {
    case 401:
      await Message.alertMessage(...);
      location.href = Path.login; // 페이지 이동
      break;
    case 500:
      await Message.alertMessage(...);
      location.href = Path.error500; // 페이지 이동
      break;
    case 600:
      await Message.alertMessage(err.responseData.message, ...);
      break;
  }
}

에러 코드별로 메시지 표시와 리다이렉트까지 전부 처리하는 구조다. 401이면 로그인 페이지로, 500이면 에러 페이지로 이동해버린다. 호출부가 받아도 추가로 할 수 있는 일이 없다.

💡 예외를 호출부로 던지는 것이 의미 있으려면, 호출부가 예외 상황을 알고 추가로 처리해야 할 일이 있어야 한다.

지금 설계의 의도는 명확하다. "예외는 handleServerException이 전부 책임진다. 호출부는 신경 쓰지 않아도 된다." 그러면 executeRequest 내부에서 처리하는 것이 이 설계 의도와 완전히 일치한다.

나중에 특정 케이스에서 에러 이후 추가 작업이 필요해지는 시점이, 그 케이스만 executeRequest 밖으로 꺼내는 신호로 보면 된다.


executeRequest, 어디에 위치해야 하나

추상화 자체는 결론이 났는데, 이 함수를 어느 파일에 넣어야 할지가 남았다.

  • common.js — 페이지 초기화 흐름을 담당하는 파일이라 성격이 다르다.
  • api-request.js — 순수하게 fetch 실행과 헤더 생성만 담당하는 레이어다. 여기에 ResponsehandleServerException 의존성을 추가하면 레이어가 오염된다.
  • response.jsgetServerResponseData만 있는 순수한 파싱 레이어다. 여기에 handleServerException 의존성을 추가하면 마찬가지로 순수함이 깨진다.

executeRequest가 하는 일을 보면 여러 레이어를 조합하는 함수라는 걸 알 수 있다.

  • api-request.js — 요청 실행
  • response.js — 응답 파싱
  • exception-handler.js — 예외 처리

어느 한 레이어 안에 넣으면 그 레이어가 오염되는 구조다. 자연스러운 결론은 별도의 조합 파일이다.

util/api-executor.js

각 레이어의 순수함을 유지하면서, 조합 책임만 따로 가져가는 파일이다.


돌아보며: 분리가 과한 걸까?

이 고민을 하면서 "내가 너무 잘게 쪼개고 있는 건 아닐까?"라는 생각도 들었다. 파일 구조를 보면 이렇다.

  • api-request.js — fetch 실행, 헤더 생성
  • response.js — 응답 파싱
  • exception-handler.js — 예외 처리
  • common.js — 페이지 초기화
  • domain/user.js — 유저 API 호출

과도한 분리는 "하나의 작업을 하기 위해 파일을 5개씩 열어봐야 하거나, 파일 하나가 너무 얇아서 존재 이유가 불명확할 때"다. 지금 각 파일은 그 기준에 해당하지 않는다.

오히려 executeRequest를 어디에 넣어야 할지 고민이 생긴 것 자체가, 역설적으로 설계가 잘 된 덕분이다. 기존 파일들이 각자 명확한 책임을 갖고 있어서 "여기에 넣기엔 어색하다"는 판단이 가능했던 것이니까.


정리

이번 고민을 통해 정리된 기준들을 표로 정리했다.

주제 판단 기준
추상화 여부 동일한 패턴이 반복된다면 추상화를 검토한다. 단, 혼용하면 차라리 안 하는 게 낫다.
추상화 해체 신호 에러 처리를 케이스별로 달리해야 할 때, 옵션이 늘어나기 시작할 때.
예외 처리 위치 호출부가 예외 상황에서 추가로 할 일이 있으면 던지기. 없으면 내부 처리.
파일 위치 여러 레이어를 조합하는 함수는 기존 레이어 어디에도 넣지 않는다. 조합 전용 파일을 만든다.