Swift에서 데이터 타입을 이해하는 핵심은 값 타입(Value Type)과 참조 타입(Reference Type)의 차이를 명확히 아는 것입니다. 특히 struct와 class는 이 두 가지를 대표하며, 메모리 할당 방식과 데이터 동작 방식에서 큰 차이를 보입니다.
1. struct의 Copy-on-Write (COW) 및 메모리 동작
struct는 Swift의 기본 값 타입입니다. 이는 struct 인스턴스를 변수에 할당하거나 함수에 전달할 때, 값 자체가 복사된다는 의미입니다.
1.1 Copy-on-Write (COW) 원칙
Array, Dictionary, Set, String과 같은 Swift의 표준 컬렉션들은 모두 struct로 구현되어 있지만, 효율적인 메모리 관리를 위해 Copy-on-Write (COW) 최적화가 적용되어 있습니다.
- 즉시 복사되지 않음: struct 인스턴스를 파라미터로 넘겨받거나 단순히 값을 읽을 때는 즉시 복사본이 생성되지 않습니다. 대신, 두 변수가 동일한 메모리 공간을 공유하게 됩니다.
- 쓰기 시점에 복사 발생: 실제 변수에 쓰기(수정)를 시도할 때 비로소 복사가 발생합니다. 예를 들어, var arr1 = [1, 2, 3]을 var arr2 = arr1으로 할당한 후 arr2.append(4)를 하면, arr2가 수정되기 직전에 새로운 배열 복사본이 생성되고 arr2는 이 복사본을 가리키게 됩니다. arr1은 원래의 값을 유지합니다.
- COW의 장점은 불필요한 복사를 줄여 성능을 최적화하고, 값 타입의 예측 가능한 불변성을 유지하면서도 필요할 때만 복사본을 생성하여 독립적인 변경을 가능하게 하는 것입니다.
1.2 struct 인스턴스의 프로퍼티 변경
struct는 기본적으로 값 타입이지만, var로 선언된 struct 인스턴스에서는 내부 프로퍼티를 변경할 수 있습니다.
- let으로 선언된 struct: 완전히 불변(immutable)입니다. 초기화 후 어떤 프로퍼티도 변경할 수 없습니다.
- var로 선언된 struct: 인스턴스 자체의 값을 새로운 값으로 교체할 수 있습니다.
- 직접 프로퍼티 수정: var myPoint = MyPoint(x: 10, y: 20); myPoint.x = 15와 같이 직접 접근하여 수정할 수 있습니다. 이는 사실상 myPoint 변수가 가리키는 MyPoint 값 전체가 새로운 값으로 교체되는 것과 유사하게 동작합니다.
- mutating 메서드 사용: struct 내부에서 자신의 프로퍼티를 변경하는 메서드에는 반드시 mutating 키워드를 붙여야 합니다. 이 mutating 메서드는 var로 선언된 인스턴스에서만 호출될 수 있습니다.
var struct의 프로퍼티를 변경할 때 해당 변수의 값만 변경되고 다른 변수에는 영향을 주지 않으므로, 코드의 예측 가능성이 높아집니다. 이는 class 인스턴스가 참조를 공유하여 여러 변수에 동시에 영향을 줄 수 있는 것과 대비됩니다.
2. 메모리 할당: 스택(Stack) vs. 힙(Heap)
Swift에서 데이터가 메모리에 할당되는 방식은 타입에 따라 다릅니다.
2.1 스택(Stack) 메모리
할당 대상: 주로 struct 및 enum (값 타입)의 인스턴스와 함수의 지역 변수.
특징
- LIFO (Last-In, First-Out) 방식으로 데이터를 저장합니다. 마치 접시를 쌓아 올리는 것과 같습니다.
- 빠른 할당/해제: 컴파일 시점에 크기가 결정되고, 함수 호출이 끝나면 자동으로 메모리가 해제되어 매우 빠릅니다.
- 선형적: 메모리 주소가 순서대로 할당되고 해제됩니다.
장점: 빠른 접근, 자동 메모리 관리로 인한 낮은 오버헤드.
단점: 크기가 제한적이고, 고정된 크기의 데이터에 주로 사용됩니다.
2.2 힙(Heap) 메모리
할당 대상: 항상 class (참조 타입)의 인스턴스.
특징
- 비선형적 저장: 특정 순서 없이 동적으로 메모리 공간을 할당하고 해제합니다. 마치 넓은 땅에 건물을 자유롭게 짓는 것과 같습니다.
- 느린 할당/해제: 메모리 관리자(Swift에서는 ARC)가 빈 공간을 찾아 할당하고 메타데이터를 관리하는 과정에서 추가적인 오버헤드가 발생하여 스택보다 느립니다.
- 유연한 크기: 필요한 만큼 동적으로 메모리를 할당할 수 있습니다.
장점: 유연한 크기 조절, 여러 곳에서 동일한 인스턴스를 공유할 수 있습니다.
단점: 상대적으로 느린 접근, 메모리 관리 오버헤드, 메모리 파편화 및 순환 참조(retain cycle) 발생 가능성.
3. 포인터(메모리 주소)의 존재 이유와 class의 역할
class와 같은 참조 타입이 주소값을 사용하는 근본적인 이유는 유연성과 효율성 때문입니다.
3.1 class가 주소값을 사용하는 이유
- 가변적인 크기: 클래스 인스턴스는 런타임에 그 크기가 결정될 수 있으므로, 스택처럼 고정된 크기만 다룰 수 있는 곳에는 부적합합니다.
- 공유와 상호작용: 여러 변수나 함수가 동일한 객체를 참조하고, 그 객체의 상태를 공유하며 변경할 필요가 있을 때 class가 사용됩니다.
- 생명주기 관리: class 인스턴스는 함수 호출이 끝나도 사라지지 않고, 프로그램의 특정 시점까지 계속 유지되어야 합니다.
이러한 특성을 위해 class 인스턴스는 힙에 동적으로 할당됩니다. 그리고 힙에 할당된 특정 인스턴스에 접근하려면, 그 인스턴스가 메모리의 어디에 있는지 주소값(포인터)을 알아야 합니다. 변수는 이 주소값을 "참조"함으로써 실제 힙에 있는 객체에 접근하는 것입니다.
3.2 포인터(메모리 주소)의 존재 이유
포인터는 컴퓨터가 메모리에 저장된 데이터에 접근하고, 데이터를 공유하며, 복잡한 데이터 구조를 만들고, 동적으로 메모리를 관리하는 데 필수적인 도구입니다.
- 간접 참조를 통한 유연성: 데이터를 직접 복사하는 대신, 데이터가 있는 곳의 주소만 넘겨주면 됩니다. 이는 특히 큰 데이터 구조를 다룰 때 메모리와 시간을 크게 절약합니다.
- 동적 메모리 관리: 프로그램 실행 중에 필요에 따라 메모리를 할당하고 해제하는 힙과 같은 동적 메모리 영역에서 할당된 메모리의 시작 위치를 포인터로 받아서 사용합니다.
- 데이터 구조 구현: 링크드 리스트, 트리, 그래프 등 각 노드가 다음 노드의 메모리 주소를 가리키는 방식으로 연결되는 복잡한 데이터 구조 구현에 필수적입니다.
- 다형성 구현: 객체 지향 프로그래밍에서 상위 클래스 타입의 포인터가 하위 클래스의 실제 객체를 가리킬 수 있게 함으로써 다형성을 구현합니다.
Swift에서 struct와 class 중 어떤 것을 선택할지는 데이터의 특성(값 복사가 필요한가, 공유가 필요한가), 변경 가능성(let vs var), 그리고 성능 요구사항을 종합적으로 고려하여 결정해야 합니다.