iOS 컬렉션 식별 검증: Hashable, 중복 제거와 필드 갱신

iOS 컬렉션 식별 검증: Hashable, 중복 제거와 필드 갱신

iOS 컬렉션 식별 검증: Hashable, 중복 제거와 필드 갱신

서버에서 같은 레코드를 다시 가져왔는데 이름만 바뀌었을 뿐 목록에는 행이 하나 더 생깁니다. 다른 곳에서는 이 레코드를 Dictionary의 키로 사용했더니 이전 상태를 찾을 수 없습니다. 이런 문제가 생겼다면 새로고침 로직부터 다시 작성하지 마세요. 모델을 클라우드 Mac으로 가져가 동등성과 컬렉션 동작을 따로 검증하면, 식별 기준이 어디서 어긋났는지 바로 찾을 수 있는 경우가 많습니다. 아래에서는 값 타입 모델만 테스트합니다. 시뮬레이터도, 실제 API 연결도 필요하지 않습니다.

같은 레코드로 판단할 기준부터 정하기

여러 테넌트의 멤버를 관리하는 앱을 가정해 보겠습니다. 멤버 ID는 해당 테넌트 안에서만 고유하고, 이름은 수정할 수 있습니다. 따라서 업무상 식별 기준은 “테넌트 ID + 멤버 ID”이며, 이름은 여기에 포함되지 않습니다. 프로토콜을 구현하기 전에 이 규칙을 테스트 설명에 먼저 명시하세요.

여기에는 서로 다른 두 가지 질문이 있습니다. 두 데이터가 같은 멤버를 나타내는지, 그리고 두 데이터의 모든 필드가 같은지입니다. 구조체의 Hashable 준수를 자동 합성에 맡기면 일반적으로 모든 저장 프로퍼티가 비교에 참여합니다. 그러면 이름이 바뀐 뒤에는 두 스냅샷이 더 이상 동등하지 않게 됩니다.

중복을 제거하겠다는 이유만으로 모든 모델의 동등성 의미를 함부로 바꾸지 마세요. 전체 스냅샷을 비교해야 한다면 불변 식별 키를 별도로 정의하고, 그 키를 전체 레코드에 매핑하는 딕셔너리를 사용하는 편이 더 안전합니다. 이 글에서는 계약을 명확히 보여 주기 위해 식별 기준에 따라 동등성을 판단하는 모델을 사용합니다.

동등한 값은 동일한 해시 규칙을 따라야 하지만, 해시가 같은 값이 반드시 동등한 것은 아닙니다. 해시는 검색 범위를 좁히는 수단이지 영구적인 레코드 ID가 아닙니다.

최소 스크립트로 동등성과 해시 규칙 고정하기

클라우드 Mac에서 Xcode 명령줄 도구를 사용할 수 있는지 확인하고 xcrun swift --version을 실행하세요. 출력 결과는 코드의 커밋 식별자와 함께 기록합니다. identity-contract.swift를 만들고 다음 내용을 입력하세요. 이 예제는 외부 의존성이 없으며 자격 증명도 읽지 않습니다.

struct Member: Hashable {
    let tenantID: String
    let memberID: String
    var displayName: String

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.tenantID == rhs.tenantID &&
        lhs.memberID == rhs.memberID
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(tenantID)
        hasher.combine(memberID)
    }
}

let original = Member(
    tenantID: "team-a",
    memberID: "member-7",
    displayName: "Old"
)
var renamed = original
renamed.displayName = "New"

let otherTenant = Member(
    tenantID: "team-b",
    memberID: "member-7",
    displayName: "Old"
)

precondition(original == renamed, "Rename changed identity")
precondition(original.hashValue == renamed.hashValue,
             "Equal values hash differently")
precondition(original != otherTenant, "Tenant boundary lost")

var members: Set<Member> = [original]
precondition(!members.insert(renamed).inserted,
             "Duplicate identity inserted")
precondition(members.count == 1, "Unexpected member count")
precondition(members.first?.displayName == "Old",
             "Insert unexpectedly replaced the member")

let previous = members.update(with: renamed)
precondition(previous?.displayName == "Old",
             "Previous member missing")
precondition(members.count == 1, "Update changed member count")
precondition(members.first?.displayName == "New",
             "Updated fields missing")

members.insert(otherTenant)
precondition(members.count == 2, "Distinct tenants merged")

let states: [Member: String] = [original: "selected"]
precondition(states[renamed] == "selected",
             "Lookup failed after rename")
precondition(states[otherTenant] == nil,
             "Lookup crossed tenant boundary")

동등성 비교와 hash(into:)는 동일한 식별 필드를 사용합니다. 식별 필드는 let으로 선언하고, 표시용 필드는 갱신할 수 있게 합니다. 특히 비교할 때는 이름을 무시하면서 해시에는 이름을 포함하는 미묘한 오류가 없는지 확인하세요. 이는 프로토콜의 요구 사항을 위반하며, 실행할 때마다 곧바로 드러난다고 기대할 수 없습니다.

중복 제거뿐 아니라 갱신된 내용까지 검증하기

스크립트가 있는 디렉터리에서 다음 명령을 실행합니다.

