C#

C# 객체지향 문법 [C#의 클래스 확장 - 구조체]

devrabbit22 2025. 6. 4. 21:04

'Object로부터 파생된 타입 관계'를 보면 기본 타입에서 숫자형과 char, bool 타입이 값 형식에 속한다.

참조 형식은 string, object와 class로 정의되는 모든 타입이 포함된다. 그런데 값 형식에도 class처럼 사용자 형식을 두려면?

구조체(Struct)를 사용하면 된다.

 

구조체는 클래스를 정의하는 문법과 매우 유사하다. 

class 예약어를 struct 예약어로 대체한다는 것과 함께 다음의 차이점이 있다.

  1.  인스턴스 생성을 new로 해도 되고, 안 해도 된다.
  2. 기본 생성자는 명시적으로 정의할 수 없다. - (C# 10 부터 궂체에도 기본 생성자를 정의할 수 있다.)
  3. 매개변수를 갖는 생성자를 정의해도 마치 기본 생성자가 있는 것처럼 C# 컴파일러에 의해 자동으로 지원된다. (클래스의 경우 포함되지 않는다.)
  4. 매개변수를 받는 생성자의 경우, 반드시 해당 코드 내에서 구조체의 모든 필드에 값을 할당해야 한다.

따라서 다음과 같이 struct를 정의해서 사용 가능하다.

구조체 인스턴스를 new로 생성하는 것의 의미

- 값 형식의 변수를 new로 생성하면 해당 변수의 모든 값을 0으로 할당하는 것과 동일한 효과를 갖는다. 

따라서 다음의 v1, v2, v3 변수는 같은 의미의 서로 다른 표현일 뿐이다.

Vector v1 = new Vector();

Vector v2;
v2.X = 0;
v2.Y = 0;

Vector v3 = new Vector(0, 0);

이 규칙은 구조체에만 해당하는 것이 아니다.

기본형도 동일하게 new로 할당할 수 있는데, 이 역시 같은 방식으로 해석될 수 있다.

//n1, n2, n3는 같은 표현
int n1 = new int();

int n2;
n2 = 0;

int n3 = 0;

값 형식의 변수를 다루면서 값 형식에 속하는 모든 타입은 기본적으로 메모리 상태가 0으로 초기화 된다.

그렇다면 개발자가 값을 명시적으로 할당하든 하지 않든 new로 생상한 인스턴스와 같은 상태인데 왜 굳이 명시적으로 0을 할당하는가?

  • 이는 C# 컴파일러의 규칙으로 발생하는 차이점 때문이다. C# 컴파일러는 개발자가 직접 코드상에서 값을 할당하지 않는 변수를 사용하는 것을 '오류'라고 판단한다. 이 때문에 다음 코드는 C# 컴파일러가 빌드할 때 오류를 발생시킨다.
int n;	//n은 0의 값을 가지고 있지만 개발자가 할당한 것은 아니다.

Console.WriteLine(n);	//컴파일 오류 발생

구조체가 클래스와 너무 유사해서 그 둘의 차이를 간과하면 안된다.

클래스는 참조형이고 구조체는 값 형식이라는 점을 잊으면 안된다.

깊은 복사와 얕은 복사

값 형식과 참조 형식의 결정적인 차이점

  • 인스턴스의 대입이 일어날 때 뚜렷해진다.
//두 가지 사용자 정의 타입
struct Vector
{
    public int X;
    public int Y;
}

class Point
{
    public int X;
    public int Y;
}
//각각 사용하는 경우를 예시로 한다.
Vector v1;

v1.X = 5;
v2.Y = 10;

Vector v2 =v1;	//값 형식의 대입

Point pt1 = new Point();
pt1.X = 6;
pt1.Y = 12;

Point pt2 = pt1;	//참조 형식의 대입

Vector 구조체는 값 형식이고, Point 클래스는 참조 형식이다. v1과 pt1이 대입된 v2, pt2의 변수 상태는 어떻게 변하는가?

'값 형식과 참조 형식의 차이점'을 struct와 calss에 그대로 적용해 볼 수 있다.

구조체와 클래스의 차이점

구조체는 인스턴스가 가진 메모리 자체가 복사되어 새로운 변수에 대입되는 것을 볼 수 있는데, 이를 다른 말로 깊은 복사(deep copy)라고 한다. 

참조 형식의 변수가 대입되는 방식을 일컬어 얕은 복사(shallow copy)라고 한다.

v1의 값을 v2에 대입하면 메모리상에서 깊은 복사가 일어나고 v2는 새로운 인스턴스를 가리키게 된다. 따라서 v2의 값을 바꿔도 그 변화가 v1과는 전혀 무관하게 이뤄진다. 하지만 얕은 복사가 일어나는 참조 형식은 이와 다른 결과를 낳는다.

pt1과 pt2는같은 메모리 상의 인스턴스를 가리키고 있으므로 둘 중 어느 하나라도 해당 필드의 값을 변경하면 변수의 결과값이 함께 변경된 것으로 보이는데 이런 규칙은 메서드에 인자로 넘길 때도 동일하게 적용된다.

값 형식의 v1 인스턴스는 메서드로 전달될 때 복제되어 또 다른 인스턴스가 생성되고 해당 인스턴스를 새롭게 vt 변수가 가리킨다.

따라서 Change 메서드 내에서 vt의 변수 값을 변경하는 것은 원래의 v1 변수에 영향을 미치지 않는다. 반면 참조 형식을 메서드에 전달하는 경우를 예시로 든다.

이 경우 pt1과 pt의 변수는 동일한 인스턴스를 가리킨다. 즉, 메서드에 넘겨지는 것은 변수가 가진 참조 주소일 뿐이다.

이 때문에 메서드에서 값을 변경하면 그 영향이 메서드를 호출한 측의 참조 변수에도 미친다.

 

깊은 복사의 장점은 값의 변경에 대한 간섭을 일으키지 않음으로써 개발자가 원했던 동작을 한다.

이 것은 참조 주소만을 전달함으로써 때때로 원치않는 값의 변경이 발생하는 얕은 복사의 단점을 설명한다.

하지만 때로는 그런 깊은 복사의 장점이 단점으로 바뀔 수 있다.

ex) 구조체가 내부에 많은 필드를 담게 되어 크기가 1,024바이트까지 커졌다고 가정하면, 해당 구조체 변수를 메서드에 전달할 때마다 컴퓨터는 1KB의 메모리 영역을 매번 복사하는 작업을 해야 하는 부담이 있다.

