dev._.note

[Swift] struct와 class 본문

Dev/SWIFT

[Swift] struct와 class

Laena 2024. 1. 3. 19:37

struct와 class

struct와 class중에 어떤걸 사용해야 할까?

여기서 어떤걸 사용해야 할지 알기 위해서는 값타입과 참조타입의 차이를 알아야한다.

값 타입(Value type)은 값 자체를 변수명과 함께 Stack에 저장한다.

참조타입(Reference type)은 값을 별도의 메모리 공간 Heap에 저장하고 메모리의 주소를 변수명과 함께 Stack에 저장한다.

예를들어 컴퓨터 파일로 비유해서 말하면 "파일로저장"과 "링크로 저장"의 차이이다.

 

 

Struct와 Class 예시

struct StructTest {
    var name: String
    var age: Int
    var memo: String
}
var _st1 = StructTest(name: "", age: 0, memo: "")
var _st2 = _st1

_st2.name = "leana"

print(_st1.name) // ""
print(_st2.name) // "leana"

class ClassTest1 {
    var name: String
    var age: Int
    var memo: String
    
    init(name: String, age: Int, memo: String) {
        self.name = name
        self.age = age
        self.memo = memo
    }
}

var _ct1 = ClassTest1(name: "", age: 0, memo: "")
var _ct2 = _ct1

_ct2.name = "leana"

print(_ct1.name) // "leana"
print(_ct2.name) // "leana"

메모리 할당 = 어디에 저장되는가

메모리 할당은 인스턴스가 어떤 메모리 영역에 저장되는가의 관점이다.

우리가 프로그래밍한 프로그램이 실제로 실행되면, 운영체제에서 메모리를 할당받아 프로세스가 된다. 이때 프로세스는 메모리를 4개의 영역으로 나눠서 사용한다.

Text와 Global은 컴파일된 코드와 전역 변수/데이터가 저장되는 곳이다. 이 두 영역은 어차피 프로세스를 실행하는 내내 사라지지 않고, 새로 추가되지도 않는 데이터다. 다시 말해 할당/해제라는 주기가 없다. 그냥 고정된 메모리 공간을 배정해준다.

Stack과 Heap은 할당/해제 주기가 있다. 이 둘은 각각 장단점이 있다.

Stack

- 지역변수와 매개변수 등이 저장되는 영역

- 이 영역에 할당된 변수는 함수 호출이 완료되면 사라짐

- 컴파일 시 크기 결정

- ValueType이 할당됨

 

Heap

- 동적 메모리 할당을 위한 영역

- 프로그래머가 할당 및 해제를 해줘야 함

- 런타임 시 크기 결정

 

Stack의 장단점

Stack은 Push, Pop을 통해 메모리를 할당/해제한다. 선형적인 데이터 구조다. 단순하고 효율적이다.

하지만 컴파일 타임에 스택에 필요한 메모리 크기를 미리 알 수 있어야 한다. 하나의 스택이 올라갔을 때 그 크기를 확정할 수 있어야, 다음 스택이 생길 때 할당할 메모리를 알 수 있다.

또 스택이 끝나면 해제되어 버린다. 데이터를 스택에 할당하면 다른 객체에서 데이터에 접근하기가 어렵다.

 

Heap의 장단점

Heap은 임의의 메모리 주소에 메모리를 할당/해제한다. 메모리 할당을 할 때 순차적인 탐색이 필요하다. 더 이상 사용하지 않는 데이터를 탐지하기 위해 참조 카운팅(Reference counting)을 해야 한다. Stack보다 할당/해제에 드는 오버헤드가 크다.

다만 Heap은 런타임에 메모리 할당 크기가 변할 수 있고, 여러 객체나 스코프에서 참조값을 통해 데이터에 접근이 가능하다.

