개발 일지

Clean Code in Reactive

북극곰은콜라 2024. 1. 25. 17:56
반응형

개요

Reactive 모델은 익숙한 기존 Thread 모델보다 가독성이 떨어지는 이슈가 있다.
이러한 문제는 Reactive의 한계가 아닌, 코드를 작성할 때 가독성을 고려하지 않고 작성하면 발생한다.
이번 리뷰는 가독성 좋은 Reactive 코드를 위한 개념들에 대해 정리한다.

 


1. 비즈니스 로직은 Operator가 기준이 된다.

userService.getFavorites(userId) 
           .map(Favorite::toRequestModel)
           .flatMap(favoriteService::getDetails)
Reactive 모델은 Data Flow가 곧 비즈니스이다.
Data Flow는 결국 비즈니스의 “행위”가 되며, 행위는 operator의 묶음으로 표현하게 된다.
이는 결국 추후 Reactive 코드를 읽을 때 operator를 기준으로 비즈니스를 이해하게 되도록 만든다.

위 예제의 비즈니스 해석은
1. userService에서 유저가 좋아하는 것들을 가져온다. (Create Data Flow)
2. requestModel로 변환한다. (change data format)
3. favoriteService로 부터 details를 가져오는 스트림으로 변환한다. (to another data flow)

1-1. Operator를 기준으로 구분을 나눈다.

// Reactive Operator is the key to Reactive programming

//Bad
userService.getFavorites(userId).map(Favorite:toRequestModel)
           .flatMap(favoriteService::getDetails) 
 
// Good
userService.getFavorites(userId) 
           .map(Favorite::toRequestModel)
           .flatMap(favoriteService::getDetails)
코드를 읽을 때, 위 → 아래, 왼쪽 → 오른쪽 으로 읽는다.
operator를 수직으로 배열하여, 하나의 행위를 하나의 라인으로 배치되도록 하면 가독성이 높아진다.

 


1-2. Operator간의 간격을 줄인다.

// Reduce the distance between Operators

//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
     )
}
operator간의 간격이 길어지면, 개발자는 비즈니스를 해석하기 위해 많은 라인을 읽어야 한다.
따라서 길어지는 내부 로직을 별도의 function으로 정의하여, 피로도를 줄인다.
이 방식은 function의 name으로 내용을 충분히 설명할 수 있어야한다.
너무 포괄적인 function naming은 오히려 가독성을 떨어뜨릴 뿐 아니라, 혼란을 가중시킨다.
ex) Map<String, Object> getOthers()
→ 다른 무언가를 가져오는 함수로 보이지만, 구체적인 내용을 알 수 없어 개발자가 무조건 해당 함수를 들여다 보아야 한다.

1-3. 적절한 Operator 사용

// Use Operators matching the name

//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)
operator를 적절히 사용하는 것이 가독성을 높이는 방법이다.
위 log.info()는 로깅 작업으로, map Operation이랑 전혀 관련이 없다.
이를 분리하여 가독성을 높일 수 있다.

2. Operation 결과는 Null 대신 empty()

// Null equivalent in reactive is Empty

//Bad
Flux.just("a", "b", "c", "d")
        .flatMap(s -> {
          if (s.equals("b")) {
            return null;
          }
          return Mono.just(s);
        })
        .filter(Objects::nonNull)
        .doOnNext(log::info)
        .subscribe();

//Good
Flux.just("a", "b", "c", "d")
        .flatMap(s -> {
          if (s.equals("b")) {
            return Mono.empty();
          }
          return Mono.just(s);
        })
        .doOnNext(log::info)
        .subscribe();
Operation의 결과는 Null이 아닌 Empty로 처리한다.
NPE의 위험 뿐 아니라, Null Check 로직은 가독성을 배로 힘들게 만든다.

3. Method Reference 사용

// Use a method reference

// 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) 
                            
           }
Method Reference는 variable을 숨기며, 가독성을 높인다.
zip 등 일부 Operator를 제외하면, 모두 넘어오는 인자는 단건이며, 이는 생략하여 가독성을 높일 수 있다.

4. Operator의 분리

// Don’t let Collection API shadow Operators

