대략적인 러스트 CLI 구현 방법

notJoon·2023년 9월 27일
0

CLI(Command-Line Interface)은 명령어를 이용해 시스템과 상호작용 할 수 있게 하는 인터페이스입니다. GUI(Graphical User Interface)에 비해 접근성은 떨어지지만, 명령을 잘 조합하면 고수준의 제어도 가능하기 때문에 익숙해지면 이거만한 도구도 없다고 개인적으로 생각합니다. 좀 있어보이는 효과는 덤입니다.

UNIX의 grep 명령어는 텍스트에서 특정 패턴을 찾는 도구입니다. 예를 들어, server.log 파일에 다음과 같은 내용이 있다고 가정해봅시다.

192.168.1.1 - - [21/Sep/2023:10:01:42] "GET /index.html HTTP/1.1"
192.168.1.2 - - [21/Sep/2023:10:01:50] "GET /about.html HTTP/1.1"
192.168.1.1 - - [21/Sep/2023:10:01:55] "POST /login HTTP/1.1"
192.168.1.3 - - [21/Sep/2023:10:02:01] "GET /contact.html HTTP/1.1"

이 로그 파일에서 특정 IP를 가진 로그만 뽑아내려면 다음과 같이 처리할 수 있습니다.

grep '192.168.1.1' server.log

grep 단독으로 사용해도 꽤 유용하지만, UNIX의 다른 도구인 awk나 파이프 연산자를 잘 조합하면 복잡한 케이스의 데이터도 추출할 수 있습니다. 192.168.1.1라는 특정 IP에서 어떤 HTTP 요청 유형(GET, POST 등)이 있었는지를 알고 싶다면 다음과 같이 작성할 수 있습니다.

grep '192.168.1.1' server.log | awk '{print $6}' | tr -d '"'

그렇다면 이런건 어떻게 만들 수 있을까요? 이번 글에서는 러스트를 이용해 CLI을 어떻게 개발하는지에 다루겠습니다.

명령어 구현하기

CLI는 크게 명령어 입력과, 결과 출력 두 단계로 나눌 수 있습니다. 이 일련의 과정을 처리하려면 입력으로 들어온 문자열을 파싱한 다음에 정해진 규칙에 따라 명령어 인자들을 인식하고, 해당하는 함수를 호출하면 됩니다.

여기서는 덧셈과 뺄셈을 수행하는 계산기를 CLI로 만들어보겠습니다. 아래는 대략적인 명령어 구조입니다. 추후 글이 진행되가면서 이 구조는 조금씩 바뀌기 때문에 그냥 그런가보다 하면서 넘어가면 됩니다.

binop <number1> --add | --sub  <number2>

곱셈과 나눗셈은 없는데, 이건 편의상 연산자 우선순위(Operator Precedence)를 배제하기 위해서입니다. 추가로 음수를 입력 값으로 받는 것도 제외하겠습니다. 이걸 처리하려면 좀 복잡한 파서를 추가해야 하는데 그러면 이 문사의 내용 범위 밖이기 때문입니다. 절대 귀찮아서가 아니에요.

입력

이제, 명령어 입력을 어떻게 받는지 알아보겠습니다. 단순히 문자열을 받고 분해한 다음 올바른 명령인지 검증하는 파서를 제작하면 됩니다. 이 방식은 러스트 책의 12장 I/O 프로젝트 문단에서 소개하는 내용입니다. 하지만 이 방법은 꽤 번거로운 작업이 많습니다. 하지만 clap을 이용하면 이 과정을 간단하게 넘어갈 수 있습니다. 명령어와 플래그, 입력값을 따로 인식할 필요 없이 그냥 “명령어가 어떻게 생겼고 이런 기능들이 있을거야”라고 정의만 해두면 파서 같은 부분은 라이브러리 단계에서 가져다 쓰면 됩니다.

전체 코드는 링크를 참고해주세요. 다만 제가 실수로 단계 별로 커밋하는걸 깜박해서 코드가 업데이트 되는 흐름은 날아갔습니다.

저는 입력을 받는 부분을 AppSubcommand로 나눠서 코드를 작성합니다. 여기서는 앞에 BinOp을 붙여서 BinOpAppBinOpSubcommand로 구조체와 열거형을 정의하겠습니다.

// main.rs

use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
struct BinOpApp {
    #[clap(subcommand)]
    command: BinOpCommand,
}

#[derive(Subcommand, Debug)]
enum BinOpCommand {}

