
다른 회사가 운영하던 서비스를 우리 모노레포로 넘겨받았다. 코드는 멀쩡히 돌아가고 있었고, 사용자도 쓰고 있었다. 넘겨받은 쪽 입장에서 제일 먼저 하는 일은 "이 코드가 뭘 하는지" 가 아니라 "이 코드를 어떻게 안전하게 고칠 수 있는지" 를 아는 것이다.
API 요청부터 봤다. 네 갈래였다.
commons/api/api.ts apiGet · apiPost · apiPatch · apiDelete (axios)
services/api-get.ts apiGet (fetch, 서버 전용)
api.post(...) 직접 호출 3곳
fetch(...) 직접 호출 3곳
새 화면을 만들려는 사람이 "요청은 뭘로 하지?"를 매번 고민해야 하는 상태다. 그 자체도 문제지만, 진짜 위험한 건 따로 있었다.
apiGet 이 두 개였고, 실패했을 때 동작이 달랐다.

한쪽은 서버 오류에 throw 하고, 한쪽은 null 을 돌려준다. 그리고 이름이 같아서 호출부만 봐서는 어느 쪽인지 알 수 없다. import 한 줄에 갈린다.
import { apiGet } from '@/commons/api/api'; // 던진다
import { apiGet } from '@/services/api-get'; // null 이다
null 쪽이 더 무섭다. 서버가 500 을 내도 화면에는 "데이터 없음" 으로 보인다. 장애가 빈 화면으로 위장된다. 목록이 비었을 때와 서버가 죽었을 때를 사용자도 개발자도 구분할 수 없다.
실패를 던지느냐 삼키느냐는 취향 차이일 수 있다. 하지만 같은 이름으로 둘 다 있는 것은 취향이 아니라 함정이다.
넘겨받은 코드를 정리할 때 제일 쉬운 실수는, 정리하면서 동작도 같이 "개선"하는 것이다. 그러면 나중에 문제가 생겼을 때 구조를 바꿔서 깨진 건지 동작을 바꿔서 깨진 건지 알 수 없다.
그래서 이 PR 의 목표를 하나로 못 박았다. 화면에 보이는 동작은 바뀌지 않는다.
| 전 | 후 |
|---|---|
apiGet 두 개 (axios / 서버 fetch) | getAPI 하나 — 서버에서는 fetch, 브라우저에서는 axios |
api.post(...) 직접 호출 3곳 | postAPI(...) |
axios 인스턴스 api | apiClient |
apiGet·apiPost·apiPatch·apiDelete | getAPI·postAPI·patchAPI·deleteAPI + putAPI |
null 을 돌려주던 서버 조회 네 곳(커리큘럼·FAQ·홈 영상·프로모션)은 이렇게 바꿨다.
// 전: 함수가 알아서 null 을 돌려줬다 — 호출부는 그 사실을 모른다
const faq = await apiGet('/faq');
// 후: 실패를 삼키는 결정이 호출부에 보인다
const faq = await getAPI('/faq', { server: { next: { revalidate: 3600 } } })
.catch(() => null);
동작은 똑같다. 달라진 건 "여기서 실패를 무시하기로 했다"가 코드에 적혀 있다는 것이다. 숨어 있던 정책이 호출부로 올라왔다.
apiGet → getAPI. 별 차이 없어 보이지만 이유가 있다.
우리 모노레포의 다른 브랜드 웹이 이미 getAPI·postAPI 를 쓰고 있었다. 두 앱을 오가는 사람이 같은 일을 하는 함수를 두 이름으로 기억할 이유가 없다. 이관 작업에서 이름을 맞추는 건 공짜에 가까운데, 안 맞추면 영원히 두 벌을 기억해야 한다.
그리고 이름을 바꾸면 옛 이름이 남아 있는 곳이 컴파일 에러로 전부 드러난다. 같은 이름으로 내용만 바꾸면 조용히 섞인 채로 남는다. 이관에서는 이게 오히려 장점이다.
세 군데는 일부러 그대로 뒀다.
전부 fetch 직접 호출이고, 전부 다른 서버와 이야기하는 코드다. 그리고 전부 컷오버(서비스 전환일) 전까지 건드리면 안 되는 경로다.
이관 작업에서 "일관성"은 좋은 목표지만, 컷오버 당일에 터질 확률을 0.1%라도 올리는 일관성이라면 미루는 게 맞다. 돈이 오가는 경로는 특히 그렇다.
대신 CLAUDE.md(이 저장소에서 사람과 AI 에이전트가 함께 읽는 규칙 파일)에 한 줄 적었다.
요청은 이 다섯 함수로만 한다.
이게 없으면 다음 사람이 또 여섯 번째 갈래를 만든다. 코드를 정리하는 것과 다시 흩어지지 않게 하는 것은 다른 작업이다.
원래는 같이 하려고 했다. 요청 함수를 모으는 김에 React Query 까지 붙이면 PR 하나로 끝난다.
안 했다. 두 가지가 섞이기 때문이다.
섞으면 리뷰어가 "이 줄은 바뀌어도 되는 건가"를 매번 판단해야 하고, 나중에 회귀가 나면 어느 쪽인지 모른다. 그래서 먼저 머지하고, React Query 는 그 위에 올렸다.
그 후속 PR 에서 실제로 숨어 있던 버그가 하나 잡혔다. useEffect 안에서 직접 조회하던 코드를 쿼리 훅으로 바꿨더니, 자녀를 빠르게 전환할 때 앞 자녀의 늦게 온 응답이 현재 화면을 덮던 문제가 같이 사라졌다. 쿼리 키가 자녀마다 달라서 늦게 온 응답은 그 자녀의 캐시로 들어갈 뿐 지금 화면을 건드리지 않는다. 경합을 막는 가드를 따로 쓸 필요가 없어졌다.
구조를 바꾸면 따라오는 수정이 있는데, 분리해뒀기 때문에 그게 따라온 수정이라는 걸 알 수 있었다.
① 넘겨받은 코드에서 제일 먼저 보는 건 실패 경로다. 성공 경로는 사용자가 쓰고 있으니 어차피 돌아간다. 위험한 건 실패했을 때 무슨 일이 일어나는지 아무도 모르는 코드다.
② 같은 이름, 다른 동작이 가장 비싼 중복이다. 그냥 중복은 고칠 곳이 두 군데라서 귀찮을 뿐이다. 이름이 같고 동작이 다르면 읽는 사람이 틀린 걸 맞다고 확신한다.
③ 동작을 안 바꾸는 PR 과 바꾸는 PR 을 섞지 않는다. 섞으면 리뷰 비용이 올라가는 게 아니라, 회귀가 났을 때 원인을 못 찾는다.
다음 글은 같은 이관 작업에서 나온 다른 이야기다. 관리자 화면에서 같은 화면이 두 벌씩 있던 걸 한 겹으로 줄이면서, 10,401줄을 지운 과정.