콘텐츠로 이동

Java 개발자를 위한 Rust 입문

Java로 오랫동안 일해 왔다면, Rust를 배우는 데 필요한 건 이미 대부분 갖추고 있어요. 여러분은 타입으로 생각하는 데 익숙하고, 제네릭, 인터페이스, 람다, 스트림 파이프라인을 알고 있어요. Rust에도 이게 다 있어요. 트레이트는 기본 메서드가 있는 인터페이스와 아주 비슷하고, 반복자는 스트림처럼 읽히고, Cargo는 Maven이나 Gradle이 하던 일을 훨씬 적은 설정으로 해 줘요.

새로운 건 JVM이 대신해 주던 모든 일이에요. Rust에는 가비지 컬렉터도 가상 머신도 없어요. 코드는 네이티브 바이너리로 컴파일되고, 메모리를 언제 해제할지는 컴파일러가 프로그램을 실행하기 전에 소유권(ownership)과 대여(borrowing)를 검사해서 정해요. 또 Rust에는 null이 없고(Option을 써요), 예외도 없고(Result를 써요), 클래스 상속도 없어요. 공부 시간은 대부분 이 네 가지 변화에 쓰게 될 거예요.

Java 개발자는 왜 Rust를 배울까요? 보통 이런 이유 중 하나예요. 메모리 사용량이 적고 일정해야 하며 GC 멈춤이 없어야 하는 서비스, JVM 없이 바로 시작해야 하는 커맨드 라인 도구와 서버리스 함수, Java 앱에서 네이티브 코드로 옮기고 싶은 뜨거운 경로(Rust는 JNI나 새로운 Foreign Function & Memory API로 Java에서 호출할 수 있어요), 아니면 그냥 Rust가 왜 “가장 사랑받는 언어” 설문에서 계속 1위를 하는지 궁금해서예요. 이 페이지는 Java에서 알던 것을 Rust와 짝지어 보여 줘서, 정말 다른 부분에 집중할 수 있게 도와드려요.

C++, Go, Python과의 일반적인 비교가 궁금하다면 Rust vs C++, Go, Python을 보세요. .NET도 알고 있다면 C# 개발자를 위한 Rust 페이지도 따로 있어요.

이 표를 회화 사전처럼 써 보세요. 마지막 열은 Rust 쪽을 설명하는 이 사이트의 강좌로 연결돼요.

Java Rust 더 배우기
필드와 메서드가 있는 class 데이터는 struct, 메서드는 impl 블록 구조체
생성자와 new Self를 반환하는 평범한 함수. 관례상 이름이 new예요 구조체
interface trait 트레이트
인터페이스의 default 메서드 트레이트의 기본 메서드 (똑같이 동작해요) 트레이트
abstract class, extends, super 상속은 없어요. 공통 동작은 기본 메서드가 있는 트레이트로, 공통 데이터는 컴포지션(구조체 안에 구조체)으로 만들어요 트레이트, 디자인 패턴
여러 클래스를 담는 List<Shape> Vec<Box<dyn Shape>> (트레이트 객체) 트레이트 객체
sealed interface + record + 패턴 switch (Java 17/21) 배리언트가 데이터를 담는 enum + match 열거형, match
필드와 생성자가 있는 enum 배리언트마다 서로 다른 데이터를 담을 수 있는 enum 열거형
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } 구조체, 자주 쓰는 트레이트
toString(), equals(), hashCode(), compareTo() Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (보통 derive로) 자주 쓰는 트레이트
==(같은 객체)와 .equals()(같은 값) ==는 항상 값을 비교해요. “참조 비교” 함정은 없어요 연산자
null과 Optional<T> Option<T>: Some(value) 아니면 None Option
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) Option, if let / while let
검사 예외(checked exception)와 throws 반환 타입의 Result<T, E> + ? 연산자 Result, 사용자 정의 에러
진짜 버그를 알리는 비검사 예외(IllegalStateException) panic! (스레드를 멈춰요) panic
예외 체이닝 (new ConfigException(msg, cause)) map_err, 또는 thiserror의 #[source]와 anyhow의 .context(...) anyhow와 thiserror
try-with-resources와 AutoCloseable Drop: 주인이 스코프를 벗어나면 자동으로 실행돼요 스코프, 스마트 포인터
가비지 컬렉터 소유권과 대여, 컴파일할 때 검사 소유권, 대여
final 지역 변수 그냥 let (기본이 불변). 바꾸려면 let mut 변수
static final 상수 const 변수
가변 static 필드, 싱글턴 LazyLock이나 OnceLock을 쓴 static. 바뀌어야 하면 Mutex로 감싸요 공유 상태
<T extends Comparable<T>>가 붙은 제네릭 트레이트 바운드가 붙은 제네릭: T: Ord 또는 where T: Ord 제네릭
타입 소거, List<int> 대신 List<Integer> 단형화(monomorphization): 제네릭 코드가 타입마다 컴파일되고, Vec<i32>는 평범한 숫자를 저장해요 제네릭, 고급 제네릭
람다, Function<T, R>, Supplier<T>, Consumer<T> 클로저. Fn, FnMut, FnOnce 트레이트로 표현해요 클로저, Fn, FnMut, FnOnce
스트림: filter, map, reduce, collect 반복자 어댑터: filter, map, fold, sum, collect 반복자
Iterable<T>와 Iterator<T> IntoIterator와 Iterator (메서드는 Option을 반환하는 next 하나) 사용자 정의 반복자
parallelStream() rayon 크레이트의 par_iter() 동시성
Thread, ExecutorService std::thread::spawn과 std::thread::scope. 풀에는 rayon이나 tokio 동시성
synchronized, ReentrantLock 보호할 데이터를 감싸는 Mutex<T> 공유 상태
AtomicInteger, AtomicLong AtomicI32, AtomicI64, AtomicUsize 공유 상태
스레드 사이의 BlockingQueue 채널 (std::sync::mpsc) 채널
CompletableFuture, 가상 스레드 async fn과 .await. tokio 같은 런타임이 실행해요 async/await
String(불변)과 StringBuilder &str(빌려 온 텍스트 뷰)과 String(소유하고, 늘릴 수 있음) 문자열
char (UTF-16 단위 하나) char (어떤 유니코드 문자든, 4바이트). 문자열은 UTF-8이에요 문자열
int, long, double과 박싱된 Integer i32, i64, f64, 그리고 부호 없는 u8부터 u128까지. 박싱은 없어요 데이터 타입
조용히 넘어가는 정수 오버플로 debug 빌드에서는 패닉. checked_add, wrapping_add, saturating_add로 동작을 골라요 정수 오버플로
(int) x 같은 캐스트 숫자에는 as, 안전한 변환에는 From / TryFrom 타입 변환, 타입 변환 (From, Into)
BigDecimal, BigInteger rust_decimal과 num-bigint 크레이트 데이터 타입
패키지와 import 모듈(mod)과 use 모듈
public, private, protected, package-private 기본은 비공개. pub과 pub(crate). protected는 없어요 모듈
JAR / Maven 아티팩트 크레이트(라이브러리 또는 바이너리) Cargo와 크레이트
애너테이션 (@Override, Lombok의 @Data) #[derive(Debug)], #[test] 같은 어트리뷰트 매크로
리플렉션, 런타임 애너테이션 처리 런타임에는 거의 없어요. 대신 매크로가 컴파일할 때 코드를 만들어요 절차적 매크로
메서드 오버로딩 오버로딩은 없어요. 이름을 다르게 하거나 제네릭 매개변수를 써요 함수
instanceof 검사와 다운캐스트 열거형에 match (Any로 다운캐스트할 수도 있지만 드물어요) 패턴 매칭
JNI, Foreign Function & Memory API extern "C" 함수 FFI (C 호출하기)