derive macro나 attribute를 사용할 수 없다는 오류가 발생하는 경우 높은 확률로 clap을 프로젝트에 추가할 때 feature를 지정하지 않아서 생기는 문제입니다. tokio 같이 derive macro를 이용하는 라이브러리를 사용할 때도 이런 경우가 종종 있기 때문에 cargo.toml 파일을 확인해보면 얼추 해결할 수 있습니다.

clap = { version = "4.4.5", features = ["derive"] }

옵션에 derive를 추가하면, derive macro 같은 요소들을 사용할 수 있습니다.

이름에서 알 수 있듯이 BinOpApp은 프로그램의 엔트리 포인트이고, 사용자가 입력하는 모든 명령어 인자들을 관리하는 역할을 합니다. 또한 BinOpApp은 필드로 BinOpCommand를 받는데 이건 실제 작업을 정의하는 열거형이고, 이 정보를 이용해 BinOpApp은 어떤 하위 커멘드(subcommand)가 실행되어야 할 지 결정합니다.

이제 덧셈과 뺄셈을 추가하면 됩니다. 이건 BinOpCommand에 추가하면 됩니다.

#[derive(Subcommand, Debug)]
enum BinOpCommand {
	#[clap(about = "add two numbers")]
	Add,
	#[clap(about = "subtract two numbers")]
	Sub,
}

clap의 parse() 메서드를 이용하면 명령어를 쉽게 파싱할 수 있습니다.

fn main() {
    let app = BinOpApp::parse();
    println!("{:?}", app);
}

코드를 실행해보면 명령어 목록을 보여주는 help가 출력됩니다.

Usage: binop <COMMAND>

Commands:
  add   Add two numbers
  sub   Subtract two numbers
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

매개 변수 받기

하지만 지금은 명령어의 종류만 추가했기 때문에 그 어떤 계산기스러운 동작을 하지 않습니다. 계산기스러운 동작을 하기 위해 이제 어떤 값을 계산할 것인지 정의하겠습니다.

값을 넣으려면 하위 커멘드에 플래그(flag) 타입을 지정하면 됩니다. 일단은 간단하게 i32 타입만 받도록 하겠습니다.

#[derive(Subcommand, Debug)]
enum BinOpCommand {
	#[clap(about = "add two numbers")]
	Add {
		#[clap(short='a', help = "The first number")]
		lhs: i32,
		#[clap(short='b', help = "The second number")]
		rhs: i32,
	},
	#[clap(about = "subtract two numbers")]
	Sub { ... },
}

lhs, rhs는 이항 연산에서 왼쪽 값과 오른쪽 값을 의미합니다. 또한 각 플래그마다 #[clap(..)] 매크로를 지정해 단축키(short)와 도움말을 추가했습니다. short는 기본값으로 필드 이름의 맨 앞글자를 사용합니다. 하지만 이 경우 중복되는 글자가 생길 수 있기 때문에 위의 코드 처럼 명시적으로 플래그를 지정할 수도 있습니다.

프로그램이 명령어와 각 플래그를 잘 받는지 확인하기 위해 메인 함수를 수정해보겠습니다.

fn main() {
    let app = BinOpApp::parse();
    match app.command {
        BinOpCommand::Add { lhs, rhs } => println!("{} + {}", lhs, rhs),
        BinOpCommand::Sub { lhs, rhs } => println!("{} - {}", lhs, rhs)
    };
}

패턴 매칭을 사용하면 인자로 받은 값을 쉽게 꺼낼 수 있습니다. 지금은 두 값을 인식하면 단순히 어떤 연산을 하겠다고 보여주는 정도이기 때문에 prinln!을 이용해 수식을 출력하도록 작성했습니다.

위에서 처럼 cargo run으로 이 프로그램을 실행시켜도 CLI는 동작하지만, 각 연산이 어떤 플래그를 가지고 있는지는 확인할 수 없습니다. 하지만 아래의 명령어를 실행하면 코드와 직접 상호작용할 수 있습니다.

cargo run -- binop -a 1 -b 2

터미널에 1 + 2라는 식이 출력되면 성공입니다.

플래그 타입 지정

이제 얼추 계산기스러운 동작을 하긴 하지만, 각 연산마다 플래그를 지정하는 것은 좀 많이 번거로운 것 같습니다. 플래그를 하나하나 입력하는 것 보다 아예 하나의 플래그에 여러 값을 받도록 하고 적당히 잘 가공해서 처리하면 어떨까요?

