[PART7.클래스와 객체 입문(12/21)] struct 기초 — 값 타입을 직접 정의하기
값 타입은 변수 안에 데이터를 통째로 담는다 / 스택·필드 인라인으로 GC를 건드리지 않는다 / 상속이 없는 대신 크기가 예측 가능하다
목차
1. [문제 제기] — 좌표 하나에 GC가 일을 하면 안 된다
Unity에서 매 프레임 Update() 안에서 캐릭터 위치를 갱신한다고 해봅시다. 만약 Vector3가 클래스(class)였다면 어떤 일이 벌어질까요.
// 가상의 시나리오 — Vector3가 class라면
public class Vector3Class { public float X, Y, Z; }
void Update()
{
// 매 프레임 새 위치 계산
Vector3Class next = new Vector3Class { X = transform.position.x + 1f, Y = 0, Z = 0 };
transform.SetPosition(next);
}
문제는 new Vector3Class가 힙(Heap) 에 메모리를 할당한다는 점입니다. 매 프레임 60번, 캐릭터 100개라면 1초에 6,000개의 작은 객체가 힙에 쌓입니다. 이 객체들은 곧 버려지지만, 메모리는 여전히 점유한 상태입니다. 어느 순간 GC(Garbage Collector, 사용하지 않는 힙 객체를 자동으로 회수하는 런타임 구성요소)가 깨어나 청소를 시작하고, 그동안 게임은 한 프레임 멈춥니다. 모바일 기기에서 이건 곧 "끊김"입니다.
실제 Unity의 Vector3는 클래스가 아니라 구조체(struct) 입니다. 구조체 인스턴스는 힙이 아니라 스택(Stack, 함수가 끝나면 자동으로 정리되는 빠른 메모리 영역) 에 놓이거나, 다른 객체의 필드 안에 그대로 박혀 들어갑니다. GC가 손댈 일이 없습니다.
public struct Vector3 { public float X, Y, Z; } // ← 이게 핵심
이 한 글자(class → struct) 차이가 프레임 드랍을 만들기도 하고, 막기도 합니다. 이번 글은 그 한 글자가 컴파일러와 런타임 안에서 무엇을 바꾸는지를 따라갑니다. 클래스를 충분히 다뤄본 입문자가 "내가 직접 값 타입을 만들어야 할 때는 언제인가"에 답할 수 있도록 하는 것이 목표입니다.
이번 글은 struct의 첫 단추입니다.ref struct·readonly struct·record struct같은 심화 기능은 이어지는 토픽에서 따로 다루며, 여기서는 값 타입을 정의하고 사용하는 가장 기본적인 사용법에 집중합니다.
2. [개념 정의] — 값을 담는 그릇 vs 위치를 가리키는 표지판
비유: 사진 vs URL
class 변수는 사진의 URL과 같습니다. 변수 자체는 짧은 문자열(주소)이고, 진짜 사진은 다른 어딘가(힙)에 한 장만 존재합니다. 두 사람이 같은 URL을 공유하면, 한 사람이 사진을 수정했을 때 다른 사람도 같은 변경을 봅니다.
struct 변수는 인쇄된 사진 그 자체입니다. 변수 안에 픽셀 데이터가 통째로 들어 있습니다. 두 사람이 사진을 나눠 가지면, 각자 다른 종이를 갖는 것이고, 한 사람이 자기 사진에 낙서해도 다른 사람의 사진은 깨끗합니다.
struct— 구조체 (Value type, 값 타입) 변수 안에 데이터를 직접 보관하는 사용자 정의 타입. 대입·매개변수 전달 시 값이 통째로 복사된다.class(참조 타입)와 반대 개념이며,int·float·bool이 모두 이 부류에 속한다.
예시:struct Vector2 { public float X, Y; }이 한 줄이 X·Y 두 필드를 가진 8바이트짜리 값 타입을 정의한다.
SVG: 메모리에 놓이는 모양
같은 두 필드(float X, Y)를 가졌지만, 클래스는 스택에 8바이트 참조 + 힙에 16바이트 헤더 + 8바이트 데이터로 나뉩니다. 구조체는 스택에 8바이트 한 덩어리로 끝납니다. 헤더가 없는 이유는 [3장](#3-내부-동작--컴파일러는-struct를-어떻게-바라보는가)에서 설명합니다.
기본 코드: 같은 모양 두 가지 정의
// 참조 타입
public class PointClass
{
public float X;
public float Y;
}
// 값 타입
public struct Vector2
{
public float X;
public float Y;
}
class Demo
{
static void Main()
{
// 1) 클래스: new 필수, 힙 할당
PointClass p = new PointClass();
p.X = 3.0f;
p.Y = 4.0f;
// 2) struct: new 없이 선언만 해도 메모리 확보 (단, 모든 필드 초기화 후 사용)
Vector2 v;
v.X = 3.0f;
v.Y = 4.0f;
// 3) struct 복사 — 데이터 통째로 복사됨
Vector2 v2 = v;
v2.X = 99f;
Console.WriteLine($"v.X={v.X}, v2.X={v2.X}"); // 출력: v.X=3, v2.X=99
}
}
v와 v2는 별개의 메모리입니다. v2.X를 99로 바꿔도 v.X는 3 그대로입니다. 같은 코드를 클래스로 바꾸면 둘 다 99가 됩니다. 이게 값 의미론(value semantics)의 핵심 효과입니다.
IL: 컴파일러가 본 두 타입의 차이
위 코드를 컴파일한 IL(Intermediate Language, .NET이 실행 전 단계로 사용하는 가상 명령어 집합) 일부입니다.
.class public sequential ansi sealed beforefieldinit Vector2
extends [System.Runtime]System.ValueType // ← struct는 ValueType을 상속
{
.field public float32 X
.field public float32 Y
}
.class public auto ansi beforefieldinit PointClass
extends [System.Runtime]System.Object // ← class는 Object를 상속
{
.field public float32 X
.field public float32 Y
.method instance void .ctor() cil managed // ← class는 기본 생성자가 자동 생성됨
{
ldarg.0
call instance void [System.Runtime]System.Object::.ctor()
ret
}
}
핵심 차이는 두 줄입니다.
extends System.ValueTypevsextends System.Object— 구조체는 모든 값 타입의 공통 부모인ValueType을 상속합니다. 이 한 줄이 런타임에 "이 인스턴스는 힙이 아니라 변수 자리에 직접 놓아라"라는 신호가 됩니다.sealed키워드 — 구조체에는 컴파일러가 자동으로sealed를 붙입니다. 누구도 이 구조체를 상속할 수 없다는 뜻이며, 그래서 [4장](#4-실전-적용--struct로-써야-할-때-class로-써야-할-때)에서 다룰 다형성 시나리오와는 어울리지 않습니다.
또 클래스에는 빈 생성자(Object::.ctor() 호출 한 줄)가 IL에 자동으로 들어가는 반면, 위 IL의 Vector2에는 생성자가 아예 없습니다. struct의 기본 생성자(매개변수 없는 생성자)는 컴파일러가 별도로 만들지 않고 런타임이 "0으로 가득 찬 메모리"로 처리합니다.
3. [내부 동작] — 컴파일러는 struct를 어떻게 바라보는가
SVG: 같은 코드, 다른 IL 명령어
메서드 안에서 일어나는 일
class Inside
{
static void Run()
{
Vector2 v;
v.X = 3.0f;
v.Y = 4.0f;
Vector2 v2 = v; // 복사
v2.X = 99f;
object boxed = v; // 박싱 (← 5장에서 자세히)
Vector2 def = default;
}
}
이 코드의 IL입니다. class 버전(new PointClass())과 비교하면 명령어 자체가 다릅니다.
.locals init (
[0] valuetype Vector2, ; 변수 v — 메모리에 8바이트 자리
[2] valuetype Vector2, ; 변수 v2
[3] object, ; 박스(힙 객체) 참조
[4] valuetype Vector2 ; 변수 def
)
// v.X = 3.0f
IL_0000: ldloca.s 0 ; v의 주소 — newobj 호출 없음
IL_0002: ldc.r4 3
IL_0007: stfld float32 Vector2::X ; 그 주소의 X 필드에 3 저장
// Vector2 v2 = v; — 값 복사
IL_0034: ldloc.0 ; v 전체를 스택에 로드 (8바이트)
IL_0035: stloc.2 ; v2에 그대로 저장
// object boxed = v; — 박싱
IL_0042: ldloc.0
IL_0043: box Vector2 ; 힙에 새 박스 생성, 데이터 복사
IL_0048: stloc.3
// Vector2 def = default;
IL_0049: ldloca.s 4
IL_004b: initobj Vector2 ; 8바이트를 0으로 채움
해설은 네 가지가 핵심입니다.
ldloca.s 0— "지역 변수 0번의 주소를 스택에 올린다". struct는 변수 자리에 데이터가 직접 있으니 그 자리의 주소를 바로 잡을 수 있습니다. 클래스라면 먼저newobj로 힙에 객체를 만들고, 그 참조를 지역 변수에 저장한 뒤, 다시ldloc으로 참조를 꺼내stfld를 해야 합니다. 단계가 다릅니다.stloc.2—v전체를v2에 그대로 복사. 8바이트 정도면 한 번의 mov 명령으로 끝나는 가벼운 작업이지만, struct가 100바이트라면 복사도 100바이트만큼 일을 합니다. 그래서 [4장](#4-실전-적용--struct로-써야-할-때-class로-써야-할-때) 가이드라인에서 "16바이트 미만"이라는 기준이 등장합니다.box Vector2— 박싱 명령어. 이 한 줄이 힙 할당을 일으킵니다. struct를 사용할 때 가장 조심해야 하는 명령어이며, [5장](#5-함정과-주의사항--박싱이라는-조용한-성능-암살자) 전체가 이 명령어 한 줄을 피하는 방법에 대한 내용입니다.initobj Vector2— "그 자리를 0으로 채워라".default나 매개변수 없는new Vector2()가 결국 같은 명령어로 컴파일된다는 뜻입니다. struct가 항상 0으로 시작할 수 있는 이유가 여기에 있습니다 — 별도의 생성자 호출이 아니라 메모리 0 채움으로 처리됩니다.
struct가 클래스 필드로 들어가면
struct를 class 안에 필드로 두면 어떻게 되는지가 또 하나의 핵심입니다.
public struct Vec { public float X, Y; }
public class Holder
{
public Vec Pos; // ← 이게 인라인인가 참조인가?
public string Name;
}
IL은 명확합니다.
.class public auto ansi beforefieldinit Holder
extends [System.Runtime]System.Object
{
.field public valuetype Vec Pos ; ← valuetype: 데이터가 그 자리에 박혀 있음
.field public string Name ; ← 참조: 8바이트 포인터만, 실제 string은 다른 힙
}
.field public valuetype Vec Pos. valuetype이라는 한 단어가 핵심입니다. Holder 객체가 힙에 만들어질 때 그 안에 Pos의 8바이트(X, Y)가 그대로 박혀 들어갑니다. 별도의 힙 할당이 추가로 일어나지 않습니다.
만약 Pos가 class였다면 Holder 안에는 8바이트짜리 참조만 들어가고, Pos의 진짜 데이터는 힙의 또 다른 자리에 별도로 할당됐을 겁니다 — 객체 하나 만드는 데 힙 할당이 두 번 일어나는 셈입니다. 1만 개의 Holder를 만들면 힙 객체가 2만 개가 되며, GC가 그만큼 더 일을 합니다. struct는 이 두 번째 할당을 없앱니다.
4. [실전 적용] — struct로 써야 할 때, class로 써야 할 때
마이크로소프트의 4가지 기준
표준 가이드라인은 다음 네 조건을 모두 만족할 때만 struct를 권장합니다.
- 단일 값을 논리적으로 표현할 때 (좌표·색상·금액처럼 그 자체가 작은 데이터 한 덩어리)
- 인스턴스가 16바이트 이하일 때 (참조 두 개 = 16바이트가 손익분기점)
- 불변(immutable)으로 만들 수 있을 때 (생성 후 필드를 바꾸지 않을 때)
- 빈번히 박싱되지 않을 때
하나라도 어긋나면 클래스가 더 안전합니다. 예를 들어 Player처럼 정체성(누가 누구냐)이 중요한 객체는 항상 클래스입니다 — 두 곳에서 같은 플레이어를 참조해야 하므로 복사 의미론은 오히려 버그를 만듭니다.
Before: 큰 struct를 매개변수로 넘김
// ❌ 32바이트 struct를 값으로 매번 복사
public struct BigState
{
public float A, B, C, D;
public float E, F, G, H; // float * 8 = 32바이트
}
public static float Sum(BigState s)
{
return s.A + s.B + s.C + s.D + s.E + s.F + s.G + s.H;
}
void Update()
{
BigState state = ReadFromSensors();
for (int i = 0; i < 1000; i++)
{
Sum(state); // 매 호출마다 32바이트 복사
}
}
Sum은 state를 읽기만 하지만, 호출할 때마다 32바이트가 통째로 스택에 복사됩니다. 이게 1000번이면 32KB를 의미 없이 옮기는 셈입니다.
After: 클래스로 바꾸거나, 작게 쪼개기
// ✅ 옵션 1: class로 — 8바이트 참조만 복사
public class BigState
{
public float A, B, C, D, E, F, G, H;
}
// ✅ 옵션 2: 16바이트 이하로 쪼개기
public struct Vec4
{
public float X, Y, Z, W; // 16바이트 — 캐시 라인 친화적
}
class로 바꾸면 메서드 호출 시 참조 8바이트만 스택에 올라갑니다. 다만 BigState를 new로 매번 만들면 GC 문제가 새로 생기니, 한 번 만들어 재사용하는 패턴과 함께 써야 합니다. struct로 유지하고 싶다면 16바이트 이하로 쪼개거나 in 매개변수(Sum(in BigState s))로 참조 전달을 하는 방식이 있습니다 — 후자는 후속 토픽에서 다룹니다.
Unity 실전: GC 스파이크 방지
Unity 모바일 빌드는 Boehm GC(.NET BCL의 generational GC와 달리, mark-and-sweep 한 번에 모든 힙을 훑는 단순한 GC)를 사용합니다. 한 번 깨어나면 게임 전체가 한 프레임 멈출 수 있어 다른 어떤 환경보다 GC 압력에 민감합니다. 그래서 Unity는 좌표·색상·물리 결과 같은 빈번한 데이터에 거의 모두 struct를 씁니다.
// Unity의 실제 정의 (요약)
public struct Vector3 { public float x, y, z; } // 12바이트
public struct Quaternion { public float x, y, z, w; } // 16바이트
public struct Color { public float r, g, b, a; } // 16바이트
public struct RaycastHit { /* point, normal, distance, ... */ }
public class Player : MonoBehaviour
{
void Update()
{
// ✅ Vector3는 struct — new 한 번에 GC 영향 없음
Vector3 next = transform.position + new Vector3(1, 0, 0);
transform.position = next;
// ✅ Raycast 결과도 struct — 호출당 힙 할당 0
if (Physics.Raycast(transform.position, Vector3.forward, out RaycastHit hit))
{
// hit.point, hit.normal 등 사용
}
}
}
여기서 new Vector3(1, 0, 0)는 IL 레벨에서 newobj를 호출하지만 힙 할당이 아닙니다. struct에 매개변수 있는 생성자를 호출하면 컴파일러는 변수 자리에 메모리를 잡고 그 위에서 생성자를 실행하는 IL을 생성합니다 — class의 newobj와 단어만 같을 뿐 메모리 동작이 다릅니다. 매 프레임 60번, 100명이 호출해도 GC는 깨어나지 않습니다.
만약 Vector3가 클래스였다면 같은 코드가 1초에 6,000개의 작은 힙 객체를 만들고 그만큼 GC를 자주 깨웠을 겁니다. 한 개발자가 자신의 게임에서 시야 안개(fog of war) 데이터 구조를 클래스에서 struct로 바꿨더니 메모리 성능이 비약적으로 향상됐다는 회고를 남긴 사례가 있습니다 — 이 글의 모든 내용이 그 한 줄짜리 변경 안에 압축되어 있습니다.
5. [함정과 주의사항] — 박싱이라는 조용한 성능 암살자
박싱은 언제 일어나는가
박싱(boxing) — 값 타입을 참조 타입으로 변환하는 작업 CLR이 스택의 struct 데이터를 힙에 새로 할당된 박스 객체로 복사한다. 그 결과로 힙 할당 1번 + 메모리 복사 1번 + 나중에 GC가 청소할 부담이 추가로 생긴다.
예시:object o = myStruct;이 한 줄에서 박싱 발생.
박싱이 일어나는 대표적인 세 가지 패턴입니다.
- struct를
object타입에 대입 - struct를 비제네릭 컬렉션(
ArrayList,Hashtable)에 추가 - struct를 인터페이스 타입 변수에 대입
겉보기에 평범한 대입문이지만, IL에는 box 명령어가 추가되고 그 한 줄이 곧 힙 할당입니다.
❌ 잘못된 패턴: ArrayList에 int 넣기
using System.Collections;
public static long Sum(int n)
{
ArrayList list = new ArrayList(); // 비제네릭 — 모든 원소를 object로 취급
long s = 0;
for (int i = 0; i < n; i++)
list.Add(i); // ← 여기서 int 박싱
foreach (object o in list)
s += (int)o; // ← 여기서 언박싱
return s;
}
int는 작은 struct(System.Int32)입니다. ArrayList.Add(object)의 매개변수 타입이 object이므로, 컴파일러는 int를 object로 바꾸는 박싱을 매번 삽입합니다. n이 10,000이면 힙 객체가 10,000개 생기고, 그만큼 GC 부담이 쌓입니다.
IL을 보면 정확히 잡힙니다.
.method public hidebysig static int64 Sum(int32 n)
{
.locals init (
[0] class ArrayList,
[1] int64,
[2] int32,
...
)
IL_0000: newobj instance void ArrayList::.ctor()
IL_0005: stloc.0
...
// 루프: list.Add(i)
IL_000d: ldloc.0
IL_000e: ldloc.2
IL_000f: box [System.Runtime]System.Int32 ; ← 박싱! 매 반복마다 힙 할당
IL_0014: callvirt instance int32 ArrayList::Add(object)
...
// foreach (object o in list) s += (int)o;
IL_0036: unbox.any [System.Runtime]System.Int32 ; ← 언박싱
}
box [System.Runtime]System.Int32가 루프 안에 들어 있습니다. 반복 횟수만큼 힙 할당이 발생합니다.
✅ 올바른 패턴: 제네릭 컬렉션 사용
using System.Collections.Generic;
public static long Sum(int n)
{
var list = new List<int>(); // 제네릭 — 타입이 int로 고정
long s = 0;
for (int i = 0; i < n; i++)
list.Add(i); // ← 박싱 없음, int 그대로 저장
foreach (int o in list)
s += o; // ← 언박싱 없음
return s;
}
같은 메서드의 IL입니다.
.method public hidebysig static int64 Sum(int32 n)
{
.locals init (
[0] class List`1<int32>,
[1] int64,
[2] int32,
[3] valuetype List`1/Enumerator<int32>,
[4] int32
)
IL_0000: newobj instance void List`1<int32>::.ctor()
...
// 루프: list.Add(i)
IL_000d: ldloc.0
IL_000e: ldloc.2
IL_000f: callvirt instance void List`1<int32>::Add(!0) ; ← box 없음
...
// foreach (int o in list) s += o;
IL_0027: call instance !0 List`1/Enumerator<int32>::get_Current()
; ← unbox.any 없음, int 그대로 반환
}
루프 안에 box가 단 한 번도 등장하지 않습니다. List<T>는 컴파일 시점에 타입이 int로 고정되므로 Add(int)가 그대로 호출됩니다 — 박싱이 일어날 자리가 없습니다.
Unity에서 자주 보는 박싱 함정
Unity 신입이 가장 자주 만드는 박싱은 string 보간입니다.
void Update()
{
int score = ComputeScore();
// ❌ score는 int(struct)인데 object로 박싱됨
Debug.Log("Score: " + score);
}
+ 연산자는 string + object로 해석됩니다. int(struct)가 object로 박싱되어 매 프레임 힙 할당 1회. 60FPS면 1초에 60번 GC를 조금씩 압박합니다. 해결책은 $"" 보간 문자열 또는 string.Format으로 박싱이 일어나지 않는 오버로드를 쓰는 것입니다 — 다만 이건 string 토픽에서 자세히 다룰 주제입니다.
핵심은 IL을 한 번이라도 들여다보면 어디서 box가 생기는지 눈에 들어온다는 점입니다. 개념과 특징·장단점을 외우려 하기보다 의심스러운 코드를 IL로 컴파일해 box/unbox를 검색하는 습관이 더 빠르게 통찰을 줍니다.
또 하나의 함정: struct를 readonly 필드에 두면 메서드 호출 시마다 복사
public struct Counter
{
private int _value;
public void Increment() { _value++; }
}
public class Holder
{
private readonly Counter _counter; // ← readonly가 함정의 시작
public void Tick()
{
_counter.Increment(); // 직관: _counter._value가 1 증가
// 실제: _counter는 readonly이므로
// 컴파일러가 임시 복사본을 만들어 그 위에서 Increment 호출
// → _counter._value는 그대로 0
}
}
readonly 필드의 메서드를 호출하면 CLR이 원본을 보호하기 위해 임시 복사본 위에서 메서드를 실행합니다. struct 내부 상태를 변경했다고 생각하지만 실제로는 복사본이 변경되고 즉시 버려집니다. 이 함정의 정공법이 readonly struct(C# 8 이후)입니다 — 후속 토픽에서 자세히 다룹니다. 입문 단계에서는 "struct를 가변(mutable)으로 만들지 말라"는 가이드라인을 기억하면 충분합니다. 좌표·색상처럼 만들고 끝나는 데이터에는 이 함정이 애초에 발생하지 않습니다.
6. [C# 버전별 변화]
struct 자체는 C# 1.0부터 존재한 가장 오래된 기능 중 하나입니다. "값 타입을 사용자가 정의한다"는 핵심 의미는 변하지 않았습니다. 다만 PART 7의 다음 토픽들에서 다루듯, struct를 더 안전하고 빠르게 쓰기 위한 부가 기능이 버전마다 추가됐습니다.
| 버전 | 추가 기능 | 의미 |
|---|---|---|
| C# 1.0 | 기본 struct |
값 타입 정의의 출발점 |
| C# 7.2 | readonly struct, in 매개변수 |
불변 struct 보장, 큰 struct도 참조로 전달 |
| C# 7.2 | ref struct |
스택 전용 struct (Span<T> 기반) |
| C# 8.0 | readonly 인스턴스 메서드 |
메서드 단위로 불변 보장 |
| C# 10 | record struct, 매개변수 없는 생성자 |
데이터 중심 struct, 명시적 기본값 |
| C# 11 | ref 필드, scoped |
안전한 ref struct 작성 |
이 글은 입문 단계에서 가장 처음 만나는 "그냥 struct 키워드"를 다뤘습니다. 위 표의 어느 행도 기본 struct의 의미를 바꾸지 않습니다 — 모두 기본 struct 위에 안전성·성능 보장을 더하는 추가 키워드입니다. 이 글의 1~5장 내용은 어느 C# 버전에서도 그대로 적용됩니다.
7. [정리]
- struct는 값 타입입니다. 변수 안에 데이터가 직접 들어가고, 대입은 통째로 복사됩니다. class(참조 타입)와 정반대 의미론입니다.
- 메모리 위치: 지역 변수면 스택에, 클래스의 필드면 그 클래스 안에 인라인으로, 배열의 원소면 배열 메모리 안에 빈틈없이 연속해서 놓입니다. 어느 경우에도 GC가 추적할 별도의 힙 객체가 생기지 않습니다.
new없이도 사용 가능:Vector2 v;로 변수 자리만 잡고 모든 필드를 직접 초기화하면 됩니다.default나new Vector2()는 모든 필드를 0(또는 null)으로 채우는initobj한 명령어로 컴파일됩니다.- 상속 불가: 컴파일러가 자동으로
sealed를 붙입니다. 모든 struct는System.ValueType을 상속하지만, 사용자가 임의의 상속 관계를 만들 수는 없습니다. 인터페이스 구현은 가능합니다. - MS 4가지 가이드라인: ① 단일 논리값, ② 16바이트 이하, ③ 불변, ④ 박싱 빈도 낮음 — 모두 만족할 때만 struct.
- 박싱 주의:
object·비제네릭 컬렉션·인터페이스 변수 대입 시box명령어가 삽입되고 힙 할당이 발생합니다. 제네릭(List<T>)을 쓰면 사라집니다. - Unity에서:
Vector3·Color·RaycastHit같은 빈번한 데이터는 모두 struct. 매 프레임 만들어도 GC를 깨우지 않습니다.
다음 토픽에서는 struct에 매개변수 없는 생성자를 직접 정의하는 C# 10의 새 기능을 다룹니다. 지금까지 "struct는 항상 0으로 시작한다"라고 정리했지만, 그 기본값을 바꾸고 싶을 때 어떤 일이 일어나는지가 다음 글의 출발점입니다.