Java 컬렉션 프레임워크는 std::collections에 깔끔하게 대응돼요. 가장 큰 차이는, Rust 컬렉션은 절대 null을 반환하지 않는다는 거예요. 조회하면 Option을 돌려줘요.

Java Rust 더 배우기
ArrayList<T> Vec<T> 벡터
int[], String[] 컴파일할 때 길이를 알면 [T; N], 아니면 Vec<T>. 어느 쪽이든 슬라이스 &[T]로 넘겨요 배열, 슬라이스
List.of(...) (수정 불가) 배열, 또는 &Vec<T> / &[T]로만 건네주는 Vec (공유 참조로는 아무도 바꿀 수 없어요) 대여
HashMap<K, V> HashMap<K, V> (get은 Option<&V>를 반환해요) HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 HashMap
TreeMap<K, V> BTreeMap<K, V> (키가 정렬된 상태로 유지돼요) HashMap
LinkedHashMap<K, V> (넣은 순서) indexmap 크레이트의 IndexMap
HashSet<T>, TreeSet<T> HashSet<T>, BTreeSet<T> HashSet과 BTreeMap
ArrayDeque<T> VecDeque<T> VecDeque와 BinaryHeap
Stack<T>, 또는 스택으로 쓰는 Deque push / pop을 쓰는 Vec<T> 벡터
PriorityQueue<T> (작은 것부터) BinaryHeap<T> (큰 것부터 나와요. 작은 것부터 꺼내려면 Reverse로 감싸요) VecDeque와 BinaryHeap
LinkedList<T> LinkedList<T>도 있지만, 거의 항상 Vec이나 VecDeque가 더 빨라요 VecDeque와 BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>>, 또는 dashmap 크레이트 공유 상태
list.sort(Comparator.comparing(...)) v.sort_by_key(...) 또는 v.sort_by(...) 정렬과 검색
list.removeIf(...) v.retain(...) (조건에 맞는 항목을 남겨요) 벡터

Java의 HashMap처럼 Rust의 HashMap에도 정해진 순서가 없고, 실행할 때마다 순서가 달라질 수 있어요. 정렬된 출력이 필요하면 BTreeMap을 쓰거나 먼저 키를 정렬하세요.

Java 개발자 대부분이 금방 Cargo에 반해요. 도구 하나, 설정 형식 하나, 플러그인 XML은 없어요. 빌드, 의존성, 테스트, 문서, 포매팅, 린트가 모두 한곳에서 나와요.

