클린 (리액티브) 코드

클린코드(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

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