오픈소스 CATS로 간단히 퍼즈(Fuzz) 테스트

퍼징이란

퍼즈(Fuzz) 또는 퍼징(Fuzzing)이란 블랙박스 테스트 의 한 방법으로 정상/비정상 데이터를 자동으로 생성하고 함수나 API에 전달한다. 어떻게 보면 암호 해독(crypto anaylysis) 과정과 비슷하게 보이지만 퍼징은 프로토콜이나 입력 데이터 타입에 의존적이다. 그래서 실패한 테스트 결과들은 암호 해독과는 다르게 실제로 유의미하다.

여담으로 몇년전 대기업에서는 유사한 테스트를 수행하기 위해서 전문 인력까지 두었었다. 그때도 도구를 사용하긴 했지만 테스트 설정부터 수행 후 보고서 작성까지 상당히 많은 부분에서 고급 인력들을 투입했었는데, 이번에 발견한 도구는 만약 테스트 대상이 Rest API이고 Open API 스펙 (i.e. swagger)을 가지고 있다면 CI/CD에 바로 연동하고 바로 깔끔한 리포트를 확인할 수 있을 정도로 고도화 되었다. 소프트웨어 테스트쪽은 정말 빠르게 컴퓨터로 대체되어가고 있는 것 같다.

CATS

https://github.com/Endava/cats

CATS는 자바 기반의 라이브러리로 거의 코딩을 하지 않고도 몇백가지의 API테스트를 자동으로 수행해준다. 6.0 버전 기준으로 76개의 퍼저(Fuzzer)가 존재한다.

퍼저는 크게 5가지로 분류된다

  • 필드 퍼저 – Post 요청의 몸통이나 URL의 경로 변수(path variable)을 대상으로 하는 퍼저
  • 헤더 퍼저 - HTTP 헤더들을 대상으로하는 퍼저
  • HTTP 퍼저 - 필드나 헤더와 관계없는 HTTP 요청을 대상으로하는 퍼저
  • API 계약 검증 퍼저 – Open API 정의가 베스트 프랙티스를 따르고 있는지 검사하는 퍼저.
  • 특수 퍼저 - 보안이나 특별한 절차를 필요로 하는 조금 더 복잡한 행동들을 테스트하기 위한 퍼저

팀에서 관리중인 내부 API는 이미 swagger 2.0 으로 관리되고 있었기 때문에 CATS jar를 다운 받은 뒤 아주 간단하게 테스트를 실행할 수 있었다.

cats.jar --contract=swagger.yaml --server=$FUZZ_SERVER_ADDR --headers=header.yml --refData=refData.yml

아틀라시안은 내부 API 인증에 JWT토큰의 일종인 ASAP을 사용하기 때문에 모든 API 호출에 Authorization 헤더를 제공해줘야 한다. herader.yml 에는 다음과 같이 작성한다. CI/CD에서는 매 빌드마다 값을 갱신 시켜준다.

all:
  Authorization: Bearer TOKEN_PLACE_HOLDER

그 다음이 데이터 파일로 API에 비즈니스 특유의 값을 제공한다. 여기서는 간단히 고정된 값을 사용했지만 Apache Common의 라이브러리등도 호출이 가능해 보인다.

all:
    boardId: 29c3fd3c-0239-3cbb-ac02-3ef08e267d4f
    columnId: 52

이것으로 기본적인 준비는 모두 끝이다. 로컬에서 돌아가는 것을 확인하고 바로 Bitbucket Cloud 의 custom Pipe를 생성해 CATS jar를 포함하는 도커이미지를 생성한 뒤 API 리포지토리의 파이프라인에서 일주일에 한번씩 테스트가 수행되도록 설정해 놓았다. Github Action을 사용해서도 유사한 결과를 얻을 수 있으리라 생각한다.

테스트 결과

실제 내가 관리하고 있는 API는 다음과 같은 테스트들이 실패했다. 실패한 모든 테스트들을 수정하지는 않을 것 이다. 아마 REST API가 외부에 공개된 것이라면 대단히 유용할 것 같다.

권당되는 헤더를 찾을 수 없다는 에러, 자세한 내용은 아래 링크에 있고  사설 API이기 때문에 이는 무시해도 좋을 것 같다.   [{name=X-XSS-Protection, value=1; mode=block}]

X-XSS-Protection – Preventing Cross-Site Scripting Attacks – KeyCDN

중복된 헤더입력

중복된 헤더입력을 허용하고 내부적으로는 리스트로 변환되고 있음. HTTP 스펙에서는 허용되지만 보안상 권장되지 않음.

HTTP Desync Attacks in the Wild and How to Defend Against Them | Imperva

유니코드 제어 문자를 인식하지 못함

유니코드 제어문자는 시각적으로 표현되는 데이터가 아니기 때문에 입력에 포함되어 있을 경우에 일반 문자가 아니라 제어문자로 인식되어야 한다.

Unicode control characters

권장되는 REST API 네이밍 규칙을 따르지 않음

API경로에 복수형, 명사, 소문자를 사용해야 하며 엔드포인트에는 스네이크 케이스나 케밥 케이스를 사용해야한다. JSON 프로퍼티에는 스네이크 케이스와 카멜 케이스를 허용함.

새로운 JSON 필드 입력을 허용함

요청의 몸통에 새로운 JSON 필드 입력을 허용하고 있는데 OWASP 에서는 이경우에 요청을 거절하도록 권장한다.

REST Security – OWASP Cheat Sheet Series

존재하지 않는 HTTP 메서드에 대해서 405(Method Not Allowed)를 반환하지 않음

제목 그대로 CONTENT와 같이 존재하지 않는 HTTP 메서드에 대해 403 에러를 반환하고 있다.

지원하지 않는 Content Types 헤더를 허용함

Content-Type 헤더에 OpenAPI 계약아 정의되지 않은 값을 보냈을 때 요청을 허용함. OWASP 는 검증을 수행하도록 권장한다.

REST Security – OWASP Cheat Sheet Series

API 사양의 GET 경로에 권장되는 헤더인 TracedId/CorrelationId가 존재하지 않음.

마치며

실제 jar 파일을 다운받아서 로컬에서 실험해 보는데는 한시간도 걸리지 않았다. Open API 스펙을 가지고 있다면 반드시 돌려보면 좋을 것 같다. CATS에서 좀 아쉬운 점은 테스트 갯수가 엄청나게 많은데 비해 클라이언트 측 Rate limiting 이나 Throttling 기능이 있어야 할 것 같다. 많은 API 응답이 503 에러를 반환해서 의도치 않게 성능 테스트가 되어버릴 수도 있다.

