옵셔널 체이닝
옵셔널 체이닝
옵셔널 값을 풀지 않고도 그 안의 멤버에 접근하는 방법을 배워요.
*옵셔널 체이닝(Optional Chaining)*은 지금은 nil일 수도 있는 옵셔널에 대해 프로퍼티와 메서드, 서브스크립트를 조회하고 호출하는 과정이에요. 옵셔널에 값이 들어 있으면 프로퍼티·메서드·서브스크립트 호출이 성공하고, 옵셔널이 nil이면 그 호출은 nil을 돌려줘요. 여러 번의 조회를 연결(chain)할 수도 있는데, 체인 중 어느 한 고리라도 nil이면 전체 체인이 조용히(gracefully) 실패해요.
참고: Swift의 옵셔널 체이닝은 Objective-C에서
nil에 메시지를 보내는 것과 비슷해요. 다만 어떤 타입이든 동작하고, 성공했는지 실패했는지도 확인할 수 있다는 점이 달라요.
본문
강제 언래핑 대안으로서의 옵셔널 체이닝
옵셔널 체이닝을 쓰려면 프로퍼티·메서드·서브스크립트를 호출하고 싶은 옵셔널 값 뒤에 물음표(?)를 붙여요. 이건 옵셔널 값을 강제로 풀기 위해 느낌표(!)를 붙이는 것과 아주 비슷해요. 가장 큰 차이는 옵셔널이 nil일 때예요. 옵셔널 체이닝은 조용히 실패하지만, 강제 언래핑은 런타임 에러를 일으켜요.
옵셔널 체이닝이 nil 값에 대해서도 호출될 수 있다는 점을 반영해서, 체이닝 호출의 결과는 항상 옵셔널 값이에요. 조회하는 프로퍼티·메서드·서브스크립트가 옵셔널이 아닌 값을 돌려준다고 해도 마찬가지죠. 이 옵셔널 반환값으로 체이닝 호출이 성공했는지(반환된 옵셔널에 값이 있는지), 아니면 체인에 nil이 있어서 실패했는지(반환된 옵셔널 값이 nil인지) 확인할 수 있어요.
정확히 말하면, 옵셔널 체이닝 호출의 결과 타입은 기대한 반환 타입과 같되 옵셔널로 감싸진 형태예요. 평소에 Int를 돌려주는 프로퍼티도 옵셔널 체이닝으로 접근하면 Int?를 돌려줘요.
다음의 몇몇 코드 조각이 옵셔널 체이닝이 강제 언래핑과 어떻게 다른지, 그리고 성공 여부를 어떻게 확인하게 해 주는지 보여줘요.
먼저 Person과 Residence라는 두 클래스를 정의해 볼게요.
class Person {
var residence: Residence?
}
class Residence {
var numberOfRooms = 1
}
Residence 인스턴스는 기본값이 1인 Int 프로퍼티 numberOfRooms 하나를 가져요. Person 인스턴스는 Residence? 타입의 옵셔널 프로퍼티 residence를 가져요.
새 Person 인스턴스를 만들면 residence 프로퍼티는 옵셔널이기 때문에 기본적으로 nil로 초기화돼요. 아래 코드에서 john의 residence 프로퍼티 값은 nil이에요.
let john = Person()
이 사람의 residence 뒤에 느낌표를 붙여 값을 강제로 풀어서 numberOfRooms 프로퍼티에 접근하려고 하면, 풀 residence 값이 없으므로 런타임 에러가 나요.
let roomCount = john.residence!.numberOfRooms
// this triggers a runtime error
위 코드는 john.residence가 nil이 아닌 값을 가질 때 성공해서, roomCount에 알맞은 방 개수가 담긴 Int 값을 설정해요. 하지만 위에서 보듯 residence가 nil이면 이 코드는 항상 런타임 에러를 일으켜요.
옵셔널 체이닝은 numberOfRooms 값에 접근하는 다른 방법을 제공해요. 옵셔널 체이닝을 쓰려면 느낌표 자리에 물음표를 쓰면 돼요.
if let roomCount = john.residence?.numberOfRooms {
print("John's residence has \(roomCount) room(s).")
} else {
print("Unable to retrieve the number of rooms.")
}
// Prints "Unable to retrieve the number of rooms."
이건 Swift에게 옵셔널 residence 프로퍼티를 "체이닝"하고, residence가 존재하면 numberOfRooms 값을 가져오라고 말하는 거예요.
numberOfRooms에 접근하는 시도가 실패할 가능성이 있기 때문에, 옵셔널 체이닝 시도는 Int?, 즉 "옵셔널 Int" 타입의 값을 돌려줘요. 위 예시처럼 residence가 nil이면 이 옵셔널 Int도 nil이 되는데, numberOfRooms에 접근할 수 없었다는 사실을 반영한 거예요. 이 옵셔널 Int는 옵셔널 바인딩으로 풀어서 정수 값을 roomCount 상수에 옵셔널이 아닌 값으로 할당해요.
numberOfRooms는 옵셔널이 아닌 Int인데도 이렇게 된다는 점에 주목해요. 옵셔널 체인을 통해 조회된다는 사실 때문에 numberOfRooms 호출은 항상 Int 대신 Int?를 돌려줘요.
john.residence에 Residence 인스턴스를 할당해서 더는 nil이 아니게 만들 수 있어요.
john.residence = Residence()
john.residence에는 이제 nil이 아니라 실제 Residence 인스턴스가 들어 있어요. 전과 같은 옵셔널 체이닝으로 numberOfRooms에 접근하면 이제 기본 numberOfRooms 값인 1이 담긴 Int?를 돌려줘요.
if let roomCount = john.residence?.numberOfRooms {
print("John's residence has \(roomCount) room(s).")
} else {
print("Unable to retrieve the number of rooms.")
}
// Prints "John's residence has 1 room(s)."
옵셔널 체이닝을 위한 모델 클래스 정의
옵셔널 체이닝은 한 단계보다 깊은 프로퍼티·메서드·서브스크립트 호출에도 쓸 수 있어요. 이렇게 하면 서로 연관된 타입들로 이룬 복잡한 모델 안을 파고들어 하위 프로퍼티(subproperty)에 접근하고, 그 하위 프로퍼티들의 프로퍼티·메서드·서브스크립트에 접근할 수 있는지도 확인할 수 있어요.
아래 코드 조각들은 여러 단계의 옵셔널 체이닝을 포함한 몇몇 예시에서 쓸 모델 클래스 네 개를 정의해요. 이 클래스들은 위의 Person과 Residence 모델을 확장해서 Room과 Address 클래스를 추가하고, 연관된 프로퍼티·메서드·서브스크립트까지 담고 있어요.
Person 클래스는 전과 같은 방식으로 정의돼요.
class Person {
var residence: Residence?
}
Residence 클래스는 전보다 더 복잡해요. 이번에는 Residence 클래스가 [Room] 타입의 빈 배열로 초기화되는 변수 프로퍼티 rooms를 정의해요.
class Residence {
var rooms: [Room] = []
var numberOfRooms: Int {
return rooms.count
}
subscript(i: Int) -> Room {
get {
return rooms[i]
}
set {
rooms[i] = newValue
}
}
func printNumberOfRooms() {
print("The number of rooms is \(numberOfRooms)")
}
var address: Address?
}
이 버전의 Residence는 Room 인스턴스의 배열을 저장하기 때문에 numberOfRooms 프로퍼티를 저장 프로퍼티가 아니라 연산 프로퍼티(computed property) 로 구현해요. 연산 numberOfRooms 프로퍼티는 rooms 배열의 count 프로퍼티 값을 그대로 돌려줘요.
이 버전의 Residence는 rooms 배열에 접근하는 지름길로, rooms 배열에서 요청한 인덱스의 방에 접근할 수 있는 읽기-쓰기 서브스크립트도 제공해요.
이 버전의 Residence는 또 printNumberOfRooms라는 메서드를 제공하는데, 이건 거주지의 방 개수를 그냥 출력해요.
마지막으로 Residence는 Address? 타입의 옵셔널 프로퍼티 address를 정의해요. 이 프로퍼티의 Address 클래스 타입은 아래에서 정의해요.
rooms 배열에 쓰는 Room 클래스는 name 프로퍼티 하나와, 그 프로퍼티를 알맞은 방 이름으로 설정하는 초기화 구문을 가진 간단한 클래스예요.
class Room {
let name: String
init(name: String) { self.name = name }
}
이 모델의 마지막 클래스는 Address라고 불러요. 이 클래스는 String? 타입의 옵셔널 프로퍼티 세 개를 가져요. 처음 두 프로퍼티 buildingName과 buildingNumber는 주소의 일부로 특정 건물을 식별하는 서로 다른 두 방법이에요. 세 번째 프로퍼티 street는 그 주소의 거리 이름을 나타내는 데 써요.
class Address {
var buildingName: String?
var buildingNumber: String?
var street: String?
func buildingIdentifier() -> String? {
if let buildingNumber = buildingNumber, let street = street {
return "\(buildingNumber) \(street)"
} else if buildingName != nil {
return buildingName
} else {
return nil
}
}
}
Address 클래스는 또 반환 타입이 String?인 buildingIdentifier() 메서드를 제공해요. 이 메서드는 주소의 프로퍼티를 확인해서, buildingName에 값이 있으면 buildingName을, 둘 다 값이 있으면 buildingNumber와 street를 연결한 값을, 그 외에는 nil을 돌려줘요.
옵셔널 체이닝으로 프로퍼티 접근하기
앞에서 봤듯이 옵셔널 체이닝으로 옵셔널 값의 프로퍼티에 접근하고, 그 접근이 성공했는지 확인할 수 있어요.
위에서 정의한 클래스들로 새 Person 인스턴스를 만들고, 전처럼 numberOfRooms 프로퍼티에 접근해 볼게요.
let john = Person()
if let roomCount = john.residence?.numberOfRooms {
print("John's residence has \(roomCount) room(s).")
} else {
print("Unable to retrieve the number of rooms.")
}
// Prints "Unable to retrieve the number of rooms."
john.residence가 nil이기 때문에 이 옵셔널 체이닝 호출은 전과 같은 방식으로 실패해요.
옵셔널 체이닝으로 프로퍼티의 값을 설정하는 것도 시도할 수 있어요.
let someAddress = Address()
someAddress.buildingNumber = "29"
someAddress.street = "Acacia Road"
john.residence?.address = someAddress
이 예시에서 john.residence가 현재 nil이므로 john.residence의 address 프로퍼티를 설정하는 시도는 실패해요.
할당은 옵셔널 체이닝의 일부인데, 이는 = 연산자 오른쪽의 코드가 하나도 평가되지 않는다는 뜻이에요. 앞의 예시에서는 상수에 접근하는 일이 부수 효과가 없어서 someAddress가 절대 평가되지 않는 게 잘 안 보여요. 아래 나열은 같은 할당을 하되 주소를 만드는 데 함수를 써요. 이 함수는 값을 돌려주기 전에 "Function was called"라고 출력해서, = 연산자 오른쪽이 평가됐는지 보여줘요.
func createAddress() -> Address {
print("Function was called.")
let someAddress = Address()
someAddress.buildingNumber = "29"
someAddress.street = "Acacia Road"
return someAddress
}
john.residence?.address = createAddress()
아무것도 출력되지 않으므로 createAddress() 함수가 호출되지 않았음을 알 수 있어요.
옵셔널 체이닝으로 메서드 호출하기
옵셔널 체이닝으로 옵셔널 값의 메서드를 호출하고, 그 메서드 호출이 성공했는지 확인할 수 있어요. 메서드가 반환 값을 정의하지 않아도 이게 가능해요.
Residence 클래스의 printNumberOfRooms() 메서드는 현재 numberOfRooms 값을 출력해요. 이 메서드는 이렇게 생겼어요.
func printNumberOfRooms() {
print("The number of rooms is \(numberOfRooms)")
}
이 메서드는 반환 타입을 지정하지 않아요. 하지만 반환 타입이 없는 함수와 메서드는 Void라는 암시적 반환 타입을 가져요. 즉 () 또는 빈 튜플을 돌려준다는 뜻이에요.
이 메서드를 옵셔널 체이닝으로 옵셔널 값에 대해 호출하면, 메서드의 반환 타입은 Void가 아니라 Void?가 돼요. 옵셔널 체이닝으로 호출하면 반환 값은 항상 옵셔널 타입이 되기 때문이에요. 이 덕분에 printNumberOfRooms() 메서드가 자체적으로 반환 값을 정의하지 않는데도 if 문으로 그 메서드를 호출할 수 있었는지 확인할 수 있어요. printNumberOfRooms 호출의 반환 값을 nil과 비교해서 메서드 호출이 성공했는지 보면 돼요.
if john.residence?.printNumberOfRooms() != nil {
print("It was possible to print the number of rooms.")
} else {
print("It was not possible to print the number of rooms.")
}
// Prints "It was not possible to print the number of rooms."
옵셔널 체이닝으로 프로퍼티를 설정하려는 경우에도 마찬가지예요. 앞의 예시에서는 residence 프로퍼티가 nil인데도 john.residence에 address 값을 설정하려고 했죠. 옵셔널 체이닝으로 프로퍼티를 설정하려는 시도는 모두 Void? 타입의 값을 돌려줘서, nil과 비교해 프로퍼티가 성공적으로 설정됐는지 알 수 있어요.
if (john.residence?.address = someAddress) != nil {
print("It was possible to set the address.")
} else {
print("It was not possible to set the address.")
}
// Prints "It was not possible to set the address."
옵셔널 체이닝으로 서브스크립트 접근하기
옵셔널 체이닝으로 옵셔널 값의 서브스크립트에서 값을 가져오거나 설정하고, 그 서브스크립트 호출이 성공했는지 확인할 수 있어요.
참고: 옵셔널 체이닝으로 옵셔널 값의 서브스크립트에 접근할 때는 물음표를 서브스크립트의 대괄호 뒤가 아니라 앞에 둬요. 옵셔널 체이닝 물음표는 항상 옵셔널인 표현식 부분 바로 뒤에 따라붙어요.
아래 예시는 Residence 클래스에 정의된 서브스크립트를 사용해 john.residence 프로퍼티의 rooms 배열에서 첫 번째 방의 이름을 가져오려고 해요. john.residence가 현재 nil이므로 서브스크립트 호출은 실패해요.
if let firstRoomName = john.residence?[0].name {
print("The first room name is \(firstRoomName).")
} else {
print("Unable to retrieve the first room name.")
}
// Prints "Unable to retrieve the first room name."
이 서브스크립트 호출에서 옵셔널 체이닝 물음표는 john.residence 바로 뒤, 즉 서브스크립트 대괄호 앞에 있어요. 옵셔널 체이닝이 시도되는 옵셔널 값이 바로 john.residence이기 때문이에요.
마찬가지로 옵셔널 체이닝으로 서브스크립트를 통해 새 값을 설정할 수도 있어요.
john.residence?[0] = Room(name: "Bathroom")
이 서브스크립트 설정 시도도 residence가 현재 nil이므로 실패해요.
실제 Residence 인스턴스를 만들어 john.residence에 할당하고, 그 rooms 배열에 Room 인스턴스를 하나 이상 넣어 두면, 옵셔널 체이닝을 통해 Residence 서브스크립트로 rooms 배열의 실제 항목들에 접근할 수 있어요.
let johnsHouse = Residence()
johnsHouse.rooms.append(Room(name: "Living Room"))
johnsHouse.rooms.append(Room(name: "Kitchen"))
john.residence = johnsHouse
if let firstRoomName = john.residence?[0].name {
print("The first room name is \(firstRoomName).")
} else {
print("Unable to retrieve the first room name.")
}
// Prints "The first room name is Living Room."
옵셔널 타입의 서브스크립트 접근하기
서브스크립트가 옵셔널 타입의 값을 돌려준다면(Swift Dictionary 타입의 키 서브스크립트처럼), 서브스크립트의 닫는 대괄호 뒤에 물음표를 둬서 그 옵셔널 반환 값에 체이닝해요.
var testScores = ["Dave": [86, 82, 84], "Bev": [79, 94, 81]]
testScores["Dave"]?[0] = 91
testScores["Bev"]?[0] += 1
testScores["Brian"]?[0] = 72
// the "Dave" array is now [91, 82, 84] and the "Bev" array is now [80, 94, 81]
위 예시는 String 키를 Int 값의 배열에 대응시키는 키-값 쌍 두 개가 들어 있는 testScores라는 딕셔너리를 정의해요. 이 예시는 옵셔널 체이닝으로 "Dave" 배열의 첫 번째 항목을 91로 설정하고, "Bev" 배열의 첫 번째 항목을 1만큼 증가시키며, "Brian" 키의 배열 첫 번째 항목을 설정하려고 해요. 처음 두 호출은 성공해요. testScores 딕셔너리에 "Dave"와 "Bev" 키가 있기 때문이에요. 세 번째 호출은 실패해요. testScores 딕셔너리에 "Brian" 키가 없기 때문이에요.
여러 단계의 체이닝 연결하기
여러 단계의 옵셔널 체이닝을 연결해서 모델 안 더 깊은 곳의 프로퍼티·메서드·서브스크립트까지 파고들 수 있어요. 다만 여러 단계의 옵셔널 체이닝이 반환 값에 더 많은 옵셔널 레벨을 추가하지는 않아요.
다르게 말하면 이래요.
- 가져오려는 타입이 옵셔널이 아니라면, 옵셔널 체이닝 때문에 옵셔널이 돼요.
- 가져오려는 타입이 이미 옵셔널이라면, 체이닝 때문에 더 옵셔널해지지는 않아요.
그래서 이렇게 돼요.
- 옵셔널 체이닝으로
Int값을 가져오려 하면, 몇 단계를 체이닝하든 항상Int?가 돌아와요. - 마찬가지로 옵셔널 체이닝으로
Int?값을 가져오려 하면, 몇 단계를 체이닝하든 항상Int?가 돌아와요.
아래 예시는 john의 residence 프로퍼티의 address 프로퍼티의 street 프로퍼티에 접근하려고 해요. 여기서는 옵셔널 타입인 residence와 address 프로퍼티를 통과하는 두 단계의 옵셔널 체이닝을 쓰고 있어요.
if let johnsStreet = john.residence?.address?.street {
print("John's street name is \(johnsStreet).")
} else {
print("Unable to retrieve the address.")
}
// Prints "Unable to retrieve the address."
현재 john.residence의 값은 유효한 Residence 인스턴스를 담고 있어요. 하지만 john.residence.address의 값은 현재 nil이에요. 그래서 john.residence?.address?.street 호출이 실패해요.
위 예시에서 가져오려는 것은 street 프로퍼티의 값이라는 점에 주목해요. 이 프로퍼티의 타입은 String?이에요. 따라서 john.residence?.address?.street의 반환 값도 String?이 돼요. 프로퍼티의 원래 옵셔널 타입에 더해 두 단계의 옵셔널 체이닝이 적용되는데도 말이죠.
john.residence.address의 값으로 실제 Address 인스턴스를 설정하고, 주소의 street 프로퍼티에도 실제 값을 설정하면, 다단계 옵셔널 체이닝으로 street 프로퍼티의 값에 접근할 수 있어요.
let johnsAddress = Address()
johnsAddress.buildingName = "The Larches"
johnsAddress.street = "Laurel Street"
john.residence?.address = johnsAddress
if let johnsStreet = john.residence?.address?.street {
print("John's street name is \(johnsStreet).")
} else {
print("Unable to retrieve the address.")
}
// Prints "John's street name is Laurel Street."
이 예시에서 john.residence의 값이 현재 유효한 Residence 인스턴스를 담고 있기 때문에 john.residence의 address 프로퍼티를 설정하는 시도는 성공해요.
옵셔널 반환 값을 가진 메서드에 체이닝하기
앞의 예시는 옵셔널 체이닝으로 옵셔널 타입의 프로퍼티 값을 가져오는 법을 보여줬어요. 옵셔널 체이닝으로 옵셔널 타입의 값을 돌려주는 메서드를 호출하고, 필요하면 그 메서드의 반환 값에도 체이닝할 수 있어요.
아래 예시는 Address 클래스의 buildingIdentifier() 메서드를 옵셔널 체이닝으로 호출해요. 이 메서드는 String? 타입의 값을 돌려줘요. 앞서 설명했듯이 옵셔널 체이닝을 거친 이 메서드 호출의 최종 반환 타입도 String?이에요.
if let buildingIdentifier = john.residence?.address?.buildingIdentifier() {
print("John's building identifier is \(buildingIdentifier).")
}
// Prints "John's building identifier is The Larches."
이 메서드의 반환 값에 옵셔널 체이닝을 더 수행하고 싶다면, 옵셔널 체이닝 물음표를 메서드의 괄호 뒤에 두면 돼요.
if let beginsWithThe =
john.residence?.address?.buildingIdentifier()?.hasPrefix("The") {
if beginsWithThe {
print("John's building identifier begins with \"The\".")
} else {
print("John's building identifier doesn't begin with \"The\".")
}
}
// Prints "John's building identifier begins with "The"."
참고: 위 예시에서 옵셔널 체이닝 물음표를 괄호 뒤에 두는 이유는 체이닝 대상인 옵셔널 값이
buildingIdentifier()메서드 자체가 아니라 그 반환 값이기 때문이에요.