WWDC24 Swift 부문 정리

백상휘·2024년 6월 15일
0

iOS_Programming

목록 보기
11/11

작년에 왔던 각설이가 죽지도 않고 또 오듯 올해도 WWDC 가 왔다. 서비스를 위한 앱 개발을 하는 입장에서 Swift, UIKit, SwiftUI 의 중요한 부분은 반드시 봐야겠다고 생각해서 세션을 보고 난 후 정리 겸 포스트를 작성한다. 나머지는 필요할 때 따로 공부할 생각이다.

Swift6 가 나올겁니다!

Swift6 의 업데이트가 가시화 되었다고 본다. Apple 에서 공식적으로 출시일을 언급한 부분은 찾지 못했지만 9월로 많이 얘기하는 것 같다. 9월은 애플이 뭔 이벤트 하나 여는 때이기도 하니 합리적인 의심이라 생각된다.

이번 Swift 세션을 전체적으로 보면서 든 생각은 새로운 기능보다는 내부적인 개선 에 중점을 맞추었다고 본다.

  • Swift 세션에서 계속 강조하는 건 Data-race 라는 동시성(비동기 프로그래밍)의 고질적 문제이다. 예전부터 값 타입이 이 문제를 극복할 수 있다고 강조했지만 이번 WWDC 에서는 특히 더 많이 언급된다.
  • Embeded Programming (Preview), Swift-on-Server, Swift Community 등 외연을 넓히려는 시도가 돋보인다.
  • 새로운 테스트 프레임워크가 나왔다.

개인적인 흥미

현재 프리랜서인 필자는 Swift 를 통해 최대한 많은 프로젝트를 수행하는 것이 이득이다. 그런 입장에서 두 가지 키워드가 눈에 띄었다.

  • Embeded Swift
  • Swift on Server

임베디드 프로젝트랑 간단한 서버 개발도 Swift 개발자가 할 수 있는 건가?

실제로 임베디드 관련 프로젝트는 잘잘하게 끊이지 않는 것 같다.

하지만 Swift 를 바로 시도할 만한 기업은 많지 않을 것 같다. 아직 이 자체가 Preview 이기도 하고, 업계 표준은 쉽게 바뀌지 않는다. 수많은 C 의 향연을 위의 이미지에서 볼 수 있다.

그리고 가장 중요한 문제는 Swift 로 만든 임베디드 프로그램이 디바이스 공급자가 만든 API 를 호출하기만 할 뿐이라는 것이다. 더 깊은 수준의 커스텀이 필요하다면 Swift 가 과연 좋은 선택일지는 의문이다.

Swift 둘러보기: Swift의 기능 및 디자인 살펴보기

Swift 기본, 설계 철학에 대한 세션이었다. 유튜브에서 "개발 공부 어떻게 하나요" 에 항상 빠지지 않는 얘기가 사용하는 언어의 철학에 대해 공부하라고 하는데 과연 그 질문에 대답할만한 내용을 담았는지 한번 살펴보고자 한다.

이 세션은 각 주제에 대해 깊이 다루기보단 모든 주제의 중심이 될만한 내용을 다루는 것이다.

이 세션에서 다룬 주제는 다음과 같다.

  • Value types
  • Errors and optionals
  • Code organization
  • Classes
  • Protocols
  • Concurrency
  • Extensibility

What are value types?

Value type 에 대한 간단한 코드를 보여주면서 알아둬야 할 내용을 정리해준다.

var x: Int = 1
var y: Int = x // 1
x = 42 // 42
y // 1
  • 값 타입은 공유되지 않는다.
  • 값 타입에는 아이덴티티가 없다. 같은 값은 구분되지 않는다.
  • 데이터를 나타내는 기본적인 타입인 정수, 논리, 소수값 등은 모두 값 타입이다.

여기서 더 복잡한 데이터 타입은 struct 이다.

struct User {
    let username: String
    var isVisible: Bool = true
    var friends: [String] = []
}

var alice = User(username: "alice")
alice.friends = ["charlie"]

var bruno = User(username: "bruno")
bruno.friends = alice.friends // 복사!!

alice.friends.append("dash")
bruno.friends

타입 자체는 더 복잡해져도 위의 3가지 사항을 어기지는 않는다. 여기서 또 다른 중요한 사항이 나온다.

  • 값 타입이 다른 변수에 할당되면 복사 된다는 것이다.