Java Rust 참고
Maven / Gradle cargo 빌드, 실행, 테스트, 의존성 추가, 배포까지. Cargo와 크레이트 참고
pom.xml / build.gradle.kts Cargo.toml 짧고, 평범한 프로젝트에는 빌드 스크립트가 필요 없어요
Gradle 의존성 잠금 Cargo.lock 자동으로 만들어지고 갱신돼요
멀티 모듈 빌드 Cargo 워크스페이스 맨 위의 Cargo.toml 하나가 멤버 크레이트를 나열해요
Maven Central crates.io 공개된 모든 크레이트의 API 문서는 docs.rs(영어)에 있어요
<dependency> 추가 cargo add serde
mvn archetype:generate / gradle init cargo new my_app (라이브러리는 cargo new --lib)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release debug 빌드는 실행 속도가 훨씬 느려요. 성능은 release로 재세요
java -jar app.jar ./target/release/app 네이티브 바이너리 하나예요. 서버에 JVM을 설치할 필요가 없어요
JDK 버전, SDKMAN!, 툴체인 rustup과 rust-toolchain.toml 파일 Rust 도구와 개발 환경 참고
javac rustc Cargo가 대신 호출해 줘서 직접 실행할 일은 드물어요
JShell 브라우저의 Rust Playground(영어), 또는 evcxr_repl
Checkstyle, SpotBugs, PMD, Error Prone cargo clippy 공식 린트 수백 개
google-java-format, Spotless cargo fmt 표준 스타일 하나. 중괄호 위치로 다툴 일이 없어요
IntelliJ IDEA, Eclipse JetBrains RustRover, 또는 VS Code + rust-analyzer rust-analyzer가 자동 완성, 정의로 이동, 실시간 에러 표시를 해 줘요
Javadoc 마크다운으로 쓰는 /// 문서 주석 + cargo doc 문서 안의 코드 예제가 테스트로 실행돼요. Rust 문서화 참고
JUnit 5 내장된 #[test]와 cargo test (더 빠른 실행기로 cargo nextest가 인기예요) Rust 테스트 참고
@ParameterizedTest rstest
Mockito mockall, 또는 트레이트를 구현한 직접 만든 가짜 객체 테스트 더 알아보기 참고
AssertJ / Hamcrest assert!, assert_eq!, 읽기 쉬운 diff에는 pretty_assertions
WireMock, Testcontainers wiremock, testcontainers 아이디어는 같고, Rust 버전이에요
JaCoCo cargo llvm-cov 또는 cargo tarpaulin
JMH criterion 성능 참고
JFR, async-profiler, VisualVM cargo flamegraph (cargo install flamegraph로 설치), perf 튜닝할 GC나 JIT가 없어요
GraalVM native-image 필요 없어요 모든 Rust 빌드는 이미 네이티브 코드예요

작은 Cargo.toml을 보여 드릴게요. 같은 의존성 세 개를 넣으려면 pom.xml이 얼마나 길어지는지 비교해 보세요.

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?”

섹션 제목: “라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?””

Java 팀이라면 모두 가장 먼저 묻는 질문이에요. “우리는 Spring Boot와 Hibernate를 쓰는데, Rust 버전은 뭐예요?” Rust에는 Spring이 없어요. 모든 걸 다 가져다주는 프레임워크 하나 대신, 한 가지 일에 집중하는 크레이트 몇 개를 조합해요. 가장 가까운 것들을 정리해 볼게요.

Java Rust 참고
Spring Boot (Spring MVC / WebFlux) axum (프로젝트: Axum으로 웹 API에서 직접 만들어 봐요), actix-web, Rocket axum과 actix-web은 실무에서 널리 쓰여요
@RestController + @GetMapping 평범한 async fn 핸들러를 가리키는 axum 라우트 애너테이션도 클래스패스 스캔도 없어요. 라우트는 코드로 연결해요
서블릿 필터, Spring 인터셉터 tower 레이어와 tower-http 크레이트(CORS, 압축, 트레이싱, 타임아웃) axum은 tower를 그대로 써요
Spring 의존성 주입 (@Autowired) axum의 공유 State, 평범한 생성자, 트레이트 아래쪽의 DI 행을 참고하세요
springdoc-openapi utoipa
Thymeleaf askama (컴파일할 때 템플릿을 검사해요) 또는 tera
Spring WebSocket axum의 WebSocket(ws 기능), 또는 tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic (protobuf는 prost와 함께)
java.net.http.HttpClient, OkHttp, WebClient reqwest 기본은 비동기이고, 블로킹 클라이언트도 선택할 수 있어요
Netty tokio + hyper
Spring Security (JWT, 비밀번호 해싱) jsonwebtoken, argon2 모든 걸 갖춘 보안 프레임워크는 없어요
Java Rust 참고
Hibernate / JPA / Spring Data JPA SeaORM: 엔티티, 관계, 마이그레이션을 지원하는 비동기 ORM JPA와 가장 비슷한 느낌이에요. sqlx 위에 만들어졌어요
엄격한 타입을 중시하는 Hibernate 사용 Diesel: ORM이자 쿼리 빌더예요. 쿼리 타입을 컴파일할 때 검사해요 성숙한 라이브러리(2.x). 기본은 동기 방식이에요(diesel-async도 있어요)
JDBC, JdbcTemplate, jOOQ, MyBatis sqlx: 순수 SQL을 쓰고, query! 매크로가 컴파일할 때 실제 데이터베이스에 대고 검사해요 비동기, 아주 인기 있음, ORM은 아니에요
Flyway / Liquibase sqlx migrate (sqlx-cli), diesel migration, 또는 refinery
Jedis / Lettuce (Redis) redis
Kafka 클라이언트, Spring Kafka rdkafka (librdkafka 기반)
RabbitMQ Java 클라이언트 lapin
AWS SDK for Java 공식 AWS SDK for Rust: aws-config, aws-sdk-s3, …