오픈소스 CATS로 간단히 퍼즈(Fuzz) 테스트

클린 (리액티브) 코드

클린코드(Clean Code)는 소프트웨어 엔지니어링 분야에서 굉장히 의미가 깊은 책이다. 클린코드로 많은 개발자들이 자기가 작성한 코드의 정량적인(Quantitative) 부분 뿐만 아니라 정성적인(Qualitative) 부분도 신경쓰게 되었다. 책을 관통하는 주제는 일관되다. 사람이 읽기 쉬운 코드가 유지보수가 더 쉽기 때문에 높은 품질을 가진다는 것이다.

나는 3~4년에 걸쳐 스프링 WebFlux를 사용해 증권앱의 서버 그리고 GraphQL의 게이트웨이 프로젝트를 진행했다. WebFlux를 사용한 결과에 충분히 만족했고 Graphql 게이트웨이는 안정화 단계에 접어들어서 최근에는 여러번의 코드리뷰를 진행했다. 다양한 사람들의 코드를 리뷰하면서 많은 사람들이 자신들만의 방식으로 코드를 작성하고 있음을 깨달았다. 공식 매뉴얼을 읽고 작동하는 코드를 만들긴 했지만 어떻게 해야 다른 사람이 더 쉽게 이해할 수 있을지에 대한 배려는 부족했다.

이는 아직 리액티브 프로그래밍이 본격적으로 사용 된지 얼마 지나지 않았기 때문에 클린코드나 이펙티브 자바와 같이 많은 엔지니어들이 이정표로 삼을 수 있는 자료들이 부족한 것이 주된 이유라고 생각한다. 엔지니어들 사이에 합의된 관습등이 부족해서 리액티브 코드는 작성자 별로 중구난방이 되기 쉬우며 코드 리뷰시에도 더 많은 시간을 필요로 한다.

여기서는 내가 개인적으로 리액티브 코드 리뷰시에 자주 언급하는 항목들을 정리해 본다. 여기서 말하는 내용들이 반드시 옳다는 이야기는 아니며 프로젝트 상황마다 다르겠지만 일반적으로 리뷰어의 입장에서 읽기 편안함에 중점을 두고 나열해본다.

모든 예제는 코틀린코드로 작성되었다.

1. 연산자를 중심으로 코드를 작성

연산자(Operator)는 리액티브 프로그래밍에서 사용 가능한 연산의 기본 단위이다. 그래서 코드를 작성할 때는 연산자를 중심으로 왼쪽에서 오른쪽, 위에서 아래 방향으로 마치 책을 읽는 것처럼 작성하는 것이 다른 사람들이 제일 이해하기 편하다.

//Bad
userService.getFavorites(userId).map(Favorite:toRequestModel)
           .flatMap(favoriteService::getDetails) 

// Good
userService.getFavorites(userId) 
           .map(Favorite:toRequestModel)
           .flatMap(favoriteService::getDetails) 

2. map, flatMap의 함수는 최대한 간결하게

1번 에서 언급한 것처럼 모든 코드는 연산자 중심으로 읽혀져야 한다. 많은 사람들이 제일 자주 사용하는 map과 flatMap의 함수 내부에 장황한 비즈니스 로직을 작성하곤 한다. 이것은 코드의 흐름을 끊기게 해 가독성에 좋지 않다. 중요한 큰 흐름은 연산자의 체인과 그 인자만으로 읽어낼 수 있어야 하기 때문에 장황한 로직은 외부 함수로 빼내도록 하자.

//Bad
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               val userRequest = user.toUserRequest()
               GetFavoriteDetailRequest(
                   favorites = favorites,
                   user = userRequest
               )
            }
           .flatMap(favoriteService::getDetails) 

//Good
userService.getFavorites(userId)
           .map(this:toRequestModel)
           .flatMap(favoriteService::getDetails) 

fun toRequestModel(input: Tuple2<Favorites, Users>) {
     val (favorites, user) = input
     userRequest = user.toUserRequest()
     GetFavoriteDetailRequest(
        favorites = favorites,
        user = userRequest
     )
} 

3. 연산자는 목적과 그 이름에 걸맞게 사용

리액티브 연산자의 이름은 실제 작동 방식만큼이나 중요하다. 예를들어 map은 값을 다른 값으로 매핑할때, flatMap은 값을 Publisher 타입으로 매핑할 때 사용한다. 여기서 Publihser타입은 값의 계산 자체를 추상화한 타입이다. doOnNext 연산자는 전체 연산의 결과에 영향을 미치지 못하는 부수 효과(Side effect)를 주고 싶을 때 사용한다. 이름에 맞는 연산자를 사용하면 코드리뷰시에 인지부하를 줄여 준다.

//Bad
userService.getFavorites(userId)
           .map { 
               log.info("Received favoirtes, $it")
               it.toRequestModel()
            }
           .flatMap(favoriteService::getDetails) 

//Good
userService.getFavorites(userId)
           .doOnNext { log.info("Received favoirtes, $it") }
           .map(this:toRequestModel)

           .flatMap(favoriteService::getDetails) 

4. Mono, Flux 사용이 필요한지 확인

3번에서 언급했듯이 Mon, Flux의 상위 타입인 Publisher 타입은 “계산” 자체를 추상화 한다. 이 타입은 리액티브의 프로그래밍의 장점인 비동기 연산을 제어하는데 사용되어야 하기 때문에 중구난방으로 사용되어서는 안된다. 되도록 데이터 클래스의 멤버는 모두 Non-Publihser타입으로 선언한다.

//Bad
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               GetRequestModel(
                   Mono.just(favorites),
                   Mono.just(user)
               )
            }
           .flatMap(favoriteService::getDetails) 

data class GetRequestModel(
     val favorites: Mono<Favorites>,
     val user: Mono<User>
)

//Good
userService.getFavorites(userId)
           .map { 
               val (favorites, user) = it
               GetRequestModel(favorites, user)
           }
           .flatMap(favoriteService::getDetails) 

data class GetRequestModel(
     val favorites: Favorites,
     val user: User
)

5. Publihser의 Null 타입은 Mono.empty

