Swift - 언제 class 대신 struct 를 사용하는가

Swift 언어 포럼에서는 struct와 class의 차이와 이를 언제 쓰는게 좋냐라는 주제에 대해 자주 토론이 되는 것 같다. 개인적으로도 관심이 많은 편이다보니 좀 정리가 필요할 것 같다는 생각이 들었다.

우선은 이 둘의 차이에 대해 알아야 할 것 같다. 이미 별도의 글로 정리(글 하단의 글목록 참고)했던 적이 있으니 간단히 정리해보자.

struct의 특징은 대충 이렇다:
  • 값(Value)이다. 정확히, 값의 타입을 정의하기 위해 사용한다. 객체(Object) 레퍼런스 타입을 정의하는 class 와는 다르다.
  • 대입 명령 시 내용이 복사된다. (단 데이터 변동이 없으면 레퍼런스 대입 형태로 동작한다)
  • 참조 카운트가 없어서 메모리 관리에 안전하다.
  • 레퍼런스 형태가 아니기 때문에 공유가 불가능하다.
  • 불변성(Immutable) 구현에 유리하다.
  • 멀티스레딩에 안전하다.
  • 상속이 불가능하다. 하지만 프로토콜은 사용 할 수 있다.
  • Object가 아니기 때문에 AnyObject로의 캐스팅이 되지 않는다.

struct와 class를 비교할 때 첫 번째 항목인 값과 레퍼런스 라는 차이점에 대해 가장 많이 언급한다. 당연히 개념상의 차이이다 보니 중요한 부분이긴 하다. 그리고 나머지 항목들은 이 첫 번째 항목에 의해서 발생되는 특징이기도 하다.

추가로 마지막의 AnyObject 캐스팅 이야기는 어떻게 보면 Cocoa(AppKit)나 Cocoa Touch(UIKit) 를 다룰때 제약이 될 수도 있다. 기존의 id 로 쓰던 타입들을 모두 AnyObject 로 받는 경우가 많은데 이 경우 struct 로 만들어진 값을 넘기는게 불가능하다는 의미가 된다. 대신 Any 의 경우는 struct 로 만들어진 값도 캐스팅이 가능하다.

레퍼런스(Reference)와 값(Value)

struct 로 구현된 타입의 가장 중요한 특징은 인스턴스가 값(Value)이라는 점이다. 값이라는 표현은 '고유한 메모리에 데이터가 저장된다' 라고 기계적인(?) 설명이 가능하다.

이미 값과 레퍼런스 차이에 대한 글에서 비슷한 예제를 올린 적이 있지만 비슷한 예제를 다시 만들어 봤다.

아래의 코드는 클래스를 이용해 상속 및 레퍼런스 복사에 의한 공유에 대한 예제이다.
class Animal {
    let name: String
    var hint: String?
    
    init(name: String) {
        self.name = name
    }
}

class Cat: Animal {
    override init(name: String) {
        super.init(name: name)
        self.hint = "Some Cat"
    }
}

let cat = Cat(name: "Nabi")
let animal = cat as Animal
let kitty = cat

cat.hint        // "Some Cat"
animal.hint     // "Some Cat"
kitty.hint      // "Some Cat"

cat.hint = "Nyaah"

cat.hint        // "Nyaah"
animal.hint     // "Nyaah"
kitty.hint      // "Nyaah"
위 코드의 동작 방식을 보자. 인스턴스들 중 최초에 만들어진 Cat 타입의 cat이 있고 이를 이용해 animal과 kitty를 만들어냈다. 그런데 cat의 내용을 바꾸니 animal과 kitty도 동시에 바뀌었다.

즉 animal, kitty 두 변수는 모두 cat의 메모리(레퍼런스)를 가리키고 있다. 다르게 말하자면 셋 다 동일한 메모리 레퍼런스를 가지고 있다는 말이다. 따라서 셋 중에 무엇을 바꾸든 세 가지가 동시에 바뀐다고 볼 수 있다. (물론 실제론 하나만 바뀌는 것이지만...)

하지만 의도에 따라서는 이 코드가 맞는 걸수도 있고 틀린 걸수도 있다. 만약 kitty가 cat과 동일한 데이터를 가지고 생성되지만 둘은 별도의 객체이길 원했다면 어떻게 해야 했었을까? 아마도 귀찮게 오브젝트를 새로 만들고 내용물을 복사해 주는 행위를 추가로 했어야 했다.

이번에는 struct로 구성된 코드를 살펴보자.
protocol AnimalType {
    var hint: String? { set get }
    init(name: String)
}

struct Cat: AnimalType {
    let name: String
    var hint: String?
    
    init(name: String) {
        self.name = name
    }
}

// why use 'var'? Makes Mutable.
var cat = Cat(name: "Nabi")
var kitty = cat
var motherCat: AnimalType = cat

cat.hint = "Nyaaaaong"
kitty.hint = "Nyang"

cat.hint        // "Nyaaaaong"
kitty.hint      // "Nyang"

motherCat.hint  // nil
motherCat.hint = "Nyong"

cat.hint        // "Nyaaaaong"
motherCat.hint  // "Nyong"
protocol을 이용했다는 점을 빼면 클래스 방식과 크게 다를게 없는 구현이다. 참고로 cat, kitty, motherCat의 경우 var를 이용해 변수로 생성했는데 이는 let으로 할 경우 불변값(Immutable)이 되어버리기 때문에 hint 값 수정을 못 하기 때문이다.

