Launching a URL Shortener in Rust using Rocket

Matthias Endler

Rust와 Rocket으로 URL 단축기 만들기

면접에서 자주 나오는 시스템 디자인 과제 중 하나는 URL 단축기(간단히 말해 bit.ly 클론)의 소프트웨어 아키텍처를 그려 보는 것입니다. 마침 Rust용 웹 프레임워크인 Rocket을 가지고 놀고 있던 터라 한번 만들어 보기로 했습니다.

우주를 날아가는 로켓
우주를 날아가는 로켓

요구사항

URL 단축기에는 두 가지 핵심 역할이 있습니다.

  • 긴 URL을 짧은 URL로 만들기(당연하죠!).
  • 짧은 링크가 요청되면 원래의 긴 링크로 리다이렉트하기.

우리 서비스 이름을 rust.ly라고 해 보겠습니다(힌트, 힌트: 글을 쓰는 시점에는 아직 그 도메인을 구매할 수 있습니다…).

먼저 새 Rust 프로젝트를 생성합니다.

cargo new --bin rustly

다음으로 Cargo.toml에 Rocket을 추가합니다.

[dependencies]
rocket = "0.2.4"
rocket_codegen = "0.2.4"

주의: 아마도 가장 최신 버전의 Rocket을 사용해야 할 겁니다. 그렇지 않으면… 꽤나 ‘재미있는’ 오류 메시지를 만나게 될 수도 있습니다. 최신 버전은 crates.io에서 확인하세요.

Rocket은 최신 Rust 기능을 필요로 하므로 최신 nightly 빌드를 사용해야 합니다. Rustup을 이용하면 stable과 nightly 사이를 간편하게 전환할 수 있습니다.

🤔 이제는 Nightly Rust가 더 이상 필요하지 않을 수도 있습니다. nightly 없이 시도해 보신 분이 있다면 알려 주실 수 있을까요?

rustup update && rustup override set nightly

첫 번째 프로토타입

이제 작은 서비스를 코딩해 보겠습니다. 먼저 시작을 위해 간단한 “hello world” 스켈레톤을 작성해 보죠. 아래 내용을 src/main.rs에 넣으세요.

#![feature(plugin)]
#![plugin(rocket_codegen)]

extern crate rocket;

#[get("/")]
fn lookup(id: &str) -> String {
    format!("⏩ You requested {}. Wonderful!", id)
}

#[get("/")]
fn shorten(url: &str) -> String {
    format!("💾 You shortened {}. Magnificent!", url)
}

fn main() {
    rocket::ignite().mount("/", routes![lookup])
                    .mount("/shorten", routes![shorten])
                    .launch();
}

내부적으로 Rocket이 이 멋진 문법을 가능하게 하려고 마법을 부리고 있습니다. 좀 더 구체적으로 말하면, 이를 위해 rocket_codegen 크레이트를 사용합니다.

rocket 라이브러리를 스코프 안으로 가져오기 위해 extern crate rocket;이라고 작성합니다.

우리 서비스의 두 개 라우트를 정의했습니다. 두 라우트 모두 GET 요청에 응답합니다.
이는 함수에 get이라는 어트리뷰트를 추가해서 이루어집니다. 어트리뷰트는 추가 인자를 받을 수 있습니다. 여기서는 lookup 엔드포인트에는 id 변수를, shorten 엔드포인트에는 url 변수를 정의했습니다. 두 변수 모두 유니코드 문자열 슬라이스입니다. Rust는 유니코드 지원이 훌륭하므로 자랑 삼아 멋진 이모지로 응답해 보겠습니다. 🕶

마지막으로 Rocket을 실행하고 두 라우트를 마운트하는 main 함수가 필요합니다. 이렇게 하면 라우트가 외부에 공개됩니다. 더 자세한 내용이 궁금하다면 공식 Rocket 문서를 참고하세요.

애플리케이션을 실행해서 제대로 진행되고 있는지 확인해 보겠습니다.

