수기 Axios 호출을 없앤 방법: Spring Boot API에서 SDK 자동 생성하기
서버와 앱이 URL·메서드·타입을 따로 관리하면 API 변경은 런타임 오류가 된다. Springdoc OpenAPI와 OpenAPI Generator로 Java·TypeScript SDK를 만들고, 수기 Axios 호출을 걷어낸 과정을 적는다.
배경 — API 호출 코드가 두 곳에 있었다
앱은 서버 API를 호출한다. 처음에는 화면마다 Axios 호출을 직접 적었다. URL, HTTP 메서드, 요청 본문, 응답 타입을 앱 코드에 썼다.
서버에도 같은 정보가 있다. Controller의 경로와 메서드, DTO가 그것이다. 한 API를 바꿀 때마다 두 곳을 함께 고쳐야 했다.
문제는 둘이 동시에 바뀌지 않는다는 데 있다. 서버가 필드 이름을 바꿔도 앱이 옛 타입을 들고 있으면 컴파일은 통과할 수 있다. 실제 호출이 나간 뒤에야 응답을 읽지 못한다. URL이나 필수 요청값이 달라진 경우도 마찬가지다.
서비스가 늘자 수기 클라이언트도 늘었다. 인증 API는 한 서비스, 조직 API는 다른 서비스, 근태와 휴가는 또 다른 서비스가 맡았다. 앱은 각 API의 약속을 따로 기억해야 했다.
필요했던 것은 Axios를 다른 도구로 바꾸는 일이 아니었다. 서버가 정한 API 약속을 앱이 다시 적지 않게 만드는 방법이었다.
선택 — OpenAPI 명세에서 SDK를 만든다
Spring Boot에는 API 명세를 만드는 라이브러리로 springdoc-openapi가 있다. 실행 중인 서비스의 Controller와 DTO를 읽어 OpenAPI 문서로 보여 준다. 이 글에서 쓴 조합은 다음과 같다.
Spring Boot API
→ springdoc-openapi가 OpenAPI 명세 생성
→ OpenAPI Generator가 클라이언트 SDK 생성
→ Java는 Maven, TypeScript는 npm으로 발행
→ 서버와 앱이 생성된 SDK를 소비
여기서 API가 자동으로 만들어지는 것은 아니다. API 구현은 여전히 Spring Boot에서 한다. 자동으로 만드는 것은 그 API를 부르는 쪽의 코드다. 이 구분을 해 두면 도구의 역할이 분명해진다.
우리 쪽에서는 API 버전을 SDK 버전으로도 썼다. 명세가 바뀌면 SDK도 새 버전이 나온다. 소비자는 정확한 버전을 고정하고 lockfile에 남긴다. 어느 앱이 어떤 약속을 보고 빌드됐는지 추적할 수 있다.
수기 클라이언트를 고치면 안 됐나
수기 Axios 호출도 작은 프로젝트에서는 충분하다. API가 몇 개 없고 서버와 앱을 한 사람이 같은 날 고친다면 SDK 생성 설비가 더 큰 일이 될 수 있다.
하지만 이 서비스는 API를 소유한 서버와 앱이 나뉘어 있었다. 서버 간 동기 호출도 일부 있었다. 타입만 따로 복사하는 방식은 URL과 요청 형식의 변경을 못 막는다. 한 번 만든 명세에서 Java와 TypeScript를 함께 뽑는 편이 중복을 줄였다.
적용 — 명세를 입력으로 삼았다
먼저 API를 제공하는 Spring Boot 서비스에 springdoc-openapi를 붙였다. 서비스가 OpenAPI 3 문서를 만들면 그 문서가 SDK 생성의 입력이 된다.
클라이언트 SDK는 Gradle의 OpenAPI Generator 플러그인으로 만들었다. 서버 API 전체를 한 패키지에 넣지 않고, 서비스가 맡은 API 태그만 골랐다. 예를 들어 근태와 휴가를 맡은 서비스는 그 두 API만 TypeScript SDK에 넣었다.
tasks.register<GenerateTask>("generateTsSdk") {
group = "sdk"
generatorName.set("typescript-axios")
inputSpec.set(contractSpec)
outputDir.set(layout.buildDirectory.dir("generated/ts-sdk").get().asFile.absolutePath)
globalProperties.set(mapOf("apis" to "Attendance,Leave", "models" to "", "supportingFiles" to ""))
configOptions.set(mapOf(
"npmName" to "@firstemployee/hr-domain-sdk",
"npmVersion" to "1.1.0-dev",
"supportsES6" to "true",
"withSeparateModelsAndApi" to "true",
"apiPackage" to "api",
"modelPackage" to "models"
))
}
같은 명세에서 Java SDK도 만들었다. 서버가 다른 서버를 동기로 불러야 할 때, HTTP 요청과 DTO를 다시 만들지 않기 위해서다.
tasks.register<GenerateTask>("generateJavaSdk") {
group = "sdk"
generatorName.set("java")
library.set("restclient")
inputSpec.set(contractSpec)
outputDir.set(layout.buildDirectory.dir("generated/java-sdk").get().asFile.absolutePath)
}
생성물은 저장소에 넣지 않았다. 빌드 결과물로만 두고, CI에서 생성한 뒤 Java SDK는 Maven 저장소에, TypeScript SDK는 npm 저장소에 발행했다. 앱은 서비스별 SDK 세 개를 설치해 썼다.
인증 API → @firstemployee/mobile-bff-sdk
조직 API → @firstemployee/identity-org-sdk
근태·휴가 API → @firstemployee/hr-domain-sdk
앱 전환 — SDK가 Axios 설정까지 가져가지는 않는다
생성된 TypeScript SDK는 typescript-axios 형식이다. API 메서드와 요청·응답 타입은 SDK가 갖지만, 앱의 공통 통신 정책까지 생성기가 알 수는 없다.
기존 Axios 인스턴스는 남겼다. JWT를 넣고, 현재 선택한 사업장을 헤더에 넣고, 401 응답에서 토큰을 한 번만 다시 받으며, 오류 응답을 앱이 읽을 형태로 바꾸는 역할이다. 이 인스턴스를 SDK 설정에 주입했다.
const client = axios.create({ baseURL: apiBaseUrl });
client.interceptors.request.use(addAccessToken);
client.interceptors.request.use(addActiveCompany);
client.interceptors.response.use(undefined, refreshOnceOn401);
const attendanceApi = new AttendanceApi(
new Configuration({ basePath: apiBaseUrl }),
undefined,
client,
);
이후 화면은 URL을 직접 조합하지 않고 AttendanceApi 같은 생성된 API 객체를 부른다. 요청값과 응답값은 SDK가 만든 타입을 쓴다.
SDK와 Axios의 역할을 나눈 것이 중요했다. SDK는 API 계약, Axios 인스턴스는 앱 전체의 통신 정책을 맡는다. SDK를 들였다고 인증이나 오류 처리가 저절로 해결되지는 않는다.
실수한 것들
Gradle 캐시가 스펙 생성을 건너뛰었다
처음에는 테스트가 OpenAPI 스펙 파일을 만든 뒤, 다음 작업이 그 파일을 입력으로 SDK를 만들게 했다. 로컬에서는 잘 됐지만 캐시가 테스트 결과를 되살리면 테스트 본문이 실행되지 않았다. 스펙 파일은 없는데 SDK 생성만 시작했다.
생성기는 입력 파일을 찾지 못하고 실패했다. 테스트가 성공했다는 것과 스펙 파일이 있다는 것은 같은 말이 아니었다.
스펙 생성이 필요한 작업에서는 cleanTest 또는 --rerun으로 테스트 실행을 강제했다. 더 중요한 건 SDK 생성의 입력과 산출물을 빌드 작업으로 명시하는 일이다. 다른 작업의 부수 효과에 기대면 캐시가 켜진 순간 흔들린다.
생성된 코드도 빌드해 봐야 했다
명세 문법 오류는 SDK 생성 단계에서야 드러날 수 있다. YAML의 설명 문장 하나가 잘못되어도 생성기가 멈춘다. 그래서 명세 문법 검사와 SDK 생성을 CI에서 먼저 돌렸다.
생성된 TypeScript 패키지는 설치만 해서는 부족했다. TypeScript 컴파일, lint, Android 번들까지 확인했다. API 호출 코드를 바꾼 작업이므로 실제 앱 번들에서 깨지지 않는지도 봐야 했다.
결과
| 항목 | before | after |
|---|---|---|
| 앱의 API 호출 | 수기 Axios 엔드포인트 호출 | 서비스별 TypeScript SDK 호출 |
| 요청·응답 타입 | 앱에서 별도 관리 | OpenAPI 명세에서 생성 |
| 서버 간 동기 호출 준비 | HTTP 클라이언트와 DTO를 새로 작성 | 같은 명세에서 Java SDK 생성 |
| API 변경 확인 | 호출 뒤 또는 화면 검증 때 발견 | SDK 갱신과 앱 빌드에서 확인 |
| SDK 발행 | 없음 | Java·TypeScript SDK를 CI에서 생성·발행 |
앱 전환 뒤에는 TypeScript 컴파일, lint, Android 번들이 모두 통과했다. 서버와 앱이 API 계약을 따로 적는 자리가 줄었다.
남는 것
SDK 생성은 API 설계를 대신하지 않는다. 잘못 설계한 API는 정확한 타입으로 생성될 뿐이다. 인증, 재시도, 오류 표시, 캐시 정책도 각 애플리케이션이 정해야 한다.
명세를 바꾸는 일도 더 조심해야 한다. 필드 하나를 고쳐도 생성된 SDK의 타입이 바뀌고, 소비하는 앱이 영향을 받는다. 그래서 명세 버전, SDK 버전, 발행 시점은 함께 관리해야 한다.
그래도 수기 호출을 없앤 뒤 바뀐 점은 분명했다. API 약속은 문서에만 있지 않고, 서버와 앱이 함께 컴파일하는 코드가 됐다. SDK 자동 생성의 가장 큰 값은 호출 코드를 줄이는 데 있지 않다. 계약이 어긋난 사실을 사용자가 보기 전에 알려 주는 데 있다.