Hyunseok

Rust 를 배워보자 - 생소한 몇가지 개념들

2026.09.24

rust를 배워보자

이번에 퇴사도하겠다 미루고 미루던 rust 공부를 시작해보자
이전부터 rust 문법이나 기초적인것들은 조금씩 공부하고있다보니 개념자체는 이해하고있었지만

실제로 무언가를만들지 않으니 ​그 실력이 늘지는 않고 .. 자꾸 하나씩 까먹는 사태가 계속 발생했다

그런데 최근들어서 rust로 뭘 작성하는 일이 많아졌고 ​​​​​​​.. 이제는 진짜 러스트 코드를 정확히 읽을줄 알아야겠다 라는 생각을 가지게되었다

아니면 최악의 상황에는 rust로 짠 것들을 다 폐기하는 상황도 올 수 있으니 .. 배워야하지 않겠는가 ​

동기부여가 필요해

결국 본질은 웹 개발자.. 그렇다면 웹에 관련된 무언가를 하는게 가장 좋지 않을까 한다
이번기회에 저번부터 생각만하던 현재 개인 백엔드 인프라의 모든 것을 rust로 다시 써보기로하자

마이그레이션 방향

node나 spring이면 눈감고도 코드를 쓰겠지만 이번엔 진짜 생소한 영역이기때문에..

난 초보자다.. 난 초보자다 ..

라는 마인드로 접근을 시작했다

chatgpt랑도 이야기도 좀 나눠보고 .. 일단 가장 만만하고 내가 가장 많이 썼던 해당 블로그부터 마이그레이션을 해보려한다

openapi.json을 따서 모든 api를 1:1로 대응한다라는 미친생각도 했지만 과거 나를 되돌아보면 크게 벌렸다가 끝이 좋지 않은경우가 많았으니 생각을 접었다

결국 BE의 구현순서는 다음과 같다

기존 테이블 분석 -> 인증인가 시스템 + 미들웨어 -> 비즈니스모델을 포함한 서비스로직 -> 라우팅 -> CI/CD 파이프라인 구현 -> 가배포 -> FE에 맞춰서 시험동작 -> 수정후 정식배포

orm 유무

이번에는 orm 사용도 빼기로 했다. AI 시대에 orm 을 안써도, 그리고 확실한 스펙을 가진 상태라면 굳이 확장과 이기종 db의 고려를 안해도 괜찮은거 아닌가 ? 라는생각
고로 sqlx 로 쿼리를 적기로 마음먹었다. 이게 사실 마음이 더 편한다

일단 TcpListener부터

웹서버를 만들려면 일단 기본기능에 가깝게 먼저 구현해봐야하지 않겠는가 ?
gpt의 힘을빌려 웹서버 코드를 하나 시켜본다​
​​

