Start RESTful Service with Spring Boot 2.x + Kotlin

최근 안드로이드 이외에도 Kotlin을 활용해 개발할 수 있는 것을 공부하고 있었다. Android, IOS 간 Shared library 가 가능한 Kotlin Multiplatform도 도전했었고, 이전에 만들다가 문서가 하나도 없어 포기했었던 Ktor라던지…

그렇게 찾다보니 회사의 한 프로젝트가 쓰고 있는 Spring Boot 1.4 + Spring Framework 4 구조를 보고 충분히 Kotlin으로 개발하는 이점을 가지고 개발할 수 있다는 생각이 들었다.

그렇게 해서 2019년 2월 초 부터 코틀린 마이크로서비스 개발 (https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=178926495) 이라는 책을 사서 공부하게 되었다.

공부하고 있던 도중 한번쯤은 토이 프로젝트로 개발해보는 것이 실제로 이점을 잘 느낄 수 있을 것 같아, 최근 몇일간 ‘파일 업로드 / 바이너리 제공‘ 에 대한 API 서비스를 개발하게 되었다.

해당 서비스는 Spring Boot 2.1 기반의 Gradle 프로젝트로 구성되고, 메인 언어는 Kotlin, 설정 파일으로는 yaml를 사용했다. 그 외에도 Swagger를 이용한 API Document 생성, Jenkins를 이용한 Continous Delivery, Dockerfile과 openjdk-8-alpine 이미지를 이용한 컨테이너 배포까지 담고 있다. (단, 작성일자 2019-02-16 기준으로 Jenkins 연동에 성공하지 못했다. 이는 아마 별도 글로 정리해야 될 것 같다.)

이 글은 그 과정에 있어서 튜토리얼 비슷한 글이 될 것이다. 이 튜토리얼에서 사용한 코드 대부분은 (지우지 않은 이상) Github에 공개되어있다. 주소는 https://github.com/WindSekirun/UploadFileBoot 이다. 참고로 이 글에서 모든 코드를 한 줄마다 설명하지는 않을 것이다.

물론, 제대로 된 백엔드 라고 말할 수 있는 서비스를 만든 것이 처음이므로 설계를 이해하지 못하고 코딩할 수 있으나… 만일 발견한다면 친절하게 알려주었으면 한다.

프로젝트 생성

