Bruno를 단일 소스로 쓰되, React Query는 생성하지 않기로 한 이유

API 명세가 바뀌는 일은 자주 일어나는데, 프론트에서 그 변화를 따라가는 방식은 생각보다 제각각이었어요. 어떤 화면은 직접 axios를 붙이고, 어떤 화면은 React Query 훅을 따로 만들고, 어떤 곳은 타입만 수동으로 관리하고 있었어요.

팀 내에서도 이런 문제가 반복적으로 제기되었고, 웹과 어드민이 같은 API를 쓰고 있음에도 사용 방식에는 표준이 부재한 상태였어요.

그래서 Bruno .bru를 단일 소스로 두고, 여기서 타입과 API 클라이언트를 자동 생성하는 흐름을 만들기 시작했어요. 처음에는 “이왕 자동화하는 김에 React Query 훅까지 같이 생성하자”는 방향도 검토했어요. 그런데 실제로 붙여보니, 문제는 오히려 그 다음부터 시작됐어요.


1. 발단 — “자동 생성하면 다 편해질 줄 알았어요”

처음 구상은 꽤 단순했어요.

Bruno 명세만 잘 관리하면,

타입도 자동으로 나오고,

axios API도 자동으로 나오고,

React Query 훅까지 자동으로 만들 수 있을 것 같았어요.

겉으로 보기에는 꽤 이상적인 구조였어요. API 계약의 단일 소스가 있고, 프론트는 생성된 결과물을 가져다 쓰기만 하면 되니까요.

그런데 실제 프로젝트에 붙여보니, 자동 생성의 범위가 넓어질수록 편해지기보다는 관리해야 할 경계가 더 흐려지는 문제가 생겼어요.


2. 실제 적용 후 바로 드러난 문제들

하나만 수정했는데 diff가 너무 커졌어요

가장 먼저 체감된 문제는 PR이 너무 시끄러워진다는 점이었어요.

실제로는 Bruno 파일 하나만 수정했는데, 생성기를 돌리고 formatter까지 거치면 예상보다 훨씬 많은 파일에 diff가 생겼어요. import 순서가 바뀌고, 줄바꿈이 달라지고, 객체 포맷이 바뀌면서 실제 변경 의도와 무관한 수정이 함께 쏟아졌어요.

이건 리뷰 관점에서도 좋지 않았어요.

정작 중요한 API 계약 변경은 묻히고, 생성기 영향인지 formatter 영향인지 구분도 어려워졌어요. 자동화는 원래 변경을 단순하게 만들어야 하는데, 오히려 변경을 더 시끄럽게 만드는 상태가 되어버린 거예요.

React Query까지 생성하려 하니 앱 정책이 섞이기 시작했어요

더 큰 문제는 이쪽이었어요.

처음에는 React Query 훅까지 생성하면 정말 편할 것 같았어요. 그런데 막상 생성하려고 들어가면 생성기가 알아야 할 정보가 너무 많아졌어요.

예를 들면 이런 것들이에요.

  • query key를 어떤 규칙으로 만들지
  • onSuccess, onError를 어디까지 포함할지
  • invalidate는 어떤 범위까지 책임질지
  • retry 정책은 무엇을 따를지
  • toast, analytics, redirect 같은 부수효과를 포함할지

이 시점부터 생성물은 더 이상 단순한 API 클라이언트가 아니게 돼요. 이미 앱의 비즈니스 정책과 화면 정책을 들고 있는 코드가 되기 시작해요.

문제는 웹과 어드민이 같은 API를 쓰더라도, 소비 방식은 충분히 다를 수 있다는 점이었어요. 어떤 앱은 React Query를 적극적으로 쓰고, 어떤 앱은 단순한 fetch 스타일 호출이면 충분할 수도 있어요. 어떤 화면은 성공 후 라우팅이 필요하고, 어떤 화면은 캐시 무효화만 해도 충분해요.

즉, API 계약은 공통이지만 상태 관리와 UX 정책은 앱마다 다르다는 사실을 다시 확인하게 됐어요.

그래서 여기서 방향을 바꿨어요.

React Query는 생성하지 않고, 타입과 axios 래퍼만 생성하자. 상태 관리와 부수효과는 각 앱이 직접 책임지자.


3. 방향 수정 — 생성기는 네트워크 레이어까지만 책임지게 했어요

이후에는 생성기의 역할을 아주 명확하게 다시 정의했어요.

Bruno codegen은 여기까지만 책임져요.

  • Bruno .bru를 읽는다
  • 요청/응답 타입을 추론한다
  • axios 기반 API 함수를 생성한다
  • endpoint 메타데이터를 생성한다

반대로 생성기가 하지 않기로 한 것도 분명하게 정했어요.

  • React Query 훅 생성
  • query key 전략 강제
  • onSuccess, onError 주입
  • invalidate 정책 내장
  • toast, redirect, analytics 같은 부수효과 처리