위의 코드에 나온 변수들은 User 타입이고, 이는 값 타입이므로 안의 프로퍼티도 값 타입이다.

여기서 대놓고 Swift 는 struct 를 class 보다, let 을 var 보다 더 중요하게 생각한다고 하였는데 이는 전문 자체를 남기는 것이 좋을 것 같다.

Swift emphasizes value types and immutability because controlling when a value can change makes it much easier to reason about code, especially in tricky domains like concurrent programming.

스위프트는 값 타입과 불변성을 더 집중합니다. 그 이유는 변하는 시점을 컨트롤하는 것은 비동기 프로그래밍 같은 까다로운 영역에서 특히 코드를 더욱 읽기 쉽게 만듭니다.

Errors and optionals

Swift 의 에러 핸들링 철학은 3개 라고 한다.

  1. 에러의 발생원인을 인지하기 쉬워야 한다.
  2. 후속작업을 할 수 있는 정보를 포함해야 한다.
  3. 복구 가능한 에러와 프로그래머의 실수는 다르다. (추가: 네트워크 연결 실패는 프로그램을 죽이지 않지만, 배열에서 접근할 수 없는 인덱스에 접근하는 것은 앱을 죽인다.)

이 세션에서는 Index-Out-Of-Bounds 보다는 데이터 자체의 Threshold 즉, 데이터가 가지지 말아야 할 상태 등에 대한 에러에 대해 상세히 얘기하려 한다(고 생각한다). 즉 Validation 에 대해 설명한다.

struct User {
    let username: String
    var isVisible: Bool = true
    var friends: [String] = []

    mutating func addFriend(username: String) throws {
        guard username != self.username else {
            throw SocialError.befriendingSelf
        }
        guard !friends.contains(username) else {
            throw SocialError.duplicateFriend(username: username)
        }
        friends.append(username)
    }
}

enum SocialError: Error {
    case befriendingSelf
    case duplicateFriend(username: String)
}

var alice = User(username: "alice")
do {
    try alice.addFriend(username: "charlie")
    try alice.addFriend(username: "charlie")
} catch {
    error
}

var allUsers = [
    "alice": alice
]

func findUser(_ username: String) -> User? {
    allUsers[username]
}

if let charlie = findUser("charlie") {
    print("Found \(charlie)")
} else {
    print("charlie not found")
}

let dash = findUser("dash")!

코드에서 프로그래머는 중복된 데이터를 배열에 넣을 경우 정의된 에러를 Throw 하도록 하려 한다. enum 은 에러를 정의하는데 굉장히 유용하기 때문에 enum 을 사용한다.

이제 에러를 throw 할 수 있는 함수를 그냥 호출하려 하면 컴파일러는 에러를 handling 하지 않았다면서 경고를 표시한다. do-catch 블록으로 이를 해결한다.

에러와 동시에 원치 않는 상황을 핸들링하는 데엔 Optional 도 좋은 선택이다.

Optional은 값이 nil 혹은 특정 타입의 데이터인 값이다. 값을 사용하기 위해서는 이 Optional 을 걷어내기 위해 Optional-Binding 을 사용한다. 값이 nil 인 상황에서 발생할 크래시를 막는 데 Optional 이 유용하다.

Code organization

Swift 의 코드 구조는 Modules, Package 라는 2개의 세부구조로 나뉜다. 패키지와 패키지는 서로 의존성을 가질 수 있다.

패키지는 Swift-Package-Manager 라는 툴을 통해 생성하고 관리할 수 있는데, 커맨드 라인을 통해 build/test/run 할 수 있다. IDE(Xcode, VSCode) 에서도 수정하고 실행할 수 있다. 패키지는 Swift-Package-Index 에 배포할 수 있다.

패키지를 만들 때 코드 베이스에서 중요한 것은 접근 제한자를 잘 선택하는 것이다. (대부분 public 으로 가는 것 같지만...)

  • private : 파일 내 같은 프로퍼티 레벨에서만 접근 가능.
  • internal : 같은 모듈 내에서만 접근 가능. (default acces-control-level)
  • package : 같은 패키지 내에서 접근 가능.
  • public : 전부 접근 가능. (패키지 외부에서 접근 가능)

