Swift 클래스와 구조체 | Class, Struct, Enum
이 글의 핵심
Swift는 다른 언어보다 struct를 기본으로 권하는데, 값 복사 덕분에 한 곳의 수정이 다른 곳에 퍼지지 않기 때문입니다. 은행 계좌 예제로 class와 struct를 바꿔 가며 동작 차이를 확인하고, 공유된 상태나 상속이 필요할 때 class를 고르는 기준과 ARC 순환 참조를 피하는 방법을 정리합니다.
들어가며
클래스는 참조 의미론(여러 변수가 같은 인스턴스를 공유), 구조체와 열거형은 값 의미론(대입할 때마다 독립된 복사본)을 따릅니다. 흔히 “구조체는 스택, 클래스는 힙”이라고 설명하지만, 이는 대략적인 경향일 뿐입니다. 클래스 인스턴스 안에 들어 있거나 클로저에 캡처된 구조체는 힙에 있고, 컴파일러가 최적화로 클래스 인스턴스를 스택에 두기도 합니다. 선택 기준으로 삼아야 할 것은 메모리 위치가 아니라 “이 값을 복사했을 때 원본과 따로 움직여야 하는가, 같이 움직여야 하는가”입니다.
Swift가 다른 언어와 달리 구조체를 기본으로 권하는 이유는 공유 상태가 버그의 큰 원인이기 때문입니다. 클래스 인스턴스를 여러 화면이나 스레드에 넘기면 어느 한 곳의 수정이 다른 곳에 조용히 반영되고, 누가 언제 바꿨는지 추적하기 어려워집니다. 값 타입은 넘겨받은 쪽이 자기 복사본만 바꿀 수 있으므로 이런 원격 영향이 원천적으로 차단됩니다. Swift 표준 라이브러리의 String, Array, Dictionary가 모두 구조체인 것도 같은 설계 철학입니다.
클래스: 참조 타입과 상속
기본 클래스
class Person {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
func introduce() {
print("안녕하세요, \(name)입니다.")
}
}
let person = Person(name: "홍길동", age: 25)
person.introduce()
참조 타입
// 타입 정의
class Counter {
var count = 0
func increment() {
count += 1
}
}
let counter1 = Counter()
let counter2 = counter1 // 같은 인스턴스 참조
counter1.increment()
print(counter2.count) // 1 (같은 객체)
counter1을 let으로 선언했는데도 increment()로 내부 값을 바꿀 수 있다는 점이 클래스의 특징입니다. 클래스에서 let은 “이 변수가 다른 인스턴스를 가리키지 못한다”는 뜻일 뿐 인스턴스 내용을 고정하지 않습니다. 반면 구조체를 let으로 선언하면 모든 프로퍼티가 읽기 전용이 되어 mutating 메서드도 호출할 수 없습니다. 같은 let이 두 타입에서 다른 의미를 갖는다는 것이 처음 Swift를 배울 때 가장 자주 헷갈리는 지점입니다. 두 변수가 같은 인스턴스인지 확인하려면 ==가 아니라 동일성 연산자 ===를 씁니다.
상속
class Vehicle {
var speed: Int = 0
func describe() {
print("속도: \(speed)km/h")
}
}
class Car: Vehicle {
var brand: String
init(brand: String) {
self.brand = brand
super.init()
}
override func describe() {
print("\(brand) 자동차, 속도: \(speed)km/h")
}
}
let car = Car(brand: "현대")
car.speed = 100
car.describe()
구조체: 값 타입과 계산 프로퍼티
기본 구조체
struct Point {
var x: Int
var y: Int
mutating func moveBy(x: Int, y: Int) {
self.x += x
self.y += y
}
func distance() -> Double {
return sqrt(Double(x * x + y * y))
}
}
var point = Point(x: 0, y: 0)
point.moveBy(x: 10, y: 20)
print("(\(point.x), \(point.y))")
값 타입
struct Rectangle {
var width: Int
var height: Int
}
var rect1 = Rectangle(width: 10, height: 20)
var rect2 = rect1 // 복사됨
rect1.width = 30
print(rect1.width) // 30
print(rect2.width) // 10 (독립적)
계산 프로퍼티
struct Circle {
var radius: Double
var area: Double {
return Double.pi * radius * radius
}
var circumference: Double {
return 2 * Double.pi * radius
}
}
let circle = Circle(radius: 5)
print("넓이: \(circle.area)")
print("둘레: \(circle.circumference)")
열거형: 연관 값·원시 값·메서드
기본 열거형
enum Status {
case active
case inactive
case pending
}
var status = Status.active
switch status {
case .active:
print("활성")
case .inactive:
print("비활성")
case .pending:
print("대기")
}
연관 값
enum Result {
case success(String)
case failure(String)
}
func processResult(_ result: Result) {
switch result {
case .success(let message):
print("성공: \(message)")
case .failure(let error):
print("실패: \(error)")
}
}
processResult(.success("데이터 로드 완료"))
processResult(.failure("네트워크 에러"))
원시 값
enum Direction: Int {
case north = 0
case south = 1
case east = 2
case west = 3
}
let direction = Direction.north
print(direction.rawValue) // 0
if let dir = Direction(rawValue: 2) {
print(dir) // east
}
열거형 메서드
enum TrafficLight {
case red
case yellow
case green
func duration() -> Int {
switch self {
case .red: return 60
case .yellow: return 5
case .green: return 45
}
}
}
let light = TrafficLight.red
print("신호 시간: \(light.duration())초")
Class와 Struct 차이 비교
| 특징 | Class | Struct |
|---|---|---|
| 타입 | 참조 타입 | 값 타입 |
| 상속 | 가능 | 불가능 |
| Deinit | 있음 | 없음 |
| 복사 | 참조 복사 | 값 복사 |
| 사용 예 | 복잡한 객체 | 간단한 데이터 |
예제: 은행 계좌 시스템
class BankAccount {
let accountNumber: String
private(set) var balance: Double
init(accountNumber: String, initialBalance: Double) {
self.accountNumber = accountNumber
self.balance = initialBalance
}
func deposit(_ amount: Double) {
guard amount > 0 else { return }
balance += amount
print("입금: \(amount)원, 잔액: \(balance)원")
}
func withdraw(_ amount: Double) -> Bool {
guard amount > 0 && amount <= balance else {
print("출금 실패")
return false
}
balance -= amount
print("출금: \(amount)원, 잔액: \(balance)원")
return true
}
}
struct Transaction {
let type: TransactionType
let amount: Double
let date: Date
enum TransactionType {
case deposit
case withdrawal
}
}
let account = BankAccount(accountNumber: "123-456", initialBalance: 10000)
account.deposit(5000)
account.withdraw(3000)
이 예제에서 BankAccount를 클래스로, Transaction을 구조체로 만든 데는 이유가 있습니다. 계좌는 “같은 계좌”라는 정체성이 중요합니다. 이체 화면과 잔액 화면이 같은 계좌를 보고 있다면 한쪽에서 입금한 결과가 다른 쪽에도 보여야 하므로 참조 타입이 자연스럽습니다. 만약 BankAccount를 구조체로 바꾸면 deposit과 withdraw에 mutating을 붙여야 하고, let account는 var로 바꿔야 하며, 함수에 계좌를 넘겨 입금하면 함수 안의 복사본만 바뀌어 호출한 쪽의 잔액은 그대로인 결과가 나옵니다. 반대로 거래 내역은 한 번 만들어지면 바뀌지 않는 값이므로 구조체와 let 프로퍼티가 잘 맞습니다.
private(set)은 외부에서 balance를 읽을 수는 있지만 직접 대입할 수는 없게 해, 잔액 변경이 반드시 검증 로직이 있는 deposit·withdraw를 거치게 합니다. 또 예제는 설명을 위해 금액을 Double로 다뤘지만, 부동소수점은 0.1 같은 값을 정확히 표현하지 못해 합계에 오차가 쌓이므로 실제 금융 코드에서는 Decimal이나 최소 화폐 단위의 정수를 씁니다. 여러 스레드에서 같은 계좌에 동시에 입출금한다면 클래스만으로는 데이터 경쟁을 막을 수 없으므로, Swift 5.5 이상에서는 class 대신 actor로 선언하는 방법을 검토할 만합니다.
클래스와 구조체 선택 기준 (참조 vs 값)
기본 원칙: 모델은 struct로 시작하며, 정말 필요할 때만 class를 씁니다.
| 상황 | 추천 | 이유 |
|---|---|---|
| 좌표, DTO, 설정 값 묶음 | struct | 복사가 안전하며, 스레드 간 공유 이슈가 적음 |
| 동일 인스턴스를 여러 곳에서 공유해야 함 | class | 참조가 하나로 유지됨 |
UIKit UIViewController 등 Cocoa API | class | 프레임워크가 class 요구 |
| 상속·다형성이 필수 | class | struct는 상속 불가 |
| 큰 데이터를 자주 복사하면 부담 | class 또는 struct + Copy-on-Write 타입 활용 | 배열·딕셔너리 등은 이미 COW |
값 타입: 대입·전달 시 복사되어 서로 독립적입니다. 참조 타입: 같은 객체를 가리키므로 한쪽 수정이 다른 참조에도 반영됩니다. SwiftUI에서 @State로 다루는 모델은 보통 값 타입(struct)에 두며, 공유 상태는 ObservableObject 등으로 참조를 관리합니다.
프로토콜 배열로 다형성 구현하기
프로토콜 타입으로 배열을 다루면 런타임에 실제 타입에 맞는 동작을 선택할 수 있습니다(다형성).
protocol Drawable {
func draw()
}
class CircleModel: Drawable {
var radius: Double
init(radius: Double) { self.radius = radius }
func draw() { print("원 그리기 r=\(radius)") }
}
class RectangleModel: Drawable {
var width: Double, height: Double
init(width: Double, height: Double) {
self.width = width
self.height = height
}
func draw() { print("사각형 그리기 \(width)x\(height)") }
}
func renderAll(_ items: [Drawable]) {
for item in items {
item.draw() // 실제 타입에 따라 다른 구현 호출
}
}
let shapes: [Drawable] = [CircleModel(radius: 3), RectangleModel(width: 2, height: 4)]
renderAll(shapes)
상속은 기본 동작 공유와 override에 쓰며, 역할(역할 기반 설계)은 프로토콜로 나누는 조합이 흔합니다.
init과 deinit 활용
- init: 모든 저장 프로퍼티가 초기화된 뒤에만
self사용 가능. 지정 이니셜라이저 체인(super.init등) 규칙을 지켜야 합니다. - 편의 이니셜라이저(
convenience init): 같은 클래스 안의 다른init을 호출해 중복 코드를 줄입니다. - deinit: class 전용, 참조 카운트가 0이 될 때 호출. 파일 닫기, 타이머 해제,
NotificationCenter구독 해제 등 정리 작업에 사용합니다.
class Resource {
let name: String
init(name: String) {
self.name = name
print("\(name) 열림")
}
convenience init?(named: String?) {
guard let n = named, !n.isEmpty else { return nil }
self.init(name: n)
}
deinit {
print("\(name) 정리(deinit)")
}
}
struct에는 deinit이 없습니다. 값이 스코프를 벗어나면 저장 프로퍼티들이 함께 정리되고, 그 안에 클래스 참조가 있다면 참조 카운트만 줄어듭니다.
클래스의 이니셜라이저 규칙이 까다로운 이유는 상속 때문입니다. Swift는 2단계 초기화를 강제해서, 1단계에서는 자기 클래스의 저장 프로퍼티를 모두 채운 뒤 super.init()을 호출하고, 그 이후 2단계에서야 self의 메서드를 부르거나 상속받은 프로퍼티를 수정할 수 있습니다. 앞의 Car 예제에서 self.brand = brand를 super.init()보다 먼저 쓴 것이 이 순서이며, 순서를 바꾸면 “Property ‘self.brand’ not initialized at super.init call” 에러가 납니다. 이 규칙 덕분에 부모 이니셜라이저가 오버라이드된 메서드를 호출하더라도 자식의 프로퍼티가 초기화되지 않은 상태로 쓰이는 일이 생기지 않습니다. 구조체는 상속이 없으므로 이런 규칙 없이 멤버별 이니셜라이저가 자동으로 만들어집니다.
프로퍼티 옵저버 (willSet, didSet)
저장 프로퍼티에 값이 바뀔 때 추가 로직을 넣을 수 있어 UI 동기화·로깅·유효성 검사에 많이 씁니다. 다만 자기 타입의 이니셜라이저 안에서 값을 설정할 때는 옵저버가 호출되지 않습니다. 초기값으로 로그를 남기거나 화면을 갱신하려고 didSet에 코드를 넣었는데 첫 값에서는 아무 일도 일어나지 않는다면 이 규칙 때문입니다. 예외적으로 서브클래스의 이니셜라이저가 super.init() 이후에 부모의 프로퍼티를 바꾸면 부모에 정의된 옵저버는 호출됩니다.
struct Temperature {
var celsius: Double {
willSet {
print("곧 \(celsius) → \(newValue)")
}
didSet {
if celsius < -273.15 {
celsius = -273.15 // 자기 didSet 안의 대입은 옵저버를 다시 호출하지 않음
}
}
}
}
willSet의 암시 매개변수 이름은 newValue, didSet은 oldValue입니다.
ARC와 weak, unowned로 순환 참조 끊기
Swift는 ARC(Automatic Reference Counting)로 참조 횟수를 관리합니다. 강한 참조 순환이 있으면 두 인스턴스가 서로를 잡고 있어 해제되지 않을 수 있습니다.
- weak: 옵셔널 참조, 카운트에 포함하지 않음. 상대가 먼저 없어지면 자동으로
nil. 델리게이트, 클로저 캡처, 부모–자식 중 한쪽만 소유할 때 자주 사용합니다. - unowned: 약한 참조이지만 옵션이 아님. 상대 수명이 항상 자기보다 길거나 같다고 보장될 때만 사용. 잘못 쓰면 댕글링 참조로 크래시 가능.
// 타입 정의
class Parent {
var child: Child?
deinit { print("Parent deinit") }
}
class Child {
weak var parent: Parent?
init(parent: Parent) {
self.parent = parent
}
deinit { print("Child deinit") }
}
클로저에서는 [weak self] 또는 [unowned self]로 캡처해 self ↔ 클로저 순환을 끊습니다.
위 예제에서 Child.parent가 weak가 아니라면, Parent는 child를, Child는 parent를 강하게 잡고 있어 바깥 변수를 모두 nil로 만들어도 두 객체의 참조 카운트가 1에서 멈춥니다. 이때 증상은 에러가 아니라 deinit이 호출되지 않는 것입니다. 화면을 닫았는데 뷰 컨트롤러의 deinit 로그가 찍히지 않고, 화면을 열고 닫을 때마다 메모리가 조금씩 늘어난다면 대부분 이런 순환 참조입니다. 제가 가장 흔하게 만나는 경우는 뷰 컨트롤러가 가진 클로저(네트워크 콜백, 타이머, NotificationCenter 블록 등) 안에서 self를 그대로 쓰는 패턴이며, Xcode의 Debug Memory Graph를 열면 서로를 가리키는 객체 고리가 보라색 경고와 함께 표시되어 원인을 빠르게 찾을 수 있습니다. 소유 관계가 분명한 쪽(부모가 자식을 소유)은 강한 참조로, 반대 방향(자식이 부모를 참조)은 weak로 두는 것이 기본 규칙입니다.
클래스·구조체·열거형 요약
- Class: 참조 타입, 상속 가능
- Struct: 값 타입, 복사됨
- Enum: 연관 값, 메서드 가능
- mutating: Struct 메서드에서 수정
- 선택 기준: 공유·상속·UIKit 연동은 class, 대부분의 모델은 struct
- 다형성: 프로토콜 타입과
override로 실제 구현 분기 - init / deinit: 초기화 규칙과 해제 시 정리
- willSet / didSet: 값 변경 시 부가 로직
- ARC:
weak·unowned로 순환 참조 방지
다음 단계
같이 보면 좋은 글
- Rust 구조체와 열거형 | Struct, Enum, Pattern Matching
- C++ Aggregate Initialization
- C++ 클래스와 객체
- C++ struct vs class
- Kotlin 클래스와 객체 | 클래스, 상속, 인터페이스
- Java 변수와 타입 | 기본 타입, 참조 타입, 형변환
자주 묻는 질문 (FAQ)
Q. weak와 unowned는 어떻게 골라 쓰나요?
A. 둘 다 참조 카운트를 올리지 않아 강한 참조 순환을 끊는 데 쓰지만, weak는 옵셔널이라서 상대가 먼저 해제되면 자동으로 nil이 됩니다. unowned는 옵셔널이 아니므로 상대의 수명이 항상 자기보다 길거나 같다고 보장될 때만 써야 하고, 그 보장이 깨지면 해제된 객체에 접근해 크래시가 납니다. 델리게이트나 클로저의 self 캡처처럼 수명이 불확실하면 weak를 기본으로 선택하는 것이 안전합니다.