본문 바로가기

러스트의 시작점 main 함수 살펴보기

기술적인 이야기/기타 개발 2020. 7. 26.
반응형

단독 실행 프로젝트에서 시작점은 매우 중요합니다. 그야 시작하는 곳이니깐요. C 언어와 비슷하게 러스트(Rust)도 시작점은 main이라는 이름의 함수에서 출발합니다. 이 함수와 관련해 개인적으로 궁금했던 몇 가지 사항을 정리해 보겠습니다.

main 함수

위에서도 언급했지만, 러스트 프로그램이 시작되는 지점은 main이라는 이름을 가진 함수입니다.

fn main() {
    // ...
}

튜토리얼 등에서 자주 볼 수 있는 러스트의 메인 함수는 위와 같은 모양입니다. 여기에 코딩하는 모든 코드는 프로그램 실행 시 바로 실행됩니다.

 

보이지 않는 리턴 타입

C 언어에 익숙하다면 표준(?)적인 main 함수는 아래와 같이 기본적으로 리턴 타입을 가지고 있다는 것을 알고 있을 것입니다.

int main() {
    return 0;  // Ok
}

유닉스 혹은 유닉스에서 유래한 OS에서 단독 실행 명령은 모두 종료 코드(Exit Code)를 가집니다. 이 종료 코드로 해당 프로그램이 제대로 실행되었는지를 판단하지요. 참고로 유닉스에서는 전통적으로 숫자 0은 성공을 의미하는 종료 코드입니다.

그런데 특이하게도 러스트 가이드 글들의 main 함수는 하나같이 아래와 같은 모양만 볼 수 있었습니다.

fn main() {
    ...
}

아무리 봐도 리턴 타입이 명시되어 있지 않습니다. 그리고 리턴 타입도 없으므로 당연히 return 문 혹은 동일한 역할의 식(expression)도 보이질 않지요.

그렇다면 러스트로 작성된 바이너리는 종료 코드를 가지지 않는 것일까요? 그럴 리가요.

 

러스트식 종료 코드 반환 방법

사실 러스트의 main 함수는 기본 형태(?)로 썼을 때 별 다른 오류가 없다면 성공한 것으로 간주하고 정상(0) 코드를 리턴하도록 동작합니다. 그래서 아무 리턴이 없었더라도 셸 스크립트에서 성공으로 간주하여 동작하는 것을 볼 수 있습니다. 아래처럼 말이죠.

$ target/debug/someproj && echo "Ok"
Ok

someproj라는 프로젝트명을 가지는 단독 실행 바이너리 프로젝트를 빌드했다고 가정하고 위처럼 실행시키면 Ok가 표시됩니다. 여기서 사용된 && 오퍼레이터는 앞쪽의 명령이 성공하면 뒤의 명령이 실행되는 셸(Shell) 구문입니다. 결과적으로 여기서 someproj는 종료 코드로 0을 리턴했다는 것을 알 수 있습니다.

그렇다면 반대로 런타임 에러가 발생되는 케이스는 어떻게 될까요? 일부러 아래와 같은 코드를 작성해 봤습니다.

use std::fs::File;

fn main() {
    File::open("unknown_file.txt").expect("File not found");
}

이 코드는 일부러 존재하지 않는 파일을 열려고 시도합니다. 당연하게도 오류가 발생하게 되고 그래서 expect가 이 오류를 보고 프로그램을 바로 종료시켜 버립니다. 그것도 적어 놓은 것 이상의 무시무시한(?) 에러를 내뿜으면서 말이죠.

$ target/debug/someproj
thread 'main' panicked at 'File not found: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

다시 && 오퍼레이터를 이용해 동일하게 시험해 봅시다.

$ target/debug/someproj && echo "Ok"
thread 'main' panicked at 'File not found: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이번엔 Ok가 표시되지 않습니다. someproj 명령의 종료 코드가 0이 아니었다는 말이 되겠지요?

방금 실행 종료된 프로그램의 종료 코드를 셸을 이용해 알 수 있는 방법이 있습니다.

$ echo $?
101

bashzsh 처럼 Bourne Shell(sh)과 호환되는 셸에서는 $?를 이용해 가장 최근에 종료된 프로그램의 종료 코드를 알 수 있습니다. 위 결과에서 이 코드가 101이었다는 말은 뭔가 에러가 있다는 의미겠지요.

결과적으로 러스트의 메인 함수도 종료 코드를 명확하게 반환하고 있는 것을 확인할 수 있었습니다.

 

리턴 타입이 있을 수도 있었다

위에서는 없다고 했는데 사실 main 함수는 리턴 타입을 가질 수 있습니다. 다만 특이하게도 유닉스스럽지(?) 않게 Result 타입을 리턴하도록 강제됩니다. 아래와 같은 식이지요.

fn main() -> Result<(), i32> {
    Ok(())
}

위 코드의 main 함수는 리턴 타입으로 성공 시 빈(?) 타입과 실패 시 32비트 정수 데이터를 반환하는 Result 타입을 사용하고 있습니다. 그리고 함수 구현부에서 Result 타입의 성공을 의미하는 Ok()가 반환되니 왠지 0을 반환하고 종료할 것 같습니다.

참고로 러스트의 함수 문법에서 마지막에 return 없이 그리고 줄 끝에 세미콜론도 없이 뭔가의 데이터만 적혀 있는 경우 이 값을 리턴한다는 의미로 이해하면 됩니다. 이런 표현을 식(expression)이라고 합니다. 그래서 위의 main 함수는 단순히 Ok(())를 리턴하는 코드입니다.

$ target/debug/someproj && echo "Ok"
Ok