다시 하위 커멘드를 수정해보겠습니다. 이번에는 플래그를 하나만 받도록 수정하면 됩니다.

#[derive(Subcommand, Debug)]
enum BinOpCommand {
    #[clap(about = "Add two numbers")]
    Add {
        #[clap(short = 'n', help = "The numbers to add", )]
        numbers: Option<String>,
    },
    #[clap(about = "Subtract two numbers")]
    Sub {
        #[clap(short = 'n', help = "The numbers to subtract")]
        numbers: Option<String>,
    },
}

한번에 여러 값을 받으려면 Vec<T> 타입을 이용하는게 깔끔하겠지만, 아쉽게도 인식을 못해서 부득이하게 String 타입을 받도록 지정했습니다. GPT에게 방법이 있냐고 물어보면 매크로에 multiple(true)를 추가하면 된다고 하긴하는데 이건 아예 존재하지도 않습니다. 그래서 저는 입력으로 들어온 문자열을 띄어쓰기 단위로 분리한 다음에 i32 타입으로 파싱하는 식으로 처리하기로 했습니다. 문자열이 길어지면 엄청 느려지겠지만, 솔직히 누가 그 정도로 길게 문자열을 넣을까요?

[2023-09-28 수정 내역]
위에서 Vec<T>를 인식하지 못한다 했는데, 이후에 따로 찾아보니 #[arg(num_args(0..))]를 원하는 플래그 위에 추가하면 쉽게 처리할 수 있었습니다. 출처

