learn

Rust의 str과 String 차이

Rust의 str과 String 차이

"hello"라는 리터럴과 String::from("hello")는 같은 문자열을 다루는 것 같지만, 메모리상의 위치, 생성 시점, 가변성이 모두 다르다. 이 차이를 이해하면 Rust의 *"컴파일 타임에 알 수 있는 건 미리 처리하자"*는 zero-cost abstraction 철학이 보인다.

String literal "hello"는 어디에 있는가

"hello" 같은 string literal은 컴파일러가 처리해 실행 파일(binary)의 한 영역에 박아 넣는다. 실행 파일은 보통 이런 섹션들로 나뉜다.

┌─────────────────────────────┐
│ .text     (코드)            │  ← 함수 바디, 명령어들
├─────────────────────────────┤
│ .rodata   (read-only data)  │  ← 문자열 리터럴, const 값들 ★
├─────────────────────────────┤
│ .data     (초기화된 전역)   │  ← static mut 같은 것들
├─────────────────────────────┤
│ .bss      (0으로 초기화된)  │  ← 0 초기 전역 변수
└─────────────────────────────┘

String literal은 .rodata (read-only data) 섹션에 들어간다. "읽기 전용 데이터" 영역이다.

컴파일 타임 vs 런타임에 무슨 일이

컴파일 타임에 일어나는 일

컴파일러가 "hello"를 보면:

  1. 바이트들(h, e, l, l, o)을 binary의 .rodata 섹션에 박아 넣음
  2. 그 위치를 가리키는 상대 오프셋을 코드에 심어둠

여기서 *"자리를 잡는다"*는 건 **binary 안에서의 위치(파일 오프셋)**가 정해진다는 뜻이다. 절대 메모리 주소는 아직 정해지지 않는다.

런타임에 일어나는 일

프로그램이 실행되면:

  1. OS가 binary를 메모리로 로드 (가상 메모리에 mmap)
  2. .rodata 섹션이 메모리의 어떤 페이지에 매핑됨 (이때 비로소 절대 주소 결정)
  3. 코드에서 "hello"를 참조하면, 그 매핑된 주소를 읽음

ASLR(Address Space Layout Randomization) 때문에 매번 다른 주소에 로드된다. 그래서 컴파일 타임에 결정되는 건 binary 내 상대 위치, 런타임에 결정되는 건 실제 메모리 주소다.

String::from은 어떻게 다른가

let s: &str = "hello";              // 리터럴
let owned: String = String::from("hello");   // 힙 할당

이 두 줄에서 일어나는 일을 그려보면:

binary의 .rodata
┌──────────────┐
│  h e l l o   │  ← 컴파일 타임에 박힘. 영구히.
└──────────────┘
       ↑
       │ &str 포인터 + 길이(5)
       │
   [s: &str]    ← 스택에 있음 (16바이트)


런타임 힙
┌──────────────┐
│  h e l l o   │  ← String::from이 런타임에 malloc해서 채움
└──────────────┘
       ↑
       │ String 본체: 포인터 + 길이 + capacity
       │
   [owned]      ← 스택에 있음 (24바이트)

String::from("hello")가 하는 일:

  1. 런타임에 힙에 5바이트 공간을 할당 (malloc)
  2. .rodata에서 "hello"의 5바이트를 그 힙 공간으로 복사
  3. 힙 포인터, 길이, 용량을 가진 String 구조체를 만들어 반환

즉, "hello" 데이터가 두 군데에 존재한다. binary의 .rodata에 한 번, 런타임 힙에 한 번. 그래서 이름이 String::from이다 — "무언가로부터 String을 만든다". 여기서 무언가&str 리터럴이다.

리터럴이 많으면 binary가 커진다

모든 string literal은 binary의 .rodata에 그대로 들어가므로, 리터럴이 많거나 길수록 binary 크기가 커진다.

fn main() {
    println!("Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...");
    // 이 문자열의 바이트가 통째로 binary에 박힘
}

확인 방법:

$ cargo build --release
$ strings target/release/myprogram | head

strings 명령으로 binary 안의 문자열들을 다 뽑아볼 수 있다.