반면 그와 같은 내용을 클래스로 정의했다면 메서드를 호출할 때마다 참조 주소값만 복사하면 되므로 구조체와 비교해 월등한 성능 향상을 가져올 수 있다.

구조체와 클래스를 선택하는 기준

  1. 일반적으로 모든 사용자 정의 타입은 클래스로 구현한다.
  2. 깊은/얕은 복사의 차이가 민감한 타입은 선택적으로 구조체로 구현한다.
  3. 참조 형식은 GC(Garbage Collector)에 의해 관리받게 된다. 따라서 참조 형식을 사용하는 경우 GC에 부담이 되는데, 이런 부하를 피해야 하는 경우에는 구조체를 선택한다.

절대적인 기준, 강제적인 기준은 없다. 즉, 구조체로 정의하면 좋은 것을 클래스로 정의했다고 해서 C# 컴파일러가 오류를 발생시키지는 않는다. 따라서 적절한 선택은 개발자의 몫이다.

ref 예약어

얕은 복사와 깊은 복사의 동작 방식에 공통점이 하나 있다. 

  • 변수의 스택의 값은 여전히 복사된다는 점이다. 값 형식의 변수는 해당 변수가 실제 값을 가리키고 있고, 따라서 그 값이 복사되어 전달된다.
  • 반면 참조 형식의 변수는 힙에 존재하는 실제 데이터의 주소값을 가리키고 있으면서 따라서 그 주소값이 복사되어 전달된다.
  • 이렇게 '변수의 스택 값'이 복사되는 상황을 특별히 메서드의 인자 전달과 관련해 값에 의한 호출(CBV: Call By Value)라고 한다.

'참조에 의한 호출'이 어떤 의미를 갖는지 ref 예약어를 통해 알 수 있다.

ref 예약어는 두 군데에서 사용해야 한다.

  1. 메서드의 매개변수를 선언할 때 함께 표기해야한다.
  2. 해당 메서드를 호출하는 측에서도 명시해야 한다.

ref 예약어는 구조체를 클래스처럼 '얕은 복사'로 전달한 것과 동일한 효과를 낸다.

하지만 얕은 복사와 ref 예약어는 동작 방식에서 차이점이 있다.

