주소는 같고 권한만 다르다: 내 Rust 슬라이스가 안 바뀌던 이유

Rust를 배우다가 슬라이스 원소를 고치는 간단한 연습 문제를 풀었는데, 컴파일러가 에러를 하나씩 내길래 시키는 대로 다 고쳤더니 컴파일은 되는데 아무것도 안 바뀌는 코드가 나왔어요. 왜 그런지 파고들다 보니 결국 &와 &mut이 뭐가 다른가로 이어졌어요.

이 글은 슬라이스가 실제로 무엇인지, iter()와 iter_mut()이 왜 나뉘는지, 그리고 *item의 *가 왜 꼭 필요한지를 다뤄요. split_at_mut과 unsafe, reborrow, 인덱스 루프와의 비교는 다루지 않아요. 아래 나오는 에러 메시지와 주소, 크기는 전부 rustc 1.96.0에서 직접 돌려본 값이에요.

시작은 이 코드였어요.

Rust
pub fn transform_even_odd(slice: &mut [i32]) {
    // Your code here: iterate over the mutable slice and modify its elements.

    for &item in slice.iter() {
        if item % 2 == 1 {
            item -= 1
        }
    }

    slice
}

들어가기 전에 — 용어 짚기

대여(borrow)와 참조(reference). Rust에서 모든 값에는 주인이 하나 있어요. 주인은 값을 다른 곳에 잠시 빌려줄 수 있고, 빌려 간 쪽이 갖는 것이 참조예요. &T는 읽기용으로 빌린 참조, &mut T는 수정할 수 있게 빌린 참조예요. 빌린 것은 언젠가 돌려줘야 하므로, 참조는 원본보다 오래 살 수 없어요.

Copy 트레이트. 값을 옮길 때 원본을 그대로 두고 사본을 만들어도 되는 타입에 붙는 표시예요. i32처럼 크기가 작고 자기 밖의 자원을 갖고 있지 않은 타입이 여기 속해요. String은 힙에 데이터를 갖고 있어서 Copy가 아니에요. 이 구분이 뒤에서 중요해져요.

unsized 타입과 fat pointer. 컴파일 타임에 크기를 알 수 없는 타입을 unsized라고 해요. [i32]가 그래요 — i32가 몇 개인지 타입에 적혀 있지 않기 때문이에요. 이런 타입은 변수에 직접 담을 수 없고 항상 참조를 통해서만 다뤄요. 그 참조는 주소만으로는 부족해서 길이를 함께 갖고 있는데, 이렇게 두 칸짜리가 된 포인터를 fat pointer라고 불러요.

IntoIterator와 auto-deref. for x in y는 문법 설탕(syntactic sugar)이고, 실제로는 y.into_iter()를 부른 뒤 나오는 이터레이터를 돌려요. auto-deref는 메서드를 부를 때 컴파일러가 *를 알아서 채워 넣는 기능이에요. item.abs()가 (*item).abs()로 바뀌는 것이 그 예예요.

1. 슬라이스는 남의 데이터 일부를 가리키는 참조예요

[i32]와 &[i32]는 비슷하게 생겼지만 다른 타입이에요. 앞의 것을 변수에 담으려 하면 에러가 나요.

TEXT
error[E0277]: the size for values of type `[i32]` cannot be known at compilation time
  |         ^^ doesn't have a size known at compile-time
  = help: the trait `Sized` is not implemented for `[i32]`

[i32]는 "i32가 연속으로 놓여 있는데 몇 개인지는 모르는 것"이에요. 크기를 모르니 스택에 자리를 잡을 수 없고, 그래서 언제나 참조를 통해서만 다뤄요. 실제로 크기를 재보면 이렇게 나와요.

TEXT
size_of::<&i32>()       = 8    (얇은 포인터: 주소만)
size_of::<&[i32]>()     = 16   (뚱뚱한 포인터: 주소 + 길이)
size_of::<&mut [i32]>() = 16
size_of::<[i32; 3]>()   = 12   (배열은 크기를 안다)

