CROSS Blog 홈
기술프론트엔드

서버는 하나인데 API 타입이 두 벌이 될 뻔했어요 (내부 관리자 개편 #2)

코드를 생성하기 시작하니 관리할 대상이 우리 코드에서 서버 스펙으로 옮겨갔어요. 어긋난 지점과 스펙에서 나온 코드를 각각 한 곳으로 모은 이야기예요.

JS

프론트엔드 · · 9

DX

지난 편에서 저희는 OpenAPI 스펙에서 API 함수와 타입, React Query 훅을 생성하기로 했어요. 서버가 바꾼 것을 사람이 눈으로 찾는 대신 재생성과 타입 검사가 찾게 만드는 게 목적이었어요.

그러고 나니 일의 성격이 바뀌었어요.

전에는 스펙이 부실해도 상관없었어요. 개발자가 실제 응답을 열어 보고 적당한 타입을 적으면 그걸로 끝이었거든요. 스펙은 참고 자료였고, 진짜 계약은 각자의 머릿속에 있었어요. 이제는 아니에요. 스펙이 부실하면 부실한 타입이 그대로 나와요. 생성기는 스펙을 의심하지 않거든요.

그래서 저희가 관리해야 할 대상이 우리 코드에서 서버 스펙으로 옮겨갔어요. 이번 편은 그게 실제로 무슨 일이었는지에 대한 이야기예요. 결론부터 말하면 대부분 흩어지지 않게 한 곳에 모으는 일이었어요.

스펙이 비어 있으면 빈 타입이 나와요

생성을 돌리고 나서 이런 타입들을 마주쳤어요.

export type AlarmTemplateVariables = { [key: string]: unknown }

스펙에 이 필드가 additionalProperties: {} 로 적혀 있었기 때문이에요. "객체이긴 한데 안에 뭐가 들었는지는 말하지 않겠다"는 뜻이죠. 백엔드 프레임워크가 스펙을 자동 생성하면서 표현하지 못한 자리예요.

이런 타입은 없느니만 못할 수도 있어요. 화면에서 값을 쓰려면 결국 단언을 하게 되거든요.

const detail = data.id_auth as { first_name: string; status: number }

이 단언은 지난 편에서 없앴다고 한 문제를 그대로 되살려요. 서버가 first_name을 바꿔도 아무 말도 하지 않아요. 그리고 더 나쁜 건, 이런 단언이 화면 코드 여기저기에 흩어진다는 거예요. 나중에 백엔드가 스펙을 고쳤을 때 어디를 지워야 하는지 아무도 모르게 돼요.

고칠 곳은 출력이 아니라 입력이에요

제일 빠른 해결책은 생성된 파일을 열어서 타입을 고치는 거죠. 근데 그건 하면 안 돼요. 다음 재생성 때 사라지니까요.

그래서 스펙을 생성기에 넘기기 전에 한 번 손보기로 했어요. Orval의 input transformer는 스펙 객체를 받아 고친 스펙을 돌려주는 함수일 뿐이에요.

const SCHEMA_OVERRIDES = {
  /**
   * TicketDetailRes — 상세 응답
   * id_auth 가 additionalProperties: {} 로 정의됨
   */
  'TicketDetailRes.id_auth': {
    properties: { id: integer, status: integer, first_name: string, manager: string },
  },
}

여기서 한 가지 조심했어요. 모르는 걸 아는 척 적지 않기. 어떤 비율 필드는 서버가 상황에 따라 숫자로도 주고 문자열로도 줬어요. number라고 단정하면 편하지만 그건 사실이 아니라, 사실 그대로 적었어요.

first_done_rate: { oneOf: [{ type: 'number' }, string] }

쓰는 쪽은 분기를 해야 해서 불편해져요. 하지만 그 불편은 실제로 존재하는 불확실성이에요. 타입을 깔끔하게 만들려고 이걸 숨기면 숨긴 만큼 런타임에서 터져요.

이 파일이 하는 진짜 역할은 타입 보정이 아니에요. 어긋난 지점을 한 곳에 모아 두는 것이에요. 그래서 파일 맨 위에 이렇게 적어 뒀어요 — "백엔드 스키마가 수정되면 해당 오버라이드를 제거할 것." 항목 수가 곧 스펙과 실제가 벌어진 정도이고, 백엔드가 고칠 때마다 줄어드는 숫자예요. 지금은 286줄에 15개 항목이 들어 있어요.

그리고 같은 스펙에서 타입이 두 벌 나올 뻔했어요

여기까지는 스펙의 구멍을 한 곳에 모으는 이야기였어요. 다음 문제는 스펙에서 나온 코드 자체가 복제될 위기였어요.

생성물이 계속 불어났어요. 처음 도입했을 때 607개 파일에 훅 235개였는데, 다섯 달 뒤에는 이렇게 됐어요.

항목 규모
생성 파일 2,176개
용량 13MB
생성 훅 약 1,531개
모델 2,131개

전부 저장소에 커밋돼 있었어요. 앱이 하나일 때는 견딜 만했어요. 그런데 같은 서버 스펙을 쓰는 두 번째 관리자 앱을 만들게 됐어요.

용량이 두 배가 되는 건 그나마 나은 문제예요. 진짜 위험은 저희 스펙 입력이 파일이 아니라 라이브 URL이라는 데 있었어요. 두 앱이 서로 다른 시점에 재생성하면 같은 모델이 앱마다 다른 모양이 돼요. 서버는 하나인데 프론트엔드가 서로 다른 API 타입 두 벌을 갖게 되는 거예요. 지난 편에서 없애려던 드리프트가 이번엔 앱과 앱 사이에서 생겨요.

필터가 아니라 import 그래프로

처음 떠올린 방법은 앱마다 필요한 태그만 생성하는 것이었어요. Orval에는 태그로 거르는 옵션이 있거든요.

접었어요. 두 앱이 공통으로 쓰는 태그는 여전히 두 벌 생성되고, 재생성 시점이 다르면 드리프트도 그대로거든요. 번들 이득도 없어요. 쓰지 않는 모듈은 어차피 번들러가 걸러내니까요. 이 안은 사본을 줄일 뿐 하나로 만들지는 못해요.

그래서 생성 자체를 워크스페이스 패키지 한 곳으로 모았어요. @cross/api 하나가 스펙을 소비해 생성하고, 앱들은 필요한 태그 모듈만 import 해요. "필요한 것만 갖는다"를 생성 필터가 아니라 import 그래프로 달성하는 방식이에요.

그 대가로 의존 방향을 뒤집었어요

지난 편의 숙제가 여기서 돌아와요. mutator 파일이 124줄이었다가 지금은 30줄이라고 했는데, 30줄이 된 게 이 지점이에요.

mutator가 인증 패키지를 import하면 API 패키지가 인증 위에 올라가요. 그런데 인증 패키지도 생성된 API를 쓰고 싶어 해요. 서로를 참조하게 되죠.

방향을 뒤집었어요. API 패키지는 전송 함수가 무엇인지 모르게 하고, 앱이 시작할 때 밖에서 넣어 주도록요.

let transport: Transport | null = null

export const setApiTransport = (t: Transport): void => {
  transport = t
}

export const customInstance = <T>(url: string, options?: Record<string, unknown>): Promise<T> => {
  if (!transport) {
    throw new Error('transport 가 등록되지 않았습니다. initAuth() 가 먼저 호출돼야 해요.')
  }
  return transport<T>(url, options)
}

등록은 앱 진입점에서 이미 부르고 있던 initAuth() 안에서 해요. 앱 코드는 한 줄도 바뀌지 않았어요. 124줄이 30줄이 된 건 코드를 잘 짜서가 아니라 이 파일이 알아야 할 것이 줄었기 때문이에요.

옮기는 작업 자체는 지루했어요. 생성물을 참조하던 import가 547개 파일에 880여 구문이었고, 코드모드로 치환했어요. 한 가지만 조심했어요. 스펙 입력이 라이브 URL이라 옮기는 시점과 마지막 생성 시점 사이의 서버 변경이 이동 diff에 섞여 들어와요. 그러면 리뷰어가 "파일이 움직인 건가, 서버가 바뀐 건가"를 구분할 수 없어요. 옮기기 직전에 기존 방식으로 재생성을 한 번 돌려 스펙을 맞춘 다음 곧바로 새 패키지에서 생성했어요. 순서 하나 바꿨을 뿐인데 diff가 "이동"만 담게 됐어요.

결과적으로 2,869개 파일을 건드렸고 54,145줄이 지워졌어요. 두 벌이 될 뻔한 생성물과 앱마다 있던 설정, mutator 사본 두 벌이 사라진 자리예요.

스펙을 기준으로 삼으면, 스펙을 마음대로 못 고쳐요

이 글에서 제일 하고 싶은 이야기는 여기예요.

지난 편에서 생성 훅으로 바꾸자 데이터 접근이 두 겹 깊어졌다고 했어요. data.body 같은 모양이요. 생성을 한 곳으로 모은 김에 이 봉투도 정리하면 좋았겠죠. 저희에겐 스펙을 손보는 transformer까지 있었으니 기술적으로도 가능했어요.

안 했어요. 정확히는, 하면 안 되는 일이었어요.

근데 이 봉투는 껍데기가 아니에요. 서버는 모든 응답을 code, message, body, error_fields 로 감싸서 보내요. 그리고 code를 실제로 읽는 자리가 81곳, error_fields를 읽는 자리가 20곳이에요. 업로드 실패 사유를 error_fields에서 꺼내는 식으로요. body만 남기면 이 정보가 사라져요. 게다가 스펙상 body는 optional이라 벗겨 봐야 T | undefined가 나와요.

결정적인 건 따로 있어요. 같은 스펙에서 응답 목(mock)도 생성되는데, 그 목은 이 봉투를 그대로 만들어요. 실제 서버가 와이어로 보내는 모양이 그거니까요. 스펙에서 봉투를 걷어내면 생성 타입은 깔끔해지지만, 같은 스펙에서 나온 목이 실제와 다른 모양을 만들게 돼요. 스펙을 기준으로 삼기로 해 놓고 스펙을 편의대로 고치면, 거기서 나온 다른 것들이 전부 거짓말이 돼요.

그래서 봉투는 남겼어요. 대신 훅을 쓸 때 select로 벗기는 방식으로 관례를 통일했어요. 지금 그렇게 벗기는 자리가 581곳이에요.

솔직히 덧붙이면 두 겹 중 바깥 한 겹은 벗길 수 있었어요. 나중에 알았는데 저희가 쓰는 생성기에 그 래핑을 끄는 옵션이 있더군요. 서버와는 무관한, 생성기가 자기 마음대로 씌운 겹이었어요. 이건 판단이 아니라 그냥 못 본 거예요. 지금 켜려면 581곳을 다시 손봐야 해서 아직 그대로 두고 있어요.

남겨둔 것들

로그인 직전에 오가는 요청 몇 개는 여전히 손으로 짠 코드예요. 생성 함수에는 요청 서명을 넘길 통로가 없었고, 토큰을 받기 전 단계라 공통 흐름에 얹기도 애매했어요. 억지로 맞추는 대신 예외로 두고 이유를 적어 뒀어요.

아직 생성으로 옮기지 못한 수기 API 레이어도 남아 있어요. 193개 파일이 그걸 쓰고 있어요. 고정 URL을 전부 확인해 보니 스펙에 다 존재하더군요. 못 옮기는 게 아니라 아직 안 옮긴 거예요.

그리고 이때 저희는 스펙에서 응답 목을 만드는 옵션을 껐어요. 22개 파일이 생성되는데 쓰는 곳이 한 군데도 없었거든요. 필요해지면 옵션 하나로 되살릴 수 있다는 판단이었어요. (한 달쯤 뒤에 정말로 되살리게 됐는데, 그건 다른 글에서 이야기할게요.)

두 편을 마치며

두 편에 걸쳐 저희가 한 일을 한 문장으로 줄이면 이래요. 서버가 바꾼 것을 사람이 눈으로 찾던 구조를, 도구가 찾는 구조로 바꿨다.

그런데 그 과정에서 배운 건 도구를 넣는 방법이 아니었어요. 도구가 찾게 하려면 찾을 곳이 한 군데여야 한다는 거였어요. 스펙과 실제가 어긋나는 지점도, 스펙에서 나온 코드도, 흩어져 있으면 도구도 못 찾아요. 저희가 한 일의 대부분은 생성기를 설정하는 게 아니라 흩어질 뻔한 것들을 한곳에 모으는 일이었어요.

그리고 기준을 정한다는 건 그 기준을 마음대로 못 고친다는 뜻이기도 했어요. 응답 봉투를 남긴 것도, 스펙 보정을 부채로 적어 둔 것도 같은 이야기예요. 기준이 불편할 때 기준을 고치는 대신 불편을 감수하기로 한 거죠. 그게 기준을 기준으로 만드는 유일한 방법이더군요.

아직 다 옮기지 못한 수기 API가 남아 있고, 벗기지 못한 봉투도 그대로예요. 다만 이제는 그것들이 어디에 몇 개 있는지 셀 수 있어요. 세어지지 않던 때보다는 나은 자리라고 생각해요.

도움이 되었다면 눌러주세요

댓글 0

첫 댓글을 남겨보세요.