Bullshitting Blog

개소리하는 블로그

[ rust ]

죽은 FIX 라이브러리 살려내기

회사에서 리드 역할에 더 충실하기 위해 실제 작업은 팀원에게 넘기고 그 전에 계획하고 개발 후에 리뷰 및 관리하는 역할로 차차 넘어가고 있다.

이번엔 FIX 프로토콜을 개발해야 한다. FIX 프로토콜은 금융 거래에서 사용되는 하나의 표준 프로토콜이다. 처음엔 그냥 머리속에서, DMA 프로토콜을 개발하면서 짜내려간 코드를 재활용하면서, TagValue 인코딩만 구현하면 되겠다고 생각하고 있었다. 그런데, 실제로 팀원이 수행하도록 업무를 구상하려 하니, 가장 쉬운 방법을 고민하게 됨. 결국 FIX는 표준이므로, Rust 라이브러리 또한 존재했다.

문제는 이 라이브러리가 관리되지 않고 있다는 것이다. Rust 1.29부터 deprecated되는 문법들을 그대로 둔 채... 현재 Rust 버전은 1.79다. 결국 현재 사용이 불가한 상태다. 그럼 여기서 포기해야 하나 했는데, 사용법을 보니 너무 편해서 고치고 싶다는 생각이 들었다. 고치기만 하면, 개발 업무는 개꿀이 될 것이라는 확신이 들었음.

그래서 달려들었다. 매크로가 굉장히 많아서 디버깅이 좀 힘들긴 했는데, 그래도 새로 짜는 것보다는 분명 낫다. 오늘 약 2-3일쯤 되었는데, 역시 하다가 익숙해지니 속도가 붙어서 방금 끝났다. 꽤 재밌었다. 매크로를 이렇게 맛깔나게 쓰다니, 감탄하면서 고쳤다. 그냥 고친거 갖다 써도 되지만, 겸사겸사 오픈소스 기여도 할 겸 PR을 올려 뒀다.

Rust의 모노리포 - 개꿀

내가 속한 조직은 각 구성원이 믿을 만한 사람들로 구성되어 있다. 따라서 요구조건을 전달하고, 상대방이 어려워하는 경우 힌트만 좀 공유해서 이해시키면 됨. 그래서 마이크로서비스 구조로 잘 지속되어 왔다.

하지만 이것은 한계에 봉착하고 있다. 현재 우리 제품은 지속적으로 개선을 하고 있기 때문에 업데이트 빈도가 매우 빠르다. 그 업데이트들은 DB 스키마까지도 변경될 수 있을 정도로 큰 경우가 빈번하다. 심지어 개발속도도 빨라서 개발 중에 다른 서비스에 큰 변화가 생기는 경우도 발생한다. 또한 cargo update를 깜빡해서 버전에 안맞는 코드를 작성했는데, Cargo.lock파일 또한 커밋되지 않았으므로 CI 테스트를 그냥 통과하는 경우도 발생했다.

최근엔 작업 관리 서비스와 엔드포인트 서비스를 통합하는 과정에 있다. 그래서, 통합하는 김에 모노리포를 채택하기로 하였다. Rust에서 모노리포? 그거 아주 쉽다.

Rust에는 cargo라는 패키지 툴이 있다. cargo로 컴파일, 빌드, 라이브러리 import 모두 진행한다. 이뿐만이 아니라 workspace라는걸 이용하면, 최상위 디렉터리에서 여러 개의 cargo workspace를 구성하고 상호간 라이브러리처럼 import할 수 있음. 각 마이크로서비스는 마이크로서비스로 그대로 가져가지만, 리포만 하나로 통합할 수 있다. 이렇게 되면 다른 마이크로서비스의 버전 변화가 있을 때, 그냥 pull만 당겨주고 맞춰서 작업하면 됨. 브렌치 머지할 때도, conflict가 있는지 볼 수 있고, CI를 하는 것도 용이해진다. 또한, 각 마이크로서비스에서 이용하는 라이브러리의 버전도 자연스럽게 통합된다. 각 workspace의 Cargo.toml파일을 따로 세팅하긴 하지만, 최상위 디렉터리의 Cargo.lock파일에서 싸그리 관리하기 때문이다. 추가로, 이건 사소해 보이긴 하지만, 수정이 있을 때마다 깃허브 알림설정이 모두에게 날아가게 될 것이다. 자기 서비스에만 신경쓸 땐, 일부 리포는 알림이 안되어 있는 경우가 생기기 때문에, 따라가기 힘들 때가 있었는데, 해결될 듯 하다.