다시 한번 이야기 하지만 Publisher 타입은 미래의 계산에 대한 추상화이다. 그렇기 때문에 Publihser타입을 반환해야 하는 장소에서 Null을 반환하면 에러가 발생한다. 코틀린에서는 non-nullible 타입을 제공해서 한결 처리하기 수월하지만 자바는 Mono를 반환하는 함수가 Null을 반환하는지 확인하고 Mono.empty 타입을 반환하도록 한다. 반대로 값에서 값으로의 맵핑을 수행하는 map은 null을 반환해도 에러가 발생하지 않고 Mono.empty 타입과 동일하게 처리된다.

// Bad
Mono
   .just("test")
   .flatMap { testFunc(it) }

// Good
Mono
  .just("test")
  .flatMap { 
      testFunc(it) ?: Mono.empty()
   }

private fun testFunc(seed: String): Mono<String>? =
    if (seed  == "test") {
        null
    } else {
        Mono.just("Mono - test")
    }

6. Mono나 Flux가 중첩되는 경우에는 메소드 참조를 활용

1번에서 언급한 것처럼 연산자를 중심으로 메소드 체인 형식으로 작성하는 것이 최선이나 어쩔 수 없이 연산자들을 중첩해서 사용할 때가 있다. 그럴 때는 반드시 메서드 참조를 사용할 수 있도록 한다. 특히 람다에서 묵시적 타입 “it” 을 허용하는 코틀린에서 모든 람다의 매개 변수로 it을 사용하면 변수 스코프가 가려져서 (Variable shadowing) 가독성에 심각한 문제가 발생한다. 자바에서도 메서드 참조가 유리한 이유는 일반적으로 개발자가 한 클래스 내에서 여러 번 변수명을 지정해야 할 경우 특별한 의미 없이 비슷한 이름을 연속해서 사용하기 쉽기 때문이다.

// Bad
userService.getUser(userId)
           .map { it.toFavoriteReq() }
           .flatMap { 
               favoriteService
                       .getFavorites(it)
                       .flatMap { 
                          favoriteService.getDetails(it.toDetailRequest) 
                       }

           }

// Good 
userService.getUser(userId)
           .map(User::toFavoriteReq)
           .flatMap { favoriteReq ->
               favoriteService
                       .getFavorites(favoriteReq)
                       .map(Favorite:toDetailRequest)
                       .flatMap(favoriteService::getDetail) 
                          
           }

7. Collection API와 겹치지 않게 사용

Mono 타입의 가장 대표적인 연산자인 map과 flatMap, filter 등은 자바, 코틀린의 컬렉션 API뿐만 아니라 여러 곳에서 사용된다. 동일한 범위에서 사용되면 소스 타입이 Publisher인지 Iterable인지 바로 알기가 힘들다. 두 가지가 서로 겹치게 되면 분리할 수 있는 방법을 찾자.

// val books: List<Book>
// Bad
Flux.merge(
  books.map { book ->
     if  (book.id == null) {
       Mono.just(card.copy(id = UUID.randomUUID()))
     } else {
       Mono.just(book)
     }
  }
)

//Good
Flux.merge(
  books.collectionMap { book ->
     if  (book.id == null) {
       Mono.just(card.copy(id = UUID.randomUUID()))
     } else {
       Mono.just(book)
     }
  }
)

private fun <T, R> Iterable<T>.collectionMap(transform: (T) -> R): List<R> = this.map(transform)

8. Mon, Flux의 구분이 필요한 곳은 변수명에 명시하기

일반적으로 모든 Publisher타입에 mono나 flux를 변수 명에 넣어줄 필요는 없다. 하지만 해당 변수가 Publisher임을 명시적으로 하고 싶거나 Flux와 Mono 사이에 연산을 수행할 때는 변수명에 flux나  mono를 포함 시키도록 한다. 특히 flux 와 mono 간의 연산이 필요할 때는 변수명을 mono나 flux를 붙여서 서로 다른 타입과 연산을 수행함을 알 수 있게 한다. 다음 예제에서는 Flux 와 Mono를 zip하는데 그 결과는 Mono타입이 된다.

// Bad
val numberKor = Mono.just("hana")
val numberEng = Flux.just("one", "two")
numberEng
      .zipWith(numberKor)
      .doOnNext { zipped ->
         val (eng, kr) = zipped
         log.info("English:$eng, Korean:$kr")
       }

// Good
val numberKorMono = Mono.just("hana")
val numberEngFlux = Flux.just("one", "two")

numberEngFlux
     .zipWith(numberKorMono)
     .doOnNext { zipped ->
        val (eng, kr) = zipped
        log.info("English:$eng, Korean:$kr")
     }

9. 명시적인 subscribe() 호출은 신중하게

리액티브 체인 내부에서 subscibe를 호출하는 것은 되도록 피하자. 이는 엔지니어가 인식하지 못하는 사이 장기간 돌아가는 스레드를 만들 가능성이 있다. 리액티브 프로그래밍은 subscribe가 반환하는 Disposable 타입을 사용해 처리량을 조절할 수 있어야 한다.

// Bad
userService
         .getUser(req)
         .flatMap(userService::changePassword)
         .doOnNext {
              auditLogger
                      .auditLog(it)
                      .subscribe()
         }

10. 가장 중요한 연산자는 flatMap

리액티브에서 이야기 하는 성능의 향상, 즉 높은 동시성은 거의 대부분은 flatMap을 중심으로 이루어진다. 그래서 데이터 흐름을 조절하는 delayElement등을 flatMap 주변에서 사용할 때는 유의하고 자매 연산자인 flatMapSequential, concatMap 등과의 차이점을 확실히 파악하도록 하자.

클린 (리액티브) 코드

아치유닛(ArchUnit) 테스트

아치유닛(ArchUnit)을 사용하는 이유

“두 패키지 사이에 순환 참조(Circular Dependency)가 존재합니다. 변경이 필요해요.”

“@SpringBootTest 어노테이션을 사용하는 통합 테스트 코드는 test 폴더가 아니라 integration-test 폴더에 위치해야합니다.”

“Service 레이어는 Controller와 Model 패키지 에서만 접근해야 합니다”

팀내 경력이 오래된 시니어 개발자는 위와 같은 커멘트를 작성할 때가 많다. 그러면서 왜 그래야 하는지 코드나 패키지 단위로 다이어그램을 그려서 어떻게 컴포넌트들이 서로 작동을 해야하는지 설명한 경험이 있지 않은가?

