C#LAB
C# 2.7 · 2부. 객체지향

정적 멤버와 구조체

객체마다 두지 않는 것, 그리고 값으로 다루는 형식입니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 기초
개념 설명

객체마다 두지 않는 것

지금까지 만든 멤버는 모두 객체마다 하나씩 있었습니다. 학생이 셋이면 이름도 셋입니다. 그런데 학생이 몇 명인지 같은 값은 학생 개개인의 것이 아니라 학생 전체에 하나만 있으면 됩니다.

static 을 붙이면 그 멤버는 객체가 아니라 클래스 자체에 귀속됩니다. 객체를 만들지 않고 클래스이름.멤버 로 곧바로 사용합니다. 사실 우리는 이미 써 왔습니다 — Console.WriteLine 도, int.Parse 도 정적 멤버입니다.

이 단원의 나머지 절반은 구조체(struct)입니다. 클래스와 생김새가 거의 같지만 값 형식이라는 점이 다릅니다. 1.3에서 본 값 형식과 참조 형식의 차이가 여기서 완성됩니다.

최소 예제 · 편집 가능

객체 없이 부르는 멤버

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
var a = new Student("홍길동");
var b = new Student("김철수");

Console.WriteLine(a.Name);
// 객체가 아니라 클래스에 물었습니다
Console.WriteLine(Student.Count);

class Student {
    // 모든 객체가 함께 사용하는 값 — 클래스에 하나뿐입니다
    public static int Count { get; private set; }

    // 객체마다 하나씩 있는 값
    public string Name { get; }

    public Student(string name) {
        Name = name;
        Count++;
    }
}
출력
홍길동 2

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

a.Name객체에 물었고 Student.Count클래스에 물었습니다. a.Count 라고 적으면 컴파일되지 않습니다 — 그 값은 객체의 것이 아니기 때문입니다.

정적 메서드 안에서는 객체의 멤버를 사용할 수 없습니다. 어느 객체인지 정해져 있지 않으니 당연합니다. 반대로 객체의 메서드에서 정적 멤버를 보는 것은 됩니다.

상세 사용법

객체를 만들 일이 없는 클래스

도구 모음처럼 담아 둘 값 없이 하는 일만 모아 둔 클래스가 있습니다. 그런 클래스는 객체를 만들 까닭이 없는데, 그렇다고 못 만들게 막을 길도 없었습니다. static class 로 적으면 아예 만들 수 없게 됩니다.

static class MathHelper {
    public static int Square(int n) => n * n;
}

Console.WriteLine(MathHelper.Square(5));   // 25
var h = new MathHelper();               // 컴파일 오류

MathConsole 이 그렇게 만들어져 있습니다. 안에 담는 멤버는 모두 정적이어야 하고, 물려받을 수도 물려줄 수도 없습니다.

다만 정적 필드는 조심해서 사용하세요. 프로그램 전체가 함께 사용하는 값이라, 어디서 바뀌었는지 알기 어렵고, 테스트할 때도 앞선 테스트가 남긴 값이 뒤에 영향을 줍니다. 2.6에서 본 대로 값은 되도록 객체 안에 두고 필요한 것은 밖에서 받는 편이 낫습니다.

값으로 다루는 형식, 구조체

struct 는 클래스와 적는 모양이 거의 같습니다. 다른 것은 값 형식이라는 점 하나인데, 그 하나가 움직임을 통째로 바꿉니다.

class — 위치가 복사됩니다
var a = new PointClass();
a.X = 1;

var b = a;
b.X = 99;

Console.WriteLine(a.X);   // 99
// 둘이 같은 것을 가리킵니다

class PointClass {
    public int X;
}
struct — 값이 복사됩니다
var a = new PointStruct();
a.X = 1;

var b = a;
b.X = 99;

Console.WriteLine(a.X);   // 1
// 서로 다른 값입니다

struct PointStruct {
    public int X;
}

글자 하나(classstruct)만 달라졌는데 결과가 뒤집힙니다. 1.3에서 배열로, 2.1에서 클래스로 보았던 그 차이입니다. 다른 변수에 담을 때, 메서드에 넘길 때, 목록에 넣을 때 모두 이렇게 움직입니다.