여튼 그래서, 아주 쉽게 모노리포 구성해서 작업중이다. 개꿀.

Rust의 개꿀 라이브러리들

개인적으로 개발을 할 때, 당장 어떻게든 만들어내는건 어렵지 않게 하지만, 개발 도구를 맛깔나게 활용하지는 않는 편이다. 그냥 '요래요래 하면 될거같은데?' 하고 손부터 가는 타입. 그래서 개꿀 라이브러리를 놓치고 깡으로 손으로 짜는 경우가 많다.

그러던 중, CTO가 관리하던 코드를 물려받게 되었다. 코드가 아름답다고 한다면 이런 것일 것이다. LSTM + 강화학습 모델의 학습 인프라를 구성하는 코드리포인데, 시뮬레이션은 기본, 데이터 프로세싱까지 모두 담겨있다. 마이크로서비스를 섣불리 도입해서 삽질을 하지도 않고 바로 모노리포 구조로 잘 짜여 있었다. 여튼 개쩐다는 표현은 대충 이쯤에서 마무리하고...

structopt: Argument를 편리하게 받자

일단 난 CLI를 더 선호한다. 그래서 왠만하면 CLI로 툴을 만드는 편이다. 그런데, bash scripting을 하는 경우가 많아서, argument를 가져올 때 손이 알아서 아래와 같이 입력한다.