새롭게 팀에 들어왔거나 해당 지식을 가지지 않은 사람들은 제일 경험이 많은 사람이 친절하게 알려주는 것이 팀내 지식 공유 차원에서는 제일 바람직할 것이다. 하지만 확장 가능하지 않고 팀내 고급인력의 끊임 없는 관심을 요구한다. 갑자기 사람이 늘어나거나 담당자가 휴가를 가버리거나 퇴사하면 잘 작동하지 않는 모델인 것이다.

우리는 이미 유사한 상황들에 많이 대처해봤다. 아치유닛을 사용하면 아키텍처상의 결정사항이나 결함들도 통합 빌드의 한 부분으로 테스트를 작성하고 자동화 하는 것이 가능하다.

홈페이지에서도 언급하고 있듯이 아치유닛이 아니어도 AspectJ나 CheckStyle, Findbugs를 사용해서 유사한 테스트를 수행할 수 있다. 하지만 해당 도구들은 조금 더 범용적인 성격을 가지고 있기 때문에 코드의 구조를 분석해서 읽기 쉬운 테스트를 작성하기 위해서는 아치유닛을 사용하는 것을 것을 추천한다.

아치유닛 구성

아치유닛은 Core 레이어, Lang 레이어, Library 레이어가 존재한다. Core API를 통해 대상을 특정하고 Lang API를 사용해 규칙을 정의한다. Library 레이어는 미리 정의된 규칙들, 예를들어 3 Tier 아키텍처, 6각형 아키텍처(Hexagonal Architecture) 등을 위한 규칙들을 제공한다. 아직은 실험적인 상태로 보이며 추후 확장의 여지가 있는 부분이다.

Core 레이어의 ClassImporter

리플렉션과 유사한 기능을 제공하는 Core레이어에서는 ClassImporter가 가장 중요한 API들을 제공한다. ClassImporters는 컴파일된 클래스들을 불러오기 위해 사용한다. 다음 코드는 com.book 패키지내의 클래스중에 Jar나 테스트 폴더등을 빼놓고프로덕션 코드만 대상으로 지정하는 설정이다.

ClassFileImporter()
        .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
        .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
        .importPackages("com.book")

Lang 레이어는 아키텍처 규칙을 정의하는 API들을 제공한다. 규칙을 정하고 나면 다음과 같이 실제 검증을 수행한다.

JavaClasses importedClasses = new ClassFileImporter().importPackage("com.myapp");
ArchRule rule = // 아래 예와 같이 다양한 룰 생성
rule.check(importedClasses);

패키지 의존성 확인

service 패키지는 controller와 resource에서만 접근 가능하다.

classes()
   .that()
   .resideInAPackage("..service..")
   .should()
   .onlyHaveDependentClassesThat()
   .resideInAnyPackage("..controller..", "..resource..")

클래스 의존성 확인

*Service 클래스는 Controller 클래스 에서만 접근 가능하다.

classes()
  .that()
  .haveNameMatching(".*Service")
  .should()
  .onlyBeAccessed()
  .byClassesThat()
  .haveSimpleName("Controller")

클래스와 패키지 관계 확인

Book으로 시작하는 클래스는 com.book 패키지에 위치해야 한다

classes()
  .that()
  .haveSimpleNameStartingWith("Book")
  .should()
  .resideInAPackage("com.book")

상속 관계 확인

Connection 인터페이스를 구현하는 클래스는 이름이 Connectiond으로 끝나야한다.

classes()
  .that()
  .implement(Connection.class)
  .should()
  .haveSimpleNameEndingWith("Connection")

EntityManger클래스로 할당 가능한 클래스들은 persistence 패키지에 위치해야 한다.

classes()
  .that()
  .areAssignableTo(EntityManager.class)   
  .should()
  .onlyBeAccessed()
  .byAnyPackage("..persistence..")

주석 테스트

com.book 패키지 중에서도 “build/classes/kotlin/test” 폴더에 위치한 테스트들은 SpringBootTest를 사용해서는 안된다.

 classes()
    .that()
    .resideInAPackage("com.book")
    .should()
    .notBeAnnotatedWith(SpringBootTest::class.java)
    .check(ClassFileImporter()
         .importPath("build/classes/kotlin/test"))

레이어 테스트

논리적인 레이어를 구성해서 그 관계를 검증한다. 패키지로 구분된 controller, service, persistence 레이어를 각각 정의하고 각 레이어 별로 접근가능한 레이어들을 정의한다.

layeredArchitecture()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Persistence").definedBy("..persistence..")
    .whereLayer("Controller")
    .mayNotBeAccessedByAnyLayer()
    .whereLayer("Service")
    .mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Persistence")
    .mayOnlyBeAccessedByLayers("Service")

순환 참조 테스트

패키지 com.book 의 하위 패키지들을 slice로 구성해서 각 slice들이 순환 참조 하지 않는 지 검사한다.

slices()
 .matching("com.book.(**)")
 .should().beFreeOfCycles()
 .check(javaClasses)

Aside

유용한 애자일 의식들 (Agile Rituals)

이전 아틀리시안 취업 후기에서 짧게 언급했지만 아틀리시안에서는 애자일 관련된 미팅을 할 때 사용하는 여러가지 템플릿들이 존재한다. 보통 애자일 의식(ritual, ceremony)이라고 하면 스프린트 플래닝, 데일리 스크럼, 회고 등을 떠올리는데 그 외에도 팀이 업무를 하면서 마주치는 여러가지 상황에 맞게 활용할 수 있는 다양한 의식들이 존재한다.

애자일 의식으로 얻는 것

정확한 의사전달

이렇게 미리 정의된 도구들을 사용할 때 얻는 가장 큰 장점은 구성원간 의미전달이 아주 명확해 진다는 점이다. 예를 들어 “이번주 금요일에 회의 있어요”. “오늘은 일하는 날이에요” 라고 말하는 것 보다 “이번주 금요일에 스파링 있습니다”, “오늘은 GSD날이에요”. 라고 말하는게 세세한 차이까지 전부 전달할 수 있다.

생산성 증가

