본문 바로가기
C.W.K.
Stream
Lesson 01 of 06 · published

값과 참조, 틀린 답을 거부하는 타입

~15 min · swift-for-apple, structs, classes, enums, modeling

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"값의 뜻이 그게 어디 있냐에 달려 있으면, 그 '어디'를 타입에 넣어."

사실은 구조체로, 정체성이 있는 건 클래스로

Swift struct는 값이야. 복사하면 서로 상관없는 사실이 두 개가 돼. class는 참조야. 변수를 복사해도 두 이름이 같은 객체 하나를 가리켜. 애플 플랫폼에서 뭘 어떻게 모델링할지는 대부분 이 차이로 정해져.

  • 네 데이터는 값이야. 캡처, 설정 문서, 전송 메시지는 구조체로 해. 필드가 전부 Sendable이면 알아서 Sendable이 되고, Equatable만 선언하면 ==로 비교되고, 앱의 다른 구석이 몰래 바꿀 수도 없어.
  • 프레임워크의 오래 사는 객체는 클래스야. 창, 뷰, 뷰 컨트롤러, 델리게이트, WatchConnectivity 세션. 리스폰더 체인에 들어가는 AppKit, UIKit 객체는 전부 NSObject 서브클래스야. 정체성 자체가 핵심이거든. 네가 설정한 그 뷰가 화면에 있는 그 뷰여야 하니까.
  • 관찰 가능한 모델은 클래스야. @Observable 매크로는 클래스에 붙어. 뷰가 지켜봐야 하는 건 시간이 지나며 바뀌는 공유 인스턴스 하나거든.

서브클래스 만들 생각이 없으면 클래스에 final을 붙여. 의도가 드러나고, 컴파일러가 메서드를 바로 호출할 수 있어.

잘못된 호출 자체를 못 하게 만드는 enum

이 가족의 데스크톱 클라이언트엔 파일이랑 터미널을 보여주는 도크가 있었는데, 그게 "세션의 작업 경로"에 붙어 있었어. 그냥 URL 하나였지. 레포가 여러 Mac에 같은 절대 경로로 똑같이 있는 환경이라, 다른 Mac에서 도는 세션을 열면 도크는 이 Mac의 체크아웃을 보여주고 거기를 고쳤어. 모델은 원격 호스트에서 일하는데 말이야. 겉보기엔 다 멀쩡했어. URL은 자기가 어느 기계 거인지 말을 못 하니까.

고칠 때 호출하는 곳마다 검사를 넣진 않았어. 대신 도크 연결부가 맨 URL 말고 경우가 두 개인 enum을 받게 바꿨어. .local(URL) 아니면 .unavailable(reason:). 그 뒤론 어떤 호출도 "이 경로, 호스트는 모름"을 표현조차 못 하게 됐어. 나중에 원격 세션에서도 도크가 돌아가야 해서 세 번째 경우 .remote(socket)를 추가했는데, 호출하는 코드는 한 줄도 안 바뀌었어. 모든 화면이 이미 그 결정 하나를 거치고 있었거든.

가져다 쓸 만한 모델링 습관이 이거야. 틀린 답을 거부하려고 만든 타입이 나중에 맞는 답을 담는 그릇이 돼. 연관값 있는 enum, 컴파일러가 빠짐없이 다뤘는지 검사해주는 switch, 불가능한 상태를 이니셜라이저에서 거부하는 구조체. 이런 것들이 런타임 버그 한 부류를 통째로 컴파일 에러로 바꿔.

Swift가 Objective-C를 만나는 곳

값을 싣는 Swift enum은 Objective-C엔 없어. 제네릭 구조체도 없고. 프레임워크 API 밑바닥이 Objective-C라면, 넘겨주는 건 클래스나 @objc 프로토콜, 단순한 타입이 돼. 풍부한 Swift 모델은 경계 이쪽에 남겨둬. 이 경계가 퀘스트 내내 나와. 스레드 가정, nil 규칙, 런타임이 객체를 만드는 규칙이 다 거길 넘나들거든.

Code

Spark의 캡처 목적지: '어디'를 타입에 담기·swift
import Foundation

struct Capture: Codable, Equatable, Sendable {
    let id: String          // the idempotency key the engine dedupes on
    var text: String
}

enum CaptureDestination: Equatable {
    case local(URL)
    case remote(host: String, port: Int)
    case unavailable(reason: String)
}

func folderLabel(_ destination: CaptureDestination) -> String {
    switch destination {                       // the compiler insists every case is handled
    case .local(let url):              return url.lastPathComponent
    case .remote(let host, let port):  return "\(host):\(port)"
    case .unavailable(let reason):     return "unavailable — \(reason)"
    }
}

final class CaptureQueueModel {   // one shared instance a view watches: a class
    private(set) var pending: [Capture] = []
    func enqueue(_ capture: Capture) {
        guard !pending.contains(where: { $0.id == capture.id }) else { return }
        pending.append(capture)
    }
}

External links

Exercise

Spark 패키지에 CaptureDestination enum이랑 Capture 구조체를 넣어. 이어서 네가 쓴 코드에서 맨 String이나 URL이 사실은 맥락(어느 서버, 어느 사용자, 어느 시간대)에 몰래 기대고 있는 곳을 하나 찾아. 그 맥락을 까먹을 수 없게 enum이나 구조체로 다시 설계하고, 그 바람에 컴파일러가 다시 들여다보라고 붙잡는 호출 지점을 전부 적어봐.
Hint
기본형 옆에 "로컬이라고 가정", "로그인 후에만 유효", "UTC 기준" 같은 주석이 있으면 이게 필요하다는 신호야. 그 가정 하나하나가 enum의 경우나 구조체 필드로 옮겨 갈 후보야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.