Swift 시작하기: iOS 개발 언어의 특징과 Xcode 환경 설정
이 글의 핵심
Swift는 옵셔널과 강한 타입 검사로 실행 전에 실수를 잡는 쪽에 무게를 둔 언어입니다. Mac과 Xcode가 없어도 Swift Package Manager로 콘솔에서 실습하는 방법을 함께 보여주고, 입문 단계에서 자주 하는 실수와 메모리 안전성·소유권 모델이 무엇을 보장하는지도 짧게 짚어 다음 단계로 이어갑니다.
시리즈 안내
#01 | 📋 전체 목차 | 다음: #02 변수와 타입
들어가며
Swift란?
Swift는 Apple 플랫폼과 서버·도구까지 쓰이는 안전성과 성능을 겨냥한 언어입니다. 값 타입·프로토콜(타입이 따라야 할 기능·규약을 묶어 둔 것) 중심 설계가 강조됩니다. 특징:
- 안전성: 타입 안전, 옵셔널(값이 없을 수 있음을 타입으로 표현)
- 빠름: LLVM 기반 네이티브 컴파일 (최적화 빌드 기준, 안전 검사 비용은 있음)
- 간결함: 현대적 문법
- 상호운용: Objective-C 호환
- 오픈소스: Swift.org Swift vs Objective-C: | 특징 | Swift | Objective-C | |------|-------|-------------| | 문법 | 간결 | 복잡 | | 안전성 | 옵셔널 | Null 가능 | | 성능 | 빠름 | 빠름 | | 학습 곡선 | 완만 | 가파름 |
“안전하다”는 말은 구체적으로 실수를 실행 전에, 또는 실행 중 즉시 드러낸다는 뜻입니다. Objective-C에서는 nil 객체에 메서드를 호출해도 조용히 아무 일도 일어나지 않아서, 값이 비어 있다는 사실을 한참 뒤에야 알게 됩니다. Swift는 nil이 될 수 있는 값을 String?처럼 다른 타입으로 구분해 컴파일 단계에서 처리를 강제하고, 배열 범위를 벗어나거나 정수가 넘치면 그 자리에서 프로그램을 멈춥니다. 조용히 틀린 값으로 계속 실행되는 것보다 크래시가 낫다는 선택이며, 이 성향을 알고 나면 뒤에 나오는 컴파일 에러들이 왜 그렇게 엄격한지 이해하기 쉽습니다.
Xcode 설치와 Playground
Mac에서 설치
- Mac App Store에서 Xcode 검색
- 다운로드 (버전에 따라 수 GB~10GB 이상이며, 설치 중 압축 해제 공간이 추가로 필요)
- 설치 완료 후 실행
Xcode는 App Store 설치가 가장 간단하지만, 디스크 여유 공간이 부족하면 설치가 멈춘 것처럼 오래 걸리거나 실패합니다. 여러 버전을 함께 써야 하거나 특정 버전이 필요하면 Apple Developer 사이트에서 .xip 파일로 받는 방법도 있습니다. 설치 후 처음 실행할 때 추가 구성 요소와 시뮬레이터 런타임을 내려받는 단계가 있으니 끝날 때까지 기다리세요. 터미널에서 swift --version이 동작하지 않는다면 xcode-select --install 또는 Xcode 설정의 Command Line Tools 항목을 확인합니다.
Playground 사용
- Xcode 실행
- File > New > Playground
- 코드 작성 후 즉시 실행
Playground와 프로젝트에서 Hello World
Playground에서
print("Hello, Swift!")
프로젝트에서
import Foundation
func main() {
print("Hello, Swift!")
}
main()
Swift에는 C처럼 컴파일러가 자동으로 호출하는 main 함수가 없습니다. 실행 파일 프로젝트의 main.swift 파일은 최상단 코드가 곧 프로그램의 시작점이라서, 위 예제는 main()을 마지막 줄에서 직접 호출합니다. 이 호출을 빼먹으면 아무것도 출력되지 않은 채 정상 종료합니다. 반대로 main.swift가 아닌 다른 파일에 print(...) 같은 문장을 최상단에 쓰면 Expressions are not allowed at the top level 오류가 납니다. 앱 프로젝트에서는 @main이 붙은 타입이 시작점 역할을 하므로 main.swift가 따로 없습니다.
let과 var, 타입 추론
기본 사용
// 변수 (var)
var name = "홍길동"
name = "김철수" // OK
// 상수 (let)
let age = 25
// age = 26 // 컴파일 에러!
// 타입 명시
var score: Int = 100
let pi: Double = 3.14159
기본은 let입니다. 한 번 정한 값을 바꾸지 않는 경우가 생각보다 훨씬 많고, 바뀌지 않는다는 사실이 코드에 드러나면 읽는 사람이 추적할 상태가 줄어듭니다. var로 선언하고 한 번도 바꾸지 않으면 컴파일러가 Variable 'name' was never mutated; consider changing to 'let' constant 경고를 내 주므로, 경고를 따라가는 것만으로도 습관이 잡힙니다. 반대로 let으로 선언한 값에 다시 대입하면 Cannot assign to value: 'name' is a 'let' constant 오류가 납니다.
타입 추론
let message = "안녕하세요" // String
let count = 10 // Int
let price = 9.99 // Double
let isActive = true // Bool
타입 추론은 “타입이 없다”는 뜻이 아니라 컴파일러가 초깃값을 보고 타입을 정한다는 뜻입니다. 한 번 Int로 정해진 count에 나중에 문자열을 넣으면 컴파일 에러가 납니다. 소수점이 있는 리터럴은 기본적으로 Float가 아니라 Double로 추론되고, 정수 리터럴은 Int(64비트 기기에서 64비트)로 추론됩니다. 다른 타입이 필요하면 let ratio: Float = 0.5처럼 명시합니다.
숫자·문자열·불리언 기본 타입
숫자 타입
// 정수
let a: Int = 10
let b: UInt = 20 // 양수만
// 실수
let x: Double = 3.14159 // 64비트
let y: Float = 3.14 // 32비트
// 연산
let sum = a + 5
let product = x * 2.0
다른 언어에서 넘어온 사람이 가장 먼저 부딪히는 것이 숫자 타입 사이의 자동 변환이 없다는 점입니다. a + x처럼 Int와 Double을 섞으면 Binary operator '+' cannot be applied to operands of type 'Int' and 'Double' 오류가 나고, Double(a) + x처럼 직접 변환해야 합니다. 위 코드의 a + 5와 x * 2.0이 되는 이유는 리터럴 5와 2.0은 아직 타입이 정해지지 않은 값이라 상대 타입에 맞춰지기 때문입니다.
정수 연산이 범위를 넘으면 C처럼 조용히 값이 돌아가지 않고, 실행 중에 Swift runtime failure: arithmetic overflow로 즉시 멈춥니다. 값이 돌아가는 동작이 정말 필요하면(해시 계산 등) &+, &* 같은 오버플로 연산자를 명시적으로 씁니다. UInt는 “양수만 담는다”는 의미를 표현하기에 좋아 보이지만, Int와 섞일 때마다 변환이 필요하고 0에서 1을 빼면 크래시가 나므로, Apple 가이드도 특별한 이유가 없으면 음수가 될 수 없는 값에도 Int를 쓰라고 권합니다.
문자열
// 변수 선언 및 초기화
let name = "홍길동"
let greeting = "안녕하세요, \(name)님!"
// 여러 줄
let multiline = """
첫 번째 줄
두 번째 줄
"""
// 문자열 연산
let fullName = "홍" + "길동"
let length = name.count
name.count는 바이트 수가 아니라 사람이 보는 글자 수(확장 자소 클러스터)를 셉니다. 그래서 “홍길동”의 count는 3이고, 여러 코드 포인트로 이루어진 이모지도 1로 셉니다. 이 설계의 대가로 Swift 문자열은 name[0]처럼 정수로 인덱싱할 수 없고, 시도하면 'subscript(_:)' is unavailable: cannot subscript String with an Int 오류가 납니다. 글자 경계를 알려면 앞에서부터 세어야 하기 때문에 정수 인덱스가 O(1)이 될 수 없어서입니다. 첫 글자는 name.first, n번째 글자는 name[name.index(name.startIndex, offsetBy: n)]처럼 String.Index로 접근합니다. 자세한 내용은 #02에서 다룹니다.
불리언
// 변수 선언 및 초기화
let isActive = true
let isCompleted = false
if isActive && !isCompleted {
print("진행 중")
}
함수 선언과 매개변수 레이블
기본 함수
func greet(name: String) {
print("안녕하세요, \(name)님!")
}
greet(name: "홍길동")
반환 값
func add(a: Int, b: Int) -> Int {
return a + b
}
let result = add(a: 10, b: 20)
print("결과: \(result)")
매개변수 레이블
Swift의 독특한 기능으로, 함수 호출 시 가독성을 높입니다:
// to, from: 외부 매개변수 레이블 (호출 시 사용)
// name, sender: 내부 매개변수 이름 (함수 본문에서 사용)
func greet(to name: String, from sender: String) {
// 함수 내부에서는 name, sender 사용
print("\(sender)가 \(name)에게 인사합니다.")
}
// 호출 시 외부 레이블 사용 (가독성 향상)
greet(to: "홍길동", from: "김철수")
// "to 홍길동, from 김철수" - 마치 자연어처럼 읽힘
// 레이블 없이 호출하려면 _ 사용
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
add(10, 20) // 레이블 없이 호출
매개변수 레이블의 장점:
- 함수 호출이 자연어처럼 읽힘
- 같은 타입의 매개변수를 명확히 구분
- API 설계 시 의도를 명확히 전달
레이블은 함수 이름의 일부처럼 취급됩니다. greet(to:from:)과 greet(name:)은 이름이 같아도 서로 다른 함수이고, 호출할 때 레이블을 빼거나 순서를 바꾸면 Missing argument label 'to:' in call 같은 오류가 납니다. 레이블 없이 호출하게 하려면 func add(_ a: Int, _ b: Int)처럼 외부 이름 자리에 _를 둡니다. 표준 라이브러리의 print(_:)나 max(_:_:)가 레이블 없이 호출되는 것도 이 때문입니다. 첫 번째 인자가 함수 이름만으로 뜻이 분명하면 _, 그렇지 않으면 레이블을 두는 것이 Swift API 설계 가이드라인의 기본 방향입니다.
if-else, for, switch 제어문
if-else
let age = 20
if age >= 18 {
print("성인")
} else {
print("미성년자")
}
for 루프
// 실행 예제
for i in 1...5 {
print(i)
}
let names = ["홍길동", "김철수", "이영희"]
for name in names {
print(name)
}
switch
let status = "active"
switch status {
case "active":
print("활성")
case "inactive":
print("비활성")
default:
print("알 수 없음")
}
Swift의 switch는 두 가지 점에서 C와 다릅니다. 첫째, 각 case가 끝나면 자동으로 빠져나오므로 break가 필요 없고, 다음 case로 넘어가려면 fallthrough를 명시해야 합니다. 둘째, 모든 경우를 다뤄야 합니다. 문자열처럼 경우의 수가 무한한 값을 검사할 때 default를 빼면 Switch must be exhaustive 오류가 납니다. enum을 switch할 때 default 없이 모든 case를 나열해 두면, 나중에 enum에 case가 추가됐을 때 처리하지 않은 모든 switch가 컴파일 에러로 드러나므로 유지보수에 유리합니다. 범위(case 0..<18:)나 튜플, 조건(case let x where x > 100:)도 case로 쓸 수 있습니다.
예제: 간단한 계산기
func calculate(operation: String, a: Double, b: Double) -> Double? {
switch operation {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return b != 0 ? a / b : nil
default:
return nil
}
}
if let result = calculate(operation: "+", a: 10, b: 5) {
print("결과: \(result)")
}
if let result = calculate(operation: "/", a: 10, b: 0) {
print("결과: \(result)")
} else {
print("0으로 나눌 수 없습니다")
}
반환 타입이 Double?인 것이 이 예제의 핵심입니다. “계산할 수 없음”을 -1이나 0 같은 특별한 값으로 표현하면 호출하는 쪽이 그 약속을 잊는 순간 버그가 되지만, 옵셔널로 돌려주면 호출하는 쪽은 if let으로 값을 꺼내지 않고는 결과를 쓸 수 없습니다. 다만 실패 이유가 여러 가지라면(0으로 나누기, 알 수 없는 연산자) nil 하나로는 구분이 안 되므로, 그때는 #07에서 다루는 throws와 에러 타입으로 바꾸는 것이 자연스럽습니다. 참고로 Double을 0으로 나누면 C와 마찬가지로 크래시 없이 inf가 나오기 때문에, 이 검사를 빼면 조용히 inf가 출력됩니다.
Codable로 JSON 한 번에 디코딩하기
Xcode 없이도 Swift Package Manager로 콘솔에서 실험할 수 있습니다. Package.swift에 실행 타겟을 두고 아래를 실행해 보세요.
main.swift:
import Foundation
struct Repo: Codable {
let name: String
let stargazers_count: Int
}
let json = """
{"name":"swift","stargazers_count":68000}
""".data(using: .utf8)!
do {
let repo = try JSONDecoder().decode(Repo.self, from: json)
print("\(repo.name) ★ \(repo.stargazers_count)")
} catch {
print("디코딩 실패: \(error)")
}
Codable을 채택하기만 하면 컴파일러가 프로퍼티 이름을 JSON 키로 쓰는 인코딩·디코딩 코드를 자동으로 만들어 줍니다. 그래서 위 예제는 JSON 키에 맞추려고 Swift 관례에 어긋나는 stargazers_count라는 이름을 썼습니다. 실제 코드에서는 프로퍼티를 stargazersCount로 두고 decoder.keyDecodingStrategy = .convertFromSnakeCase를 설정하거나 CodingKeys 열거형으로 이름을 매핑하는 방식이 일반적입니다.
디코딩이 실패하면 error에 원인이 구체적으로 담깁니다. JSON에 stargazers_count가 없으면 keyNotFound, 숫자 자리에 문자열이 오면 typeMismatch 오류가 나며, print(error)로 출력하면 어느 키에서 실패했는지 경로까지 보입니다. 서버 응답에 없을 수도 있는 필드라면 프로퍼티를 Int?로 선언해야 디코딩 전체가 실패하지 않습니다. .data(using: .utf8)!의 강제 언래핑은 문자열 리터럴이라 실패할 수 없는 경우라서 허용한 것이고, 외부 입력에는 이렇게 쓰지 않습니다.
강제 언래핑과 값·참조 타입 혼동
- 옵셔널을
!로 강제 언래핑해 런타임 크래시를 만드는 경우. if let/guard let범위를 벗어난 뒤 옵셔널을 사용하려는 경우.- 구조체를 값 타입으로 두고 참조 공유가 필요한데 클래스로 안 바꾸는 경우(또는 그 반대).
- Swift 버전·플랫폼에 따라 사용 가능한 API가 다릅니다(
#available).
팀 코드 스타일과 SPM 모듈화
- 팀에서 SwiftFormat/SwiftLint 규칙을 공유합니다.
- 작은 모듈부터 SPM 패키지로 쪼개 재사용성을 높입니다.
Swift와 Kotlin 한눈에 비교
| 언어 | 메모 |
|---|---|
| Swift | 안전성·플랫폼 통합 |
| Kotlin | Android·멀티플랫폼 |
| Dart | Flutter UI |
참고 자료
Swift의 메모리 안전성과 소유권 모델
입문 예제만으로는 “Swift가 안전하다”는 말이 추상적으로 느껴질 수 있습니다. 여기서는 컴파일 타임·런타임이 무엇을 보장하려 하는지, 그리고 Rust의 소유권과 어떻게 다른지를 짧게 짚습니다.
메모리 안전(Memory safety)이 가리키는 것
메모리 안전은 “유효하지 않은 주소를 읽거나 쓰지 않는다”는 넓은 목표를 두며, Swift는 그중에서도 특히 다음을 강하게 겨냅니다.
- 미초기화 읽기 금지:
let·var는 사용 전에 반드시 초기화되어야 하며, 분석 가능한 경로에서 이를 어기면 컴파일 에러가 납니다. - nil 접근 방지: 옵셔널이 아닌 타입은
nil이 될 수 없으며, 옵셔널은 언래핑·체이닝으로만 안전하게 풀도록 유도합니다. 이는 널 포인터 역참조를 상당 부분 형(type) 수준에서 제거합니다. - 배타적 접근(Exclusivity): 같은 변수에 대해 겹치는 구간에서 동시에 가변 접근하지 못하도록 규칙이 있습니다. 이는 데이터 레이스로 이어지는 미정의 동작을 줄이기 위한 층입니다. 클로저가
inout을 캡처하는 패턴 등에서 컴파일러가 거부하는 이유가 여기에 있습니다.
C/C++에서 익숙한 “초기화되지 않은 스택 값 읽기”, “해제된 메모리 접근(use-after-free)” 같은 범주의 버그를 가능한 한 설계 단계에서 배제하려는 방향이라고 이해하면 됩니다.
소유권(ownership)이라는 말을 Swift에서 쓸 때
소유권이라는 용어는 Swift 5.9 이후 이동 전용(move-only) 타입 논의와 함께 더 자주 등장합니다. 다만 Swift의 기본 모델은 Rust의 소유권·수명·대여(borrow checker)와 동일하지 않습니다.
- 참조 타입(
class): 인스턴스는 힙에 있으며, 강한 참조의 개수를 ARC(Automatic Reference Counting)로 추적합니다. “소유”는 참조가 몇 개나 강하게 잡고 있는가에 가깝으며, 순환 참조는weak/unowned로 끊는 것이 일반적입니다. - 값 타입(
struct,enum): 복사 의미론을 따릅니다. 다만Array·String처럼 내부에 버퍼를 두는 타입은 Copy-on-Write로 실제 복사를 지연합니다. “값 타입이므로 항상 스택에만 있다”는 설명은 부정확할 수 있습니다. - inout, mutating: 메서드나 함수가 값을 제자리에서 바꿀 권한을 요청할 때 사용되며, 이때도 배타적 접근 규칙과 맞물립니다.
정리하면, Swift에서 말하는 소유권은 “ARC 아래의 참조 수명”과 “값 타입의 복사·배타적 접근”이 합쳐진 실무 규칙에 가깝으며, Rust 수준의 정적 대여 검사를 기대하면 모델이 다릅니다.
동시성과 C 브리징에서는 보장이 달라진다
안전한 언어라도 동시성(concurrency)과 C 브리징·원시 포인터가 섞이면 보장이 달라집니다. 메모리 안전은 기본 언어 서브셋에서 강하며, 그 경계를 넘을수록 명시적 계약과 도구(Thread Sanitizer, Instruments)가 필요합니다.
Swift 첫걸음 요약
- Swift: iOS/macOS 공식 언어
- Xcode: 통합 개발 환경
- Playground: 빠른 실험 도구
- 안전성: 타입 안전, 옵셔널
- 성능: 네이티브 컴파일, 안전 검사는 실행 중에도 유지
다음 단계
다른 언어와 비교
자주 묻는 질문 (FAQ)
Q. 입문 단계에서 옵셔널을 !로 강제 언래핑해도 되나요?
A. 값이 nil이면 그 자리에서 런타임 크래시가 나기 때문에 습관처럼 쓰지 않는 것이 좋습니다. if let이나 guard let으로 값이 있을 때만 사용하도록 흐름을 나누고, 바인딩한 변수는 그 범위 안에서만 쓸 수 있다는 점을 기억해야 합니다. 플랫폼 버전에 따라 없는 API를 호출하는 문제도 비슷하게 #available로 미리 분기해 두는 것이 안전합니다.