시작은 중복 제거였다
서비스 레이어 코드를 보면 반복되는 패턴이 있었다.
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가 하는 일은 두 가지다.
- 예외를 어떻게 처리할 것인가 →
handleServerException - 예외 발생 시 이후 로직을 멈출 것인가 → catch 블록으로 자연스럽게 차단
invoke는 1번은 처리하지만 2번을 처리하지 못한다. 중복처럼 보였던 try-catch가 사실은 흐름 제어라는 중요한 역할을 하고 있었던 것이다.
되돌아보면
이번 리팩토링을 돌아보면 유효한 변경과 잘못된 변경이 섞여 있었다.
유효했던 변경: 도메인 레이어에 getServerResponseData 통합
원래 구조에서 호출부는 Response 객체를 받아서 직접 파싱해야 했다.
const response = await User.getUserInfo();
const userInfo = await Response.getServerResponseData(response); // 매번 필요
도메인 레이어가 파싱까지 담당하면 호출부가 내부 구현(Response 객체)을 알 필요가 없어진다. getUserInfo()를 호출하면 파싱된 데이터가 오는 게 자연스러운 인터페이스다. 이 변경은 유지한다.
잘못된 변경: invoke 추상화
try-catch 반복을 없애려다 흐름 제어를 잃었다. invoke와 request-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 패턴이 반복됐지만, 각 함수마다 예외 발생 후 흐름을 제어하는 독립적인 역할을 하고 있었다.
추상화 전에 "무엇을 제거하려 하는가"를 더 깊이 봐야 한다
단순히 형태의 중복을 제거하는 게 목적이었는데, 그 형태 안에 담긴 역할을 제거해버렸다. 추상화는 코드의 형태가 아니라 역할을 기준으로 판단해야 한다.
좋은 설계는 문제가 생겼을 때 드러난다
원래 구조가 왜 그렇게 설계됐는지는 리팩토링을 시도하고 문제를 만나면서 비로소 명확해졌다. 이번 시도가 실패했지만, 덕분에 원래 설계의 이유를 더 깊이 이해하게 됐다.