Swift 변수와 타입 | var, let, 옵셔널
이 글의 핵심
Swift 변수와 타입: var, let, 옵셔널. 변수와 상수·기본 타입.
Swift 시리즈 #02 — 전체 목차 |
var·let·바인딩 흐름은 변수·옵셔널 바인딩이랑 같이 보면 편해.
타입 추론 믿다가 컴파일 20분…
한때 “Swift 컴파일러가 똑똑하니까 타입은 전부 생략해도 되지 않을까?” 싶은 적 있어. 큰 제네릭 덩어리에 클로저가 겹겹이 꽂힌 화면만 만지다 보면, 파일 하나 저장할 때마다 DerivedData 쪽이 숨 쉬는 느낌으로 컴파일이 늘어지거든. 나중엔 : 찍고 타입 달기 시작한 게 오히려 빨리 끝난 케이스가 많았어. 추론이 틀린 게 아니라, 문맥이 애매한 지점에서 컴파일러가 이리저리 찔러보느라 시간이 늘어나는 느낌이랄까. 그래서 나는 공개 API, 프로퍼티, 복잡한 제네릭에는 그냥 타입 써 주는 쪽이 마음이 편해. “추론이 싫다”가 아니라, 팀·미래의 나를 위한 쪽지인 거지.
강타입이라는 말, 실감나는 지점
Swift는 Int랑 String을 +로 섞는 짓을 컴파일 단계에서 막아. 귀찮다고 느껴질 수 있는데, 그게 곧 “런타임에 터질 짓”을 앞으로 당기는 값이야.
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]
Dictionary는 Key가 Hashable, Set도 Element: 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로 바꿔. !로 터지면 “데이터가 없을 수 있는데 ‘항상 있어’라고 찍었는지”부터 의심해. 숫자는 범위 넘기면 Int64나 Decimal 쪽 눈독.
이건 옛날엔 표로 박아 두던 부분이었는데, 난 표 쓰기 싫어서 그냥 흐름으로만 적었어. 필요하면 댓이나 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 add → commit → push → npm run deploy — 이 순서는 예전이랑 똑같아.