JPA 엔티티가 좋았다면 SeaORM부터 써 보세요. SQL을 직접 쓰는 게 좋았다면(jOOQ와 MyBatis 쪽 사람들) sqlx를 써 보세요. 컴파일 타임 보장을 가장 강하게 원한다면 Diesel을 써 보세요. 세 가지 모두 동작하는 코드와 함께 데이터베이스에서 볼 수 있어요.

Java Rust 참고
Jackson / Gson serde + serde_json 사실상 표준이에요. TOML, YAML 등도 다뤄요. Serde로 JSON 다루기 참고
JAXB, Jackson XML quick-xml (serde와 쓰려면 serialize 기능을 켜요)
SLF4J + Logback / Log4j 2 tracing (+ tracing-subscriber), 또는 log + env_logger 로깅 참고
Lombok 내장 derive(Debug, Clone, PartialEq, Default, …). 빌더는 bon이나 derive_builder 바이트코드 트릭은 없어요. 모두 평범한 매크로예요
MapStruct From / Into 구현 직접 작성하고, 컴파일러가 검사해 줘요. 타입 변환 (From, Into) 참고
Spring IoC 컨테이너 보통은 아무것도 안 써요. 의존성은 생성자로 넘기고, 테스트에서 바꿔 끼울 부분은 트레이트로 만들어요 DI 컨테이너를 쓰는 Rust 프로젝트는 드물어요
Bean Validation (@NotNull, @Size) validator 또는 garde (derive 기반 검증) @NotNull은 필요 없어요. 값이 없을 수 있는 곳에만 Option을 써요
Resilience4j backon, 또는 tower의 retry와 timeout 미들웨어
Caffeine (캐싱) moka
Guava, Apache Commons 대부분 표준 라이브러리로 충분하고, 그 밖에 itertools
java.time chrono 또는 jiff 날짜와 시간 참고
picocli clap 프로젝트: clap으로 CLI 도구 참고
@Scheduled, Quartz tokio-cron-scheduler
Micrometer metrics, 또는 opentelemetry
Jakarta Mail lettre

데스크톱, 게임, Android, 상호 운용

섹션 제목: “데스크톱, 게임, Android, 상호 운용”
Java Rust 참고
JavaFX / Swing Tauri (웹 UI + Rust 백엔드), Slint, egui, Iced Tauri 2와 Slint 1.x가 가장 안정적이에요. GUI 앱 참고
LibGDX, jMonkeyEngine Bevy, macroquad Bevy는 1.0 이전이라 릴리스마다 API가 바뀌어요. 게임 개발 참고
Android 앱 코드 UI는 Kotlin이나 Java로 두고, 공통 로직을 Android NDK로 빌드한 Rust 라이브러리에 넣어요 Mozilla의 uniffi가 Kotlin 바인딩을 만들어 줘요
Java에서 네이티브 코드 호출 extern "C" 함수를 공개하고 FFM API(JDK 22+)로 호출하거나, jni 크레이트로 JNI 함수를 작성해요 FFI (C 호출하기) 참고

나란히 보기: 추상 클래스는 트레이트로

섹션 제목: “나란히 보기: 추상 클래스는 트레이트로”

전형적인 Java 클래스 계층이에요. 추상 메서드 하나와 공통 메서드 하나가 있는 추상 기본 클래스예요.

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " earns " + monthlyPay() + " a month"; }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

Rust에는 extends가 없어요. 공통 메서드는 트레이트의 기본 메서드로 옮기고, 구조체마다 자기 데이터를 가져요.

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// 기본 메서드: 추상 클래스에 있던 구체적인 payslip()과 같아요.
fn payslip(&self) -> String {
format!("{}의 월급: {}", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee>가 "어떤 Employee를 가리키는 참조" 역할을 해요.
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

실행 결과:

Ada의 월급: 5000
Linus의 월급: 6000
▶ 직접 실행해 보기

트레이트는 필드를 가질 수 없어서, protected name은 구조체마다 구현하는 작은 name() 메서드가 돼요. 처음에는 타이핑이 늘어난 것 같지만, 문제 하나를 통째로 없애 줘요. 깨지기 쉬운 기본 클래스도, 깜빡하기 쉬운 super 호출도, 스크롤해야 하는 깊은 계층도 없어요. Java 8의 인터페이스 default 메서드를 안다면, Rust가 동작을 공유하는 방법은 이미 아는 거예요. 그리고 타입의 종류가 정해져 있다면(Java 17의 sealed interface처럼), 트레이트보다 열거형과 match가 더 간단할 때가 많아요.

나란히 보기: Optional은 Option으로

섹션 제목: “나란히 보기: Optional은 Option으로”

Java의 Optional은 큰 발전이었지만 반환값에만 쓰이고, map.get()이나 아무 필드나 여전히 null일 수 있어요.

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("no email on file"));
}
findEmail(1).ifPresent(email -> System.out.println("sending to " + email));

