struct와 class는 언제 나누어 쓰나요?
기본값은 값 의미가 자연스러운 struct입니다. 동일한 정체성·수명·공유 변경이 필요할 때만 class를 고르고, UI 상태처럼 여러 작업이 동시에 바꾸는 데이터는 actor 또는 MainActor 경계를 함께 설명합니다.
PRACTICAL LANGUAGE GUIDE
Swift의 iOS·macOS 제품 개발, 동시성, 시스템 프레임워크 활용법과 면접 준비를 정리한 실무 가이드입니다.
Swift로 처음 코드를 쓰면 컴파일러가 계속 무언가를 요구한다는 인상을 받습니다. 값이 없을 수 있으면 옵셔널로 적어야 하고, 옵셔널을 벗기지 않으면 빌드가 통과하지 않습니다. 물음표와 느낌표가 붙는 문법이 처음에는 번거롭게 보이지만, 실제로 하는 일은 "값이 없을 때 화면에 무엇을 보여줄지"를 그 자리에서 결정하게 만드는 것입니다. 이 결정을 미룰 수 없으니 애매한 상태가 배포까지 흘러가는 경로가 하나 줄어듭니다.
두 번째로 걸리는 것은 값 의미입니다. struct와 배열, 딕셔너리를 다른 변수에 대입하면 논리적으로 복사본이 생깁니다. 한쪽을 바꿔도 다른 쪽은 그대로이므로, 객체를 넘긴 뒤 원본이 조용히 바뀌는 종류의 버그가 애초에 성립하지 않습니다. 실제 메모리 복사는 copy-on-write로 미뤄져 큰 배열을 함수 인자로 넘길 때마다 전체가 복제되지는 않지만, 쓰기가 일어나는 순간 복사가 발생한다는 점은 성능을 볼 때 기억해 둘 만합니다.
메모리는 ARC가 관리합니다. 가비지 컬렉터가 도는 것이 아니라 참조 카운트가 0이 되는 시점에 해제되므로 해제 시점이 예측 가능하고, 파일 핸들이나 소켓처럼 자원을 쥔 객체의 수명을 코드로 설명하기 쉽습니다. 대신 서로를 강하게 붙잡는 순환 참조가 생기면 그 메모리는 끝까지 남습니다. 실무에서 이 문제가 가장 자주 나오는 자리는 클로저입니다. 뷰 모델이 클로저를 보관하고 그 클로저가 self를 캡처하면 순환이 완성되므로, [weak self]를 적는 습관은 문법 장식이 아니라 누수를 막는 실제 조치입니다.
동시성도 컴파일러가 검사하는 영역으로 들어왔습니다. 화면 상태는 @MainActor에 두고, 여러 작업이 함께 바꾸는 데이터는 actor 안에 넣고, 그 경계를 넘어가는 값에는 Sendable 요구가 붙습니다. 규칙을 지키면 "어느 스레드에서 이 프로퍼티를 건드렸는가"를 로그로 추적하던 일이 상당 부분 타입 검사로 옮겨갑니다. 다만 콜백 기반의 오래된 코드를 옮길 때는 경고가 한꺼번에 쏟아지므로 모듈 단위로 끊어서 이관하는 편이 현실적입니다.
클래스 상속으로 공통 동작을 물려주던 습관은 Swift에서 잘 맞지 않습니다. 프로토콜로 필요한 능력을 선언하고 extension으로 기본 구현을 주는 방식이 표준에 가깝습니다. 상속 트리를 만들지 않아도 struct와 enum에 같은 동작을 붙일 수 있고, 테스트에서는 프로토콜에 가벼운 대역 구현을 끼워 넣으면 됩니다. 처음에는 "클래스를 왜 안 쓰지"라고 느끼지만, 값 타입 위주의 설계와 함께 보면 두 선택이 하나의 방향을 가리킨다는 것이 보입니다. 객체지향 개념을 언어별로 비교해 두고 싶다면 OOP 기초 정리를 함께 읽어도 좋습니다.
반환 타입에 some이 붙는 코드도 낯설게 보입니다. some Protocol은 구체 타입 하나를 숨겨서 돌려준다는 뜻이고, 프로토콜 이름을 그대로 타입 자리에 쓰는 경우는 여러 타입이 섞여 들어올 수 있는 상자에 가깝습니다. 앞쪽은 컴파일 시점에 타입이 하나로 정해져 간접 비용이 적고, 뒤쪽은 유연한 대신 실행 중 판단이 늘어납니다. SwiftUI에서 some View를 계속 보게 되는 이유가 여기 있습니다.
반대로 Swift가 잘 맞지 않는 자리도 분명합니다. 리눅스 서버와 명령줄 도구를 만들 수는 있지만 Apple 플랫폼 밖에서는 라이브러리 폭과 예제, 사람을 구할 여지가 함께 얇아집니다. 웹과 Android까지 한 팀이 같은 코드로 내야 하는 상황이라면 Kotlin이나 Flutter 쪽을 먼저 견줘 보고, 모바일 전반의 학습 순서를 정리하려면 모바일 개발 로드맵을 기준으로 삼는 편이 낫습니다. Swift의 강점은 언어 자체보다 카메라, 건강, 위젯, 결제처럼 플랫폼이 먼저 제공하는 기능을 제품 차별점으로 쓸 때 드러납니다.
actor DownloadStore {
private var cache: [URL: Data] = [:]
func data(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let (data, _) = try await URLSession.shared.data(from: url)
cache[url] = data
return data
}
}이 코드에서 캐시를 평범한 딕셔너리 프로퍼티로 두면 여러 화면이 같은 URL을 동시에 요청할 때 읽기와 쓰기가 겹칩니다. actor로 감싸면 이 타입의 저장 프로퍼티를 건드리는 코드는 반드시 await를 붙여야 하고, 잊으면 컴파일이 되지 않습니다. 캐시 적중이면 즉시 반환하고 없을 때만 네트워크를 타는 흐름이 한 곳에 모여 있어, 중복 요청이 어디서 나갔는지 찾는 시간도 줄어듭니다. 비동기 문법이 언어마다 어떻게 다른지는 언어별 비동기 비교에서 나란히 볼 수 있습니다.
iOS 앱을 실제로 배포하는 경로에서 Xcode를 빼기는 어렵습니다. 시뮬레이터, 미리보기, 디버거, 서명 설정이 한 도구에 묶여 있어 시작은 빠르지만, 팀이 커지면 프로젝트 파일 충돌과 빌드 시간이 눈에 띄는 비용으로 돌아옵니다. 의존성은 Swift Package Manager로 선언하는 방식이 자리를 잡았고, 앱을 여러 패키지로 쪼개 두면 증분 빌드가 짧아지고 기능별로 테스트를 따로 돌리기도 쉬워집니다.
테스트는 XCTest가 오래 쓰였고, 매크로 기반의 Swift Testing이 함께 쓰이는 방향으로 가고 있습니다. UI 테스트는 화면이 조금만 바뀌어도 깨지므로 유지 비용이 큽니다. 도메인 로직을 UI에서 떼어 내고 순수 함수와 actor 단위로 검증하는 쪽이 같은 시간 대비 회수가 큽니다. 성능이 의심되면 Instruments로 시간, 메모리 할당, 누수를 프로파일링합니다. 특히 순환 참조는 코드를 다시 읽는 것보다 그래프를 보는 편이 빠릅니다.
배포는 코드 서명에서 시작합니다. 인증서, 프로비저닝 프로파일, 팀 계정 권한, 기기 등록 같은 항목은 빌드 성공 여부와 무관하게 배포를 막습니다. 준비가 되면 TestFlight로 내부와 외부 테스터에게 먼저 돌리고, 크래시 리포트와 피드백을 확인한 뒤 스토어 심사에 올립니다. 이 과정은 처음 한 번이 가장 오래 걸리므로, 기능을 다 만든 다음에 착수하지 말고 프로젝트 초반에 빈 앱으로라도 한 번 끝까지 통과시켜 두는 편이 안전합니다.
스토어 심사는 팀이 통제할 수 없는 구간입니다. 리젝이 나오면 사유 확인, 수정, 재제출까지 며칠이 그대로 사라집니다. 결제, 계정 삭제, 개인정보 표기, 사용자 생성 콘텐츠처럼 정책에 자주 걸리는 기능은 마감 직전이 아니라 일정 초반에 한 번 심사를 통과시켜 두세요. 인증서와 프로비저닝 프로파일 만료일도 릴리스 달력에 함께 적어 두면 출시 당일에 서명 문제로 멈추는 상황을 피할 수 있습니다.
아래 항목을 읽으면서 지금 팀 상황에 그대로 대입해 보세요. 절반 이상이 앞쪽에 해당하면 배우는 시간이 곧 제품으로 돌아오고, 뒤쪽에 몰리면 다른 언어를 먼저 잡는 편이 낫습니다.
INTERVIEW PREP
기본값은 값 의미가 자연스러운 struct입니다. 동일한 정체성·수명·공유 변경이 필요할 때만 class를 고르고, UI 상태처럼 여러 작업이 동시에 바꾸는 데이터는 actor 또는 MainActor 경계를 함께 설명합니다.
UI는 일반적으로 메인 실행 맥락에서 변경해야 합니다. 네트워크·파싱은 분리해 수행하고, 화면 상태를 반영하는 지점만 MainActor로 보내면 응답성과 데이터 경계를 함께 관리할 수 있습니다.
NEXT STEP