구조체와 클래스 - 값 타입과 참조 타입
이 장에서 배우는 것
앞 장까지는 값 하나, 컬렉션 하나, 함수 하나를 따로 다뤘다. 이제 습관 기록 앱의 로직이 커지면서 "물 마시기"라는 이름, 하루 목표량, 날짜별 기록을 한 덩어리로 묶어야 할 때가 왔다. Swift에서 이렇게 여러 값을 하나로 묶어 새 타입을 만드는 도구가 구조체(struct)와 클래스(class)다. 둘은 겉모양이 비슷하지만 값을 대입하거나 함수에 넘길 때 동작이 다르다. 이 차이를 모르면 "분명히 고쳤는데 값이 그대로다" 또는 "나는 건드리지 않았는데 값이 바뀌었다" 같은 버그를 만나게 된다. 이 장에서는 두 방식을 같은 습관 기록 예제로 나란히 놓고 비교한다.
- 구조체와 클래스를 정의하고, 구조체의 멤버와이즈 초기화로 인스턴스를 만든다.
- 값 타입은 복사되고 참조 타입은 공유된다는 차이를 코드로 확인한다.
mutating메서드와inout인자가 왜 필요한지 설명한다.- 계산 프로퍼티로 합계와 달성 일수처럼 저장하지 않아도 되는 값을 만든다.
===로 두 이름이 같은 객체를 가리키는지 확인하고, 상황에 따라 struct 와 class 를 고른다.
문제 상황
물 마시기 습관을 기록하는 코드를 짠다고 하자. 지금까지 배운 도구만 쓰면 이름은 String, 하루 목표는 Int, 날짜별 기록은 [Int: Int] 같은 딕셔너리로 따로 들고 다니게 된다. 습관이 세 개가 되면 변수가 아홉 개가 되고, 함수는 인자를 세 개씩 받는다. 어떤 이름이 어떤 기록과 짝인지는 변수 이름 규칙으로만 겨우 지킬 수 있다.
묶는 데까지는 쉽다. 진짜 문제는 그다음이다. 사용자가 "어제 기록을 고치기 전의 상태와 비교해 보여 달라"고 하면 원본을 복사해 둬야 한다. 반대로 같은 습관 목록을 "오늘 화면"과 "통계 화면"이 함께 보고 있다면 한쪽에서 고친 내용이 다른 쪽에도 보여야 한다. 복사되어야 할 때 공유되거나, 공유되어야 할 때 복사되면 기록이 사라지거나 엉뚱하게 바뀐다. 이 장은 두 요구를 각각 어떤 타입으로 풀지 정하는 방법을 다룬다.
struct 로 값 만들기
구조체는 관련된 값(프로퍼티, property)과 그 값을 다루는 함수(메서드, method)를 한 타입으로 묶는다. 타입으로 실제 만들어 낸 하나하나를 인스턴스(instance)라고 부른다. 하루 기록 한 줄을 구조체로 쓰면 다음과 같다.
struct DailyRecord {
let day: Int
var amount: Int
}
day 는 한번 정해지면 바뀌지 않으므로 let, 양은 같은 날 여러 번 더할 수 있으므로 var 로 썼다. 상수와 변수를 다룬 장의 원칙인 "기본은 let"을 프로퍼티에도 그대로 적용한 것이다.
멤버와이즈 초기화
위 구조체에는 초기화 코드를 한 줄도 쓰지 않았지만 바로 인스턴스를 만들 수 있다.
let record = DailyRecord(day: 1, amount: 8)
Swift가 프로퍼티를 선언한 순서대로 인자 라벨을 붙인 초기화 함수를 자동으로 만들어 주기 때문이다. 이를 멤버와이즈 초기화(memberwise initializer)라고 한다. 인자 라벨이 있는 함수를 다룬 앞 장의 내용 그대로, 호출할 때 day: 와 amount: 를 빼먹으면 컴파일 오류가 난다. 프로퍼티에 기본값을 주면 그 인자는 생략할 수 있다.
struct Habit {
let name: String
var goalPerDay: Int
var records: [DailyRecord] = []
}
let water = Habit(name: "물 마시기", goalPerDay: 8)
let reading = Habit(name: "독서", goalPerDay: 30,
records: [DailyRecord(day: 1, amount: 40)])
records 에 빈 배열이 기본값으로 있으므로 첫 번째 호출에서는 생략했고, 두 번째 호출에서는 기록을 직접 넘겼다. 인자는 선언한 순서를 지켜야 한다.
계산 프로퍼티
합계나 달성 일수는 records 에서 언제든 계산할 수 있다. 이런 값을 별도 프로퍼티에 저장해 두면 기록을 추가할 때마다 함께 고쳐야 하고, 하나라도 빠뜨리면 두 값이 서로 어긋난다. 계산 프로퍼티(computed property)는 값을 저장하지 않고 접근할 때마다 중괄호 안의 식으로 계산해 돌려준다.
var totalAmount: Int {
records.reduce(0) { $0 + $1.amount }
}
var achievedDays: Int {
records.filter { $0.amount >= goalPerDay }.count
}
선언은 변수처럼 var 와 타입을 쓰고, 값을 읽는 방식도 habit.totalAmount 처럼 괄호 없이 쓴다. 함수가 아니라 프로퍼티처럼 보이게 하는 것이 요점이다. "얼마를 마셨나"는 동작이 아니라 상태를 묻는 말이므로 totalAmount() 보다 자연스럽다. 본문이 한 식뿐이면 return 을 생략할 수 있다. 값을 쓰는 쪽(setter)도 정의할 수 있지만, 이 장의 예제는 읽기만 하는 계산 프로퍼티로 충분하다.
mutating
구조체의 메서드가 자기 자신의 프로퍼티를 바꾸려면 mutating 을 붙여야 한다.
mutating func log(day: Int, amount: Int) {
if let index = records.firstIndex(where: { $0.day == day }) {
records[index].amount += amount
} else {
records.append(DailyRecord(day: day, amount: amount))
}
}
구조체는 값 타입이라서, 메서드가 값을 바꾼다는 것은 "이 변수가 담고 있는 값을 새 값으로 교체한다"는 뜻에 가깝다. 그래서 mutating 메서드는 var 로 선언한 변수에서만 호출할 수 있다. let 으로 선언한 구조체는 프로퍼티가 var 여도 바꿀 수 없다. 이 규칙은 "실무에서 자주 틀리는 것"에서 다시 본다.
함수에 구조체를 넘길 때도 같은 이유가 적용된다. 함수의 인자는 기본적으로 상수처럼 취급되고, 넘겨받는 것도 복사본이다. 원래 변수를 직접 바꾸고 싶다면 인자 타입 앞에 inout 을 쓰고, 호출할 때 변수 앞에 & 를 붙여 "이 변수 자체를 넘기겠다"고 밝힌다.
func logGlass(of habit: inout Habit, day: Int) {
habit.log(day: day, amount: 1)
}
var water = Habit(name: "물 마시기", goalPerDay: 8)
logGlass(of: &water, day: 3)
class 와 참조 공유
클래스도 프로퍼티와 메서드를 묶는다는 점은 같다. 달라지는 지점은 세 가지다. 첫째, 멤버와이즈 초기화가 자동으로 생기지 않아서 init 을 직접 써야 한다. 둘째, 메서드가 프로퍼티를 바꿔도 mutating 이 필요 없다. 셋째, 변수에 대입하거나 함수에 넘길 때 값이 복사되지 않고 같은 인스턴스를 가리키는 이름만 하나 더 생긴다. 마지막 차이가 가장 중요해서 참조 타입(reference type)이라는 이름이 붙었다.
final class HabitBoard {
var title: String
var habits: [Habit]
init(title: String, habits: [Habit] = []) {
self.title = title
self.habits = habits
}
var habitCount: Int { habits.count }
}
init 안의 self.title = title 에서 왼쪽의 self.title 은 인스턴스의 프로퍼티, 오른쪽의 title 은 인자다. 이름이 같으므로 self. 로 구분한다. 맨 앞의 final 은 이 클래스를 물려받아 확장하는 일(상속)을 막는 표시다. 이 책은 상속을 쓰지 않으므로 클래스를 만들 때마다 붙여 두기로 한다.
=== 로 같은 객체인지 확인하기
참조를 공유하는 클래스에서는 "내용이 같은가"와 "같은 객체인가"가 서로 다른 질문이다. 제목이 같은 보드 두 개를 따로 만들었다면 내용은 같지만 객체는 둘이다. Swift는 후자를 === (세 개의 등호)로 묻는다. 두 이름이 메모리의 같은 인스턴스를 가리키면 true 다.
let morning = HabitBoard(title: "아침 보드")
let alias = morning
let other = HabitBoard(title: "아침 보드")
print(morning === alias) // true
print(morning === other) // false
=== 는 클래스 인스턴스에만 쓸 수 있다. 구조체에는 "같은 객체"라는 개념이 없다. 대입할 때마다 독립된 값이 생기기 때문이다. 구조체의 내용 비교는 == 를 쓰는데, 이를 쓰려면 비교 방법을 약속해야 하며 그 방법은 프로토콜을 다루는 장에서 배운다.
또 하나 눈여겨볼 점이 있다. morning 은 let 인데도 morning.title = "..." 로 내용을 바꿀 수 있다. let 은 "이 이름이 가리키는 객체를 다른 객체로 바꾸지 않겠다"는 뜻이지 객체 속 내용이 고정된다는 뜻이 아니기 때문이다. 구조체는 이름과 값이 한 몸이므로 let 이면 내용까지 고정된다.
한 번 더 보는 비교
| 항목 | struct | class |
|---|---|---|
| 타입 종류 | 값 타입 | 참조 타입 |
| 대입·인자 전달 | 값이 복사된다 | 같은 인스턴스를 가리킨다 |
| 초기화 | 멤버와이즈 초기화가 자동으로 생긴다 | init 을 직접 쓴다 |
| 프로퍼티를 바꾸는 메서드 | mutating 이 필요하다 | 필요 없다 |
let 으로 선언하면 | 내용도 바꿀 수 없다 | 내용은 바꿀 수 있다 |
| 같은 대상인지 확인 | 해당 없음 | === |
한 가지 주의할 점이 있다. 구조체 안에 클래스 인스턴스를 프로퍼티로 넣으면, 구조체를 복사해도 그 프로퍼티는 같은 객체를 가리킨 채 복사된다. 복사되는 것은 이름(참조)이지 객체가 아니기 때문이다. 반대로 클래스 안의 구조체 프로퍼티는 그 클래스가 소유한 독립된 값이다. 예제 코드에서 HabitBoard 가 [Habit] 을 들고 있는 것이 바로 이 경우다.
언제 무엇을 고르나
기준은 하나다. "같은 인스턴스를 여러 곳에서 함께 바꾸고 함께 봐야 하는가"를 먼저 묻는다. 그렇다면 클래스, 아니라면 구조체다.
하루 기록, 습관 하나, 통계 결과처럼 "값"에 가까운 것은 구조체가 맞다. 두 기록이 우연히 같은 내용이면 같은 기록으로 봐도 되고, 복사해서 고쳐 보다가 버려도 된다. 함수가 값을 받아 새 값을 돌려주는 방식으로 짜면 호출 전후의 상태를 비교하기도 쉽다. 반면 여러 화면이 함께 들여다보는 설정이나 보드처럼 "누구의 것인가"가 중요한 대상은 클래스가 어울린다. 한 곳에서 고친 결과를 다른 곳이 같은 인스턴스로 곧바로 보기 때문이다.
Swift의 표준 라이브러리에서도 String, Array, Dictionary, Int 모두 구조체다. 이 책의 앞 장에서 배열을 다른 변수에 대입한 뒤 한쪽만 고쳐 본 적이 있다면, 그때 본 독립적인 동작이 바로 값 타입의 성질이다. 판단이 서지 않으면 구조체로 시작하고, 공유가 꼭 필요하다는 근거가 생겼을 때 클래스로 바꾸는 편이 안전하다. 두 타입의 공식 설명은 The Swift Programming Language 의 Structures and Classes 에서 확인할 수 있다.
완성 코드
아래는 지금까지의 조각을 모두 합친 main.swift 한 파일이다. 구조체 복사, mutating·inout, 클래스 공유, 클래스가 들고 있는 구조체 배열의 동작을 차례로 출력한다.
struct DailyRecord {
let day: Int
var amount: Int
}
struct Habit {
let name: String
var goalPerDay: Int
var records: [DailyRecord] = []
var totalAmount: Int {
records.reduce(0) { $0 + $1.amount }
}
var achievedDays: Int {
records.filter { $0.amount >= goalPerDay }.count
}
var summary: String {
"\(name): 합계 \(totalAmount), 달성 \(achievedDays)일"
}
mutating func log(day: Int, amount: Int) {
if let index = records.firstIndex(where: { $0.day == day }) {
records[index].amount += amount
} else {
records.append(DailyRecord(day: day, amount: amount))
}
}
}
final class HabitBoard {
var title: String
var habits: [Habit]
init(title: String, habits: [Habit] = []) {
self.title = title
self.habits = habits
}
var habitCount: Int { habits.count }
}
func logGlass(of habit: inout Habit, day: Int) {
habit.log(day: day, amount: 1)
}
func rename(_ board: HabitBoard, to newTitle: String) {
board.title = newTitle
}
print("== 1. 멤버와이즈 초기화 ==")
let reading = Habit(
name: "독서",
goalPerDay: 30,
records: [DailyRecord(day: 1, amount: 40), DailyRecord(day: 2, amount: 20)]
)
print(reading.summary)
print("== 2. 값 타입: 복사본은 독립이다 ==")
var water = Habit(name: "물 마시기", goalPerDay: 8)
water.log(day: 1, amount: 8)
water.log(day: 2, amount: 5)
var waterCopy = water
waterCopy.log(day: 2, amount: 3)
print(water.summary)
print(waterCopy.summary)
print("== 3. mutating 과 inout ==")
logGlass(of: &water, day: 3)
logGlass(of: &water, day: 3)
print(water.summary)
print("== 4. 참조 타입: 같은 객체를 가리킨다 ==")
let morning = HabitBoard(title: "아침 보드", habits: [water])
let alias = morning
rename(alias, to: "아침 루틴")
print(morning.title)
print(morning === alias)
let other = HabitBoard(title: "아침 루틴", habits: [water])
print(morning === other)
print(morning.title == other.title)
morning.habits[0].log(day: 4, amount: 8)
print(morning.habits[0].summary)
print(alias.habits[0].summary)
print(water.summary)
줄별 해설
struct DailyRecord: 날짜(let)와 양(var)을 묶은 값 타입이다. 초기화 코드가 없어도DailyRecord(day:amount:)로 만들 수 있다.struct Habit: 이름은let, 목표량과 기록은var다.records에 기본값[]가 있어Habit(name:goalPerDay:)처럼 기록을 생략한 호출도 가능하다.totalAmount: 저장하지 않고 호출할 때마다records의amount를reduce로 더한다. 기록이 바뀌면 다음 접근에서 자동으로 새 값이 나온다.achievedDays: 하루 양이 목표 이상인 기록만filter로 걸러 개수를 센다. 클로저 안의goalPerDay는 이 인스턴스의 프로퍼티다.summary: 문자열 보간으로 위 두 계산 프로퍼티를 이어 붙인다. 다른 계산 프로퍼티를 읽는 계산 프로퍼티도 만들 수 있다.mutating func log: 같은 날 기록이 있으면firstIndex(where:)로 위치를 찾아 양을 더하고, 없으면 새 기록을 덧붙인다.records를 바꾸므로mutating이 필요하다.final class HabitBoard: 제목과 습관 목록을 가진 참조 타입이다. 클래스에는 멤버와이즈 초기화가 없으므로init을 직접 썼고, 모든 프로퍼티에 값을 채운 뒤 끝낸다.logGlass(of:day:):inout인자 덕분에 호출한 쪽의 변수를 직접 바꾼다. 함수 안에서는habit.log로mutating메서드를 부를 수 있다.rename(_:to:): 클래스는 참조가 넘어가므로inout이 없어도 호출한 쪽의 객체 내용이 바뀐다. 첫 인자 라벨은_로 생략했다.- 1번 구역: 멤버와이즈 초기화에 기록 배열을 직접 넘긴다. 독서는 1일에 40(달성), 2일에 20(미달)이므로 합계 60, 달성 1일이다.
- 2번 구역:
var waterCopy = water는 값을 복사한다. 복사본의 2일 기록에 3을 더해도 원본water는 그대로이므로 합계가 13과 16으로 갈린다. - 3번 구역:
&water로 변수 자체를 넘겨 3일에 1을 두 번 더한다. 3일 양은 2이고 목표 8에 못 미치므로 합계만 15로 늘고 달성 일수는 1일 그대로다. - 4번 구역:
alias는morning과 같은 객체이므로alias로 제목을 바꾸면morning.title도 바뀐다.===는alias와true, 따로 만든other와는false이고 제목 문자열만 비교한==는true다. - 마지막 세 줄:
morning.habits[0]은 보드를 만들 때water에서 복사된 값이다. 4일에 8을 더하면 합계 23, 달성 2일이 되고,alias는 같은 보드이므로 똑같이 보인다. 그러나 보드 밖의water는 복사 이후의 변경과 무관하게 합계 15에 달성 1일이다.
실행 결과
$ swiftc main.swift -o app
$ ./app
== 1. 멤버와이즈 초기화 ==
독서: 합계 60, 달성 1일
== 2. 값 타입: 복사본은 독립이다 ==
물 마시기: 합계 13, 달성 1일
물 마시기: 합계 16, 달성 2일
== 3. mutating 과 inout ==
물 마시기: 합계 15, 달성 1일
== 4. 참조 타입: 같은 객체를 가리킨다 ==
아침 루틴
true
false
true
물 마시기: 합계 23, 달성 2일
물 마시기: 합계 23, 달성 2일
물 마시기: 합계 15, 달성 1일
실무에서 자주 틀리는 것
1. let 으로 선언한 구조체에 mutating 메서드를 부른다
틀린 코드:
let run = Habit(name: "운동", goalPerDay: 30)
run.log(day: 1, amount: 30)
// 오류: cannot use mutating member on immutable value: 'run' is a 'let' constant
구조체는 이름과 값이 한 몸이라 let 이면 내용도 고정된다. 기록을 쌓아 갈 값이라면 처음부터 var 로 선언한다.
var run = Habit(name: "운동", goalPerDay: 30)
run.log(day: 1, amount: 30)
2. for 문의 반복 변수를 고치면 원본이 바뀐다고 생각한다
틀린 코드:
var habits = [water, reading]
for var habit in habits {
habit.log(day: 9, amount: 1)
}
// habits 안의 값은 하나도 바뀌지 않는다
반복 변수 habit 은 배열 요소의 복사본이다. 복사본에 기록을 더했다가 반복이 끝나면 버려진다. 인덱스로 배열 요소에 직접 접근해야 한다.
for index in habits.indices {
habits[index].log(day: 9, amount: 1)
}
3. 클래스 인스턴스를 == 로 비교하려 한다
틀린 코드:
if morning == other {
print("같은 보드")
}
// 오류: binary operator '==' cannot be applied to two 'HabitBoard' operands
클래스는 == 의 비교 방법을 정해 주지 않으면 쓸 수 없다. 같은 객체인지 묻고 싶다면 === 를, 내용을 비교하고 싶다면 제목처럼 비교할 프로퍼티를 직접 꺼내 비교한다.
if morning === other {
print("같은 객체")
}
if morning.title == other.title {
print("제목이 같다")
}
4. 클래스에 멤버와이즈 초기화가 있다고 가정한다
틀린 코드:
final class StreakCounter {
var days: Int
}
// 오류: class 'StreakCounter' has no initializers
클래스의 저장 프로퍼티는 인스턴스가 만들어지는 시점에 모두 값이 있어야 하는데, 구조체와 달리 초기화 함수를 자동으로 만들어 주지 않는다. 기본값을 주거나 init 을 쓴다.
final class StreakCounter {
var days: Int = 0
}
final class StreakCounter2 {
var days: Int
init(days: Int) {
self.days = days
}
}
한눈에 보기
| 용어 | 의미 | 예 |
|---|---|---|
| 값 타입 | 대입·전달 때 복사된다 | var b = a 뒤 b 만 고쳐도 a 는 그대로 |
| 참조 타입 | 대입·전달 때 같은 인스턴스를 가리킨다 | let b = a 뒤 b.title 을 고치면 a.title 도 바뀜 |
| 멤버와이즈 초기화 | 구조체에 자동으로 생기는 초기화 | DailyRecord(day: 1, amount: 8) |
mutating | 구조체 메서드가 자기 프로퍼티를 바꾼다는 표시 | mutating func log(day:amount:) |
| 계산 프로퍼티 | 저장하지 않고 접근할 때 계산한다 | var totalAmount: Int { ... } |
inout 과 & | 호출한 쪽 변수를 직접 바꾸게 넘긴다 | logGlass(of: &water, day: 3) |
=== | 두 이름이 같은 객체인지 확인한다 | morning === alias |
| 고르는 기준 | 공유가 필요하면 class, 아니면 struct | 기록·습관은 struct, 공유 보드는 class |
연습 문제
reps(한 세트 횟수)와sets(세트 수)를 가진 구조체ExerciseSet을 만들고, 총 횟수를 돌려주는 계산 프로퍼티totalReps를 추가하라. 멤버와이즈 초기화로 12회 3세트를 만들어 총 횟수를 출력하라.- 다음 코드의 출력을 예상하라.
struct Counter { var value = 0 mutating func up() { value += 1 } } final class Box { var value = 0 } var c1 = Counter() var c2 = c1 c2.up() let b1 = Box() let b2 = b1 b2.value += 1 print(c1.value, c2.value, b1.value, b2.value) - 이 장의
Habit에 기록을 모두 지우는reset()메서드를 추가하라. 이 메서드에mutating이 필요한 이유와,let으로 선언한Habit에서 호출했을 때 어떻게 되는지 설명하라. - 다음 세 가지를 각각 struct 와 class 중 무엇으로 만들지 고르고 이유를 한 문장으로 써라. (가) 하루 기록 한 줄 (나) 오늘 화면과 통계 화면이 함께 읽고 고치는 알림 설정 (다) 한 달 통계 계산 결과
정답과 해설
- 멤버와이즈 초기화의 인자 순서는 선언 순서다.
출력은struct ExerciseSet { var reps: Int var sets: Int var totalReps: Int { reps * sets } } let pushUp = ExerciseSet(reps: 12, sets: 3) print(pushUp.totalReps)36이다. 총 횟수는reps와sets로 언제든 계산되므로 저장 프로퍼티로 두지 않고 계산 프로퍼티로 만들었다. 이렇게 하면reps를 고칠 때 총 횟수를 따로 갱신할 필요가 없다. - 출력은
0 1 1 1이다.Counter는 구조체라서var c2 = c1에서 값이 복사되고,c2.up()은 복사본만 바꾼다.Box는 클래스라서b1과b2가 같은 인스턴스를 가리키므로b2.value를 바꾸면b1.value도 1이 된다. - 예시 구현은 다음과 같다.
구조체의 메서드가 자기 프로퍼티를 바꾸는 것은 변수에 담긴 값 전체를 교체하는 일이므로, 그 사실을 알리는mutating func reset() { records = [] }mutating이 필요하다. 붙이지 않으면 "self is immutable" 계열의 컴파일 오류가 난다.let으로 선언한Habit에서reset()을 부르면 상수는 바꿀 수 없다는 컴파일 오류가 나므로, 호출하려면var로 선언해야 한다. - (가) struct: 복사해도 되고 같은 내용이면 같은 기록으로 봐도 되는 값이기 때문이다. (나) class: 두 화면이 한 설정 객체를 공유하고 한쪽의 변경이 다른 쪽에 바로 보여야 하기 때문이다. (다) struct: 계산이 끝난 뒤에는 읽기만 하는 결과 값이고, 복사해서 다른 곳에 넘겨도 문제가 없기 때문이다.