안녕하세요! 이번 시간에는 Unity 개발에서 정말 중요한 구조체(Struct)와 클래스(Class)의 차이점, 특히 메모리에서 어떻게 동작하고 어떻게 올바르게 활용해야 하는지에 대해 알아보겠습니다. 🤔
가장 먼저, 구조체와 클래스가 무엇인지 명확히 이해하는 것이 중요해요.
구조체 (Struct)
값 타입 (Value Type) 입니다.
데이터를 변수 자체에 직접 저장해요.
메모리: 주로 스택(Stack) 영역에 할당됩니다 (함수 내 지역 변수 등).
복사: 변수를 다른 변수에 할당하거나 함수 인자로 전달할 때, 데이터 전체가 복사되어 독립된 복사본이 생성됩니다.
특징: 작고 단순한 데이터를 묶을 때 적합하며, 가비지 컬렉터(GC)의 부담이 적다는 장점이 있습니다.
클래스 (Class)
참조 타입 (Reference Type) 입니다.
실제 데이터는 힙(Heap) 메모리에 저장되고, 변수는 이 데이터가 저장된 힙 메모리의 주소를 가지고 있어요.
메모리: 실제 데이터는 힙(Heap) 영역에 할당되며, 변수는 이 힙 메모리 주소를 스택에 저장합니다.
복사: 변수를 다른 변수에 할당하거나 함수 인자로 전달할 때, 메모리 주소만 복사됩니다. 즉, 여러 변수가 동일한 원본 데이터를 가리키게(공유하게) 됩니다.
특징: 크고 복잡한 객체, 상속과 같은 객체지향 프로그래밍 기능을 활용할 때 주로 사용되며, GC에 의해 관리됩니다.
값 타입과 참조 타입이 메모리에서 어떻게 동작하는지 예시를 통해 살펴볼게요.
값 타입 (구조체, 기본 자료형)
스택 메모리에 값이 직접 할당됩니다.
C#
int a = 100;
int b = a; // b에는 a의 값 100이 '복사'되어 저장됩니다.
a = 200; // a를 변경해도 b는 여전히 100입니다.
구조체도 동일한 방식으로 동작합니다. StructB = StructA; 코드가 실행되면, StructB는 StructA의 모든 멤버 값을 그대로 복사하여 가집니다.
참조 타입 (클래스)
new 키워드를 사용하면 힙 메모리에 객체(인스턴스)가 생성되고, 변수는 이 객체의 힙 메모리 주소를 저장합니다.
C#
MyClass classA = new MyClass();
classA.value = 10;
MyClass classB = classA; // classB는 classA와 동일한 힙 객체의 '주소'를 가리킵니다.
classB.value = 20; // classB를 통해 값을 변경하면,
// classA가 가리키는 객체의 값도 20으로 변경됩니다.
그렇다면 유니티 프로젝트에서는 언제 구조체를 쓰고, 언제 클래스를 써야 할까요?
구조체 사용이 권장되는 경우 👍
데이터 크기가 작을 때: Vector3, Quaternion, Color, Bounds 와 같이 가볍고 작은 데이터 묶음에 적합합니다.
복사 비용이 크지 않고, 독립적인 데이터가 필요할 때: 값이 복사되므로 원본 데이터에 영향을 주지 않고 독립적으로 사용할 수 있습니다.
빈번한 생성/소멸로 GC 부담을 줄이고 싶을 때: 스택 메모리는 함수(스코프)가 종료되면 자동으로 정리되므로 GC 부담이 적습니다.
주의사항:
구조체의 크기가 너무 크면 복사 시 성능 저하가 발생할 수 있습니다.
함수 인자로 자주 넘기면 복사 오버헤드가 커질 수 있습니다.
클래스 사용이 권장되는 경우 🌟
데이터 크기가 크거나 복잡한 상태를 가질 때: 많은 정보를 담거나 복잡한 로직을 수행하는 객체에 적합합니다.
여러 곳에서 동일한 데이터를 참조하고 공유해야 할 때: MonoBehaviour를 상속받는 컴포넌트나 GameObject처럼 여러 시스템에서 접근하고 수정해야 하는 데이터에 유리합니다.
상속 등 객체지향적 설계가 필요할 때: 클래스는 상속, 다형성 등 OOP의 강력한 기능을 제공합니다.
주의사항:
힙 메모리 할당은 스택보다 상대적으로 느립니다.
GC의 관리 대상이므로, 불필요하게 자주 new 키워드로 객체를 생성하면 GC의 작업량이 늘어나 성능에 영향을 줄 수 있습니다 (특히 Update 루프 내에서의 잦은 할당).
구조체와 클래스를 이해하는 데 있어 메모리 관리 지식은 필수입니다.
스택(Stack) vs 힙(Heap)
스택:
할당/해제가 매우 빠르고 간단합니다.
함수나 특정 코드 블록(스코프)이 종료되면 해당 영역에 할당된 메모리가 자동으로 정리됩니다.
크기에 제한이 있습니다.
힙:
크고 유연한 메모리 공간을 제공합니다.
가비지 컬렉터(GC)에 의해 관리됩니다.
할당/해제 비용이 스택보다 상대적으로 큽니다.
메모리 파편화(Fragmentation)가 발생할 수 있습니다.
박싱 (Boxing) 과 언박싱 (Unboxing) 🎁
박싱: 값 타입(스택) 데이터를 참조 타입(object 등 힙)으로 변환하는 과정입니다. 이 과정에서 힙 메모리 할당이 발생합니다.
C#
int myValue = 10;
object boxedValue = myValue; // myValue(값 타입)가 object(참조 타입)로 박싱됨
언박싱: 박싱된 참조 타입 데이터를 다시 원래의 값 타입으로 변환하는 과정입니다.
주의점: 불필요한 박싱/언박싱은 성능 저하와 GC 부담을 유발하므로 주의해야 합니다. 예를 들어, Debug.Log(myValue)와 같이 값 타입을 object 타입 매개변수를 받는 함수에 전달하면 내부적으로 박싱이 일어날 수 있습니다.
가비지 컬렉터 (GC: Garbage Collector) 🧹
더 이상 참조되지 않는 힙 메모리(사용하지 않는 객체)를 자동으로 찾아 수거하여 재사용 가능한 공간으로 만듭니다.
씬(Scene) 전환 시: 일반적으로 씬이 전환되면 해당 씬에서 사용되던 객체들의 참조가 끊겨 GC의 수거 대상이 됩니다.
참조가 유지되는 경우: DontDestroyOnLoad로 지정된 객체, static 변수, 싱글톤 패턴으로 관리되는 객체 등은 씬이 바뀌어도 참조가 유지되어 GC에 의해 수거되지 않을 수 있습니다.
순환 참조 (Circular Reference) 🔗
두 개 이상의 객체가 서로를 참조하여, 실제로는 사용되지 않음에도 불구하고 GC가 이들을 수거하지 못하는 상태를 말합니다.
C#
class A { public B bInstance; }
class B { public A aInstance; }
A objA = new A();
B objB = new B();
objA.bInstance = objB;
objB.aInstance = objA;
// objA와 objB는 서로를 참조하여 GC 대상에서 제외될 수 있음 (메모리 누수 발생)
결과: 메모리 누수(Memory Leak)가 발생하여 애플리케이션 성능 저하 또는 예기치 않은 종료를 유발할 수 있습니다.
"구조체와 클래스의 차이점은 무엇인가요?" 라는 면접 질문은 단순히 정의를 묻는 것이 아니라, 스택/힙 메모리에 대한 이해와 값/참조 타입의 동작 방식에 대한 이해를 확인하려는 의도입니다.
개발 시에는 데이터의 크기, 생명 주기, 공유 필요성, GC에 미치는 영향 등을 종합적으로 고려하여 구조체와 클래스 중 적절한 타입을 선택해야 합니다.
불필요한 힙 메모리 할당을 최소화하는 것이 중요합니다 (특히 Update와 같이 매 프레임 호출되는 함수 내에서의 new 키워드 사용은 신중해야 합니다).
박싱/언박싱 발생 지점을 인지하고 최소화하며, 순환 참조가 발생하지 않도록 설계에 주의해야 합니다.
강의 중 나왔던 주요 질문과 답변을 요약했어요.
Q: 클래스 객체의 깊은 복사(Deep Copy)는 어떻게 하나요?
A: ClassB = ClassA;는 주소만 복사하는 얕은 복사(Shallow Copy)입니다. 깊은 복사를 하려면, 새로운 클래스 인스턴스를 new로 생성한 후, 원본 객체의 멤버 변수 값들을 하나하나 직접 복사해야 합니다. 만약 멤버 변수 중 다른 참조 타입 객체가 있다면, 그 객체 또한 깊은 복사를 수행해야 합니다. ICloneable 인터페이스를 구현하여 Clone() 메서드를 제공하는 방법도 있습니다.
Q: ref 키워드는 어떻게 동작하나요? 값 타입도 원본을 바꿀 수 있나요?
A: 네, 그렇습니다. ref 키워드를 사용하면 값 타입 변수를 함수에 전달할 때, 값 자체가 아닌 해당 변수의 메모리 주소(참조)를 전달하게 됩니다. 따라서 함수 내에서 ref로 받은 파라미터를 수정하면 원본 변수의 값이 직접 변경됩니다. C/C++의 포인터와 유사하지만, C#에서는 더 안전하게 메모리 주소를 다룰 수 있도록 해줍니다.
Q: 구조체 내부에 클래스 타입의 멤버 변수가 포함되면 메모리 동작은 어떻게 되나요?
A: 구조체 자체는 스택에 생성될 수 있지만(예: 함수 내 지역 변수), 내부에 포함된 클래스 타입의 멤버 변수는 힙 메모리에 있는 실제 객체를 참조합니다. 즉, 구조체 내에는 해당 힙 객체의 메모리 주소가 저장됩니다. 만약 이 구조체 변수를 다른 변수에 할당하여 복사하면, 구조체의 값 타입 멤버들은 값이 그대로 복사되지만, 클래스 타입 멤버는 메모리 주소만 복사됩니다. 결과적으로 두 구조체 변수는 동일한 힙 객체를 참조하게 됩니다.
C#
public class MyData { public int value; }
public struct MyStruct { public MyData dataInstance; }
MyStruct s1 = new MyStruct();
s1.dataInstance = new MyData(); // 힙에 MyData 객체 생성
s1.dataInstance.value = 10;
MyStruct s2 = s1; // s1을 s2로 복사
// s2.dataInstance는 s1.dataInstance와 같은 힙 객체를 참조함
s2.dataInstance.value = 20;
// Console.WriteLine(s1.dataInstance.value); // 결과: 20
이번 내용이 여러분의 유니티 개발 여정에 도움이 되셨기를 바랍니다. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요! 😊