cargo run

컴파일이 끝나면 Rocket의 멋진 시작 로그를 볼 수 있을 겁니다.

🔧  Configured for development.
    => address: localhost
    => port: 8000
    => log: normal
    => workers: 8
🛰  Mounting '/':
    => GET /
🛰  Mounting '/shorten':
    => GET /shorten/
🚀  Rocket has launched from https://localhost:8000...

좋습니다! 이제 서비스를 호출해 보죠.

> curl localhost:8000/shorten/www.endler.dev
💾 You shortened www.endler.dev. Magnificent!

> curl localhost:8000/www.endler.dev
⏩ You requested www.endler.dev. Wonderful!

지금까지는 순조롭습니다.

데이터 저장과 조회

여러 요청에 걸쳐 단축된 URL을 유지해야 하는데… 어떻게 할까요? 프로덕션 환경이라면 Redis 같은 NoSQL 저장소를 사용할 수도 있을 겁니다. 하지만 이번 목표는 Rocket을 가지고 놀면서 Rust를 배우는 것이므로, 단순히 인메모리 저장소를 사용하겠습니다.

Rocket에는 managed state라는 기능이 있습니다. 여기서는 URL들의 저장소를 관리하려고 합니다.

먼저 src/repository.rs라는 파일을 만들어 보겠습니다.

use std::collections::HashMap;
use shortener::Shortener;

pub struct Repository {
    urls: HashMap,
    shortener: Shortener,
}

impl Repository {
    pub fn new() -> Repository {
        Repository {
            urls: HashMap::new(),
            shortener: Shortener::new(),
        }
    }

    pub fn store(&mut self, url: &str) -> String {
        let id = self.shortener.next_id();
        self.urls.insert(id.to_string(), url.to_string());
        id
    }

    pub fn lookup(&self, id: &str) -> Option<&String> {
        self.urls.get(id)
    }
}

이 모듈에서는 먼저 표준 라이브러리에서 HashMap 구현을 가져옵니다. 그리고 다음 단계에서 URL을 단축하는 데 도움을 줄 shortener::Shortener;도 포함합니다. 지금은 너무 걱정하지 마세요. 관례에 따라 빈 HashMap과 새로운 ShortenerRepository 구조체를 생성하는 new() 메서드를 구현합니다. 추가로 storelookup 두 메서드가 있습니다.

store는 URL을 받아 인메모리 HashMap 저장소에 씁니다. 아직 정의하지 않은 shortener를 이용해 고유한 ID를 생성하고, 해당 항목의 단축 ID를 반환합니다. lookup은 저장소에서 주어진 ID를 찾아 Option으로 반환합니다. ID를 찾으면 반환값은 Some(url)이 되고, 일치하는 항목이 없으면 None을 반환합니다.

문자열 슬라이스(&str)를 to_string() 메서드로 String으로 변환한다는 점에 유의하세요. 이렇게 하면 라이프타임을 신경 쓰지 않아도 됩니다. 초심자라면 라이프타임에 대해 너무 깊게 고민하지 마세요.

추가 설명 (건너뛰어도 됩니다)

노련한 (Rust) 개발자™라면 여기서 몇 가지를 다르게 할 수도 있습니다. 저장소와 단축기 사이의 강한 결합을 눈치채셨나요? 프로덕션 시스템에서는 RepositoryShortener가 단순히 트레이트(다른 언어의 인터페이스와 비슷하지만 더 강력한 기능)의 구체적인 구현일 수 있습니다. 예를 들어 RepositoryCache 트레이트를 구현할 수 있습니다.

trait Cache {
    // Store an entry and return an ID
    fn store(&mut self, data: &str) -> String;
    // Look up a previously stored entry
    fn lookup(&self, id: &str) -> Option<&String>;
}

