Swift & Objective-C

[Swift 심화 #8] ~Copyable과 소유권, 복사가 금지된 타입

파일 핸들이나 락처럼 하나여야 의미 있는 자원은 복사되면 안 됩니다. Swift 5.9의 ~Copyable로 복사 금지를 타입에 새기는 법과 borrowing·consuming·inout 세 전달 방식, 쓸 자리와 안 쓸 자리를 정리했습니다.

7분 읽기
[Swift 심화 #8] ~Copyable과 소유권, 복사가 금지된 타입 대표 이미지

값 타입 편에서 Swift의 기본 동작을 “복사”로 정리했습니다. 대입하면 복사되고 함수에 넘기면 복사되고.

이전 글 Swift 심화 #7에서 이어지는 내용입니다.

그런데 복사되면 안 되는 값이 있다면 어떨까요. 파일 핸들, 뮤텍스 락, 은행 이체 토큰처럼 “세상에 딱 하나여야 의미 있는” 자원 말입니다.

심화 시리즈, 이번 편의 주제는 소유권(ownership)입니다. Swift 5.9의 ~Copyable(비복사 타입)과 borrowing·consuming이 그 문을 엽니다. SE-0390: Noncopyable Structs and Enums

Rust를 아는 분이라면 익숙한 냄새가 날 텐데, 맞습니다. Swift가 Rust의 핵심 아이디어를 자기 방식으로 수입한 영역이에요.

다만 방향이 다릅니다. Rust는 소유권이 기본이고 예외가 없지만, Swift는 복사가 기본이고 소유권은 선택 도구입니다.

복사 가능이 기본인 세계의 구멍

Swift의 모든 타입은 기본적으로 Copyable입니다. 명시한 적 없어도 컴파일러가 암묵적으로 채택시키는 프로토콜이에요.

값 편에서 본 대입·전달의 복사 의미론이 여기서 나옵니다. 대부분의 값(숫자, 문자열, 좌표)에는 완벽한 기본값이고요.

문제는 자원을 표현하는 타입입니다. 파일 디스크립터를 감싼 struct를 생각해볼게요.

struct FileHandle {
    let fd: Int32
    func close() { /* fd 닫기 */ }
}

let a = FileHandle(fd: open("data.txt"))
let b = a          // 복사 — 이제 같은 fd를 둘이 안다
a.close()
b.close()          // 이미 닫힌 fd를 또 닫음 — 미정의 동작

핸들이 복사되는 순간 “누가 닫을 책임자인가”가 모호해집니다. 이중 해제, 닫힌 핸들 사용 같은 자원 버그의 뿌리가 전부 이 무단 복사예요.

지금까지의 관례적 처방은 클래스로 만들고 deinit에서 닫기였습니다. 참조 하나로 관리 주체를 통일하는 거죠.

동작은 합니다. 다만 ARC(Automatic Reference Counting, 자동 참조 계수) 편에서 본 비용, 즉 힙과 참조 카운팅을 내야 하죠.

게다가 “복사하면 안 된다”는 규칙 자체는 여전히 컴파일러가 모릅니다.

~Copyable — 복사 금지를 타입에 새기다

Swift 5.9의 답이 비복사 타입입니다(Swift Evolution 제안 SE-0390). 물결표가 붙은 ~Copyable은 “Copyable을 채택하지 않는다”는 선언이에요. SE-0390: Noncopyable Structs and Enums

struct FileHandle: ~Copyable {
    let fd: Int32

    consuming func close() { /* fd 닫기 */ }
    deinit { /* 닫힌 적 없으면 여기서 닫기 */ }
}

let a = FileHandle(fd: open("data.txt"))
let b = a          // 복사가 아니라 이동 — 소유권이 b로 넘어감
// print(a.fd)     // 컴파일 에러: a는 이미 소비됨

동작이 근본적으로 바뀝니다. 대입은 복사가 아니라 이동(move)이 되고, 소유권을 넘긴 변수는 그 순간부터 사용 금지입니다.

위반은 런타임 크래시가 아니라 컴파일 에러고요. “이 값의 주인은 항상 정확히 하나”라는 규칙을 컴파일러가 장부로 관리하는 겁니다.

옵셔널이 nil을, Sendable이 레이스를 타입 문제로 만들었듯, 자원의 유일성이 타입 문제가 된 거예요.

덤도 있습니다. struct인데 deinit을 선언할 수 있어서, 주인이 스코프를 벗어나면 결정적으로 정리 코드가 돕니다.

클래스 없이 RAII(Resource Acquisition Is Initialization — 자원 확보를 초기화에 묶는 관용구) 스타일 자원 관리가 되는 겁니다.

복사가 만드는 이중 해제 버그와 이동으로 막는 구조를 대비한 도식
이중 해제라는 버그 클래스가 컴파일 에러로 바뀝니다

borrowing과 consuming — 함수에 넘기는 세 가지 방식

비복사 값을 함수에 넘기려는 순간 새 질문이 생깁니다. 복사해서 줄 수 없으니, 빌려줄 것인가 넘겨줄 것인가를 정해야 해요.

파라미터 수식어가 그 선언입니다.

borrowing은 대여입니다. 함수가 값을 읽기만 하고 소유권은 호출자에게 남습니다.

함수가 반환된 뒤에도 호출자는 값을 계속 쓸 수 있어요. func checksum(of handle: borrowing FileHandle) -> Int 같은 읽기 전용 작업의 자리입니다.

consuming은 양도입니다. 소유권이 함수로 넘어가고 호출자는 그 값을 다시 못 씁니다. 위의 consuming func close()가 정확히 이 의미예요.

close를 부른 뒤의 핸들 사용이 컴파일 에러가 되는 것, “닫은 핸들을 또 쓰는” 버그 클래스가 문법에서 소멸하는 순간입니다.

은행 이체 토큰, 일회용 티켓처럼 “쓰면 없어져야 하는” 도메인 개념을 모델링하는 도구이기도 합니다.

inout은 기존 그대로, 빌려서 고쳐놓기입니다. 셋을 나란히 놓으면 함수 파라미터의 소유권 어휘가 완성됩니다. 읽기만(borrowing), 가져가기(consuming), 고쳐놓기(inout).

이 수식어들은 Copyable 타입에도 붙일 수 있습니다. 이때는 의미가 아니라 성능 힌트가 돼요.

기본 규약에서 발생할 수 있는 retain/release나 복사를 빌림·이동으로 대체해 ARC 트래픽을 줄이는 겁니다. consume 연산자(let b = consume a)로 명시적 이동을 요구하는 것도 같은 계열이고요.

다만 이건 계측이 선행돼야 하는 마이크로 최적화 영역입니다. 디스패치 편의 경고가 여기도 그대로 적용돼요.

실무 감각 — 어디에 쓰고 어디엔 안 쓰나

이 기능의 현재 위치를 정확히 잡아두는 게 중요합니다.

어울리는 자리는 자원의 유일 소유입니다. 파일·소켓 핸들 래퍼, 락 토큰, 트랜잭션 가드, 하드웨어 접근권 같은 것들.

실제로 이 기능의 주요 동력 중 하나가 임베디드 Swift였습니다. 힙도 ARC도 부담스러운 마이크로컨트롤러 환경에서, 클래스 없이 자원을 안전하게 관리할 도구가 필요했거든요.

표준 라이브러리의 Mutex가 넘겨주는 것들, Span 같은 최신 타입들이 이 계보 위에 있습니다.

일반 앱 코드의 기본값은 여전히 Copyable struct입니다. 값 편의 원칙은 그대로예요.

도메인 데이터에 ~Copyable을 선제 적용하는 건 과설계입니다. 제네릭 생태계와의 마찰도 아직 있고요(비복사 타입은 Copyable을 가정한 기존 제네릭·컬렉션과 바로 섞이지 않아서, 언어가 단계적으로 풀어가는 중입니다).

“복사되면 버그인가?“라는 질문에 그렇다고 답할 수 있는 타입에만 꺼내는 전문 도구로 두는 게 현재의 균형입니다.

Rust와의 비교로 마무리하면, Rust는 모든 값에 소유권 규칙을 강제하고 라이프타임 주석까지 요구하는 “소유권 기본” 언어입니다.

Swift는 복사 기본 세계를 유지하면서 필요한 타입만 소유권 세계로 옮기는 “선택적 소유권”을 택했어요.

Progressive Disclosure 철학의 전형입니다. 소유권을 모르는 개발자의 코드에는 이 개념이 아예 등장하지 않고, 필요한 사람에게만 계단이 열리는 구조요.

borrowing·consuming·inout 세 전달 방식을 창구에 비유한 다이어그램
읽기만(borrowing), 가져가기(consuming), 고쳐놓기(inout)

정리

  • 모든 타입은 암묵적으로 Copyable이고, 자원 타입에게 무단 복사는 이중 해제류 버그의 뿌리입니다.
  • ~Copyable(SE-0390)은 복사를 금지해 대입을 이동으로 바꾸고 소비된 변수 사용을 컴파일 에러로 만듭니다. struct에 deinit이 허용되어 결정적 정리도 됩니다.
  • 파라미터 소유권 어휘: borrowing(읽기만, 대여), consuming(가져가기, 양도), inout(고쳐놓기). consuming 메서드는 “쓰면 사라지는” 의미를 문법에 새깁니다.
  • 자리는 자원의 유일 소유(핸들·락·토큰·임베디드)이고, 일반 데이터의 기본값은 여전히 Copyable struct입니다. Rust의 전면 소유권과 달리 Swift는 선택적 소유권입니다.

다음 편은 심화 시리즈이자 Swift 시리즈 전체의 마지막 주제입니다. “Swift 5에서 앱 용량이 갑자기 줄었던” 사건의 전말, ABI(Application Binary Interface) 안정성입니다. SE-0390: Noncopyable Structs and Enums

출처 및 확인 기준

이어서 읽기