우리는 주로 값 타입은 Stack을 쓰고, 참조 타입은 Heap을 쓴다고 배운다. 그렇지 않은 경우도 있다. 즉, 시멘틱의 관점에서는 '불변성'을 가지면서도, 실제 메모리는 힙을 사용할 수도 있다는 뜻이다.

 


언제 struct와 class를 써야 하는가?

  1. 최대한 struct를 쓴다.
  2. Cocoa 프레임워크의 타입 계층, obj-c 런타임이 필요할 때는 class를 쓴다.
  3. 고유성을 가진 데이터의 변경을 공유해야할 때는, class 안에 struct를 쓴다. (ex. Observer 패턴 구현)
  4. 많은 양의 복사가 많이 일어나야 한다면, struct 안에 class를 쓴다.

 

최대한 Struct를 쓴다.

Swift가 값 타입을 괜히 사랑하는 게 아니다. 값 타입은 '불변성'이라는 엄청난 장점이 있다. 이 불변성은 프로그래머들이 골치 아파하는 문제를 미리 차단해준다.

  1. 예측 가능성 : 값 타입은 부수 효과가 없다. 관심사의 분리가 일어난다. 외부 코드를 다 이해하지 못해도, 특정 부분의 코드만 보고 결과를 예측할 수 있다.
  2. 쓰레드 안전성 : 멀티 스레드 환경에서 공유 데이터는 두통을 일으킨다. 동시에 접근하지 않도록 신경 써줘야 하기 때문이다. 하지만 값 타입은 그럴 필요가 없다. 멀티 스레드 환경에서 안전하고 간단하게 쓸 수 있다.
  3. 테스트의 편리함 : 참조 타입을 테스트하려면 외부의 영향을 제어하고 원하는 상태를 만들기 위해서 신경써줘야할 게 많다. 하지만 값 타입은 그저 값일 뿐이고 복사되기 때문에 환경을 고립시키고 테스트하기가 훨씬 쉽다.

🔗 Encapsulating Domain Data, Logic and Business Rules With Value Types in Swift

이런 장점 뿐 아니라, 스택 사용 덕분에 할당/해제의 성능도 훨씬 효율적이다.

Swift에서는 상속이 class를 쓰는 이유가 될 수 없는데우리가 class 상속이 필요한 이유는 결국 다형성을 활용하기 위해서다.

하지만 swift에서는 프로토콜과 값 타입의 조합으로도 얼마든지 다형성을 만들어낼 수 있다.

심지어 프로토콜은 상속 메커니즘으로 할 수 있는 것을 다 할 수 있을 뿐만 아니라, extension과 generic을 사용한 기능까지 쓸 수 있기 때문에 대부분은 프로토콜을 쓰는 게 합리적이다. (그래서 swift가 Protocol-oriented라고 불린다.)

Cocoa 프레임워크 계층이나 obj-c 런타임을 사용하는 경우, class를 쓴다.

swift가 없던 시절에 만들어진 iOS 프레임워크와 obj-c 런타임을 써야한다면 여기에는 방법이 없다.

UIKit을 서브클래싱한다거나, KVO 같은 메커니즘을 사용하고 싶다면 별수 없이 class를 써야 한다.

고유성을 가진 데이터의 변경을 공유해야할 때는, class 안에 struct를 쓴다.

일단 struct로 시작해서 타입을 만들기 시작한다.

하지만 다른 객체가 특정 객체의 값을 관찰하고 공유받아야하는 경우가 많이 있다.

이 경우에는 struct가 뭔가 삐걱거리기 시작한다.

공유할 데이터가 struct일 때의 문제점: Closure를 통한 Observer pattern

가장 흔하게 쓰이는 예시가 바로 observer pattern이다. observer pattern을 직접 closure로 구현하는 경우에, 다음과 같은 코드를 짜게 된다.

struct Observable <T> {
    typealias Listener = (T) -> Void
    var listener: Listener?

    var value: T {
        didSet {
            listener?(value)
        }
    }

    init(_ value: T) {
        self.value = value
    }