이렇게 하면 관심사를 명확히 분리할 수 있고, 다른 구현(예: RedisCache)으로 쉽게 교체할 수 있습니다. 또한 테스트를 간단하게 하기 위해 MockRepository를 만들 수도 있습니다. Shortener도 마찬가지입니다.

더 나아가 store의 매개변수로 &strString을 모두 받을 수 있도록 Into 트레이트를 사용할 수도 있습니다.

pub fn store>(&mut self, url: T) -> String {
		let id = self.shortener.shorten(url);
		self.urls.insert(id.to_owned(), url.into());
		id
}

이 내용이 궁금하다면 Herman J. Radtke III의 이 글을 읽어 보세요. 지금은 간단하게 유지하겠습니다.

실제로 URL 단축하기

이제 URL 단축기 자체를 구현해 보겠습니다. 웹상에 URL 단축에 대해 얼마나 많은 글이 있는지 보면 놀라실 겁니다. 흔한 방법 중 하나는 base 62 변환을 이용해 짧은 URL 만들기입니다.

조금 더 찾아보다가 harsh라는 아주 괜찮은 작은 크레이트를 찾았는데, 요구사항에 딱 맞았습니다. 이 크레이트는 입력 문자열로부터 해시 ID를 생성합니다.

harsh를 사용하려면 Cargo.toml의 의존성 섹션에 추가합니다.

harsh = "0.1.2"

다음으로 main.rs 상단에 크레이트를 추가합니다.

extern crate harsh;

src/shortener.rs라는 새 파일을 만들고 다음과 같이 작성합니다.

use harsh::{Harsh, HarshBuilder};

pub struct Shortener {
    id: u64,
    generator: Harsh,
}

impl Shortener {
    pub fn new() -> Shortener {
        let harsh = HarshBuilder::new().init().unwrap();
        Shortener {
            id: 0,
            generator: harsh,
        }
    }

    pub fn next_id(&mut self) -> String {
        let hashed = self.generator.encode(&[self.id]).unwrap();
        self.id += 1;
        hashed
    }
}

use harsh::{Harsh, HarshBuilder};로 필요한 구조체들을 스코프 안으로 가져옵니다. 그런 다음 Harsh를 감싸는 우리만의 Shortener 구조체를 정의합니다. 이 구조체에는 두 필드가 있습니다. id는 단축을 위한 다음 ID를 저장합니다. (음수 ID는 없을 것이므로 부호 없는 정수를 사용합니다.) 다른 필드는 generator 자체로, 여기서는 Harsh를 사용합니다. HarshBuilder를 이용하면 ID에 사용할 사용자 정의 알파벳을 설정하는 등 다양한 멋진 기능을 할 수 있습니다. 지금은 이 정도로 충분하지만, 더 자세한 내용은 공식 문서를 확인해 보세요. next_id로 URL을 위한 새로운 String ID를 얻습니다.

보시다시피 next_id에 URL을 전달하지 않습니다. 즉, 실제로는 아무것도 단축하지 않는 셈입니다. 단지 짧고 고유한 ID를 생성할 뿐입니다. 대부분의 해싱 알고리즘이 꽤 긴 URL을 만들기 때문인데, 짧은 URL을 갖는 것이 핵심 아이디어이기 때문입니다.

연결하기

이제 shortener와 저장소가 완성되었습니다. 둘을 활용할 수 있도록 src/main.rs를 다시 수정해야 합니다.

여기부터는 조금 까다로워집니다.

솔직히 여기서 조금 헤맸습니다. 주로 멀티스레드 요청 처리에 익숙하지 않았기 때문입니다. Python이나 PHP에서는 공유 가변 접근에 대해 고민할 필요가 없습니다.

처음에는 main.rs에 다음과 같은 코드를 작성했습니다.

#[get("/")]
fn store(repo: State, url: &str) {
    repo.store(url);
}

fn main() {
    rocket::ignite().manage(Repository::new())
                    .mount("/store", routes![store])
                    .launch();
}

