Structs Aren't on the Stack. How C# Actually Manages Memory.
C#의 struct는 꼭 스택에 있지 않습니다 — CLR의 메모리 관리 방식
C#에서 struct와 class의 차이는 스택과 힙의 구분이 아니라 값 의미론과 참조 의미론입니다. struct는 선언된 컨테이너가 놓인 곳에 저장되므로 클래스 필드나 배열, async 상태 머신 안에서는 힙에 놓일 수 있습니다. 글은 박싱, 배열의 메모리 배치, Span<T>와 struct 설계 원칙까지 살펴봅니다.
- 주제
에디터 노트
이 글의 한 줄 요약은 원문에도 있습니다. 값 형식은 선언된 컨테이너가 놓인 곳에 산다. class와 struct의 차이는 스택과 힙이 아니라 값 의미론과 참조 의미론이라는 겁니다. 면접에서 외운 답을 버리라는 이야기입니다. 수치가 재밌습니다. int 두 개인 class는 32바이트(300% 오버헤드), struct는 8바이트입니다. struct 배열 순회는 class 배열보다 5~10배 빠를 수 있고, ArrayList에 int 백만 개를 넣으면 힙 객체 백만 개가 생깁니다. 다만 @azaleakuts의 지적이 한 걸음 더 나갑니다. 단순한 메모리 모델은 escape analysis와 JIT 최적화 앞에서 한계를 드러낸다고 합니다. 이 글의 규칙도 결국 근사치라는 뜻입니다.
AI 요약
“class는 힙, struct는 스택”이라는 설명은 C# 메모리 모델을 지나치게 단순화합니다. 글의 중심 주장은 struct가 특정 메모리 영역을 요구하지 않는다는 점입니다. 값 형식은 자신을 담은 컨테이너가 놓인 곳에 함께 저장됩니다. class와 struct를 가르는 기준은 스택과 힙이 아니라 참조 의미론과 값 의미론입니다.
struct가 놓이는 위치
일반 메서드 안에서 선언한 지역 변수는 보통 해당 스레드의 스택 프레임에 놓입니다. 이 경우 메서드가 끝나면 스택 프레임이 정리되고, 지역 변수 회수에 가비지 컬렉터가 개입하지 않습니다. 하지만 모든 struct가 이런 방식으로 저장되는 것은 아닙니다.
class의 필드로 선언한 struct는 그 클래스 인스턴스 안에 포함됩니다. 인스턴스가 관리 힙에 놓이므로 struct 필드도 힙에 있습니다. struct 배열도 마찬가지입니다. 배열 자체가 참조 형식이므로 힙에 생성되고, 배열 원소인 struct 값들은 별도 객체나 포인터로 분리되지 않은 채 연속된 메모리 영역에 들어갑니다.
async 메서드에서도 지역 변수의 위치가 달라질 수 있습니다. 메서드가 await에서 중단된 뒤 실행을 이어 가도록 컴파일러는 상태 머신을 생성합니다. await 이후에도 필요한 지역 변수는 상태 머신의 필드로 옮겨질 수 있어 힙에 놓입니다. 람다나 LINQ 식에서 캡처한 지역 변수도 비슷하게 별도 저장 공간으로 옮겨질 수 있습니다.
값 형식의 공간과 박싱
글은 값 형식이 실제 데이터를 저장하고, 참조 형식 변수는 객체를 가리키는 참조를 저장한다고 설명합니다. 작은 struct는 객체 헤더와 참조 간접 접근이 필요하지 않아 데이터가 작고 연속된 배열에서는 메모리 사용량과 접근 비용을 줄이는 데 도움이 됩니다. 반면 class 인스턴스에는 객체 관리에 필요한 헤더가 붙고, 참조를 따라 실제 필드에 접근합니다. 글은 64비트 환경에서 두 개의 int 필드를 가진 예를 들어 class 객체에 참조와 헤더, 필드 공간이 필요하지만 struct에는 필드 데이터가 직접 저장된다고 비교합니다.
값 형식을 object나 인터페이스 형식으로 다루면 박싱(boxing)이 발생할 수 있습니다. 런타임은 값 형식의 데이터를 담을 힙 객체를 만들고 값을 복사합니다. 다시 값 형식으로 변환할 때는 타입을 확인한 뒤 데이터를 꺼냅니다. 이런 변환이 반복문 같은 빈번한 경로에서 일어나면 객체 할당과 가비지 컬렉션 부담이 커집니다.
글은 제네릭 컬렉션이 이 문제를 피하는 방법이라고 설명합니다. 예를 들어 List<int>는 int 값을 박싱해 object로 저장하는 대신, 값 형식에 맞는 저장 공간을 사용합니다. 인터페이스 매개변수에 struct를 전달할 때도 박싱이 생길 수 있습니다. 예시에서는 Counter 값을 IIncrementable 매개변수로 전달하면서 박싱된 복사본이 만들어지고, 인터페이스 메서드가 그 복사본을 수정하므로 원래 값은 바뀌지 않습니다.
배열 배치와 Span<T>
class 배열에는 객체 자체가 아니라 객체를 가리키는 참조가 들어갑니다. 각 객체가 힙의 서로 다른 위치에 놓이면 반복 과정에서 포인터를 따라 메모리를 오가야 합니다. 글은 이를 포인터 추적(pointer chasing)이라 부르고, 캐시 미스를 유발할 수 있다고 설명합니다. 반면 struct 배열은 값을 연속 배치하므로 인접한 데이터를 함께 읽기 쉽습니다. CPU 캐시 라인과 하드웨어 프리페처의 이점을 얻을 수 있어, 글은 특정 반복 작업에서 struct 배열이 class 배열보다 빠를 수 있다고 주장합니다.
Span<T>와 ReadOnlySpan<T>는 연속된 메모리 구간을 가리키는 값 형식입니다. 배열 일부, stackalloc으로 만든 스택 메모리, 관리되지 않는 메모리 등을 하나의 방식으로 다룰 수 있습니다. 문자열의 일부를 Substring으로 나누면 새 문자열을 할당하지만, ReadOnlySpan<char>의 Slice는 기존 문자열 안의 구간을 가리키므로 새 문자열 할당을 피할 수 있습니다.
Span<T>는 ref struct라서 컴파일러가 수명을 제한합니다. object로 박싱하거나 일반 class의 필드로 저장할 수 없고, 람다에서 캡처할 수 없습니다. 글은 async 메서드의 await 경계를 넘어 사용할 수 없다는 제한도 함께 소개합니다. 이런 제약으로 Span<T>가 가리키는 메모리보다 오래 살아남지 않도록 컴파일 단계에서 검사합니다.
struct를 설계할 때의 기준
글은 모든 타입을 struct로 만들지 말라고 권합니다. 큰 struct를 값으로 전달하면 전체 데이터가 복사되므로 작은 참조 하나를 전달하는 class보다 비쌀 수 있습니다. 좌표나 통화처럼 하나의 값으로 취급할 수 있고, 크기가 작으며, 상속이 필요 없는 타입에 struct가 잘 맞습니다. 특히 변경 가능한 struct는 메서드 호출이나 속성 접근에서 복사본이 만들어져 수정 결과가 원본에 반영되지 않는 버그를 일으킬 수 있습니다. 이를 막기 위해 readonly struct를 권하고, 큰 readonly struct를 전달할 때는 in 매개변수를 사용해 복사를 줄이는 방법을 제안합니다.
값 형식의 동등성 비교도 직접 구현할 수 있습니다. 글은 IEquatable<T>를 구현하면 기본 비교에 기대는 것보다 빠르고 박싱을 피하는 비교를 작성할 수 있다고 설명합니다. 결국 struct와 class 중 하나를 일률적으로 선택하기보다 값의 크기, 복사 비용, 저장 위치, 변경 가능성, 박싱 여부를 함께 살펴야 합니다.
dev.to 반응
- @azaleakuts — 스택과 힙 설명은 지나치게 단순화되는 경우가 많은 주제입니다. 이 글은 “struct = 스택, class = 힙”이라는 규칙으로 다루지 않고 값이 실제로 어디에 놓이는지를 결정하는 요소에 초점을 맞춘 점이 좋습니다. 단순한 메모리 모델이 한계를 드러내는 지점이라 특히 유용한 부분은 escape analysis와 JIT 최적화입니다.
원문: dev.to / 번역·요약: Trawling