클린 (리액티브) 코드

클린코드(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 등과의 차이점을 확실히 파악하도록 하자.