- Published on
Rust 소유권 에러 E0502/E0507 10분 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 초반 러닝커브의 대부분은 소유권(ownership)과 빌림(borrowing) 규칙에 부딪히는 경험으로 채워집니다. 그중에서도 E0502(불변/가변 빌림 충돌)와 E0507(빌린 값에서 move 시도)는 빈도가 높고, 에러 메시지는 친절하지만 “어떻게 고칠지”는 감이 안 잡히는 경우가 많습니다.
이 글은 두 에러를 원인 유형으로 빠르게 분류하고, 그 유형별로 가장 짧은 수정 패턴을 제시합니다. 목표는 “이해를 곁들인 10분 해결”입니다.
참고로 이런 류의 문제는 원인을 좁히는 체크리스트가 있으면 빠르게 해결됩니다. 인증/환경 문제를 체크리스트로 푸는 방식이 익숙하다면, 비슷한 접근을 다른 글에서도 참고할 수 있습니다: OpenAI Responses API 401 403 인증오류 점검 가이드
0. 10분 디버깅 루틴(공통)
아래 순서대로만 보면 대부분의 E0502/E0507은 바로 방향이 잡힙니다.
- 에러가 가리키는 “첫 번째 빌림 지점”과 “두 번째 충돌 지점”을 각각 확인
- 변수의 타입이
T인지&T인지&mut T인지, 혹은 스마트 포인터(Rc,RefCell,Arc,Mutex)인지 확인 - “빌림이 끝나는 시점”을 코드 상에서 의도적으로 앞당길 수 있는지 확인
- move가 필요한지, clone으로 충분한지, 혹은 참조로 바꾸면 되는지 결정
이제 본격적으로 E0502와 E0507을 분해합니다.
1. E0502: 불변으로 빌린 상태에서 가변으로 빌릴 수 없음
1-1. E0502의 의미를 한 문장으로
E0502는 같은 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들려고 해서 발생합니다.
Rust 규칙은 단순합니다.
&T는 여러 개 OK&mut T는 단 하나만 OK&T와&mut T의 공존은 불가(동일 스코프/동일 생명주기에서)
1-2. 가장 흔한 패턴: 읽고 있는 동안 수정하려고 함
다음 코드는 전형적인 E0502입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 불변 빌림 시작
v.push(4); // 가변 빌림 필요 -> E0502
println!("{}", first);
}
해결 패턴 A: 불변 빌림의 생명주기를 짧게(스코프 분리)
가장 “Rust다운” 해결은 불변 참조를 더 일찍 끝내는 것입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first_val = v[0]; // 복사 타입(i32)이므로 값 복사
v.push(4);
println!("{}", first_val);
}
i32처럼 Copy인 타입은 참조를 잡지 말고 값을 복사하는 게 최단 해결입니다.
만약 String처럼 Copy가 아니라면, 스코프를 쪼갭니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
{
let first = &v[0];
println!("{}", first);
} // 여기서 불변 빌림 종료
v.push(String::from("c"));
}
해결 패턴 B: 인덱싱 대신 split_at_mut로 “서로 다른 조각”을 가변 빌림
서로 다른 원소를 동시에 만지고 싶은데 빌림이 꼬이면, split_at_mut가 정석입니다.
fn swap_first_two(v: &mut [i32]) {
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0];
std::mem::swap(a, b);
}
split_at_mut는 컴파일러가 “서로 다른 메모리 구간”임을 확신할 수 있게 해주므로, 안전하게 복수의 &mut를 만들 수 있습니다.
1-3. 루프에서 자주 터지는 E0502: 순회하면서 수정
다음은 순회(iter)는 불변 빌림인데, 내부에서 수정하려 해서 터집니다.
fn main() {
let mut v = vec![1, 2, 3];
for x in v.iter() { // v를 불변 빌림
if *x == 2 {
v.push(4); // 가변 빌림 시도 -> E0502
}
}
}
해결 패턴 C: 2단계 처리(읽기 단계와 쓰기 단계 분리)
실무에서 가장 많이 쓰는 패턴입니다.
fn main() {
let mut v = vec![1, 2, 3];
let need_push = v.iter().any(|x| *x == 2);
if need_push {
v.push(4);
}
}
또는 “추가할 목록”을 따로 모아 마지막에 반영합니다.
fn main() {
let mut v = vec![1, 2, 3];
let mut to_add = Vec::new();
for x in v.iter() {
if *x == 2 {
to_add.push(4);
}
}
v.extend(to_add);
}
1-4. HashMap에서 E0502: get하고 insert하려는 순간
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".to_string(), 1);
let v = m.get("a"); // 불변 빌림
m.insert("b".to_string(), 2); // 가변 빌림 -> E0502
println!("{:?}", v);
}
해결 패턴 D: entry API로 “읽기+쓰기”를 한 번에
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
*m.entry("a".to_string()).or_insert(0) += 1;
}
entry는 소유권/빌림을 컴파일러가 추론하기 쉬운 형태로 묶어줘서, get 후 insert 같은 충돌을 피할 수 있습니다.
2. E0507: 빌린 컨텍스트에서 move 할 수 없음
2-1. E0507의 의미를 한 문장으로
E0507은 &T 또는 &mut T 같은 “빌린 값”을 통해 그 내부의 값을 move(소유권 이전)하려고 해서 발생합니다.
즉, “내 것이 아닌데 가져가려고” 할 때 나는 에러입니다.
2-2. 가장 흔한 패턴: &Option<T>에서 T를 꺼내려고 함
fn take_name(user_name: &Option<String>) -> String {
user_name.unwrap() // E0507: &Option<String>에서 String을 move하려 함
}
해결 패턴 A: 참조로 꺼내기(as_ref)
소유권이 아니라 “보기만” 하면 되는 경우가 많습니다.
fn name_len(user_name: &Option<String>) -> usize {
user_name.as_ref().map(|s| s.len()).unwrap_or(0)
}
as_ref()는 Option<String>을 Option<&String>으로 바꿔서 move를 피합니다.
해결 패턴 B: 정말로 가져가야 한다면, 소유한 쪽에서 take()
가져가려면 원본이 소유하고 있어야 합니다. 그래서 함수 시그니처를 바꾸거나, &mut Option<T>를 받아 take()를 씁니다.
fn take_name(user_name: &mut Option<String>) -> Option<String> {
user_name.take()
}
take()는 내부 값을 꺼내고 원본을 None으로 바꿉니다. 이 패턴은 상태 머신, 세션/캐시 핸들링에 특히 자주 등장합니다.
2-3. &String에서 String을 move하려는 실수
fn into_upper(s: &String) -> String {
*s // E0507
}
해결 패턴 C: clone 또는 to_owned
소유한 String이 필요하면 복제 비용을 지불해야 합니다.
fn into_upper(s: &String) -> String {
s.to_owned().to_uppercase()
}
더 나은 선택지는, 애초에 &str을 받는 것입니다.
fn into_upper(s: &str) -> String {
s.to_uppercase()
}
API 설계에서 &String보다 &str이 범용적이고 빌림 규칙도 단순해지는 경우가 많습니다.
2-4. 구조체 필드를 move하려다 E0507: &self 메서드에서 필드 꺼내기
struct User {
name: String,
}
impl User {
fn name(self: &User) -> String {
self.name // E0507
}
}
해결 패턴 D: 반환을 참조로 바꾸기
impl User {
fn name(&self) -> &str {
&self.name
}
}
해결 패턴 E: 소유권을 넘기고 싶으면 self를 소비하기
impl User {
fn into_name(self) -> String {
self.name
}
}
이건 “이 메서드를 호출하면 User는 더 이상 못 쓴다”는 명확한 신호가 됩니다.
해결 패턴 F: 부분 move가 필요하면 mem::take 또는 replace
&mut self가 있고 필드를 비워도 된다면 다음이 실전에서 매우 유용합니다.
use std::mem;
struct User {
name: String,
}
impl User {
fn take_name(&mut self) -> String {
mem::take(&mut self.name)
}
}
mem::take는 해당 타입의 Default 값으로 교체합니다(String은 빈 문자열). Default가 없다면 mem::replace로 원하는 값으로 교체하세요.
3. E0502와 E0507을 “원인별로” 고르는 치트시트
3-1. E0502 치트시트
- 읽기(
&T)와 쓰기(&mut T)가 겹친다- 해결: 스코프 분리, 중간값을 복사/계산 후 빌림 종료, 읽기/쓰기 2단계 처리
- 컬렉션에서 서로 다른 원소를 동시에
&mut로 잡고 싶다- 해결:
split_at_mut, 또는 인덱스 설계를 바꿔 “서로 다른 조각”임을 증명
- 해결:
HashMap에서 조회 후 수정- 해결:
entry사용
- 해결:
3-2. E0507 치트시트
&T에서T를 꺼내려 한다- 해결: 참조로 꺼내기(
as_ref,iter,get등)
- 해결: 참조로 꺼내기(
- 정말로 소유권이 필요하다
- 해결: 함수가
T를 받게 바꾸기, 혹은&mut Option<T>에서take()
- 해결: 함수가
- 구조체 필드를 빼고 싶다
- 해결: 반환을 참조로, 또는
self소비, 또는mem::take/replace
- 해결: 반환을 참조로, 또는
4. 실전 예제: E0502/E0507이 연쇄로 터질 때(리팩터링 순서)
실무에서는 두 에러가 같이 나오는 경우가 많습니다. 예를 들어, 어떤 상태를 읽고(불변 빌림) 조건에 따라 상태를 업데이트(가변 빌림)하면서, 중간에 필드를 꺼내(move)려 할 때입니다.
아래는 의도적으로 꼬아둔 예시입니다.
#[derive(Default)]
struct State {
last_msg: Option<String>,
count: usize,
}
fn process(state: &mut State) {
let msg_ref = state.last_msg.as_ref(); // 불변 빌림
if let Some(m) = msg_ref {
if m.contains("ok") {
state.count += 1; // 가변 빌림 필요 -> E0502 가능
}
}
// 그리고 나중에 last_msg를 꺼내고 싶어짐
// let owned = state.last_msg.unwrap(); // E0507 가능
}
권장 리팩터링 순서
- 먼저 읽기 결과를 “값”으로 축약해서 불변 빌림을 끝냅니다.
fn process(state: &mut State) {
let is_ok = state
.last_msg
.as_ref()
.map(|m| m.contains("ok"))
.unwrap_or(false);
if is_ok {
state.count += 1;
}
}
- 그 다음 소유권이 필요하면
take()로 명확히 꺼냅니다.
fn process_and_take(state: &mut State) -> Option<String> {
let is_ok = state
.last_msg
.as_ref()
.map(|m| m.contains("ok"))
.unwrap_or(false);
if is_ok {
state.count += 1;
}
state.last_msg.take()
}
이 흐름은 “읽기 판단을 먼저 끝내고, 쓰기/이동은 나중에”라는 원칙을 코드로 강제합니다.
5. 언제 RefCell 같은 내부 가변성을 써야 하나
E0502를 만날 때마다 RefCell로 도망가고 싶은 유혹이 있습니다. 하지만 내부 가변성은 컴파일 타임 보장을 런타임 체크로 바꾸는 선택입니다.
- 단일 스레드에서, 구조상 빌림이 복잡하지만 논리적으로 안전함이 명확할 때:
RefCell이 유용 - 멀티 스레드에서 공유 갱신:
Arc+Mutex/RwLock고려 - 단순히 스코프/데이터 흐름을 정리하면 해결되는 문제: 먼저 리팩터링이 우선
운영 환경에서 “원인과 해결을 분리해서 재현하고 고친다”는 태도는 다른 장애 대응에도 그대로 적용됩니다. 예를 들어 배포 동기화 실패를 원인별로 쪼개는 글도 같은 결입니다: Argo CD Sync 실패 comparisonError 원인·해결
6. 마무리: 10분 안에 끝내는 핵심만 다시
E0502는 불변 빌림이 살아있는 동안 가변 빌림을 만들었는지부터 본다- 스코프 분리, 2단계 처리,
entry,split_at_mut가 주력
- 스코프 분리, 2단계 처리,
E0507은 빌린 값에서 move를 시도했는지를 본다- 참조로 바꾸기(
as_ref), 소유권을 받도록 API 변경,take/mem::take가 주력
- 참조로 바꾸기(
이 두 에러는 “Rust가 까다롭다”의 상징처럼 느껴지지만, 사실은 데이터 흐름을 명확히 만들라는 리팩터링 신호에 가깝습니다. 위 패턴들을 머릿속에 치트시트로 넣어두면, 실제로는 컴파일러가 가장 빠른 코드 리뷰어가 되어줍니다.