이렇게 나누고 나니 구조가 훨씬 단순해졌어요.

Bruno(.bru)
  -> 타입 생성
  -> axios API 생성
  -> endpoint 메타 생성
  -> 각 앱에서 필요에 따라 React Query / UI 로직 조합

핵심은 아주 단순해요.

공통인 것은 생성하고, 앱마다 달라지는 것은 생성하지 않는다.

이 원칙 하나만 지켜도 생성 시스템이 훨씬 오래 가는 구조가 되더라고요.


4. 현재 구조 — Bruno를 단일 소스로 두는 이유

현재 구조에서 Bruno .bru는 API 계약의 단일 소스예요.

여기서 OpenAPI, 타입, API 클라이언트, endpoint 메타데이터를 자동 생성해요.

그리고 웹과 어드민은 이 공통 생성물을 가져다 써요. 다만 소비 방식은 각 앱이 직접 결정해요.

예를 들어 웹에서는 생성된 API를 React Query로 얇게 감싸서 사용할 수 있고, 어드민에서는 endpoint 메타데이터를 활용해 Inspector 형태로 직접 호출할 수 있어요. 어떤 화면은 그냥 순수 API 함수만 호출하면 충분할 수도 있고요.

즉, 하나의 계약을 공유하면서도 소비 방식은 앱 특성에 맞게 분리할 수 있게 한 거예요.

이게 실제 운영에서는 꽤 중요했어요. 공통화라는 이름으로 모든 것을 하나의 추상화 안에 넣기 시작하면, 결국 어디 하나 바꾸는 것도 어려워져요. 반대로 계약만 공통화하고 정책은 분리하면, 유연성과 유지보수성을 같이 가져갈 수 있어요.


5. 생성물은 “똑똑한 코드”보다 “예측 가능한 코드”여야 했어요

이 작업을 하면서 가장 중요하게 본 기준은 하나였어요.

생성 코드가 얼마나 영리한가보다,

얼마나 예측 가능한가가 더 중요하다는 점이었어요.

생성물이 너무 많은 판단을 하기 시작하면 이런 문제가 생겨요.

왜 이런 코드가 나왔는지 이해하기 어려워지고, 예외 케이스가 늘어나고, 생성기 수정 난이도도 함께 올라가요. 사람이 봐도 헷갈리는데, AI가 다루기에는 더 불안정해져요.

반대로 생성물이 보일러플레이트처럼 단순하면 장점이 분명해요. 구조가 반복적이라 읽기 쉽고, 문제가 생겼을 때 어디를 고쳐야 하는지도 분명해져요. 규칙만 바꾸면 언제든 재생성할 수 있고, 앱에서는 필요한 방식으로 얇게 감싸 쓰면 돼요.

그래서 최종적으로는 타입 + axios를 래핑한 순수 네트워크 함수를 생성하는 방향이 가장 낫다고 봤어요.

예를 들면 생성된 api.ts는 이런 성격을 가져요.

  • GET은 params?: Record<string, unknown>
  • POST/PUT/PATCH는 data?: XxxRequest
  • 반환은 Promise
  • URL 파라미터는 :id, {id}, {{var}} 형태를 함수 인자로 변환
  • 함수명은 HTTP 메서드 prefix를 붙여 충돌 가능성을 줄임

즉, 생성물은 그 자체로 똑똑하려고 하기보다, 항상 같은 패턴으로 읽히는 순수 네트워크 레이어여야 했어요.


6. 현재 Bruno codegen은 어떻게 동작하나요

현재 Bruno codegen의 흐름은 꽤 단순해요.

Bruno

.bru

를 입력으로 받아요

입력 단위는 .bru 파일이에요. 여기서 주로 읽는 블록은 다음과 같아요.

  • meta { … }
  • HTTP 선언 (get /path, post { url: … })
  • headers { … }
  • body:json { … }
  • docs { json ... }

이 중 실제 타입 생성에 가장 중요한 건 body:json과 docs예요.

요청 타입은 body:json에서 추론하고, 응답 타입은 docs 안의 JSON 예시에서 추론해요.

즉, 이 시스템은 서버 스키마를 엄밀하게 검증하는 시스템이라기보다, 문서화된 예시를 바탕으로 타입을 빠르게 생성하는 시스템에 더 가까워요.

도메인별 생성물을 만들어요

생성 결과는 도메인별로 나뉘어요.

  • api.ts
  • apiDefinitions.ts
  • index.ts

그리고 루트에는 전체 export를 모은 src/apis/index.ts가 생겨요.

여기서 api.ts는 실제 axios 호출 함수와 타입을 담고 있고, apiDefinitions.ts는 endpoint 메타 정보를 담아요. 이 메타데이터는 단순 문서용이 아니라, 어드민에서 API Inspector 같은 도구를 만들 때도 꽤 유용하게 쓸 수 있어요.

즉, 생성물은 단순히 “호출 코드”만 만드는 게 아니라, API를 기계적으로 이해할 수 있는 구조화된 정보도 함께 제공하는 셈이에요.