Classes

공유 가능하며 수정 가능한 타입이 필요할 때 Swift 의 참조타입을 쓰면 되는데, Classes 가 대표적이다.

class Pet {
    func speak() {}
}

class Cat: Pet {
    override func speak() { print("meow") }

    func purr() { print("purr") }
}

let pet: Pet = Cat()
pet.speak()

if let cat = pet as? Cat {
    cat.purr()
}

Swift 는 단일 상속만을 지원한다. 다중 상속을 하려면 상속의 상속을 해야 한다 (A 가 B / C 를 상속하고 싶다면, B 가 C 를 상속하도록 한 다음 A 가 B 를 상속하는 등의 방법이 있지만 프로토콜 썼으면 좋겠다)


참조 타입을 다룰 때 중요하게 다뤄져야 하는 것이 메모리 관리이다. 얘기하자면 말이 길어지니 짧게 정리해본다.

  • Swift 는 ARC(Automatic-Reference-Counting) 기법으로 메모리를 관리한다.
  • 컴파일러는 참조 타입이 참조되어 있다면 객체를 메모리에 남겨 놓는다.
  • 메모리에 남겨 놓기 위해 Reference-Count 를 참조할 때마다 더하고, 참조가 해제되면 뺀다. 참조 카운트가 0이 되는 순간 deallocate 된다.
  • Reference-Cycle 에 주의해야 한다. A 참조 타입이 B 참조 타입을 참조하는데, B 참조 타입의 특정 프로퍼티가 A 참조 타입을 참조할 경우 두 참조 타입은 Reference-Cycle 을 발생시킬 우려가 있다. 이럴 경우 B 에서 A 타입을 참조하는 프로퍼티에 weak 키워드를 써서 참조 카운트를 증가시키지 않도록 처리한다.

Protocols

다형성, 추상화는 객체지향 프로그래밍의 기본 특성이고 Swift 또한 그런 특성을 갖는다. 프로토콜은 타입이 추상화를 위해 구현해야 할 요구사항을 정의해놓은 선언문이다.

Swift 의 프로토콜의 예시로서 가장 많이 사용되는 것은 Collection 프로토콜이다. Collection 프로토콜을 implement 할 경우, 연속된 값을 다루는 Array/Dictionary/Set/String(Character 의 배열) 가 가진 map, filter, reduce, append, for-loop 에서 순환하기, 인덱스로 특정 데이터 접근하기 등을 사용할 수 있는 새로운 타입을 만들 수 있다.

/// An in-memory store for users of the service.
public class UserStore {
    var allUsers: [String: User] = [:]
}

extension UserStore {
    /// If the username maps to a User and that user is visible,
    /// returns the User. Returns nil otherwise.
    public func lookUpUser(_ username: String) -> User? {
        guard let user = allUsers[username],
              user.isVisible else {
            return nil
        }
        return user
    }

    /// If the username maps to a User and that user is visible,
    /// returns the User. Otherwise, throws an error.
    public func user(for username: String) throws -> User {
        guard let user = lookUpUser(username) else {
            throw SocialError.userNotFound(username: username)
        }
        return user
    }

    public func friendsOfFriends(_ username: String) throws -> [String] {
        let user = try user(for: username)
        let excluded = Set(user.friends + [username])
        return user.friends
            .compactMap { lookUpUser($0) }      // [String] -> [User]
            .flatMap { $0.friends }             // [User] -> [String]
            .filter { !excluded.contains($0) }  // drop excluded
            .uniqued()
    }
}

extension Collection where Element: Hashable {
    func uniqued() -> [Element] {
        let unique = Set(self)
        return Array(unique)
    }
}

Collection 프로토콜을 직접 구현하는 경우도 흔하지만, 위와 같이 uniqued() 메소드를 따로 사용하기 위해 타입을 extension 하는 경우는 흔한 것 같다.

저 코드에서 개인적으로 아쉬운 것은 위와 같은 extension 에는 private extension 을 우선적으로 고려했으면 좋겠다는 것이다. 다른 소스코드나 타입 등에 영향을 끼쳐서 전체 코드베이스에 안좋은 결과를 안길 수 있다.

