같은 이름의 함수 두 개가 실패할 때 서로 다르게 동작했다

하승진·어제

Level Up 개발자

목록 보기
26/30
post-thumbnail

다른 회사가 운영하던 서비스를 우리 모노레포로 넘겨받았다. 코드는 멀쩡히 돌아가고 있었고, 사용자도 쓰고 있었다. 넘겨받은 쪽 입장에서 제일 먼저 하는 일은 "이 코드가 뭘 하는지" 가 아니라 "이 코드를 어떻게 안전하게 고칠 수 있는지" 를 아는 것이다.

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 인스턴스 apiapiClient
apiGet·apiPost·apiPatch·apiDeletegetAPI·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 를 쓰고 있었다. 두 앱을 오가는 사람이 같은 일을 하는 함수를 두 이름으로 기억할 이유가 없다. 이관 작업에서 이름을 맞추는 건 공짜에 가까운데, 안 맞추면 영원히 두 벌을 기억해야 한다.

그리고 이름을 바꾸면 옛 이름이 남아 있는 곳이 컴파일 에러로 전부 드러난다. 같은 이름으로 내용만 바꾸면 조용히 섞인 채로 남는다. 이관에서는 이게 오히려 장점이다.


안 건드린 것

세 군데는 일부러 그대로 뒀다.

  • 결제 승인 호출
  • 외부 인증 콜백
  • 학습앱 SSO 연결

전부 fetch 직접 호출이고, 전부 다른 서버와 이야기하는 코드다. 그리고 전부 컷오버(서비스 전환일) 전까지 건드리면 안 되는 경로다.

이관 작업에서 "일관성"은 좋은 목표지만, 컷오버 당일에 터질 확률을 0.1%라도 올리는 일관성이라면 미루는 게 맞다. 돈이 오가는 경로는 특히 그렇다.

대신 CLAUDE.md(이 저장소에서 사람과 AI 에이전트가 함께 읽는 규칙 파일)에 한 줄 적었다.

요청은 이 다섯 함수로만 한다.

이게 없으면 다음 사람이 또 여섯 번째 갈래를 만든다. 코드를 정리하는 것과 다시 흩어지지 않게 하는 것은 다른 작업이다.


React Query 전환은 다음 PR 로 뺐다

원래는 같이 하려고 했다. 요청 함수를 모으는 김에 React Query 까지 붙이면 PR 하나로 끝난다.

안 했다. 두 가지가 섞이기 때문이다.

  • 요청 함수 통합 → 동작이 바뀌면 안 되는 변경
  • React Query 도입 → 동작이 바뀌는 변경 (캐시·재요청·로딩 상태)

섞으면 리뷰어가 "이 줄은 바뀌어도 되는 건가"를 매번 판단해야 하고, 나중에 회귀가 나면 어느 쪽인지 모른다. 그래서 먼저 머지하고, React Query 는 그 위에 올렸다.

그 후속 PR 에서 실제로 숨어 있던 버그가 하나 잡혔다. useEffect 안에서 직접 조회하던 코드를 쿼리 훅으로 바꿨더니, 자녀를 빠르게 전환할 때 앞 자녀의 늦게 온 응답이 현재 화면을 덮던 문제가 같이 사라졌다. 쿼리 키가 자녀마다 달라서 늦게 온 응답은 그 자녀의 캐시로 들어갈 뿐 지금 화면을 건드리지 않는다. 경합을 막는 가드를 따로 쓸 필요가 없어졌다.

구조를 바꾸면 따라오는 수정이 있는데, 분리해뒀기 때문에 그게 따라온 수정이라는 걸 알 수 있었다.


남은 세 가지

① 넘겨받은 코드에서 제일 먼저 보는 건 실패 경로다. 성공 경로는 사용자가 쓰고 있으니 어차피 돌아간다. 위험한 건 실패했을 때 무슨 일이 일어나는지 아무도 모르는 코드다.

② 같은 이름, 다른 동작이 가장 비싼 중복이다. 그냥 중복은 고칠 곳이 두 군데라서 귀찮을 뿐이다. 이름이 같고 동작이 다르면 읽는 사람이 틀린 걸 맞다고 확신한다.

③ 동작을 안 바꾸는 PR 과 바꾸는 PR 을 섞지 않는다. 섞으면 리뷰 비용이 올라가는 게 아니라, 회귀가 났을 때 원인을 못 찾는다.


레거시를 넘겨받았을 때 순서

  • 요청·에러·인증 경계가 몇 갈래인지 먼저 센다
  • 같은 이름으로 다른 동작을 하는 것이 있나? 있으면 그게 1순위
  • 실패를 삼키는 코드가 있나? 삼키는 결정이 호출부에 보이나?
  • 정리 PR 과 개선 PR 을 분리했나?
  • 다시 흩어지지 않게 하는 장치(규약 문서·린트·구조 테스트)를 남겼나?
  • 컷오버 전까지 건드리면 안 되는 경로를 목록으로 갖고 있나?

다음 글은 같은 이관 작업에서 나온 다른 이야기다. 관리자 화면에서 같은 화면이 두 벌씩 있던 걸 한 겹으로 줄이면서, 10,401줄을 지운 과정.

profile
기어갈지언정 한 발자국씩이라도 가보자

0개의 댓글