프로젝트 생성은 Spring Initializer (https://start.spring.io) 를 이용했다. 맨 위에는 Gradle, Kotlin, 2.1.3 으로 설정하고 초기 Dependencies 에 Web를 입력하고서 다운로드 하면 미리 구성된 프로젝트 폴더가 열리게 된다.

이를 적당한 곳에 압축을 풀고 Intellij로 열게 되면 개발 준비가 모두 완료된다.

기본 구조

기본적인 구조는 다음과 같이 구성했다.

  • .config 패키지: 서비스 구성에 필요한 Config를 담고 있다. 후술할 Swagger에 대한 Config 파일을 담고 있다.
  • .controller 패키지: 실제 외부에 노출되는 public API단을 담고 있다. 후술할 Swagger가 이 패키지를 대상으로 검사를 시행, API 문서를 생성하게 된다.
  • .data 패키지: public API에서 호출부로 반환할 데이터 클래스를 담고 있다.
  • .exception 패키지: 요청을 처리하는 도중에 발생하는 Exception에 대한 정의 클래스를 담고 있다.
  • .service.storage 패키지: public API에 노출되면 안되는 내부 기능을 담고 있다.

여기서 public API 라는 용어를 사용했는데, 이 용어는 마이크로서비스 설계 9원칙 중 ‘구현 은닉’ 에 대한 나름대로의 용어이다.

구현 은닉 이란 ‘일반적인 마이크로서비스는 구현 세부사항을 숨기는, 명확하고 이해하기 쉬운 인터페이스를 가진다. 이는 내부의 세부 사항을 공개하면 안 되며 기술적 구현이나 이를 구현하는 비즈니스 규칙도 노출돼서는 안된다’ 라는 의미를 담고 있다.

즉, 세부 사항에 대한 변경다른 마이크로서비스에 영향을 주는 것을 줄이는 것이 마이크로서비스 설계에 있어서 중요한 점이라고 볼 수 있다. 당연하게도 한 마이크로서비스를 수정했을 때 다른 부분에 문제가 생긴다는 것은 제대로 분리가 되지 않고 이는 큰 문제를 야기시킬 수 있음을 내포하고 있다.

구현 은닉을 구현하는 제일 쉬운 방법은 Impl Design Pattern으로 외부에는 Interface를 사용하고 실제로는 Interface를 구현한 클래스를 사용하는 것이다. (혹자는 Impl 패턴이 악마같다고도 표현하지만, 세부 사항을 쉽게 은닉하는 일반적인 방법이므로 여기서는 크게 고려하지 않고 사용하는 것으로 한다.)

따라서 service.storage 패키지에는 StorageService라는 인터페이스가 있고 이를 구현하는 FileStorageService가 있다. 이 StorageService 패키지에 대해서는 다음 챕터에서 말하려 한다.

세부 사항 은닉을 위한 StorageService

전 챕터에서 구현 세부 사항을 은닉하기 위해서 Impl Design Pattern을 사용하는 것이 쉽게 은닉하는 일반적인 방법이라고 설명했다. 구조를 살펴보기 전 앞서 개발하려는 서비스를 다시 살펴보자.

파일 업로드 / 바이너리 제공

파일 업로드와 바이너리 제공 외에도 서버가 현재 보관하고 있는 바이너리의 리스트를 반환하는 기능, 모든 파일을 삭제하는 기능, 초기 구동했을 때의 initialize 작업이 필요할 것이다.

이를 Interface로 구현하면 아래 Interface로 표현할 수 있다.

interface StorageService {
   // 초기화 작업
   fun init()

   // 파일 저장
   fun store(file: MultipartFile, extension: String): String

   // 전체 파일 리스트 제공
   fun loadAll(): Stream<Path>

   // 단일 파일 경로 제공
   fun load(fileName: String): Path
   
   // 단일 파일 바이너리 제공
   fun loadAsResource(fileName: String): Resource

   // 파일 모두 삭제
   fun deleteAll()
}

이 Interface를 사용해 클래스를 구현하면 StorageService 가 필요한 곳에 실제 구현체 (예제에서는 FileStorageService)가 제공될 수 있다. (여러 구현체를 가진 경우 yaml를 통해 설정 값에 따라 제공될 객체를 변경할 수 있으나, 여기에서는 다루지 않는다.)

다만 여기서 보충하자면, deleteAll() 과 init()는 public API 에서 제공하는 기능이 아니므로 Bean을 생성할 때 호출할 것이다. FileStorageService 에 @Service 어노테이션이 있으면 자동으로 Bean이 만들어 지긴 하지만, 이를 커스텀하기 위해 별도의 Bean을 선언한다.

@SpringBootApplication
class UploadFileBootApplication {
   @Bean
   fun init(storageService: StorageService): CommandLineRunner {
       return CommandLineRunner {
           storageService.deleteAll()
           storageService.init()
      }
  }
}

위 클래스는 SpringBootApplication 이라는 어노테이션을 가진 UploadFileBootApplication 으로 실제로 이 애플리케이션이 시작되는 것이다.

fun main(args: Array<String>) {
   runApplication<UploadFileBootApplication>(*args)
}

이 부분은 Spring Initializer 를 이용하면 자동으로 잡히므로 따로 코드를 작성할 필요는 없다.

실제 구현체는 어떻게 제공될까?

위에서 StorageService가 필요한 곳에 실제 구현체가 제공될 수 있다 라고 설명하였는데, 실제로 init(storageService: StorageService) 가 실행되는 런타임에서는 StorageService의 구현체인 FileStorageService 가 값으로 들어가 있게 된다.

이는 StorageService 의 구현체인 FileStorageService가 @Service 어노테이션, 즉 Spring Framework의 기반이 되는 Dependency Injection에 대하여 FileStorageService가 제공되었고 이를 Spring Framework가 구동되는 컴포넌트 내에서는 제공된 FIleStorageService를 사용할 수 있다는 의미가 된다.

이 방식은 제어의 역전(Inversion of Control, IoC) 라는 개념에 의해 성립되는데, 제어의 역전의 반대, 즉 일반적으로 사용하는 개념은 제어의 흐름(Flow of Control, FoC) 라고 표현한다. 이는 주체인 ‘나’ 가 ‘프레임워크’ 의 메서드를 부름으로서 특정 기능을 사용하는 반면, 제어의 역전의 경우 ‘프레임워크’ 가 ‘나’를 부르는 것이다.

이를 확장한 것이 의존성 주입(Dependency Injection, DI) 이고, 이는 프레임워크가 Dependency에 대해 관리하고 주체인 ‘나’가 해당하는 Dependency를 필요로 할 때 ‘나’가 직접 Dependency를 연결하는 것이 아닌 프레임워크가 Dependency를 연결하는 것이다.

물론, 이 설명은 아주 간추려 설명한 것이므로 정확한 것과는 다소 차이가 있을 수 있다. 다만 이 문구를 기억하면 된다.

Don’t call us, we’ll call you. – Hollywood Principle

우리(프레임워크)를 호출하지 마세요, 우리가 당신(주체인 ‘나’)을 호출할 것입니다.

데이터 구조 구현

파일 업로드, 바이너리 제공에 대한 비즈니스 로직을 담고 있는 Service를 구현했으면, 이를 호출부에 적절히 반환할 데이터가 필요하다.

이 때, Http Status Code를 사용하여 표현할 수도 있지만, 호출부에 정확한 메세지를 주거나 Fallback를 할 수 있는 방법을 제공할 필요가 있다. (호출부 담당인 안드로이드의 경우 거의 모든 라이브러리에서 Http Status Code는 예외 없이 전부 Error로 간주된다.)

이를 위해 Response라는 데이터 클래스를 만드는데, 간단히 resultCode, resultMessage, data를 담고 있다.

data class Response<T>(val code: Int, val message: String, val data: T?)

Kotlin의 덕분에 toString() 나 copy() 등의 구현은 필요 없이 이 한 줄로 모든 것이 처리된다.

또한, 후술할 Exception를 익숙한 형태로 전달하기 위해 ErrorResponse라는 데이터 클래스도 만든다.

data class ErrorResponse(val code: Int = Constants.RESPONSE_FAILED_REQUEST, val message: String)

그리고, 서비스 등에서 오류가 발생했을 때 예외를 발생시킬 필요가 있는데 이를 범용적인 Exception으로 하기 보다는 커스텀 Exception을 제작해 오류를 명확히 표현하는 것이 좀 더 구성에 도움이 될 것이다.

class StorageException(message: String, exception: Exception? = null): IllegalStateException(message, exception)

Controller 구현

기본 구조, 서비스, 데이터 구조까지 만들었다면 남은 것은 public API를 만들어 외부에 제공하는 것이 남았다.

먼저 파일을 업로드하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@PostMapping("/uploadFile")
fun uploadFile(@RequestParam("extension") extension: String, @RequestParam("file") file: MultipartFile):
           ResponseEntity<Response<String>> {
       val path = storageService.store(file, extension)
       val response = Response(RESPONSE_OK, "OK", path)
       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_TYPE, "application/json")
              .body(response)
  }