프로토콜은 Generic 과 더하여 타입을 확장하는 데 아주 훌륭한 장점을 갖는다. 클래스의 상속을 통한 추상화보다 더 유연하기 때문에 자주 쓰이고 잘 알아둬야 한다.

Concurrency(+ Data-race)

Swift 의 동시성을 대표하는 것은 Task 이다.

Task 는 독립적이고 동시적으로 실행 가능한 컨텍스트를 뜻한다. Task 는 가벼워서 오버헤드가 적고, 취소하거나 작동을 멈추도록 할 수도 있다.

Task 에서 고려해봐야 할 점은 멈췄다가 실행하고 멈췄다가 실행한다 는 것이다. Task 가 멈춰있을 때는 CPU 를 yield 하기 때문에 CPU 자원도 효과적으로 사용할 수 있다.

Data-race(경쟁상태) 는 여기서 발생한다. 만약 수정가능한 상태값이 있을 때 서로 다른 스레드에서 접근하여 수정하거나 읽으려고 할 경우 원치 않는 결과를 가져올 수 있다.

Swift 6 는 경쟁상태를 해결하기 위해 컴파일 시점에 동시성에 사용할 값 타입에 대해 Sendable 프로토콜을 구현할 것을 요구한다. 이를 구현하였다는 것은 컴파일러에게 동시적으로 해당 값에 접근할 수 있음을 뜻하는 것이며, 값을 읽거나 쓸 때 많은 lock 이 실행되게 된다.

이를 안전하게 실행하기 위해 Actor 가 제공된다. Actor 는 참조 타입인데, 공유 가능하고 수정 가능한 상태값을 동기화 된 접근으로부터 캡슐화 한다. 하나의 Task 만이 Actor 의 실행 중 접근이 가능하다. Actor 의 메소드를 캡슐화 된 영역 바깥에서부터 호출하는 것이 비동기 프로그래밍이다.

extension UserStore {
    static let shared = UserStore.makeSampleStore()
}

router.get("friendsOfFriends") { request, context -> [String] in
    let username = try request.queryArgument(for: "username")
    return try await UserStore.shared.friendsOfFriends(username) // actor 로 인한 비동기 접근
}

public actor UserStor {
	package var allUsers: [String: User] = [:]
	static func makeSampleStore() {
    	...
    }
}

class 였던 UserStore 를 actor 로 바꿈으로써 UserStore 에 대한 비동기적 접근이 가능해졌다.

Extensibility

타입 확장은 프로토콜에서도 한번 다뤘지만, boiler-plate code, duplication 을 줄이는데 효과적이다.

대표적인 예시는 property wrapper 이다.

struct FriendsOfFriends: AsyncParsableCommand {
    @Argument var username: String

    mutating func run() async throws {
        // ...
    }
}

애플의 예시는 서버 애플리케이션의 Argument 로서 동작할 username 에 대한 코드라 좀 어려운 것 같다. 좀 더 간단한 예시는 역시 RGB 컬러를 저장하는 타입이지 않을까 싶다.

@propertyWrapper struct RGBElement {
	private var value: Int = 0
    
    var wrappedValue: Int {
    	get { self.value }
        set { // RGB 색상에서 red/green/blue 는 0 부터 255 의 값만 가질 수 있다.
        	if 0 ... 255 ~= newValue {
            	self.value = newValue
            } else {
            	self.value = newValue < 0 ? 0 : 255
            }
        }
    }
}

struct SafeRGBColor {
	@RGBElement var red: Int
	@RGBElement var green: Int
	@RGBElement var blue: Int
    
    var color: UIColor {
        UIColor(red: red, green: green, blue: blue, alpha: 1.0)
    }
}

Swift의 새로운 기능

이 세션은 먼저 Swift 10주년 기념인 만큼 Swift 가 걸어온 길을 간단히 소개한다. (그러므로 나는 소개하지 않으려 한다. 본인도 흥미로운 부분들이 많았으니 궁금하신 분은 세션을 통해 직접 확인 바란다.)

개인적으로 눈에 띄는 부분은 ABI 가 공식적으로 지원되지 않았다는 것이었다. 머리털나고 처음 듣는 내용이라 위키를 찾아보니 아래와 같이 설명하고 있다.