Rust의 Option은 API가 거의 같지만, 컬렉션 조회를 포함해서 값이 없을 수 있는 모든 곳에 쓰여요. 그리고 그 뒤에 숨은 null도 없어요.

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "등록된 이메일이 없어요".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("보내는 중: {email}");
}
// flatMap은 and_then이라고 불러요
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

실행 결과:

등록된 이메일이 없어요
보내는 중: [email protected]
Some("example.com")
▶ 직접 실행해 보기

None을 처리하기 전에는 컴파일러가 Option<String>에 .to_uppercase()를 호출하지 못하게 해서, NullPointerException이란 게 아예 존재하지 않아요. Option에는 추가 비용도 없어요. Option<&T>는 평범한 참조와 크기가 같아요. 도우미 메서드는 Option 페이지에서 배워 보세요.

나란히 보기: 검사 예외는 Result로

섹션 제목: “나란히 보기: 검사 예외는 Result로”

검사 예외의 핵심에는 좋은 아이디어가 있었어요. 시그니처가 무엇이 잘못될 수 있는지 알려 준다는 거예요. 전형적인 Java 코드예요.

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("need " + needed + " more"); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("paid " + amount + ", " + left + " left");
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("error: " + e.getMessage()); }

Rust는 그 좋은 아이디어는 살리고 나머지는 버려요. 에러는 반환 타입의 일부이고(Result<u32, BankError>), ?가 throws의 역할, 즉 “이게 실패했으면 에러를 호출한 쪽으로 넘겨줘”를 해요. 에러는 보통 enum이라서, 타입 하나에 작업이 실패할 수 있는 모든 경우를 나열할 수 있어요.

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display가 getMessage() 역할을 해요.
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "금액은 0보다 커야 해요"),
BankError::InsufficientFunds { needed } => write!(f, "{needed}만큼 더 필요해요"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // 예외를 "던지는" 메서드를 호출하는 것과 같아요
println!("{amount} 지불, {left} 남음");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("에러: {e}");
}
}
}

실행 결과:

80 지불, 20 남음
에러: 30만큼 더 필요해요
에러: 금액은 0보다 커야 해요
▶ 직접 실행해 보기

무엇이 사라졌는지 보세요. try 블록도, 스택 되감기도, 람다를 통과시키려고 RuntimeException으로 감싼 예외도 없어요. 에러는 평범한 값이라서 클로저와 반복자 체인 안에서도 문제없이 동작해요. ?는 함수가 일찍 반환할 수 있는 모든 지점을 눈에 보이게 표시해 줘요. Display를 손으로 쓰는 건 금방 지겨워지는데, thiserror 크레이트가 대신 만들어 주고, anyhow는 애플리케이션에 모든 에러를 담는 타입을 제공해요(anyhow와 thiserror 참고). 비검사 예외로 알리던 진짜 버그에는 Rust의 panic!이 있어요.

나란히 보기: 스트림은 반복자로

섹션 제목: “나란히 보기: 스트림은 반복자로”

groupingBy 컬렉터를 쓰는 스트림 파이프라인이에요.

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

Rust에서는 .iter()가 .stream() 역할을 하고, 어댑터 이름도 익숙해요.

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()): 정렬은 반복자 안이 아니라 Vec에서 해요.
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

실행 결과:

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ 직접 실행해 보기

도시 이름은 BTreeMap이 알파벳순으로 정렬하기 때문에 영어로 두었어요.

스트림처럼 Rust 반복자도 게을러서(lazy), 무언가가 소비할 때(collect, sum, for 반복문)에만 실행돼요. 스트림과 달리 Collectors 클래스는 없어요. collect()가 여러분이 요청한 타입(Vec, HashMap, String, …)을 보고 그걸 만들어 줘요. 그룹으로 묶을 때는 entry API를 쓰는 평범한 반복문이 보통의 답이고, groupingBy가 그립다면 itertools 크레이트가 도우미를 더해 줘요. 그리고 반복자 체인은 손으로 쓴 반복문과 똑같은 기계어로 컴파일되고, 중간에 int를 박싱하는 일도 없어요. 더 자세한 내용은 반복자와 정렬과 검색에서 읽어 보세요.

나란히 보기: 타입 소거와 단형화

섹션 제목: “나란히 보기: 타입 소거와 단형화”

Java 제네릭은 컴파일할 때만 존재해요. 런타임에는 T가 지워지기 때문에 new T()라고 쓸 수 없고, 박싱하지 않고는 int를 List에 넣을 수 없어요.

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // 컴파일되지 않아요: T가 지워졌어요
return list;
}
List<int> ids; // 이것도 컴파일되지 않아요: List<Integer>가 필요해요

Rust는 제네릭 함수를 쓰는 타입마다 따로 복사본을 컴파일해요(이걸 단형화(monomorphization)라고 해요). 그래서 함수는 T가 정확히 무엇인지 알고, T::default()를 호출할 수 있어요.

