- Published on
Rust E0502, NLL로 소유권 충돌 풀기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Rust를 쓰다 보면 가장 자주 마주치는 에러 중 하나가 E0502 입니다. 메시지는 대개 비슷합니다. 어떤 값에 대해 불변 대여(&T)를 잡아 둔 상태에서, 같은 값에 가변 대여(&mut T)를 시도했다는 내용이죠.
중요한 점은 이 에러가 단순히 "소유권이 까다롭다" 수준의 문제가 아니라, Rust가 메모리 안전을 위해 동시에 만족할 수 없는 두 조건을 감지했다는 신호라는 것입니다.
이 글에서는 E0502가 왜 나는지, 그리고 NLL(Non-Lexical Lifetimes, 비어휘적 라이프타임)이 무엇을 바꿨는지, 마지막으로 실무에서 통과시키는 대표적인 리팩터링 패턴을 코드로 정리합니다.
또한 컴파일러가 "언제까지 빌림이 살아있다고 판단하는지"를 이해하는 방식은, 다른 런타임 문제를 진단할 때의 사고법과도 닮아 있습니다. 예를 들어 CI 캐시가 꼬여서 원인을 좁히는 과정은 범위와 생존 기간을 추적하는 점에서 유사합니다. 필요하면 GitHub Actions 캐시로 CI 꼬일 때 진단·해결 가이드도 같이 참고해 보세요.
E0502 한 줄 요약과 규칙
Rust의 빌림 규칙을 아주 짧게 요약하면 아래입니다.
- 같은 값에 대해
- 여러 개의 불변 참조는 동시에 가능
- 가변 참조는 오직 하나만 가능
- 가변 참조가 존재하는 동안 불변 참조도 불가
E0502는 보통 다음 상황에서 발생합니다.
- 이미
&T로 빌려서 사용 중인데, 그 값에 대해&mut T를 만들려 함 - 혹은 그 반대
문제는 개발자가 보기엔 "불변 참조는 이미 끝난 것 같은데" 컴파일러는 아직 끝나지 않았다고 판단하는 경우가 많다는 점입니다.
NLL이란 무엇이고, 무엇을 해결했나
NLL은 "라이프타임을 블록 스코프 단위로만 잡지 않고, 실제로 참조가 마지막으로 사용되는 지점까지로 더 정밀하게 줄여서 추론"하는 기능입니다.
과거(정확히는 Rust 2018 이전의 일부 케이스)에는 아래처럼 보이는 코드가 불필요하게 실패하는 경우가 있었습니다.
- 개발자 입장: 불변 참조를 더 이상 안 쓰는데도
- 컴파일러 입장: 스코프가 끝날 때까지 살아있다고 간주
NLL 이후에는 "참조가 마지막으로 사용된 시점"을 기준으로 빌림이 종료되므로, 많은 E0502가 자연스럽게 사라졌습니다.
하지만 NLL이 만능은 아닙니다. 참조가 실제로 더 오래 살아야 하는 형태로 코드가 작성되어 있으면, NLL이 있어도 E0502는 그대로 발생합니다. 즉, NLL은 "컴파일러가 더 똑똑해진 것"이지 "규칙이 완화된 것"은 아닙니다.
E0502를 재현해 보기
가장 흔한 패턴은 컬렉션에서 값을 읽어 놓고, 같은 컬렉션을 수정하려는 경우입니다.
fn main() {
let mut v = vec![10, 20, 30];
let first = &v[0];
// 여기서 first는 불변 참조
v.push(40); // E0502: 불변 대여 중 가변 대여 시도
println!("{}", first);
}
이 코드는 논리적으로도 위험합니다. push는 내부 버퍼 재할당을 일으킬 수 있고, 그 순간 first가 가리키던 메모리가 무효가 될 수 있습니다. Rust는 이 가능성을 컴파일 타임에 차단합니다.
여기서 중요한 관찰 포인트는 println!이 first를 나중에 쓰기 때문에, 불변 대여가 실제로 그 시점까지 유지된다는 점입니다. 즉 NLL이 있더라도 이 코드는 안전하지 않으니 통과시켜 줄 수 없습니다.
NLL로 "자연스럽게" 해결되는 케이스
반대로 아래 코드는 NLL 덕분에 통과하는 전형적인 형태입니다.
fn main() {
let mut v = vec![10, 20, 30];
let first_val = v[0]; // 복사(Copy)로 값만 가져옴
// 여기서는 참조(&)가 아니라 값(i32)을 가져왔기 때문에 빌림이 없음
v.push(40);
println!("{}", first_val);
}
i32는 Copy라서 참조를 들고 있을 필요가 없습니다. 실무에서도 "참조를 오래 들고 있지 말고 필요한 값만 복사하거나 소유로 가져오기"가 가장 쉬운 해결책인 경우가 많습니다.
다만 모든 타입이 Copy는 아니므로, 다음 패턴들이 더 중요해집니다.
해결 패턴 1: 불변 참조의 생존 범위를 줄이기
E0502를 가장 정석적으로 해결하는 방법은 "불변 참조를 더 짧게 쓰고 빨리 끝내는" 것입니다. 즉, 참조를 변수에 오래 저장하지 않고 필요한 계산을 먼저 끝냅니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
// 불변 참조를 잡는 대신, 필요한 정보를 먼저 계산
let len0 = v[0].len();
// 이제 v를 가변으로 써도 됨
v.push(String::from("c"));
println!("{}", len0);
}
핵심은 "참조를 들고 있는 시간"을 줄이는 것입니다. NLL은 마지막 사용 지점을 기준으로 줄여주지만, 애초에 마지막 사용을 앞당기면 더 확실해집니다.
해결 패턴 2: 스코프 블록으로 참조를 강제로 끝내기
가끔은 코드 구조상 불변 참조를 잠깐 만들어야 하고, 그 다음에 반드시 수정이 필요할 때가 있습니다. 이때는 스코프를 인위적으로 나누면 의도가 명확해집니다.
fn main() {
let mut v = vec![String::from("hello"), String::from("world")];
{
let s = &v[0];
println!("{}", s);
// s는 이 블록 끝에서 drop
}
v.push(String::from("!"));
}
이 방식은 특히 "로그 출력"이나 "검증" 때문에 잠시 참조를 잡는 코드에서 유용합니다.
해결 패턴 3: split_at_mut로 "서로 다른 영역"임을 증명하기
E0502가 나는 이유 중 하나는 컴파일러가 "두 참조가 같은 요소를 가리킬 수 있다"고 보수적으로 판단하기 때문입니다. 하지만 실제로는 인덱스로 서로 다른 요소를 가리키는 경우가 많죠.
예를 들어 벡터의 서로 다른 두 원소를 동시에 바꾸고 싶을 때, 아래는 실패합니다.
fn main() {
let mut v = vec![1, 2, 3];
let a = &mut v[0];
let b = &mut v[1]; // 같은 v에 대한 두 개의 &mut
*a += 10;
*b += 10;
}
이때는 split_at_mut로 "서로 겹치지 않는 두 슬라이스"를 만들면 됩니다.
fn main() {
let mut v = vec![1, 2, 3];
let (left, right) = v.split_at_mut(1);
let a = &mut left[0];
let b = &mut right[0];
*a += 10;
*b += 10;
println!("{:?}", v);
}
이 패턴은 Rust가 원하는 방식으로 "aliasing이 없다"는 것을 타입 시스템에 의해 증명하는 대표적인 예입니다.
해결 패턴 4: mem::take 또는 소유권 이동으로 교통정리
구조체 필드나 맵에서 값을 꺼내 처리한 뒤 다시 넣는 로직에서 E0502가 자주 터집니다. 이때는 값의 소유권을 잠시 밖으로 꺼내서 작업하고, 다시 넣는 형태가 깔끔합니다.
use std::mem;
#[derive(Debug)]
struct State {
buf: Vec<String>,
}
fn main() {
let mut s = State { buf: vec!["a".into(), "b".into()] };
// buf를 통째로 꺼내오고(빈 Vec로 대체), 밖에서 마음껏 수정
let mut buf = mem::take(&mut s.buf);
buf.push("c".into());
// 다시 넣기
s.buf = buf;
println!("{:?}", s);
}
이 방식은 "한 번에 한 곳만 가변으로"라는 규칙을 유지하면서도 코드가 단순해집니다.
해결 패턴 5: 인덱스/키를 먼저 구하고, 실제 접근은 나중에
반복문에서 어떤 조건을 검사하기 위해 불변 참조를 들고 있다가, 같은 컬렉션을 수정하려고 할 때 충돌이 납니다. 이때는 "참조"가 아니라 "식별자"를 먼저 구해두는 전략이 좋습니다.
fn main() {
let mut v = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
// 불변 참조 대신, 수정할 대상의 인덱스를 찾는다
let idx = v.iter().position(|s| s.starts_with("b"));
if let Some(i) = idx {
v[i].push_str("-updated");
}
println!("{:?}", v);
}
position 내부에서의 불변 빌림은 클로저 실행 동안만 유효하고, 결과로는 인덱스만 남으므로 이후 가변 접근이 가능합니다.
자주 하는 오해: NLL이면 다 해결된다
NLL은 "불필요하게 길게 잡히던 빌림"을 줄여주지만, 아래 같은 경우는 해결되지 않습니다.
- 참조를 실제로 이후에도 사용함
- 참조가 살아있는 동안 컬렉션 구조를 바꾸는 작업이 있음(
push,insert,remove등) - 한 함수 호출이 참조를 내부에 저장할 가능성이 있어 라이프타임이 길어짐
특히 메서드 체이닝이나 이터레이터 조합에서 "마지막 사용"이 눈에 잘 안 보이게 되는 경우가 많습니다. 이때는 중간 값을 변수로 분리해서 참조의 범위를 눈에 보이게 만드는 것이 좋습니다.
디버깅 접근법: 컴파일러가 의심하는 "겹침"을 찾아라
E0502가 떴을 때는 아래 순서로 보면 빠릅니다.
- 에러 메시지에서 "immutable borrow occurs here"와 "mutable borrow occurs here"의 위치를 정확히 본다
- 불변 참조가 마지막으로 사용되는 지점을 찾는다(생각보다 뒤에 있을 때가 많음)
- 그 사이에 컬렉션 구조 변경이 있는지 확인한다
- 해결책은 보통 둘 중 하나다
- 참조를 더 짧게 만들기(값 복사, 스코프 분리, 계산 선행)
- 겹치지 않음을 증명하기(
split_at_mut, 인덱스 분리)
이런 "범위" 문제를 추적하는 습관은 프런트엔드에서도 비슷하게 쓰입니다. 예를 들어 RSC에서 클라이언트 번들이 불필요하게 커지는 원인을 추적할 때도, 어떤 코드가 어디까지 영향을 미치는지 범위를 줄이는 게 핵심입니다. 관련해서는 Next.js RSC에서 use client로 번들 폭증 잡기도 참고할 만합니다.
마무리: E0502는 설계 신호다
E0502는 귀찮은 에러처럼 보이지만, 실제로는 코드가 "참조를 너무 오래 잡고 있거나", "한 번에 너무 많은 일을 한 함수/블록에서 처리하고 있거나", "데이터 구조 변경과 조회가 얽혀 있는" 설계를 드러내는 경우가 많습니다.
NLL 덕분에 많은 케이스가 부드럽게 해결되지만, 그래도 막힌다면 아래를 우선 적용해 보세요.
- 참조를 값으로 바꿀 수 있으면 바꾸기(
Copy,clone, 필요한 정보만 추출) - 참조의 스코프를 짧게 만들기(블록으로 감싸기, 계산을 앞당기기)
- 서로 다른 영역임을 API로 증명하기(
split_at_mut) - 소유권 이동으로 작업을 분리하기(
mem::take)
이 패턴들을 익혀두면 Rust의 소유권 모델이 "장벽"이 아니라 "안전한 리팩터링 가이드"로 느껴질 겁니다.