응용 프로그램 이진 인터페이스(Application Binary Interface, ABI)는 응용 프로그램과 운영 체제 또는 응용 프로그램과 해당 라이브러리, 마지막으로 응용 프로그램의 구성요소 간에서 사용되는 낮은 수준의 인터페이스이다.

그리고 해당 세션의 전문에서는 이렇게 설명한다.

In Swift 5 we introduced the stable ABI on Apple Platforms. For app developers, this meant a smaller download size, because you no longer bundled a complete copy of the Swift standard library in your app.
Swift 5 에서 애플 플랫폼의 안정적인 ABI 를 공개했습니다. 앱 개발자에게 있어 더 이상은 Swift standard library 를 앱에 완전히 복사하여 번들링 할 필요가 없게 되었습니다.

가끔 클라이언트 측으로부터 앱 사이즈 측정을 요청받는 경우가 있는데 이거랑 연관이 있는건가 싶었다. Swift 4 가 2017년이니 ABI 로 앱 사이즈 개선이 많이 이뤄졌다는 것이 현업에 전달되기는 아직 이르다.

그 뒤에는 Swift 의 새로운 기능을 설명하는데 목록은 아래와 같다.

  • Cross compilation to Linux
  • Foundation
  • Swift Testing
  • Improvements to builds
  • Swift's new space
  • Launguage updates
    - Noncopyable types
    - Embedded Swift
    - C++ Interperability
    - Typed throws
    - data-race safety

Cross compilation to Linux

Cross compilation 이 뭘까? 영상에서는 이렇게 설명하고 있다.

MacOS 실행가능한 앱을 iPad 에서 실행하는 것과 같이 MacOS 실행가능한 앱을 Linux 에서 실행할 수 있도록 지원한다는 것이다. 앱은 MacOS 에서 개발하고 배포 및 실행은 Linux 에서 하는 것이다.

이를 위해 Linux SDK for Swift 를 제공한다. 앱 실행 및 개발을 위해 Linux SDK for Swift 외에 추가적인 Swift Package 설치가 필요하지 않다.

세션에서는 리눅스 서버에서 랜덤 이모지를 반환하는 간단한 API 를 제공하는 프로그램을 Swift Package 로 개발 및 배포하는 것을 보여준다.

  1. Xcode Command Line Tools 등을 통해 package 를 만들어서 localhost 에 특정 URL을 통해 API를 제공되는 간단한 프로그램을 만든다.
  2. swift build 명령어를 통해 빌드 하고 실행한다. 터미널 창을 하나 열고 curl 명령어로 실제 API 를 호출해본다.
  3. 성공했다면 swift sdk install ~/preview-static-swift-linux-0.0.1.tar.gz 명령어를 통해 Linux SDK for Swift 를 설치한다.
  4. cross compile 을 위해 아래의 플래그를 붙여서 컴파일한다.
    • --swift-sdk : 빌드를 진행할 SDK 를 정의한다.
    • aarch64-swift-linux-musl : musl 이라는 리눅스 커널 라이브러리를 사용하는 ARM64 Linux 환경에서 실행할 바이너리를 생성하라고 지시한다.
  5. Linux SDK for Swift 설치가 완료되었다면 swift build --swift-sdk aarch64-swift-linux-musl 로 다시 빌드하고 실행가능한 앱을 리눅스 서버로 복사(배포)한다.
  6. 리눅스 서버에서 앱을 실행하고 터미널에서 localhost 부분을 리눅스 서버의 IP 및 Port 로 바꾼다.

Swift Package 를 만드는 것에 익숙하지 않은 나로선 리눅스에서 실행 가능한 앱을 만든것보다 Swift Package 로 앱을 만드는 과정이 더 흥미로웠다.

Foundation

Foundation 은 모든 애플 플랫폼에서 사용할 프레임워크로 만들어졌다. 이는 Swift 에서도 마찬가지였기 때문에 swift-corelib-foundation 이 만들어졌고, 이 둘을 하나의 프레임워크처럼 모든 애플 플랫폼에서 사용하기 위해 swift-foundation 이 만들어졌다.

swift-foundation 은 오픈소스이다. 누구든 contribute 할 수 있다.

Swift Testing

새로운 프레임워크이고 오픈소스이다. 최신 Swift 기능을 탑재하였으며 코드베이스와 seamlessly integrate 한다고 한다.

// XCTest framework
import XCTest

