티스토리 뷰

반응형

안녕하세요 Pingu입니다.🐧

 

오늘은 iOS 개발에 쓰이는 Swift 언어에서 Class, Struct의 차이점이라는 주제를 가지고 글을 써보려고 합니다.

 

iOS 개발자로 면접을 준비하다 보면 Class, Struct의 차이점이라는 질문을 자주 접하게 됩니다. 아주 간단하게 차이점을 보자면 "Class는 참조타입이고 ARC로 메모리 관리를 한다. Struct는 값 타입이다." 정도로 표현 할 수 있을 거 같습니다. 이 질문을 통해 ARC를 통한 메모리 관리, 참조 타입과 값 타입의 차이점 등을 함께 답변할 수 있을 거 같습니다.

 

저는 이 iOS 개발자 면접에서 자주 등장하는 해당 질문이 iOS 개발자에게 어떤 의미를 갖는지 좀 더 생각하게 되었고 좀 더 깊이 공부하게 되었습니다. 공부를 하다 보니 깨달은 것은 "개발자가 가장 중요하게 고려해야 할 것 중 하나는 성능이며 Class, Struct의 차이점을 확실히 알고 있다면 성능개선을 할 수 있다" 였습니다.

 

즉 면접 질문에서 "Class, Struct의 차이점은 무엇인가요?"를 묻는 것은 단순히 Swift 공식문서에 나와있는 기본적인 내용뿐만 아니라 좀 더 깊은 내용에도 관심이 있고 공부를 했는지, 그리고 결국 그렇게 깊이 공부를 했다면 성능 개선을 할 수 있는 개발자가 될 수 있다고 생각할 수 있기에 묻는다고 개인적으로 생각하게 되었습니다. 실제로 저도 이 질문을 여러 면접에서 받았는데, 이 글을 작성하기 위해 공부하기 전 까지는 항상 기본적인 내용만 답변을 해왔던 터라 아쉽기도 합니다.

 

그럼 일단 공식문서에서 제공하는 Swift의 Class, Struct의 공통점부터 살펴보겠습니다.

Class, Struct의 공통점

  • 값을 저장할 프로퍼티를 선언할 수 있습니다.
  • 함수적 기능을 하는 메서드를 선언 할 수 있습니다.
  • 내부 값에. 을 사용하여 접근할 수 있습니다.
  • 생성자를 사용해 초기 상태를 설정할 수 있습니다.
  • extension을 사용하여 기능을 확장할 수 있습니다.
  • Protocol을 채택하여 기능을 설정할 수 있습니다.

 

그럼 공식문서에서 제공하는 Class, Struct의 차이점도 살펴보겠습니다.

Class (클래스)

  • 참조 타입입니다.
  • ARC로 메모리를 관리합니다.
  • 같은 클래스 인스턴스를 여러 개의 변수에 할당한 뒤 값을 변경시키면 할당한 모든 변수에 영향을 줍니다. (메모리만 복사)
  • 상속이 가능합니다.
  • 타입 캐스팅을 통해 런타임에서 클래스 인스턴스의 타입을 확인할 수 있습니다.
  • deinit을 사용하여 클래스 인스턴스의 메모리 할당을 해제할 수 있습니다.

Struct (구조체)

  • 값 타입입니다.
  • 구조체 변수를 새로운 변수에 할당할 때마다 새로운 구조체가 할당됩니다.
  • 즉 같은 구조체를 여러 개의 변수에 할당한 뒤 값을 변경시키더라도 다른 변수에 영향을 주지 않습니다. (값 자체를 복사)

 

그럼 가장 중요한 차이점인 참조 타입과 값 타입의 차이를 느끼기 위해 직접 코드를 만들어 보겠습니다.

class SimpleClass {
    var count: Int = 0
    
    deinit {
        print("할당 해제")
    }
}
struct SimpleStruct {
    var count: Int = 0
}

var class1 = SimpleClass()
var class2 = class1
var class3 = class1

class3.count = 3

print(class1.count) // class3의 값을 변경했지만 참조타입이므로 class1도 변경 되는 것을 볼 수 있습니다.

var struct1 = SimpleStruct()
var struct2 = struct1
var struct3 = struct1

struct2.count = 2
struct3.count = 3

print(struct1.count) // 0
print(struct2.count) // 2 <- 구조체는 값 타입이므로 항상 새로운 메모리가 할당됩니다.
print(struct3.count) // 3

위와 같이 클래스는 참조 타입이라 같은 클래스 객체를 할당한 변수의 값을 변경시키면 참조된 객체의 값이 변경되고, 구조체는 값 타입이기 때문에 같은 구조체 객체를 할당하더라도 매번 새로운 메모리가 할당되어 값을 변경하더라도 다른 구조체 변수에 영향을 주지 않는 것을 볼 수 있습니다.

 

