일이 지났습니다. 시의성에 유의하세요
설명
누군가 왜 JavaScript가 아닌지 물었는데, 그것은 스크립트 언어이며 타입 시스템 등이 없어 Swift와 전혀 비교할 수 없기 때문이다.
저는 프론트엔드 개발자로, C, Java, Python 언어는 입문 수준만 이해하고 있으며, TypeScript(이하 TS)의 기능과 사용에는 고급 수준(자평)에 도달했습니다. 따라서 저에게 충격적인 점이 다른 분들께는 "그냥 평범한 언어 기능일 뿐"이거나 "TypeScript를 설계한 사람이 천재인가?"라고 느껴질 수도 있습니다.
저는 Swift와 TS의 모든 차이점에 무조건 충격을 받지는 않을 것입니다. 어떤 차이는 TS의 자체적인 부족함 때문이며, 또 어떤 설계는 컴퓨터 언어에서 흔히 볼 수 있는 것(예: 숫자를 Int와 Double로 구분하고 Number 타입만 있는 것이 아닌)이기 때문입니다. 다만 JS의 설계가 충격적일 뿐(결국 일주일 만에 급하게 설계된 것이니까)이지 Swift가 아닙니다.
제 생각에: 만약 어떤 언어의 규칙이 너무 많고, 예외가 너무 많고, 예약어가 너무 많다면 그것은 좋은 언어가 아닙니다.
서문
이 글은 Swift 공식 언어 소개 순서에 따라 체계적으로 충격을 받은 내용을 담고 있으며, 충격을 받지 않았거나 이해하지 못한 내용(예: 추가 매크로)은 생략했습니다.
기본 지식
주석
충격 포인트: XCode에는 `/** */` 블록 주석 단축키가 없고, `cmd + /` 라인 주석만 있습니다.
VSCode의 블록 주석 단축키도 누르기 어려운 Alt + Shift + A인데, 이 기능을 자주 사용하지 않는 걸까요?
물론 VSCode와 XCode 모두 /**을 입력하고 엔터를 누르면 자동으로 블록 주석이 생성됩니다.
옵셔널 바인딩
충격 포인트: 디버깅할 때 `if`에 항상 `true`인 값을 테스트용으로 쓰려고 해도 안 됩니다. `if` 문의 옵셔널 바인딩은 반드시 옵셔널 타입의 값이어야 합니다.
1 | |
이렇게까지 해야 할까?
컬렉션 타입
놀라운 점: 배열 메서드 중 `sort`는 원본 배열을 정렬하고, `sorted`는 새로운 배열을 반환한다는 점입니다.
하나의 언어가 프레임워크의 역할까지 수행한다니, 훌륭하고 애플의 스타일에 잘 맞습니다. 이와 같은 예는 많지만, 일일이 놀라기에는 한계가 있네요.
제어 흐름
if 표현식
놀라운 점: `if-else`로 동일한 변수에 값을 할당하는 경우를 해결하기 위해 `if` 표현식 문법을 제공합니다. 마찬가지로 `switch`도 비슷한 표현식 형태를 가지고 있습니다.
Swift는 정말 많은 것을 하고 있네요:
1 | |
Switch의 강력한 판단력
놀라운 점: switch에는 암묵적인 fall-through가 없다
하지만 이는 이해할 수 있고 더 합리적이다. 정상적인 사람 중에 case 1에 추가로 break를 넣지 않으면 자동으로 case 2로 넘어가길 바라는 사람이 있을까?
놀라운 점: switch가 객체 값을 판단할 수 있다!
이는 사실 feature라고 할 수 있으며, 직관적으로 더 합리적이다. 정말 전문가다운 방식이다.
TS에서 switch의 case 문은 완전 항등(===) 비교를 사용하기 때문에, 일반적으로 switch 괄호 안에 객체를 전달하지 않는다. 객체는 참조를 비교하는데, TS에서는 객체 동등성을 판단해야 하는 경우가 드물며, switch 문으로 이를 판단하는 경우는 더욱 드물다.
하지만 Swift에서는 case 문에서 판단하는 값을 "캡처"할 수 있다(공식 용어로 "패턴"이라고 함):
1 | |
주의할 점은 TS에서도 '다중 매칭’을 지원하지만 Swift의 case 다중 매칭과는 완전히 다르다는 것입니다.
Swift의 다중 매칭에서는 쉼표로 구분된 내용 중 하나라도 매칭되면 case 문이 실행됩니다. 그러나 TS의 쉼표로 구분된 매칭은 본질적으로 마지막 항목만 매칭하는데, 이는 TS에서 쉼표로 구분된 문장이 마지막 값만 반환하기 때문입니다:
1 | |
함수
함수의 라벨 매개변수
놀라운 점: 형식 매개변수(라벨 매개변수)가 동일한 이름을 가질 수 있다??? 그리고 규정(제약이 또 등장하네요, 정말): 가변 매개변수 뒤의 매개변수는 반드시 인자 라벨이 있어야 합니다(만약 하나뿐이라면 실제 인자가 형식 매개변수가 됩니다) 생략할 수 없습니다.
1 | |
클로저(Closure)
클로저의 N가지 축약 형태
놀라운 점: 클로저의 문법 설탕(syntactic sugar)이 너무 많아 여기서 일일이 나열하지 않겠지만, 가장 황당한 것은 단 하나의 `>` 기호로 표현할 수 있는 형태입니다.
이렇게 작성할 수 있는 근본적인 이유는 String에 >라는 이름의 함수가 존재하기 때문입니다. 네, 잘못 보신 게 아닙니다. 기호도 함수가 될 수 있습니다! 이에 대한 자세한 내용은 아래에서 더 놀라운 사실로 다루겠습니다.
1 | |
정말 어이없다.
return 생략 반환
충격 포인트: "한 줄 반환은 return을 생략할 수 있다"는 이해할 수 있지만, "여러 줄도 return을 생략할 수 있다"는 절대 이해할 수 없을 거다
TS에서 return을 생략하는 문법은 Swift의 일반적인 작성 방식과 같다. 한 줄 return 생략:
1 | |
일반적인 Swift 문법:
1 | |
하지만! SwiftUI에서의 작성 방식:
1 | |
당신이 본 것은 맞습니다. 이것도 후행 클로저 함수입니다. 여기서 VStack은 함수이며, 클로저 매개변수가 마지막이자 유일한 매개변수로 사용될 때 VStack의 괄호를 생략할 수 있습니다. 하지만! 여기서는 두 개의 Text 함수 호출을 반환하면서도 return을 쓰지 않았습니다. 왜일까요?
왜냐하면 ViewBuilder를 사용했기 때문입니다. 이것은 뒤에 나오는 resultBuilder와 같은 문법 설탕입니다.
자동 클로저
놀라운 점: 평범한 클로저 할당에는 특별한 것이 없지만, 그것의 지연 계산 능력은 나를 놀라게 만들었습니다.
자동 클로저를 처음 볼 때는 이 예제로 소개되었고, ‘지연 계산’ 능력에 대해 설명했습니다:
1 | |
이것이 평범한 클로저라고 생각했는데, TS와 마찬가지로 customerProvider 변수가 클로저에 할당된 것뿐이라고 여겼습니다. 동일한 구현은 TS에서 다음과 같습니다:
1 | |
그리고 물론 호출될 때 클로저의 로직이 실행되는데, 이게 무슨 지연 계산이냐고 할 수 있습니다!
하지만 클로저가 매개변수로 전달될 때의 지연 계산 형태가 진정으로 강력합니다:
평범한 명시적 클로저 호출은 TS 호출 방식과 기본적으로 유사하며, 클로저가 매개변수로 전달될 때도 여전히 중괄호 안에 작성됩니다:
1 | |
하지만 customerProvider를 @autoclosuer로 표시하면 상황이 달라집니다. 이제 serve 함수 호출은 다음과 같이 작성할 수 있습니다:
1 | |
마지막 serve 함수 호출을 주목하세요. 이 함수의 매개변수 customer은 customersInLine.remove(at: 0)라는 구문입니다. TS에서는 어떤 경우든 호출 스택이 먼저 이 값을 평가한 후 serve 함수를 호출합니다. 그러나 Swift에서 이 작성 방식은 위의 형태와 단지 형태만 다를 뿐, 논리는 동일합니다. 즉, serve 함수가 먼저 실행된 후 내부에서 이 구문이 실행됩니다(customerProvider가 호출될 때). 이것이 바로 전설적인 '지연 계산(lazy evaluation)'이겠죠.
이런 패턴을 자주 사용하다 보면, 다음과 같은 코드를 자주 마주하게 될 것입니다:
1 | |
이때 괄호 안의 문장을 먼저 실행할지, 외부 함수를 먼저 실행할지 구분하기 어렵습니다. 따라서 Swift 공식 문서에도 다음과 같이 설명되어 있습니다:
자동 클로저를 과도하게 사용하면 코드를 이해하기 어려울 수 있습니다. 컨텍스트와 함수 이름은 계산이 지연되고 있음을 명확히 나타내야 합니다.
정말 어이없네요, 권장하지 않는다면 애초에 설계하지 말았어야죠!
열거형(Enum)
놀라운 점: 다른 언어에서 열거형은 단순히 편의를 위한 선택적 상태 판별 타입에 불과하지만, Swift에서는 가장 흔히 사용되는 1급 타입이며, 일부 기능에서는 Struct를 대체할 수 있다는 사실을 믿을 수 있나요?
놀라운 점 2: 열거형은 값 타입(value type)입니다.
TS에서 열거형은 평범한 '열거형’일 뿐이며, 단순히 값을 나열하는 용도로 사용됩니다. 대부분 상태 머신에서 상태를 설명하는 데 사용되죠:
1 | |
물론, TS에서 런타임과 컴파일 타임의 차이로 인한 다양한 기법들은 논외로 하겠습니다. TS에서는 객체를 완전히 열거형 대신 사용할 수 있습니다:
1 | |
연관 값
놀라운 점: Swift에서 열거형을 이렇게 설계한 목적이 뭐야!
Swift에서 열거형의 중요성은 첫 번째입니다. 모든 case를 순회할 수 있고(CaseIterable 프로토콜을 준수해야 함), case를 함수처럼看待하여 호출 시 값을 전달해 열거형 인스턴스가 처리하도록 할 수 있습니다. 이를 '연관 값(Associated Values)'이라고 합니다:
1 | |
그리고 사용할 때 값을 전달할 수 있습니다:
1 | |
열거형(enum)은 switch 문에서 가장 많이 사용되며, 놀라운 switch와 함께 놀라운 case를 결합하여 다음과 같이 작성할 수 있습니다:
1 | |
처음 봤을 때 나는 상당히 추상적으로 느껴졌고, 실제 사용 사례가 무엇인지도 잘 떠오르지 않았습니다(결국 저는 그렇게 해본 적이 없으니까요).
암시적 할당
놀라운 점: 열거형의 타입 선언은 열거형 자체의 타입(키-값 쌍)이 아닌, case의 타입을 선언한다는 것입니다.
Swift의 암시적 할당은 TS나 다른 C 계열 언어들과 일치합니다. 첫 번째 숫자는 n, 그다음은 n + 1이 되는 방식이죠.
하지만 Swift는 한 걸음 더 나아가, 선언한 타입에 따라 암시적 할당을 수행합니다. 기본적으로는 그렇지 않는데, 예를 들어:
1 | |
TS에서는 Swift에서 지정할 수 있는 Int 타입(이 타입은 실제로 case의 타입을 의미함)을 지정할 수 없으며, 오직 enum의 타입(일반적으로 Record로 정의된 키-값 쌍)만 지정할 수 있습니다.
구조체와 클래스
놀라운 점: 구조체가 값 타입이라고???
이렇게 단단해 보이는 구조체가 값 타입이었습니다(성능 최적화를 위해 Immutable 방식을 사용함):
1 | |
중괄호로 둘러싸인 것이 어떻게 값처럼 보이지 않을 수 있죠! 말도 안 돼! 충격적이네요!
속성
다양한 xx 속성/래퍼/옵저버
충격 포인트: Swift가 너무 많은 것을 해냈습니다. 계산 속성, 저장 속성, 속성 래퍼(TS에도 있음), 속성 옵저버(TS에도 있으며 래퍼와 함께 제공됨)와 같은 개념들은 일반적으로 Vue의 `computed`, `watch` 등과 같이 프레임워크에서 제공하는 기능인데, Swift는 Struct와 클래스 내에서 이를 직접 구현했습니다. 말도 안 되죠!
1 | |
추가: 속성 래퍼는 전역 변수에 사용할 수 없습니다.
서브스크립트
놀라운 점: 이 부분은 특별히 설명할 것이 없습니다. '서브스크립트'라는 개념 자체가 이미 놀라운데, 이는 컬렉션, 리스트, 딕셔너리 요소에 접근하는 빠른 방법을 제공합니다. 심지어 서브스크립트는 함수처럼 여러 값을 전달할 수도 있습니다.
기본적으로 서브스크립트 호출 방식은 데이터 구조(위에서 언급한 컬렉션, 리스트, 딕셔너리)에 대한 함수 호출로 값을 접근하는 것으로 볼 수 있습니다. 다른 점은 함수 호출은 ()를 사용하는 반면, 서브스크립트는 []를 사용한다는 것입니다.
상속
놀라운 점: 이전의 모든 놀라운 점과 유사하게, Swift는 다양한 목적과 효과를 달성하기 위해 매우 자유롭게 다양한 키워드를 추가하는 것 같습니다. 따라서 상속과 관련된 예약어(물론 '예약어'라고 부를 수도 없습니다. Swift에서는 모든 예약어를 백틱으로 감싸면 사용할 수 있기 때문입니다. 이에 대해서는 나중에 다시 놀라겠습니다)가 매우 많습니다. 예를 들어: `final`, `override`, `open`, `required` 등이 있습니다.
생성 과정
Struct의 멤버별 생성자
놀라운 점: Struct는 값 타입이므로 초기화 시 전통적인 Class의 생성자 규칙과 차이가 있습니다.
원래 Struct와 Class는 모두 평범한 데이터 구조로, TS의 Class와 다르지 않습니다. 하지만 Swift는 여기서 또 다른 특이한 점을 만들어냈습니다.
Struct는 값 타입이기 때문에 특별하게, '멤버별 생성자’라는 생성자 형태를 가집니다. 즉, Struct에 init이 전혀 없을 때, 속성을 초기화하기 위해 매개변수를 전달하는 방식으로 init할 수 있으며, 명시적으로 작성할 필요가 없습니다.
1 | |
하지만 이 규칙은 Class에는 적용되지 않으며, Class는 반드시 명시적으로 init 생성자 메서드를 통해 속성을 초기화해야 합니다.
클래스의 지정 생성자와 편의 생성자
놀라운 점: 뭐? 생성자가 두 종류라고???
클래스의 지정 생성자는 TS의 일반적인 construct와 동일한 역할을 하는 init 메서드입니다. 하지만 편의 생성자는… init 앞에 convenience 키워드를 붙인 것입니다(또 등장!).
1 | |
사실 여기 Swift 문서는 독자들에게 왜 편의 생성자(convenience initializer)가 필요한지 명확히 설명하지 않고, 바로 클래스의 생성, 상속 과정 및 생성자 위임에 대해 이야기합니다. 여기서 초보자들을 위해 왜 편의 생성자라는 것이 존재해야 하는지 설명해 드리겠습니다. 아주 간단한 이유입니다: 클래스 내의 여러 지정 생성자(designated initializer)는 서로를 호출할 수 없습니다. 이렇게 간단한 이유입니다. 참을 수 없이 호출하고 싶으신가요? 초기화 과정을 단순화하고 싶으신가요? 편의 생성자를 사용하세요!
일반적인 작성 방식:
1 | |
위에서 self.b 같은 것을 두 번 작성하는 것이 번거롭다고 느껴지시나요? 그래서 두 번째 init() 에서는 이렇게 하고 싶습니다:
1 | |
컴파일러 오류 발생: Designated initializer for 'A' cannot delegate (with 'self.init'); did you mean this to be a convenience initializer?
이 경우에는 반드시 편의 생성자(convenience initializer)를 사용해야 합니다:
1 | |
편의를 추구하는 거죠!
실패 가능한 생성자(Failable Initializer)
놀라운 점: 생성자도 실패할 수 있다고?
사실 실패 가능한 생성자란, 생성 과정(init 함수 호출 과정)에서 오류가 발생할 수 있다는 것을 의미합니다.
따라서 생성자에서 오류가 발생하면 TS처럼 외부에서 try-catch로 감쌀 필요 없이, 간단히 표시할 수 있습니다. 방법은 init 뒤에 물음표를 붙여 init?로 만들고, 오류가 발생할 수 있는 부분에서 nil을 반환하면 됩니다. 인스턴스화할 때 nil인지 확인하면 되죠.
Swift에서는 일반 생성자가 값을 반환하지 않는데, 이는 TS와 동일합니다. 하지만 TS의 생성자는 값을 반환할 수 있으며, 반환 값이 객체 타입인 경우 this 객체를 대체합니다. 이는 더욱 놀라운 사실이네요.
옵셔널 체이닝(Optional Chaining)
놀라운 점: 질문: 어떤 경우에 함수에 ()를 썼는데도 실행되지 않을까요? 답: 옵셔널 체이닝 상황에서입니다.
말보다 예시를 보는 게 나을 거예요:
1 | |
오류 처리
놀라운 점: 열거형과 마찬가지로 Swift의 try-catch(실제로는 do-catch) 설계가 매우 복잡하다는 것입니다.
catch 문은 임의의 오류를 포착할 수 있을 뿐만 아니라 특정 오류 유형도 포착할 수 있으며, is를 사용하거나 switch와 같이 여러 catch 분기를 통해 매칭할 수 있어서 매우 독특합니다:
1 | |
동시성
놀라운 점: 문서를 볼 때, 처음에는 `withTaskGroup`이 Group Task를 수행하는 내장 메서드라는 것을 깨닫지 못했습니다...
한편 await는 TS의 await와 완전히 동일한 사용법으로, 정말 편리합니다!
확장(Extension)
놀라운 점: 확장은 Swift에서 가장 강력한 설계 중 하나로, 내장 객체든 서드파티 객체든 상관없이 어떤 객체라도 자유롭고 저렴한 비용으로, 복잡한 선언이나 키워드 없이 확장할 수 있습니다.
TS에서는 기존 클래스를 확장하려면 프로토타입 체인을 조작하고 객체의 this를 해당 클래스로 수정해야 합니다.
하지만 Swift에서는 단순히 extension을 사용하면 원하는 메서드나 프로퍼티를 마음껏 작성할 수 있으며, 이들의 this는 인스턴스를 가리키거나 확장 유형(즉 정적) 메서드/프로퍼티로 동작합니다.
정말 끝내주게 강력합니다.
프로토콜
놀라운 점: 프로토콜은 Struct의 추상화 문제를 해결하기 위해 등장한 것 같습니다. 대부분의 언어에서 클래스 자체는 상속이 가능하므로(절대적인 표현일 수 있음), 추가적으로 "프로토콜"을 구현할 필요 없이 추상 클래스로 충분합니다. Swift는 클래스가 단일 부모만 상속할 수 있도록 제한하면서, 프로토콜이 클래스, Struct, 열거형에서 동시에 작동하도록 한 부수적인 효과가 있습니다.
더 설명할 필요 없이, 위에 다 말해두었습니다.
제네릭
놀라운 점: 제네릭에는 "연관 타입(associated type)"이라는 개념이 있는데, 이는 기본적으로 선언 시 사용하는 제네릭과 유사하지만 더 강력한 기능을 제공합니다.
Swift의 제네릭 연관 타입은 다음과 같습니다:
1 | |
왜 이런 방식으로 설계하지 않았을까:
1 | |
아마도 이 Item이 너무 길어서 우아하지 않기 때문일 것입니다. 예를 들어:
1 | |
이름 뒤에 작성하면, 제네릭 선언이 자주 화면을 벗어나게 되어 버거울 수 있습니다.
불투명 타입과 캡슐화 프로토콜
놀라운 점: 컴파일러는 타입을 알지만 호출 측에서는 구체적인 타입을 모르고 특정 프로토콜을 준수한다는 것만 알 수 있는 효과를 구현할 수 있습니다.
기본적으로, 불투명 타입과 캡슐화 프로토콜이 달성하고자 하는 효과는 한편으로 호출자에게 구현 세부 사항을 숨기면서도 코드 리팩토링 시 많은 부분을 수정하지 않아도 되도록 하는 것입니다. 예를 들어:
1 | |
여기서 makeShape이 반환하는 타입은 Shape 프로토콜을 준수하는 어떤 타입이든 가능하며, 명시적으로 Circle 타입을 반환할 필요가 없습니다. 이렇게 하면 이후에 Shape 타입을 준수하는 새로운 Struct를 추가하더라도 해당 함수가 반환할 수 있습니다(이는 SwiftUI에서 흔히 사용되는 방식 중 하나로, some View 등이 해당합니다).
메모리 안전성
놀라운 점: Swift는 값을 참조로 전달할 수 있기 때문에 동일한 메모리 영역에 대한 동시 읽기/쓰기가 발생할 수 있습니다(이건 합리적이죠?)
1 | |
위 코드를 실행하면 오류가 발생하지만 컴파일러는 경고하지 않습니다. 이와 같은 경우가 많기 때문에 특히 주의해야 합니다. TS에는 참조에 의한 전달이 없기 때문에 이런 문제가 발생하지 않아 편리합니다.
고급 연산자
놀라운 점: 기호를 함수명으로 사용하는 함수를 재정의하거나 커스터마이징하여 동일한 클래스나 구조체의 인스턴스 간 상호 작용을 구현할 수 있습니다.
예를 들어:
1 | |
이 디자인 정말 좋은데, 내가 왜 생각 못했을까? 대단하다 대단해.
주의할 점은 연산자 메서드는 자체 속성을 가지고 있다는 것입니다. 예를 들어 +는 이항 연산자이면서 중위 연산자이므로 두 개의 함수를 받습니다. -는 중위 연산자(이항 연산자)일 수도 있고, 접두사 연산자(단항 연산자, func 앞에 prefix를 추가해야 함)일 수도 있어서 오버로드할 수 있습니다.
하지만 Swift는 할당 연산자 =는 오버로드할 수 없고, 삼항 조건 연산자(a ? b : c)도 오버로드할 수 없다고 규정하고 있습니다.
이 연산자는 너무 흔해서 Swift 내장 객체/Foundation 객체 어디에서나 == 같은 동등 판단 연산자 함수를 볼 수 있습니다.
심지어 자신만의 연산자를 구현할 수도 있습니다!
1 | |
진짜 대단하다.
결과 빌더
놀라운 점: Swift는 "우아함"과 "재사용"을 위해 극한까지 활용한다.
아래 코드가 보기 좋게 보이도록 하기 위해 Swift는 @resultBuilder라는 신기한 것을 "발명"했는데, 다음과 같다:
일반적인 작성 방식:
1 | |
Swift는 위의 삼항 연산자 판단이 너무 번거롭고, 복잡한 판단의 경우 길어져 가독성이 떨어진다고 말하며 더 우아한 방법을 원했습니다. 그래서 결과 빌더(Result Builder)가 탄생했습니다(제가 이해한 것이 맞다면):
1 | |
그런 다음 위의 c 변수에 다음과 같이 값을 할당합니다:
1 | |
재사용, 우아함, 효율성, 완벽하다!
어휘 구조
놀라운 점: Swift에는 예약어가 매우 많지만, 예약어를 식별자로 사용할 수 있다는 것입니다.
방법은 역따옴표로 감싸는 것입니다:
1 | |
하지만 키워드를 제외하면, x와 x는 동일한 변수입니다.
저는 인생의 중요한 선택의 기로에 섰을 때, 누군가 최선의 방법을 알려주어 소중한 시간을 헛되이 보내지 않기를 바라곤 합니다. 그런 마음으로 저는 자주 블로그를 쓰며, 광활한 인터넷의 이 작은 구석에 제게는 단 한 번뿐인 인생 경험을 기록하여 도움이 필요한 분들에게 도움이 되기를 바랍니다.