classstruct
담을 때위치가 복사됩니다값이 복사됩니다
상속물려받고 물려줍니다할 수 없습니다(인터페이스는 됩니다)
기본값null모든 멤버가 기본값인 것
알맞은 자리대부분작고 잘 바뀌지 않는 값

고민되면 클래스입니다. 구조체는 좌표·색·금액처럼 작고, 값 그 자체이며, 잘 바뀌지 않는 것에 알맞습니다. 크거나 자주 바뀌는 것을 구조체로 두면 복사가 잦아 오히려 느려집니다.

바뀌는 구조체가 위험한 까닭

구조체에 set 을 두어 값을 바꿀 수 있게 만들면, 같은 한 줄이 담긴 자리에 따라 되기도 하고 안 되기도 합니다. 클래스였다면 넷 다 그냥 되는 코드입니다.

struct Movable { public int X { get; set; } }

var a = new Movable();
a.X = 10;          // 됩니다 — 지역 변수

var arr = new Movable[1];
arr[0].X = 10;     // 됩니다 — 배열은 자리를 직접 가리킵니다

list[0].X = 10;    // 컴파일 오류 — List 는 복사본을 내줍니다
obj.Pos.X = 10;    // 컴파일 오류 — 프로퍼티도 복사본을 내줍니다

아래 둘이 막히는 것은 고쳐 봐야 복사본만 바뀌기 때문입니다. 아무 일도 일어나지 않는 코드가 조용히 지나가는 것보다 낫다고 보아 컴파일러가 막는 것입니다. 굳이 바꾸려면 통째로 갈아 끼워야 합니다.

obj.Pos = new Movable { X = 10 };   // 통째로 담는 것은 됩니다

바뀌지 않는 구조체

그래서 구조체는 만든 뒤로 바뀌지 않게 두는 편이 안전합니다. 어디서도 바꿀 수 없으면 어디서 되는지 따질 일도 없어집니다. C# 7.2부터는 그것을 아예 못박을 수 있습니다.

readonly 를 붙이면 안의 모든 값이 읽기 전용이 됩니다. 담을 수 있는 곳은 생성자뿐이고, 그 뒤로는 밖에서든 자기 메서드 안에서든 바꿀 수 없습니다. 붙이지 않으면 아래 Loose 처럼 안에서 슬쩍 바꾸는 코드가 생길 수 있는데, 그것이 바로 헷갈림의 씨앗입니다.

위 예제에는 잠금이 두 겹 걸려 있습니다. { get; }밖에서 대입하는 것을 막고 (p.X = 10readonly 없는 구조체에서도 컴파일되지 않습니다), readonly자기 메서드 안에서 바꾸는 것까지 막습니다. 앞의 Loose 가 바로 그 빠져나갈 구멍이었습니다.

감춘다고 빠져나갈 수 있는 것도 아닙니다. 안에 비공개 필드를 두고 메서드로 바꾸려 해도, readonly struct 는 인스턴스 필드 자체에 readonly 를 요구하므로 필드를 선언하는 자리에서 걸립니다. 이것은 밖에서 못 바꾸게 하는 장치가 아니라 이 형식은 통째로 바뀌지 않는다는 선언이기 때문입니다.

그러면 값을 바꾼 것이 필요할 때는 어떻게 할까요. 바꾸는 대신 새로 만듭니다. 1.9에서 본 문자열이 그랬듯이, 바뀌지 않는 형식은 늘 이 방식으로 다룹니다.

// 고치는 것이 아니라 고쳐진 것을 새로 내줍니다
public Point WithX(int newX) => new Point(newX, Y);

var p2 = p1.WithX(99);   // p1 은 그대로입니다

// readonly 가 없으면 안에서는 바꿀 수 있습니다
struct Loose {
    public int X;
    public void Move() { X = 99; }   // 됩니다 — 어느 복사본을 바꾼 걸까요
}

// readonly 를 붙이면 생성자 뒤로는 안에서도 못 바꿉니다(C# 7.2+)
readonly struct Point(int x, int y) {
    public int X { get; } = x;
    public int Y { get; } = y;

    // public void Move() { X = 99; }   ← 컴파일 오류
}