use std::mem::size_of;
// "T는 기본값이 있고 복제할 수 있어야 해요"
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// 박싱이 없어요. Vec<i32>는 4바이트짜리 평범한 숫자를 나란히 저장해요.
println!("i32 하나는 {}바이트를 차지해요", size_of::<i32>());
println!("bool 하나는 {}바이트를 차지해요", size_of::<bool>());
}

실행 결과:

[0, 0, 0] ["", ""] [false, false]
i32 하나는 4바이트를 차지해요
bool 하나는 1바이트를 차지해요
▶ 직접 실행해 보기

대가도 있어요. 코드 복사본이 많아지면 바이너리가 커지고 컴파일 시간이 길어져요. 대신 모든 호출이 직접 호출이고 인라인될 수 있어요. (Java처럼) 공유하는 복사본 하나를 원할 때는 Box<dyn Trait> 같은 트레이트 객체를 쓰세요. 콜론 뒤의 바운드(T: Default + Clone)가 Rust판 <T extends ...>예요. 제네릭과 고급 제네릭을 참고하세요.

나란히 보기: synchronized는 Arc와 Mutex로

섹션 제목: “나란히 보기: synchronized는 Arc와 Mutex로”

스레드 네 개가 공유 카운터를 증가시켜요. Java에서는 락이 객체에 붙어 있고, synchronized를 기억하는 건 여러분의 몫이에요.

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("count = " + counter.get());

Rust에서는 Mutex가 숫자를 소유해요. 먼저 잠그지 않고는 숫자에 닿을 방법이 없어요. Arc(원자적으로 참조 개수를 세는 포인터)는 여러 스레드가 뮤텍스를 공유할 수 있게 해 줘요.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
// 스레드마다 같은 Mutex를 가리키는 자기 핸들을 받아요.
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock()은 가드를 반환해요. 가드가 drop되면 잠금이 풀려요.
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("count = {}", *counter.lock().unwrap());
}

실행 결과:

count = 4000
▶ 직접 실행해 보기

Java 개발자가 좋아하는 부분이 여기 있어요. Java에서 synchronized를 깜빡하면 코드는 컴파일되고, 가끔씩 틀린 숫자가 나와요. Rust에서는 평범한 i32나 Vec을 스레드 사이에서 공유하면서 바꾸려고 하면 프로그램이 컴파일되지 않아요. 데이터 경쟁은 타입 시스템이 잡아 줘요(Send와 Sync 트레이트를 통해서요). 카운터 하나만 있다면 AtomicInteger처럼 AtomicUsize가 뮤텍스보다 가벼워요. 동시성과 공유 상태를 참고하세요.

Java 개발자가 자주 걸려 넘어지는 부분

섹션 제목: “Java 개발자가 자주 걸려 넘어지는 부분”