func testRating(videoId: Int, videoName: String, expectedRating: String) {
    let video = Video(id: videoId, name: videoName)
    #expect(video.rating == expectedRating)
}

// Swift Testing 

import Testing

@Test("Recognized rating", // display name
       .tags(.critical), // tag
       arguments: [ // add argument as test function
           (1, "A Beach",       "⭐️⭐️⭐️⭐️⭐️"),
           (2, "Mystery Creek", "⭐️⭐️⭐️⭐️"),
       ])
func rating(videoId: Int, videoName: String, expectedRating: String) {
    let video = Video(id: videoId, name: videoName)
    #expect(video.rating == expectedRating)
}

위처럼 기존에는 test 라는 이름을 넣었어야 하는 것과 다르게 새로 추가된 매크로인 @Test 를 통해 여러 방식으로 커스텀이 가능해졌다.

자세한 사항은 따로 세션이 준비되어 있으므로 따로 참고하기 바란다.

Improvements to builds

빌드 과정에 향상된 부분이 있다고 한다.

하나의 모듈(소스코드 및 파일의 집합)은 다른 모듈에 의존성을 갖고 이는 SDK 까지 이어질 수도 있다. 빌드 과정에서 이 의존성을 파악하는 과정은 내부적으로 이루어진다.

이 과정은 굉장히 많은 작업량을 갖는데, 모듈의 의존성을 파악하는 과정이 병렬적이 아닌 순차적이기 때문이다. 여기다가 디버거가 위의 과정을 똑같이 반복하면 디버거가 처음 무언가를 출력하기 전까지는 또 한번의 긴 멈춤을 확인할 수 있다.

이번에 소개된 Explicitly built modules 를 통해 내부적인 빌드과정을 외부로 이동시키게 되었다. 이를 통해 아래의 개선사항이 있다고 한다.

  • More parallelism in builds : 병렬적으로 진행되는 빌드. 빌드 성능 향상.
  • Better visibility into build steps : 빌드 로그가 더욱 직관적.
  • Improved reliability of builds : 빌드가 좋아졌다는 뜻.
  • Faster debugger start-up : 디버거가 빨라졌다는 뜻.

물론 이를 강제하지는 않는 것 같다. Xcode 에 아래의 빌드 세팅이 추가되는 것을 기다려보자.

Swift's new space

Swift 관련 프로젝트를 관리하는 레포지토리를 관리하기 위해 깃허브 프로젝트로 swiftlang 을 만들었다고 한다.

현재시각 2024-6-16 12:15PM. 레포지토리 수는 7개이다. 관련 프로젝트 몇가지만 확인되고 세션에서 언급한 Swift Compiler, Foundation 등은 확인되지 않는다. 앞으로 쭉 진행한다고 했으니 순차적으로 진행할 것인가보다.

개인적으로 깃허브 내 Swift 코드를 직접 참조 링크로 건 일이 있는데 주소가 바뀌면 한번 확인해봐야 겠다.

Launguage updates

Swift 는 6버전이 되면서 data-race safety, 제한된 embedded 환경 제공을 위한 업데이트가 이뤄질 것이다. 이를 위해 아래 5가지 업데이트 사항이 제공될 것이다.

Noncopyable types

값 타입, 참조 타입 모두 Copyable 타입이었다. 하지만 이를 차단하는 Noncopyable 타입이 생겼다.

struct File: ~Copyable {
  private let fd: CInt
  
  init(descriptor: CInt) {
    self.fd = descriptor
  }

  func write(buffer: [UInt8]) {
    // ...
  }

  deinit {
    close(fd)
  }
}

Noncopyable 타입은 위와 같이 ~Copyable 로 선언할 수 있다. deinit 의 close 는 자동으로 실행된다고 하는데 직접 써보지 않으면 어떨지 모르겠다. Noncopyable 타입으로 가장 적절한 것은 앱이 사용하는 시스템의 자원이다. 위의 예시처럼 파일이 대표적일 것이다.

이를 통해 동시성에 의한 런타임 이슈인 스레드 간 경쟁상태를 제거하고, 자동 cleanup 함수를 사용하지 않아 파일이 닫히지 않은 resource leak 을 방지할 수 있다.

