구조체와 클래스에 대해서 이야기하면 으레 객체지향 이야기가 따라나온다. 하지만 그 이야기를 하기 전에, 이 둘을 왜 ‘사용자 정의 데이터 타입’이라고 부르는지부터 먼저 짚어보고자 한다.
우리는 앞서 기본 데이터 타입에 대해 배웠다. 프로그램에게 전달하는 데이터를 프로그램이 이해할 수 있는 단위로 만들기 위한 방식이다. 정수, 실수 같은 기본 데이터 타입은 컴퓨터가 값을 어떤 방식으로 이해하고 다룰지를 정해주는 약속이다. 프로그램이 데이터를 처리할 수 있게 해 주는 가장 기본적인 도구다.
그렇다면 사용자 정의 데이터 타입이란 무엇일까. 말 그대로 사용자가 정의하는 데이터 타입, 즉 사용자가 직접 만들어 사용하는 데이터 타입이다. 이러한 개념이 등장한 이유는 우리가 풀려는 문제가 점점 복잡해졌기 때문이다. 문제의 복잡도가 높아질수록 프로그램이 다루어야 하는 데이터 역시 복잡해졌고, 단순한 기본 데이터 타입만으로는 이를 효율적으로 표현하기 어려워졌다.
그래서 프로그래밍 언어들은 사용자가 표현하고자 하는 데이터를 직접 정의할 수 있도록 새로운 수단을 제공하게 되었고, 그 대표적인 형태가 구조체다.
참고로 이와 함께 도입된 또 하나의 중요한 개념이 배열이다. 배열과 구조체의 차이는 다음과 같이 정리할 수 있다.
배열 : 동일한 데이터형들의 집합
구조체 : 서로 다른 데이터형들의 집합
이러한 사용자 정의 데이터 타입의 도입은 데이터를 단순한 값의 나열이 아니라 의미를 가진 구조화된 정보로 다룰 수 있게 만들었고, 프로그램의 표현력을 크게 확장시켰다.
하지만 데이터가 구조화되었다고 해서 문제가 완전히 해결된 것은 아니었다. 구조체나 배열은 데이터를 하나의 단위로 묶을 수 있었지만, 그 데이터를 처리하는 함수들은 여전히 별도의 영역에 존재했다. 특정 구조체를 다루기 위한 함수들이 존재하긴 했지만, 코드 상에서는 서로 분리되어 흩어져 있었고, 이로 인해 관련 코드의 응집도가 낮아졌다. 어떤 데이터에 어떤 동작이 허용되는지 한눈에 파악하기 어려웠고, 잘못된 데이터와 잘못된 함수가 결합되는 실수도 쉽게 발생할 수 있었다.
즉, 데이터는 하나의 단위로 묶였지만 그 데이터가 수행할 수 있는 행동은 여전히 외부에 존재하는 상태였다. 데이터의 구조는 정의되었지만, 그 의미는 아직 완결되지 않은 셈이다.
이러한 한계를 해결하기 위해 등장한 개념이 데이터와 행동을 하나의 경계 안에 함께 두는 방식이다. 단순히 구조체에 함수를 추가하는 것이 아니라, 특정 데이터에 속한 행동을 그 데이터의 일부로 간주하는 개념적 전환이 이루어진 것이다. 이 발상이 구체화된 형태가 바로 클래스다.
클래스는 구조체를 확장한 개념으로 이해할 수 있다. 구조체가 “어떤 데이터인가”를 정의한다면, 클래스는 “이 데이터는 무엇을 할 수 있는가”까지 함께 정의한다. 데이터와 그 데이터를 다루는 연산을 하나의 단위로 묶음으로써 타입은 단순한 데이터 형식이 아니라 의미를 가진 개념 단위가 된다. 이 관점에서 보면 구조체가 데이터를 묶었다면, 클래스는 데이터와 그 의미를 함께 묶었다고 할 수 있다.
이후 현대의 프로그래밍 언어들은 한 걸음 더 나아가 타입 자체가 본질적으로 행위를 가지도록 설계되기 시작했다. 더 이상 데이터와 기능의 결합은 특별한 선택이 아니라 기본 전제가 되었다. 어떤 언어는 구조체라는 구분 없이 모든 사용자 정의 타입을 클래스 형태로 제공하기도 하고, 구조체와 클래스를 모두 제공하는 언어에서는 둘의 차이를 메소드의 유무가 아니라 의미론의 차이로 설명한다. 대표적으로 값으로 전달되는 타입과 참조로 공유되는 타입이라는 구분이 그것이다. 즉, 구조체와 클래스의 구분은 “무엇을 담을 수 있는가”가 아니라 “어떻게 존재하고 전달되는가”의 차이로 재해석된 것이다.
정리하면 배열과 구조체는 복잡해진 데이터를 표현하기 위해 등장했고, 클래스는 데이터와 행동을 하나의 단위로 묶어 프로그램의 복잡성을 제어하기 위해 등장했다. 그리고 오늘날의 타입은 단순한 데이터 형식이 아니라 상태와 행위를 함께 포함하는 의미 단위로 발전하였다.
1. 구조체와 클래스 차이
스위프트에서 구조체와 클래스는 문법적으로 매우 유사하지만, 존재 방식에서 근본적인 차이를 가진다.
가장 핵심적인 구분은 다음과 같다.
값 타입 vs 참조 타입
구조체는 값 타입이고, 클래스는 참조 타입이다.
구조체 인스턴스는 다른 변수에 전달되거나 할당될 때 값이 복사되어 독립적인 상태를 가진다.
반면 클래스 인스턴스는 참조가 전달되므로 여러 곳에서 동일한 인스턴스를 공유하게 된다.
이 차이는 이후의 모든 특성 차이로 이어진다.
상속 가능 여부
클래스는 상속을 지원한다. 기존 타입을 확장하거나 재정의하는 계층 구조를 만들 수 있다.
구조체는 상속을 지원하지 않으며, 타입 확장은 프로토콜 채택과 확장(extension)을 통해 이루어진다.
상태 변경 방식
구조체는 값 타입이므로 인스턴스가 불변이면 내부 프로퍼티도 변경할 수 없다.
구조체의 메소드가 프로퍼티를 변경하려면 mutating 선언이 필요하다.
클래스는 참조 타입이므로 인스턴스가 상수로 선언되어도 내부 상태 변경이 가능하다.
메모리 관리 방식
클래스 인스턴스는 자동 참조 카운팅에 의해 생명 주기가 관리된다.
여러 참조가 존재할 수 있으며, 참조가 모두 사라질 때 메모리에서 해제된다.
구조체는 값 자체가 전달되므로 별도의 참조 관리가 존재하지 않는다.
동일성 비교 가능 여부
클래스는 동일한 인스턴스를 여러 곳에서 참조할 수 있기 때문에 “같은 객체인가”를 비교할 수 있다.
구조체는 값이 복사되므로 동일성 개념이 없고 값의 내용만 비교할 수 있다.
소멸자 존재 여부
클래스는 인스턴스 해제 시 실행되는 소멸자를 가질 수 있다.
구조체는 참조 생명 주기가 없기 때문에 소멸자를 정의할 수 없다.
초기화 구조
클래스는 상속을 고려한 초기화 규칙을 가진다.
구조체는 기본적으로 멤버 단위 초기화가 자동으로 제공되며 초기화 구조가 단순하다.
⸻
정리하면,
구조체는 값으로 존재하는 독립적인 데이터 단위이고,
클래스는 참조로 공유되는 객체 단위다.
두 타입의 문법적 차이보다 중요한 것은
데이터가 복사되는가, 공유되는가라는 존재 방식의 차이다.
⸻
2. 프로퍼티
프로퍼티는 타입이 가지는 상태(state)를 표현하는 구성 요소다. 값이 실제로 저장되는가, 계산을 통해 제공되는가에 따라 여러 형태로 나뉜다.
참고로 사용자 정의 데이터 타입의 구성 요소는 언어와 관점에 따라 member, attribute, property 등 다양한 이름으로 불린다. 이들은 모두 타입을 이루는 요소를 가리키지만, 무엇에 초점을 두는지에 따라 용어가 달라진다.
member나 field는 내부에 저장된 데이터라는 구현 관점을 강조한 표현이고, attribute는 타입이 가지는 성질이라는 개념적 관점을 반영한 표현이다. 반면 property는 외부에서 접근 가능한 값이라는 인터페이스 관점을 중심으로 한 용어다.
애플 플랫폼의 언어들은 타입의 상태를 내부 저장 구조가 아니라 외부에 제공되는 값의 형태로 바라보았고, 이러한 관점을 반영하여 property라는 명칭을 사용하게 되었다.
저장 프로퍼티 (Stored Property)
저장 프로퍼티는 값을 메모리에 실제로 보관하는 프로퍼티다.
구조체와 클래스 인스턴스가 생성될 때 함께 생성되며, 인스턴스의 상태를 직접적으로 표현한다.
struct User {
var name: String
var age: Int
}
let user = User(name: "Lee", age: 30)
print(user.name) // Lee
계산 프로퍼티 (Computed Property)
계산 프로퍼티는 값을 저장하지 않고, 다른 값으로부터 계산된 결과를 제공하는 프로퍼티다.
필요할 때마다 연산을 통해 값을 반환하며, getter와 setter를 통해 동작을 정의할 수 있다.
struct Temperature {
var celsius: Double
var fahrenheit: Double {
get {
return celsius * 9 / 5 + 32
}
set {
celsius = (newValue - 32) * 5 / 9
}
}
}
var temp = Temperature(celsius: 0)
print(temp.fahrenheit) // 32
temp.fahrenheit = 68
print(temp.celsius) // 20
지연 저장 프로퍼티 (Lazy Stored Property)
lazy 키워드로 선언된 저장 프로퍼티는 인스턴스 생성 시점이 아니라 처음 접근되는 시점에 초기화된다.
초기화 비용이 크거나, 필요할 때만 생성해도 되는 값에 사용된다.
class DataLoader {
init() {
print("데이터 로더 생성")
}
}
class ViewModel {
lazy var loader = DataLoader()
}
let vm = ViewModel()
print("아직 생성 안됨")
_ = vm.loader
// 여기서 "데이터 로더 생성" 출력
프로퍼티 옵저버 (Property Observer)
프로퍼티의 값이 변경될 때 특정 동작을 수행하도록 하는 기능이다.
• willSet : 값이 변경되기 직전에 호출된다.
• didSet : 값이 변경된 직후에 호출된다.
값의 변화에 반응해야 하는 로직을 명확하게 표현할 수 있다.
struct Score {
var value: Int = 0 {
willSet {
print("변경 예정 값:", newValue)
}
didSet {
print("이전 값:", oldValue)
}
}
}
var score = Score()
score.value = 10
// 변경 예정 값: 10
// 이전 값: 0
⸻
3. 메서드
메서드는 타입이 수행할 수 있는 행위(behavior) 를 정의한다.
어떤 대상에 속해 호출되는지에 따라 구분된다.
인스턴스 메서드
특정 인스턴스에 속한 메서드다.
인스턴스의 프로퍼티에 접근하고 상태를 변경할 수 있다.
struct Counter {
var value: Int = 0
mutating func increment() {
value += 1
}
func printValue() {
print(value)
}
}
var counter = Counter()
counter.increment()
counter.printValue() // 1
핵심 포인트
• 인스턴스 상태에 접근 가능
• 구조체에서 상태 변경 시 mutating 필요
self 사용
모든 타입의 인스턴스는 self라는 암시적 프로퍼티를 가지고 있으며, 이는 해당 인스턴스 자체를 나타낸다.
인스턴스 메서드 내에서 현재 인스턴스를 참조하기 위해 self 프로퍼티를 사용한다.
스위프트에서는 대부분의 경우 self를 명시적으로 작성하지 않아도 되며, 컴파일러가 현재 인스턴스를 자동으로 참조한다. 따라서 특별한 이유가 없다면 self를 생략하는 것이 일반적인 스타일이다.
다만 다음과 같은 경우에는 self를 명시적으로 사용해야 한다.
• 프로퍼티 이름과 매개변수 이름이 같을 때
• 클로저 내부에서 인스턴스를 명확히 참조해야 할 때
struct User {
var name: String
mutating func updateName(to name: String) {
self.name = name
}
}
이처럼 self는 현재 인스턴스를 명확히 가리킬 필요가 있을 때 사용된다.
타입 메서드
특정 인스턴스가 아니라 타입 자체에 속한 메서드다.
공통 동작이나 인스턴스와 무관한 기능을 정의할 때 사용된다.
struct MathUtils {
static func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
}
let result = MathUtils.add(3, 4)
print(result) // 7
클래스에서의 타입 메서드 예시:
class Person {
static func species() -> String {
return "Human"
}
}
print(Person.species()) // Human
핵심 포인트
• 인스턴스 생성 없이 호출
• 타입 이름으로 직접 접근
• 공통 기능 표현에 사용
⸻
4. 초기화와 소멸
초기화 (Initializer)
초기화는 인스턴스를 사용하기 위해 준비하는 과정이다.
스위프트에서는 이 과정을 init이라는 특별한 메서드를 통해 수행한다.
init은 일반 메서드와 달리 반환 타입을 가지지 않으며, 인스턴스가 생성되는 시점에 자동으로 호출된다. 초기화의 목적은 저장 프로퍼티에 값을 설정하여 인스턴스를 사용할 수 있는 상태로 만드는 것이다.
스위프트에서 중요한 원칙은 다음과 같다.
인스턴스가 생성되기 전에 모든 저장 프로퍼티는 반드시 초기화되어야 한다.
저장 프로퍼티는 선언 시 기본값을 가지거나, init 내부에서 값이 설정되어야 한다. 하나라도 초기화되지 않으면 인스턴스를 생성할 수 없다.
구조체는 기본적으로 멤버 단위 초기화가 자동으로 제공된다.
struct User {
var name: String
var age: Int
}
let user = User(name: "Lee", age: 30)
저장 프로퍼티에 기본값을 지정하면 별도의 초기화를 정의하지 않아도 된다.
struct Counter {
var value: Int = 0
}
let counter = Counter()
클래스 역시 init을 통해 모든 저장 프로퍼티가 초기화되어야 한다.
class Person {
var name: String
init(name: String) {
self.name = name
}
}
소멸 (Deinitializer)
소멸은 인스턴스가 메모리에서 해제되기 직전에 수행되는 과정이다.
스위프트에서는 이를 deinit이라는 특별한 메서드로 정의한다.
소멸은 자원 정리나 후처리가 필요한 경우 사용되며, 참조 타입인 클래스에서만 제공된다.
class Resource {
init() {
print("생성됨")
}
deinit {
print("해제됨")
}
}
var obj: Resource? = Resource()
obj = nil
⸻
5. 값 타입과 참조 타입
타입이 어떻게 전달되고 공유되는가를 결정하는 개념이다.
값 타입은 전달될 때 값이 복사되며, 각 인스턴스는 독립적인 상태를 가진다.
어떤 변수에 값을 대입하거나 함수에 전달하더라도 원본과 사본은 서로 영향을 주지 않는다.
참조 타입은 전달될 때 참조가 공유되며, 여러 변수가 동일한 인스턴스를 가리킬 수 있다.
하나의 인스턴스를 여러 곳에서 함께 사용하는 방식이다.
위에서 언급했듯이 스위프트에서
구조체는 값 타입이고,
클래스는 참조 타입이다.
값 타입 예제 (구조체)
struct Counter {
var value: Int
}
var a = Counter(value: 0)
var b = a
b.value = 10
print(a.value) // 0
print(b.value) // 10
b는 a의 복사본이므로 값을 변경해도 원본은 영향을 받지 않는다.
추후 언급하겠지만 실제 복사는 b.value = 10 이 단계에서 일어난다.
참조 타입 예제 (클래스)
class Counter {
var value: Int
init(value: Int) {
self.value = value
}
}
var a = Counter(value: 0)
var b = a // 같은 인스턴스를 참조
b.value = 10
print(a.value) // 10
print(b.value) // 10
a와 b는 동일한 인스턴스를 가리키므로 한쪽의 변경이 다른 쪽에도 반영된다.
참조 타입 사용 시 주의점
참조 타입은 여러 변수가 동일한 인스턴스를 공유하기 때문에,
어느 한 곳에서 상태를 변경하면 그 영향이 공유된 모든 곳에 전파된다.
이로 인해 상태를 변경하는 코드가 여러 위치에 흩어져 있을 경우
값이 언제, 어디서, 왜 변경되었는지 추적하기 어려워질 수 있다.
따라서 참조 타입은 공유가 필요한 경우에만 사용하고,
상태 변경의 범위를 명확하게 관리하는 것이 중요하다.
⸻
6. 상속과 오버라이딩
상속(Inheritance)은 기존 타입의 기능을 기반으로 새로운 타입을 정의하는 방식이다.
클래스는 다른 클래스의 프로퍼티, 메서드, 기타 특성을 상속(inherit) 할 수 있으며, 이를 통해 코드 재사용과 기능 확장이 가능해진다. 
상속 관계가 있는 두 타입을 표현할 때는 다음처럼 작성한다.
class Subclass: Superclass {
// ...
}
이 구조에서 Subclass는 하위 클래스(subclass), Superclass는 상위 클래스(superclass) 라 부른다. 
상속의 의미
상속은 단순히 코드를 복사해서 쓰는 것이 아니라
기능을 기반으로 확장하는 수직적 관계를 만든다.
즉, 기존 타입이 가진 특성을 재사용하면서, 필요한 부분을 세분화하거나 새 기능을 추가할 수 있다. 
기본 클래스(Base Class)
다른 클래스로부터 상속받지 않은 클래스를 기본 클래스라고 한다.
기본 클래스는 공통적인 기능과 상태를 정의하고, 이를 기반으로 여러 하위 클래스를 만들 수 있다. 
예를 들어 다음과 같은 기본 클래스가 있다.
class Vehicle {
var currentSpeed = 0.0
func makeNoise() {
// 기본 차량은 소리를 내지 않음
}
}
이 클래스는 “이동하는 대상”이라는 공통된 상태와 동작을 표현한다.
서브클래싱 (Subclassing)
하위 클래스는 상위 클래스의 모든 프로퍼티와 메서드를 자동으로 갖는다.
또한 새로운 프로퍼티나 메서드를 추가할 수도 있다. 
class Bicycle: Vehicle {
var hasBasket = false
}
let bike = Bicycle()
bike.hasBasket = true
bike.currentSpeed = 10.0
상속받은 상태와 메서드를 그대로 사용할 수 있다.
오버라이딩 (Overriding)
하위 클래스는 상위 클래스의 메서드나 프로퍼티를 자신만의 방식으로 다시 정의할 수 있다.
이것을 오버라이딩이라고 한다. 
오버라이딩을 할 때는 override 키워드를 반드시 사용해야 한다.
이 키워드는 컴파일러가 상위 클래스에 동일한 선언이 있는지 확인하도록 돕는다.
class Train: Vehicle {
override func makeNoise() {
print("칙칙폭폭")
}
}
let train = Train()
train.makeNoise() // 칙칙폭폭
상위 클래스 기능 호출 (super)
오버라이딩된 메서드나 프로퍼티 내부에서 상위 클래스의 동작을 호출하고 싶을 때는 super를 사용한다.
class LoudTrain: Train {
override func makeNoise() {
super.makeNoise()
print("매우 크게!")
}
}
let loud = LoudTrain()
loud.makeNoise()
// 칙칙폭폭
// 매우 크게!
super는 상위 클래스에 정의된 기능에 접근하기 위한 공식적인 방법이다. 
프로퍼티 오버라이딩 (Property Overriding)
메서드뿐 아니라 프로퍼티도 오버라이딩할 수 있다.
단, 저장 프로퍼티를 저장 프로퍼티로 재정의할 수는 없으며
계산 프로퍼티 형태로 재정의하거나 옵저버를 추가하는 방식으로 확장한다.
계산 프로퍼티로 재정의
class Car: Vehicle {
override var currentSpeed: Double {
get { super.currentSpeed }
set { super.currentSpeed = max(0, newValue) }
}
}
프로퍼티 옵저버 추가
class AutomaticCar: Vehicle {
override var currentSpeed: Double {
didSet {
print("속도 변경: \(currentSpeed)")
}
}
}
재정의 방지 (Preventing Overrides)
final 키워드는 메서드, 프로퍼티, 서브스크립트가 하위 클래스에서 재정의되는 것을 막기 위한 표시다.
선언 앞에 final을 붙이면 해당 요소는 더 이상 오버라이딩할 수 없고, 하위 메서드, 프로퍼티, 서브스크립트에서 재정의를 시도하면 컴파일 오류가 발생한다.
(final var, final func, final class func, final subscript 와 같이 작성).
이 표시는 클래스 정의 내부뿐 아니라 확장에서 추가된 멤버에도 적용할 수 있다.
또한 클래스 자체에 final을 붙이면 그 클래스는 더 이상 상속할 수 없는 타입이 된다. (final class)
⸻
7. 접근 제어
접근 제어는 코드 요소에 접근할 수 있는 범위를 제한하는 규칙이다.
타입의 내부 구현을 보호하고, 외부에 어떤 부분을 공개할 것인지 결정하기 위해 사용된다.
Swift의 접근 수준은 다음과 같다.
- open : 모든 모듈에서 접근 가능하며, 다른 모듈에서 상속과 오버라이딩까지 허용된다.
- public : 모든 모듈에서 접근 가능하지만, 다른 모듈에서 상속이나 오버라이딩은 허용되지 않는다.
- internal : 동일 모듈 내부에서만 접근 가능하다. 별도 지정이 없으면 기본값이다.
- fileprivate : 동일 소스 파일 내부에서만 접근 가능하다.
- private : 선언이 포함된 범위 내부에서만 접근 가능하다.