Java 개발자는 누구나 같은 곳에서 걸려요. 미리 알고 있으면 훨씬 수월해요.

  • 함수에 값을 넘기면 이동해요. Java에서 객체를 넘기면 참조가 넘어가고, 그 뒤에도 변수를 계속 쓸 수 있어요. Rust에서 String이나 Vec을 값으로 넘기면 함수 안으로 이동해요.

    fn greet(name: String) {
    println!("Hello, {name}!");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    컴파일러 메시지는 영어로 나와요. “name의 값은 7번째 줄에서 이동했는데, 8번째 줄에서 이동한 값을 다시 쓰려고 했다”는 뜻이에요. 보통은 함수가 빌리게 해서 해결해요. fn greet(name: &str)로 만들고 greet(&name)으로 호출하세요. 소유권을 먼저 읽어 보세요. 이 규칙을 설명하는 페이지예요. 이 에러는 E0382에서 자세히 다뤄요.

  • 대여 검사기(borrow checker). 읽는 쪽(&T)은 여럿이어도 되고, 아니면 쓰는 쪽(&mut T)이 딱 하나여야 해요. 둘을 동시에 가질 수는 없어요. 부모를 다시 가리키는 자식 객체나, 여러 객체가 공유 객체 하나를 들고 바꾸는 것처럼 Java에서는 평범한 객체 그래프도 스마트 포인터나 다른 설계가 필요해요. 처음 몇 주는 답답할 수 있어요. 그래도 정말 쉬워져요.

  • 상속도, protected도, super도 없어요. 트레이트로 클래스 계층을 다시 만들고 싶은 유혹이 들 거예요. 먼저 열거형과 컴포지션을 써 보세요. 보통 그쪽이 더 잘 맞아요.

  • ==는 값을 비교해요. 이번에는 좋은 소식이에요. 두 String에 a == b를 하면 텍스트를 비교해서, 고전적인 ==와 .equals() 버그가 일어날 수 없어요. 직접 만든 구조체에서는 PartialEq를 derive하거나 구현해야 ==가 동작해요.

  • BinaryHeap은 가장 큰 항목을 꺼내요. Java의 PriorityQueue는 가장 작은 것부터 줘요. Rust에서 최소 힙처럼 쓰려면 항목을 std::cmp::Reverse로 감싸세요. VecDeque와 BinaryHeap을 참고하세요.

  • 정수 오버플로는 조용히 넘어가지 않아요. Java에서 Integer.MAX_VALUE + 1은 조용히 값이 한 바퀴 돌아요. Rust의 debug 빌드에서는 패닉이 나요. checked_add, wrapping_add, saturating_add로 의도를 밝히세요. 정수 오버플로를 참고하세요.

  • String과 &str, 그리고 charAt이 없어요. Rust 문자열은 UTF-8이라서 s[0]은 컴파일되지 않아요. 글자를 하나씩 보려면 s.chars()를 쓰세요. 함수는 보통 &str을 받고, 구조체는 보통 String을 저장해요. 문자열을 참고하세요.

  • 메서드 오버로딩이 없어요. 매개변수만 다른 print 메서드 두 개를 만들 수 없어요. 이름을 다르게 하거나(print, print_with_color) 제네릭 매개변수를 쓰세요.

  • 런타임 마법이 없어요. 클래스패스 스캔도, @Autowired도, 메서드 주위에 트랜잭션을 덧붙이는 프록시도 없어요. 모든 건 코드에서 명시적으로 연결되고, 매크로는 컴파일할 때 실행돼요. 타이핑은 늘지만, 무슨 일이 일어나는지 언제나 눈으로 볼 수 있어요.

  • null도 예외도 없어요. 처음에는 여기저기 Option과 Result를 쓰는 게 일이 늘어난 것처럼 느껴져요. 몇 주 지나면 NullPointerException도, 예상치 못한 스택 트레이스도 한동안 못 봤다는 걸 알게 될 거예요. 배우는 동안 .unwrap()을 써도 괜찮지만, 실제 코드에서 이건 Rust판 “그냥 죽게 둬”예요.

  • 컴파일 시간. 처음부터 빌드하면 모든 의존성을 소스에서 가져와 컴파일해서 javac보다 느려요. 증분 빌드는 훨씬 빠르고, 코드를 고치는 동안 가장 많이 실행하게 될 건 cargo check(코드 생성을 건너뛰어요)예요. 반대쪽 끝에 좋은 소식도 있어요. JVM 워밍업 없이 프로그램이 바로 시작돼요.

  • async에는 런타임이 필요해요. 가상 스레드는 없어요. 실행기(executor)가 돌려 주기 전까지 async fn은 아무 일도 하지 않아서, 웹 프로젝트는 tokio를 추가하고 main에 #[tokio::main]을 붙여요. async/await를 참고하세요.

이 사이트를 순서대로 읽을 필요는 없어요. 이 경로는 Java가 이미 가르쳐 준 건 건너뛰고, Rust가 다른 부분에 시간을 써요.

  1. Rust 시작하기와 Rust 도구와 개발 환경: Rust를 설치하고, 에디터를 고르고, cargo new를 실행해요.
  2. 변수와 데이터 타입: 가볍게 훑어보면서 let mut과 숫자 타입 위주로 익혀요.
  3. 소유권, 대여, 슬라이스 순서로: 여러분에게 가장 중요한 페이지예요. 천천히 읽으세요.
  4. 문자열: String과 &str.
  5. 구조체와 열거형, 그다음 match와 패턴 매칭.
  6. Option과 Result, 그다음 사용자 정의 에러와 anyhow와 thiserror.
  7. 트레이트, 제네릭, 자주 쓰는 트레이트, 트레이트 객체: 여러분의 인터페이스와 추상 클래스예요.
  8. 클로저와 반복자: 여러분의 람다와 스트림이에요.
  9. 벡터, HashMap, VecDeque와 BinaryHeap.
  10. 모듈, Cargo와 크레이트, 테스트.
  11. 라이프타임과 스마트 포인터.
  12. 동시성, 공유 상태, 채널, async/await.
  13. Serde로 JSON 다루기, 로깅, 데이터베이스, 그다음 웹 API 프로젝트를 만들어 봐요.
  14. 기존 Java 앱을 빠르게 하고 싶다면 FFI (C 호출하기)를 읽고, Rust가 extern "C" 함수를 어떻게 공개하는지 보세요.

두 문제 모두 Java 코드를 주고, 그걸 Rust로 옮기는(포팅하는) 문제예요.

1. record와 스트림 파이프라인 옮기기

섹션 제목: “1. record와 스트림 파이프라인 옮기기”

이 코드는 합격한 학생, 만점자가 있는지, 그리고 최고 점수를 출력해요. 힌트: 반복자에는 any(anyMatch와 같아요)와 max(OptionalInt처럼 Option을 반환해요)가 있어요. Collectors.joining 대신 Vec<&str>로 모은 다음 .join(", ")을 호출하세요.

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("passed: " + passed);
System.out.println("perfect score: " + perfect);
System.out.println("best: " + best);
정답 보기
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("합격: {}", passed.join(", "));
println!("만점자 있음: {perfect}");
println!("최고 점수: {best}");
}

실행 결과:

합격: Ada, Grace, Ken
만점자 있음: true
최고 점수: 100
▶ 직접 실행해 보기