if [ $# -ne 2 ]; then
    echo "Usage: $0 <hello> <world>"
    exit -1
fi
HELLO=$1
WORLD=$2

그리고 이 습관은 rust까지 따라왔다.

fn main() {
    let args = std::env::args().collect::<Vec<String>>();
    if args.len() != 3 {
        eprintln!("Usage: {} <hello> <world>", args[0]);
    }
    let hello = &args[1];
    let world = &args[2];
}

그리고 난 CTO가 물려준 코드에서 structopt를 발견했다. 아 물론, 다른 마이크로서비스에도 있었는데, 내 담당은 아니라 대충 넘겼었긴 했다. structopt를 이용하면 아래와 같이 이용할 수 있다.

use structopt::StructOpt;

#[derive(StructOpt)]
#[structopt(name = "opt", about = "hello world")]
struct Opt {
    #[structopt(short, long)]
    hello: String,
    #[structopt(short, long)]
    world: String,
}

fn main() {
    let Opt { hello, world } = Opt::from_args();
}

코드 줄 수는 별다른 차이가 없어 보일 수 있지만, eprintln이 사라진 것을 볼 수 있다. argument를 입력받기 위한 안내를 굳이 내가 입력하지 않아도 되는 것이다. 게다가 커맨드라인 상에서 더 깔끔하게 나온다. 추가적으로, Opt struct에서 타입을 정의했다. 자료형에 대해 걱정할 필요가 없이 가져다 쓰면 된다.

serde: 데이터 파싱? 직렬화? 그냥 add하자

이 라이브러리는 내부 프로토콜로서 프로토버프를 이용하면서 예전부터 이용하고 있었다. 꽤 깔끔하게 구조체를 표현할 수 있어서 좋다 정도였고, api 클라이언트를 만들 때, 사용이 아주 권장되지만, 개인적으로 작업을 진행할 때는 귀찮다고 대충 손으로 써내려가면서 만들어 왔다. 하지만, API 클라이언트를 만드는 일이 생각보다 많다. 당장, 개인적으로 작업중인 한국투자증권OpenAPI, open-banking-api만 해도 API 클라이언트다. 또한 회사에서 메시지큐의 공식 라이브러리에서 지원하지 않는 Admin API를 날리는 라이브러리도 개발했는데, 이 또한 API 클라이언트이다. 그리고 이 클라이언트가 날린 리퀘스트에 대한 리스폰스를 파싱하기 위해서는... 그냥 serde 쓰자. 나처럼 손으로 다 빚어내지 않았음 한다 ㅋㅋㅋㅋㅋㅋ. 체대 아니랄까봐 신체의 스피드로 개발속도를 내고 앉아있다. 개인적으로 불편함이 어느정도 쌓여야 해소를 위해 움직이기 때문에, open-banking-api를 작업하면서 serde를 도입하게 되었다. 물론 한국투자증권API에도 serde 라이브러리를 add해놓긴 했지만, 사용해야 할 모든 곳에 제대로 사용하지는 않고 있다. 조만간 바꿔야지.

serde는 데이터의 serialize, deserialize를 지원하는 라이브러리다. derive를 통해 쉽게 serialize/deserialize 매서드를 가져올 수 있다. 만약 이게 없으면 아래와 같이 작성해야 한다.

use json::json;

fn main() {
    // deserialize
    let raw = b"{\\"hello\\":1,\\"world\\":\\"universe\\"}";
    let parsed_json = json::parse(&raw).unwrap();
    let mut hello = 0;
    let mut world = String::new();
    match parsed_json {
        json::JsonValue::Object(o) => {
            match o.get("hello") {
                Some(hello) => {
                    match hello {
                        json::JsonValue::Short(n) => {
                            let (positive, mantissa, exponent) = n.as_parts();
                            hello = positive as i64 * mantissa as i64 * 10i64.pow(exponent as u32);
                        },
                    }
                }
                None => todo!(),
            };
            match o.get("world") {
                Some(world) => {
                    match world {
                        json::JsonValue::String(s) => {
                            world = s.to_owned();
                        },
                    }
                }
                None => todo!(),
            }
        },
        _ => {
            todo!();
        },
    }

    // serialize
    let hello = 1;
    let world = "universe".to_string();
    {% raw %}
    let serialized_message = format!("{{\\"hello\\":{},\\"world\\":\\"{}\\"}}", hello, world);
    {% endraw %}
}

json::parse가 모든 것을 꼬아버렸다. 아주 귀찮다. 이걸 serde와 serde_json을 이용하면 아래와 같이 간단하게 해결된다.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct ParsedJson {
    pub hello: u8,
    pub world: String,
}

fn main() {
    // deserialize
    let raw = b"{\\"hello\\":1,\\"world\\":\\"universe\\"}";
    let parsed_json: ParsedJson = serde_json::from_slice(raw).unwrap();

    // serialize
    let message = ParsedJson {
        hello: 1,
        world: "universe".to_string(),
    };
    let serialized_message = serde_json::to_string(&message).unwrap();
}

serde 만세

Rust를 업무에 사용하면서 느끼는 점들

현재 다니고 있는 회사의 소속팀에서는 Rust를 주 언어로 채택했다.
도입하신 분이 말씀하시길, 속도를 포기하지 않을 때 쓸 수 있는 언어는 C, C++, Rust 정도인데, C, C++은 개발자 마다 스타일이 천차만별이라서 Rust를 채택했다고 한다. 물론, 그 이외에 보안성 등은 말할 것도 없을 것이다.

여튼, 이 언어를 근 2년 가까이 사용하다 보니, 거의 적응이 된 듯 한데, 뭔가 불편하면서 편하다. 역설적인데, 딱 이렇게 느끼는 중이다. 애증의 관계이기도 하고. 근데 내 성격엔 이게 맞는 듯 하다.

1. 컴파일러가 일거수일투족 감시하면서 (딴지를 건다 / 가르쳐 준다[v])

이 컴파일러는 단순히 이걸 바이너리로 빌드했을 때 프로그램으로서 잘 돌 것인가만 중요한게 아니다.
다른 언어였으면 런타임에서 오류가 발생할 것들도, rust는 일부 잡아줄 수 있을 정도로 빡세다. 그리고 이건 rust의 소유권과 라이프타임 개념에서 오는데, 쭉 써오면서 느낀 점을 단순히 표현해 보자면, 아나바다 운동을 철폐하라는것이다. 아껴쓰고 나눠쓰고 바꿔쓰고 다시쓰다가 취약점 발생해서 쉘 뜯기지 말라는거.

그래서 가끔 정말 귀찮으면 클론 범벅의 코드가 된다. 이것도 신경 좀 쓰면 최소화 할 수 있는데, 당장 급해서 막 짜다 보면 뭔 문장 끝마다 clone을 남발하고 있다. 만약 C/C++이랑 퍼포먼스 비교했을 때 Rust가 더 떨어진다면, 아마 이 클론들 때문일 듯 하다. clone을 하지 않는 바이너리는 서로 비교했을 때 거의 똑같지 않을까 싶다.

그래서, copy를 잘못하거나, 포인터를 잘못 이용해서 뜻밖의 곳에서 수정이 일어나는 등의 사소하고 찾기 힘든 것들은 왠만하면 거의 일어나지 않는다. 덕분에 지금까지 발생한 버그들은 대부분 비즈니스 로직이나, Network 문제, 비동기 이슈 이 세 가지 안에서만 일어났다.

반대로 불편한 점도 있는데, 라이브러리를 만들 때이다. 최근 회사에서 actor 구조를 라이브러리로 만들었다가 재밌어서 왠만한 돌려쓸 수 있을 것 같은 것들은 다 라이브러리로 빼서 만들고 있는데, 제네릭, 트레잇, 소유권, 라이프타임 이 네 가지가 콜라보되니까 난이도가 급상승한다. 심지어 컴파일러도 여기까지 오면 자기도 헷깔려서 핵심 원인을 안짚어주는 경우가 많다. 그래서 컴파일러에서 뱉는 오류 메시지들의 패턴을 익히는 중이다.

개인적으로는 '가르쳐 준다'에 한 표 던진다. 경력이 2년밖에 안되는 쩌리 개발자가 여따 대고 딴지 건다고 하면 이건 오만이다. 지금 회사가 첫 개발 커리어를 시작한 곳인데, 사수 시스템이 따로 없다. 그런데 업무 지시/지도를 해주시는 팀장님과 함께 사수 역할을 도와준 것이 바로 rust 컴파일러다.

2. 라이브러리가 많지 않다

이제는 개발을 시작할 때, 라이브러리 찾는데 시간을 별로 할애하지 않는다. 실무에서 rust를 사용하는 케이스가 아직 많지 않다 보니, 알아서 구현해야 하는 경우가 꽤 있다. 한국투자증권 API도 마찬가지 이유로 시작된 프로젝트다. "한투 분들이 python으로 예제코드까지 방대하게 잘 구현해 놓으셨지만, rust 사용자는 python은 죽어도 쓸 수 없다며 알 수 없는 오기에 사로잡혀 스스로 API 클라이언트를 개발하게 되었다." 식의 스토리가 꽤 자주 있다. 그래서 시스템 트레이딩 전략을 만들기는 커녕 아직도 라이브러리단 개발만 이어지고 있다. 근데 뭐 이거 당장 안끝낸다고 밥 못벌어먹는 것도 아니고, 일단 재밌으니까 쭉 이어서 하고 있긴 하다.

문제는 이렇게 개발이 늘어지는걸 실무에서는 용납하지 않는다. 그래서 그냥 본인이 미친듯이 빨라져야 함. 근데 개인적으로, 갈수록 빨라져서 지금은 딱히 불편하지 않다. 다만 내가 짠 라이브러리를 돌려쓰는데 문제가 없을지 걱정되긴 한다. 아무리 라이브러리로써 개발한다고 해도 내가 담당하는 마이크로서비스에 알게모르게 맞춰지기 마련이기 때문이다. 게다가, 나중에 만약 이직하거나 퇴사하게 된다면, 인수인계를 했을 때 계속 쓸 지도 의문이다. 만약 퇴사 타이밍에 남아있을 rust 개발자들의 실력이 올라오지 않으면 제네릭, 트레잇, 매크로를 이해해서 라이브러리를 유지보수하는 것보다 각 마이크로서비스에서 따로 개발하는게 빠를 수도 있다. 물론 지금 팀원들이 그때도 계속 남으면 인수인계 쌉가능일 듯 하다.
근데, 라이브러리를 직접 만드는게 재밌긴 하다. 뭔가를 만들려고 할 때 제약이 사라진다. 일단 라이브러리 없다고 포기하는 개발자는 아니게 되었다. 컴퓨터 뺨을 쎄리며 개발하는 느낌이다. 안되면 되게 하라.

3. C/C++과는 다른 개념으로 높은 적응 난이도

적응하기가 쉽지 않다. C/C++은 스타일이 너무 다양하다는 것, 그리고 메모리 누수와 취약점이 쉽게 발생하는게 어려운 요소라면, rust는 컴파일 조건이 너무 까다로워서 실행조차 해볼 수 없다는 것이 어려운 요소이다. 소유권, 라이프사이클은 그 다음의 문제이다. 비컴파일 언어를 좋아하는 개발자는 가장 극혐하는 언어가 될 가능성이 높아 보인다. 로그 찍어서 보는 것조차 허용되지 않으니, 구조를 잘 짜고, 뇌내에서 잘 기억하면서 짜야 한다(잘 메모해 두던가). 물론 그걸 해결해주는 것이 테스트코드이기 때문에, 어떤 경우엔 TDD에 아주 적합한 언어가 될 지도 모르겠다. 하지만 컴파일이 되지 않는 상태에서는 테스트코드도 실행이 안되므로, 작게 작게 완결된 코드를 짜야 한다. 안그럼 지옥을 맛볼 것이다. 덕분에 개발 습관은 잘 잡히는 것 같다고 생각한다. 아님 말고.

대충 요---런 특징들이 있는데, 이 세 가지 모두 나는 좋아한다. 일단 1번은 이미 이유를 설명했고, 2번의 경우, 원래 내가 라이브러리 대충 익혀서 가져다 쓰는걸 좋아하지 않는다. 라이브러리를 써서 빠르게 결과물을 내는 것은 회사 입장에서 아주 중요하겠지만, 개발자로서는 '라이브러리 사용법 빨리 익히기', '라이브러리 빨리 찾기'와 같이 나이가 먹으면서 밀려날 수밖에 없는 능력만 기른다는 생각을 떨칠 수가 없다. 게다가, 이런 능력을 자신의 무기라 할 수 있을까? 토익과 같을 것이라 생각한다. 이건 당연히 어느정도 잘 해야 하는 것이고, 그 다음 무언가가 있어야 그 이상으로 올라갈 수 있을 것이라 생각한다. 아 그리고, 라이브러리 써서 빠르게 결과물을 내는게 중요한 단계의 회사라면, 십중팔구 초기 프로덕트를 만들거나 이것저것 MVP 양산하면서 되는거 하나를 찾기 위한 회사일 가능성이 높은데, 확률상 제대로된 회사는 많지 않을 것이다. 그 회사에 인생을 걸어보겠다는 각오가 없다면, 이런 능력을 요구하는 회사는 더 철저한 검증을 해봐야 할 것이다. 토익점수만 요구하는 회사는 수상할 수밖에 없다.
3번의 경우, 마찬가지로 내 무기가 될 수단으로서 손색이 없기 때문에 선호한다. 어려운 만큼 따라오기도 힘들다. 물론 너무 어려우면 시장에서 사장될 수 있겠지만, 그럴 수준은 아니다. 메모리 취약점 없는 성능좋은 프로그램을 개발하는 수준에 도달하는데 1-2년 걸린다고 생각해보면, 오히려 rust는 쉬운 언어라고 해야 한다고 생각한다. 단지 러닝커브가 지수함수라서 처음에 느릴 뿐이다.

결론: X같은 rust 만만세