State는 Rocket에서 요청 간에 데이터를 저장하는 기본 방법입니다. manage()로 애플리케이션 상태에 속하는 것이 무엇인지 알려주기만 하면 Rocket이 이를 라우트에 자동으로 주입해 줍니다.

하지만 컴파일러는 허락하지 않았습니다.

error: cannot borrow immutable borrowed content as mutable
  --> src/main.rs
   |
   |     repo.store(url);
   |     ^^^^ cannot borrow as mutable

돌이켜 보면 모두 이해가 됩니다. 만약 두 요청이 동시에 저장소를 수정하려고 하면 어떻게 될까요? Rust가 여기서 레이스 컨디션을 막아 준 것입니다! 헉. 다만 오류 메시지는 조금 더 친절했으면 좋았을 것 같긴 합니다.

다행히 Sergio Benitez(Rocket의 창시자)가 Rocket IRC 채널에서 도움을 주었습니다(다시 한 번 감사드립니다!). 해결책은 저장소를 Mutex 뒤에 두는 것이었습니다.

다음은 완성된 src/main.rs의 전체 모습입니다.

#![feature(plugin, custom_derive)]
#![plugin(rocket_codegen)]

extern crate rocket;
extern crate harsh;

use std::sync::RwLock;
use rocket::State;
use rocket::request::Form;
use rocket::response::Redirect;

mod repository;
mod shortener;
use repository::Repository;

#[derive(FromForm)]
struct Url {
    url: String,
}

#[get("/")]
fn lookup(repo: State>, id: &str) -> Result {
    match repo.read().unwrap().lookup(id) {
        Some(url) => Ok(Redirect::permanent(url)),
        _ => Err("Requested ID was not found.")
    }
}

#[post("/", data = "")]
fn shorten(repo: State>, url_form: Form) -> Result {
    let ref url = url_form.get().url;
    let mut repo = repo.write().unwrap();
    let id = repo.store(&url);
    Ok(id.to_string())
}

fn main() {
    rocket::ignite().manage(RwLock::new(Repository::new()))
                    .mount("/", routes![lookup, shorten])
                    .launch();
}

보시다시피 여기서는 공유 가변 접근으로부터 저장소를 보호하기 위해 std::sync::RwLock을 사용하고 있습니다. 이 락은 동시에 여러 명의 리더 또는 최대 한 명의 라이터를 허용합니다. 저장소에 접근할 때마다 먼저 readwrite 메서드를 호출해야 하므로 코드가 조금 더 읽기 어려워집니다.

lookup 메서드에서는 이제 Result 타입을 반환하는 것을 볼 수 있습니다. 두 가지 경우가 있습니다. 저장소에서 ID를 찾으면 리다이렉트를 처리할 Ok(Redirect::permanent(url))을 반환하고, ID를 찾지 못하면 Error를 반환합니다.

shorten 메서드에서는 get 요청에서 post 요청으로 바꿨습니다. 이렇게 하면 URL 인코딩을 신경 쓸 필요가 없다는 장점이 있습니다. 그냥 Url 구조체를 만들고 FromForm을 derive하면 역직렬화를 알아서 처리해 줍니다. 멋지죠!

이제 끝났습니다. 서비스를 다시 실행해서 테스트해 보겠습니다!

cargo run

새 창에서 이제 첫 번째 URL을 저장해 보겠습니다.

curl --data "url=https://www.endler.dev" https://localhost:8000/

돌려받은 ID를 이용해 다시 URL을 조회할 수 있습니다. 제 경우에는 gY였습니다. 브라우저에서 https://localhost:8000/gY 로 이동하면 제 홈페이지로 리다이렉트될 것입니다.

정리

Rocket은 훌륭한 문서와 멋진 커뮤니티를 제공합니다. 정말로 관용적인 Rust 웹 프레임워크라는 느낌이 듭니다.

Rocket을 가지고 노는 동안 즐거우셨기를 바랍니다.
전체 예제 코드는 Github에서 확인할 수 있습니다.

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.