Launching a URL Shortener in Rust using Rocket

Matthias Endler

Rocket으로 Rust URL 단축기 만들기

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

면접에서 단골로 등장하는 시스템 디자인 과제 중 하나는 URL 단축기(간단히 말해 bit.ly 클론)의 소프트웨어 아키텍처를 스케치하는 것이다. 마침 Rocket — Rust용 웹 프레임워크 — 를 만지작거리고 있던 참이라, 직접 한번 만들어보기로 했다.

우주를 비행하는 로켓
우주를 비행하는 로켓

요구사항

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("/<id>")]
fn lookup(id: &str) -> String {
    format!("⏩ You requested {}. Wonderful!", id)
}

#[get("/<url>")]
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 /<hash>
🛰  Mounting '/shorten':
    => GET /shorten/<url>
🚀  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<String, String>,
    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) 개발자™라면 여기서 몇 가지를 다르게 할 수도 있다. repository와 shortener 사이의 강한 결합을 눈치챘는가? 프로덕션 시스템에서는 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<T: Into<String>>(&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의 dependencies 섹션에 추가한다:

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가 나올 일은 없으므로 부호 없는 정수를 사용한다.) 다른 필드는 Harsh를 사용하는 generator 자체다. HarshBuilder를 이용하면 id에 사용할 커스텀 알파벳을 설정하는 등 여러 멋진 작업을 할 수 있다. 지금은 이 정도로 충분하지만, 더 자세한 내용은 공식 문서를 참고하자. next_id로 URL에 사용할 새로운 String id를 가져온다.

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

연결하기

이제 shortener와 repository가 완성되었으니, 둘을 활용하도록 src/main.rs를 다시 수정해야 한다.

여기서부터는 일이 좀 복잡해진다.

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

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

#[get("/<url>")]
fn store(repo: State<Repository>, 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

돌이켜보면 충분히 이해가 간다. 두 요청이 동시에 repository를 수정하려고 하면 어떻게 될까? Rust가 여기서 레이스 컨디션을 막아준 것이다! 으악. 다만 에러 메시지는 좀 더 친절했으면 좋았을 텐데 말이다.

다행히 Sergio Benitez(Rocket의 제작자)가 Rocket IRC 채널에서 도움을 주었다(다시 한번 감사드립니다!). 해결책은 repository를 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("/<id>")]
fn lookup(repo: State<RwLock<Repository>>, id: &str) -> Result<Redirect, &'static str> {
    match repo.read().unwrap().lookup(id) {
        Some(url) => Ok(Redirect::permanent(url)),
        _ => Err("Requested ID was not found.")
    }
}

#[post("/", data = "<url_form>")]
fn shorten(repo: State<RwLock<Repository>>, url_form: Form<Url>) -> Result<String, String> {
    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();
}

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

lookup 메서드에서는 이제 Result 타입을 반환한다는 것을 알 수 있다. 두 가지 경우가 있다. repository에서 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/

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

정리

Rocket은 훌륭한 문서와 멋진 커뮤니티를 제공한다. 정말 Rust답게 느껴지는 웹 프레임워크다.

Rocket을 가지고 놀면서 즐거운 시간을 보냈길 바란다.
전체 예제 코드는 Github에서 확인할 수 있다.

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

댓글