ref를 사용하지 않았을 때는 전형적인 값 형식의 스택 복사가 있엇지만, 이를 사용하게 되면 메서드의 vt 변수가 호출 측의 v1 변수와 동일한 주소를 가리키게 된다

값 형식에 대한 ref의 동작 방식

기존의 얕은 복사와 깊은 복사는 변수의 스택 값이 복사되어 전달되었지만, v1 변수가 가리키고 있는 데이터의 주소값(0x1600)이 vt에도 그대로 전달되어 결국 같은 메모리의 주소를 가리키는 것을 확인할 수 있다.

참조형 변수를 ref 예약어로 전달한 효과를 구분하려면 특수한 예제가 필요하다.

결과로는 Change1의 출력 결과는 pt1: 인 공란으로 나오고 Change2의 출력결과는 pt1: X= 7, Y = 14의 값이 출력된다.

이 처럼 메서드의 호출 결과가 달라진다.

Change1의 메서드를 호출하면 참조 값이 또 다른 메모리에 복사되어 전달되므로 메서드 내에서의 new 메모리 할당이 원래의 pt1의 변수에 영향을 미치지 않는다.

얕은 복사로 전달된 참조값

ref 예약어와 함께 전달하는 경우에는 결과가 달라진다.

pt1 변수의 스택 주소 값이 직접 전달되므로 pt1과 pt 변수는 같은 곳을 가리키게 되고 change2의 메서드 내에서 new 할당이 그대로 원본 pt1 변수에도 반영된다.

ref 예약어로 같은 주소를 가리키는 변수

구조체와 클래스가 아닌 기본 자료형에도 '참조에 의한 호출'을 사용할 수 있다. 

위의 SwapValue 메서드에서 ref 예약어를 빼면 내부에서만 값이 바뀔 뿐 외부의 변수에 대해서는 값이 바뀌지 않는다.

 

메서드에 ref 인자로 전달되는 변수는 호출하는 측에서 반드시 값을 할당해야 한다.

할당될 값은 null이든 new든 상관없이 어떤 값이든 개발자가 지정하기만 하면 된다.

int value1;	//값이 없으므로 ref 인자로 전달할 수 없다.
string text = null;	//null값을 가지므로 ref 인자로 전달 가능하다.
int value2;
value2 = 5;	//메서드 호출 전에 값을 가진다면 ref 인자로 전달 가능

Vector vt;
vt.X = 5;	//X, Y가 포함된 Vector구조체에 Y값이 초기화되지 않았으므로 ref 인자로 부적절하다.
Vector vt2 = new Vector();	//X, Y 필드가 0으로 초기화되었기 때문에 ref 인자로 전달 가능하다.

out 예약어

참조에 의한 호출을 가능하게 하는 또 하나의 예약어이다.

out은 ref와 비교했을 때 몇 가지 차이점이 있다.

  1. out으로 지정된 인자에 넘길 변수는 초기화되지 않아도 된다. 초기화 되어 있더라도 out 인자를 받는 메서드에서는 그 값을 사용할 수 없다.
  2. out으로 지정된 인자를 받는 메서드는 반드시 변수에 값을 넣어서 반환해야 한다.

out 예약어가 사용되는 곳에 ref 예약어를 사용해 구현하는 것도 가능하다. 즉, out 예약어는 ref 예약어의 기능 가운데 몇 가지를 강제로 제한함으로써 개발자가 좀 더 특별한 용도로 사용하도록 일부로 제공된 것이다.

- 어떤 용도가 out 예약어를 사용하는 데 적합한가?

ex) 메서드는 단 1개의 반환값만 가질 수 있지만 out으로 지정된 매개변수를 사용함으로써 여러 개의 값을 반환할 수 있다.

특이하게 사칙연산 가운데 오직 나눗셈만 제약이 하나 있는데, 그것은 절대로 분자를 0으로 나눌 수 없다는 규칙이다.

따라서 나눗셈을 구현하는 메서드가 있다면 분모에 0인 경우에 대해 나눗셈 겨로가를 따로 반환하도록 코드를 작성해야 한다.

int Divide(int n1, int n2)
{
    if(n2 == 0)	//분모가 0이면 나눗셈 결과로 0을 반환
    {
        return 0;
    }
    return n1 / n2;
}