호출부에서, extension, file를 각각 Body로서 전송받으면, 내부에서 구현한 StorageService 로 전달하고 결과를 반환하는 형태이다. storageService.store 메서드는 별도로 비동기 처리가 되지 않았으므로, 호출부는 storageService.store 작업이 끝날 때 까지 대기하게 된다.

그 다음 Response 인스턴스를 생성하는데, 여기서는 업로드된 파일의 파일 이름을 반환하도록 한다. 파일 이름은 UUID로 구성되어 있어 별도의 DB를 구성하지 않아도 고유 ID로서 사용하게 했다.

마지막으로 ResponseEntity 라는 클래스의 인스턴스를 생성하는데, 이 클래스는 호출부로 반환될 Response 데이터의 메타데이터를 담을 수 있다. header에는 Response가 Json으로 직렬화될 것이므로 Content-Type로서 application/json을 반환하고, body에는 Response를 반환한다.

이 메서드에는 두 개의 어노테이션이 부착되어 있는데, @ResponseBody 와 @PostMapping 이다. PostMapping는 @RequestMapping 의 축약형으로 POST 메서드를 허용하는 API 경로로 /uploadFile가 API 경로가 된다. 즉 이 API의 전체 호출 경로는 POST http://localhost:8080/uploadFile 가 된다.