&[i32]가 16바이트인 건 주소 8바이트에 길이 8바이트가 더해졌기 때문이에요. [i32; 3]처럼 개수가 타입에 정해진 배열은 12바이트로, 데이터 그 자체예요.

이 차이가 슬라이스의 성격을 정해요. 슬라이스는 데이터를 소유하지 않아요. 다른 곳에 있는 데이터의 일부를 가리키고, "어디서부터 몇 개"라는 정보만 갖고 있어요.

TEXT
원본 시작 0x4664aff5a8,  슬라이스 시작 0x4664aff5ac, 길이 2      // &v[1..3]

그러니 함수 시그니처의 slice: &mut [i32]는 "수정할 수 있게 빌린 슬라이스 하나"를 받는다는 뜻이에요. 소유하지 않으므로 돌려줄 것도 없어요. 맨 위 코드가 마지막에 slice를 반환하려 한 것은 그래서 틀렸어요.

TEXT
error[E0308]: mismatched types
10 |     slice
   |     ^^^^^ expected `()`, found `&mut [i32]`

제자리에서 고치는 함수라서 반환할 게 없어요.

2. 주소는 같고, 권한이 달라요

저는 iter_mut()을 써야 비로소 주소가 넘어오는 줄 알았어요. "iter()로 하면 item 수정 못하고 iter_mut()ㄹ 해야 item 수정할 수 있게 주소 받는건가?" 싶었거든요. 절반만 맞았어요.

iter()도 주소를 줘요. 출력해 보면 바로 보여요.

TEXT
배열 시작 주소 = 0xf350ddf49c
  iter()     item = 0xf350ddf49c  (값 10)
  iter()     item = 0xf350ddf4a0  (값 20)
  iter()     item = 0xf350ddf4a4  (값 30)
  iter_mut() item = 0xf350ddf49c  (값 10)
  iter_mut() item = 0xf350ddf4a0  (값 20)
  iter_mut() item = 0xf350ddf4a4  (값 30)

주소가 완전히 같아요. 크기도 같아서 &i32도 8바이트, &mut i32도 8바이트예요. 기계 입장에서 둘은 구별되지 않는 포인터예요.

그러니 차이는 주소를 받느냐가 아니라, 그 주소에 어떤 권한이 붙어 있느냐예요.

  • &i32 — 이 주소로 읽기만 할 수 있어요
  • &mut i32 — 이 주소로 읽고 쓸 수 있어요

권한은 타입에만 존재해요. &i32와 &mut i32는 서로 다른 타입일 뿐이고, 컴파일러는 *x = ...를 볼 때 x의 타입이 &mut _인지만 확인해요. 검사가 끝나면 그 정보는 실행 파일에 남지 않아요. 런타임 비용이 없어요.

읽기 전용 주소에 쓰려고 하면 이렇게 막혀요.