얼핏 보면 위에 메서드가 타당할 것 같지만, 0으로 나올 수 있는 올바른 연산 결과가 있다는 것을 감안했을 때 분모가 0인 경우도 동일한 값을 반환하는 것은 잘못된 구현이다.

이를 더 나은 코드로 개선하려면 나누기를 할 수 있는지 여부를 함께 불린형으로 반환해야 한다. 이는 구조체를 통해 구현할 수 있다.

struct DivideResult
{
     public bool Success;
     public int Result;
 }
 DivideResult Divide(int n1, int n2)
 {
     DivideResult ret = new DivideResult();
     
     if(n2 == 0)	//분모가 0이면 Success 필드를 false로 설정
     {
         ret.Success = false;
         return ret;
     }
     
     ret.Success = true;
     ret.Result = n1 / n2;
     return ret;
}

하지만 코드가 지저분해 보인다. 이런 상황을 out 예약어로 개선하면 다음과 같이 깔끔하게 정리된다.

bool Divide(int n1, int n2, out int result)
{
    if(n2 == 0)
    {
        result = 0;
        return false;
    }
    return = n1 / n2;
    return true;
}

//Divide 메서드 사용 예제
int quotient;
if(Divide(15, 3, out quotient) == true)
{
    COnsole.WriteLine(quotient);	//출력 결과 : 5
}

out 예약어가 '참조에 의한 호출'로 값을 넘기지 않는다면 위와 같은 구현은 가능하지 않다는 점을 알 수 있다.

out으로 지정된 result 변수는 메서드가 reutrn 하기 전에 반드시 초기화 되어 있어야 한다.

만약 5번째 줄의 result = 0;을 제거한다면 C# 컴파일러는 6번째 줄의 return 시점에 초기화되지 않았다는 이유로 오류를 발생시킨다. 이와 유사한 용도로 닷넷에서는 각 기본 타입에 TryParse라는 메서드를 제공한다.

//System.Int32 타입에 정의된 TryParse 정적 메서드
public static bool TryParse(string s, out int result);

이 메서드는 변환이 성공했는지 여부를 true/false로 반환하고, 변환이 성공했다면 out으로 지정된 result 변수에 값을 반환한다.

//TryParse의 예시
int n;
if(int.TryParse("1234567", out n) == true)	//System.Int32의 TryParse를 호출
{
    Console.WriteLine(n);	//출력 결과: 1234567
}

double d;
if(double.TryParse("12E3", out d) == true)	//double은 지수 표기법의 문자열도 지원
{
    Console.WriteLine(d);	//출력 결과: 12000
}

bool b;

if(bool.TryParse("true", out b) == true)	//bool 타입도 관련된 문자열 해석
{
    Console.WriteLine(b);	//출력 결과: True
}

short s;
if(short.TryParse("123456789", out s) == true)	//short의 범위를 초과: false를 반환
{
    Console.WriteLine(s);	//false가 반환되었으므로 실행되지 않는다.
}
if(short.TryParse("Not_a_number", out s) == true)	//숫자가 아니므로 false를 반환
{
    Console.WriteLine(s);	//false가 반환되었으므로 실행되지 않는다.
}

TryParse와 System.Object로부터 재정의된 ToString은 문자열과 타입 간의 변환에 있어 쌍을 이룬다.

즉, 다음과 같이 타입을 문자열로 변환하고, 그로부터 값을 복원하는 것이 가능하다.

int n = 500;
string text = n.ToString();	//int형 값을 문자열로 반환
int result;
int.TryParse(txt, out result);	//문자열로부터 int형 값을 복원

ref와 out의 정리

  • ref는 메서드를 호출하는 측에서 변수의 값을 초기화함으로써 메서드 측에 의미 있는 값을 전달한다. 
  • 반면 out은 메서드 측에서 반드시 값을 할당해서 반환함으로써 메서드를 호출한 측에 의미 있는 값을 반환한다. 
  • 하지만 ref도 out처럼 참조에 의한 전달이기 때문에 메서드 측에서 의미 있는 값을 호출하는 측에 전달할 수 있다.

- ref와 out의 특성을 IN, OUT 이라는 표현으로 간단하게 나타내기도 한다. ref는 값을 메서드 측에 전달(IN)하고 전달 받기(OUT)도 하기 때문에 [IN, OUT]특성을 띈다고 한다. 반면 out은 메서드 측으로부터 전달받기(OUT)만 가능하므로 [OUT] 특성을 띈다.


Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지