Noncopyable 타입이 resource leak 을 방지할 순 있지만 한 가지 주의사항이 있다. 하지만 위의 예시에서 문제가 하나 있는데 initializer 이다.

guard let fd = open(name) else { // open function
  return
}
let file = File(descriptor: fd) // initialization
file.write(buffer: data)

위 코드에서 File descriptor 를 사용하는데 이는 직관적이지 않고 안전하지도 않다. open 함수에 의해 initialization 가 실행되지 않고 종료되면 deinit 이 실행되지 않고 resource leak 이 발생한다. 아래와 같이 코드를 변경해보자.

struct File: ~Copyable {
  private let fd: CInt
  
  init?(name: String) {
    guard let fd = open(name) else {
      return nil
    }
    self.fd = fd
  }

  func write(buffer: [UInt8]) {
    // ...
  }

  deinit {
    close(fd)
  }
}

우선 init 함수 자체가 직관적이다. 그리고 Optional init에 의해 Optional Noncopyable 타입이 생성되었다. Optional 은 대표적인 제네릭 타입인데, Swift 5.10 의 Noncopyable 타입은 제네릭 타입이 아닌 구체타입만 제공된다. 그 대신 Swift 6 은 제네릭으로 제공된다.

Noncopyable 의 목적은 무엇일까? 그것은 Unique ownership 을 통한 퍼포먼스 향상 이다. Noncopyable 타입으로 선언된 데이터는 특정 스레드나 Task 등만 소유하고 write/update 할 수 있다. 이를 통해 data-race 를 방지하고 성능도 향상한다는 얘기이다. 사람에 따라서는 다르게 들릴 수도 있을 것 같다.

Swift 개발자로 처음 입문하는 개발자들이 점점 진입하기 어려워지는 것 같다는 느낌도 든다...

Embedded Swift

Embedded Swift 는 Javascript 와 Typescript 의 관계처럼 Swift 의 서브셋이다. Swift 라는 말로 들린다. Embedded Swift 는 우선 작다. 작게 만들기 위해 새로운 컴파일 모델을 생성했다는 것이다.

이를 위해 제한된 사항 중 대표적인 것은 Mirror(swift reflection), any 이다. 컴파일 과정에서는 full generics specialization(무슨 소리인지 잘 모르겠다), static linking 을 통해 작은 바이너리를 생성한다.

ARM / RISC-V 등 여러 칩에서 작동 가능할 것이다.

C++ interoperability

작년의 C++ 상호작용과 관련하여 올해는 아래와 같은 개선사항이 있다고 한다. C++ 은 잘 몰라서 아래의 스크린샷으로 대체한다...

Typed throws

특정 타입의 에러를 핸들링 하기 위해서는 catch 구문에서 에러에 대해 as 로 타입 변환을 해줘야 했다. 이를 줄이기 위해 throws 키워드 뒤에 실제 에러의 concrete 타입을 정의할 수 있도록 하였다.

enum IntegerParseError: Error {
  case nonDigitCharacter(String, index: String.Index)
}

func parse(string: String) throws(IntegerParseError) -> Int {
  for index in string.indices {
    // ...
    throw IntegerParseError.nonDigitCharacter(string, index: index)
  }
}

do {
  let value = try parse(string: "1+234")
}
catch {
   // error is 'IntegerParseError'
}

이제 위와 같이 에러 타입을 선언하지 않는 것은 any Error 를 선언한 것과 같이 된다. 만약 throws 자체를 하지 않는 경우는 throws(Never) 와 같아진다.

이는 아래와 같이 응용할 수 있다. Collection 프로토콜의 map 을 확장하는 예시인데, map 에 전달할 클로저가 throws 를 할 경우 throws 할 에러의 타입을 명시할 수 있다.

extension Collection {
	func map<T, Failure>(body: (Element) throws(Failure) -> T) throws(Failure) -> [T] {
    	// ....
    }
}

물론 에러 타입을 제한하는 것이 제한사항은 아니다.

Data-race safety

Swift 6 compiler 의 Swift 6 language mode 는 Data-race safety 를 기본 제공한다.(!!!)

Swift concurrency 가 나오고 나서 부터 Data-race safety 는 Swift concurrency 의 목표 중 하나였다. Swift concurrency 가 data isolation 메커니즘을 갖도록 디자인된 것도 이러한 이유였다.