use std::io::{Read, Write}; use std::net::TcpListener; fn main() { let listener = TcpListener::bind("127.0.0.1:3000").unwrap(); println!("server is running at http://localhost:3000"); for stream in listener.incoming() { let mut stream = stream.unwrap(); let mut buffer = [0; 1024]; let size = stream.read(&mut buffer).unwrap(); let request = String::from_utf8_lossy(&buffer[..size]); println!("{request}"); let body = "hello Rust"; let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Length: {}\r\n\ Content-Type: text/plain\r\n\ \r\n\ {}", body.len(), body ); stream.write_all(response.as_bytes()).unwrap(); } } ​

여기서 많은 개념을 머리에 넣을 수 있었는데 하나씩 짚어보자

값의 개념

리터럴과 문자열을 배우면서 이 부분을 조금 헷갈리게 이해했었는데, 대충 다음과 같다.

let a = "text"; let b = String::from("texttext");

둘은 같은 문자열처럼 보이지만 실제로는 꽤 다르다.

먼저

let a = "text";

에서 "text"문자열 리터럴이다.

이 문자열 데이터는 컴파일될 때 프로그램 바이너리 안에 들어가고, a는 그 문자열 데이터를 참조한다.

a의 타입은 보통

&str

이고, 문자열 리터럴 자체는 프로그램이 실행되는 동안 계속 존재하기 때문에 사실상 'static 수명을 가진다.

반대로

let b = String::from("texttext");

는 실행 중에 실제 String 값을 만든다.

String은 자신이 사용하는 문자열 메모리를 소유하고 있고, 여기서는 b가 그 String의 소유자다.

"text" 프로그램 바이너리에 들어있는 문자열 데이터 a는 해당 데이터를 참조 String::from("texttext") 실행 중 String 값 생성 b가 해당 값을 소유

그리고 b의 scope가 끝나거나 ownership이 다른 곳으로 넘어간 뒤 최종 소유자가 사라지면 Rust가 해당 메모리를 정리한다.

그래서 처음에는

문자열 리터럴 : 이미 존재하는 정적 데이터를 참조 String : 실행 중 생성되고 ownership을 가지는 값

정도로 이해하면 편했다.

단순히

참조냐 vs 값이냐

라고만 나누면 조금 애매할 수 있다.

String 역시 &String이나 &str 형태로 참조할 수 있기 때문이다.

중요한 차이는 문자열 데이터를 누가 소유하고 있으며 그 데이터의 수명이 어떻게 관리되는가에 있다.

이 부분을 이해하고 나면 추후에 나오는 'static도 조금 더 이해하기 쉬워진다.


use

간단하게는 TypeScript의 import와 비슷한 역할이라고 이해했다.

use std::net::TcpListener;

이렇게 하면 원래

std::net::TcpListener

라고 써야 하는 것을

TcpListener

만으로 사용할 수 있다.

다만 여기서 계속 등장하는

::

가 정확히 무엇인지는 뒤에서 다시 정리한다.


fn

함수다.

기본적인 구조는 익숙하다.

fn 함수이름(파라미터) { 로직 }

예를 들면

fn add(a: i32, b: i32) -> i32 { a + b }

여기서 인상적이었던 것은 마지막 값에 return을 적지 않아도 반환값으로 사용할 수 있다는 점이다.

fn add(a: i32, b: i32) -> i32 { a + b }

fn add(a: i32, b: i32) -> i32 { return a + b; }

둘 다 가능하다.

차이는 이것이다.

a + b

처럼 마지막 표현식에 ;가 없으면 그 값이 함수의 반환값이 된다.

반대로

a + b;

처럼 세미콜론을 붙이면 해당 값을 반환하지 않는다.

반환값이 없는 함수는 실제로 아무것도 반환하지 않는 것이 아니라 Rust의 unit 타입인

()

을 반환한다.

fn hello() { println!("hello"); }

는 사실상

fn hello() -> () { println!("hello"); }

와 같다.

그리고 중간에 함수를 종료하고 싶다면

return;

도 사용할 수 있다.


let, let mut

Rust를 보면서 느낀 것은 ownership뿐만 아니라 상태 변경 자체도 상당히 명시적으로 표현한다는 점이다.

처음 이해할 때는 TypeScript와 대충 이렇게 대응시켰다.

Rust TypeScript 감각 let const let mut let

예를 들어

let a = 10;

은 값을 다시 변경할 수 없다.

let a = 10; a = 20; // 불가능

값을 변경하려면 명시적으로

let mut a = 10; a = 20;

처럼 mut를 붙여야 한다.

다만 let = const가 완전히 같은 개념은 아니다.

어디까지나 처음 Rust를 이해하기 위한 감각적인 대응 정도로 생각하는 게 좋다.


&&mut

mut를 이해하면 바로 다음에 &mut가 등장한다.

&value

는 값을 소유권째 넘기는 대신 참조해서 빌려주는 것이고,

&mut value

수정 가능한 상태로 빌려주는 것이다.

& : 불변 참조 &mut : 가변 참조

정도로 시작하면 된다.

예를 들어

fn change(value: &mut i32) { *value = 20; } fn main() { let mut number = 10; change(&mut number); println!("{}", number); }

여기서 number의 ownership 자체를 change()에 넘긴 것이 아니다.

change()가 잠시 number를 수정할 수 있도록 빌려간 것이다.

Rust에서는 이 참조에도 규칙이 있다.

대표적으로 같은 시점에 여러 개의 불변 참조는 가능하지만, 가변 참조가 존재하는 동안에는 다른 참조를 마음대로 동시에 사용할 수 없다.

쉽게 말하면 여러 명이 읽는 것은 가능하지만 누군가 수정하고 있다면 독점적으로 접근한다고 생각하면 편하다.

이건 GC를 막기 위한 문법이라기보다는, 컴파일 시점에 메모리 안전성과 데이터 경쟁을 막기 위한 Rust의 borrowing 규칙이다.

Rust가 GC 없이 메모리를 관리할 수 있는 구조와 연결되어 있기는 하지만 &mut 자체가 GC를 대신하는 것은 아니다.


::.

여기서 한 5분 날린 듯한데, 이 개념을 제대로 이해하려면 선행 개념인 structimpl을 먼저 이해하는 게 좋다.

그리고 머릿속에서 일단 class의 개념도 잠깐 제거하는 편이 이해하기 쉽다.

struct User { name: String, age: u32, } impl User { fn new(name: String, age: u32) -> User { User { name, age } } fn introduce(&self) { println!("{} / {}살", self.name, self.age); } } fn main() { let user = User::new("Hyunseok".to_string(), 30); user.introduce(); }

먼저

struct User

를 통해 User라는 타입을 정의했다.

그리고

impl User

를 통해 User 타입에서 사용할 함수와 메서드를 정의했다.

여기에는 크게 두 종류가 있다.

fn new(...)

처럼 특정 User 값이 없어도 호출할 수 있는 함수와,

fn introduce(&self)

처럼 실제 User 값을 대상으로 실행하는 메서드다.

그래서 new는 다음처럼 호출한다.

User::new(...)

여기서 ::

User라는 타입에서 new라는 항목을 찾는다.

정도로 이해하면 된다.

new() 내부에서는

User { name, age }

를 반환하고 실제 User 값이 만들어진다.

let user = User::new(...);

이제 user라는 실제 값이 생겼다.

그리고 introduce()

fn introduce(&self)

처럼 self를 받기 때문에 실제 User 값을 대상으로 실행할 수 있다.

그래서

user.introduce();

처럼 .을 사용한다.

처음에는 대략 이렇게 이해하면 편하다.

:: : 타입이나 모듈 등의 경로에서 무언가를 찾는다. . : 실제 값의 필드나 메서드에 접근한다.

예를 들어 우리가 자주 사용하는 이것도 같은 원리다.

let text = String::from("This is text");

여기서는

String::from(...)

으로 String이라는 타입에서 from을 찾아 실행한다.

그리고 그 결과 만들어진 실제 String 값이 text에 들어간다.

이제 text는 실제 값이므로

text.len();

처럼 .으로 메서드를 호출할 수 있다.

정리하면

String::from(...) User::new(...)

처럼 **타입이나 경로 쪽에서 무언가를 찾을 때 ::**를 사용하고,

text.len() user.introduce() user.name

처럼 **실제 값을 대상으로 메서드나 필드에 접근할 때 .**을 사용한다고 우선 이해하면 된다.

단,

new

라는 이름 자체는 Rust의 특별한 생성자 문법이 아니다.

개발자가 값을 생성하는 함수에 관례적으로 new라는 이름을 사용하는 것뿐이다.

예를 들어 이것도 문제없다.

impl User { fn create(name: String, age: u32) -> User { User { name, age } } }

그러면

User::create(...)

로 호출하면 된다.

그리고 한 가지 더.

fn introduce(&self)

처럼 self, &self, &mut self를 받는 함수는 실제 값에서

user.introduce();

처럼 .을 이용해 호출할 수 있다.

반대로

fn new(...)

처럼 self를 받지 않는 함수는 특정 user 값에 종속된 함수가 아니므로 보통

User::new(...)

처럼 타입을 통해 호출한다.

다만 :: 자체를 단순히

값이 없을 때 사용하는 것

이라고 외우면 나중에 문제가 생긴다.

왜냐하면 이런 코드도 있기 때문이다.

std::net::TcpListener

여기서는 아무 값도 생성하지 않는다.

그냥 std 안의 net, 그리고 그 안의 TcpListener라는 경로를 찾아가는 것이다.

그래서 최종적으로는

:: : 경로를 따라가며 항목을 찾는다. . : 실제 값에서 필드나 메서드에 접근한다.

정도로 이해하는 것이 가장 덜 깨지는 설명인 것 같다.


Ownership

Rust를 이해하면서 가장 중요한 개념 중 하나였다.

간단하게 말하면 생성된 값에는 그 값을 책임지는 소유자가 존재한다.

let text = String::from("hello");

여기서는 text가 생성된 String 값의 소유자다.

그리고 text가 존재하는 scope가 끝나면 Rust는 해당 값이 더 이상 필요하지 않다고 판단하고 메모리를 정리한다.

fn main() { let text = String::from("hello"); println!("{}", text); }

main()이 끝나면 text도 사라지고, text가 소유하고 있던 문자열 메모리도 같이 정리된다.

JS처럼 GC가 나중에 사용 여부를 확인하고 수거하는 방식이라기보다는, 컴파일러가 ownership 규칙을 통해 이 값이 언제 더 이상 필요하지 않은지 미리 알 수 있게 만드는 방식에 가깝다.

처음에는 그냥

값을 만들면 누군가는 그 값을 가지고 있어야 한다. 마지막 소유자가 사라지면 값도 정리된다.

정도로 이해했다.

여기서 중요한 건 변수 자체와 값의 개념을 조금 분리해서 보는 것이다.

let text = String::from("hello");

text라는 이름 자체가 중요한 게 아니라, 실제 생성된 String 값을 현재 text가 소유하고 있다는 것이 중요하다.

이 소유권은 다른 변수나 함수로 이동할 수도 있다.

그게 다음에 나오는 move다.


Move

Ownership을 다른 곳으로 넘기는 것을 이해할 때 move라는 개념이 나온다.

예를 들어

let a = String::from("hello"); let b = a;

처음에는 당연히 ab 둘 다 사용할 수 있을 것 같았다.

하지만 String 같은 타입에서는 그렇지 않다.

let a = String::from("hello"); let b = a; println!("{}", b); println!("{}", a); // 에러

let b = a를 실행하면서 a가 가지고 있던 String의 ownership이 b로 이동한다.

즉 문자열 데이터를 하나 더 복사한 것이 아니다.

실제 값의 소유자가 바뀐 것이다.

처음 a가 String 값을 소유함 let b = a; 이후 b가 String 값을 소유함 a는 더 이상 그 값을 사용할 수 없음

이게 처음에는 굉장히 이상했는데 이유는 꽤 납득할 만했다.

만약 ab가 동시에 같은 메모리를 자기 것이라고 생각하고 있다가 둘 다 정리하려고 하면 같은 메모리를 두 번 해제하는 문제가 생길 수 있다.

Rust는 이런 상황 자체를 컴파일 단계에서 막는다.

함수에서도 똑같이 적용된다.

fn print_text(text: String) { println!("{}", text); } fn main() { let text = String::from("hello"); print_text(text); println!("{}", text); // 에러 }

print_text(text)를 호출하면서 text가 소유하던 String이 함수의 text 파라미터로 이동했다.

함수가 끝나면 그 함수가 소유하던 값도 정리된다.

그래서 원래 maintext에서는 더 이상 사용할 수 없다.

물론 모든 타입이 이렇게 동작하는 것은 아니다.

예를 들어

let a = 10; let b = a; println!("{}", a); println!("{}", b);

이건 가능하다.

i32 같은 단순한 타입들은 Copy가 가능하기 때문이다.

그래서 처음에는

String 같은 소유 데이터를 가진 값 : 보통 move i32 같은 단순한 값 : Copy가 가능

정도로 이해했다.


Borrow

그런데 ownership을 함수에 넘길 때마다 원래 변수를 다시 못 쓴다면 너무 불편하다.

그래서 Rust에는 값을 빌려주는 borrow 개념이 있다.

fn print_text(text: &String) { println!("{}", text); } fn main() { let text = String::from("hello"); print_text(&text); println!("{}", text); }

이번에는 함수에

text

를 넘긴 것이 아니라

&text

를 넘겼다.

따라서 ownership 자체가 이동하지 않는다.

함수는 잠깐 해당 값을 참조해서 사용할 뿐이고, 함수 호출이 끝난 뒤에도 원래 text는 그대로 사용할 수 있다.

text : 실제 String을 소유 &text : 해당 String을 잠깐 빌려서 봄

그래서 Rust에서는 ownership을 넘길 필요가 없는 경우 굳이 값을 통째로 넘기지 않고 reference를 사용하는 경우가 많다.

수정까지 허용하려면

&mut

를 사용한다.

fn change(text: &mut String) { text.push_str(" world"); } fn main() { let mut text = String::from("hello"); change(&mut text); println!("{}", text); }

이 경우에도 ownership은 maintext에 그대로 있다.

함수가 잠시 수정 가능한 상태로 빌려간 것이다.

그리고 Rust에서는 borrow에 규칙이 있다.

한 시점에 여러 개의 불변 참조를 가지는 것은 가능하다.

let a = &text; let b = &text;

둘 다 읽기만 하기 때문에 문제가 없다.

반면 가변 참조는 동시에 여러 곳에서 사용할 수 없도록 상당히 엄격하게 제한된다.

let a = &mut text; let b = &mut text;

같은 값을 동시에 두 군데에서 수정하려고 하는 상황을 Rust가 막는 것이다.

처음에는 귀찮은 제약처럼 보였는데 결국

읽기는 여러 곳에서 가능 수정은 독점적으로

라는 규칙이라고 보면 꽤 단순하다.

이 규칙 덕분에 잘못된 메모리 접근이나 동시 수정 문제를 컴파일 단계에서 많이 차단할 수 있다.


'static

처음 문자열 리터럴을 보면서 등장했던 개념이다.

let text = "hello";

여기서 "hello"라는 문자열 리터럴은 프로그램 바이너리 안에 들어간다.

그래서 이 문자열 데이터는 특정 함수가 끝났다고 사라지는 데이터가 아니다.

프로그램이 실행되는 동안 계속 존재한다.

이런 참조는

&'static str

이라고 표현할 수 있다.

let text: &'static str = "hello";

여기서 'static

이 참조가 가리키는 데이터는 프로그램 전체 실행 기간 동안 유효하다.

라는 lifetime이다.

처음에는 static이라는 단어 때문에 전역 변수 같은 개념으로 생각했는데 그 자체로는 조금 다르다.

&'static str

에서 중요한 것은 변수 text가 영원히 존재한다는 뜻이 아니다.

text 변수 자체는 함수가 끝나면 사라질 수 있다.

fn test() { let text: &'static str = "hello"; }

test()가 끝나면 text라는 지역 변수는 사라진다.

하지만 text가 가리키던 "hello" 문자열 데이터 자체는 프로그램이 실행되는 동안 계속 존재한다.

변수의 수명 참조가 가리키는 데이터의 수명

은 별개의 문제다.

그리고 모든 String'static인 것도 아니다.

let text = String::from("hello");

String은 현재 scope와 ownership에 따라 수명이 결정된다.

반면

let text = "hello";

의 문자열 리터럴은 프로그램에 들어있는 데이터이므로 'static 수명을 가질 수 있다.

그래서 이전에 문자열을 보면서 배웠던

let a = "text"; let b = String::from("text");

차이가 여기서 다시 연결됐다.

a는 프로그램 전체 실행 기간 동안 유효한 문자열 데이터를 참조할 수 있고, b는 실행 중 만들어진 String 값을 직접 소유한다.

아직 lifetime 문법 전체를 배운 것은 아니기 때문에 현재는 'static

프로그램 전체 실행 기간 동안 유효한 데이터에 대한 lifetime

정도로 이해하고 넘어가면 될 것 같다.

많다 많아

생각보다 많은 개념들, ts를 쓰다 굳어버린 애매모호함이 쌓여만든 개념들을 하나씩 제거하는 그런게 필요하다

어찌보면 JAVA를 배운상태에서 typescript를 배우지않고 바로 rust로가면 이해가 더 수월하지 않았을까라는 생각이 든다​​​

아마 이렇게 글 3개정도쓰면 웹서비스정도는 간단하게 rust로 써내려갈수있는 수준이 되지않을까한다.

또 일정수준의 정리가 필요한 때가 오면.. 아마 이렇게 글을 쓸 듯하다

다음은 AXUM + Tokio 로 BE의 전신 작성인데.. 요건 다해보고 다시 정리해보자 ​​​​​​​

​​

Rust

HB

Hyunseok Byun

Hyunseok Byun
hs@gumyo.net

잡식성 개발자

댓글

댓글을 작성하려면 로그인이 필요합니다.
댓글이 없습니다.
Rust 를 배워보자 - 생소한 몇가지 개념들 | Hyunseok