구조체와 클래스
구조체와 클래스 (Structures and Classes)
데이터를 담는 나만의 타입을 만들어 볼게요. 이번 장은 Swift에서 사용자 정의 타입을 만드는 두 축, **구조체(struct)**와 **클래스(class)**를 다루는 곳이에요. 나중에 실제 앱을 만들 때 가장 많이 손대게 되는 부분이기도 해요.
본문
구조체와 클래스는 범용적이고 유연한 구성 요소로, 프로그램 코드의 빌딩 블록이 돼요. 여기에 프로퍼티와 메서드를 정의해서 기능을 더할 때는, 상수·변수·함수를 정의할 때 쓰는 것과 똑같은 문법을 사용해요.
다른 프로그래밍 언어와 달리 Swift에서는 사용자 정의 구조체와 클래스를 위해 인터페이스 파일과 구현 파일을 따로 나눌 필요가 없어요. 한 파일 안에 구조체나 클래스를 정의하면, 그 클래스나 구조체의 외부 인터페이스가 자동으로 다른 코드에서 사용할 수 있게 공개돼요.
참고: 클래스의 인스턴스는 전통적으로 *객체(object)*라고 불러요. 그런데 Swift의 구조체와 클래스는 다른 언어들보다 기능이 훨씬 비슷하고, 이 장에서 다루는 기능 대부분이 클래스 타입 또는 구조체 타입의 인스턴스 모두에 적용돼요. 그래서 여기서는 더 일반적인 용어인 *인스턴스(instance)*를 사용할게요.
구조체와 클래스 비교하기
Swift의 구조체와 클래스는 공통점이 아주 많아요. 둘 다 다음을 할 수 있어요.
- 값을 저장할 프로퍼티 정의하기
- 기능을 제공할 메서드 정의하기
- 서브스크립트 문법으로 값에 접근하게 해주는 서브스크립트 정의하기
- 초기 상태를 설정하는 이니셜라이저 정의하기
- 기본 구현을 넘어 기능을 확장하도록 확장(extension)하기
- 특정 종류의 표준 기능을 제공하도록 프로토콜 준수하기
자세한 내용은 doc:Properties, doc:Methods, doc:Subscripts, doc:Initialization, doc:Extensions, doc:Protocols 장을 참고하세요.
클래스에는 구조체에는 없는 추가 기능이 있어요.
- 상속(Inheritance)으로 한 클래스가 다른 클래스의 특성을 물려받을 수 있어요.
- 타입 캐스팅(Type casting)으로 런타임에 클래스 인스턴스의 타입을 확인하고 해석할 수 있어요.
- 디이니셜라이저(Deinitializer)로 클래스 인스턴스가 할당한 리소스를 해제할 수 있어요.
- 참조 카운팅(Reference counting)으로 하나의 클래스 인스턴스에 여러 참조를 허용해요.
자세한 내용은 doc:Inheritance, doc:TypeCasting, doc:Deinitialization, doc:AutomaticReferenceCounting 장을 참고하세요.
클래스가 지원하는 이런 추가 기능은 그만큼 복잡도가 올라가는 대가를 치러요. 일반적인 지침으로는, 구조체가 더 쉽게 추론할 수 있으니 구조체를 우선하고, 클래스가 적절하거나 필요할 때만 클래스를 쓰는 걸 권장해요. 실제로 여러분이 정의하는 사용자 정의 타입 대부분은 구조체와 열거형이 될 거예요. 더 자세한 비교는 구조체와 클래스 고르기를 참고하세요.
참고: 클래스와 액터(actor)는 공통된 특성과 동작을 많이 공유해요. 액터에 대한 정보는 doc:Concurrency 장을 참고하세요.
정의 문법
구조체와 클래스는 정의 문법이 비슷해요. 구조체는 struct 키워드로, 클래스는 class 키워드로 시작하고, 둘 다 전체 정의를 중괄호 한 쌍 안에 넣어요.
struct SomeStructure {
// structure definition goes here
}
class SomeClass {
// class definition goes here
}
참고: 구조체나 클래스를 새로 정의할 때마다 새로운 Swift 타입이 생겨요. 타입 이름은 표준 Swift 타입(예:
String,Int,Bool)의 대소문자 규칙에 맞춰UpperCamelCase로 지어요(여기서의SomeStructure,SomeClass처럼요). 반면 프로퍼티와 메서드 이름은 타입 이름과 구분되도록lowerCamelCase로 지어요(예:frameRate,incrementCount).
구조체 정의와 클래스 정의의 예시를 볼게요.
struct Resolution {
var width = 0
var height = 0
}
class VideoMode {
var resolution = Resolution()
var interlaced = false
var frameRate = 0.0
var name: String?
}
위 예시는 픽셀 기반 디스플레이 해상도를 표현하는 Resolution이라는 구조체를 새로 정의해요. 이 구조체는 width와 height라는 두 저장 프로퍼티를 가져요. 저장 프로퍼티는 구조체나 클래스의 일부로 묶여 저장되는 상수 또는 변수예요. 두 프로퍼티는 초기 정수값 0으로 설정돼서 타입이 Int로 추론돼요.
위 예시는 또 영상 표시용 특정 비디오 모드를 표현하는 VideoMode라는 클래스도 정의해요. 이 클래스는 네 개의 변수 저장 프로퍼티를 가져요. 첫 번째 resolution은 새 Resolution 구조체 인스턴스로 초기화되면서 프로퍼티 타입이 Resolution으로 추론되고, 나머지 세 프로퍼티는 새 VideoMode 인스턴스가 만들어질 때 interlaced는 false(비월 주사가 아닌 영상), 재생 프레임 레이트는 0.0, 이름은 옵셔널 String 값인 name으로 초기화돼요. name 프로퍼티는 옵셔널 타입이라서 자동으로 기본값 nil("값 없는 name")을 받아요.
구조체와 클래스 인스턴스
Resolution 구조체 정의와 VideoMode 클래스 정의는 Resolution이나 VideoMode가 어떤 모습인지만 설명해요. 이 정의 자체는 특정 해상도나 비디오 모드를 설명하지 않아요. 특정한 걸 표현하려면 구조체나 클래스의 인스턴스를 만들어야 해요.
인스턴스를 만드는 문법은 구조체와 클래스 모두 아주 비슷해요.
let someResolution = Resolution()
let someVideoMode = VideoMode()
구조체와 클래스 모두 새 인스턴스를 위해 이니셜라이저 문법을 사용해요. 가장 단순한 이니셜라이저 문법은 Resolution()이나 VideoMode()처럼 클래스나 구조체의 타입 이름 뒤에 빈 괄호를 붙이는 형태예요. 이렇게 하면 모든 프로퍼티가 기본값으로 초기화된 클래스나 구조체의 새 인스턴스가 만들어져요. 클래스와 구조체의 초기화는 doc:Initialization 장에서 자세히 다뤄요.
프로퍼티 접근
인스턴스의 프로퍼티는 *점 문법(dot syntax)*으로 접근할 수 있어요. 점 문법에서는 공백 없이 인스턴스 이름 바로 뒤에 마침표(.)로 구분해 프로퍼티 이름을 써요.
print("The width of someResolution is \(someResolution.width)")
// Prints "The width of someResolution is 0".
이 예시에서 someResolution.width는 someResolution의 width 프로퍼티를 가리키고, 기본 초기값인 0을 돌려줘요.
VideoMode의 resolution 프로퍼티 안에 있는 width 프로퍼티처럼 하위 프로퍼티로도 파고들어 갈 수 있어요.
print("The width of someVideoMode is \(someVideoMode.resolution.width)")
// Prints "The width of someVideoMode is 0".
점 문법으로 변수 프로퍼티에 새 값을 할당할 수도 있어요.
someVideoMode.resolution.width = 1280
print("The width of someVideoMode is now \(someVideoMode.resolution.width)")
// Prints "The width of someVideoMode is now 1280".
구조체 타입의 멤버와이즈 이니셜라이저
모든 구조체에는 자동으로 생성되는 *멤버와이즈 이니셜라이저(memberwise initializer)*가 있어서, 새 구조체 인스턴스의 멤버 프로퍼티를 이걸로 초기화할 수 있어요. 새 인스턴스의 프로퍼티 초기값은 이름으로 멤버와이즈 이니셜라이저에 전달하면 돼요.
let vga = Resolution(width: 640, height: 480)
구조체와 달리 클래스 인스턴스는 기본 멤버와이즈 이니셜라이저를 받지 못해요. 이니셜라이저에 대한 자세한 내용은 doc:Initialization 장을 참고하세요.
구조체와 열거형은 값 타입
*값 타입(value type)*은 변수나 상수에 할당하거나 함수에 전달할 때 그 값이 복사되는 타입이에요.
사실 이전 장들에서 여러분은 이미 값 타입을 두루 사용하고 있었어요. Swift의 모든 기본 타입 — 정수, 부동소수점 숫자, 불리언, 문자열, 배열, 딕셔너리 — 이 값 타입이고, 내부적으로는 구조체로 구현돼 있거든요.
Swift에서 모든 구조체와 열거형은 값 타입이에요. 즉 여러분이 만드는 어떤 구조체와 열거형 인스턴스든 — 그 프로퍼티로 갖는 값 타입들도 — 코드 어디로 전달될 때 항상 복사돼요.
참고: 배열, 딕셔너리, 문자열 같은 Swift 표준 라이브러리가 정의한 컬렉션은 복사 성능 비용을 줄이기 위한 최적화를 사용해요. 이런 컬렉션은 곧바로 복사본을 만드는 대신, 원본 인스턴스와 복사본 사이에서 요소가 저장된 메모리를 공유해요. 컬렉션의 복사본 중 하나가 수정되면, 수정 직전에 요소를 복사해요. 코드에서 보이는 동작은 항상 복사가 즉시 일어난 것처럼 보여요.
이전 예시의 Resolution 구조체를 쓰는 예시를 볼게요.
let hd = Resolution(width: 1920, height: 1080)
var cinema = hd
이 예시는 hd라는 상수를 선언하고, 풀HD 영상의 가로세로(1920픽셀 × 1080픽셀)로 초기화된 Resolution 인스턴스를 설정해요. 그리고 cinema라는 변수를 선언해 현재 hd의 값으로 설정해요. Resolution은 구조체이므로 기존 인스턴스의 복사본이 만들어지고 이 새 복사본이 cinema에 할당돼요. hd와 cinema가 지금은 같은 가로세로 값을 갖지만, 내부적으로는 완전히 다른 두 인스턴스예요.
이제 디지털 시네마 프로젝션에 쓰이는 약간 더 넓은 2K 표준(2048픽셀 × 1080픽셀)의 가로 폭으로 cinema의 width 프로퍼티를 바꿔 볼게요.
cinema.width = 2048
cinema의 width 프로퍼티를 확인해 보면 실제로 2048로 바뀐 걸 볼 수 있어요.
print("cinema is now \(cinema.width) pixels wide")
// Prints "cinema is now 2048 pixels wide".
하지만 원래 hd 인스턴스의 width 프로퍼티는 여전히 예전 값 1920을 갖고 있어요.
print("hd is still \(hd.width) pixels wide")
// Prints "hd is still 1920 pixels wide".
cinema에 hd의 현재 값을 줄 때, hd에 저장된 값들이 새로운 cinema 인스턴스로 복사됐어요. 그 결과 같은 숫자 값을 담은 완전히 분리된 두 인스턴스가 됐어요. 그래서 이 둘은 별개의 인스턴스라서, cinema의 width를 2048로 설정해도 hd에 저장된 width에는 영향이 없어요(아래 그림 참고).
같은 동작이 열거형에도 적용돼요.
enum CompassPoint {
case north, south, east, west
mutating func turnNorth() {
self = .north
}
}
var currentDirection = CompassPoint.west
let rememberedDirection = currentDirection
currentDirection.turnNorth()
print("The current direction is \(currentDirection)")
print("The remembered direction is \(rememberedDirection)")
// Prints "The current direction is north".
// Prints "The remembered direction is west".
rememberedDirection에 currentDirection의 값을 할당하면, 실제로는 그 값의 복사본이 설정돼요. 그 뒤에 currentDirection의 값을 바꿔도 rememberedDirection에 저장된 원래 값의 복사본에는 영향을 주지 않아요.
클래스는 참조 타입
값 타입과 달리 *참조 타입(reference type)*은 변수나 상수에 할당하거나 함수에 전달할 때 복사되지 않아요. 복사본 대신에 같은 기존 인스턴스에 대한 참조가 사용돼요.
앞서 정의한 VideoMode 클래스를 쓰는 예시를 볼게요.
let tenEighty = VideoMode()
tenEighty.resolution = hd
tenEighty.interlaced = true
tenEighty.name = "1080i"
tenEighty.frameRate = 25.0
이 예시는 tenEighty라는 새 상수를 선언하고 VideoMode 클래스의 새 인스턴스를 참조하도록 설정해요. 비디오 모드는 앞선 1920×1080 HD 해상도의 복사본을 할당받고, 비월 주사(interlaced)로 설정되며, 이름은 "1080i", 초당 프레임 레이트는 25.0으로 설정돼요.
다음으로 tenEighty를 alsoTenEighty라는 새 상수에 할당하고, alsoTenEighty의 프레임 레이트를 수정해 볼게요.
let alsoTenEighty = tenEighty
alsoTenEighty.frameRate = 30.0
클래스는 참조 타입이므로 tenEighty와 alsoTenEighty는 실제로 같은 VideoMode 인스턴스를 가리켜요. 사실상 같은 하나의 인스턴스를 가리키는 두 개의 다른 이름인 셈이죠(아래 그림 참고).
tenEighty의 frameRate 프로퍼티를 확인해 보면, 밑에 깔린 VideoMode 인스턴스의 새 프레임 레이트인 30.0을 제대로 보고해요.
print("The frameRate property of tenEighty is now \(tenEighty.frameRate)")
// Prints "The frameRate property of tenEighty is now 30.0".
이 예시는 참조 타입이 왜 추론하기 더 어려울 수 있는지도 보여줘요. tenEighty와 alsoTenEighty가 프로그램 코드에서 멀리 떨어져 있으면, 비디오 모드가 바뀌는 모든 경로를 찾기 어려울 수 있어요. tenEighty를 쓰는 곳 어디서든 alsoTenEighty를 쓰는 코드까지 함께 생각해야 하고, 그 반대도 마찬가지예요. 반면 값 타입은 같은 값을 다루는 코드가 소스 파일 안에서 서로 가까이 붙어 있어서 추론하기가 더 쉬워요.
여기서 tenEighty와 alsoTenEighty는 변수가 아니라 상수로 선언됐다는 점을 눈여겨보세요. 그런데도 tenEighty.frameRate와 alsoTenEighty.frameRate는 바꿀 수 있어요. 왜냐하면 tenEighty와 alsoTenEighty 상수의 값 자체는 실제로 변하지 않기 때문이에요. tenEighty와 alsoTenEighty는 VideoMode 인스턴스를 "저장"하지 않아요. 대신 둘 다 내부적으로 VideoMode 인스턴스를 참조할 뿐이에요. 바뀌는 건 밑에 깔린 VideoMode의 frameRate 프로퍼티지, 그 VideoMode를 가리키는 상수 참조의 값이 아니에요.
항등 연산자
클래스는 참조 타입이라서 여러 상수와 변수가 내부적으로 같은 하나의 클래스 인스턴스를 가리킬 수 있어요. (구조체와 열거형은 상수나 변수에 할당하거나 함수에 전달할 때 항상 복사되므로 이 경우에 해당하지 않아요.)
두 상수나 변수가 정확히 같은 클래스 인스턴스를 가리키는지 알아내는 게 유용할 때가 있어요. 이를 위해 Swift는 두 항등 연산자를 제공해요.
- 동일함(Identical to,
===) - 동일하지 않음(Not identical to,
!==)
이 연산자들로 두 상수나 변수가 같은 단일 인스턴스를 가리키는지 확인할 수 있어요.
if tenEighty === alsoTenEighty {
print("tenEighty and alsoTenEighty refer to the same VideoMode instance.")
}
// Prints "tenEighty and alsoTenEighty refer to the same VideoMode instance."
*동일함(Identical to)*이 등호 세 개(===)로 표현되지만, 등호 두 개(==)로 표현되는 *같음(Equal to)*과는 뜻이 다르다는 점을 기억하세요. 동일함은 클래스 타입의 두 상수나 변수가 정확히 같은 클래스 인스턴스를 가리킨다는 뜻이고, 같음은 타입 설계자가 정의한 적절한 같음의 의미에 따라 두 인스턴스가 값으로 같거나 동등하다고 간주된다는 뜻이에요.
여러분이 직접 사용자 정의 구조체와 클래스를 정의할 때, 어떤 경우에 두 인스턴스가 같다고 할지 결정하는 건 여러분의 책임이에요. ==와 != 연산자의 직접 구현을 정의하는 과정은 doc:AdvancedOperators#Equivalence-Operators에서 다뤄요.
포인터
C, C++, Objective-C 경험이 있다면 이 언어들이 메모리의 주소를 가리키는 데 *포인터(pointer)*를 사용한다는 걸 아실 거예요. 어떤 참조 타입의 인스턴스를 가리키는 Swift 상수나 변수는 C의 포인터와 비슷하지만, 메모리의 주소를 직접 가리키는 포인터는 아니고 참조를 만든다는 표시로 별표(*)를 쓸 필요도 없어요. 대신 이런 참조는 Swift의 다른 상수나 변수처럼 정의돼요. Swift 표준 라이브러리는 포인터를 직접 다뤄야 할 때 쓸 수 있도록 포인터와 버퍼 타입을 제공해요 — 수동 메모리 관리를 참고하세요.
더 알아보기
- 구조체와 클래스 중 선택하기 — 언제 구조체를 쓰고 언제 클래스를 써야 하는지 공식 가이드라인이 궁금하다면 Apple의 Choosing Between Structures and Classes 문서를 보세요.
- 상속과 참조 카운팅 — 클래스 고유 기능인 상속, 타입 캐스팅, 디이니셜라이저, 자동 참조 카운팅은 다음 챕터에서 각각 다뤄요.
- 값 타입 컬렉션 — 배열·딕셔너리·문자열이 값 타입일 때 복사가 어떻게 최적화되는지는 이 장의 참고 노트로 충분하니, 더 깊게는 컬렉션 챕터를 이어서 보면 좋아요.