Data-race safety default 를 제공하는 Swift 6 language mode 는 제한사항이 아니다(우선 지금은 그렇다). 모듈별로 준비가 되면 하나하나 스위치 켜듯 Swift 6 compiler 로 업데이트 하여 flag 값이 수정되면 진행된다.

구체적으로 어떤 일이 일어날까?

class Client {
  init(name: String, balance: Double) {}
}

actor ClientStore {
  static let shared = ClientStore()
  private var clients: [Client] = []
  func addClient(_ client: Client) {
    clients.append(client)
  }
}

@MainActor
func openAccount(name: String, balance: Double) async {
  let client = Client(name: name, balance: balance)
  await ClientStore.shared.addClient(client)
  // 에러 발생시키기!
  logger.log("Opened account for \(client.name)")
}

Swift 5.10 에서는 addClient 쪽에 컴파일러 warning 이 뜬다. MainActor 에서 실행되는 actor 가 다루는, 새로 만들어진 client 변수의 Client 는 Sendable 을 구현하지 않기 때문이다.

하지만 client 는 MainActor 에서 addClient 더 이상 사용되지 않는다. 이를 통해 client 가 더 이상 공유되지 않는다는 것을 컴파일러가 인지하고 warning 이 발생하지 않아 컴파일이 정상적으로 작동한다.

하지만 MainActor 에서 client 를 통해 무언가를 하려고 하면 warning 이 아닌 error 를 볼 것이다.

추가로 저레벨의 Synchronization module 을 소개한다.

  • Atomic 타입은 어떤 타입이든 제네릭하게 가지고 있고 효율적으로 lock-free 하게 동작한다. 안전한 접근을 위해 let 을 사용하도록 권장하고 있다.
import Dispatch
import Synchronization 

let counter = Atomic<Int>(0)

DispatchQueue.concurrentPerform(iterations: 10) { _ in
  for _ in 0 ..< 1_000_000 {
    counter.wrappingAdd(1, ordering: .relaxed)
  }
}

print(counter.load(ordering: .relaxed))
  • Mutex 를 제공한다. 완벽한 Lock 상태에서 값을 쓰고, 다른 쓰기 동작은 앞의 동작이 끝날 때까지 기다리는데, 이는 Mutext 의 withLock 클로저에 의해 수행된다.
import Synchronization

final class LockingResourceManager: Sendable {
  let cache = Mutex<[String: Resource]>([:])
  
  func save(_ resource: Resource, as key: String) {
    cache.withLock {
      $0[key] = resource
    }
  }
}

Swift 6 는 Swift 5 의 확장팩인가?

Swift 5 의 변천사를 가볍게 살펴보자. (출처)

  • 5.1 : Opaque return type, return 생략 가능, module format stability
  • 5.2 : key-path 함수에도 적용, 진단기능 향상
  • 5.3 : 여러 타입의 에러 catch, 연속적인 trailing closure 표현, SPM 기능 향상
  • 5.4 : Result builder, Variadic 파라미터 지원
  • 5.5 : Swift concurrency 에 대한 방대한 업데이트.
  • 5.6 : Concurrency, SPM 에 대한 추가 업데이트.
  • 5.7 : optional unwrap 표현 방식, Swift Regex 추가.
  • 5.8 : Result builder 제한사항 일부 제거, self 표현 생략 가능 범위 증가, @backDeployed 어노테이션 추가.
  • 5.9 : Macros 추가, if/switch 개선, task group discard 추가
  • 5.10 : global variable 을 동시성에서 사용 못하도록 제한.

이해가 가지 않는 몇가지 요소는 뺏다. 지금까지 살펴본 Swift 6 에 비해 굉장히 넓은 범위로 업데이트가 이뤄졌다.

Swift 6 는 이러한 업데이트를 더욱 향상된 방식으로 사용할 수 있도록 추가 업데이트를 계속 해나갈 생각인 것 같다. 그 첫번째는 Noncopyable 타입과 Swift 6 language mode 등을 통한 data-race safety, 그리고 Typed throws 를 통한 에러 핸들링 방식 개선이다.

Swift 5 에서 6 로 넘어가는 것은 생각보다 수월하지 않을까 생각한다.

Reference

profile
plug-compatible programming unit

0개의 댓글