TEXT
error[E0594]: cannot assign to `*item`, which is behind a `&` reference
2 |     for item in slice.iter() {
  |                 |     help: use mutable method: `iter_mut()`
  |                 this iterator yields `&` references
3 |         *item -= 1;
  |         ^^^^^^^^^^ `item` is a `&` reference, so it cannot be written to

주소는 갖고 있고, 역참조도 돼요. 쓰는 것만 막혀요. 쓰기 권한이 없기 때문이에요.

3. &mut은 독점적인 접근을 뜻해요

"신기하네 근데 어떻게 mut 한건 변경해도 되구 mut 안한건 변경하면 안되는거지?" — 권한을 왜 굳이 나누는지가 궁금했어요.

&mut의 핵심은 쓰기 권한보다 독점적인 접근이에요. 세 경우를 컴파일해 보면 규칙이 보여요.

TEXT
// &mut 두 개를 동시에
error[E0499]: cannot borrow `*v` as mutable more than once at a time

// & 가 살아있는데 &mut 을 또
error[E0502]: cannot borrow `*v` as mutable because it is also borrowed as immutable

// & 두 개를 동시에
(통과)

읽기 참조는 몇 개든 동시에 있을 수 있어요. 쓰기 참조는 하나만 있을 수 있고, 그게 살아 있는 동안에는 읽기 참조도 함께 있을 수 없어요. 여러 곳에서 동시에 읽거나, 한 곳에서만 수정하거나 둘 중 하나예요.

두 번째 경우가 왜 위험한지는 Vec에 원소를 계속 추가해 보면 보여요. 용량이 차면 데이터 전체가 힙의 다른 위치로 재할당돼요.

TEXT
이사 #1: len      5 에서  0x1c18595a9f0 -> 0x1c185956460
이사 #2: len      9 에서  0x1c185956460 -> 0x1c1859529c0
이사 #3: len     17 에서  0x1c1859529c0 -> 0x1c185954480

20000번 push 하는 동안 데이터가 이사한 횟수: 10

(출력의 "이사"는 재할당을 뜻해요.)

재할당 전에 &v[0]으로 참조를 받아 뒀다면, 그 참조는 재할당 후에 더 이상 유효하지 않은 메모리를 가리켜요. C++에서 iterator invalidation이라고 부르는 버그이고, Rust는 이걸 E0502로 컴파일 단계에서 막아요. 읽는 참조가 있는 동안에는 아무도 구조를 바꿀 수 없어요.

여기서 짚어 둘 게 하나 있어요. 위의 재할당이 항상 일어나지는 않아요. realloc은 뒤 공간이 비어 있으면 제자리에서 늘리고, 실제로 Vec::with_capacity(2)에 세 번 추가해도 주소가 그대로였어요. 20000번을 추가하고 나서야 열 번의 재할당을 확인했어요.

즉 재할당은 일어날 수도 있는 일이에요. 컴파일러는 그 가능성만으로 거절해요. 실제로 위험한 경우만 골라내지 않고, 위험할 수 있는 코드를 전부 거절해요. Rust가 가끔 지나치게 엄격하게 느껴지는 이유가 이거예요.

이제 함수 시그니처를 다르게 읽을 수 있어요. slice: &mut [i32]를 받았다는 건 "지금 이 슬라이스에 접근하는 건 이 함수뿐"이라는 보장을 받았다는 뜻이에요. 수정해도 되는 이유가 이 보장에서 나와요. 다른 곳에서 동시에 읽고 있지 않으니, 수정해도 문제가 생기지 않아요.

4. for가 실제로 부르는 것, 그리고 세 가지 메서드

iter_mut()이 궁금했어요. iter()도 있는데 저건 mut 슬라이스 전용인가, 아니면 뭔가 다른 건가 싶었거든요.

전용이 아니에요. 어느 쪽을 부를 수 있는지는 슬라이스의 타입이 아니라 **메서드가 요구하는 self**가 정해요.

Rust
pub fn iter(&self)         -> Iter<'_, T>      // 공유 참조만 있으면 된다
pub fn iter_mut(&mut self) -> IterMut<'_, T>   // 가변 참조가 있어야 한다

그래서 규칙이 대칭이 아니에요. iter()는 어디서나 부를 수 있고, iter_mut()은 가변 접근 권한이 있을 때만 부를 수 있어요.

갖고 있는 것iter()iter_mut()
&[i32]통과error[E0596]: cannot borrow *slice as mutable, as it is behind a & reference
&mut [i32]통과통과
let arr = [1,2,3]통과error[E0596]: cannot borrow arr as mutable, as it is not declared as mutable

눈여겨볼 칸은 &mut [i32]에서 iter()가 통과하는 자리예요. 가변 참조를 가진 쪽이 공유 참조로 낮춰 쓰는 건 자유예요. 권한은 강한 쪽에서 약한 쪽으로 낮출 수 있고, 그 반대는 안 돼요.

같은 E0596이지만 문구가 다른 것도 볼 만해요. &[i32]에서는 "behind a & reference"라며 빌려 온 경로가 불변이라고 지적하고, let arr에서는 "not declared as mutable"이라며 소유한 변수가 불변이라고 지적해요. mut은 그 시점에 갖고 있는 접근 권한이에요.

for는 이 위에 있는 문법 설탕이에요. for x in y는 y.into_iter()를 불러요. 그리고 into_iter()는 받은 대상의 종류에 따라 결과가 달라져요.

TEXT
슬라이스 s: &mut [i32] 에서
  s.iter()      -> &i32
  s.iter_mut()  -> &mut i32
  s.into_iter() -> &mut i32        // 슬라이스에선 iter_mut()과 같다
  &mut *s       -> &mut i32        // 이렇게 써도 같다

소유한 대상에서
  [String; 1] .into_iter()  -> String
  Vec<String> .into_iter()  -> String
  &[i32]      .into_iter()  -> &i32

T를 소유했으면 T를, &T를 갖고 있으면 &T를, &mut T를 갖고 있으면 &mut T를 내놓아요. iter()와 iter_mut()은 그중 두 경우를 명시적으로 고르는 메서드예요.

5. 모양은 같은데 결과가 반대인 &와 *

맨 위 코드로 돌아갈게요. 그 코드는 컴파일러가 막지 못하는 코드였어요. 에러를 순서대로 지워 나가면 결국 컴파일이 통과해요.

반환문을 지우면 두 번째 에러가 나와요.

TEXT
error[E0384]: cannot assign twice to immutable variable `item`
2 |     for &item in slice.iter() {
  |          ---- first assignment to `item`
4 |             item -= 1
  |             ^^^^^^^^^ cannot assign twice to immutable variable
help: consider making this binding mutable

컴파일러가 mut을 붙이라고 해요. 시키는 대로 하면 컴파일이 통과해요. 그리고 아무 일도 일어나지 않아요. 같은 컴파일에서 나온 경고가 진짜 원인을 가리키고 있었어요.

TEXT
warning: value assigned to `item` is never read

slice.iter()는 &i32를 내놓고, 패턴 &item이 그걸 구조분해해서 i32 값을 복사해 와요. item은 원본이 아니라 복사본이에요. 주소를 출력해 보면 분명해요.

TEXT
  iter()     item        = 0xf350ddf49c   // 원본을 가리킨다
  &item 패턴  복사본 주소 = 0xf350ddf5ac   // 다른 위치. 세 번 반복하는 동안 같은 위치

복사본은 매 반복마다 스택의 같은 위치를 다시 써요. 원본과는 상관이 없어요.

여기서 &가 두 자리에서 반대로 동작한다는 게 드러나요. 타입 자리의 &는 참조를 만들고, 패턴 자리의 &는 참조를 벗겨요. 그래서 for &item in은 참조를 풀어서 값을 꺼내는 문법이에요.

세 경우를 나란히 놓으면 이렇게 돼요.

쓴 것받는 것원본 수정컴파일러 반응
for item in slice.iter()읽기 전용 주소불가E0594로 막아준다
for &item in slice.iter()값 복사본불가에러 없이 통과한다
for item in slice.iter_mut()읽기·쓰기 주소가능통과한다

첫 줄을 골랐다면 컴파일러가 use mutable method: iter_mut()까지 알려줬을 거예요. &를 붙이는 순간 문제가 "권한 부족"에서 "복사본을 고치고 버림"으로 바뀌면서, 에러가 경고로 바뀌었어요.

이게 이 버그의 성격이에요. &item 패턴은 참조를 값으로 바꾸기 때문에, 소유권 검사기가 다루는 문제가 아예 없어져요. 남는 건 지역 변수를 고치고 버리는 평범한 논리 버그이고, 소유권 검사기는 논리 버그를 잡는 도구가 아니에요.

그리고 이 변환은 i32가 Copy라서 에러 없이 일어나요. 원소가 String이었다면 같은 패턴이 이렇게 막혀요.

TEXT
error[E0507]: cannot move out of a shared reference
7 |     for &item in v.iter() { ... }
  |          ----    ^^^^^^^^
  |          data moved here because `item` has type `String`,
  |          which does not implement the `Copy` trait
help: consider removing the borrow

Copy 타입에서만 생기는 문제예요. String이었다면 컴파일러가 처음부터 막았을 거예요.

6. 대입문 왼쪽의 *는 쓸 위치를 지정해요

"아 그럼 궁금한게 iter() 나 iter_mut() 하면 주소값이 오니까 실제 값 수정하려면 *item 으로 해야겠네?" 싶었어요. 맞는 말인데, 확인해 보니 쓸 때와 읽을 때가 달랐어요.

쓸 때는 예외가 없어요.

TEXT
error[E0368]: binary assignment operation `-=` cannot be applied to type `&mut i32`
3 |         item -= 1;
help: `-=` can be used on `i32` if you dereference the left-hand side

읽을 때는 *를 생략할 수 있는 경우가 있는데, iter()냐 iter_mut()이냐에 따라 달라요.

표현&i32 (iter())&mut i32 (iter_mut())
*item % 2통과통과
item % 2통과E0369
*item > 0통과통과
item > &0통과E0308
item.abs()통과통과
item.pow(2)통과통과

메서드 호출은 둘 다 통과해요. auto-deref가 (*item).abs()로 바꿔 주기 때문이에요. 반면 연산자는 &i32만 통과해요. 표준 라이브러리에 &i32 % i32 구현은 있지만 &mut i32용은 없기 때문이에요. 그래서 고친 코드에서 if *item % 2 != 0의 *가 실제로 필요했어요.

쓸 때 예외가 없는 이유는 item이 그 자체로 하나의 변수이기 때문이에요. 주소가 담긴 변수예요. item = ...이라고 쓰면 "이 변수에 다른 주소를 넣어라"가 되어, 가리키는 대상의 값이 아니라 가리키는 주소를 바꾸는 문장이 돼요. item -= 1이 E0368인 것도 주소에서 1을 빼라는 말이 되기 때문이에요.

*item -= 1이라고 써야 "그 주소가 가리키는 위치의 값에서 1을 빼라"가 돼요. 그래서 대입문 왼쪽의 *는 참조가 가리키는 위치를 지정하는 연산자로 보는 게 정확해요.

for &item in의 &와 *item의 *는 둘 다 참조를 벗기는 모양이지만, 하나는 복사본에 닿고 하나는 원본에 닿아요.

7. 곁가지: %는 modulo가 아니에요

-3 % 2 != 0으로 홀수를 판별하는 게 좀 크랙 같았어요.

크랙이 아니라 관용 표현이에요. 틀린 쪽은 == 1이에요. Rust의 %는 modulo가 아니라 나머지(remainder)라서 부호가 피제수를 따라가요.

TEXT
n=-3  n%2!=0 -> true   n&1==1 -> true   rem_euclid -> true
n=-2  n%2!=0 -> false  n&1==1 -> false  rem_euclid -> false
n= 3  n%2!=0 -> true   n&1==1 -> true   rem_euclid -> true

-3 % 2는 1이 아니라 -1이에요. 그래서 item % 2 == 1은 음수 홀수를 전부 놓쳐요. n % 2 != 0, n & 1 == 1, n.rem_euclid(2) == 1 셋 다 음수에서 맞게 동작해요. is_multiple_of라는 메서드도 있는데 u32에는 있고 i32에는 없어요.

다시 정리하면

고친 코드는 이렇게 돼요.

Rust
pub fn transform_even_odd(slice: &mut [i32]) {
    for item in slice.iter_mut() {   // item: &mut i32
        if *item % 2 != 0 {
            *item -= 1;
        }
    }
}

바뀐 곳은 세 군데예요. iter()를 iter_mut()으로, 패턴 &item을 그냥 item으로, 그리고 대입할 때 *로 역참조. 반환문은 뺐고요.

  • &i32와 &mut i32는 같은 주소를 갖고, 다른 건 권한이에요.
  • &mut은 독점적인 접근이에요. 그래서 동시에 읽는 참조가 있으면 만들 수 없어요.
  • &mut [i32]를 받았어도 iter()를 부르면 원소를 읽기 전용으로 받아요. 수정하려면 iter_mut()으로 가변 권한을 넘겨야 해요.
  • 패턴의 &item은 참조를 벗겨 값을 복사해요. i32처럼 Copy인 타입에서는 에러 없이 통과해서, 복사본만 고치는 버그가 생겨요.
  • 대입할 때는 *item처럼 *로 쓸 위치를 지정해야 원본이 바뀌어요.