본문으로 건너뛰기
Previous
Next
Swift 변수와 타입 | var, let, 옵셔널

Swift 변수와 타입 | var, let, 옵셔널

Swift 변수와 타입 | var, let, 옵셔널

이 글의 핵심

Swift 변수와 타입: var, let, 옵셔널. 변수와 상수·기본 타입.

Swift 시리즈 #02전체 목차 | var·let·바인딩 흐름은 변수·옵셔널 바인딩이랑 같이 보면 편해.

타입 추론 믿다가 컴파일 20분…

한때 “Swift 컴파일러가 똑똑하니까 타입은 전부 생략해도 되지 않을까?” 싶은 적 있어. 큰 제네릭 덩어리에 클로저가 겹겹이 꽂힌 화면만 만지다 보면, 파일 하나 저장할 때마다 DerivedData 쪽이 숨 쉬는 느낌으로 컴파일이 늘어지거든. 나중엔 : 찍고 타입 달기 시작한 게 오히려 빨리 끝난 케이스가 많았어. 추론이 틀린 게 아니라, 문맥이 애매한 지점에서 컴파일러가 이리저리 찔러보느라 시간이 늘어나는 느낌이랄까. 그래서 나는 공개 API, 프로퍼티, 복잡한 제네릭에는 그냥 타입 써 주는 쪽이 마음이 편해. “추론이 싫다”가 아니라, 팀·미래의 나를 위한 쪽지인 거지.

강타입이라는 말, 실감나는 지점

Swift는 IntString+로 섞는 짓을 컴파일 단계에서 막아. 귀찮다고 느껴질 수 있는데, 그게 곧 “런타임에 터질 짓”을 앞으로 당기는 값이야.

let n: Int = 40
// let s = n + "점" // 오류: Int와 String을 +로 결합할 수 없음
let s = String(n) + "점" // OK: String으로 변환 후 결합

초기값이 있으면 컴파일러가 알아서 타입 잡아주는 건 맞아. 1이면 Int, 1.0이면 Double 쪽이 기본이고, Float 쓰고 싶으면 let x: Float = 1처럼 문맥을 먹여주면 돼. 모듈이 커지면 ModuleName.Type 같은 한정이 필요해질 때도 있고, 그때 “이게 왜 이 타입이지?” 싶은 건 이름 충돌 쪽을 한번 의심해 보면 돼.

값이 복사냐, 참조냐

struct·enum·튜플 쪽은 대입하면 다른 덩어리가 되는 쪽(값 의미론)이 기본이야. class같은 인스턴스를 가리키는 쪽. 예전에 표로 박아 두던 건 이렇게 말로만 정리해 볼게: 화면 여러 군데서 같은 걸 같이 바꿔야 하면 클래스 쪽이 후보고, 복사가 자연스러우면 struct 먼저. 그 외엔 “그냥 클래스”로 땡기지 말고, Swift 스타일은 보통 struct/enum 먼저 밀어.

struct Point { var x: Int; var y: Int }
var a = Point(x: 0, y: 0)
var b = a
b.x = 10
print(a.x) // 0 — a와 b는 별개의 값

class Counter { var value = 0 }
let c1 = Counter()
let c2 = c1
c2.value = 5
print(c1.value) // 5 — 같은 객체

Int, Double, String, Bool — 암승(암시적 승격) 없어

Int는 “그냥 정수”로 쓰는 게 맞고, 실수는 기본이 Double이야. String은 인덱스가 정수가 아니라는 것만 머릿속에 박아 두면, 나중에 한글·이모지 쪽에서 덜 울어. Bool은 C처럼 0이 자동으로 false 되는 식 아님.

숫자 끼리도 타입이 다르면 알아서 늘려주는 마법 없음. 더하기 전에 맞춰.

let i: Int = 10
let d: Double = 0.5
let sum = Double(i) + d

UInt 산술에 음수 섞이면… 그냥 심리적으로 피곤하니, 도메인에 진짜 필요할 때만 쓰는 걸 권해. (규칙이 아니라 내 취향이야.)

String 인덱스

[0]으로 찍는 거 안 돼. startIndex, index(after:) 같은 쪽 루트로 가. 유니코드 이슈 파고들 거면 unicodeScalars까지.

let s = "Swift"
let first = s[s.startIndex]
let i = s.index(s.startIndex, offsetBy: 2)
print(s[i]) // i

Optional — nil을 타입에 써 두는 것

T?는 문법 설탕이고, 실제로는 Optional 열거형이야. 그래서 switch에도 잘 맞지.

var name: String? = "Lee"
name = nil

switch name {
case .none:
    print("없음")
case .some(let v):
    print(v)
}

if let / guard let / ?. / ??로 흐름에 녹이고, !T!“진짜 항상 있어”가 코드·데이터·불변식에서 입증될 때만. 아니면 나중에 EXC_ 로고 보면서 후회해.

func len(_ s: String?) -> Int {
    guard let s = s else { return 0 }
    return s.count
}

튜플, 컬렉션 쪽은 “타입만” 짚고

짧은 반환이면 튜플도 좋아. 컬렉션은 배열/딕셔너리/Set 내부 이야기가 길어서 그쪽에서 보고, 여기선 타입만.

var list: [String] = ["a", "b"]
var scores: [String: Int] = ["a": 1]
var unique: Set<Int> = [1, 2, 2, 3]

DictionaryKeyHashable, SetElement: Hashable — 이 정도면 됐어.