#[derive(Subcommand, Debug)]
enum Command {
 #[clap(about = "...")]
 Foo {
  	#[clap(...)]
	#[arg(num_args(0..)]
  	foo_flag: "벡터와 같은 여러 값을 받는 타입"
}

또한 String 타입을 Option으로 묶었는데, 기본 값을 설정하고 플래그를 이용해 값을 지정하지 않으면 기본 값인 0을 출력하도록 할 것입니다. 그럼 수정된 메인 함수는 아래와 같아집니다.

fn main() {
    let app = BinOpApp::parse();
    match app.command {
        BinOpCommand::Add { numbers } => {
            let numbers = numbers.unwrap_or(String::from("0"));
            let num_vec = numbers
                .split_ascii_whitespace()
                .collect::<Vec<&str>>()
                .into_iter()
                .map(|n| n.parse::<i32>().unwrap());

            println!("{}", num_vec.sum::<i32>());
        }
        BinOpCommand::Sub { numbers } => {
            let numbers = numbers.unwrap_or(String::from("0"));
            let num_vec = numbers
                .split_ascii_whitespace()
                .collect::<Vec<&str>>()
                .into_iter()
                .map(|n| n.parse::<i32>().unwrap())
                .collect::<Vec<i32>>();

            let mut sub = num_vec[0];
            for n in num_vec[1..].iter() {
                sub -= n;
            }

            println!("{}", sub);
        }
    }
}

저는 코드의 깊이(depth)가 깊은걸 지향하기 때문에 함수형 프로그래밍 기법을 사용했는데, for 문을 이용해서 합과 차를 구해도 별 문제는 없습니다. 문자열을 i32 벡터로 만드는 부분은 코드가 중복되기 때문에 따로 함수로 빼면 좋을 것 같네요. 리팩토링을 적용한 수정된 코드는 다음과 같습니다.

fn main() {
	  let app = BinOpApp::parse();
	  match app.command {
	      BinOpCommand::Add { numbers } => {
	          let numbers = numbers.unwrap_or(String::from("0"));
	          let sum = string_to_numbers(numbers).into_iter().sum::<i32>();
	
	          println!("{}", sum);
	      }
	      BinOpCommand::Sub { numbers } => {
	          let numbers = numbers.unwrap_or(String::from("0"));
	          let num_vec = string_to_numbers(numbers);
	
	          let mut sub = num_vec[0];
	          for n in num_vec[1..].iter() {
	              sub -= n;
	          }
	
	          println!("{}", sub);
	      }
	  }
}

fn string_to_numbers(numbers: String) -> Vec<i32> {
    numbers
        .split_ascii_whitespace()
        .collect::<Vec<&str>>()
        .into_iter()
        .map(|n| n.parse::<i32>().unwrap())
        .collect::<Vec<i32>>()
}

타입별 특성

플래그의 타입은 i32, f32, u64와 같은 숫자 리터럴과 String과 같은 문자열 그리고 Vec<T>와 같은 벡터 타입들을 사용할 수 있다는건 이미 코드에서 확인했습니다. 그렇다면 불리언과 같은 bool을 사용하면 어떻게 될까요?

연산자 내부에 결과를 출력하는 옵션을 추가해보겠습니다. 플래그는 불리언 타입의 -p로 설정하겠습니다. 여기서 p는 print의 약자입니다.

#[derive(Subcommand, Debug)]
enum BinOpCommand {
    #[clap(about = "Add two numbers")]
    Add {
        #[clap(short = 'n', help = "The numbers to add", )]
        numbers: Option<String>,
		#[clap(short = 'p', help = "Print the result")]
		print: bool,
    },
    // ...
}

fn main() {
	  let app = BinOpApp::parse();
	  match app.command {
	      BinOpCommand::Add { numbers, print } => {
	          let numbers = numbers.unwrap_or(String::from("0"));
	          let sum = string_to_numbers(numbers).into_iter().sum::<i32>();
	
			  if print {
		          println!("{}", sum);
			  }
	      }
	      // ...
	  }
}

이렇게 하면 계산 결과를 출력할지를 결정할 수 있습니다. 명령어는 binop add -n "1 2 3" -p를 사용하면 됩니다. 플래그를 사용하면 true, 없으면 false로 알아서 정해지기 때문에 따로 값을 넣을 필요가 없습니다.

원시 타입 이외에서 열거형이나 PathBuf 같은 타입들도 사용할 수 있습니다.

테스트를 통한 번거로움 줄이기

지금까지는 cargo run을 이용해 동작을 검증했습니다. 빌드 후 실행을 하더라도 다시 명령어를 입력하는 것은 번거롭습니다. 하지만 테스트 코드를 사용한다면 번거로운 과정을 없앨 수 있습니다. 물론 테스트를 작성하는 것도 꽤나 번거롭지만, 적어도 다시 처음부터 명령어를 입력하는 것 보단 훨씬 정신건강에 좋지 않을까 싶네요. 애써 터미널에 엄청 긴 명령어를 입력했는데 오타 때문에 실행이 안되는 상황을 생각해보면 바로 느껴질겁니다. 참고로 맥에서는 option 키를 누른 상태로 커서를 움직여 원하는 위치에 클릭하면 한번에 되지만 그래도 번거롭습니다.

현재 메인 함수는 값을 따로 반환하지 않고 출력만 하기 때문에 assert_eq!를 이용해서 값을 직접 비교할 수는 없습니다. 직접적인 비교는 불가능하지만 (따로 함수로 빼면 되긴합니다.), 문자열을 원하는 형태로 받았는지 그리고 string_to_numbers 함수가 문자열을 Vec<i32> 타입으로 잘 변환하는지만 확인한다면 전체 동작을 간접적으로 비교할 수는 있습니다.

#[cfg(test)]
mod binop_test {
    use super::*;

    #[test]
    fn test_add() {
        let app = BinOpApp::parse_from(&["binop", "add", "-n", "1 2 3 4 5"]);
        assert_eq!(app.command, BinOpCommand::Add { numbers: Some(String::from("1 2 3 4 5")) });
    }

    #[test]
    fn test_sub() {
        let app = BinOpApp::parse_from(&["binop", "sub", "-n", "1 2 3 4 5"]);
        assert_eq!(app.command, BinOpCommand::Sub { numbers: Some(String::from("1 2 3 4 5")) });
    }

    #[test]
    fn test_string_to_numbers() {
        let numbers = String::from("1 2 3 4 5");
        let num_vec = string_to_numbers(numbers);

        assert_eq!(num_vec, vec![1, 2, 3, 4, 5]);
    }

