OpenAPI Generator로 자동생성된 API, Model 파일을 적용하고 리펙토링한 후기

kdeun1·2023년 2월 7일
1
post-thumbnail

1. 개요

OpenAPI Generator로 API의 안전한 Model과 정형화된 구현코드 자동생성하기 를 프로젝트에 적용한 후기를 정리한 글이다. 실제로 적용하면서 발생한 사례와 어떤 관점으로 반영되었는지 정리하고자 한다.

프로젝트 환경

Vue 3, Vuex, Pinia, JavaScript, TypeScript 등

  • 프로젝트가 어느정도 진행된 이후로 composition api, script setup 등의 방식이 도입되어 컴포넌트의 크기(하나의 파일에 존재하는 코드)가 커지지는 않았다. vue2의 option-based api 방식의 단점인 장황함을 없애기 위해 노력하였다. 하지만 모든 코드가 composable하게 개발되지는 않았다(초창기 파일들은 하나의 파일에 로직이 다 들어있다).
  • typescript가 도입된 환경이며, 최대한 러프한 룰을 적용하였다(채신 ts 룰은 도입X).
  • 프로젝트에서 컴포넌트 개발 패턴에 규칙이 완전히 정립되지 않았다. 당연히 mixin이나 mixin factory 패턴은 vue 3가 되면서 제외되었으며 container/presentation component 패턴을 주로 사용하였다. 이 때 발생한 props drilling을 그대로 적용한 컴포넌트도 있고, store를 사용하여 해결한다거나 provider 패턴을 사용하여 해결한 코드들이 존재한다.

2. 적용

적용 이유

  • API 서버 JAVA 코드의 controller와 method의 관계가 점진적으로 변경되는 사이에, FE 프로젝트에는 초창기 방식의 코드와 함수, 폴더가 레거시가 되었다.
  • FE의 API 구현 TypeScript 코드의 퀄리티가 초기와 비교해서 많이 발전되었다. 하지만 코드 리펙토링이 되지 않았다.
  • API 구현부 코드 구현 방식이 많이 다른 경우가 존재하였다.
  • 프로젝트 내 worker를 도입하게 되었으며, 이를 위해 기존 API 구조를 변경할 필요성을 느꼈다. worker를 도입하면서 동시에 API 리펙토링도 하면 좋을 것 같다고 생각이 들었다.
  • OpenAPI Generator를 사용하면 개발자가 일일이 API 코드와 TypeScript 모델을 구현할 필요없이 자동으로 생성된 파일을 교체하는 방식으로 진행한다면 노가다없이 진행할 수 있을 것 같았다(하지만 생각외로 오래걸리긴 하였다).
  • 프로젝트 처음 투입된 다양한 개발자과 함께 리펙토링을 진행하면서 서비스 내 API의 방식을 전체적으로 알아볼 수 있으며, 어느 메뉴와 컴포넌트에서 API를 호출하는지 점검 및 테스트를 진행할 기회가 될 것이라고 생각했다.

리펙토링을 진행하면서 느낀 점

openapi-generator를 사용하여 생성된 api 함수를 변경/교체하면서 전체적인 서비스 레이어를 점검하게 되었는데, 이 때 느낀 점은 다음과 같다.

1. REST API 로직과 UI 컴포넌트를 경계가 애매한 점

  • 프로젝트 초기에 개발했던 레거시 코드에서는 API 로직과 UI 컴포넌트 간에 결합성이 너무 높아 구조적 문제가 있었고 단순히 API를 교체하는데 많은 부분들을 건드려야 했다.
  • 하나의 파일에 API 로직과 UI 컴포넌트가 섞여있는 경우에는 교체하기가 까다로웠다.
  • container/presentational component 패턴 기반으로 개발했더라면 좀 더 분리가 되어있어 API 코드를 교체가 쉬웠을 것 같다.

2. 공통 로직 재사용이 안되어있음

  • UI(컴포넌트) 스토어와 도메인 스토어의 분리가 되어있지 않는 프로젝트 구조라 스토어의 구조적인 부분이 중복된 경우가 많았다. 그래서 데이터 가공 로직이 컴포넌트와 스토어 여기저기에 산재되어있는 모습을 볼 수 있었다.