    mutating func bind(listener: Listener?) {
        self.listener = listener
    }
}

여기서 struct로 Observable을 구현하고 있다. 프로퍼티를 관찰하고 싶은 객체가 listener를 등록(bind)한다. 그 다음 Observable이 바뀔 때마다 value 값이 바뀌면 value를 넘겨주면 되지 않을까?

그런데 문제는 이 Observable를 변경할 때, '재할당'이 일어난다는 점이다. 간단히 보면 mutating 를 달았으니까 값이 변경되고, 그 변경이 잘 전파될 거 같다.

하지만 사실 이 mutating이 일어날 때, 인스턴스가 변하는 게 아니라, (값 타입은 변하지 않는다!) 새로운 인스턴스를 자기 자신(self)에게 할당하는 식으로 작동한다.

이 Observable이 변경되거나 전달될 때마다 복사와 재할당이 일어난다는 뜻이다. 우리가 원하는 것처럼 Observable 변수는 일관된 상태를 유지하는게 아니라 다 각각 따로 놀게 된다. Observer들은 일관성없는 값을 계속 전달받게 될 것이다.

공유할 데이터가 struct일 때의 문제점: Notification Center

두번째 예시는 notification center다.

공유할 데이터를 갖고 있는 Publisher를 struct로 구현했다. NotificationCenter를 사용해 observer로 등록하기 위해서 subscriber는 class로 구현했다.

publisher.update()를 해준다면, 자신의 값을 notification center에 포스팅하고, Subscriber는 그 알림을 받아 "Got update!"를 출력하게 될 것이다.

struct Publisher {
    var value: Int
    
    func update() {
        NotificationCenter.default.post(name: Notification.Name("Update"), object: self)
    }
}

class Subscriber {
    var publisher = Publisher(value: 5)
    
    func listen() {
        NotificationCenter.default.addObserver(self, selector: #selector(getUpdate(_:)), name: Notification.Name("Update"), object: publisher)
    }
    
    @objc func getUpdate(_ notification: Notification) {
        print("Got update!")
    }
}

하지만 실제로 그 동작을 구현해보면 아무 일도 일어나지 않는다. 왜일까?

여기서 Subscriber는 publisher에게서 오는 이벤트를 관찰하고 있다. 하지만 publisher는 이벤트를 포스팅할 때 자기 자신(self)를 파라미터로 넘긴다. 파라미터로 넘긴다? 할당한다? 새로운 값을... 복사한다?

그렇다. Subscriber는 당연히 자기가 프로퍼티로 가진 publisher가 알림을 보내오리라 생각했지만, 실제 알림을 보낸 객체로 등록된 것은 복사된 또다른 publisher 다. 따라서 우리가 아무런 알림도 받을 수 없었다.

관찰하려면 '식별'할 수 있어야 한다.

이런 경우들을 겪고 나서, 곰곰히 생각해보았다. struct는 왜 데이터 공유가 이렇게 까다로운 것일까?

우리가 '무언가'를 관찰하고 변경이 있을 때 그 값을 알게 되려면 (Observer pattern) 우리가 그 '무언가'를 식별할 수 있어야 한다. 다시 말해, 그 '무언가'가 변경되는 값과 별개로 어떠한 '고유성'을 가져야한다.

생각해보면 단순히 숫자 4를 관찰할래. 라고 할 수는 없는 것이다. 우리가 원하는 어떤 정보는 고유하고, 그 정보 안에 담긴 값이 변하는 것을 관찰하는 것에 가깝다.

우리가 관찰해야할 데이터가 순수한 값 타입이라면, 문제가 생긴다. 값 타입에는 고유성이 없다. 값이 변경되었을 때는 그냥 새로운 값을 가진 인스턴스로 교체가 되어버릴 뿐이다.

관찰자 입장에서는 내가 관찰해야할 값이 무엇인지 식별할 수가 없는 것이다.

고유성이 필요한 값에는 class를 쓰자

따라서 Observer 패턴, 그러니까 어떤 데이터가 그 변경이 공유되고 식별될 수 있어야 하는 상황에서는 class를 쓰는 게 적합하다고 결론을 내렸다.

하지만 그렇다고 상위 타입을 다 class로 바꿀 필요는 없다. 식별가능해야하는 데이터만 고유성을 띄면 된다. 그 외의 프로퍼티나 비즈니스 로직까지 class에 담아서 부수효과의 범위를 키울 필요는 없다.

공유해야 하는 데이터를 값 타입으로 유지하되, class로 감싸주자. 이렇게 되면 해당 데이터의 고유성을 유지하면서 다른 객체가 관찰할 수 있도록 하되, 우리가 의도한 범위 이상의 부수효과를 만들지 않는 효과가 있다.

예를 들면 다음과 같은 wrapper 타입을 만들 수 있겠다.

class Reference<Value> { 
	var value: Value 