변경된 것만 다시 만들어요

전체 Bruno를 매번 다 재생성하면 느리기도 하고, 불필요한 diff도 많이 생겨요.

그래서 .bru 파일의 SHA-256 해시를 저장해두고, 변경된 파일이 속한 도메인만 다시 생성하는 방식을 사용하고 있어요.

이건 성능 최적화이기도 하지만, 더 중요한 건 생성 시스템에서 생기는 쓸데없는 churn을 줄이기 위해서예요. 아무 의미 없는 재생성이 많아질수록 리뷰 경험은 나빠지고, 생성 시스템에 대한 신뢰도도 떨어지게 되니까요.


7. 이 구조가 AI 협업에도 유리했던 이유

이 구조는 사람만 편하게 하는 게 아니라, AI가 작업하기에도 꽤 유리했어요.

AI가 코드베이스에서 가장 어려워하는 것 중 하나는 암묵적인 규칙이 많은 생성물이에요. 어떤 훅은 생성 코드이고, 어떤 훅은 사람이 직접 만든 코드고, query key 규칙은 파일마다 다르고, onSuccess는 생성물에 있기도 하고 없기도 하면 사람도 AI도 안정적으로 다루기 어려워져요.

반대로 지금처럼 역할을 나누면 훨씬 단순해져요.

Bruno는 계약 소스이고, 생성물은 타입과 순수 API 함수이고, React Query는 앱이 조합하고, 화면 정책은 화면이 책임져요.

이렇게 경계가 선명하면 AI는 생성된 API를 가져와 그 위에 필요한 로직만 얹으면 돼요. 생성 시스템이 모든 것을 대신하려고 하기보다, AI가 활용하기 좋은 안정적인 바닥을 만들어주는 쪽이 더 실용적이었어요.


8. 물론 한계도 분명해요

지금 구조가 만능은 아니에요. 몇 가지 한계는 분명히 있어요.

응답 타입이 docs 예시 JSON에 의존하기 때문에, 서버의 실제 응답과 Bruno 문서가 어긋나면 생성 타입도 함께 틀릴 수 있어요. 또 동일한 200 응답 안에 여러 케이스가 있는 경우에는 현재 구조가 충분히 풍부하게 표현하지 못해요. 런타임 검증도 없어서, 지금은 zod나 io-ts처럼 응답을 실제로 검증하기보다 컴파일 타임 타입 안정성에 더 초점을 두고 있어요.

body:json 외의 요청 형식, 예를 들면 multipart/form-data 같은 경우도 아직 표현력이 제한적이에요.

그렇지만 지금 단계에서는 이 정도가 더 적절하다고 보고 있어요. 모든 문제를 한 번에 해결하려 하면 생성기가 다시 무거워져요. 현재 목표는 완벽한 스키마 시스템이 아니라, 실무에서 자주 필요한 공통 레이어를 안정적으로 자동화하는 것에 더 가까워요.


9. 결론 — 자동화의 양보다 경계가 더 중요했어요

처음에는 생성기가 더 많은 것을 해주길 바랐어요.

하지만 실제로 운영해보니, 생성기는 생각보다 덜 하는 쪽이 더 좋은 경우가 많다는 걸 느꼈어요.

지금의 원칙은 아주 단순해요.

API 계약은 Bruno에 모으고,

타입과 axios 호출 함수만 생성하고,

React Query와 부수효과는 각 앱이 책임지고,

생성물은 단순하고 반복 가능하게 유지하는 거예요.

결국 중요한 건 자동화의 양이 아니라, 자동화의 경계였어요.

생성기가 앱의 의도까지 대신하려고 하면 금방 무거워지고, 공통 계약만 책임지게 하면 오히려 더 오래 가는 구조가 돼요. 이번 작업을 하면서 저는 “무엇을 자동화할 것인가”보다, “무엇은 자동화하지 않을 것인가”를 정하는 일이 더 중요하다는 걸 많이 느꼈어요.

지금은 이 선택이 꽤 괜찮은 방향이었다고 생각하고 있어요.


10. 회고

Bruno .bru를 API 계약의 단일 소스로 사용했고, 여기서 타입과 axios API를 자동 생성하도록 만들었어요. 처음에는 React Query 훅까지 생성하려 했지만, 실제로는 앱별 상태 정책과 부수효과가 섞이면서 생성물이 너무 무거워졌어요. 게다가 formatter 영향으로 작은 변경도 큰 diff로 번지는 문제가 있었어요.

그래서 현재는 타입 + axios 기반 API 함수 + endpoint 메타데이터까지만 생성하고, React Query와 onSuccess, invalidate, toast, redirect 같은 부분은 각 앱이 직접 처리하는 구조로 정리했어요. 결과적으로 생성물은 더 단순해졌고, 사람도 AI도 더 예측 가능하게 다룰 수 있는 구조가 되었어요.