선진국을 중심으로 근로자의 월 평균 근무시간은 계속 줄어들고 있다. 어떻게 적게 일하면서 더 높은 생산성을 유지할 수 있을까? 회사 전체의 생산성을 위해서는 위해선 여러 사람이 모이는 회의나 의사결정의 생산성이 무엇보다 중요하다. 팀원이 5명인 경우 1시간 걸릴 의사 결정을 3시간 걸려서 끝낼 경우 10시간의 추가 근무가 필요하다.

결과물 명확화

각 의식 별로 단계별 결과물이 확실히 정해져 있기 때문에 회의가 중간에 다른 길로 샐 우려가 적다. 각 참가자들은 회의에 앞서 어떤 내용을 준비해야 하는지 진행되면서 어떤 행동을 해야하는지 미리 알 수 있게 된다. 이는 여러 사람이 모였을 때 쓸데 없는 시간 낭비를 줄일 수 있다.

10년전에 한국 회사에 근무할 때 목적을 알 수 없는 미팅이 참 많았다. 2-3시간을 내리 미팅을 하지만 회의록만 늘어날 뿐 결정된것은 하나 없고 미팅이 끝나도 구성원들이 무엇을 해야할지 감을 잡을 수 없는 그런 상황, 참 많이 겪어봤다. 그런상황에서는 단순히 지칭하는 용어를 미팅,회의에서 회고, 데일리 스크럼 바꾸는 것 자체가 큰 효과를 가진다. 기술회사에서 근무하는 대부분의 사람들은 해당 용어를 접했을 때 회의의 목적이 무엇인지, 참가자로서 어떻게 행동해야 하는지 대부분 바로 이해할 수 있다. 데일리 스크럼이나 회고 같이 업계 수준에서 사용하는 의식들은 훨씬 도입하기 쉽다.

아틀라시안의 팀플레이 북은 아틀라시안 내부에서 자주 사용되는 여러 의식들을 플레이(Play) 형태로 제공한다. 여기서는 플레이 북의 의식들을 위주로 어떤 상황에서 활용할 수 있는지 알아 본다. (위키삽입)

[insert page=’agile-ritual’ display=’all’]

Aside

Renovate로 의존성 관리

MSA에서 의존성 관리

MSA에서 라이브러리 업데이트는 꼭 해야 하지만 잊기 쉬운 특성을 가진다. 이는 손씻기나 양치질등과 닮아 있다. 열심히 해도 티가 안난다. 문제가 생기기 전까진!

백엔드 개발자의 백미는 자동화를 통해 적은 리소스로 많은 사용자들을 대상으로 한 서비스에서 발생하는 문제를 해결하는데 있다. Renovate는 버전업 프로세스를 사용자가 원하는 만큼 자동화 시켜준다.

주기적인 버전업은 잠재적인 기술부채를 줄이고 서비스를 더욱 안정적으로 쓸 수 있게 해준다. 최근에는 보안 관련 패치가 버전 업그레이드를 통해 자주 일어난다.

때문에 주기적으로 라이브러리를 업데이트 하지 않으면 기술 부채를 조금씩 저축하는 것과 같다. 지금 팀에서는 매 빌드시마다 SourceClear 를 사용해 소스코드와 라이브러리의 취약점을 분석한다.

작성한 코드에서 취약점이 발견 되는 경우 직접 수정을 하면 되지만 Spring이나 Jackson과 같이 사용중인 라이브러리 취약점이 발생하면 라이브러이 버전 업그레이드로 필요하다. 문제는 이 취약점이 꽤나 빈번하게 발견된다는 점이다. SourceClear와 연동하고 나서 버전 업그레이드가 팀내의 잡일처럼 되어버렸다.

그와중에 Renovate는 가뭄에 단비같은 존재, 사용자 설정한 내용대로 버전 업그레이드를 위한 풀리퀘스트를 생성해준다. 자동으로 master머지도 가능하며 시간당 생성하는 풀리퀘스트의 수 등 아주 다양한 설정이 가능하다.

팀에서는 kotlin + spring + gradle + docker 플러그인을 사용하고 있는데 이외에도 아주 다양한 플러그인을 지원한다. 아래 스크린샷에서도 확인할 수 있듯이 change log까지 전부 첨부해준다. 이건 개발자가 PR을 생성할떄 잘 안해주는 부분인데, Renovate는 세심하다.

Github에서는 App으로 제공되고 있으며 Gitlab이나 기타 설치형 저장소에서는 Renovate Bot을 직접 호스팅 하는 것도 가능하다. 아래는 팀의 일부 저장소에서 사용중인 renovate.json 설정 파일이다. 원하는 만큼 세세하게 설정이 가능하다. https://docs.renovatebot.com/self-hosted-configuration/

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "schedule": [
    "after 10am and before 5pm every weekday"
  ],
  "timezone": "Australia/Sydney",
  "prConcurrentLimit": 20,
  "prHourlyLimit": 1,
  "automerge": true,
  "dockerfile": {
    "enabled": true
  },
  "maven": {
    "enabled": true
  },
  "terraform": {
    "enabled": true
  },
  "extends": [
    "config:base"
  ]
}

Bitbucket에서 혼자서 열심히 버전 업중인 Renovate
Pull Request 템플릿

문제점

하지만 소프트웨어 문제가 언제나 그렇듯이, 항상 좋기만 한 건 없다. 그렇지 않아도 몇일전에 관련된 장애가 한 건 있었다. reactor-netty 에서 중대한 메모리 누수가 발견되서 부랴부랴 전부 롤백해야했다.

사실 Renovate의 문제라기 보다 시스템적으로 버전업시의 충격을 흡수 할 수 있는 쿠션이나 버퍼가 존재했어야 한다. 배포가 잦지 않다면 스테이징 서버가 이런 역할을 할 수 도 있겠지만 문제가 일어난 서비스는 배포가 아주 빈번하게 일어나서 버전 업그레이드가 문제인지 개발자가 머지한 PR이 문제인지 판단하기 힘들었다. 

그래서 우리는 모든 스프링기반 프로젝트의 베이스가 되는 프로젝트를  만들고 해당 프로젝트는 자동적으로 의존성을 갱신하고 배포되도록 했다. 

이글을 읽는 분들도 저장소에 Renovate를 적용해 보고 어느정도 자동화가 가능할지 테스트 해보길 권한다.

Aside

onErrorDropped explained

웹플럭스나 리액티브로 서비스를 개발하고 있다면 로그에서 onErrorDropped 메시지를 보게될 확률이 높다.  리액티브 애플리케이션을 사용하는 주된 이유는 높은 동시성을 달성하기 위한 것인데 동시성이 높아져 비동기로 여러 장소에서 데이터를 수신하는 경우 해당 에러가 자주 발생한다.

