태그 보관물: readability

Rust 프로그래밍, 코드 가독성을 높이기 위한 and_then() 활용

Rust는 선언적 프로그래밍을 적극적으로 사용하기에 method chaining을 많이 사용하는데 이는 가독성을 높이고 컴파일러가 최적화를 수행하는데 적합한 형태이기 때문이다. 이 포스팅에서는 method chaining을 유지하면서도 중첩된 Result 혹은 Option 형식을 피할 수 있는 and_then()에 대해 알아본다.

match 문을 이용한 처리

세개의 함수 각각이 다음과 같이 Result를 반환한다고 해보자.

use std::io::{Error, ErrorKind};

/// 1. 입력된 사용자 JSON 검증, 빈 문자열이 아니면 Ok 반환.
fn validate_json(raw: &str) -> Result<&str, Error> {
  if raw.is_empty() {
    Err(Error::new(ErrorKind::InvalidData, "empty user input"))
  } else {
    Ok(raw)
  }
}

/// 2. 사용자 id 반환
fn get_user_id(username: &str) -> Result<u32, Error> {
  if username == "admin" {
    Ok(1)
  } else {
    Err(Error::new(ErrorKind::NotFound, "user not found"))
  }
}

/// 3. 토큰 생성
fn generate_token(user_id: u32) -> Result<String, Error> {
  if user_id == 1 {
    Ok(format!("token_for_user_{}", user_id))
  } else {
    Err(Error::new(ErrorKind::InvalidInput, "user id unavailable"))
  }
}

각 함수들은 1. 유효한 JSON인지 판별하고, 2. 사용자를 검색해서 id를 반환한 후, 3. 해당 사용자에 대한 토큰을 생성해서 반환한다.

이를 match 문을 이용해서 처리 하면 다음과 같이 될 것이다.

fn main() {
  let raw_input = "admin";

  // 1. JSON 유효성 판별
  match validate_json(raw_input) {
    Ok(user) => {
      // 2. 유효하면 사용자 id 검색
      match get_user_id(user) {
        Ok(id) => {
          // 3. 사용자가 유효하면 토큰 생성
          match generate_token(id) {
            Ok(tok) => {
              // 토큰 출력
              println!("{:?}", tok);
            },
            // 토큰 생성시 에러 처리
            Err(e) => eprint!("Error: {e}")
          }
        },
        // 유효하지 않은 사용자
        Err(e) => eprint!("Error: {e}")
      }
    },
    // JSON이 유효하지 않음
    Err(e) => eprint!("Error: {e}")
    }
  }
}

모든 match arm들을 명시해야 하는 match 구문의 제약 때문에 ResultOkErr에 대한 경우를 모두 적어 주어야 하고 이 때문에 코드가 장황하고 가독성이 떨어진다.

map() 활용

이전의 “Rust 프로그래밍에서 map() 활용” 에서 언급 했듯이 map()은 container뿐만 아니라 ResultOptionOk / Some인 경우 동작을 정의하는데 사용할 수 있다. 따라서 map()을 이용하면 다음과 같이 작성할 수 있다.

fn main() {
  let raw_input = "admin";

  let tok = validate_json(raw_input)
      .map(|user| get_user_id(user)
      .map(|id| generate_token(id)));

  // Ok(Ok(Ok("token_for_user_1")))
  print!("{:?}", tok);
}

보기에는 훨씬 깔끔해 졌지만, 각 함수들이 Result를 반환하기 때문에 tok변수의 type이 Result<Result<Result<String, ...>, ...>, ...> 같은 여러번 중첩된 형식이 되어 여러번 unwrap 해주어야 하는 보기 싫은 형태가 된다.

and_then() 활용

이렇게 method chaining으로 중첩된 ResultOption이 반환되는 경우에는 and_then()을 사용해보자.

fn main() {
  let raw_input = "admin";

  let tok = validate_json(raw_input)
      .and_then(|user| get_user_id(user))
      .and_then(|id| generate_token(id));
 
  // Ok("token_for_user_1")  
  print!("{:?}", tok);
}

이렇게 method chaining을 하면 중복된 반환형식 들이 모두 제거되고 Result<String, Error> 형식으로 tok 변수가 반환되어 훨씬 가독성 높은 코드를 유지할 수 있다.

결론

map()and_then()은 모두 Rust의 선언형 프로그래밍을 가능하게 하는 중요한 요소로 비슷해 보이지만, closure가 무엇을 반환하는가에 따라 그 활용이 달라진다.

  • map(): 내부 값의 형태만 바꿀때(실패 가능성이 없는 단순 변환)
  • and_then(): 변환 과정에서 또 다른 Option이나 Result가 발생할 때(실패 가능성이 있는 연쇄 작업)

동일한 코드를 matchif let으로 작성할 수도 있지만, 이른바 “match hell”의 조짐이 보이고 그로 인해 코드의 가독성이 떨어진다면 and_then()을 활용해 보자.