[리팩토링] Swagger UI + Spring RestDocs 적용기

Swagger UI 와 Spring RestDocs 의 장 단점의 비교

Swagger UI 의 장점

  1. 직관적인 UI
    1. 스웨거는 API 에 대한 요청과 응답 등을 시각적으로 표현하여 사용자가 쉽게 이해할 수 있습니다
  1. 실시간 테스트
    1. API 엔드포인트에 대한 실시간 테스트를 제공합니다.

Swagger UI 의 단점

  1. 어노테이션 수동 기입 기반 API
    1. 어노테이션 등을 수기로 기입하여 문서를 생성하기에, 코드와 문서간의 불일치가 발생할 수 있다.
  1. 유지 보수의 문제성
    1. API 변경시마다 스웨거 어노테이션을 수정해야한다.
  1. 복잡
    1. 코드가 진짜~ 너무 더럽다
    1. 어노테이션때문에 불필요하게 컨트롤러단과 DTO 단에서 피로함이 가중된다.

Spring Rest Docs 의 장점

  1. 정확성
    1. 테스트 코드를 기반으로 문서를 생성하기에 코드와 문서간 일관성 유지가 가능하다
  1. 가볍다
    1. 어노테이션을 사용하지 않기에, 코드 런타임 단계에서 오버헤드가 존재하지 않는다.

Spring Rest Docs 의 단점

  1. API 테스트 UI 의 부재
    1. 스웨거 처럼 API 를 실시간으로 테스트할 수 있는 기능이 존재하지 않는다
  1. 자료의 부재
    1. 스웨거에 비해 자료가 조금 적은 것 같다..

분명 사람들이 스웨거의 문제점과 RestDocs 의 문제점에 대해 불편해하고, 이에 대해 대안이 있지 않을까? 라는 생각을 하였다.

  • 역시나, 이미 존재했다.

기반 코드 설정관련 링크

https://github.com/traeper/api_documentation
  1. 대략적으로 해당 방식은
    1. Spring REST Docs 를 사용하여 API 문서를 작성한다.
    1. restdocs-api-spec 오픈소스로 작성된 문서를 OAS 파일로 변환
    1. 변환된 OAS 파일을 스웨거 UI 에 뿌려준다.

적용

  1. Swagger-UI standalone, Static Routing 셋팅
    Swagger Documentation
    https://swagger.io/docs/open-source-tools/swagger-ui/usage/installation/
    • 에서
      • 최신 버전을 받아서
      • /dist 디렉터리의 파일을 static > swagger-ui 디렉터리 생성 후
        • 복붙
    • 해당 파일들은 스웨거 UI 를 구성할 때 필요한 html, js, css 파일들이다.

    Static Routing 설정

    @Configuration
    public class StaticRoutingConfiguration implements WebMvcConfigurer {
        @Override
        public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) {
            registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
            registry.addResourceHandler("swagger-ui.html").addResourceLocations("classpath:/static/swagger-ui/");
        }
    }
    1. @Configuration
      1. 해당 클래스가 Spring 의 설정 정보를 담고 있음을 나타냄
      1. Bean 등록으로 Spring Container 에서 관리되도록 설정
    1. WebMvcConfigurer
      1. Spring MVC 의 기본 설정을 오버라이딩하고 커스텀 하기 위한 인터페이스 구현 설정
    1. addResourceHandlers(ResourceHandlerRegistry registry)
      1. 정적 리소스를 처리하기 위한 핸들러 오버라이딩
        1. ResourceHandlerRegistry 를 사용하여 URI 패턴과 해당 패턴에 맞는 리소스 위치 매핑
        1. 일단 /static 은 기본적으로 존재하는 URI 임
        registry.addResourceHandler("swagger-ui.html").addResourceLocations("classpath:/static/swagger-ui/");
        • 는 swagger-ui.html 경로를 설정해주기 위해 핸들러 설정을 해준 것 같다.

    Swagger file 수정

    • 그대로 수행함.

2.2.2. ePages-de/restdocs-api/spec을 이용한 OAS 출력

  • 그대로 수행

