[Swift] Value type & Reference type
안녕하세요, 루피입니다.
오늘은 값과 참조에 관해 정리해보는 시간을 가져보려합니다. 가장 기본적인 내용이지만, 가장 중요한 개념이기에 이번 기회에 제대로 짚고 넘어가면 좋을거 같습니다.
바로 시작합니다.
값 타입 vs 참조 타입
값 타입은 데이터를 전달할 때 값을 복사 해서 전달합니다.
예를 들어, 구조체 인스턴스를 다른 변수에 할당하면, 전혀 별개의 인스턴스가 생성되어 원본과는 완전히 독립적으로 작동하게 됩니다.
참조 타입은 값을 전달하는 것이 아니라 그 값이 저장되어 있는 메모리 주소 를 전달합니다. 그래서 클래스 인스턴스를 다른 변수에 할당하더라도 실제로는 같은 객체를 가리키게 됩니다. 즉, 둘 중 하나에서 값을 바꾸면 나머지에도 영향을 끼치게 되는 것이죠.
Swift 의 타입
Swift에서 모든 타입은 크게 두 가지 범주로 나뉩니다. 바로 값 타입과 참조 타입인데요.
값 타입의 경우 구조체, 열거형, 튜플, 기본 타입들 (Int, String, Bool, Double 등)이 있습니다.
참조 타입의 경우 클래스, 클로저가 있습니다.
메모리 관점에서의 차이점
두 타입의 가장 큰 차이는 메모리에 저장되는 방식에 차이가 있다는 것입니다. 코드를 통해 확인해보겠습니다.
1. 값 타입의 메모리 구조
struct User {
var name: String
var age: Int
}
var user1 = User(name: "루피", age: 25)
var user2 = user1 // 값 복사 발생
user2.name = "나루토"
print(user1.name) // "루피" (원본 유지)
print(user2.name) // "나루토" (복사본 변경)
값 타입은 Stack 메모리에 실제 데이터가 저장되며, 할당 시 완전한 복사가 일어납니다.
2. 참조 타입의 메모리 구조
Struct를 Class로 변경하고 다른 내용은 다 동일하게 구성하겠습니다.
class UserClass {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
var user1 = UserClass(name: "루피", age: 25)
var user2 = user1 // 참조 복사 발생
user2.name = "나루토"
print(user1.name) // "나루토" (원본도 변경됨!)
print(user2.name) // "나루토"
참조 타입은 Heap 메모리에 실제 데이터가 저장되고, Stack에는 Heap의 주소만 저장됩니다.
실제 동작 방식의 차이
위에서는 간단한 코드를 통해 확인해봤는데, 이제는 실제로 활용될법한 예시로 살펴보겠습니다.
1. 값 타입의 특징 (Call by Value)
struct Post {
var title: String
var text: String
var numberOfLikes = 0
init(title: String, text: String) {
self.title = title
self.text = text
}
}
// 값 타입에서는 함수 매개변수가 기본적으로 상수입니다.
func like(_ post: Post) -> Post {
var post = post // 복사본 생성(상수는 변경이 불가능하기에 변수 복사본을 만듭니다.)
post.numberOfLikes += 1
return post
}
var myPost = Post(title: "Swift 공부", text: "값 타입과 참조 타입")
myPost = like(myPost) // 반환값을 다시 할당해야 함
2. 참조 타입의 특징 (Call by Reference)
class PostClass {
var title: String
var text: String
var numberOfLikes = 0
init(title: String, text: String) {
self.title = title
self.text = text
}
}
func like(_ post: PostClass) {
post.numberOfLikes += 1 // 원본 객체 직접 수정
}
let myPost = PostClass(title: "Swift 공부", text: "값 타입과 참조 타입")
like(myPost) // 원본이 바로 변경됨
3. 값 타입에서 Call by Reference 구현
Swift는 값 타입에서도 Call by Reference를 구현할 수 있는 inout 키워드를 제공합니다.
struct Counter {
var count = 0
}
// inout을 사용한 Call by Reference
func increment(_ counter: inout Counter) {
counter.count += 1 // 원본 직접 수정
}
// 일반적인 Call by Value
func incremented(_ counter: Counter) -> Counter {
var counter = counter // 복사본 생성
counter.count += 1
return counter
}
var myCounter = Counter()
// Call by Reference 방식
increment(&myCounter) // & 기호로 참조 전달
print(myCounter.count) // 1
// Call by Value 방식
myCounter = incremented(myCounter)
print(myCounter.count) // 2
하지만, 이러한 방식은 선호되지 않습니다. 왜냐하면 그럴거면 그냥 Class를 사용하는게 더 좋다고 보기 때문입니다. 복잡하게 굳이 만들 필요가 없다는 것이죠.
let 키워드의 다른 동작
1. 값 타입에서의 let
struct Point {
var x: Int
var y: Int
}
let point = Point(x: 10, y: 20)
// point.x = 30 // 컴파일 오류! 값 자체가 상수
값 타입에서 let으로 선언하면 전체 데이터가 불변이 됩니다. 프로퍼티가 var로 선언되어 있어도, 인스턴스 자체가 상수이므로 어떤 프로퍼티도 변경할 수 없습니다.
2. 참조 타입에서의 let
class PointClass {
var x: Int
var y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
let point = PointClass(x: 10, y: 20)
point.x = 30 // 정상 작동! 참조는 고정, 내용은 변경 가능
// point = PointClass(x: 0, y: 0) // 컴파일 오류! 참조 변경 불가
여기서 한 가지 의문이 생길 수 있습니다. 왜 참조 타입에서는 let으로 인스턴스를 생성 했는데 내용이 변경 가능할까요? 한번 잠깐 스스로에게 설명해보면 좋을 거 같습니다.
바로 참조 타입에서는 let이 참조(주소값)만 고정시키고, 참조가 기리키는 객체의 내용은 여전히 변경 가능하기 때문입니다.
struct Point {
var x : Int
var y : Int
}
let point = Point(x: 1, y: 2)
print(point) // Point(x: 1, y: 2)
값 타입은 Stack 메모리에 실제 데이터가 직접 저장되므로, let으로 선언하면 전체 데이터 블록이 불변이됩니다.
class Point {
var x : Int
var y : Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
let point = Point(x: 1, y: 2)
print(point) // __lldb_expr_55.Point
하지만 참조 타입은 Stack에 Heap 메모리의 주소값만 저장하므로, let은 주소값만 고정시키고 실제 객체의 내용은 변경할 수 있습니다.
즉 Stack 메모리에는 point : [주소 : 0x1234] ← 이 주소값만 상수로 저장되며
Heap 메모리에 저장된 0x1234 : [x: 1, y: 2] ← 이 내용은 변경이 가능하다는 것입니다.
그렇기에 참조 변경은 불가능합니다.
point = Point(x : 0, y: 0) // 컴파일 오류가 발생하게 됩니다.
언제 어떤 타입을 사용할까?
언제 어떤 타입을 사용할지 선택하기 위해서는 일단 Call by Value와 Call by Reference의 장.단점을 알아야겠죠?
1. Call by Value의 성능 특성
장점
- Stack 메모리 사용으로 빠른 할당/해제가 가능합니다.
- 메모리 단편화가 없습니다.
- ARC 오버헤드가 없습니다.
단점
- 큰 구조체의 경우 복사 cost가 증가합니다.
2. Call by Reference의 성능 특성
장점
- 큰 객체도 참조만 복사하므로 효율적입니다.
- 메모리 공유로 사용량 절약이 가능합니다.
단점
- Heap 메모리 할당/ 해제 cost가 발생합니다.
- ARC로 인한 오버헤드가 발생할 수 있습니다.
1. 값 타입을 사용해야 하는 경우
- 데이터 모델링 : 좌표, 크기, 색상 등 단순한 데이터
- 불변성이 중요한 경우 : 원본 데이터 보호가 필요할 때
- 스레드 안전성 : 멀티스레딩 환경에서 안전한 코드
- 성능이 중요한 경우 : Stack 메모리 사용으로 빠른 접근
struct Color {
let red: Double
let green: Double
let blue: Double
}
struct Point {
var x: Double
var y: Double
}
struct UserProfile {
let id: String
var name: String
var email: String
}
2. 참조 타입을 사용해야 하는 경우
- 상태 공유 : 여러 곳에서 같은 객체를 참조해야 할 때
- 상속이 필요한 경우 : 클래스 계층 구조가 필요할 때
- 큰 데이터 구조: 복사 비용이 큰 경우
class DatabaseManager {
static let shared = DatabaseManager()
private var cache: [String: Any] = [:]
func store(key: String, value: Any) {
cache[key] = value
}
}
class ViewController: UIViewController {
// UIKit은 클래스 기반
}
실제 활용 사례
자 이제 기본적인 개념은 충분히 이해했다고 생각 됩니다. 그러면 실제 어떻게 활용되는지 살펴 보려합니다.
1. Copy-on-Write 최적화
Swift의 Array, Dictionary 등은 값 타입이지만, 내부적으로 Copy-on-Write를 사용합니다.
var array1 = [1, 2, 3, 4, 5]
var array2 = array1 // 아직 복사 안 됨 (최적화)
array2.append(6) // 이 시점에서 실제 복사 발생
3. 함수형 vs 객체지향 스타일
// 함수형 스타일 (Call by Value)
struct FunctionalCalculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
func multiply(_ a: Int, _ b: Int) -> Int {
return a * b
}
}
// 객체지향 스타일 (Call by Reference)
class StatefulCalculator {
private var result: Int = 0
func add(_ value: Int) {
result += value
}
func getResult() -> Int {
return result
}
}
let calculator = FunctionalCalculator()
let res1 = calculator.add(1,2) // 3
let res2 = calculator.multiply(2,3) // 6
let calculator2 = StatefulCalculator()
calculator2.add(5)
calculator2.getResult() // 5
calculator2.add(5)
calculator2.getResult() // 10
오늘도 화이팅입니다!