당근마켓 모바일 실험실: Rust로 공유 라이브러리 만들기
서비스를 개발하다보면 각자 전문적인 개발 영역을 벗어나 공통적인 엔지니어링 문제를 해결해야 하는 경우가 있는데요. 당근마켓에는 이를 “플랫폼”으로 녹여내 효율적으로 풀어낼 수 있도록 고민하며 도와주는 “모바일 플랫폼 팀”이 있습니다. 당근마켓 엔지니어들은 모바일 플랫폼 팀과 협력하며 여러 문제를 해결해 나가고 있어요.
플랫폼 기능은 한 번 추가되면 모두가 함께 오랫동안 사용하게 되기 때문에 마구잡이로 일단 추가하는 것보다는 테크 스펙부터 사전 실험까지 여러 과정을 거쳐 꼼꼼하게 검토해서 추가하는 편이에요. 그러다 보니 그 중에서는 굉장히 오랜 호흡을 가지고 진행되거나 중간에 버려지는 것들도 있어요.
이런 다양한 시도들 중 이미 도입되어 “결과”로써 흥미로운 사례들 뿐만 아니라, “과정”이 흥미로운 실험들을 소개하고 대내외적으로 교훈을 공유하기 위해 “당근마켓 모바일 실험실” 글을 써볼까 해요. 중간 과정을 공유함으로써 비슷한 고민을 가지고 있는 다른 조직과 소통하고 의견을 나누는 데 도움이 되길 바랍니다 :)
첫 번째 글에서는 당근마켓 모바일 앱에 크로스 플랫폼 모듈을 도입하기 위한 고민을 소개해 볼게요.
장치 플랫폼(Android/iOS/Web) 간 중복 구현 문제
개발자들은 플랫폼 기능을 사용할 때 통합된 인터페이스를 통해 구현 디테일이나 장치 플랫폼 간의 차이를 신경쓰지 않고 사용할 수 있기를 기대해요.
실제 구현은 Android와 iOS에서 각각 구현(JavaScript Bridge를 사용하지 않는 경우 Web에서도 따로 구현)해야 하기 때문에 일관적인 동작을 보장하기 위해서는 사전에 충분한 협의와 소통, 검증이 필요해요. 모바일 플랫폼 팀에서는 각 플랫폼 개발자들이 모여 스펙을 먼저 리뷰하고, 테스트 코드를 먼저 작성하는 식으로 이런 과정을 수행해왔어요.
하지만 이런 방식들은 결국 사람의 개입에 의존하기 때문에 100% 완전함을 보장하기 어렵고, 실제 구현 차이로 인해 예상치 못한 문제를 발생시킬 수도 있어요.
무엇보다 구현과 관리에 대한 비용이 플랫폼마다 중복해서 들어가기 때문에 새로운 플랫폼 기능을 제안 할 때 마다 지속적으로 부담을 느끼게 했어요.
이런 부담을 줄이기 위해서 여러 플랫폼 간 코드를 공유할 수 있는 방법이 없을까 탐색하기 시작했어요. 혹시 쉬운 방법이 있다면 플랫폼 개발환경을 모르는 개발자가 직접 로직을 구현해서 함께 제안할 수도 있을거라 기대했어요.
네이티브 라이브러리를 통한 코드 공유
플랫폼 동작에 의존하지 않는 코어 로직을 공유하는 경우라면, 네이티브 라이브러리(.a
, .so
, .dylib
등)를 빌드할 수 있는 C와 같은 언어로 하나의 코드를 작성해서 공유하면 이런 구현 중복 문제를 줄일 수 있어요.
Android의 경우 NDK(Native Development Kit)라는 도구를 사용해서 C 코드나 네이티브 라이브러리를 호출 할 수 있어요.
C로 작성된 모듈의 인터페이스 규약이나 메모리 구조가 Java/Kotlin 과 다르기 때문에 상호운용(Interop)을 위해서 FFI(Foreign Function Interface) 작성이 필요해요.
JNI/Kotlin FFI 예시:
#include <stdio.h>
#include "com_Example.h"
JNIEXPORT void JNICALL Java_com_Example_hello(JNIEnv * env, jobject obj) {
return env->NewStringUTF("Hello, JNI!");
}
package com
class Example {
init {
System.loadLibrary("Example")
}
external fun hello(): String;
}
반면 iOS에서 사용하는 Swift/Objective-C 언어는 기본적으로 C 와의 상호운용이 지원되기 때문에 헤더파일을 설정하는 것으로 공유받은 C 코드를 사용할 수도 있어요.
라이브러리를 위한 툴체인
C 코드를 공유하는 방법은 플랫폼 개발자가 직접 사용하기에는 아무래도 불편함이 있어요.
- 실제 코드에서 사용하기 위한 FFI 작성이 필요해요.
- 복잡한 구조체나 함수를 포함한 라이브러리를 사용할 때는 FFI 만으로는 충분하지 않아요. 더 사용하기 편하게 대상 언어의 표현으로 변환하는 작은 런타임(바인딩)이 필요해요.
- C 코드를 빌드하기 위한 과정이 추가되어 프로젝트 구성이 복잡해지고 빌드시간이 증가해요.
실제로 사용하는 수준까지 도달하려면 많은 작업들이 필요해요. 이런 작업들은 한 군데 모아서 자동화 할 수 있다면 효율적이겠죠.
한 가지 방법은 라이브러리 프로젝트 구조와 컨벤션을 제약해두는 거에요. 제약사항을 바탕으로 프로젝트를 분석해 필요한 코드를 생성하는 코드 생성기나 자동화된 빌드 스크립트 등을 만들 수 있을거에요.
또 다른 방법은 이런 라이브러리들을 하나의 모노레포에 모아두는 거에요. 자동화를 위한 스크립트나 설정들을 하나의 모노레포에서 관리할 수 있을거에요.
Rust 프로그래밍 언어와 UniFFI
UniFFI(“유니파이”라고 발음)는 Mozilla 재단에서 Firefox Mobile 앱을 만들 때 같은 문제를 해결하기 위해 만든 Rust 기반 툴체인이에요.
플랫폼 중립적인 FFI를 지원하기 위해 UDL(WebIDL의 방언)이라는 독립적인 인터페이스 언어를 지원해요.
UniFFI를 사용하면 Rust로 만든 네이티브 모듈을 Kotlin이나 Swift 같은 언어를 사용하는 프로젝트에 쉽게 통합할 수 있어요.
이 후 여러 모듈들을 모아둔다고 가정하고 아래와 같이 모노레포를 구성했어요.
Cargo.toml
Cargo.lock
# 자동화 스크립트
scripts/
# 바인딩 생성 커맨드:
# ```
# fn main() {
# uniffi::uniffi_bindgen_main()
# }
# ```
uniffi-bindgen/
Cargo.toml
uniffi-bindgen.rs
# UniFFI crates
crates/
hello/
Cargo.toml
build.rs
src/
hello.udl
lib.rs
...
그리고 PoC를 위해 간단한 링크 파서 라이브러리를 Rust 로 재작성 해보았어요.
https://github.com/daangn/permalink
주어진 URL이 당근마켓 컨텐츠 퍼머링크 규격에 맞는지 검증하고 속성을 파싱하는 라이브러리에요. Permalink
라는 구조체와 WellKnownCountry
라는 열거형 타입을 사용하는 코드를 UniFFI로 통합하는 것을 테스트 해봤어요.
우선 모노레포에 permalink
라는 crate를 추가하고, UDL 파일은 다음과 같이 작성해요.
# crates/permalink/src/permalink.udl
enum WellKnownCountry {
"CA",
"JP",
"KR",
"UK",
"US",
};
[Error]
enum PermalinkError {
"InvalidUrl",
"InvalidPermalink",
"UnknownCountry",
};
dictionary Permalink {
WellKnownCountry country;
string default_language;
string? title;
string id;
string service_type;
string? data;
};
namespace permalink {
[Throws=PermalinkError]
Permalink parse(string url_like);
string normalize(Permalink permalink);
string canonicalize(Permalink permalink, optional string title = "");
};
선언한 인터페이스에 대한 바인딩을 Rust 코드로 작성할 수 있어요.
// crates/permalink/src/lib.rs
use karrot_permalink::permalink::{Permalink, PermalinkError, WellKnownCountry};
uniffi::include_scaffolding!("permalink");
fn parse(url_like: String) -> Result<Permalink, PermalinkError> {
Permalink::parse_str(url_like.as_str())
}
fn normalize(permalink: Permalink) -> String {
permalink.normalize()
}
fn canonicalize(permalink: Permalink, title: String) -> String {
permalink.canonicalize(title.as_str())
}
Cargo.toml
에 라이브러리 타겟을 지정해서 네이티브 모듈을 빌드하고 uniffi-bindgen
을 실행해서 지정한 대상 언어에 대한 바인딩을 생성할 수 있어요.
cargo run -p uniffi-bindgen generate crates/permalink/src/permalink.udl --language kotlin --out-dir $OUT_DIR
생성된 Kotlin 파일 예시:
package uniffi.permalink
// Common helper code.
//
// Ideally this would live in a separate .kt file where it can be unittested etc
// in isolation, and perhaps even published as a re-useable package.
//
// However, it's important that the detils of how this helper code works (e.g. the
// way that different builtin types are passed across the FFI) exactly match what's
// expected by the Rust code on the other side of the interface. In practice right
// now that means coming from the exact some version of `uniffi` that was used to
// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin
// helpers directly inline like we're doing here.
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.ptr.ByReference
import java.nio.ByteBuffer
import java.nio.ByteOrder
// ... 중략
data class Permalink(
var `country`: WellKnownCountry,
var `defaultLanguage`: String,
var `title`: String?,
var `id`: String,
var `serviceType`: String,
var `data`: String?,
)
// ... 중략
enum class WellKnownCountry {
CA, JP, KR, UK, US
}
// ...중략
@Throws(PermalinkException::class)
fun `parse`(`urlLike`: String): Permalink {
return FfiConverterTypePermalink.lift(
rustCallWithError(PermalinkException) { _status ->
_UniFFILib.INSTANCE.permalink_85ba_parse(FfiConverterString.lower(`urlLike`), _status)
},
)
}
fun `normalize`(`permalink`: Permalink): String {
return FfiConverterString.lift(
rustCall() { _status ->
_UniFFILib.INSTANCE.permalink_85ba_normalize(FfiConverterTypePermalink.lower(`permalink`), _status)
},
)
}
fun `canonicalize`(`permalink`: Permalink, `title`: String = ""): String {
return FfiConverterString.lift(
rustCall() { _status ->
_UniFFILib.INSTANCE.permalink_85ba_canonicalize(FfiConverterTypePermalink.lower(`permalink`), FfiConverterString.lower(`title`), _status)
},
)
}
Android에서 호출하기
생성된 라이브러리 파일과 바인딩을 테스트하기 위해 Android 샘플 프로젝트를 만들었어요.
샘플 프로젝트에서는 빌드 통합을 위해 rust-android-gradle이라는 Gradle 플러그인을 사용했어요. app/build.gradle
파일을 다음과 같이 설정했어요.
task uniffiBindgen(type: Exec) {
workingDir "../unimodules"
commandLine 'cargo', 'run', '-p', 'uniffi-bindgen', 'generate', 'crates/permalink/src/permalink.udl', '--language', 'kotlin', '--out-dir', "$projectDir/src/main/java"
}
// 빌드 전에 바인딩을 생성합니다.
preBuild.dependsOn uniffiBindgen
// 정적 라이브러리를 빌드 합니다. 결과물이 $buildDir/rustJniLibs 경로에 나옵니다.
cargo {
module = "../unimodules/crates/permalink"
targetDirectory = "../unimodules/target"
libname = "uniffi_permalink"
targets = ["arm", "arm64", "darwin", "darwin-aarch64", "x86", "x86_64"]
prebuiltToolchains = true
}
afterEvaluate {
// The `cargoBuild` task isn't available until after evaluation.
android.applicationVariants.all { variant ->
def productFlavor = ""
variant.productFlavors.each {
productFlavor += "${it.name.capitalize()}"
}
def buildType = "${variant.buildType.name.capitalize()}"
tasks["generate${productFlavor}${buildType}Assets"].dependsOn(tasks["cargoBuild"])
}
}
task uniffiClean(type: Delete) {
delete "$projectDir/src/main/java/uniffi"
}
// Clean 시 바인딩을 삭제합니다
clean.dependsOn uniffiClean
dependencies {
implementation "net.java.dev.jna:jna:5.13.0@aar"
// ... 생략
}
이러면 gradle build
시 gradle cargoBuild
태스크가 수행되면서 빌드된 라이브러리를 정적 링크해서 사용할 수 있고, gradle uniffiBindgen
태스크에서 생성된 코드를 라이브러리처럼 쓸 수 있어요.
package com.example.rusttest
import uniffi.permalink.*
// ...
// MainActivity.kt 에서 다음 코드 호출
val result = parse("https://www.daangn.com/kr/business-profiles/%EC%9E%84%EC%9D%80%ED%95%98%ED%91%B8%EB%93%9C-%EC%9D%B8%EC%B2%9C%EC%B0%BD%EA%B3%A0-97109917d5214963a7072732b61562df/")
Log.e(result)
비슷한 방식으로 Swift 바인딩을 생성해서 iOS에서도 사용할 수 있는 것을 확인했어요.
UniFFI의 장점
- Rust: Rust를 사용하는 것도 매우 큰 장점이에요. Rust는 많은 개발자들에게 사랑받고, 매년 진행되는 설문에서 “가장 배우고 싶은 언어”로 손꼽히고 있어요. 매우 활발한 생태계를 가지고 있어 진입장벽도 상대적으로 낮은 편이에요. 무엇보다 언어 특징으로 무거운 GC 런타임 없이 높은 수준의 메모리 안정성을 제공하는 것도 네이티브 모듈을 만들 때 손꼽히는 장점이에요.
- 성숙한 툴체인: UniFFI는 Firefox Mobile의 Application Services 개발에 실제로 사용되고 있는 도구에요. 이미 프로덕션에서 돌아가고 있는 도구와 활성화된 커뮤니티의 지원을 받을 수 있어요.
- 다양한 플랫폼 통합: UniFFI는 Kotlin과 Swift 말고도 Python과 Ruby 환경을 지원해요. 모바일 플랫폼 전용이 아니기 때문에 같은 모듈을 서버 환경과도 공유할 수 있어요. 플랫폼 중립적인 도구이기 때문에 이후 새로운 언어/플랫폼 지원이 추가되는 것도 기대할 수 있어요.
해결해야 할 문제들
라이브러리 릴리즈 관리
PoC를 위해 Rust 프로젝트를 Android 앱 프로젝트에 직접 통합했지만, 실제로는 좋은 방법이 아닌 것 같았어요. 이렇게 하면 모든 개발자가 매 빌드 시 마다 Rust 프로젝트를 함께 빌드해줘야 하는 문제가 생겨요. 빌드 복잡도와 시간은 개발 생산성에 주요한 영향을 미치기 때문에 더 나은 빌드 구성을 고민해야 했어요.
대상이 자주 변경되지 않는 “공통 모듈”인 만큼, 따로 구성한 모노레포에서 라이브러리를 미리 빌드하고 릴리즈하는 구성이 가능해요. 더 편하게 사용하기 위해서는 Maven Repository나 Swift Package Manager 같은 도구까지 통합하는 CI 구성이 필수적이에요.
라이브러리 크기 최적화
Permalink 모듈을 실험하고 얻은 가장 중요한 결과는 “생각보다 가볍지 않다” 였어요. 다른 일반적인 플랫폼 환경에선 URL 파서, RegExp 엔진 등이 모두 내장되어 있기 때문에 표준 API 들을 활용하면 굉장히 가벼운 모듈을 만들 수 있지만 네이티브 라이브러리를 작성할 때는 그렇지 않았어요.
사실상 정규표현식 한 줄이 전부인 라이브러리지만 빌드된 정적 라이브러리의 크기가 1MB가 넘어갔어요. 타겟 아키텍처에 따라서는 알 수 없는 이유로 5MB 까지 커지기도 했어요.
베이스라인인 hello
모듈에서 확인한 라이브러리 크기도 타겟에 따라 450KB~560KB로 작지 않았어요. permalink
라이브러리 크기는 이것과 비교해도 너무 무거웠는데 cargo-bloat으로 대략적으로 확인했을 때 regex
crate와 캡쳐 표현식이 가장 큰 영향을 미치는 것으로 보였어요.
❯ cargo bloat --crates -n 10
Finished dev [unoptimized + debuginfo] target(s) in 0.10s
Analyzing target/debug/libuniffi_permalink.dylib
File .text Size Crate
11.0% 33.1% 604.2KiB regex
8.5% 25.4% 464.0KiB regex_syntax
4.3% 13.1% 238.4KiB std
2.0% 6.0% 110.2KiB idna
1.9% 5.6% 102.0KiB url
1.6% 4.9% 90.0KiB aho_corasick
0.8% 2.5% 44.7KiB memchr
0.6% 1.8% 33.4KiB karrot_permalink
0.6% 1.8% 32.9KiB uniffi_core
0.4% 1.3% 24.5KiB anyhow
1.5% 4.4% 79.8KiB And 17 more crates. Use -n N to show more.
33.3% 100.0% 1.8MiB .text section size, the file size is 5.4MiB
Note: numbers above are a result of guesswork. They are not 100% correct and never will be.
(오히려 이 것보다 훨씬 더 복잡한 구문 파서를 pest 같은 파서 생성기로 생성해서 사용하는게 더 작은 모듈 사이즈가 나오는 아이러니한 상황이에요)
앱의 설치크기를 줄이기 위해 항상 노력하는 당근마켓에서 MB 단위는 쉽게 허용할 수 있는 크기가 아니에요. UniFFI를 실제로 프로덕션에서 사용하려면 외부 라이브러리를 최대한 사용하지 않아야 하고 더 작은 빌드를 얻을 방법을 추가로 고민해야 돼요.
웹 플랫폼과의 코드 공유
UniFFI는 Kotlin과 Swift 바인딩을 생성할 수 있어서 당장 모바일 앱에서 사용하기는 수월하지만 웹에서 바로 실행할 수 있는 형식은 아직 지원하지 않아요.
그래서 웹까지 같은 로직을 공유하고 싶다면 공통 모듈을 탑재한 서버나 모바일 앱의 JavaScript 브릿지에 의존해야 해요. 이러면 바인딩 비용이 곱절로 들고, 추가적인 플랫폼 기능에 의존해야하는 문제가 생겨요.
그나마 웹에서도 실행 가능한 형식인 WebAssembly 지원을 위한 티켓이 열려있는 상태이기 때문에 추후 발전 가능성을 기대해볼 수는 있어요.
다른 대안은 없을까?
JavaScript로 코드 공유하기
지원 환경을 Android/iOS/Web으로만 제한한다면, C나 Rust 로 빌드한 모듈이 아닌 JavaScript로 만든 코드를 공유하는 것도 방법이에요.
Android나 iOS 시스템은 웹뷰 지원을 위해 JavaScript 엔진을 내장하고 있기 때문에 API를 통해 JavaScript 실행 컨텍스트를 만들어 공유받은 코드를 실행할 수 있어요.
- Android의 JavaScriptSandbox API
- iOS의 JavaScriptCore API
브라우저 엔진과 동일한 JavaScript 실행 엔진을 사용하기 때문에, 실행성능이 매우 빨라요. URL 파서와 RegExp 엔진 등 일부 플랫폼 API를 사용할 수 있어서 위에서 언급한 라이브러리 크기 문제도 어느 정도 완화할 수 있어요.
WebAssembly로 코드 공유하기
JavaScript 실행 객체는 각 플랫폼에 내장된 브라우저 런타임을 기반으로 하기 때문에 차세대 바이트코드 형식인 WebAssembly(이하 WASM)를 실행하는데도 사용할 수 있어요.
// 플랫폼에서 WASM 바이너리 데이터를 주입합니다.
const wasmDat = await android.consumeNamedDataAsArrayBuffer('my-module');
const module = await WebAssembly.compile(wasmDat);
const instance = await new WebAssembly.Instance(module);
console.log(instance.exports.hello());
// Hello, WASM!
WASM 모듈은 크기가 큰 모듈에 대해 더 나은 부트스트랩(컴파일) 시간과 실행 성능을 가져요. 특히 산술 연산이 많은 모듈은 JavaScript 대신 WASM을 사용하면 좋을거에요. (대신UniFFI에서 발견한 문제들이 다시 나타날 수 있어요)
또한 WASM의 경우 C++, Zig, PHP, C# 과 같은 다양한 언어로 작성할 수 있다는 점도 장점이에요. Rust에 익숙치 않은 개발자들도 기여할 수 있는 기회가 생겨요.
자체 툴체인 제작
코드 형식을 JavaScript 와 WASM으로 바꿔서 여러 기존 문제들을 해결할 수는 있지만 다시 호출하는 쪽을 위한 FFI/런타임 바인딩이 필요한 문제가 생겨요.
UniFFI 처럼 이 과정을 자동화 해주는 툴체인을 자체적으로 만드는 것도 생각해볼 수 있어요.
자체적인 툴체인을 만들면 원하는 부분을 최적화하는 등 커스터마이징이 자유로운 장점이 있지만, 대신 구현 공수가 매우 클 것으로 예상하고 있어요. Interface Language, Data Representation, Code Generator 등 구현할 게 산더미라 작은 규모의 투자로 할 수 있는 작업은 아닌 것 같아요.
하지만 정말 어려운 문제를 해결하기 위해 과감한 결정과 투자가 필요할 때가 있어요. 가끔은 바퀴의 재발명도 의미있는 일이 되고는 해요. 앞서 실험한 내용을 교훈삼아 더 나은 도구를 만들어보는 것도 열어두고 생각해보고 있어요.
레퍼런스
비슷한 고민에 대한 비슷하거나 다른 접근, 또는 의견이 있으시면 자유롭게 댓글로 토론해보도록 해요. 혹은 당근마켓 모바일 플랫폼 팀에 합류해서 함께 해결해보면 어떨까요?