@ResponseBody 는 이 메서드가 Response를 반환하는 메서드라는 것을 표시한다.

이를 REST Client로 통해 호출하면 다음과 같은 결과가 나온다.

의도하던 대로 file, extension을 body로 넘기면 data 필드로 업로드된 파일 이름이 나오게 된다.

업로드된 파일은 로컬 환경이므로 프로젝트 폴더에 저장된다.

그 다음, 바이너리를 제공하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@GetMapping("/files/{fileName:.+}")
fun serveFile(@PathVariable fileName: String): ResponseEntity<Resource> {
       val resource = storageService.loadAsResource(fileName)
       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$fileName\"")
              .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
              .body(resource)
  }

이 API에서는 위의 PostMapping 와 다르게 GetMapping를 사용했다. 차이는 POST 대신 GET를 사용한다는 점이다. 또한, RequestParam 대신 PathVariable 를 사용했는데 이는 파라미터를 별도로 전달하지 않고 경로 내에 표현하는 것으로 경로가 완성되는 형태를 가지고 있다. 즉 이 API의 호출 경로는 http://localhost:8080/files/706a1262-1d46-411f-80de-6488d50829a2.jpg 가 된다.

마찬가지로 StorageService에는 별도의 비동기 처리를 진행하지 않았으므로 loadAsResource() 가 끝날 때 까지 작업은 대기되고 있을 것이다.

반환할 때에는 바이너리 자체를 반환할 것이므로, 이에 대한 Content-Disposition, Content-Type를 설정해준다.

이 API도 REST API Client로 호출하면 다음과 같다.

원하는 결과대로 바이너리 결과가 나온 것을 알 수 있다.

마지막으로, 전체 파일 리스트를 반환하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@GetMapping("/listFile")
fun listFile(): ResponseEntity<Response<MutableList<String>>> {
       val list = storageService.loadAll()
              .map { it.toString() }
              .collect(Collectors.toList())

       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_TYPE, "application/json")
              .body(Response(RESPONSE_OK, "OK", list))
  }

이 API의 호출 경로는 http://localhost:8080/listFile 가 될 것이고, 올라간 파일 이름의 리스트가 json으로서 반환될 것이다.

이로서 3개에 대한 API 구현이 모두 끝났다. 다음은 이를 배포하고 관리하기 위한 솔루션들에 대해 알아볼 차례다.

API Document 관리 – Swagger

API Document 관리를 위해 Swagger라는 라이브러리를 사용할 것이다. 의존성은 다음과 같다.

    // Swagger (API Document)
   implementation 'io.springfox:springfox-swagger2:2.9.2'
   implementation 'io.springfox:springfox-swagger-ui:2.9.2'

라이브러리를 추가했으면, config 패키지에 Swagger2Config 파일을 만든다.

@Configuration
@EnableSwagger2
class Swagger2Config {

   @Bean
   fun api(): Docket {
       return Docket(DocumentationType.SWAGGER_2)
              .select()
              .apis(RequestHandlerSelectors.basePackage("com.github.windsekirun.uploadfileboot.controller"))
              .paths(PathSelectors.regex("/.*"))
              .build()
              .apiInfo(apiInfo())
  }

   private fun apiInfo(): ApiInfo {
       return ApiInfoBuilder().apply {
           title("UploadFileBoot API")
           description("API Endpoint for serving file")
           contact(Contact("Pyxis", "https://uzuki.live", "[email protected]"))
           license("MIT License")
           licenseUrl("https://github.com/WindSekirun/UploadFileBoot/blob/master/LICENSE")
      }.build()
  }
}

Swagger2Config는 Swagger2를 사용하기 위한 정보를 설정할 수 있다. apiInfo() 메서드에서는 해당 API 문서 자체의 메타 데이터를 반환하고, api() 메서드에서는 Swagger2의 설정 클래스인 Docket를 반환한다.

