---
title: "struct 는 값을 담고 class 는 주소를 담는다"
description: "int 가 힙에 안 가는 이유, 그리고 힙에 안 가는 타입을 직접 만드는 법."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "메모리"]
canonical: https://blog.unialgames.com/posts/csharp-struct/
---
앞에서 값 타입과 참조 타입을 갈랐습니다. `int` 는 값을 직접 담고, `Dog` 는 주소를 담았습니다.
그때 값 타입 목록은 `int`, `double`, `bool`, `char` 처럼 **이미 있는 것들**이었습니다.

그런데 그 목록에 우리가 만든 것을 추가할 수 있습니다.

> **`class` 대신 `struct` 라고 쓰면 값 타입이 됩니다. 힙에 안 가고, 대입하면 통째로 복사됩니다.**

## struct 는 이렇게 생겼다

`class` 자리에 `struct` 라고 쓰는 것이 전부입니다.

```csharp
struct 점
{
    public int X;
    public int Y;
}
```

쓰는 법도 똑같이 생겼습니다.

```csharp
점 p = new 점();
p.X = 3;
p.Y = 4;
```

`new` 를 쓰는 모양까지 같습니다. 그런데 이 `new` 는 힙에 자리를 잡지 않습니다.
`struct` 에서 `new` 는 "자리를 만들어라" 가 아니라 "생성자를 불러 채워라" 에 가깝습니다.
자리는 이미 `p` 라는 변수 그 자체입니다.

## 대입하면 통째로 복사된다

이게 유일하게 중요한 차이입니다. 나머지는 전부 여기서 나옵니다.

<x-heap>
<script type="application/json">
{
  "label": "class 를 대입할 때",
  "steps": [
    {
      "code": "점C a = new 점C(); a.X = 3;",
      "why": "class 니까 힙에 상자가 생기고 a 는 그 상자를 가리킵니다.",
      "stack": [{ "name": "a", "ref": 0, "hot": true }],
      "heap": [{ "id": 0, "title": "점C", "fields": ["X: 3", "Y: 0"], "hot": true }]
    },
    {
      "code": "점C b = a;",
      "why": "복사되는 것은 상자가 아니라 주소입니다. 둘이 같은 상자를 봅니다.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "b", "ref": 0, "hot": true }],
      "heap": [{ "id": 0, "title": "점C", "fields": ["X: 3", "Y: 0"] }]
    },
    {
      "code": "b.X = 99;",
      "why": "b 를 고쳤는데 a 로 읽어도 99 입니다. 같은 상자니까요.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "b", "ref": 0 }],
      "heap": [{ "id": 0, "title": "점C", "fields": ["X: 99", "Y: 0"], "hot": true }]
    }
  ],
  "note": "class 는 상자가 하나입니다. 이름만 둘입니다."
}
</script>
</x-heap>

`struct` 로 바꾸면 힙이 아예 등장하지 않습니다.

<x-heap>
<script type="application/json">
{
  "label": "struct 를 대입할 때",
  "steps": [
    {
      "code": "점 a = new 점(); a.X = 3;",
      "why": "힙에 안 갑니다. a 라는 자리 안에 X 와 Y 가 그대로 들어 있습니다.",
      "stack": [{ "name": "a", "value": "X:3 Y:0", "hot": true }],
      "heap": []
    },
    {
      "code": "점 b = a;",
      "why": "안에 든 값이 통째로 복사됩니다. b 는 a 와 아무 관계가 없는 별개의 자리입니다.",
      "stack": [{ "name": "a", "value": "X:3 Y:0" }, { "name": "b", "value": "X:3 Y:0", "hot": true }],
      "heap": []
    },
    {
      "code": "b.X = 99;",
      "why": "b 만 바뀝니다. a 는 3 그대로입니다.",
      "stack": [{ "name": "a", "value": "X:3 Y:0" }, { "name": "b", "value": "X:99 Y:0", "hot": true }],
      "heap": []
    }
  ],
  "note": "struct 는 상자가 둘입니다. 가리키는 것이 없으니 서로 영향을 못 줍니다."
}
</script>
</x-heap>

같은 코드에서 결과가 갈립니다.

```csharp
b.X = 99;
Console.WriteLine(a.X);
```

`점` 이 `class` 면 `99`, `struct` 면 `3` 입니다. 코드는 한 글자도 안 달라졌는데 답이 다릅니다.

## 메서드에 넘길 때도 같다

