SwiftUI로 프로젝트를 만들면 숨 쉬듯이 보는 코드가 있죠.
바로 var body: some View 입니다.
그런데 Swift5.7 업데이트 이후부터는 프로토콜 앞에 any라는 녀석도 붙이기 시작했습니다. 실무에서 주니어 분들이 코드 리뷰 할 때 가장 많이 물어보는 단골 질문이기도 하죠.
"여기 프로토콜 타입으로 지정할 건데 some 써야 해요? any 써야 해요?"
이번설명 에서 some과 any의 기준을 알려 드리겠습니다. 컴파일러가 왜 우리에게 이런 걸 강제하는지 그 속사정을 파헤쳐보죠.
애플은 왜 갑자기 any를 쓰라고 강제했을까?
예전(Swift 5.6 이전)에는 프로토콜을 변수의 타입이나 반환 타입으로 쓸 때 아무 키워드 없이 그냥 썼습니다.
protocol Animal { func speak() }
class Dog: Animal { func speak() { print("멍멍") } }
// 예전에는 그냥 이렇게 썼습니다.
let myDog: Animal = Dog()
그런데 애플 엔지니어들이 보기에 이게 너무 '위험하고 비효율적'이었던 겁니다.
프로토콜을 타입으로 쓴다는 건 내부적으로 런타임에 어떤 타입이 들어올지 모르는 상태(Existential Type)로 동작하게 되는데 개발자들이 이걸 너무 무분별하게 남발해서 앱 성능을 갉아먹고 있었거든요.
그래서 Swift 5.7부터 칼을 뽑았습니다.
"야! 너네 이거 프로토콜 쓸 거면, 컴파일 때 타입이 하나로 딱 정해지는 건지(some), 아니면 실행 중에 이것저것 들어올 수 있는 무거운 박스인지(any) 코드에 명확하게 명시해!"
이게 바로 some과 any의 탄생 배경입니다.
"정체는 숨기지만, 껍데기 까보면 딱 하나야"
some은 컴파일러와 개발자가 짜고 치는 고스톱입니다. 코드를 짜는 우리는 "그냥 Animal 프로토콜을 따르는 '어떤(some)' 타입이야~" 하고 뭉뚱그려 말하지만 컴파일러는 그 내부에 진짜로 무슨 타입이 들어있는지 100% 알고 있습니다.
// 밖에서 볼 땐 "아 Animal을 반환하는구나" 싶지만,
// 컴파일러는 "음, 리턴값이 무조건 Dog 1개로 고정되어 있군. OK!" 하고 파악합니다.
func getMyPet() -> some Animal {
return Dog()
}
무조건 내부에서 반환하는 '구체적인 타입'이 단 1개로 고정되어야 합니다.
성능 최고 (Static Dispatch): 컴파일러가 빌드할 때 "아 이건 결국 Dog네" 하고 미리 메모리 크기와 호출할 함수를 싹 다 계산해 둡니다. 실행할 때 버벅거림이 없습니다.
그래서 아래와 같은 코드는 컴파일 에러를 뱉습니다.
func getRandomPet(isDog: Bool) -> some Animal {
if isDog {
return Dog() // 에러!! (컴파일러: "야! 분기마다 리턴 타입이 다르면 내가 미리 하나로 픽스할 수가 없잖아!")
} else {
return Cat()
}
}
SwiftUI는 초당 60~120번씩 화면을 다시 그립니다. 속도가 생명인데, 실행 중에 "어? 이 뷰가 뭐였지? VStack인가? Text인가?" 하고 런타임에 타입을 찾고 있으면 프레임이 다 끊기겠죠?
그래서 애플은 "니들이 만드는 모든 뷰(body)는 컴파일 시점에 타입이 무조건 하나로 결정되어야 해!" 라며 some View를 강제한 것입니다.
"누가 올지 모르니 만능 박스 대령해라"
반대로 any는 런타임용 만능 박스입니다. "여기에 강아지(Dog)가 들어올지 고양이(Cat)가 들어올지 나도 실행해 봐야 알아!" 할 때 씁니다.
// 분기마다 리턴 타입이 달라도 OK!
func getRandomPet(isDog: Bool) -> any Animal {
if isDog {
return Dog()
} else {
return Cat()
}
}
// 서로 다른 타입들을 하나의 배열에 욱여넣을 때도 OK!
let pets: [any Animal] = [Dog(), Cat(), Dog()]
컴파일러 입장에서 생각해 보세요. any Animal 배열을 만들라고 하면, 강아지가 들어올지 고양이가 들어올지, 심지어 덩치가 엄청나게 큰 코끼리 객체가 들어올지 알 수가 없습니다.
그래서 컴파일러는 메모리에 'Existential Container'라는 넉넉한 만능 예비용 박스를 런타임에 동적으로 생성합니다.
이 박스 안에 데이터를 넣고, 실행 중에(Runtime) 진짜 타입이 뭔지 까보고, 포인터를 쫓아가서 함수를 실행합니다. (Dynamic Dispatch)
편리하지만, 그만큼 메모리와 성능 측면에서 비용을 지불해야 하는 셈이죠.
그럼 실무에서는 어떻게 써야 할까요?
프로토콜을 타입으로 지정해야 할 때 일단은 무조건 some을 적으세요.
여러분의 의도와 다르게 다양한 타입이 섞여야 한다면 똑똑한 Swift 컴파일러가 기가 막히게 에러를 뱉어줄 겁니다.
any로 우회하십시오.Array)이나 딕셔너리 안에 서로 다른 구체 타입들을 짬뽕으로 넣어야 할 때.ViewModel 등에서 프로토콜 타입의 변수를 들고 있는데, 실행 중에 A 서비스에서 B 서비스로 인스턴스를 통째로 갈아 끼워야 할 때 (다형성).Delegate) 패턴을 사용할 때가끔 SwiftUI 하실 때 위에서 본 예시처럼 if-else 분기를 태우고 싶어서 any View를 쓰시는 분들이 있습니다.
// 이렇게 쓰지 마세요! 성능 깎아먹는 주범입니다.
func makeMyView(isReady: Bool) -> any View {
if isReady { return Text("완료") }
else { return ProgressView() }
}
SwiftUI에서는 이런 상황을 위해 @ViewBuilder라는 기가 막힌 래퍼를 제공합니다. any View로 런타임 비용을 내지 마시고, 뷰빌더를 사용해 some View로 해결하세요.
// 뷰빌더를 쓰면 컴파일러가 알아서 분기를 하나로 묶어(TupleView 등) some View로 만들어줍니다.
@ViewBuilder
func makeMyView(isReady: Bool) -> some View {
if isReady {
Text("완료")
} else {
ProgressView()
}
}
정체는 숨기지만 타입은 딱 하나로 고정됨. 컴파일러가 미리 다 알아서 성능 최상.
어떤 타입이든 들어갈 수 있는 만능 박스. 런타임에 타입을 확인하므로 성능 비용 발생.
이제 some과 any가 왜 필요한지 언제 무엇을 써야 할지 확실히 감이 오셨나요? 단순히 "에러 나니까 이거 써야지"가 아니라 그 이면에 있는 메모리와 컴파일러의 동작을 이해하면 여러분의 코드 퀄리티가 한 차원 달라질 겁니다.
혹시 실무에서 프로토콜 타입 지정 때문에 골치 아팠던 경험이 있으시다면 댓글로 공유해 주세요.