    #[test]
    fn test_sum_parsed_numbers() {
        let numbers = String::from("1 2 3 4 5");
        let num_vec = string_to_numbers(numbers);

        let sum = num_vec.into_iter().sum::<i32>();

        assert_eq!(sum, 15);
    }
}

참고로 BinOpCommandapp.command를 비교하려면 BinOpCommand의 매크로에 PartialEq를 추가해야 합니다.

더 복잡한 연산 처리하기

일반적인 계산기는 동일한 연산자를 여러번 사용할 수 있습니다. 하지만 CLI에서는 특성상 -a "10 10" -s "1 1" -a "10" 처럼 한 명령어에 동일한 플래그를 여러번 사용하는건 불가능합니다. 이 문제를 해결하기 위해 복잡한 식은 아예 수식 문자열을 받아서 처리하는 방법을 사용하기로 했습니다.

수식에 “-1 + 2” 처럼 음수가 포함되는 경우와 “1 +” 처럼 불완전한 경우(특히 연산자의 수가 숫자 보다 많을 때)를 고려해야겠지만, 음수는 구현의 편의를 위해 이미 이전에 생략하기로 했었죠, 그래서 후자의 경우만 예외 처리하도록 하겠습니다. 또한 이곳저곳 불완전한 부분이 더 있는데, 어려운건 아니니까 직접 수정해보는 것을 추천합니다.

#[derive(Subcommand, Debug, PartialEq)]
enum BinOpCommand {
		// ...
    Expr {
        #[clap(short = 'e', help = "The expression to evaluate")]
        expr: Option<String>,
    },
}

fn main() {
	let app = BinOpApp::parse();
    match app.command {
      // ...
      BinOpCommand::Expr { expr } => {
          let expr = expr.unwrap_or(String::from("0"));
          let mut result = 0;
          let mut temp_number = String::new();
          let mut op = '+';

          let mut num_count = 0;
          let mut op_count = 0;

          for c in expr.chars() {
              if c.is_digit(10) {
                  temp_number.push(c);
                  num_count += 1;
              } else if c == '+' || c == '-' {
                  if !temp_number.is_empty() {
                      let number = temp_number.parse::<i32>().unwrap();
                      match op {
                          '+' => result += number,
                          '-' => result -= number,
                          _ => panic!("Unknown operator"),
                      }
                      op_count += 1;
                      temp_number = String::new();
                  }

                  op = c;
              }
          }

					// 연산자의 수가 숫자 보다 많으면 패닉
          if op_count != num_count - 1 {
              panic!("Invalid expression: number of operators is more than number of numbers");
          }

          let number = temp_number.parse::<i32>().unwrap();
          match op {
              '+' => result += number,
              '-' => result -= number,
              _ => panic!("Unknown operator"),
          }

          println!("{}", result);
      }
}

수식 파싱과 계산은 그냥 각 칸 마다 숫자와 연산자가 번갈아 써진 테이프를 한칸씩 이동해나가면서 계산하는 방식이라고 보면 됩니다.

메인 함수 줄이기

물론 여기까지 작성해도 충분히 (완전히는 아니지만) 계산기의 기능을 하지만, 메인 함수의 크기가 매우 크고 단일 책임 원칙을 위반한다는 문제가 있습니다. 지금 코드의 크기는 매우 작아서 이 부분은 생략해도 되지만, 명령어의 수가 커지면 그만큼 코드도 복잡하고 읽기가 싫어지기 때문에 저는 리팩토링을 추천합니다.

제가 CLI 코드를 작성할 때 각 로직을 처리하는 핸들러 함수를 만드는 스타일을 선호합니다. 핸들러 함수를 이용하면 메인 함수에서는 플래그에 맞는 함수만 호출하면 되기 때문에 메인 함수의 크기가 작아지고, 테스트 작성이 편해지고 그리고 뭐가 어떻게 돌아가는지 쉽게 볼 수 있다는 장점이 있습니다.

이 핸들러 함수는 기존의 패턴 매칭 되는 부분에서 실행되는 로직을 따로 함수로 뺀 것이기 때문에 아래와 같이 코드를 수정할 수 있습니다.

fn main() {
    let app = BinOpApp::parse();
    match app.command {
        BinOpCommand::Add { numbers } => add_handler(numbers),
        BinOpCommand::Sub { numbers } => sub_handler(numbers),
        BinOpCommand::Expr { expr } => expr_handler(expr),
    }
}

마무리

이것으로 clap 라이브러리를 이용해서 간단히 CLI 도구를 만드는 방법에 대해 알아보았습니다. 이런저런 설명이 많긴했는데, 빠진 내용도 많아서 차라리 를 읽는게 더 많은 도움이 될 것 같습니다.

하지만 간단히 만드는 정도로는 제 경험상 이 정도 내용만 알아도 얼추 쓸만한 프로그램을 만들 수 있다고 저는 생각합니다. 암튼 이것으로 대략적인 CLI 구현 방법을 마치겠습니다.

profile
Uncertified Quasi-polyglot pseudo dev

0개의 댓글