메서드 편에서 "넘어가는 것은 값의 사본이다" 라고 하고, 왜 결과가 다른지는 미뤄 뒀습니다. 그 답이 이것입니다.

넘어가는 것이 사본이라는 규칙은 언제나 같습니다. 다만 **무엇의 사본인지**가 다릅니다.

| | 무엇이 복사되나 | 안에서 필드를 고치면 |
| --- | --- | --- |
| `struct` | 값 전체 | 바깥에 반영 안 됨 |
| `class` | 주소 | 바깥에 반영됨 |

`class` 를 넘기면 주소가 복사됩니다. 주소는 복사됐지만 그 주소가 가리키는 상자는 하나입니다.
그래서 안에서 `dog.Age = 5` 를 하면 바깥에서도 5 입니다.

`struct` 를 넘기면 값 전체가 복사됩니다. 안에서 `p.X = 5` 를 해도 바깥의 `p` 는 그대로입니다.
바깥까지 바꾸고 싶으면 `ref` 를 붙여야 합니다. `ref` 가 있는 이유가 여기 있습니다.

## 왜 나눠 놨나

`int` 하나를 쓸 때마다 힙에 자리를 잡고 주소를 들고 다닌다고 생각해 보면 답이 나옵니다.

4바이트 값 하나를 위해 힙에 상자를 만들고, 주소 8바이트를 스택에 담고, 나중에 GC 가 그 상자를 치웁니다.
값 자체보다 값을 관리하는 비용이 더 큽니다.

작고 개수가 많은 것은 그냥 자리에 담는 편이 낫습니다. 그래서 값 타입이 있습니다.

Unity 를 보면 이 판단이 그대로 보입니다.

```csharp
Vector3 위치 = transform.position;
```

`Vector3` 는 `float` 셋입니다. 게임 한 프레임에 이런 것이 수만 개씩 오갑니다.
이게 `class` 였으면 프레임마다 힙에 수만 개가 생기고 GC 가 계속 돌았을 겁니다.
그래서 `Vector3`, `Quaternion`, `Color`, `Rect` 는 전부 `struct` 입니다.

## 함정 하나: 값을 꺼내면 사본이다

이걸 모르면 반드시 한 번은 막힙니다.

```csharp
transform.position.x = 5f;   // 컴파일 에러
```

`transform.position` 은 필드가 아니라 프로퍼티입니다. 프로퍼티는 값을 **돌려줍니다**.
`Vector3` 는 struct 라서 돌려받는 것이 사본입니다.

즉 이 코드는 "사본을 하나 만들고, 그 사본의 x 를 고치고, 버려라" 가 됩니다.
아무 일도 안 일어나는 코드라서 컴파일러가 미리 막습니다.

제대로 하려면 통째로 다시 넣어야 합니다.

```csharp
Vector3 p = transform.position;
p.x = 5f;
transform.position = p;
```

같은 이유로 `List<점>` 안의 원소도 `list[0].X = 5` 로 못 고칩니다. `list[0]` 이 사본이기 때문입니다.
배열은 됩니다. 배열의 인덱서는 사본이 아니라 그 자리를 직접 가리키기 때문입니다.
같아 보이는 둘이 다르게 동작하는 자리라 헷갈리기 쉽습니다.

## 그래서 언제 struct 로 만드나

세 가지가 다 맞을 때만 값 타입으로 만드는 편이 안전합니다.

- **작다.** 대략 16바이트 안쪽. 크면 복사 비용이 주소 복사보다 비싸집니다
- **만든 뒤에 안 바꾼다.** 좌표, 색, 시간처럼 통째로 갈아 끼우는 것
- **개수가 많다.** 하나 만들고 마는 것이면 굳이 struct 일 이유가 없습니다

셋 중 하나라도 어긋나면 `class` 가 낫습니다. 특히 **바뀌는 값**을 struct 에 담으면
위에서 본 사본 함정이 계속 발목을 잡습니다.

## 정리

- `class` 대신 `struct` 라고 쓰면 값 타입이 됩니다
- 대입하면 주소가 아니라 **값 전체**가 복사됩니다. 그래서 서로 영향을 안 줍니다
- 힙에 안 갑니다. 작고 많은 것을 담을 때 관리 비용이 사라집니다
- `Vector3` 가 struct 라서 `transform.position.x = 5` 가 컴파일 에러입니다
- 작고, 안 바뀌고, 개수가 많을 때만 씁니다