네. 정말 예상대로 성공하였습니다. 역시 Ok() 안의 데이터가 뭐든 관계없이 일단 성공이니 무조건 0을 리턴 시키게 하려는 모종의 계략이 숨어 있었습니다.

자 그렇다면 에러를 리턴해야 하는 상황은 어떨까요? Result 타입을 사용하므로 에러일 때는 당연하게도 Err()을 리턴하면 될 것 같습니다. 다만 에러 코드는 다양하므로 에러 코드를 명확하게 표시는 해야겠지요.

fn main() -> Result<(), i32> {
    Err(1)
}

위 코드가 실행되면 Error: 1 이라는 내용이 화면에 표시되면서 종료됩니다. 의도하진 않았지만 Err()을 리턴하는 행위는 화면에 에러 코드도 찍나 봅니다. 어쨌든 이번에도 셸에서 확인해 봅시다.

$ target/debug/someproj && echo "Ok"
Error: 1

이번에도 의도대로 Ok가 화면에 찍히지 않았네요. 즉 someproj가 에러를 리턴하고 종료되었다는 말입니다.

리턴 코드를 확인해보면 정확하게 1이 리턴되는 것도 확인할 수 있습니다.

$ target/debug/someproj
Error: 1
$ echo $?
1

Result 타입은 generic이기 때문에 리턴되는 타입 자체가 유동적이죠. 위의 경우는 C 언어와 비슷하게 하기 위해서 에러 타입을 정수형으로 정의했지만, 아래와 같이 특정 에러 타입을 리턴하는 방식도 쓸 수 있습니다.

use std::io::Error;

fn main() -> Result<(), std::io::Error> {
    // ...
    Err(Error::from_raw_os_error(1))
}

에러 타입도 무궁무진할 것 같으나 당장 알아야 할 필요는 없겠지요. 일단은 이런게 있다는 것만 확인하고 이제 다른 내용으로 넘어가 봅시다.

 

실행 옵션(Arguments)

실컷 종료 코드를 이야기했는데 이제 여기서 벗어나서 다시 C 언어와 비교질을 해봅시다. C 언어의 main 함수 기본 형태는 대체로 아래와 같은 식으로 많이 정의합니다.

int main(int argc, char *argv[]) {
    // ...
}

argcargv를 이용해 프로그램의 실행 옵션(arguments)을 전달받기 위해서는 반드시 필요한 형태입니다. 이게 없으면 실행 옵션을 지원하지 않는 프로그램이라는 말이 되지요.

그런데 러스트의 main 함수 예제에선 저런 실행 옵션 전달을 위한 매개변수가 정의되어 있는 경우를 볼 수가 없었습니다. 설마 이런 파라미터 전달을 지원하지 않는 것일까요?

아뇨. 당연하게도 지원합니다. 단지 실행 옵션 목록의 전달 방식이 다를 뿐이지요. 그냥 함수 매개변수로 전달되는 것이 아니기 때문입니다.

아래 코드는 러스트 스타일로 프로그램 실행 옵션을 받아서 콘솔에 출력하는 코드입니다.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

러스트에서는 std::env::args 모듈을 이용해 실행 옵션을 받아올 수 있습니다. 위 코드를 실행시켜보면 아래와 같은 결과를 얻을 수 있습니다.

$ target/debug/someproj param1 param2 param3
["target/debug/someproj", "param1", "param2", "param3"]

std::env::args().collect() 함수를 이용해 프로그램에 전달된 모든 실행 옵션을 리스트(Vec)로 받아올 수 있음을 알 수 있었습니다.

보통 실행 옵션 목록의 첫 아이템은 자기 자신(실행시키는 파일 이름 혹은 명령)인데 러스트의 경우도 동일했습니다. 나머지는 공백을 기준으로 각 옵션이 나눠지는데 이것도 당연하다면 당연하겠지만요.

참고로 저렇게 빌드해서 실행하는 게 귀찮다면 Cargo의 run 커맨드에서도 실행 옵션을 줄 수도 있습니다.

$ cargo run param1 param2 param3
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/someproj param1 param2 param3`
["target/debug/someproj", "param1", "param2", "param3"]

아마도 이 방법이 좀 더 편하리라 생각됩니다.

이제 실행 옵션을 리스트(Vec) 타입으로 받아왔기 때문에 개수나 각 파라미터를 얻는 과정이 어렵지는 않을 것입니다. 물론 Vec 자료 구조의 사용 방법을 알아야 하겠지만 일단 지금은 예제로만 맛을 봅시다.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("Total {} arguments", &args.len());
    for param in &args {
        println!("{}", param);
    }
}

위 코드의 실행 결과는 아래와 같은 형태입니다.

$ target/debug/someproj param1 param2 param3
Total 4 arguments
target/debug/someproj
param1
param2
param3

이 정도면 간단한 프로그램의 실행 옵션을 받는 건 구현이 가능하겠지요. 물론 이보다 더 고수준으로 구현된 전용 패키지를 쓰는 것도 좋은 선택일 것 같습니다. 크레이트에서 뒤져보면 분명 뭔가 나올 테니깐요.

 

맺음말

사실 뭔가 글 쓰는 순서가 뒤죽박죽이라는 느낌이 들었습니다. 아직 언급도 하지 않는 내용들이 마구마구 나오고 있으니깐요. 하지만 이 글들은 가이드 글을 쓰는 게 아니라 개인적으로 궁금한 점을 주제별로 풀어보려는 목적이 강해서 언급하지 않은 내용이 나오거나 중복되거나 등 다양한 답답한 점들이 있을 수도 있습니다. 답답하더라도 양해 부탁드려요.

다음 글은 러스트의 모듈 시스템과 접근 제어에 대한 내용으로 이어집니다.

728x90
반응형

댓글