typealias

긴 제네릭·클로저 시그니처에 별칭 달면 읽는 사람(미래의 나)이 고마워해.

typealias UserID = String
typealias Completion = (Result<Data, Error>) -> Void

func load(id: UserID, done: Completion) { }

캐스팅 — is, as?, as!, as

class Animal {}
class Dog: Animal { func bark() {} }

let a: Animal = Dog()
if a is Dog {
    let d = a as! Dog
    d.bark()
}
if let d = a as? Dog {
    d.bark()
}

as!는 “실패하면 내 책임” 각오가 있을 때. 평소엔 as? + 분기가 내 기본이야. ObjC랑 섞이면 브리징 의미로 as/as?가 달라지는 경우도 있으니, 그 프레임워크 문서는 한번 같이 봐.

Any, AnyObject — Any 쓰지 마 (웬만하면)

솔직히 말할게. [Any]로 데이터 모델 피하려다 보면, 나중에 전부 as? 뜯느라 시간만 간다. JSON부터라도 Decodable이나 도메인 타입으로 바로 받을 수 있으면 그게 훨씬 낫고, 꼭 다형이 필요하면 프로토콜이나 제네릭으로 좁혀. AnyObject는 레거시 API에서 잡힐 때가 있는데, 그래도 가능하면 구체 타입·프로토콜로 끊어.

let stuff: [Any] = [1, "hi", true]
for x in stuff {
    if let n = x as? Int { print(n) }
}

위 예제는 “이렇게 쓰라”가 아니라 이렇게 돌아갈 뿐이야. 새 코드에 복붙하지 마.

Never

Never는 “여기서 안 돌아와” 흐름. fatalError 류랑 잘 엮여서, 그 뒤 코드가 죽은 분기로 인식돼.

func die(_ message: String) -> Never {
    fatalError(message)
}

func parsePort(_ s: String) -> UInt16 {
    guard let p = UInt16(s) else {
        die("bad port")
    }
    return p
}

enum / struct / class / 프로토콜, 한입만

  • enum은 “유한한 대안 집합” + 연관 값으로 상태를 데이터로 쓰는 데 강해.
  • struct는 멤버와이즈 이니셜라이저 덕에 모델 짜기 편해. mutating으로 값 안 바꾸는 것도 익혀 둬.
  • class는 공유·상속·deinit·ARC. ARC 쪽이 더 잘 씹혀.
  • 프로토콜은 요구 사항 모아두는 거. any Describable 같은 존재 타입은 프로토콜 글에서 성능·표현 쪽을 같이 봐.
enum NetworkState {
    case idle, loading, failed(String), loaded(Data)
}

struct User { var name: String; var age: Int }

static이랑 class

static은 타입에 붙는 거고, 서브클래스에서 override 못 해. class클래스에서만 쓸 수 있고, 서브클래스가 override 할 수 있어. struct/enum 쪽엔 static만 있다고 보면 돼.

class Base {
    static func a() {}
    class func b() {}
}
class Sub: Base { }
// override class func b() {} // Sub에서 override 가능

중첩 타입

타입 안에 타입 넣으면 API 스코프가 정리돼서 이름 지옥이 줄어. Server.Kind, Server.Address 같은 느낌.

struct Server {
    enum Kind { case http, https }
    struct Address { var host: String; var port: UInt16 }
    var kind: Kind
    var address: Address
}

실전에서 이렇게 흔들리면

String이랑 Int+로 못 이어? String(n)이나 "\(n)" 쪽으로 가. “옵셔널인데 언랩 안 했다”는 if let/guard/?./??로. 이미 non-optional인데 if let 쓴 거면 if로 바꿔. !로 터지면 “데이터가 없을 수 있는데 ‘항상 있어’라고 찍었는지”부터 의심해. 숫자는 범위 넘기면 Int64Decimal 쪽 눈독.

이건 옛날엔 로 박아 두던 부분이었는데, 난 표 쓰기 싫어서 그냥 흐름으로만 적었어. 필요하면 댓이나 PR로 표 달라고 해도 돼(안 달릴 수도 있음).

예제: 타입으로 상태 한 바퀴

LoadState로 로딩/실패를 문자열 말고 타입으로 박아 두는 패턴, 실무에서 꽤 도움 돼. Product는 값 모델, ProductRepository는 경계. 구현체는 주입해 두면 테스트가 살아나.

enum LoadState<Value> {
    case idle, loading, loaded(Value), failed(Error)
}

struct Product: Identifiable {
    let id: String
    var name: String
    var price: Decimal
}

protocol ProductRepository {
    func fetch(id: String) async throws -> Product
}

final class APIProductRepository: ProductRepository {
    func fetch(id: String) async throws -> Product {
        Product(id: id, name: "예시", price: 9.99)
    }
}

맺으며

Swift는 강타입 + 추론 둘 다 쓰는 언어야. 나는 “추론만 믿고 다 생략” 쪽이 아니라, 읽힌다 싶을 때 타입을 박는 쪽이 좋다고 봐. Any는 정말 최후, 옵셔널은 남발 말고 “없음”이랑 “아직”을 구분할 수 있으면 enum으로 쪼개. 다음은 변수·바인딩이나 컬렉션·ARC·프로토콜 쪽이 자연스럽게 이어져.

배포 전에 git addcommitpushnpm run deploy — 이 순서는 예전이랑 똑같아.