set -eu
xcrun swiftc -Onone identity-contract.swift -o identity-contract
./identity-contract

성공하면 스크립트는 아무 출력 없이 종료됩니다. 사전 조건 검사가 실패하면 0이 아닌 종료 상태를 반환합니다. -Onone을 유지하고, 안전성 검사를 비활성화하는 -Ounchecked는 사용하지 마세요. 기존 파이프라인에 연결할 때는 이 명령의 종료 상태로 테스트 통과 여부를 바로 판단하면 됩니다.

Set의 멤버 수가 하나라는 검증만으로는 부족합니다. insert는 동등한 멤버를 만나도 기존 내용을 교체하지 않으므로, 중복 제거에 성공했다고 해서 이름까지 갱신된 것은 아닙니다. 위 예제에서는 먼저 이전 이름이 남아 있는지 확인한 다음, update(with:)로 새 이름이 저장되었는지 검증합니다.

Dictionary 테스트는 다른 질문에 답합니다. 이름이 바뀐 키로도 원래 상태를 찾을 수 있는지 확인하는 것입니다. 최신 전체 레코드를 보관해야 한다면 딕셔너리의 값에 레코드를 저장하고 키에는 식별 정보만 담는 것이 좋습니다. 표시용 데이터를 키에 넣지 마세요.

경계 조건을 보완하고 특정 해시 숫자는 테스트하지 않기

식별 규칙과 입력 규칙 분리하기

문자열 식별자의 대소문자를 구분할지, 빈 값을 허용할지는 API 계약에서 정해야 합니다. 해시 함수 안에서 몰래 변환하지 마세요. 정규화가 필요하다면 모델을 만들기 전에 수행하고, 동등성 비교와 해시 계산 모두 동일한 정규화 결과를 사용해야 합니다.

프로젝트 테스트에 다음 검사를 추가하세요.

특정 hashValue가 고정된 정수와 같아야 한다고 검증하거나, 서로 다른 식별 정보의 해시는 절대로 충돌하지 않아야 한다고 요구하지 마세요. 프로세스별 무작위화와 허용되는 해시 충돌 때문에 이런 테스트는 의미가 없습니다. 스크립트를 다시 실행하면 불안정한 동작을 발견하는 데 도움이 될 수 있지만, 필드 규칙 검토를 대신할 수는 없습니다.

프로덕션 모델과 병합 검증에 계약 반영하기

독립 스크립트가 입증하는 것은 예제의 로직입니다. 실제 프로젝트에 적용할 때는 검증문을 테스트 타깃으로 옮기고 프로덕션 모델을 직접 가져오세요. 테스트 전용 복사본을 장기간 유지하면 안 됩니다. 실제 업무 코드에 오류가 생겨도 스크립트는 계속 통과할 수 있기 때문입니다.

현재 모델이 참조 타입이라면, 특히 객체가 컬렉션의 멤버로 들어 있는 동안 해시에 참여하는 필드를 수정해서는 안 됩니다. 객체 내부가 바뀌어도 컬렉션은 인덱스를 자동으로 재구성하지 않습니다. 불변 식별 키를 우선 사용하세요. 식별 정보를 꼭 바꿔야 한다면 기존 멤버를 먼저 제거한 다음 새 멤버를 생성해 삽입합니다.

마지막으로 영속화 데이터와 화면의 선택 상태를 점검하세요. 여기에는 해시 숫자나 컬렉션 순회 위치가 아니라 명시적인 업무 식별자를 저장해야 합니다. 검증 완료 기준은 구체적입니다. 이름을 바꿔도 중복 레코드가 생기지 않고, 갱신한 내용을 읽을 수 있으며, 서로 다른 테넌트의 멤버가 합쳐지지 않고, 조회와 삭제도 올바르게 동작해야 합니다.

자주 묻는 질문

서로 다른 기록의 hashValue가 같으면 구현 오류인가요?

아닙니다. 해시 충돌은 허용되며 컬렉션은 동등성 비교로 값을 구분합니다. 같은 값은 일관되게 해시되어야 하지만 서로 다른 식별자의 해시값이 반드시 달라야 하는 것은 아닙니다.

이름을 변경하면 Set의 기존 기록도 자동으로 바뀌나요?

아닙니다. Set 밖에 있는 복사본을 수정해도 저장된 원소는 바뀌지 않습니다. update(with:)로 동등한 원소를 교체한 뒤 원소 수와 새 필드가 저장되었는지 확인해야 합니다.

hashValue를 데이터베이스의 영구 키로 저장해도 되나요?

안 됩니다. Swift 해시는 무작위화되며 프로세스나 버전 간 안정성을 보장하지 않습니다. 서버 식별자 또는 UUID를 저장하고 테넌트별 식별자라면 테넌트 정보도 키에 포함하세요.

프로젝트를 위한 전용 Mac을 확보하세요

가상 머신이 아닌 Mac mini 물리 노드입니다. 일·주·월·분기 단위로 대여하고, 용도와 사양을 선택한 뒤 콘솔에서 실시간 제공 상태를 확인하세요.

모델 및 대여 기간 선택 전체 요금 보기