Class와 ARC, Retain Cycle

그럼 간단하게 Swift에서 참조 타입의 메모리 관리를 위한 ARC도 간단하게 코드로 보고 가겠습니다.

// ARC
print("ARC\n")
var classARC1: SimpleClass? = SimpleClass()
print(CFGetRetainCount(classARC1)) // 변수에 할당한 객체의 경우 2가 기본 값

var classARC2 = classARC1
print(CFGetRetainCount(classARC1)) // 참조 카운트가 1 증가한 3이 됩니다.

classARC1 = nil
print(CFGetRetainCount(classARC2)) // 참조 카운트가 1감소하여 2가 됩니다.
classARC2 = nil // 참조 카운트가 1 감소하여 더 이상 참조하는 곳이 없으모르 deinit이 실행됩니다.

위와 같이 클래스 인스턴스를 여러 곳에서 참조하게 되면, 원래 인스턴스를 해제하더라도 참조 카운트가 남아있어 deinit이 실행되지 않습니다. 참조되는 모든 값들을 해제해줘야 비로소 deinit이 실행되는 것을 볼 수 있습니다.

 

이러한 특징 때문에 retain cycle이 발생하기도 하는데요, 해당 코드도 보고 가겠습니다.

// ratain cycle
class StrongRefClassA {
    var classB: StrongRefClassB?
    deinit {
        print("ClassA 할당 해제")
    }
}

class StrongRefClassB {
    var classA: StrongRefClassA?
    deinit {
        print("ClassB 할당 해제")
    }
}

var classA: StrongRefClassA? = StrongRefClassA()
var classB: StrongRefClassB? = StrongRefClassB()

print(CFGetRetainCount(classA)) // reference count = 2(기본값)
print(CFGetRetainCount(classB)) // reference count = 2(기본값)

classA?.classB = classB
classB?.classA = classA

print(CFGetRetainCount(classA)) // reference count = 3
print(CFGetRetainCount(classB)) // reference count = 3

classA = nil
print(CFGetRetainCount(classB?.classA)) // reference count = 2(기본값)
classB = nil // <- 더 이상 classA, classB의 데이터에 접근 할 수 없지만 deinit 실행되지 않았음 -> 메모리 누수 발생

위와 같이 더 이상 classA, classB에 접근할 방법이 없는데도 불구하고 참조 카운트가 0이 되지 않아 deinit이 실행되지 않았습니다. 이렇게 되면 메모리 누수가 발생하게 됩니다. 이는 weak, unowned 참조를 사용하면 해결할 수 있는데요, 여기서는 간단하게 코드로만 보고 넘어가겠습니다. ARC에 대해 자세한 내용은 여기에 정리해뒀습니다😀

 

class StrongRefClassA {
    weak var classB: StrongRefClassB?
    deinit {
        print("ClassA 할당 해제")
    }
}

class StrongRefClassB {
    weak var classA: StrongRefClassA?
    deinit {
        print("ClassB 할당 해제")
    }
}

var classA: StrongRefClassA? = StrongRefClassA()
var classB: StrongRefClassB? = StrongRefClassB()

print(CFGetRetainCount(classA)) // reference count = 2(기본값)
print(CFGetRetainCount(classB)) // reference count = 2(기본값)

classA?.classB = classB
classB?.classA = classA

print(CFGetRetainCount(classA)) // reference count = 2(기본값)
print(CFGetRetainCount(classB)) // reference count = 2(기본값) -> 약한 참조를 사용했기 때문에 참조카운트 증가하지 않음

classA = nil // deinit 실행됨
classB = nil // deinit 실행됨 -> 인스턴스가 모두 메모리 해제됨 -> 메모리 누수 발생하지 않음!

위와 같이 weak 참조를 사용하면 retain cycle을 방지할 수 있습니다.

 

여기까지가 Swift 공식 문서에서 언급되는 내용입니다. 하지만 이러한 특징들 때문에 구조체와 클래스는 메모리에 저장되는 위치가 다른데요, 구조체는 언제 생기고, 사라질지 컴파일 단계에서 알 수 있기 때문에 메모리의 stack 공간에 할당되고, 클래스는 참조가 어디서 어떻게 될지 모르기 때문에 Heap이라는 공간에 할당되게 됩니다. 

 

그럼 두 할당의 차이를 살펴보도록 하겠습니다.

Stack 할당