3. 정확하지 않은 TypeScript import/export

  • Vue 3의 script setup 방식을 사용하면서 defineProps나 defineEmit과 같은 컴파일러 매크로(Type-only props/emit declarations)를 사용하였다. 이 때 타입스크립트의 지원의 한계점이 존재한다. defineProps의 import한 타입의 제너릭이 불가능하고 컴포저블에서도 사용할 수 없다(vue v3.2.45 기준). 아직 에반유가 미래에 이 기능을 추가할 것이라고 하여 vue파일과 ts파일의 컴포저블한 구조에서 interface를 import하기 힘든 구조에서 반복된 타입을 사용하게 되었다.
  • 또한, 개인적으로는 컴포넌트와 데이터 간에는 타입스크립트를 별개로 관리하는 편을 선호한다. 하지만 개발하는 동안 이 부분에 대해 각자 다른 방식으로 선언하여 사용하였기 때문에 자동생성된 모델파일을 적용하는데 있어 어려움을 겪었다.

참고로 Vue 3의 defineProps의 타입스크립트 공식 지원하기 전에 플러그인을 설치하여 우회하여 interface를 import할 수 있다.
vite-plugin-vue-type-imports 사용하면 다음과 같이 컴포넌트와 컴포저블 사이에 interface를 공유할 수 있다.

// types.ts

export interface User {
 username: string
 password: string
 avatar?: string
}
// component.vue
<script setup lang="ts">
import type { User } from '~/types'

defineProps<User>()
</script>

<template>...</template>

3. 후기

기술 부채들이 해결되었는가?

이 전에 작성한 글에서 openapi-generator를 통해 생성된 코드를 일괄 교체하면서 해결하고 싶었던 부채 5가지를 정리하였다.

  1. BE의 API의 OAS와 FE의 service layer의 API 구조가 1:1 관계로 변경되었다. 프로젝트에서 적용된 컨트롤러 파일 개수는 약 75개 정도였고, 함수는 약 300개 였다.

    • 약간의 아쉬운 점이라면 같은 기능을 하는 함수임에도 옵션이 달라 다른 axios instance를 가지는 경우에는 우선 러프하게(?) 함수를 중복해서 생성했다는 점이다. 추후 이 부분은 수정할 생각이다.
    • 나름 1:1 구조를 구축하였으며 중복된 코드나 함수를 가지지 않게 되어 관리적으로 편리하게 되었다. 리펙토링 작업을 진행하면서 문서화도 할 수 있어서 이 이슈는 기술 부채가 해결된 것 같다.
  2. Swagger UI 문서의 OAS의 Model과 FE의 Type이 동일하게 가져가고 싶은 부분은 약 80%정도 만족한다. 우선 이 이슈는 API-first Development 방식이 전제되어야 하며, OAS가 정확하지 않다면 자동생성된 Model에도 사이드 이펙트를 끼칠 수 있다. 다음과 같은 시행착오를 겪었다.

    • OAS의 json파일로 generator하는 도중에 에러가 발생하였다. 이는 Swagger에 example 항목 등에서 BE에서 리터럴로 적는 부분이 JSON 형식과 어긋나서 발생한 문제였다. 꼭 JSON validator를 돌려보도록 하자.
    • OAS의 Model 속성들이 required: true 되어있는지 확인해보자. required가 빠져있다면 생성되는 interface의 속성들이 모두 optional처리가 된다. 이 Model 파일을 프로젝트에 적용하니 optional chainingassertion 처리를 하게되었다. 진짜 필요한 속성인지 아니면 옵셔널한 속성인지에 대해 정의가 잘 되어있다면 더 좋은 코드가 되었을 텐데 많이 아쉬웠다.
    • 하지만 이 작업을 통해 컴포넌트와 store 코드가 타입을 다시 한 번 점검할 수 있었다(의외로 누락되거나 잘못된 타입들이 많았다).
  3. API 함수 코드의 퀄리티는 당연 보장되었다. 서비스 레이어의 함수의 기준이 없어 다양한 방식으로 개발되어있던 부분을 하나의 패턴과 동일한 퀄리티로 리펙토링할 수 있어서 좋았다.

  4. API 구조은 generator로 생성할 때의 속성을 정해놓아서 바로 적용할 수 있어 동일한 폴더, 파일명의 규칙을 가질 수 있었다.

    • 하지만 교체되는 메소드명의 경우 접두어 규칙을 변경하여, (구) 함수와 (신) 함수를 한 눈에 구분할 수 있도록 하였다.
  5. generator 프로젝트를 별개로 생성할 수도 있고, 기존 프로젝트 내에 설치할 수도 있는데 이 부분은 나중에 바쁜 일이 끝나고나서 자동화에 대해 고민해봐야겠다.