// 값끼리 견주고 찍어 내는 일까지 얹어 줍니다(C# 10.0+)
readonly record struct Money(int Amount, string Currency);

var m1 = new Money(1000, "KRW");
var m2 = new Money(1000, "KRW");

Console.WriteLine(m1 == m2);   // True — 담긴 값이 같으면 같습니다

record struct 는 담긴 값이 같으면 같은 것으로 보고, 화면에 찍을 때 내용을 알아보기 좋게 내줍니다. 손으로 적어야 했던 것들을 대신 만들어 주므로, 값을 담기만 하는 구조체라면 이쪽이 짧습니다.

record 는 구조체와 짝지어야만 사용하는 것이 아닙니다. 그냥 record 라고 적으면 클래스이고(C# 9.0), 값 형식으로 두고 싶을 때 뒤에 struct 를 붙입니다.

record User(string Name);                  // 클래스 — 위치가 복사됩니다
record struct Point(int X, int Y);        // 구조체 — 값이 복사됩니다
readonly record struct Money(int Amount);   // 구조체 — 바뀌지도 않습니다

record 가 주는 것은 값끼리 견주는 방식과 짧은 선언이고, class 인지 struct 인지는 그것과 별개로 담을 때 복사되는 방식을 정합니다. record 자체는 3부에서 자세히 다룹니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
6.0
7.2
8.0
9.0
10.0
12.0
14.0
  • 1.0static 멤버 · struct
  • 2.0static class — 만들 수 없는 클래스
  • 7.2readonly struct — 통째로 바뀌지 않게
  • 10.0record struct · 구조체의 매개 변수 없는 생성자
  • 12.0구조체에도 기본 생성자
C# 1.0+

staticstruct 는 처음부터 있었습니다. 뒤에 더해진 것은 대부분 구조체를 안전하게 사용하는 방법입니다 — 통째로 바뀌지 않게 못박는 readonly struct(C# 7.2), 값끼리 견주는 일을 대신해 주는 record struct(C# 10.0)가 그렇습니다. 구조체는 복사되어 다니는 성질 때문에 바뀌지 않을 때 가장 다루기 쉽다는 것을 언어가 거들어 온 셈입니다.

실습 문제

직접 해보기

1. 몇 번이나 만들어졌는지 세기 난이도 하

Ticket 클래스를 만들어 표가 만들어질 때마다 번호를 1씩 올려 붙이고, 지금까지 몇 장이 나갔는지를 객체 없이 물어볼 수 있게 해보세요.

표 전체에 하나만 있으면 되는 값은 static 입니다. 번호는 표마다 다르므로 정적이 아닙니다.
var t1 = new Ticket(); var t2 = new Ticket(); var t3 = new Ticket(); Console.WriteLine(t2.Number); // 2 Console.WriteLine(Ticket.Issued); // 3 class Ticket { // 표 전체에 하나 public static int Issued { get; private set; } // 표마다 하나 public int Number { get; } public Ticket() { Issued++; Number = Issued; } }
2. 복사되는 좌표 난이도 중

좌표를 담는 Point구조체로 만들고, 그것을 메서드에 넘겨 안에서 값을 바꿔보세요. 그리고 넘긴 쪽의 값이 어떻게 되었는지 출력해 확인해보세요.

1.8에서 "넘긴 값은 복사되어 건너간다" 고 했습니다. 구조체는 값 형식이므로 그 말이 그대로 적용됩니다 — 안에서 바꿔도 밖은 그대로입니다.
var p = new Point(); p.X = 10; Move(p); Console.WriteLine(p.X); // 10 — 바뀌지 않았습니다 void Move(Point point) { point.X = 99; // 복사본을 바꾼 것입니다 } struct Point { public int X; public int Y; }
요약
  • static 은 객체가 아니라 클래스에 귀속됩니다. 클래스 이름으로 부릅니다.
  • 정적 메서드 안에서는 객체의 멤버를 사용할 수 없습니다. 어느 객체인지 정해져 있지 않습니다.
  • struct값 형식이라 담을 때 값이 복사됩니다. 클래스와 반대입니다.
  • 고민되면 클래스입니다. 구조체는 작고 잘 바뀌지 않는 값에만 사용합니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.