변수를 선언하면 무슨 일이 일어나는가
변수를 처음 배울 때 대부분 “값을 담는 상자”라고 배웁니다. 틀린 말은 아닌데,
이 설명으로는 뒤에 나오는 것들이 하나도 설명되지 않습니다.
int 는 왜 21억까지만 되는지, 선언만 하고 읽으면 왜 컴파일 에러인지,
0.1 + 0.2 는 왜 0.3 이 아닌지. 전부 따로 외워야 할 규칙이 됩니다.
정확하게 말하면 이렇습니다.
변수는 메모리의 한 자리에 붙인 이름입니다. 그 자리가 어디고 몇 바이트인지는 타입이 정합니다.
이 문장 하나면 위의 셋이 전부 같은 이유로 설명됩니다. 하나씩 그림으로 봅니다.
이름은 사람만 쓰는 것이다
우리가 쓴 C# 코드는 바로 실행되지 않습니다. 먼저 컴파일러라는 프로그램이 코드를 CPU 가 알아듣는 명령(기계어)으로 바꾸고, 그다음에 실제로 돌아갑니다. 코드를 바꾸는 때를 컴파일 시점, 프로그램이 돌아가는 때를 실행 시점이라고 부릅니다.
int age = 25; 라고 쓰면 컴파일러는 두 가지를 합니다.
age라는 이름에 메모리 한 자리를 배정합니다. 지역 변수(메서드의{ }안에서 선언한 변수)라면 그 메서드의 스택 프레임 안 몇 번째 바이트인지가 여기서 정해집니다. 스택 프레임은 메서드 하나가 실행되는 동안 지역 변수를 두려고 잡아 두는 메모리 구역입니다. 메서드가 끝나면 통째로 돌려줍니다.- 그 자리의 크기를 4바이트로 잡습니다.
int가 하는 일이 이겁니다.
여기서 중요한 게 하나 있습니다. age 라는 이름은 컴파일이 끝나면 사라집니다.
기계어에는 이름이 없고 “스택 프레임 시작에서 몇 바이트 떨어진 자리” 만 남습니다.
이름은 사람이 읽으려고 붙인 것이지, CPU 가 쓰는 게 아닙니다.
CPU 는 계산만 합니다. 값은 전부 메모리에 있고, CPU 는 자리로 찾아가서 읽고 씁니다.
그럼 이름은 왜 필요한가
계산한 값을 기억해 두었다가 나중에 다시 쓰거나 바꾸려면 그 값이 놓인 자리를 가리킬 방법이 있어야 합니다.
이름이 없으면 “스택 프레임 시작에서 12바이트 떨어진 곳” 같은 위치를 사람이 직접 기억해야 합니다.
변수는 그 위치를 컴파일러가 대신 관리하게 하고, 우리는 age 같은 이름으로만 부르게 해 줍니다.
선언 · 할당 · 사용은 시점부터 다르다
한 줄에 같이 쓰다 보니 하나로 보이지만, 셋은 일어나는 시점부터 다릅니다. 타입을 바꿔가며 보세요. 잡히는 칸 수가 그대로 바뀝니다.
- 선언은 컴파일 시점입니다. “이 자리는
age것” 이라고 정해질 뿐, 실행 중에 아무 일도 일어나지 않습니다. - 할당은 실행 시점입니다. 그 자리에 비트를 씁니다.
=는 “같다” 가 아니라 “오른쪽을 계산해서 왼쪽 자리에 써라” 는 명령입니다. - 사용도 실행 시점입니다. 그 주소에서 4바이트를 읽어 옵니다. 읽는다고 칸이 비지 않습니다.
실제로는 실행 직전에 코드를 기계어로 바꾸는 JIT 이 자주 쓰는 지역 변수를 메모리 대신 CPU 안의 아주 작은 저장 칸(레지스터)에 올려두기도 하고, 그러면 이 칸이 아예 안 생길 수도 있습니다. 그래도 “이름 하나에 자리 하나, 크기는 타입이 정한다” 는 모델은 그대로 맞습니다. 이 글의 그림은 그 모델입니다.
선언만 하고 읽으면 왜 에러인가
위 그림에서 선언 단계의 칸이 00 이 아니라 ?? 인 걸 보세요. 이게 핵심입니다.
선언은 자리를 정할 뿐 아무것도 쓰지 않습니다. 그러니 그 자리에는 이전에 그 메모리를 쓰던 무언가가 남아 있습니다. 0일 수도 있고 아닐 수도 있습니다.
int age;
Console.WriteLine(age); // CS0165: 할당되지 않은 지역 변수 'age'를 사용했습니다
C# 은 이걸 컴파일 단계에서 막습니다. 실행해보고 이상하면 알려주는 게 아니라, 아예 빌드가 안 됩니다. 값이 뭐가 나올지 아무도 모르는 코드를 애초에 못 만들게 하는 겁니다.
이 규칙은 지역 변수에만 적용됩니다. 클래스 바로 안에 선언한 변수(필드)는 객체가 만들어질 때 프로그램을 실행해 주는 쪽(런타임)이 그 메모리를 전부 0으로 채워 주기 때문에 선언만 하고 읽어도 됩니다. 지역 변수는 스택 프레임 안에 잡히고, 그 자리는 아무도 청소해주지 않습니다.
크기가 범위를 정한다
비트(bit) 는 0 또는 1 하나입니다. 8개 모이면 바이트(byte) 입니다.
칸을 직접 눌러 보세요. 각 칸이 맡은 자릿값이 아래에 적혀 있습니다.
전부 켜면 255가 나오는데, 그게 byte 의 최댓값입니다.
“자동으로 세기” 를 눌러 두고 보세요. 255 다음에 256이 아니라 0으로 돌아갑니다. 자릿수가 8개로 정해진 이상 값은 돌 수밖에 없습니다. 시계의 12시 다음이 1시인 것과 같습니다.
여기서 알아야 할 건 숫자가 아니라 어디서 나온 숫자인가 입니다.
byte가 0~255 인 건 누가 정한 규칙이 아니라, 8비트로 만들 수 있는 조합이 28 = 256가지라서 나오는 계산 결과입니다.
int 도 똑같습니다. 4바이트 = 32비트 = 232 = 약 42억 가지입니다.
int 는 그중 절반을 음수에 쓰기 때문에 약 -21억 ~ 21억이 됩니다. 처음에 물었던 “왜 21억까지만” 의 답이 이것입니다.
int 도 byte 처럼 최댓값(2,147,483,647)에 1을 더하면 넘친 만큼 돌아서 음수 끝으로 갑니다. 이것을 오버플로라고 부릅니다.
같은 비트가 65도 되고 ’A’도 된다
위 그림에서 65를 만들어 두면 문자로 읽으면 칸에 A 가 떠 있습니다.
메모리에는 0과 1밖에 없고, 그걸 무엇으로 읽을지는 타입이 정합니다.
값을 이루는 비트는 완전히 같습니다. int 는 앞에 0을 24개 더 채워 4바이트로 두고,
char 는 8개 더 채워 2바이트로 둘 뿐입니다. 다른 건 그 비트를 무엇으로 읽느냐입니다.
그래서 타입은 세 가지를 한꺼번에 정합니다.
타입 = 크기 + 해석 + 허용 연산
bool 에 - 1 을 못 하는 이유도 여기 있습니다. 크기와 해석이 정해지면
그 위에서 할 수 있는 연산도 따라서 정해집니다.
기본 타입에는 무엇이 들어가나
타입은 크기만 정하는 게 아니라 무엇을 담는지도 정합니다. 종류별로 나눠서 보겠습니다.
정수 (소수점이 없는 수)
3, -42, 0 처럼 딱 떨어지는 수입니다. 개수를 세거나 번호를 매길 때 씁니다.
| 타입 | 크기 | 담을 수 있는 값 | 예시 |
|---|---|---|---|
byte |
1바이트 | 0 ~ 255 | byte hp = 100; |
short |
2바이트 | -32,768 ~ 32,767 | short level = 30; |
int |
4바이트 | 약 -21억 ~ 21억 | int score = 12000; |
long |
8바이트 | 약 -922경 ~ 922경 | long userId = 8_400_000_000L; |
기본은 int 입니다. 그냥 10 이라고 쓰면 int 로 봅니다.
long 값을 쓸 때 뒤에 L 을 붙이는 이유가 이것입니다.
실수 (소수점이 있는 수)
0.5, 3.14, -0.001 처럼 소수점이 붙는 수를 실수라고 부릅니다.
수학에서 쓰는 그 말이고, 프로그래밍에서는 “소수점이 있는 수” 정도로 보면 됩니다.
거리 · 속도 · 확률 · 시간처럼 딱 떨어지지 않는 값에 씁니다.
| 타입 | 크기 | 담을 수 있는 값 | 예시 |
|---|---|---|---|
float |
4바이트 | 믿을 수 있는 자릿수 약 7자리 | float speed = 5.5f; |
double |
8바이트 | 믿을 수 있는 자릿수 약 15~16자리 | double pi = 3.14159; |
decimal |
16바이트 | 약 28자리, 돈 계산용 | decimal price = 1200.50m; |
여기서 “범위” 대신 “믿을 수 있는 자릿수” 라고 쓴 게 눈에 걸릴 겁니다.
정수와 달리 실수는 범위 안에 있어도 값이 정확하지 않을 수 있습니다.
이것도 크기가 정해져 있어서입니다. 0.1 을 2진수로 바꾸면 소수점 아래가 끝없이 이어지는데,
double 은 8바이트 안에 담아야 하니 중간에서 잘라 가장 가까운 값을 저장합니다.
Console.WriteLine(0.1 + 0.2); // 0.30000000000000004
0.1 도 0.2 도 아주 조금씩 어긋난 채 저장돼 있어서, 더하면 그 어긋남이 드러납니다.
그래서 실수는 == 로 정확히 같은지 비교하지 않고, 돈처럼 소수점까지 정확해야 하는 값은 10진수로 저장하는 decimal 을 씁니다.
기본은 double 입니다. 그냥 3.14 라고 쓰면 double 로 봅니다.
그래서 float 에 넣을 때는 5.5f 처럼 f 를, decimal 은 m 을 붙여야 합니다.
참과 거짓
| 타입 | 크기 | 담을 수 있는 값 | 예시 |
|---|---|---|---|
bool |
1바이트 | true 또는 false |
bool isAlive = true; |
값이 둘뿐이라 1비트면 충분한데도 1바이트를 씁니다. 메모리를 비트 단위로 잘라 쓰는 것보다 바이트 단위로 다루는 게 훨씬 빠르기 때문입니다.
문자와 글
| 타입 | 크기 | 담을 수 있는 값 | 예시 |
|---|---|---|---|
char |
2바이트 | 문자 하나 | char grade = 'A'; |
string |
정해져 있지 않음 | 글자들 | string name = "손오공"; |
여기 함정이 하나 있습니다. 따옴표가 다릅니다.
char c = 'A'; // 작은따옴표. 문자 하나
string s = "A"; // 큰따옴표. 글자 하나짜리 글
char x = "A"; // 컴파일 에러
'A' 와 "A" 는 눈에 거의 같아 보이지만 다른 타입입니다.
char 는 한 칸이고 string 은 글자들의 묶음입니다.
string만 크기가 “정해져 있지 않음” 인 게 눈에 띌 겁니다. 글자 수에 따라 달라지기 때문이고, 그래서 변수 자리에는 값이 아니라 “그 글이 어디에 있는지”(주소, 64비트 환경에서 8바이트)가 들어갑니다. 글자들은 메모리의 다른 곳에 따로 놓입니다. 지금까지 본 타입들과 구조가 다릅니다.
외울 필요는 없습니다
코드로 물어보면 됩니다.
Console.WriteLine(sizeof(int)); // 4
Console.WriteLine(int.MaxValue); // 2147483647
Console.WriteLine(byte.MaxValue); // 255
그러면 무조건 큰 걸 쓰면 되지 않나
값 하나만 보면 안 될 것도 없습니다. 개수가 많아질 때 이야기가 달라집니다.
체력이 0~100 이면 byte 한 바이트로 충분한데 long 을 쓰면 8배를 씁니다.
그렇다고 처음부터 다 줄이라는 뜻은 아닙니다. 기준은 이렇습니다.
기본은
int와double. 줄이는 것은 이유가 생겼을 때만.
int 가 기본인 이유는 요즘 CPU 가 32비트 단위 연산에 맞춰져 있어서
short 나 byte 로 계산해도 더 빨라지지 않기 때문입니다.
줄여서 이득을 보는 건 위 계산기처럼 개수가 아주 많을 때, 메모리 쪽입니다.
정리
- 변수는 메모리 한 자리에 붙인 이름이다. 이름은 컴파일이 끝나면 사라진다.
- 선언은 컴파일 시점에 자리와 크기를 정하는 일. 아무것도 쓰지 않는다.
- 할당은 실행 시점에 그 자리에 비트를 쓰는 일.
=는 “써라” 는 명령이다. - 쓰지 않은 자리에는 이전 값이 남아 있다. 그래서 C# 이 읽기를 막는다.
- 타입은 크기와 해석과 허용 연산을 한꺼번에 정한다.
- 범위는 규칙이 아니라 비트 개수에서 나오는 계산 결과다.