onErrorDropped가 발생 이유

이 에러 메시지는  정상적인 경우라면 오류가 발생했을 때 다운스트림으로 onError 에러를 전달해야 하지만 이미 다른 스레드에서 onError가 발생해 전체 스트림이 terminated된 상황임을 알려주는 것이다. 이미 종료된 스트림에 onError를 전달하는 행동은 리액티브 사양을 위반한다. 이 사양 링크 7번째 항목을 참고하자.

모든 것이 동기화된 세상에서는 이런 비정상적인 에러를 일어나지 않는다. Operators.onErrorDropped가 사용된 곳을 라이브러리에서 검색해보면 flatMap, merge와 같이 순서가 없이 실행되는 연산자들에서 주로 사용되고 업스트림의 순서를 보장하는 map이나 concat에서는 사용되지 않는다.

스프링 부트 애플리케이션이 WebClient를 통해 여러 데이터 세트를 동시에 가져온다면 발생한 에러는 이미 진행중인 다른 요청들에 영향을 줄 수 있다.

대처방법

onErrorDropped 로그가 너무 많다면 진짜 중요한 에러를 숨기고 있을 가느성이 높다. onErroDropped의 로그 레벨을 낮추는 몇가지 방법이 존재한다. (Project Reactor 코어에서 버전 업그레이드를 통해 조정될 가능성도 있다)

Hook을 사용

onErrorDropped 의 Hook을 설정하면 onErrorDropped가 발생했을 때 Hook에서 지정한 람다 함수가 실행된다. 프로젝트 리액터의 스케쥴러와 마찬가지로 Hook은 글로벌로 등록된다.

Hooks.onErrorDropped { log.info("onError Dropped.", it) }

onErrorContinue사용

동시성이 발생할 수 있는 기존 연산자를 확장해 각 Source 퍼블리셔들에 onErrorContinue를 추가해준다. 이 방법은 위 방법보다는 영향이 덜하며(less intrusive) 원하는 곳에만 적용이 가능하다. 다만 onErrorContinue의 사용법이 다소 복잡해 특정 연산만을 대상으로 작동함으로 메뉴얼을 잘 읽어봐야 한다. 아래는 merge연산자를 확장해

fun merge(sources: Iterable<Mono<T>>): Flux<T> = Flux.merge( 
    sources.map { 
        it.onErrorContinue { throwable, any -> log.info("Suppressed error in merging flux, {}", any, throwable)} 
}
)
Aside

이제는 패스워드 매니져와 보안 토큰을 사용할 때

점점 많은 사람들의 생활의 축이 인터넷 으로 옮겨 가면서 패스워드 관리의 중요성이 부각되고 있다. 특히 개발자들은 웹으로 제공되는 다양한 도구들도 사용하게 되기 때문에 패스워드 관리의 중요성은 두말할 필요가 없다.  다음 방법을 통해 패스워드 입력 시간은 줄어들고 기억해야할 패스워드의 수도 적어지며  혹시 모를 패스워드 노출 사고에도 더욱 안전해 진다.  보안 토큰에 관련 해서는 별도의 Wiki 링크에  더 자세히 적는다.

사이트 별로 다른 패스워드 사용

패스워드 관리의 기본이라고 할 수 있다. 노출된 패스워드가 다른 사이트에서 동작하지 않게 하기 위해 중요하다. 특히 소규모 쇼핑몰이나 토렌트, 도박 등,, 위험도가 높은 사이트를 사용할수록 본인이  알아차리지 못한 사이에 주요 계정들을 탈취 당할 수 있다.  기사에 의하면  52%는 패스워드를 재사용한다. 왜일까? 업계에는 일상 생활에서 여러 패스워드를 번갈아 입력해야 하는 피로를 일컫는  Password Fatigue라는  단어가 있다. 그래서 현실은 대부분의 사람들이 패스워드가 노출 되도 그대로 사용한다는 것이다. (관련기사 )

패스워드 매니져를 사용한다

사이트 별로 다른 패스워드를 사용하기 위해선 현실적으로 도구의 도움이 반드시 필요하다.  아무런 의미도 가지지 않는 임의의 문자 10자리를 매번 생각해내기는 거의 불가능하다. 각 사이트 별로 암기하는 것도 힘들고 적어놓고 관리하기도 힘들다. 그럴때 도움이 되는 것이 패스워드 매니져이다. 이를 통해 다양한 사이트나 앱에서 회원가입시에 안전한 패스워드를 생성할 수 있으며 로그인시에도 자동입력까지 지원 받을 수 있다. 필자는 무료 1Password와 크롬의 비빌번호 관리자, mac의 키체인을 번갈아 가며 사용한다.

보안 토큰을 활성화한다

패스워드 매니져의 사용을 장려하는  Wired의 기사에도 브라우져에서 제공하는 패스워드 관리자를 사용하지 말것을 장려하고 있다. 로컬 컴퓨터의 관리자 자격이 있으면 누구나 평문의 패스워드를 볼 수 있다는 우려때문이다. 하지만 기사에 나온 1Password나 LastPass를 100% 신뢰할 수 있는가? 물론 나는 돈을 지불하고 서비스를 사용할 만큼 해당 서비스를  신뢰하고 있지만  언제든지 예기치 않은 사고는 일어날 수 있다.

그래서 최악의 경우를 고려해서 까지 보안을 강화할 수 있는 방법이 있다. 바로 구글이나 패스워드 매니져등 탈취 당하면 다른 사이트의 보안까지 위협 받을 수 있는 사이트에 보안 토큰을 등록하는 것이다. 이 방법으로 물리적인 토큰없이는 비밀번호 만으로는  로그인이 허용되지 않는다. Google AuthenticatorAuthy와 같은 2MFA 앱을 사용해도 좋지만 매번 앱을 열어서 코드를 입력하기는 번거로우며 OTP이외에 다양한 인증방법도 지원하지 않는다. 인증시에 정해진 토큰을 가지고 있는지 확인하는 과정은 엔터프라이즈급 보안에서는 최고등급이라고 할 수 있다.

민감함 데이터를 다루지 않는 일반인의 경우에는 1번과 2번의 조합으로 충분하지만 개발자와 같이 여러 종류의 웹앱을 사용하는 사람들은 반드시 보안 토큰 등록까지 활성화 해놓길 바란다.