이게 실용적으로 의미 있는 경우:

  • 임베디드 시스템: flash 메모리가 적은 환경에서 리터럴 크기가 중요
  • WebAssembly: 다운로드 크기에 영향
  • 거대한 정적 데이터: 게임의 텍스트, 사전 데이터를 리터럴로 박으면 binary가 수 MB씩 커질 수 있음

같은 리터럴은 한 번만 저장된다

여러 번 등장하는 같은 리터럴은 컴파일러가 보통 하나로 합친다. 이걸 string interning 또는 literal pooling이라고 한다.

fn main() {
    let a: &str = "hello";
    let b: &str = "hello";
    let c: &str = "hello";
    
    println!("{:p}", a);   // 0x55a... (예시)
    println!("{:p}", b);   // 0x55a... (같은 주소)
    println!("{:p}", c);   // 0x55a... (같은 주소)
}

a, b, c 모두 .rodata의 같은 한 곳을 가리킨다. rustc는 LLVM의 constant merging 패스가 처리해준다.

다만 이 최적화는 같은 컴파일 단위(crate) 안에서만 보장되는 경우가 많다. 다른 crate끼리는 합치지 않을 수 있다. 그래도 같은 프로그램에서 같은 리터럴은 보통 한 번만 저장된다고 봐도 된다.

String::from을 여러 번 쓰면

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

이 경우는 힙에 두 번 할당된다. 각자 자기 소유의 5바이트 힙 메모리를 가진다. String은 mutable이라 한쪽이 push_str해도 다른 쪽에 영향 가면 안 되므로, 따로따로 가져야 한다.

핵심 비교표

측면&str 리터럴String::from(...)
데이터 위치binary의 .rodata런타임 힙
할당 시점컴파일 타임 (binary에 박힘)런타임 (malloc 호출)
같은 값 중복보통 합쳐짐 (interning)항상 별개 (각자 힙 할당)
가변성불가 (read-only)가능
크기 변경불가가능 (push_str 등)
Binary 크기✅ 영향 줌❌ 안 줌
비용0 (이미 메모리에 있음)malloc + memcpy
[[copy-vs-moveCopy/Move]]&str은 Copy (참조라서)

왜 두 가지가 다 필요한가

&str (리터럴)이 좋은 경우:

  • 값이 컴파일 타임에 이미 알려진 경우
  • 변경할 필요가 없는 경우
  • 빠르게 접근하고 싶은 경우 (할당 비용 0)
  • 예: 에러 메시지, 키워드, 상수 문자열

String이 필요한 경우:

  • 런타임에 만들어지는 문자열 (사용자 입력, 파일 읽기 결과 등)
  • 변경되어야 하는 경우 (push_str, 글자 추가/삭제)
  • 소유권이 필요한 경우 (함수에서 반환, 컬렉션에 저장)

Rust가 이 둘을 분리한 건 *"컴파일 타임에 알 수 있는 건 굳이 런타임에 다시 처리하지 말자"*는 zero-cost abstraction 철학의 표현이다. Java, Python, JavaScript 같은 언어들은 거의 모든 문자열이 힙에 가니 이런 구분이 없다. 대신 그만큼 비용을 항상 치른다.

함수 시그니처에서의 선택

fn greet1(name: String) { ... }    // 소유권을 받음. 호출자가 못 씀.
fn greet2(name: &String) { ... }   // String을 빌림. 그러나 String만 받을 수 있음.
fn greet3(name: &str) { ... }      // 더 일반적. String도, 리터럴도, slice도 다 받음.

실무에서는 거의 항상 &str을 받는 게 좋다. *"빌리고, 가장 일반적인 타입을 받는다"*는 두 원칙이 모인 결과다. String&str로 자동 변환할 수 있어 호출자가 자유롭게 쓸 수 있다.


한 줄 요약

"hello" 같은 string literal은 binary의 .rodata 섹션에 컴파일 타임에 박혀 binary 크기를 키운다. 같은 리터럴이 여러 번 나오면 컴파일러가 interning으로 한 번만 저장한다. 반면 String::from("hello")런타임에 힙을 새로 할당해 리터럴 데이터를 복사하므로, 호출할 때마다 별도의 힙 공간을 차지한다. 이 구분은 *"컴파일 타임에 알 수 있는 건 미리 처리하자"*는 zero-cost 철학의 결과다.