여기서 cat, kitty, motherCat은 앞서 본 클래스 방식과의 비교를 위해 만들어 둔 값들인데, 보다싶이 셋 다 별도의 객체로 동작한다. 즉 각자 고유한 메모리에 생성되어 있기 때문에 메모리가 공유되지 않고 별도로 유지된다. 즉 세가지 모두 값을 보관하기 위한 용도로써 사용되며 레퍼런스를 보관하는 것 처럼은 보이지 않는다.

생성 및 대입 속도

이제 약간 다른 시선에서 둘을 살펴보자. 퍼포먼스 면에서다.

우선 메모리에 인스턴스가 생성될 때의 퍼포먼스 차이를 보자. 아래 스크린샷은 플레이그라운드에서 측정한 클래스 인스턴스(오브젝트 레퍼런스)와 구조체 인스턴스(값) 생성 속도를 비교한 내용이다.


여기서 알 수 있는 사실은 struct가 인스턴스 생성에서 class에 비해 100배 정도 빨랐다 라는 점이다. 이 속도는 플레이그라운드라는 특징과 시스템 상태에 따라 천차만별이기 때문에 정확하게 거론 할 수 있는건 아니지만, 하여간 구조체가 클래스 보다 인스턴스 생성이 빠르다는 점은 사실이다.

클래스가 느린 이유는 관련 내용을 찾을 수가 없어서 명확하진 않지만, 별도의 특수 코드가 더 삽입되는 것이 아닐까 생각될 뿐이다. 특히 거대한 NSObject를 상속받는 경우라면 확연할 것 같다.

이번에는 다른 비교 샘플을 보자. 이번엔 대입(Assignment) 시의 퍼포먼스 변화이다.


이번에는 미리 인스턴스를 만들어 놓고 이 인스턴스를 다른 변수에 대입할 때의 퍼포먼스를 측정한 결과인데 둘 다 비슷하다고 보인다. 물론 이 샘플로는 정확한 속도 비교는 불가능하다.

하지만 이 샘플을 통해 알 수 있는 것은 struct가 대입 시 퍼포먼스에 불리함을 가지느냐이다. 만약 멤버 프로퍼티가 많아지고 값이 변동된 상태에서 다른 변수에 대입이 된다면 분명 class가 struct에 비해 엄청나게 빠를 것이다. 왜냐하면 struct는 대입 시 메모리 내용물까지 복사를 해야 하니 레퍼런스만 넘겨주는 class에 비해 느릴 수 밖에 없다.

언제 struct를 쓰면 유리할까

위의 내용들을 근거로 쓰자면
  • 불변성(Immutable)이 필요한 데이터 타입
  • 적은 데이터, 즉 멤버 프로퍼티의 갯수나 차지하는 메모리 용량이 적은 타입
  • 대입 보다는 생성되는 경우가 많은 타입
  • 공유될 필요가 없는 타입
  • 클래스 타입 등 레퍼런스에 기반한 자료형을 저장용 프로퍼티로 쓰지 않는 경우
으로 정리할 수 있다.

이 중 세 번째는 사실 제대로 판단하긴 힘들다. 판단 기준이 불명확하다 -_-;

구조체로 구현하지 말아야 하는 경우들을 꼽아보면 좀 더 기준이 쉬워지리라 생각된다. 예를 들자면:
  • 싱글톤(Singletone Pattern)은 하나의 인스턴스를 여러 곳에서 공유하면서 사용해야 한다.
  • View나 ViewController의 경우도 공유 형태이니 레퍼런스 접근이 되어야만 한다. 
등등이 있을 것 같다.

그 외에 메모리 어드레싱이 필요한 경우가 클래스에 유리할 상황이 될 텐데, 이런건 지양하는 편이 좋기도 하고 자주 구현할 일은 없을 것 같다.

위 목록 중 가장 마지막 항목은 어떻게 보면 당연한 것이고 어떻게 보면 제약이다. 저장용 프로퍼티(Stored properties)에 레퍼런스가 저장되어 있다면 이 멤버를 포함하고 있는 구조체가 복사될 때 레페런스 복사가 발생한다. 결국 여러 값이 동일한 레퍼런스를 가리키는 멤버를 소유하게 된다. 이건 값(Values) 이라는 정의에 어긋난다.

어떻게 보면 가장 마지막 지침이 기준이 될 수도 있다. 만약 멤버로 class 타입을 써야한다면 그냥 struct 를 포기하자.

Swift 의 경우 최근 POP(Protocol Oriented Programming - 프로토콜 지향 프로그래밍)라는 개념이 화제이다. 이는 protocol과 struct를 활용해 'class를 이용하는 OOP'를 대체하려는 새로운 개발론인데 위의 샘플 코드 중 AnimalType protocol 이 등장하는 예제가 바로 POP 스타일(?)로 구현한 코드이다. POP는 이 글과도 연관이 있지만 분량이 큰 관계로 기회가 되면 별도로 글을 쓰고 싶다. :-)

관련글:

댓글

이 블로그의 인기 게시물

소수점 제거 함수 삼총사 ceil(), floor(), round()

버전(Version)을 제대로 이해하기