참고

https://haveibeenpwned.com (이메일을 입력하면 해외 사이트 기준으로 정보가 누출된 적 있는지 알려준다)

https://twofactorauth.org (2FA를 지원하는 사이트들의 목록을 제공한다. 일반 사용자들이 쓰는 OTP는 소프트웨어 토큰, 유비키나 타이탄 키는 하드웨어 토큰이다)

https://passwords.google.com (구글에 저장된 데이터를 기반으로 얼마나 많은 사이트에서 중복된 패스워드를 사용하고 있는지 알 수 있으며 패스워드가 누출된 사이트에 대해서는 변경을 권하기도 한다)

패스워드 매니져

https://www.dashlane.com

https://www.lastpass.com

https://1password.com

보안토큰

https://tacogrammer.com/wiki/fido-u2f-유비키-yubikey/

https://cloud.google.com/titan-security-key/

https://www.yubico.com/product/yubikey-5-nano

이제는 패스워드 매니져와 보안 토큰을 사용할 때

엘라스틱서치에 넣은 데이터가 키바나 에서 표시되지 않을 때

관련현상

Index Pattern에 Time Filter 를 지정했음에도 Discover에 아무것도 보이지 않는 현상이 있었다.  엘라스틱서치 포럼이나 구글링을 해봐도 시간 간격을 잘 조정하라는 이야기뿐.

해결방법

인덱싱 생성시 timestamp에 OFFSET 정보 (e.g. +9:00) 을 추가해준다. Kibana에서 시간을 제대로 해석하지 못해서 발생한 문제였다.

//As is
XContentFactory
    .jsonBuilder()
    .startObject()
    .field("keyword", keyword)
    .field("hits", totalHits)
    .field("seconds", tookSeconds) 
    .timeField("@timestamp", LocalDateTime.now()) 
    .endObject())
//To be
XContentFactory 
  .jsonBuilder() 
  .startObject() 
  .field("keyword", keyword) 
  .field("hits", totalHits) 
  .field("seconds", tookSeconds) 
  .timeField("@timestamp",ZonedDateTime.now(ZoneId.of("Asia/Tokyo")).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) 
  .endObject())
Aside

코딩 교육이 필요한 이유

최근 일자리 시장이 개발자 위주로 공급되면서 일반인들, 즉 비전공자들도 코딩 교육을 받는 경우가 늘어나고 있다. 코딩 열풍, 명과 암

취업을 하기 위한 것이 아니라면 왜 코딩을 배워야 할까? 여기서는 단순히 ‘코딩’만을 의미한다. 실제 많은 개발자들은 숲을 보지 못하고 나무만을 보는 개발자들을 ‘코더’라고 부르며 하대한다. 기반이 되는 공학적인 지식에 대한 배움이나 깨우침 없이 단순히 코딩 교육 만으로 일반 사람들이 어떤 이득을 얻을 수 있을까?

나는 개발자인 동시에 현대인으로 살아가면서 프로그래밍을 통해 다음과 같은 장점을 얻었다.

업무의 자동화가 가능하다

어떻게 보면 가장 유형(有形)의 장점일 것이다. 서비스 직종을 제외한 임금 노동자, 전문직, 학생등 대부분의 일반인들은 컴퓨터로 업무를 본다. 그들이 수행하는 업무들은 반복되는 작업들을 포함한다. 그런 단순 업무들을 코딩을 이용해 자동화할 수 있다. 많은 개발자들은 반복되는 코드나 데이터는 좋지 않다는 것을 본능적으로 체득하고 있다. (DRY원칙)

이런 예들은 실제로 많은 도메인에 걸쳐서 발견된다. 내가 발견한 예중 하나는 회계팀에서 각종 매크로를 사용해 비용이 자동적으로 계산되고 검증까지 수행해주는 특정 양식의 엑셀을 제공하는 것이다. 이는 빌 게이츠가 이야기한 인재 채용의 원칙과도 맞닿아 있다. ‘힘든일은 게으름 사람에게 시킨다. 그들은 쉽고 빠르고 편한 방식을 찾아낸다’. 실제로 나도 동료로 게으른 사람을 선호한다. 그들은 동일한 업무를 반복하지 않고 도구나 자동화를 사용해 일을 해결하고, 뒤에 다른 사람들이 똑같은일을 반복하지 않도록 하기 때문이다.

논리적으로 사고할 수 있는 능력을 키워준다.

컴퓨터는 기본적으로 수학과 과학으로 움직이는 커다란 계산기이다. 비논리적인 사고가 끼어틀 틈은 없다. 컴퓨터에서 생기는 버그들은 모드 그 원인과 결과가 명확히 존재하며 그것을 통제하지 못한 사람과 시스템에게 그 책임이 있다.

정치적으로도 IT직종 인력들이 대체적으로 진보적인 사고를 가지고 리버럴(liberal)의 영역에 가까운 것은 익히 알려진 사실이다. 보수나 진보의 옳고 그름을 떠나서 그들이 성장해온 토양과(오픈소스) 일하는 회사들의 기업문화등을 고려했을 때 당연한 결과이다.

과거에는 자연과 종교에 대한 피상적인 해석들이 자연을 이해하고자 하는 인간들을 가로 막았지만 이제는 그런 장애물 들은 존재하지 않는다. 코딩을 배우게 되면 절대적인 지식은 존재하지 않으며 본인의 무지에는 끝이 없다는 과거 과학자들의 마음을 배우는 것이 가능하다.

추상화에 능해진다

추상의 개념은 무었인가? 여러가지 사물에서 공통되는 개념들을 추출해내는 능력이다. 날이 갈수록 복잡해지는 현대사회에서 핵심을 꽤뚫어 보는 능력은 매우 중요하다고 할 수 있다.

컴퓨터는 계층화된(Layered) 구조를 가지고 있어서 하부의 구조들에 신경쓰지 않고 현재 업무에 집중할 수 있는 추상화의 개념은 매우 중요하다. 그렇기 때문에 개발자는 습관적으로 추상화를 수행하는데 그렇지 않고서는 전체의 시스템을 다 이해하는 일은 천재에 가까운 한두명을 제외하고는 불가능에 가깝기 때문이다.