4. 앞으로 변경되어야 할 점

컴포넌트의 종류로는 다음과 같이 정의할 수 있다.

  • Container 컴포넌트 : 하위에 container, presentational 컴포넌트를 가질 수 있으며 어떠한 역할을 할 지에 대해 책임지는 컴포넌트이다. 주로 store와 연결하여 state를 가지며 props로 하위 컴포넌트에 연결한다.
  • Presentational 컴포넌트 : UI 렌더링과 이벤트의 역할을 하며 재사용을 위해 잘게 나눠야 하는 컴포넌트이다.

이러한 역할을 하는 컴포넌트들의 사이에 store가 존재하며 API 호출이나 computed 로직이 존재해야한다. 하지만 현재 프로젝트에서는 그런 이상적인 구조가 모두 적용되지 않았다. UI와 데이터(도메인) 간의 결합성이 높은 부분은 API 코드를 적용하면서 신경쓰는 부분이 너무 많았다. 관여된 코드도 많아서 특정 부분만을 교체하는 것이 아닌 많은 범위의 코드를 교체하게 되었다.

[NHN FORWARD 22] 괴물 같이 변한 Dooray! 웹앱 정리하기 세션에 많은 내용들이 집약되어있다. 처음에는 이 세션을 보고 복잡한 서비스에서의 컴포넌트와 데이터, 유지보수성에 대해 이야기로만 생각하였으나 직접 API 서비스 레이어를 리펙토링하면서 왜 API 로직과 UI 컴포넌트간에 높은 응집도와 낮은 결합도를 가지는 구조로 개발되어야 하는지에 대해 몸소 깨닫게 되었다.


5. apiInner.mustache 샘플 코드

import { axiosInstance, PromiseAxiosResponse } from '@/worker/commands/config/apiInstance';
{{#withSeparateModelsAndApi}}
import {
{{#imports}}
  {{classname}},
{{/imports}}
} from '../model';
{{/withSeparateModelsAndApi}}

{{#operations}}
  {{#operation}}
  {{^isDeprecated}}
const {{nickname}}Axios = ({
{{#allParams}}
  {{paramName}},
{{/allParams}}
}: {
{{#allParams}}
  {{paramName}}{{^required}}?{{/required}}: {{=<% %>=}}<%&dataType%><%={{ }}=%>;
{{/allParams}}
}): PromiseAxiosResponse<{{{returnType}}}{{^returnType}}void{{/returnType}}> => {
  const localVarPath = `{{{path}}}`{{#pathParams}}
    .replace(`{${"{{baseName}}"}}`, encodeURIComponent(String({{paramName}}))){{/pathParams}};
  return axiosInstance.{{#lambda.lowercase}}{{httpMethod}}{{/lambda.lowercase}}(
  localVarPath,
  {
    params: {
{{#allParams}}
      {{paramName}},
{{/allParams}}
    }
  })
};

  {{/isDeprecated}}
  {{/operation}}
export {
  {{#operation}}
  {{^isDeprecated}}
  {{nickname}}Axios,
  {{/isDeprecated}}
  {{/operation}}
};
{{/operations}}
  • 띄어쓰기 및 줄바꿈에 유의해야한다.
  • 코드 100% 자동화를 위해 어쩔 수 없이 localVarPath 변수와 .replace() 사용을 진행하였다.
  • 변환된 파일에 lint 스크립트를 실행하여 코드를 예쁘게 변경해주어야 한다.

참고

profile
프론트엔드 개발자입니다.

1개의 댓글

comment-user-thumbnail
2023년 11월 15일

API 구조은 generator로 생성할 때의 속성을 정해놓아서 바로 적용할 수 있어 동일한 폴더, 파일명의 규칙을 가질 수 있었다. 라고 하셨는데, b/e controller 기준으로 api 폴더가 나눠졌다는 말씀 이신가요? 아니면 api와 model 폴더에 파일명이 규칙을 가진다는 말씀을 하신건가요? 폴더로 분리하는 옵션을 찾고 있는데 정보 공유 가능하시다면 댓글 부탁드립니다 ㅠ

답글 달기