//Bad
Flux.just("abcd", "1234")
        .flatMap(s -> Flux.fromIterable(Arrays.stream(s.split(""))
            .map(c -> c + "X")
            .collect(Collectors.toList())))
        .doOnNext(log::info)
        .subscribe();
        
//Good
private Stream<String> mapToX(final Stream<String> upstream) {
  return upstream.map(s -> s + "X");
}

Flux.just("abcd", "1234")
        .flatMap(s -> Flux.fromIterable(
            this.mapToX(Arrays.stream(s.split("")))
                .collect(Collectors.toList())))
        .doOnNext(log::info)
        .subscribe();
        
//Kotlin
private fun <T, R> Iterable<T>.streamMap(transform: (T) -> R): List<R> = this.map(transform)

Flux.just("abcd", "1234")
            .flatMap { Flux.fromIterable(it.toCharArray().toList().streamMap { c -> c + "X" }) }
            .doOnNext{println(it)}
            .subscribe()
Reactor의 map과 java streams의 map은 operation 이름이 중복된다.
이는 혼동을 불러올 수 있기 때문에, Function을 분리하여 사용한다.

5. Publisher Type 변수명

// Make variable name distinguishable

// 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")
     }
Mono 또는 Flux 타입의 변수를 사용할 때, 기존 변수들과 차이를 두고 naming 해야한다.
명확하게 해당 변수는 Publisher를 의미한다고 알려줘야, 혼동이 없다.

6. 비즈니스 내 직접적인 subscibe() 피하기

// Avoid calling subscribe() directly

// Bad
userService
         .getUser(req)
         .flatMap(userService::changePassword)
         .doOnNext {
              auditLogger
                      .auditLog(it)
                      .subscribe()
         }
비즈니스에서 직접적인 subscribe()가 늘어나면, 좀비 프로세스가 발생할 수 있다.
이는 시스템에 큰 부담으로 작용하기 때문에 피해야 한다.
별도의 Subscribe를 담당하는 모듈을 만들어 관리하는 방안이 필요하다.

7. Log는 Operation과 함께 작성

//Bad
public Mono<String> funcA() {
  log.info("funcA() process");
  
  return Mono.just("a")
}

public Mono<String> service() {
  Mono<String> a = funcA();
  
  // no process with a
  return Mono.just("b");
}

-----------------------------------------------------------
//Good
public Mono<String> funcA() {
  return Mono.just("a")
    .doOnNext(s -> log.info("funcA() process"))
}

public Mono<String> service() {
  Mono<String> a = funcA();
  
  // no process with a
  return Mono.just("b");
}
일반적으로 로그는 특정 프로세스의 현 상태 또는 프로세스 기록을 남긴다.
reactive 환경에서는 [함수의 호출 != 데이터 처리] 이기 때문에 로그는 operation과 함께 작성해야 오해가 생기지 않는다.

8. reusable Method 작성

//Bad
public Flux<Article> getUserArticle(final User user) {
  return articleService.findAllByUserId(user.getUserId)
}

//Good
public Flux<Article> getUserArticle(final String userId) {
  return articleService.findAllByUserId(userId)
}
지속적으로 늘어나는 함수를 줄이기 위해서는, 함수의 재사용은 필수이다.
재사용 가능한 함수를 위해서는, 특정 service 또는 vo에 종속적이지 않은 함수를 작성하는 것이 바람직하다.

 


Conclusion

Reactive Code의 가독성을 높이기 위한 방법
1. 적절한 Operator를 사용한다.
2. 함수는 하나의 기능만 하도록 한다.
3. 변수 명 및 함수 명에 공을 들인다.
로 요약할 수 있다.
clean code를 지향할 때, 좋은 방법 중 하나는 꼼꼼한 코드리뷰가 있다.
이후 코드 리뷰 시 변수 명 및 함수 명을 다 같이 확인하고, 논의를 거쳐 최종 merge 되는 과정이 코드 품질에 도움이 될 것으로 생각한다.
당연한 이야기들로 보이기 때문에, 자주 간과하게 되는 것들이다...

 


REFERENCE

 

 

반응형