	init(value: Value) { 
		self.value = value 
	} 
}

let sharedData = Reference(value: data)

복사가 많이 일어나서 비효율적인 경우, struct 안에 class를 쓴다.

값 타입인데 안의 데이터가 굉장히 많거나, 복사가 많이 일어나는 데이터의 경우는 참조 타입의 장점을 활용한다. 복사를 최소화해서 성능을 최적화하는 것이다.

이 때는 struct 안에 참조 타입으로 별도의 저장소를 만들어준다.

참조 타입 안에 들어간 데이터는 struct가 여러번 복사되고 할당되어도 참조만 늘어나기 때문에 효율적으로 사용할 수 있다.

하지만 이 경우에는 순수한 값 타입의 불변성을 잃어버리기 때문에, 변경이 일어날 경우에는 복사하는 메커니즘을 구현해주는 게 좋다. (어디서 많이 들어본 Copy-on-write)

다만 여기서 주의할 점. 성능 측면에서 값 타입이 프로퍼티로 참조 타입을 2개 이상 가지고 있는 경우는 바람직하지 않다.

2개 이상의 참조 타입을 포함한 값 타입은 오히려 참조 타입보다도 메모리 측면에서 성능이 감소한다.

그 이유는 Struct가 계속해서 복사될 때마다, 참조 카운팅도 늘어나기 때문이다. 참조 카운팅이 계속 늘어나면 오버헤드도 커진다.

요약 정리

  • '이 타입을 값으로 할거냐, 참조로 할거냐'는 Swift에서 꽤나 중요한 고민거리다.
  • Swift는 값 타입이 무척 강력해서 더욱더 고민이 된다.
  • 값 타입과 참조 타입을 볼 때 시멘틱(semantic)과 메모리 할당(memory allocation)을 구분해서 봐야 한다.
  • 불변성을 가지면서도 힙에 저장되는 경우는 Swift Collection type, struct 안의 class, class/closure 안의 struct, Protocol type 등이 있다.
  1. 최대한 struct를 쓴다.
  2. Cocoa 프레임워크의 타입 계층, obj-c 런타임이 필요할 때는 class를 쓴다.
  3. 고유성을 가진 데이터의 변경을 공유해야할 때는, class 안에 struct를 쓴다. (ex. Observer 패턴 구현)
  4. 많은 양의 복사가 많이 일어나야 한다면, struct 안에 class를 쓴다.

참고 : https://velog.io/@eddy_song/value-reference-decision#%EA%B0%92value%EC%9D%B4%EB%83%90-%EC%B0%B8%EC%A1%B0reference%EB%83%90

'Dev > SWIFT' 카테고리의 다른 글

[Swift] URLSession  (1) 2024.01.07
[Swift] URL 구성요소  (1) 2024.01.04
[Swift] Kiosk 팀과제 2  (2) 2024.01.02
[Swift] Kiosk 팀 과제  (0) 2023.12.27
[Swfit] todoList 과제  (0) 2023.12.20