2.2.3. 생성된 OAS 파일을 swagger 디렉토리로 복사하는 스크립트 작성(copyOasToSwagger)

  • 그대로 수행
  • 하지만,
    tasks.named('test') {
        useJUnitPlatform()
        finalizedBy('copyOasToSwagger') // test 태스크 실행 후에 copyOasToSwagger 태스크 실행
    }
    
    openapi3 {
        server = 'https://localhost:8080' // list로 넣을 수 있어 각종 환경의 URL들을 넣을 수 있음!
        title = 'ITEM-BROWSER API'
        description = 'ITEM-BROWSER API'
        version = '0.0.1'
        format = 'yaml' // or json
    }
    
    tasks.register('copyOasToSwagger', Copy) {
        // 기존 yaml 파일 삭제
        delete 'src/main/resources/static/swagger-ui/openapi3.yaml'
    
        // 복제할 yaml 파일 타겟팅
        from "$buildDir/api-spec/openapi3.yaml"
    
        // 타겟 디렉토리로 파일 복제
        into 'src/main/resources/static/swagger-ui'
    
        // openapi3 task가 먼저 실행되도록 설정
        dependsOn tasks.named('openapi3')
    }

    문제 상황:

    • OpenAPI 3 태스크를 설정할 때, copyOasToSwagger 태스크가 실행되지 않는 문제가 있었습니다.

    발생 원인:

    • openApi3 태스크를 설정하면, test 태스크가 자동으로 실행됩니다.
    • 테스트 코드를 실행할 때도 test 태스크가 함께 실행되는데, 이로 인해 copyOasToSwagger 태스크의 실행 타이밍이 불분명해졌습니다.

    해결 방법:

    • copyOasToSwagger 태스크가 항상 test 태스크가 완료된 후에 실행되도록 설정을 변경했습니다.
       finalizedBy('copyOasToSwagger') // test 태스크 실행 후에 copyOasToSwagger 태스크 실행
      • 위의 요 부분이 test 태스크 이후에 로컬에 OAS 파일을 복사붙여넣기하는 과정임.
    💡
    여기서 openapi3.yml 요 파일이 OAS 파일입니다.
    openapi: 3.0.1
    info:
      title: ITEM-BROWSER API
      description: ITEM-BROWSER API
      version: 0.0.1
    servers:
    - url: https://localhost:8080
    tags: []
    paths:
      /v1/api/cart/add:
        post:
          tags:
          - cart
          summary: 장바구니에 상품 추가 API
          description: 장바구니에 상품 추가 API
          operationId: add-cart
          requestBody:

    이런식으로 REST Docs 의 테스트 케이스 정보가 yml 에 빌드시 기록되면, 로컬 swagger-ui 타깃으로 oas 파일을 복제합니다.

결과

  • openapi3 task 수행
  • 어플리케이션 실행
  • 아래 결과

2024.01.07 추가

그래들 설정 변경

  • openApi3 스프링 프로파일에 따라 기본 server 도메인 설정이 변경되어야 할 필요성이 생겼다.

과정

  1. gradle 스프링 프로파일 현재 설정값을 들고와야함
    // properties file load...
    def loadProperties() {
        Properties props = new Properties()
        file("src/main/resources/application.properties").withInputStream {
            props.load(it)
        }
        return props
    }
    • Gradle Api 라이브러리와 File 자바 내장 라이브러리를 활용
      • application.properties 의 파일을 withInputStream 으로 읽어온다
        • withInputStream 메서드
          public static Object withInputStream(File file, @ClosureParams(value = SimpleType.class,options = {"java.io.InputStream"}) Closure closure) throws IOException {
            return IOGroovyMethods.withStream(newInputStream(file), closure);
          }
          • 등등 의 내부 소스를 타고 들어가보면 newInputStream 의 경우 단순한 BufferedInputStream (File …) 의 형태를 띄고 있다.
            • 8192 의 바이트 크기만큼 단순히 파일을 읽는 형태이다.
          • closure 를 매개변수로 받는데
            • 클로저 내에서 사용자가 InputStream 을 활용해서 필요한 작업을 수행하게 되는데, 해당 메서드 수행이 끝나면 try-with-resource 와 유사한 패턴으로 리소스 누수 방지를 위해 스트림을 닫는 역할을 할 뿐만 아니라
            file("src/main/resources/application.properties").withInputStream {
                props.load(it)
            }

            withInputStream { stream → … (하나의 스트림 단위의 로직 ) } 을 수행할 수 있게 해준다.

            이는 내부에 중요 로직을 작성하고, 외부에서 별도 수행해야하는 사용자 정의 로직이 있는 경우 주입이 가능하도록 한다.

            • 재사용성에 장점이 있다고 생각한다.

            • 암튼, 위 예제에서는 props 객체안에 load 를 수행하여
              public synchronized void load(InputStream inStream) throws IOException {
                Objects.requireNonNull(inStream, "inStream parameter is null");
                load0(new LineReader(inStream));
              }
              • 입력 스트림을 매개변수로 전달
                • load0 메서드에 LineReader 객체로 전달하여
                  • Properties 타입의 객체안에 key - value 형태로 속성을 저장하는 로직이다.
          • 위와 같은 과정을 수행하여
            • Properties 타입의 객체를 반환값으로 전달하여
              openapi3 {
                  def props = loadProperties()
              
                  def profile = props.hasProperty('spring.profiles.active') ? props.get('spring.profiles.active') : 'local'
                  def domainKey = "app.domain.${profile}"
                  def domain = props[domainKey]
              
                  println("domainKey: ${domainKey}")
              
                  server = domain
                  title = 'ITEM-BROWSER API'
                  description = 'ITEM-BROWSER API'
                  version = '0.0.1'
                  format = 'yaml' // or json
              }
              • 에서 props 안에 ‘spring.profiles.active’ 키 값이 있는지 확인 후
                • 없으면 local 값으로, 있으면 해당 프로파일값을 설정
                • 해당 키값의 프로파일에 맞는 domainKey 를 불러와서 server 에 세팅해준다.

2024.01.29

  • API 인증시 JWT 토큰관련 문제점 발생으로
    https://github.com/ITEM-BROWSER/ITEM-BROWSER/pull/2/files
    • 아래 풀리퀘스트를 던졌다.
    • JWT 토큰 인증을 위해서는 별도의 openapi3 방식의 인증 헤더를 던질 수 있어야 한다.
    • 스웨거 파일이 만들어지기 전에 별도의 JWT 인증을 위한 securitySchemas 와 Security 요소를 추가한다.


Uploaded by N2T