derive 덕분에 Java record가 공짜로 주던 기능을 그대로 얻어요. 디버그 문자열({:?}), 복사(.clone()), 값 비교(==)요. student 도우미 함수가 record의 생성자 역할을 해요. Java 스트림과 달리 students.iter()는 몇 번이든 호출할 수 있어요. 호출할 때마다 같은 Vec 위에 새 반복자를 만들기 때문이에요.

2. 원인이 있는 검사 예외 옮기기

섹션 제목: “2. 원인이 있는 검사 예외 옮기기”

이 메서드는 설정 파일에서 port=<number> 형식의 줄을 읽어요. 검사 예외 ConfigException을 던지고, Integer.parseInt에서 나온 NumberFormatException을 감싸요. read_port가 Result<u16, String>을 반환하도록 Rust로 옮겨 보세요. 힌트: line.split_once('=')는 Option<(&str, &str)>를 반환해요. .ok_or_else(...)로 Result로 바꾸세요. .parse::<u16>()는 Result를 반환하고, .map_err(...)가 그 에러를 여러분의 메시지로 바꿔 줘요. 예외 하나를 다른 예외로 감싸는 것과 똑같아요.

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("expected port=<number>, got '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("bad port number: " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=eighty", "host=localhost")) {
try { System.out.println("port is " + readPort(line)); }
catch (ConfigException e) { System.out.println("config error: " + e.getMessage()); }
}
정답 보기
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("port=<숫자> 형식이어야 해요. 받은 값: '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("잘못된 포트 번호: {value}"))
}
fn main() {
for line in ["port = 8080", "port=eighty", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("포트: {port}"),
Err(e) => println!("설정 에러: {e}"),
}
}
}

실행 결과:

포트: 8080
설정 에러: 잘못된 포트 번호: eighty
설정 에러: port=<숫자> 형식이어야 해요. 받은 값: 'host=localhost'
▶ 직접 실행해 보기

throws ConfigException 절은 Result 반환 타입이 됐고, parseInt를 감싼 try/catch는 map_err 하나가 됐고, throw는 Err(...)가 됐어요. 포트에 u16을 쓰면 작은 덤이 따라와요. 65535보다 큰 수는 parse가 알아서 거부하는데, Java의 int는 그렇게 해 주지 않아요. 작은 expected 클로저는 orElseThrow(() -> ...)의 supplier처럼, 필요할 때만 에러 메시지를 만들어요.

  • Java의 많은 부분을 그대로 가져갈 수 있어요. 제네릭, 인터페이스(트레이트로), 람다(클로저로), 스트림 파이프라인(반복자로)까지요.
  • 크게 달라지는 건 이거예요. 가비지 컬렉터 대신 소유권, null 대신 Option, 예외 대신 Result와 ?, 상속 대신 트레이트와 컴포지션.
  • 제네릭은 타입마다 컴파일돼요(단형화). 그래서 타입 소거도 없고, 숫자를 박싱하지도 않아요.
  • 스레드 사이에서 공유하는 데이터는 (Arc로 감싼) Mutex 안에 살아요. 잠그는 걸 깜빡하면 경쟁 상태가 아니라 컴파일 에러가 나요.
  • Cargo가 Maven이나 Gradle을 대신해요. clippy와 rustfmt가 Checkstyle, SpotBugs, 포매터를 대신하고, 테스트는 기본으로 들어 있어요.
  • Spring Boot 대신 axum이나 actix-web, JPA 대신 SeaORM이나 Diesel, JDBC와 jOOQ 대신 sqlx를 살펴보고, Jackson 대신 serde를 쓰세요.

이해도 점검

1. Java 추상 클래스에 추상 메서드 하나와, 그걸 호출하는 구체 메서드 하나가 있어요. Rust에서는 보통 무엇으로 바꿀까요?

2. Java의 PriorityQueue는 가장 작은 항목을 먼저 반환해요. Rust에서 BinaryHeap::pop은 무엇을 반환할까요?

3. Java에서는 new T()를 쓸 수 없는데, Rust 제네릭 함수는 왜 T::default()를 호출할 수 있을까요?

4. 평범한 Vec을 스레드 사이에서 공유하면서 Mutex 없이 항목을 추가하려고 해요. Rust에서는 어떻게 될까요?

점수: 0 / 4

  • The Rust Programming Language 한국어판: 공식 책의 한국어 번역이에요. Java 개발자에게는 4장(소유권), 9장(에러 처리), 10장(제네릭과 트레이트), 16장(동시성)이 가장 중요해요.
  • std::collections (영어): Vec, VecDeque, HashMap, BTreeMap 등 중에서 무엇을 고를지 알려 주는 표준 라이브러리의 공식 안내예요.
  • JEP 454: Foreign Function & Memory API (영어): JNI 코드를 쓰지 않고 Java에서 네이티브 라이브러리(Rust로 만든 것도 포함)를 호출하는 JDK 22의 기능이에요.
  • docs.rs의 jni 크레이트 (영어): JNI의 Rust 바인딩이에요. 오래된 JDK에서 Rust를 호출하거나 Java를 다시 호출해야 할 때 써요.
  • Are We Web Yet? (영어): Rust의 웹 프레임워크, 데이터베이스 라이브러리 등을 정리한 커뮤니티 사이트예요. 여러분의 Spring Boot 스택을 대신할 것을 찾을 때 좋아요.