Stack은 LIFO(Last In First Out) 형태의 자료구조로 가장 마지막에 들어간 객체가 가장 먼저 나오게 되는 자료구조인데요, 자료구조 특성상 하나의 명령어로 메모리를 할당, 해제할 수 있습니다. 또한 컴파일 단계에서 언제 생성되고 해제되는지 알 수 있는 구조체와 같은 값들이 스택에 저장되게 됩니다.

 

스레드들은 각각 독립적인 Stack 공간을 가지고 있기 때문에 상호 배제를 위한 자원이 필요하지 않습니다. 즉 스레드로부터 안전하다는 말이 됩니다. 이러한 특징 때문에 Stack의 값을 사용하는 것이 Heap의 값을 사용하는 것보다 빠르다고 할 수 있습니다.

Heap 할당

Heap에는 컴파일 단계에서 생성과 해제를 알 수 없는 참조 타입의 객체가 할당됩니다. 즉 Swift에서는 클래스 객체가 힙에 할당되게 됩니다. Heap은 Stack보다 관리하기가 어려운데요, 이는 메모리 할당과 해제가 하나의 명령어로 처리되지 않기 때문입니다. 아까 Stack에서는 pop, push라는 하나의 명령어로 할당, 해제가 이루어졌지만 Heap은 참조 계산도 해줘야 하므로 Stack보다 복잡합니다.

 

또한 Heap은 스레드가 공유하는 메모리 공간이기 때문에 스레드로부터 안전하지 않습니다. 즉 이를 관리해주기 위한 lock과 같은 자원도 필요하게 되고 이는 곧 오버 헤드로 이어지게 됩니다.

 

이렇게 Stack, Heap 할당의 차이점과 Swift의 Class, Struct 값들이 어디에 저장되는지 알았습니다.

 

근데 코딩을 하다 보면 클래스와 구조체를 혼합해서 쓰는 경우가 있지 않나요? 예를 들어 클래스 안에 구조체 필드가 있다거나, 구조체 안에 클래스 필드가 있다거나... 하는 경우가 있습니다.

 

이를 나누게 되면 결국 2가지 경우로 나눌 수 있습니다.

  • 값 타입을 포함하는 참조 타입
    • 간단하게 Class 안에 Struct 프로퍼티가 존재하는 경우입니다.
    • 이 경우 참조 타입이 할당 해제되기 전에 값 타입도 할당 해제되지 않게 하기 위해서 값 타입도 힙에 저장합니다.
    • 위의 경우 말고도 Swift에서는 클로저 내부에서 사용하는 값 타입도 위의 경우에 해당합니다.
  • 참조 타입을 포함하는 값 타입
    • Struct 안에 Class 프로퍼티가 존재하는 경우입니다.
    • 이 경우 값 타입은 힙에 할당되지 않지만 내부에 참조 타입이 있기 때문에 참조 카운트를 처리해줘야 합니다.

값 타입의 Copy-on-assignment, Copy-on-write

값 타입을 다른 변수에 할당하면 복사를 하게 됩니다. 즉 새로운 메모리 공간에 같은 값을 복사하게 되는데요, 이를 copy-on-assignment라고 합니다.

 

이와 다르게 Copy-on-write는 다른 변수에 할당하면 일단은 메모리를 할당하지 않고 같은 곳을 봅니다. 그러다 해당 값을 변경할 때 실제로 메모리에 값을 복사하고 값을 변경하게 됩니다. 이는 메모리를 최적화해주기 위함이며 Swift에서는 Int, Double, String, Array, Set, Dictionary에서만 사용하고 있습니다. 참조 타입을 포함하고 있는 값 타입은 이러한 메모리 최적화를 할 수 없습니다. 물론 억지로 만들 순 있지만 이는 많은 오버헤드를 발생시킵니다.

 

그래서 언제 클래스를 쓰고 언제 구조체를 쓰는 것이 좋을까요?

  • 단순한 데이터 값을 보유하기 위해서는 구조체가 낫습니다.
  • 메모리의 스택은 크기가 크지 않기 때문에 작은 값을 갖는 데이터를 처리할 때 구조체를 사용합니다.
  • Objectice-C와 상호 운용성을 원할 때는 클래스를 사용합니다.

이렇게 Swift의 클래스와 구조체에 대해서 조금 더 깊게 알아봤습니다.

 

확실히 면접에서 이 질문을 묻는 것은 단순히 swift에서의 사용할 때의 차이뿐만 아니라 CS적인 지식도 함께 묻는 것이고 차이점을 확실하게 알아야 성능 개선도 할 수 있는 개발자라고 판단할 것 같습니다.

 

제가 만약 다음 면접 기회에 클래스와 구조체의 차이를 질문받는다면 이번 글의 내용도 함께 대답할 수 있다면 좀 더 좋은 답변이 될 수 있을 것 같아요!

 

감사합니다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함