코딩을 배우게 되면 자연스럽게 하위의 복잡성은 무시하고 현재 문제 영역에서 관심을 가지고 있는 해결방법에만 집중할 수 있도록 도와준다.

일반인들의 모든 문제 해결에 이와같은 추상화가 적용될 수는 없겠으나 문제의 본질과 그 해결방법에 신속하게 도달하기 위해서는 추상화가 꼭 필요한 능력이라고 볼 수 있다.

코딩 교육이 필요한 이유

REST 아키텍쳐 레벨 3단계, HATEOAS 를 꼭 적용해야 할까?

HATEOAS가 이루고자 하는 이상과 현실의 차이가 존재한다. 개발과 의사결정 속도가 중요한 조직에서는 쓰지 않아도 좋다.

API 디자인을 시작하면서 뭔가 정말 제대로 REST 아키텍쳐를 만들고 싶어서 마틴 파울러가 쓴 REST성숙도 모델도 읽어보고 여러가지 자료 조사를해 보았다.

하나의 엔드포인트를 여러개의 리소스에 할당하기 보다 각 리소스를 그에 맞는 엔드포인트에 맵핑하고 API의 동작은 HTTP의 method를 동사로서 사용한다. 여기까지가 레벨2,대부분의 개발자들(백엔드,클라이언트모두 포함)이 이부분은 쉽게 이해하고 따라할 수 있지만 문제는 레벨 3부터 시작되었다.

하이퍼 미디어 컨트롤, 뭔가 멋진 단어들을 많이 모아 놓았지만 이 레벨은 HATEOAS가 적용되었냐 아니냐가 그 판단 기준이다.

REST API의 창시자인 로이필딩 (Roy Fielding)은 REST API는 반드시 하이퍼 미디어 드리븐이어야 하며 그렇지 않다면 그것은 REST가 아니라 RPC라고 주장한다. , 2008년의 글이지만 논문의 원저자이기 때문에 지금까지 미치는 파장이 적지 않은 것 같다.

HATEOAS의 장점

HATEOAS 를 적용하면 얻게 되는 장점이 무었일까, 당장 생각나는 것은 애플리케이션의 리소스가 상태머신(State Machine)으로서 해석될 수 있다는 것이다. 즉 상태머신이 가지는 장점을 API인터페이스에도 그대로 적용할 수 있다. 두번째는 API와 컨슈머의 결합이 느슨해진다는 점이다.

무슨 말이냐 하면 아래와 같은 응답이 있다고 가정하자. API응답을 봐도 상품 목록에서 이동할 수 있는 상태는 어떤것인지 짐작이 간다. 상품목록 API응답에서는 detail 과 order로 이동할 수 있다. 개발자의 입장에서도 API를 보면 다음 상태가 어떻게 변화할 수 있는지 예측이 가능하다.

두번째 느슨한 결합은, API 컨슈머 쪽에서 detail을 응답 받기위한 URL을 저장하지 않고 href 값을 얻어와 호출한다는 뜻이다. 그렇게 구현함으로서 컨슈머는 API 엔드포인트의 변화로부터 자유로워진다. (장점을 이렇게 적다보니 미래의 API에 적용하고 싶은 맘이 든다 위험하다. 하지만 이 포스트는 분명히 적용하지 않아도 좋다는 주장을 하기위함이다.)

</pre>
<pre>{
  "links": [
    {
      "rel": "detail",
      "href": "http://server/api/items/12345"
    },
    {
      "rel": "order",
      "method": "post",
      "href": "http://server/api/items/order"
    }
  ]
}</pre>
<pre>

하지만 그럼에도 불구하고 REST API의 사용 주체가 빠른 속도의 의사결정과 개발을 중시하는 웹 서비스 업체라면, 너무 아카데미컬한 주제에 파뭍혀 실제 문제를 해결하는데 집중하지 못한다는 비난을 들을 수도 있을 것 같다. 오히려 이런 문제들 때문에 GraphQL같은 대안들이 나오게 된것이 아닐까?

물론 이런 REST가 추구하는 이상은 소프트웨어 엔지니어로서 성취하고 싶은 것이나, 회사원으로서 서비스의 전개 속도도 빠트릴 수 없는 부분이다. 그래서 슬며서 이슈를 던져보고 대부분 사람들이 쉽게 이해하지 못한다면 HATEOAS는 적용하지 않는게 좋겠다는 결론을 얻었다.

Restful API를 문서화를 도와주는 swagger API같은 도구들도 오히려 HATEOAS의 도입을 저하시키는 이유가 된다. 개발자가 손쉽게 전체 API엔드 포인트를 파악할 수 있으니 어떤 가정을 가지고 API 를 탐색하게 되고 이것은 강한 결합으로 이루어진다. (예를들어 swagger 페이지를 보고 상품 목록은 GET /items, 상품 상세 정보는 GET /items/1 과 같은 유추가 가능하다. HATEOAS 개념을 이용하면 상품 목록 리소스에서 전환 가능한 변화들이 link에 나타나야 한다. ).

물론 swagger를 사용해도 HATEOAS정보인 link 를 참조하는 것이 가능하나, link 사용을 클라이언트에게 강조하는 것이 원천적으로 불가능하기 때문에, API 컨슈머가 특정 가정을 가지고 (= 결합) API를 사용해도 막을 방법이 없다.

로이필딩이 인정하지 않는 REST API 면 어떠한가, 실제 HATEOAS나 REST성숙도 레벨 3까지에 대한 이해가 존재하지 않는 조직이라면 원작자의 의도를 무시하고 RPC스타일로 사용할것이 확실하다.

관련 링크들

https://martinfowler.com/articles/richardsonMaturityModel.html

https://stackoverflow.com/questions/1164154/is-that-rest-api-really-rpc-roy-fielding-seems-to-think-so

https://stackoverflow.com/questions/1139095/actual-examples-for-hateoas-rest-architecture

https://www.infoq.com/news/2009/04/hateoas-restful-api-advantages

REST APIs must be hypertext-driven

https://opencredo.com/designing-rest-api-fine-grained-resources-hateoas-hal/

https://jeffknupp.com/blog/2014/06/03/why-i-hate-hateoas/

https://softwareengineering.stackexchange.com/questions/348054/is-rest-and-hateoas-a-good-architecture-for-web-services/348167

https://www.infoq.com/news/2011/11/web-api-versioning-options%3bjsessionid=326B76743A247A4FDF42738061443BFE

 

Aside