여기서 유의할 점은 apis(RequestHandlerSelector…) 부분인데, 이 부분에 지정한 패키지가 Swagger 가 분석할 API가 담겨 있는 패키지가 된다. 여기서는 controller 패키지를 삽입한다.

이 클래스를 제작하고 실행하면 http://localhost:8080/swagger-ui.html 에 접속이 가능해진다.

Controller에 선언한 3개의 API에 대한 항목이 구성되었음을 알 수 있다. 여기서 각 api를 클릭하게 되면 각 api에 대한 설명과 Try out 메뉴가 나오게 된다.

실제로 Try it out 메뉴에서는 API에 대해 호출하고 결과를 볼 수 있다. 이로서 API Document 및 간이 Client 까지 완성되게 된다. 무엇보다, @ApiOperation 나 @ApiResponse 등의 어노테이션을 추가적으로 부착할 경우 API의 자세한 설명과 상태 코드에 따른 예상 반환 데이터까지 지정해줄 수 있다.

배포 관리 – DockerFile

배포에는 Dockerfile를 이용해서 어느 환경에서도 잘 돌아갈 수 있도록 보장할 것이다.

FROM openjdk:8-jdk-alpine

RUN apk update && apk add bash
ADD build/libs/*.jar uploadfileboot.jar
ENTRYPOINT ["java", "-jar", "uploadfileboot.jar"]

Dockerfile 내부는 최대한 간단하게 빌드된 jar를 추가하고 실행하는 코드로 마무리한다.

이를 실행시키기 위해 docker run 명령어를 직접 실행시킬 수도 있지만, 여러 컨테이너와 링크할 때 편리한 docker-compose 를 이용해 설정할 것이다.

version: '2.2'
volumes:
upload_files: {}

services:
uploadfileboot:
  build: .
  ports:
    - "8080:8080"
  volumes:
    - upload_files:/files
  container_name: uploadfileboot
  restart: always

실제 서버에서 구동할 때에는 build: . 가 아닌 이미지 자체를 빌드해서 dockerhub와 같은 registry 에 올릴 필요가 있지만, 현재는 로컬 환경이므로 build: . 로 구성한다.

새로 빌드를 할 필요가 있을 때에는 docker-compose up -d --build 를 실행하고, 빌드를 할 필요가 없을 때에는 –build 만 제외한 나머지 명령어를 입력하면 구동될 것이다.

마무리

결론적으로 매우 만족스러웠다. API 문서 작성, 배포, 자동화는 얼핏 지루한 작업이 될 수 있는데, 이를 Swagger나 Docker로 통해 자동화해서 개발자가 신경써야 할 작업을 제거했다. 또한 API를 작성하는 것 또한 비즈니스 로직을 작성하고 API로서 노출시키기만 하면 나머지 구동 설정은 전부 된다는 것도 개발자가 신경써야 할 다른 작업을 제거한 셈이다.

Spring Boot는 그 말대로 빠른 개발 + 빠른 배포에 집중화된 느낌이라, 실제로 대규모 프로덕션 규모로 가면 어떻게 작용할 지는 아직까지 모르겠지만 언젠가 공부할 쿠버네이트 등 오케스트레이션 솔루션등을 도입하면 대규모 프로덕션에서도 무리 없이 개발이 가능할 것이라고 생각한다.

무엇보다 안드로이드만 하던 입장에서는 놀랄 수 밖에 없었던게, 실제로 구현하는 느낌이 안드로이드 앱을 Dagger나 MVVM을 이용해 작성하는 느낌과 매우 비슷해서 문법이나 API가 다를 뿐 같은 코드 베이스를 구현하는 느낌이 들었다.

이 프로젝트에서는 적용하지 않았지만 Project Reactor를 이용한 리액티브 Web까지 도입하게 되면 매우 좋을 것이라고 생각되었다.

작성 일자 2019-02-16 기준 CI/CD가 연동되지 않았지만, 이는 다른 글에서 살펴봐야 될 것 같다. 생각보다 작업이 규모가 있었던 것 같다.