← iOS 개발자 CS 로드맵 23 / 35

23 · 프라이버시와 데이터 수집

관측 데이터로 PII가 새는 경로, PrivacyInfo.xcprivacy가 선언하는 것, Required Reason API와 핑거프린팅, ATT의 실제 효과, 이중 스크러빙과 최소 수집의 절충.
진행률
0 / 0 완료

18~22장에서 만든 관측 파이프라인은 사용자 기기에서 우리 서버로 데이터를 계속 보내는 장치다. 그 데이터에 무엇이 실리는지, 어디까지 선언해야 하는지, 무엇을 물어봐야 하는지를 모르면 기능적으로는 잘 돌면서 심사에서 반려되거나 법적 문제를 만드는 앱이 된다. P0 11장 Q6이 "로그와 크래시 리포트에 민감 정보를 남기지 말라"까지였다면, 이 챕터는 실제로 어디서 새는지, 플랫폼이 무엇을 강제하는지, 어디까지 줄여야 하는지를 다룬다.

📝 노트

이 챕터는 플랫폼 기술 규칙(Apple이 강제하는 것)을 다루며, 법률 자문이 아니다. GDPR·개인정보보호법 같은 법적 요구는 별도로 검토해야 하고, 플랫폼 규칙을 지켰다고 법적 의무가 충족되는 것은 아니다.

Q1. PII는 관측 데이터의 어디로 새는가?

🔑 30초 답변

의도적으로 넣는 곳이 아니라 자동으로 딸려 들어가는 곳에서 샌다. 주요 경로는 다섯이다. (1) URL — 쿼리스트링의 토큰·이메일·검색어가 breadcrumb과 span 이름에 통째로 들어간다. (2) 에러 메시지 — 서버 응답 본문이나 디코딩 실패 메시지에 사용자 데이터가 섞인다. (3) UI 텍스트 — 자동 수집이 버튼 타이틀·접근성 레이블을 이름으로 쓰면 "김철수님 프로필"이 그대로 나간다. (4) 스크린샷·뷰 계층 — 에러 시점 화면 캡처. (5) 로그print로 찍은 것이 크래시 리포트 부가 데이터에 실린다. 공통점은 개발자가 "민감 정보를 보낸다"고 생각한 적이 없다는 것이다.

CS 원리

유출의 근본 구조는 데이터의 출처와 목적지가 설계 시점에 분리되어 있다는 데 있다. URL을 만든 코드는 그 URL이 나중에 관측 이벤트에 실릴 것을 모르고, 관측 SDK는 그 URL에 무엇이 들었는지 모른다. 양쪽 다 자기 관심사만 보므로 아무도 검사하지 않는다.

이 구조는 P0 17장의 결합·응집 논의와 정확히 이어진다 — 두 모듈이 공유 데이터 구조를 통해 암묵적으로 결합되어 있고, 그 결합이 코드에 보이지 않는다.

유출 경로를 분류하면 셋이다.

유형메커니즘탐지 난이도
직접 유출개발자가 명시적으로 값을 넣음쉬움 — 코드 검색으로 찾힌다
전이 유출민감 값이 들어 있는 구조체를 통째로 넣음 (URL, 에러 객체)중간 — 값의 내용을 봐야 안다
자동 유출SDK가 알아서 수집 (UI 텍스트, 스크린샷, 네트워크 URL)어려움 — 우리 코드에 흔적이 없다

실무에서 사고를 만드는 것은 대부분 두 번째와 세 번째다. 첫 번째는 코드 리뷰에서 걸리지만, 나머지는 실제로 전송된 데이터를 봐야 발견된다.

앱 안의 데이터 관측 백엔드 /search?q=강남역 성형외과&token=eyJ… "주문 ORD-99213 결제 실패: 4242…" 버튼 타이틀 "김철수님 프로필" 에러 시점 화면 캡처 print("user: \(user)") 자동 수집 breadcrumb span 이름 · 태그 스크린샷 · 뷰 계층 클라이언트 스크러빙 서버 스크러빙 저장 개발자가 "보낸다"고 생각한 적 없는 경로로 대부분이 샌다 그래서 코드 리뷰만으로는 못 잡고, 실제 전송 데이터를 봐야 한다
유출은 대개 자동 수집 경로에서 일어난다. 우리 코드에 send(email) 같은 흔적이 없으므로 코드 검색으로는 찾히지 않는다.

iOS에서는

iOS에서 각 경로가 구체적으로 어떻게 열리는지 보자.

(1) URL. 자동 네트워크 계측은 요청 URL을 breadcrumb과 span에 기록한다. 쿼리스트링에 액세스 토큰, 이메일, 검색어가 있으면 그대로 나간다. 검색어는 특히 민감한데, 검색 내용 자체가 건강·성향·위치 정보일 수 있기 때문이다.

(2) 에러 메시지. 서버가 {"error": "카드 4242-****-****-1234 승인 거절"}처럼 응답하면, 그 문자열이 에러 객체에 담겨 이벤트 제목이 된다. DecodingError도 위험한데, 디코딩 실패 시 문제가 된 값을 메시지에 포함하는 경우가 있다.

(3) UI 텍스트. 18장 Q4 꼬리질문에서 다룬 문제다. 자동 인터랙션 수집이 버튼 타이틀이나 접근성 레이블을 쓰면 사용자 데이터가 이름이 된다.

(4) 스크린샷·뷰 계층. 에러 시점 화면을 자동 캡처하는 기능이 있는 SDK가 많다. 진단에는 매우 유용하지만 화면에 보이는 모든 것이 전송된다. 마스킹이 기본값인 구현이 대부분이지만, 커스텀 뷰는 마스킹 대상으로 인식되지 않을 수 있다.

(5) 로그. print는 시스템 로그에 남지 않지만(18장 Q3), 일부 SDK는 앱의 콘솔 출력을 캡처해 이벤트에 첨부한다. 그리고 Loggerprivacy: .public을 남발하면 리댁션이 무력화된다.

여기서 Apple이 준 기본 안전장치를 다시 짚을 가치가 있다 — 통합 로깅은 동적 문자열과 복합 객체를 기본적으로 리댁션한다. 정수·실수·불리언은 리댁션하지 않는다. 즉 기본값이 안전한 쪽이고, 여는 것은 명시적 행위다. 우리 관측 파이프라인도 같은 원칙(기본 차단, 명시적 허용)으로 설계해야 한다.

실험 · 도구

가장 확실한 검증은 실제로 나가는 데이터를 눈으로 보는 것이다.

import Foundation

/// 디버그 빌드에서 나가는 모든 이벤트를 검사한다 — PII 패턴이 있으면 즉시 중단
enum PIIGuard {
    // 실무에서 가장 자주 새는 패턴들
    private static let patterns: [(name: String, regex: NSRegularExpression)] = {
        let defs: [(String, String)] = [
            ("이메일",      #"[\w.+-]+@[\w-]+\.[\w.]+"#),
            ("전화번호",     #"01[016789][-.\s]?\d{3,4}[-.\s]?\d{4}"#),
            ("주민번호",     #"\d{6}[-\s]?[1-4]\d{6}"#),
            ("카드번호",     #"\b(?:\d[ -]?){13,19}\b"#),
            ("Bearer 토큰", #"(?i)bearer\s+[A-Za-z0-9._~+/-]{20,}"#),
            ("JWT",        #"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."#),
        ]
        return defs.compactMap { name, p in
            (try? NSRegularExpression(pattern: p)).map { (name, $0) }
        }
    }()

    /// 전송 직전 훅에서 호출한다
    static func inspect(_ payload: String, origin: String) {
        #if DEBUG
        for (name, re) in patterns {
            let range = NSRange(payload.startIndex..., in: payload)
            if let m = re.firstMatch(in: payload, range: range) {
                let hit = (payload as NSString).substring(with: m.range)
                fatalError("🚨 \(origin) 에 \(name) 유출: \(hit.prefix(12))…")
            }
        }
        #endif
    }
}

// 전송 훅에 연결 — 이벤트가 나가기 전에 전부 검사한다
Observability.beforeSend = { event in
    PIIGuard.inspect(event.jsonString, origin: "event.\(event.type)")
    return event
}

URL 정규화는 별도로 강제하는 편이 안전하다.

extension URL {
    /// 관측용 안전 표현 — 쿼리 제거 + 경로의 식별자 치환
    var observabilitySafe: String {
        guard var c = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return "invalid" }
        c.query = nil            // 쿼리스트링은 통째로 버린다 (토큰·검색어의 주 서식지)
        c.fragment = nil
        c.user = nil
        c.password = nil

        // 경로의 가변 세그먼트를 템플릿으로 — 18장 Q6의 그룹핑 문제도 함께 해결된다
        let normalized = c.path.split(separator: "/").map { seg -> String in
            if seg.allSatisfy(\.isNumber) { return "{id}" }
            if seg.count >= 20, seg.allSatisfy({ $0.isHexDigit || $0 == "-" }) { return "{uuid}" }
            if seg.contains("@") { return "{email}" }
            return String(seg)
        }.joined(separator: "/")

        return "\(c.host ?? "?")/\(normalized)"
    }
}

// "https://api.shop.com/users/12345/orders?token=eyJhbG..."
//   → "api.shop.com/users/{id}/orders"

실제 전송 내용을 직접 확인하는 절차도 정기적으로 돌려야 한다.

# 1) 프록시로 실제 전송 페이로드를 캡처한다 (Charles / Proxyman / mitmproxy)
#    관측 백엔드로 나가는 요청만 필터링해 본문을 눈으로 확인
mitmproxy --set flow_detail=3 -w capture.mitm

# 2) 캡처에서 PII 패턴을 검색
mitmdump -nr capture.mitm -s dump_bodies.py \
  | grep -Ei '[a-z0-9._%+-]+@[a-z0-9.-]+|bearer |eyJ[A-Za-z0-9_-]{10,}'

# 3) 정기 점검 — 릴리스 전 체크리스트에 넣는다
#    주요 플로우(로그인·검색·결제)를 한 번씩 실행하고 캡처를 검사

프로젝트 적용

실무에서 세울 방어선 다섯.

⚠️ 흔한 오해

"우리는 개인정보를 이벤트에 안 넣는다"는 대부분 직접 유출만 생각한 것이다. URL·에러 메시지·UI 텍스트를 통한 전이·자동 유출이 훨씬 흔하다. "HTTPS로 보내니 안전하다"도 논점이 다르다 — 전송 구간 암호화는 도청을 막을 뿐, 수신 측에 저장되는 것은 그대로다(P0 11장 Q4). "SDK가 알아서 마스킹한다"도 위험하다 — 마스킹 규칙은 표준 UI 컴포넌트를 전제하는 경우가 많아 커스텀 뷰는 놓칠 수 있다. 또 "검색어는 개인정보가 아니다"도 잘못된 안심이다 — 검색 내용 자체가 건강·성향·위치를 드러낸다. 마지막으로 "테스트 계정으로 확인했으니 됐다"도 부족하다 — 테스트 데이터에는 실제 사용자 이름이 안 들어 있어 유출 경로가 있어도 눈에 안 띈다.

🧒 쉽게 이해하기

병원에서 진료 기록을 연구용으로 보낸다고 하자. 이름과 주민번호는 당연히 지운다. 그런데 실제로는 이런 데서 샌다.

  • 접수 번호에 생년월일이 들어 있다 — "19850312-003". 지운 줄 알았는데 번호에 남아 있었다.
  • 메모란에 "김OO 씨 보호자 연락처 010-…"이 적혀 있다. 메모는 자유 형식이라 뭐든 들어간다.
  • 진료실 사진을 같이 보냈는데 모니터에 다른 환자 이름이 찍혀 있다.
  • 버튼 이름이 "김철수 님 차트 열기"였다. 클릭 기록만 보냈는데 이름이 딸려 갔다.

공통점이 있다. 아무도 "이름을 보내자"고 결정한 적이 없다. 다른 걸 보내는데 거기에 이름이 묻어 있었다.

그래서 "우리는 이름을 안 보낸다"고 말하는 것으로는 부족하다. 실제로 나가는 봉투를 열어봐야 한다. 그리고 그 확인을 가끔이 아니라 정기적으로 해야 한다. 새 서식이 추가될 때마다 새 구멍이 생기기 때문이다.

꼬리 질문

검색어를 관측 데이터에 남기고 싶을 때 어떻게 절충하는가?

검색 기능의 품질을 개선하려면 무엇을 검색했는지가 필요한데, 검색어는 가장 민감한 데이터 중 하나다. 절충 방법이 몇 가지 있다.

(1) 메타데이터만 남긴다. 검색어 자체 대신 길이, 단어 수, 결과 수, 결과 클릭 여부, 소요 시간을 남긴다. "검색 결과가 0건인 검색이 늘었다" 같은 대부분의 성능·품질 질문은 이것만으로 답할 수 있다.

(2) 카테고리로 치환한다. 검색어를 그대로 두지 말고 분류 결과만 남긴다 — "장소", "상품명", "브랜드". 분류는 클라이언트에서 하고 원문은 보내지 않는다.

(3) 허용 목록 방식. 미리 정의된 인기 검색어 집합에 속하면 그대로 남기고, 아니면 {other}로 치환한다. 롱테일 검색어가 대개 개인적이라는 관찰에 기반한 방법이다.

(4) 별도 동의와 별도 파이프라인. 검색 품질 개선을 위해 원문이 정말 필요하다면, 관측 파이프라인과 분리해 명시적 동의를 받고 보관 기간·접근 통제를 별도로 건다. 관측 데이터는 접근 권한이 넓은 편이라 여기에 섞으면 통제가 어렵다.

실무에서는 (1)이 대부분을 커버하고, 정말 필요할 때만 (4)로 간다. "성능 조사에 원문이 필요한 경우는 거의 없다"는 것이 핵심이다.

쉽게 말하면 도서관에서 "무슨 책을 빌렸나"를 다 기록하면 그 사람의 관심사가 다 드러난다. 대신 "몇 권 빌렸나, 몇 분 걸렸나, 원하는 책이 있었나"만 적어도 도서관 운영 개선에는 충분하다. 정말 "어떤 분야가 인기인지" 알아야 하면 분야 이름만 적으면 된다.
스크린샷·세션 리플레이 기능의 마스킹을 어디까지 믿을 수 있는가?

기본 마스킹은 표준 컴포넌트에 대해서만 신뢰할 수 있다. 대개 UILabel, UITextField, UIImageView 같은 알려진 타입을 인식해 가린다. 문제는 이런 경우다.

  • 커스텀 뷰. UIView를 상속해 draw(_:)에서 직접 텍스트를 그리면 SDK가 "텍스트가 있다"는 것을 알 수 없다.
  • SwiftUI. 뷰 계층 구조가 UIKit과 달라 인식 규칙이 다르게 동작할 수 있다.
  • 웹뷰. 웹 콘텐츠는 네이티브 뷰 계층 밖이라 별도 처리가 필요하다.
  • 이미지 안의 텍스트. 서버에서 받은 이미지에 사용자 정보가 렌더링돼 있으면 마스킹 대상으로 보이지 않는다.
  • 일시적 표시. 토스트나 알림처럼 잠깐 뜨는 요소.

그래서 실무 원칙은 "허용 목록 방식"이다. 기본을 "모두 마스킹"으로 두고 안전하다고 확인한 뷰만 명시적으로 노출한다. 대부분의 SDK가 이런 모드를 제공한다. 반대(기본 노출 + 민감한 것만 마스킹)로 두면 새 화면을 만들 때마다 빠뜨린다.

그리고 실제 캡처를 눈으로 확인하는 절차가 필수다. 주요 화면마다 캡처를 떠서 가려졌는지 본다. 이건 자동화가 어려워 사람이 해야 하는 몇 안 되는 검증이다.

더 근본적인 질문도 던질 만하다 — 이 기능이 정말 필요한가? 스크린샷 없이도 breadcrumb과 뷰 계층 이름만으로 대부분의 UI 버그는 재현된다. 위험 대비 이득을 따져보면 끄는 것이 답인 경우가 많다.

쉽게 말하면 사진을 찍으면서 "얼굴은 자동으로 흐리게 처리됩니다"라고 한다. 정면 얼굴은 잘 가려진다. 그런데 옆모습, 거울에 비친 얼굴, 사진 속 사진, 이름표는? 자동 처리가 못 알아본다. 그래서 "기본은 전부 가리고, 확인한 것만 보이게" 하는 게 안전하고, 무엇보다 정말 사진이 필요한지부터 물어야 한다.
서버가 내려주는 에러 메시지를 그대로 쓰면 안 되는 이유가 프라이버시 말고 또 있는가?

세 가지가 더 있다.

(1) 그룹핑 파괴. 18장 Q6에서 본 대로, 서버 메시지에 주문 번호나 사용자 정보가 섞이면 같은 버그가 수백 개 이슈로 쪼개진다. 프라이버시와 그룹핑이 같은 해법(가변값 분리)을 요구한다.

(2) 사용자 노출 위험. 서버 메시지를 그대로 UI에 표시하는 코드가 흔한데, 그 메시지는 개발자를 위한 것이라 내부 구조나 스택 정보가 담길 수 있다. 사용자에게 NullPointerException at OrderService.java:142가 보이는 사고가 여기서 나온다.

(3) 국제화 불가. 서버 메시지가 한 언어로 고정되면 다국어 앱에서 그 부분만 언어가 다르다.

올바른 구조는 서버가 코드를, 클라이언트가 메시지를 담당하는 것이다. 서버는 {"code": "PAYMENT_DECLINED", "detail": {...}}를 주고, 클라이언트가 코드에 맞는 현지화 메시지를 보여준다. 관측 이벤트에는 코드만 실으면 그룹핑도 프라이버시도 해결된다.

서버의 detail이 진단에 필요하면 이벤트 컨텍스트에 넣되(18장 Q5), 거기서도 민감 필드는 걸러야 한다.

쉽게 말하면 은행에서 거절 통보를 받았다. 통보서에 "김철수 님의 신한 1234 계좌 잔액 부족"이라고 적혀 있으면, 이걸 그대로 어디에 붙이든 문제가 된다. 대신 "사유 코드: 잔액부족"만 적혀 있으면, 같은 사유끼리 묶기도 쉽고 남에게 보여도 안전하고 다른 나라 말로 바꾸기도 쉽다.

Q2. Privacy Manifest는 무엇을 선언하며 빠뜨리면 어떻게 되는가?

🔑 30초 답변

PrivacyInfo.xcprivacy수집하는 데이터 타입사용하는 Required Reason API를 선언하는 property list다. 최상위 키가 넷이다 — NSPrivacyTracking(추적 여부), NSPrivacyTrackingDomains(추적 도메인 목록), NSPrivacyCollectedDataTypes(수집 데이터), NSPrivacyAccessedAPITypes(사유 필수 API). 빠뜨리면 두 가지가 일어난다. (1) 업로드 거부 — Apple은 2024년 5월 1일부터 Required Reason API 사유를 선언하지 않은 앱을 App Store Connect가 받지 않는다고 명시했다. (2) 런타임 실패NSPrivacyTrackingDomains에 나열된 도메인은 ATT 허가가 없으면 네트워크 요청이 실패하고 앱이 에러를 받는다. 즉 선언 누락이 심사 문제이자 기능 장애가 된다.

CS 원리

Privacy Manifest는 선언적 계약(declarative contract) 방식이다. 플랫폼이 코드를 정적 분석해 "이 앱이 무엇을 하는지" 알아내려 하는 대신, 개발자가 선언하게 하고 그 선언과 실제 동작이 어긋나면 제재한다.

이 방식이 선택된 이유는 정적 분석의 한계에 있다. 어떤 API를 호출했는지는 바이너리에서 찾을 수 있지만, 그 결과를 무엇에 쓰는지는 알 수 없다. 같은 API가 정당한 용도와 핑거프린팅에 모두 쓰일 수 있으므로, 의도를 물어보는 수밖에 없다.

그래서 구조가 이렇게 된다.

중요한 설계 원칙이 하나 더 있다 — 선언은 바이너리 단위다. Apple 문서가 명시한다: required reason API를 쓰는 각 실행 파일이나 동적 라이브러리마다, 그것을 포함하는 번들이 해당 API를 보고하는 privacy manifest를 포함해야 한다. 그리고 서드파티 SDK는 자기를 링크하는 앱의 manifest나 다른 SDK의 manifest에 의존할 수 없다.

이건 책임의 국소화다. 각 바이너리가 자기 행위를 스스로 선언하므로, 앱 개발자가 SDK 내부를 알 필요가 없다.

선언은 바이너리 단위 — 앱 하나에 manifest 하나가 아니다 MyApp.app MyApp + PrivacyInfo.xcprivacy PlugIns/Share.appex + 자기 manifest Frameworks/SomeSDK + 벤더 manifest SDK 는 앱의 manifest 에 의존할 수 없다 집계 프라이버시 리포트 Archive → Generate → App Store Connect 항목 최상위 키 4개 — 하나는 서류가 아니라 런타임 차단이다 NSPrivacyCollectedDataTypes 수집 데이터 · 목적 NSPrivacyAccessedAPITypes 사유 필수 API (Q3) NSPrivacyTracking 추적 여부 (Bool) NSPrivacyTrackingDomains ← 여기가 다르다 ATT 허가 없으면 → 이 도메인 요청이 실패한다 앞의 셋은 심사·리포트용 선언이지만, 이것은 시스템이 실제로 네트워크를 막는다.
manifest는 바이너리마다 필요하고, NSPrivacyTrackingDomains는 선언이 아니라 실행되는 차단 목록이다.

iOS에서는

최상위 키 넷의 의미를 정확히 정리하면 이렇다.

타입의미
NSPrivacyTrackingBooleanATT 정의상 추적에 데이터를 사용하는가. trueNSPrivacyTrackingDomains를 제공해야 한다
NSPrivacyTrackingDomainsString 배열추적에 관여하는 연결 도메인 목록. ATT 허가가 없으면 이 도메인으로의 요청이 실패한다
NSPrivacyCollectedDataTypesDictionary 배열수집하는 데이터 타입과 연결 여부·추적 여부·수집 목적
NSPrivacyAccessedAPITypesDictionary 배열Required Reason API 카테고리와 승인된 사유 코드

NSPrivacyTrackingDomains의 런타임 효과가 실무에서 가장 놀라운 부분이다. 이건 단순한 선언이 아니라 시스템이 강제하는 차단 목록이다. ATT 허가를 못 받은 상태에서 그 도메인에 요청하면 실패하고 에러가 돌아온다. 즉 선언을 잘못하면 정상 사용자에게 기능이 안 되는 상황이 만들어진다.

수집 데이터 선언에는 필드가 넷 들어간다.

그리고 서드파티 SDK가 수집하는 데이터는 우리 앱 manifest가 다루지 않아도 된다 — SDK가 자기 manifest를 제공해야 한다. Apple 문서가 명시하는 분업이다.

실험 · 도구

manifest 파일의 실제 모양은 이렇다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- 추적하지 않는 앱이라면 false. true면 TrackingDomains 필수 -->
  <key>NSPrivacyTracking</key>
  <false/>

  <key>NSPrivacyTrackingDomains</key>
  <array/>

  <!-- 수집 데이터 선언 -->
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeCrashData</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypePerformanceData</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>

  <!-- Required Reason API 사유 선언 (Q3에서 자세히) -->
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Xcode에서 만들고 검증하는 절차는 이렇다.

# 생성: File › New File › Resource 섹션 › App Privacy File
#   기본 이름이 PrivacyInfo.xcprivacy 이고, 이 이름이 필수다.
#   ⚠️ 타깃의 리소스에 반드시 추가해야 Xcode가 프라이버시 리포트 생성 시 인식한다.

# 프라이버시 리포트 생성 — 앱과 링크된 모든 SDK의 manifest를 집계한다
#   Product › Archive
#   Organizer 에서 아카이브를 Control-click › Generate Privacy Report
#   → PDF로 저장되며, App Store Connect의 개인정보 항목 작성에 그대로 쓴다

# 정적 라이브러리로 SDK를 배포한다면
#   Xcode 15+ 의 static framework 지원으로 리소스를 번들해야 한다.
#   프레임워크 타깃을 만들고 Mach-O Type = Static Library 로 설정한 뒤
#   PrivacyInfo.xcprivacy 를 번들 리소스에 추가한다.

# 앱 번들 안 manifest 전수 확인 (앱 + 익스텐션 + 프레임워크)
find "YourApp.app" -name 'PrivacyInfo.xcprivacy' | while read -r f; do
  echo "── $f"
  plutil -p "$f" | head -20
done

가장 유용한 도구는 프라이버시 리포트다. 앱과 링크된 모든 SDK의 manifest를 집계해 주므로, "우리가 실제로 무엇을 수집하는지"를 한 장으로 볼 수 있다. 그리고 그 형식이 App Store의 개인정보 영양성분표와 비슷하게 구성돼 있어 그대로 옮겨 적을 수 있다.

프로젝트 적용

manifest 관리에서 정할 것 다섯.

⚠️ 흔한 오해

"앱 manifest 하나만 있으면 된다"는 틀렸다 — required reason API를 쓰는 각 실행 파일·동적 라이브러리마다 그것을 담은 번들에 manifest가 필요하다. "서드파티 SDK가 수집하는 것도 우리가 선언해야 한다"도 틀렸다 — SDK가 자기 manifest를 제공해야 하고, 우리 manifest는 그것을 다루지 않아도 된다. "manifest는 심사용 서류일 뿐"이 가장 위험한 오해다 — NSPrivacyTrackingDomains런타임에 네트워크를 차단한다. "데이터 타입을 우리가 정의해도 된다"도 안 된다 — 임의 값을 쓰면 Xcode가 프라이버시 리포트를 제대로 생성하지 못한다. 또 "만들어서 프로젝트에 넣으면 끝"도 아니다 — 타깃의 리소스에 추가해야 인식된다.

🧒 쉽게 이해하기

음식점에 성분표를 붙이는 규칙이 생겼다고 하자. 손님이 뭘 먹는지 알 수 있게 하려는 것이다.

규칙이 이렇다.

  • 가게마다 자기 성분표를 붙인다. 우리 가게 음식은 우리가 적는다.
  • 납품 업체도 자기 성분표를 준다. 우리가 소스를 사다 쓰면, 그 소스 성분은 소스 회사가 적어서 준다. 우리가 소스를 뜯어보고 추측할 필요가 없다.
  • 성분 이름은 정해진 목록에서만 고른다. 마음대로 "특제 양념"이라고 쓰면 안 된다.
  • 특수 재료를 왜 쓰는지 안 적으면 납품이 거절된다. 2024년 5월 1일부터 그렇다. (성분표 전체가 없어서가 아니라 그 칸을 안 채워서 거절되는 것이다.)

여기서 특이한 규칙이 하나 더 있다. "추적용 재료를 쓰는 거래처"를 따로 신고해야 하는데, 신고한 거래처와는 손님 동의 없이는 거래 자체가 막힌다. 서류만 내는 게 아니라 실제로 문이 잠긴다. 그래서 이 목록은 아주 신중하게 적어야 한다. 잘못 적으면 멀쩡한 거래가 끊긴다.

마지막으로 — 성분표는 종이다. 레시피를 바꿔도 종이가 저절로 바뀌지 않는다. 새 재료를 쓰기 시작하면 사람이 종이를 고쳐야 한다.

꼬리 질문

NSPrivacyTrackingDomains에 도메인을 넣었는데 ATT 허가를 못 받으면 정확히 무슨 일이 일어나는가?

Apple 문서 표현대로 해당 도메인으로의 네트워크 요청이 실패하고 앱이 에러를 받는다. 시스템 수준에서 차단되므로 우리 코드가 아무리 재시도해도 안 된다.

실무적 함의가 크다.

(1) 기능이 조용히 망가진다. 광고 SDK나 분석 SDK가 그 도메인을 쓰는데 허가를 못 받으면, 해당 기능이 동작하지 않는다. 사용자 대부분이 ATT를 거부한다는 점을 고려하면 기본 상태가 "안 됨"이 된다.

(2) 에러 처리가 필요하다. 그 요청 실패가 앱의 다른 흐름을 막지 않도록 해야 한다. 초기화 단계에서 그 도메인에 동기적으로 접속하는 SDK가 있다면 앱 시작이 지연되거나 실패할 수 있다(20장 Q5).

(3) 도메인 분리가 중요해진다. 추적용 도메인과 앱 기능용 도메인이 같으면 기능까지 차단된다. 그래서 서비스 설계 단계에서 추적 관련 트래픽을 별도 도메인으로 분리해두는 것이 안전하다.

테스트 방법은 설정에서 앱의 추적 권한을 거부한 뒤 주요 플로우를 돌려보는 것이다. 개발 중에는 대개 허용 상태로 테스트하므로 이 경로가 검증되지 않은 채 배포되기 쉽다.

쉽게 말하면 "이 거래처는 추적용"이라고 신고해두면, 손님이 동의하지 않는 한 그 거래처와의 전화선 자체가 끊긴다. 서류상 표시만 되는 게 아니라 진짜로 안 걸린다. 그런데 하필 그 거래처가 주방 재료도 같이 납품하고 있었다면, 요리도 못 하게 된다. 그래서 거래처를 나눠두는 게 안전하다.
SDK가 privacy manifest를 제공하지 않으면 어떻게 대응하는가?

먼저 필요한 경우인지 확인한다. Apple 기준으로 manifest가 필요한 SDK는 (a) Apple이 지정한 "privacy manifest와 서명이 필요한 SDK" 목록에 있거나, (b) required reason API를 쓰거나, (c) 사용자 데이터를 수집하거나, (d) 앱이 데이터를 수집하게 하거나, (e) 추적 도메인에 접속하는 경우다. 어디에도 해당하지 않으면 없어도 된다.

필요한데 없다면 대응 순서는 이렇다.

(1) 최신 버전 확인. 대부분의 주요 SDK가 이미 추가했다. 버전이 오래됐을 뿐일 수 있다.

(2) 벤더에 요청. 이건 벤더의 의무다. 우리가 대신 선언할 수 없다는 점을 명확히 한다 — Apple 문서가 SDK는 앱의 manifest에 의존할 수 없다고 못 박고 있다.

(3) 대체재 검토. 응답이 없거나 유지보수가 멈춘 SDK라면 교체를 검토한다. manifest를 제공하지 않는다는 것은 Apple 정책 변화를 따라오지 않고 있다는 신호이므로, 다른 문제(서명, 새 SDK 요구사항)도 곧 생길 가능성이 높다.

(4) 제거. 대체 불가능하고 필수도 아니면 빼는 것이 답이다.

그리고 SDK 도입 기준에 이 항목을 넣어두면 이 상황을 예방할 수 있다. 21장 Q3에서 dSYM 제공 여부를 도입 기준에 넣으라고 한 것과 같은 발상이다 — 운영 가능성을 도입 시점에 평가하는 것이다.

쉽게 말하면 납품 업체가 성분표를 안 준다. 우리가 대신 써주면 될까? 안 된다 — 우리는 그 소스에 뭐가 들었는지 모른다. 요청해도 안 주면 다른 업체를 찾는 수밖에 없다. 그리고 성분표조차 안 주는 업체라면, 앞으로 다른 규정도 안 지킬 가능성이 높다.
프라이버시 리포트를 릴리스마다 비교하면 무엇을 잡을 수 있는가?

세 가지를 잡을 수 있다.

(1) SDK 업데이트로 인한 수집 항목 증가. 라이브러리를 올렸더니 새 데이터 타입이 추가되거나 Trackingtrue로 바뀌는 경우가 있다. 리포트를 비교하지 않으면 우리가 모르는 사이 수집 범위가 넓어진다. App Store의 개인정보 항목과 실제가 어긋나면 정책 문제가 된다.

(2) 새 추적 도메인 등장. NSPrivacyTrackingDomains에 도메인이 추가되면 앞서 본 네트워크 차단 효과가 생긴다. 배포 전에 알아야 한다.

(3) 우리 코드의 API 사용 변화. 새로 쓰기 시작한 required reason API가 manifest에 반영됐는지 확인할 수 있다.

자동화는 리포트가 PDF라 다소 번거롭지만, manifest 파일들 자체를 diff하는 것으로 대부분 대체된다.

# 아카이브 안 모든 manifest를 정규화해 하나로 모은다
find "$ARCHIVE" -name 'PrivacyInfo.xcprivacy' -print0 \
  | xargs -0 -I{} sh -c 'echo "== {}"; plutil -convert json -o - "{}"' \
  > privacy-snapshot.json

# 이전 릴리스와 비교 — 변화가 있으면 검토 대상
diff previous-privacy-snapshot.json privacy-snapshot.json || {
  echo "⚠️ 프라이버시 선언이 변경되었습니다. 검토 후 App Store Connect 항목을 갱신하세요."
}

이걸 CI에 넣고 변화가 있으면 사람의 승인을 요구하도록 하면, 조용한 수집 범위 확대를 막을 수 있다.

쉽게 말하면 납품 업체가 레시피를 몰래 바꿔서 새 재료를 넣었을 수 있다. 성분표를 매번 지난번 것과 비교하면 "어? 이 재료 원래 없었는데"를 잡을 수 있다. 손님에게 붙여둔 안내문과 달라지면 문제가 되니까.

Q3. Required Reason API는 왜 생겼는가?

🔑 30초 답변

핑거프린팅을 막기 위해서다. 파일 타임스탬프, 시스템 부팅 시각, 디스크 여유 공간, 활성 키보드, UserDefaults 같은 API는 각각은 무해하지만 여러 개를 조합하면 기기를 고유하게 식별할 수 있다. Apple이 명확히 한 것은 이것이다 — 사용자가 추적 허가를 줬든 안 줬든 핑거프린팅은 허용되지 않는다. 즉 ATT와 별개의 금지 사항이다. 그래서 이 API들을 쓰려면 Apple이 정한 승인된 사유 코드를 manifest에 선언해야 하고, 선언한 사유로만 그 API와 파생 데이터를 쓸 수 있으며, 추적에는 쓸 수 없다. 2024년 5월 1일부터 미선언 앱은 App Store Connect가 받지 않는다.

CS 원리

핑거프린팅의 원리는 엔트로피 누적이다. 개별 신호가 주는 정보량은 작지만, 독립적인 신호를 여러 개 모으면 정보량이 더해져서 개인을 특정할 수 있게 된다.

정보 이론으로 표현하면, 신호 하나가 k가지 값을 가질 때 그 신호의 엔트로피는 최대 log₂k 비트다. 독립 신호를 여러 개 합치면 엔트로피가 합산된다.

신호대략적 구분력단독으로는
기기 모델수십 가지~6비트 — 무해
OS 버전수십 가지~5비트 — 무해
화면 크기·언어·시간대수백 조합~8비트 — 무해
설치된 폰트·키보드 목록매우 다양~10비트+
디스크 여유 공간 (바이트)사실상 연속값매우 큼 — 거의 고유
시스템 부팅 시각 (초)사실상 연속값매우 큼 — 거의 고유
파일 생성 타임스탬프사실상 연속값매우 큼 — 거의 고유

아래 세 개가 위험한 이유는 값의 범위가 사실상 연속이라 그 자체로 엔트로피가 크기 때문이다. 예를 들어 시스템 부팅 시각은 초 단위로 기록되는데, 같은 초에 부팅한 다른 기기를 만날 확률이 매우 낮다. 여기에 디스크 여유 공간까지 더하면 사실상 고유한 식별자가 된다.

그리고 이 신호들은 앱을 지우고 다시 깔아도 유지된다는 성질이 있다. 광고 식별자는 사용자가 초기화할 수 있지만, 부팅 시각은 초기화할 수 없다. 사용자가 통제할 수 없는 식별자라는 점이 핵심 문제다.

각각은 "이 사람일 수도 있는 후보"를 좁힐 뿐이다 기기 모델 후보 1000만 명 + OS 버전 · 언어 후보 30만 명 + 키보드 · 폰트 후보 300명 + 부팅 시각 · 디스크 여유 후보 1명 — 특정 완료 마지막 두 신호가 결정적이다 — 값의 범위가 사실상 연속이라 그것 하나만으로도 거의 고유하다 그리고 이 값들은 앱을 지우고 다시 깔아도 유지되고, 광고 식별자와 달리 사용자가 초기화할 수 없다. → 그래서 허가 여부와 무관하게 금지된다
핑거프린팅은 "허가받지 않은 추적"이 아니라 사용자가 통제할 수 없는 추적이라는 점에서 별도로 금지된다.

iOS에서는

Apple이 요구하는 선언 구조는 단순하다. NSPrivacyAccessedAPITypes 배열의 각 항목에 두 키를 넣는다.

사유 코드는 CA92.1, C617.1, 35F9.1 같은 형태로, 카테고리마다 허용된 값이 다르다. 임의로 만들 수 없고, 목록에 없는 정당한 용도가 있으면 Apple에 새 사유를 요청하는 절차가 따로 있다.

Apple이 명시한 제약이 셋이다.

  1. 선언한 사유가 실제 사용을 정확히 반영해야 한다.
  2. 선언한 사유로만 그 API와 파생 데이터를 쓸 수 있다.
  3. 선언한 사유가 사용자에게 제시된 앱 기능과 일관해야 하고, API나 파생 데이터를 추적에 쓸 수 없다.

2번이 특히 중요하다. "파일 타임스탬프를 백업 기능을 위해 읽는다"고 선언했다면, 그 타임스탬프를 사용자 식별에 재사용할 수 없다. API 호출 자체가 아니라 데이터의 용도까지 구속한다.

강제 시점은 두 단계다 — 업로드 시 미선언이면 Apple이 이메일로 알린다. 그리고 2024년 5월 1일부터는 아예 받지 않는다.

실험 · 도구

어떤 API를 쓰고 있는지 찾는 것이 첫 단계다.

# 1) 소스에서 required reason API 계열 호출을 찾는다
grep -rn --include='*.swift' --include='*.m' --include='*.h' \
  -e 'UserDefaults' \
  -e 'systemUptime' -e 'mach_absolute_time' -e 'kern.boottime' \
  -e 'volumeAvailableCapacity' -e 'systemFreeSize' -e 'statfs' \
  -e 'creationDate' -e 'modificationDate' -e 'contentModificationDate' \
  -e 'activeInputModes' \
  Sources/

# 2) 바이너리에서 심볼을 확인한다 — 서드파티 SDK 포함
#    소스에 없어도 링크된 라이브러리가 쓰고 있을 수 있다
nm -u "YourApp.app/YourApp" | grep -E 'statfs|NSUserDefaults|mach_absolute'
#   ⚠️ kern.boottime 은 링크 심볼이 아니라 sysctl 문자열이라 심볼 테이블에 안 나온다.
#      문자열 상수로 찾아야 한다:
strings -a "YourApp.app/YourApp" | grep -x 'kern.boottime'

# 3) 링크된 프레임워크 각각도 확인
for f in YourApp.app/Frameworks/*.framework; do
  bin="$f/$(basename "$f" .framework)"
  [ -f "$bin" ] || continue
  echo "== $(basename "$f")"
  nm -u "$bin" 2>/dev/null | grep -E 'statfs|NSUserDefaults|boottime' | head -3
done

2번과 3번이 중요한 이유는 우리 소스에 없어도 SDK가 쓰고 있을 수 있기 때문이다. 다만 그 경우 선언 의무는 SDK 쪽에 있으므로, 발견하면 SDK의 manifest를 확인한다.

선언한 사유가 실제와 맞는지 확인하는 것은 코드 리뷰의 몫이다. 자동 검증이 안 되므로 절차로 다룬다.

// 선언한 사유와 실제 용도를 코드에 명시해두면 리뷰가 쉬워진다
enum PrivacyAPIUsage {
    /// NSPrivacyAccessedAPICategoryUserDefaults — 사유 CA92.1
    /// 앱 자체의 설정을 읽고 쓰기 위해서만 사용한다.
    /// ⚠️ 이 값을 사용자 식별이나 추적에 재사용하면 선언 위반이다.
    static let defaults = UserDefaults.standard

    /// NSPrivacyAccessedAPICategoryFileTimestamp — 사유 (해당 코드)
    /// 사용자에게 표시할 파일 목록의 정렬에만 사용한다.
    static func modificationDate(of url: URL) throws -> Date? {
        try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
    }
}

프로젝트 적용

실무 규칙 넷.

그리고 대안이 있으면 대안을 쓴다. 예를 들어 "설치 후 경과 시간"이 필요하다면 부팅 시각을 읽는 대신 첫 실행 시 우리가 저장한 타임스탬프를 쓰면 된다. 이 값은 앱을 지우면 사라지므로 사용자가 통제할 수 있고, 핑거프린팅 우려가 없다.

⚠️ 흔한 오해

"ATT 허가를 받으면 핑거프린팅해도 된다"가 가장 중요한 오해다. Apple 문서가 명확히 한다 — 사용자가 추적 허가를 줬는지와 무관하게 핑거프린팅은 허용되지 않는다. 둘은 별개 규칙이다. "우리는 이 API를 추적에 안 쓰니 선언 안 해도 된다"도 틀렸다 — 사용 자체를 선언해야 하고, 용도가 정당하면 그에 맞는 사유를 고르면 된다. "사유 코드를 적당히 골라도 된다"도 위험하다 — 선언한 사유로만 쓸 수 있고 앱 기능과 일관해야 한다. 또 "우리 소스에 없으면 상관없다"도 부족하다 — 링크된 바이너리가 쓰면 그 바이너리의 번들에 manifest가 필요하다. 마지막으로 이 목록은 고정이 아니다 — Apple이 계속 갱신한다.

🧒 쉽게 이해하기

탐정이 사람을 찾는 방법을 생각해보자. 이름을 모르는 상태에서 한 사람을 특정하려면 어떻게 할까?

단서를 모은다. "키 175cm 정도" — 이걸로 후보가 수백만 명. "안경 씀" — 수십만 명. "왼손잡이" — 수만 명. 각각은 아무것도 아니다. 그런데 계속 모으면 점점 좁혀진다.

그러다 결정적인 단서가 나온다. "2026년 3월 14일 오후 2시 37분 12초에 집을 나섰다". 이건 거의 그 사람만의 정보다. 하나로 끝난다.

휴대폰에서 "기기가 언제 켜졌나"(초 단위)"디스크에 몇 바이트 남았나"가 정확히 이런 단서다. 아무 의미 없어 보이는데, 같은 값을 가진 다른 기기가 거의 없다.

그래서 규칙이 이렇게 됐다. "이런 단서를 왜 보는지 미리 신고하고, 신고한 용도로만 써라. 그리고 사람 찾는 데는 쓰지 마라."

여기서 중요한 게 있다. "본인이 추적해도 된다고 했으니 괜찮다"가 안 통한다. 왜냐하면 이 단서들은 본인이 바꿀 수 없기 때문이다. 광고 번호는 초기화할 수 있지만, "언제 집을 나섰는지"는 되돌릴 수 없다.

꼬리 질문

핑거프린팅 금지와 ATT는 어떻게 다른 규칙인가?

규율 대상과 작동 방식이 다르다.

ATT는 "허가 절차"다. 다른 회사의 앱·웹사이트를 가로지르는 데이터로 사용자를 추적하려면 물어보고 허락을 받아라는 규칙이다. 허락받으면 할 수 있고, 못 받으면 IDFA가 전부 0이 된다. 즉 조건부 허용이다.

핑거프린팅 금지는 "절대 금지"다. Apple 문서 표현대로 추적 허가 여부와 무관하게 허용되지 않는다. 허락을 받아도 안 된다.

왜 이렇게 나뉘었는지는 사용자 통제 가능성으로 설명된다. IDFA는 사용자가 초기화하거나 끌 수 있어서, 허락을 받는다는 절차가 의미를 갖는다. 반면 핑거프린팅은 기기의 물리적 특성에서 도출되므로 사용자가 리셋할 방법이 없다. 허락을 받았다 해도 나중에 마음을 바꿔 철회할 수단이 없으니, 동의라는 개념이 성립하지 않는다.

실무적으로는 이렇게 정리하면 된다 — ATT는 "물어봐야 하는 것", 핑거프린팅은 "하면 안 되는 것". Required Reason API 선언은 후자를 감시하기 위한 장치이고, ATT와 별개로 모든 앱에 적용된다.

쉽게 말하면 ATT는 "사진 찍어도 될까요?"라고 묻는 것이다. 된다고 하면 찍고, 싫다고 하면 안 찍는다. 핑거프린팅은 지문을 몰래 채취하는 것이다. "지문 채취해도 될까요?"라고 물어봐도 안 된다 — 지문은 바꿀 수가 없어서, 한번 넘어가면 되돌릴 방법이 없기 때문이다.
정당한 용도인데 승인된 사유 목록에 없으면 어떻게 하는가?

Apple이 이 경우를 위한 절차를 열어뒀다. 문서에 "여기에 나열되지 않은 사유로 required reason API를 사용해 앱 사용자에게 이익을 제공한다면 (요청하라)"는 안내가 있고, 새 사유 추가를 요청하는 경로가 제공된다.

다만 요청 전에 확인할 것들이 있다.

(1) 정말 그 API여야 하는가. 대안이 있는 경우가 많다. "설치 후 경과 시간"은 부팅 시각 대신 첫 실행 시 저장한 값으로, "저장 공간 부족 경고"는 정확한 여유 바이트 대신 쓰기 실패 처리로 대체할 수 있는 경우가 있다.

(2) 정밀도를 낮출 수 있는가. 핑거프린팅 위험은 값의 정밀도에서 온다. 디스크 여유 공간이 "1GB 미만/이상"이면 엔트로피가 1비트뿐이라 식별에 쓸모가 없다. 우리 기능에 정밀한 값이 정말 필요한지 따져본다.

(3) 기존 사유 중 맞는 것이 없는지 다시 본다. 목록이 생각보다 넓고, 표현이 일반적이라 해당되는 경우가 많다.

그리고 요청이 승인될 때까지 기다릴 수 없다면, 그 기능을 대안 구현으로 바꾸거나 일시적으로 빼는 것이 안전하다. 미선언 상태로 업로드하면 2024년 5월 1일 이후 거부된다.

쉽게 말하면 성분표에 "우리가 쓰는 이 재료"가 목록에 없다. 방법은 세 가지다. 다른 재료로 바꾸거나, 양을 확 줄여서 문제가 안 되게 하거나, 관리 기관에 "이 재료도 목록에 넣어달라"고 요청하는 것이다. 그동안은 그 재료를 쓰면 납품이 거절되니 기다릴 수는 없다.
관측 SDK가 required reason API를 쓸 가능성이 높은 이유는?

관측 SDK가 하는 일이 기기 상태를 수집하는 것이라 필연적으로 겹친다. 구체적으로는 이렇다.

  • UserDefaults — SDK 설정, 세션 상태, 이전 실행의 크래시 플래그(19장 Q7의 워치독 휴리스틱)를 저장한다. 거의 모든 SDK가 쓴다.
  • 디스크 여유 공간 — 오프라인 큐를 쌓기 전에 공간을 확인하거나, 진단 정보로 수집한다.
  • 파일 타임스탬프 — 저장된 크래시 리포트의 생성 시각을 읽어 오래된 것을 정리한다.
  • 시스템 부팅 시각 — 앱 실행 시간을 계산하거나, 워치독 종료 추정 휴리스틱의 조건 중 하나로 쓴다.

마지막 항목이 흥미롭다 — 19장에서 본 워치독 종료 휴리스틱은 "직전 실행에서 OS가 업데이트되지 않았는가" 같은 조건을 확인하는데, 이런 판정에 시스템 상태 API가 동원된다.

그래서 관측 SDK는 privacy manifest를 반드시 제공해야 하는 대표적 범주다. 도입 시 확인 항목이고, 없으면 Q2 꼬리질문의 대응 절차를 따른다.

우리가 직접 관측 코드를 짠다면 우리 앱 manifest에 선언해야 한다. "관측용이니까 괜찮겠지"는 통하지 않는다 — 오히려 진단 목적이라는 정당한 사유를 명시하면 되는 일이다.

쉽게 말하면 안전 점검 담당자는 일하는 성격상 건물 구석구석을 들여다볼 수밖에 없다. 그래서 가장 먼저 "무엇을 왜 보는지" 신고해야 하는 사람이 된다. "점검하려고 본 건데요"는 훌륭한 사유이지만, 사유를 적어내야 하는 것은 똑같다.

Q4. ATT는 무엇을 바꾸고 무엇은 안 바꾸는가?

🔑 30초 답변

ATT(App Tracking Transparency)는 다른 회사의 앱·웹사이트를 가로지르는 데이터를 추적에 쓰려면 사용자 허가를 받아라는 규칙이다. Apple 문서 표현으로는 "앱이 최종 사용자 데이터를 수집해 다른 회사와 공유하여 앱과 웹사이트를 가로지르는 추적 목적으로 쓴다면 이 프레임워크를 사용해야 한다". 바뀌는 것은 IDFA 접근(허가 없으면 전부 0)과 NSPrivacyTrackingDomains로 선언한 도메인의 네트워크 접근(허가 없으면 요청 실패)이다. 안 바뀌는 것이 더 중요하다 — 자사 앱 안에서의 분석, 크래시 리포팅, 성능 측정은 ATT 대상이 아니다. 그리고 핑거프린팅은 ATT와 무관하게 금지다(Q3).

CS 원리

ATT가 규율하는 것은 "추적(tracking)"이라는 특정 행위이고, 그 정의가 핵심이다. 정의의 요소가 셋이다.

  1. 데이터를 수집한다 — 앱 사용자에 관한 데이터.
  2. 다른 회사와 공유하거나 다른 회사 데이터와 결합한다 — 자사 안에서만 쓰면 해당하지 않는다.
  3. 앱과 웹사이트를 가로질러 사용자·기기를 식별한다 — 우리 앱 안에서의 사용자 식별은 해당하지 않는다.

세 요소가 모두 성립할 때 추적이다. 그래서 대부분의 관측·분석 작업은 2번에서 걸러진다 — 우리 서버로 보내 우리만 쓰면 다른 회사와 공유하지 않으므로 추적이 아니다.

"추적" 판정은 AND 게이트다 — 셋이 모두 성립해야 ATT 대상 ① 사용자 데이터 수집 앱 사용자에 관한 데이터 ② 타사 공유 · 결합 대부분 여기서 걸러진다 ③ 앱·웹 가로질러 식별 우리 앱 안 식별은 해당 없음 추적 우리 관측 파이프라인 ATT 대상 아님 크래시 리포팅 · 성능 계측 · 트레이싱 자사 앱 내 행동 분석 우리 서버로 보내 우리만 쓴다 → ②가 성립 안 함 ATT 대상 광고 어트리뷰션 · 타사 광고 네트워크 SDK 데이터 브로커 판매 타사 데이터와 결합해 앱을 가로질러 식별 추적이 아닌 것을 ATT 뒤에 두면 대부분의 사용자 데이터를 스스로 버리는 셈이다 18~22장에서 만든 파이프라인이 허가율만큼 축소된다
ATT 판정은 세 요소의 AND다. 관측 데이터는 거의 항상 ②(타사 공유)에서 걸러진다.

이 구분이 실무에서 중요한 이유는 ATT 허가율이 낮기 때문이다. 관측 데이터를 ATT 뒤에 두면 대부분의 사용자에게서 데이터를 못 받게 되고, 그러면 18~22장에서 만든 파이프라인이 무력해진다. 추적이 아닌 것을 추적으로 취급하면 스스로 손해다.

반대로 추적인 것을 아니라고 판단하면 정책 위반이 된다. 광고 SDK나 어트리뷰션 SDK를 붙였다면, 그 SDK가 데이터를 다른 회사와 결합하는지 확인해야 한다.

iOS에서는

ATT 사용 절차는 Apple 문서가 세 단계로 정리한다.

  1. NSUserTrackingUsageDescription을 Info.plist에 설정한다. 시스템 권한 알림에 표시할 문구다.
  2. ATTrackingManager.requestTrackingAuthorization을 호출해 요청을 띄운다.
  3. ATTrackingManager.trackingAuthorizationStatus로 상태를 확인한다.
import AppTrackingTransparency
import AdSupport

enum Tracking {
    /// 요청은 앱이 포그라운드 활성 상태일 때만 표시된다.
    /// 런치 직후 호출하면 알림이 안 뜨고 조용히 실패할 수 있다.
    static func requestIfNeeded() async -> ATTrackingManager.AuthorizationStatus {
        // 이미 결정된 상태면 다시 묻지 않는다 — 한 번 거부하면 다시 못 묻는다
        let current = ATTrackingManager.trackingAuthorizationStatus
        guard current == .notDetermined else { return current }
        return await ATTrackingManager.requestTrackingAuthorization()
    }

    /// IDFA — 허가 없으면 전부 0이 반환된다
    static var advertisingID: String? {
        guard ATTrackingManager.trackingAuthorizationStatus == .authorized else { return nil }
        let id = ASIdentifierManager.shared().advertisingIdentifier
        // 방어적으로 한 번 더 확인 — 전부 0이면 사용 불가
        return id.uuidString == "00000000-0000-0000-0000-000000000000" ? nil : id.uuidString
    }
}

상태 값이 넷이다 — notDetermined(아직 안 물어봄), restricted(정책상 불가), denied(거부), authorized(허용). 실무에서 중요한 점은 한 번 거부하면 앱에서 다시 물어볼 수 없다는 것이다. 사용자가 설정에서 직접 바꿔야 한다. 그래서 요청 타이밍이 한 번뿐인 기회가 된다.

그리고 앞서 본 NSPrivacyTrackingDomains의 강제력이 여기 연결된다. ATT 허가가 없으면 그 도메인으로의 요청이 실패하므로, ATT는 IDFA만 막는 것이 아니라 네트워크까지 막는다.

관측 파이프라인 관점에서 정리하면 이렇다.

용도ATT 대상?이유
크래시 리포팅아니오자사 진단 목적, 타사 공유 없음
성능 계측 · 트레이싱아니오동일
자사 앱 내 행동 분석아니오앱·웹사이트를 가로지르지 않음
광고 어트리뷰션타사 데이터와 결합해 설치 경로 추적
타사 광고 네트워크 SDK타사 공유 + 앱 간 식별
데이터 브로커에 판매타사 공유

실험 · 도구

ATT 상태별 동작을 반드시 테스트해야 한다. 개발 중에는 대개 허용 상태라 거부 경로가 검증되지 않는다.

# 1) 상태를 초기화해 notDetermined 로 되돌린다
#    기기: 설정 › 개인정보 보호 및 보안 › 추적 › (앱 목록에서 토글)
#    또는 앱을 삭제 후 재설치

# 2) 거부 상태로 전체 플로우 테스트
#    설정 › 개인정보 보호 및 보안 › 추적 › "앱이 추적을 요청하도록 허용" 끄기
#    → 모든 앱이 자동 거부 상태가 된다. 이 상태로 주요 기능을 전부 돌려본다.

# 3) ⚠️ simctl privacy 에는 tracking 서비스가 없다
#    (지원: calendar, contacts, location, photos, media-library, microphone,
#           motion, reminders, siri 등 — `xcrun simctl help privacy` 로 확인)
#    시뮬레이터에서 ATT 상태를 되돌리려면 앱을 삭제 후 재설치한다:
xcrun simctl uninstall booted com.example.shop
xcrun simctl install   booted /path/to/Shop.app
#    → ATT 검증은 위 1)·2)의 실기기 설정 경로가 유일하게 믿을 수 있는 방법이다.
// 4) 거부 상태에서 관측 파이프라인이 정상 동작하는지 확인하는 테스트
func testObservabilityWorksWithoutTrackingPermission() async {
    // ATT 거부 상태를 가정
    let status = ATTrackingManager.trackingAuthorizationStatus
    XCTAssertNotEqual(status, .authorized)

    // 크래시 리포팅과 성능 계측은 ATT와 무관하므로 계속 동작해야 한다
    Observability.capture(event: .init(type: "test"))
    await Task.yield()
    XCTAssertTrue(Observability.lastSendSucceeded,
                  "ATT 거부 상태에서 관측 전송이 막히면 설정이 잘못된 것이다")
}

가장 중요한 검증은 ATT 거부 상태로 앱 전체를 써보는 것이다. 여기서 다음을 확인한다.

프로젝트 적용

실무 판단 다섯.

⚠️ 흔한 오해

"사용자 데이터를 수집하면 ATT가 필요하다"가 가장 흔한 과잉 해석이다. ATT는 타사와 공유하며 앱·웹사이트를 가로지르는 추적에 한정된다. 자사 크래시 리포팅과 성능 계측은 대상이 아니다. 반대로 "우리 서버로만 보내니 무조건 안전하다"도 성급하다 — 그 데이터를 나중에 타사와 결합하면 추적이 된다. "ATT를 거부해도 IDFA만 못 쓸 뿐"도 틀렸다 — NSPrivacyTrackingDomains에 선언한 도메인은 네트워크 요청 자체가 실패한다. "거부한 사용자에게 다시 물어보면 된다"도 안 된다 — 한 번 결정되면 앱에서 다시 요청할 수 없다. 마지막으로 ATT를 지켰다고 핑거프린팅이 허용되는 것은 아니다(Q3).

🧒 쉽게 이해하기

가게에서 손님을 기억하는 방법을 생각해보자. 두 가지가 완전히 다르다.

첫째, 우리 가게 안에서만 기억하기. "이 손님은 지난번에 아메리카노를 시켰지", "3번 테이블에서 컵이 깨졌지". 이건 우리 가게 운영을 위한 기록이다. 손님에게 따로 물어볼 필요가 없다.

둘째, 다른 가게와 손님 정보를 맞춰보기. "이 손님이 옆 빵집에서도, 건너편 서점에서도 뭘 샀는지" 알아내서 합치는 것. 이건 완전히 다른 얘기다. 여기에는 반드시 물어봐야 한다.

ATT가 규율하는 건 두 번째다. 첫 번째 — 우리 가게 안에서 뭐가 깨졌고 뭐가 느렸는지 기록하는 것 — 은 물어볼 필요가 없다.

그래서 "손님 기록을 남기니까 일단 다 물어보자"고 하면 손해다. 대부분 거절할 텐데, 그러면 컵이 깨진 기록조차 못 남기게 된다. 물어봐야 하는 것과 아닌 것을 정확히 나눠야 한다.

그리고 물어보는 건 딱 한 번이다. 거절당하면 다시 못 묻는다. 그러니 손님이 왜 필요한지 이해한 다음에 물어야 한다.

꼬리 질문

ATT 요청 타이밍을 어떻게 정해야 하며, 왜 런치 직후는 나쁜가?

두 가지 이유로 나쁘다.

(1) 기술적 문제. 요청 알림은 앱이 포그라운드 활성 상태일 때만 표시된다. 런치 시퀀스 중에는 아직 활성 상태가 아닐 수 있어서, 호출해도 알림이 안 뜨고 상태가 notDetermined으로 남는다. 그런데 코드는 "요청했다"고 생각하고 넘어가므로 조용히 실패한다.

(2) 허가율 문제. 앱을 처음 열자마자 맥락 없이 물으면 사용자는 무엇을 허락하는지 모른 채 거절한다. 그리고 한 번 거절하면 앱에서 다시 못 묻는다 — 설정에서 직접 바꿔야 하는데 그렇게 하는 사용자는 거의 없다. 즉 첫 시도가 사실상 유일한 기회다.

더 나은 타이밍은 사용자가 그 기능의 가치를 이해한 직후다. 예를 들어 개인화 추천을 처음 보여준 뒤, 또는 관련 설정 화면에 들어왔을 때.

그리고 사전 안내(pre-prompt)를 두는 패턴이 흔하다 — 시스템 알림을 띄우기 전에 우리 UI로 이유를 설명하고, "계속" 을 누른 사용자에게만 실제 요청을 띄운다. 여기서 "나중에"를 고른 사용자는 시스템 알림을 소모하지 않으므로 나중에 다시 물어볼 수 있다. 다만 이 패턴이 정책상 허용되는 범위는 확인이 필요하다.

중요한 것은 거부를 정상 경로로 다루는 것이다. 허가율이 높지 않다는 전제에서 기능을 설계해야 한다.

쉽게 말하면 처음 만난 사람에게 인사하자마자 "전화번호 주세요"라고 하면 대부분 거절한다. 그리고 한 번 거절당하면 다시 물어볼 수 없다. 그러니 대화를 좀 나누고, 왜 필요한지 자연스럽게 이해된 다음에 묻는 게 훨씬 낫다.
자사 분석이 나중에 추적으로 바뀌는 경우는 어떤 것인가?

데이터 자체는 그대로인데 용도가 바뀌어서 추적이 되는 경우가 있다. 전형적인 시나리오 셋이다.

(1) 타사 도구 도입. 우리 서버에 쌓아둔 행동 데이터를 외부 마케팅 플랫폼에 업로드해 광고 타게팅에 쓰기 시작하면, 그 시점부터 타사 공유가 된다. 데이터 수집 코드는 하나도 안 바뀌었는데 규제 대상이 된다.

(2) 데이터 결합. 우리 데이터를 다른 회사에서 받은 데이터와 결합해 사용자를 식별하면 추적이다. 광고 성과 측정을 위해 어트리뷰션 업체와 데이터를 맞춰보는 것이 여기 해당한다.

(3) 회사 구조 변화. 계열사·인수·합병으로 "다른 회사"의 범위가 달라지면 판단이 바뀔 수 있다.

대응은 데이터 용도를 문서로 관리하는 것이다. 수집 시점의 목적을 기록해두고, 새 용도가 추가될 때 재검토 절차를 거치게 한다. 이건 Q3에서 본 required reason API의 "선언한 사유로만 사용" 원칙과 같은 구조다 — 수집이 아니라 용도가 규율 대상이다.

실무적으로는 관측 데이터와 마케팅 데이터의 저장소를 물리적으로 분리하는 것이 안전하다. 섞여 있으면 "이 데이터를 마케팅에 써도 되나"를 매번 판단해야 하고, 실수가 생긴다.

쉽게 말하면 우리 가게 장부는 우리만 보려고 적은 것이다. 그런데 어느 날 그 장부를 광고 회사에 넘겨서 "이 손님에게 이런 광고를 보내주세요"라고 하면, 장부 내용은 그대로인데 성격이 완전히 달라진다. 그래서 "운영용 장부"와 "마케팅용 장부"를 아예 다른 서랍에 두는 게 안전하다.
ATT 거부 상태에서 관측 데이터 품질이 떨어지는 부분이 있는가?

직접적으로는 거의 없다. 크래시 리포팅과 성능 계측은 ATT와 무관하므로 수집 자체는 그대로다. 다만 간접적 영향이 몇 가지 있다.

(1) 사용자 식별의 지속성. IDFA를 못 쓰면 "같은 기기의 재설치 전후"를 잇지 못한다. 그런데 관측 목적으로는 설치 단위 식별자(앱이 첫 실행 시 생성해 저장하는 UUID)로 충분하고, 오히려 이쪽이 사용자가 앱 삭제로 초기화할 수 있어 프라이버시 관점에서 낫다.

(2) 광고 유입 코호트 분석. "어떤 광고로 들어온 사용자가 크래시를 더 겪나" 같은 질문은 어트리뷰션 데이터가 필요한데, 이건 ATT 대상이라 제한된다. 다만 이런 분석이 정말 필요한 경우는 드물다.

(3) 도메인 차단의 부수 효과. NSPrivacyTrackingDomains에 선언한 도메인이 관측 트래픽도 함께 나르고 있으면 관측 데이터까지 막힌다. 그래서 Q2 꼬리질문의 도메인 분리가 중요하다.

(3)이 실제로 사고를 만드는 지점이다. 광고 SDK와 관측 SDK가 같은 벤더의 같은 도메인을 쓰는 구성이라면, ATT 거부 사용자에게서 크래시 데이터를 통째로 못 받게 된다. 이 경우 크래시율 통계가 ATT 허용 사용자에게 편향되므로, 19장 Q2 꼬리질문에서 본 것과 같은 종류의 통계 왜곡이 생긴다.

확인 방법은 단순하다 — ATT 거부 상태로 크래시를 일으켜보고 대시보드에 올라오는지 본다.

쉽게 말하면 "광고 관련 거래처"라고 신고한 곳이 사실은 안전 점검 보고서도 같이 배달하고 있었다면, 손님이 광고를 거부한 순간 점검 보고서도 함께 끊긴다. 배달 경로를 나눠두지 않으면 이런 일이 생긴다.

Q5. 클라이언트와 서버 스크러빙은 왜 둘 다 필요한가?

🔑 30초 답변

서로 다른 실패 모드를 막기 때문이다. 클라이언트 스크러빙은 데이터가 기기를 떠나기 전에 막으므로 유일하게 "전송 자체를 안 하는" 방어다. 그런데 구버전 앱은 영원히 업데이트되지 않는다 — 오늘 클라이언트 코드를 고쳐도 어제 배포된 버전은 계속 옛 규칙으로 보낸다. 서버 스크러빙은 이 구멍을 메운다. 규칙을 바꾸면 모든 버전에 즉시 적용되고, 새 유출 패턴을 발견했을 때 릴리스 없이 대응할 수 있다. 대신 서버에 도달한 뒤라 전송 자체는 이미 일어났다. 그래서 클라이언트가 1차 방어, 서버가 최후 방어선이라는 계층 구조가 된다.

CS 원리

이건 심층 방어(defense in depth)의 전형이다. 단일 방어선은 반드시 뚫리므로, 독립적인 방어를 여러 겹 두어 하나가 실패해도 다음이 막게 한다.

두 방어선의 성질을 비교하면 왜 둘 다 필요한지가 명확해진다.

클라이언트 스크러빙서버 스크러빙
차단 시점전송 전수신 후
전송 자체를 막나아니오 — 이미 나갔다
규칙 변경 반영다음 릴리스 + 사용자 업데이트즉시, 모든 버전
구버전 앱영원히 옛 규칙동일하게 적용
서드파티 SDK 데이터훅이 있어야 가능전부 적용
연산 비용 부담사용자 기기우리 서버
맥락 정보풍부 — 타입을 안다문자열만 보고 추측

가장 중요한 비대칭이 규칙 변경 반영이다. 클라이언트 규칙은 배포된 순간 고정된다. 새로운 유출 패턴을 발견해도 이미 나간 앱은 못 고친다. 그리고 P0 16장에서 본 대로 일부 사용자는 업데이트를 영원히 하지 않는다.

반대 방향의 비대칭은 맥락 정보다. 클라이언트는 값의 타입과 출처를 안다 — "이건 사용자 이메일 필드"라는 것을 확실히 알고 지울 수 있다. 서버는 문자열만 받으므로 패턴 매칭에 의존하고, 그래서 오탐(정상 값을 지움)과 미탐(유출을 놓침)이 생긴다.

두 방어선은 서로 다른 실패 모드를 막는다 이벤트 생성 타입·출처를 안다 클라이언트 스크러빙 전송 전 — 유일하게 기기 밖으로 안 내보낸다 네트워크 서버 스크러빙 수신 후 — 이미 나갔다 모든 버전에 즉시 적용 저장소 한쪽만 두면 이렇게 뚫린다 클라이언트만 있을 때 새 유출 패턴을 발견해도 이미 배포된 앱은 영원히 옛 규칙 서버만 있을 때 문자열만 보고 패턴 매칭 → 오탐·미탐 + 이미 전송은 끝났다 클라이언트는 정확하지만 갱신이 느리고, 서버는 부정확하지만 즉시 반영된다
심층 방어의 전형이다. 두 겹의 비대칭(정확도 ↔ 갱신 속도)이 정확히 서로를 보완한다.

정리하면 클라이언트는 정확하지만 갱신이 느리고, 서버는 부정확하지만 즉시 반영된다. 상보적이다.

iOS에서는

클라이언트 스크러빙은 전송 직전 훅에서 구현한다. 대부분의 SDK가 beforeSend 계열 콜백을 제공한다.

import Foundation

enum Scrubber {
    /// 전송 직전에 이벤트를 검사·수정한다. nil을 반환하면 전송을 취소한다.
    static func scrub(_ event: inout Event) -> Event? {
        // ── 1. 구조적 스크러빙: 타입을 아는 필드는 확실하게 처리한다 ──
        event.request.url = event.request.url.flatMap { URL(string: $0)?.observabilitySafe }
        event.request.headers.removeValue(forKey: "Authorization")
        event.request.headers.removeValue(forKey: "Cookie")
        event.request.body = nil                    // 본문은 통째로 제외
        event.user = event.user.map { u in          // 식별자는 남기되 개인정보는 제거
            var u = u; u.email = nil; u.name = nil; u.ipAddress = nil; return u
        }

        // ── 2. 허용 목록: 알려진 키만 남긴다 (차단 목록보다 안전) ──
        let allowedContextKeys: Set<String> = ["device", "app", "network", "order_meta"]
        event.contexts = event.contexts.filter { allowedContextKeys.contains($0.key) }

        // ── 3. 패턴 스크러빙: 남은 자유 텍스트를 정규식으로 훑는다 ──
        event.message = redact(event.message)
        for i in event.breadcrumbs.indices {
            event.breadcrumbs[i].message = redact(event.breadcrumbs[i].message)
        }

        // ── 4. 최후 검증: 그래도 남아 있으면 전송을 포기한다 ──
        if containsLikelyPII(event.jsonString) {
            Logger(subsystem: "com.example.shop", category: "privacy")
                .error("스크러빙 후에도 PII 의심 — 이벤트 폐기")
            return nil          // 보내지 않는 것이 보내는 것보다 낫다
        }
        return event
    }

    private static func redact(_ s: String) -> String { /* 정규식 치환 */ s }
    private static func containsLikelyPII(_ s: String) -> Bool { /* Q1의 PIIGuard 로직 */ false }
}

여기서 2번(허용 목록)이 설계상 가장 중요하다. 차단 목록("이 키들은 지운다")은 새 키가 추가되면 자동으로 통과하지만, 허용 목록("이 키들만 남긴다")은 새 키가 기본적으로 차단된다. 실수의 방향이 안전한 쪽이다. Q1 꼬리질문에서 스크린샷 마스킹도 같은 원칙을 권한 것과 이어진다.

4번도 중요한 판단이다 — 스크러빙에 실패했으면 보내지 않는 쪽을 택한다. 이벤트 하나를 잃는 비용보다 유출 비용이 훨씬 크다.

서버 스크러빙 쪽은 대개 관측 백엔드가 기능으로 제공한다. 설정할 것은 셋이다.

실험 · 도구

두 방어선이 각각 무엇을 잡는지 확인하는 테스트를 만들어두면 좋다.

import XCTest

final class ScrubberTests: XCTestCase {
    /// 클라이언트 스크러빙이 구조적 필드를 확실히 지우는지
    func testStructuralScrubbing() {
        var event = Event(
            message: "결제 실패",
            request: .init(url: "https://api.shop.com/pay?token=eyJhbGciOiJIUzI1NiJ9.abc&email=a@b.com",
                           headers: ["Authorization": "Bearer secret123"],
                           body: "{\"card\":\"4242424242424242\"}")
        )
        let out = Scrubber.scrub(&event)
        let json = out?.jsonString ?? ""

        XCTAssertFalse(json.contains("eyJhbGciOiJIUzI1NiJ9"), "JWT가 남았다")
        XCTAssertFalse(json.contains("a@b.com"),               "이메일이 남았다")
        XCTAssertFalse(json.contains("secret123"),             "토큰이 남았다")
        XCTAssertFalse(json.contains("4242424242424242"),      "카드번호가 남았다")
        XCTAssertTrue(json.contains("api.shop.com/pay"),       "경로는 남아야 조사가 된다")
    }

    /// 패턴 스크러빙이 자유 텍스트에서 잡아내는지
    func testPatternScrubbing() {
        var event = Event(message: "사용자 hong@example.com 의 주문 처리 실패")
        let out = Scrubber.scrub(&event)
        XCTAssertFalse((out?.message ?? "").contains("hong@example.com"))
    }

    /// 스크러빙 실패 시 폐기하는지
    func testDropsWhenScrubbingFails() {
        var event = Event(message: "주민번호 900101-1234567 확인 필요")
        // 패턴에 안 걸리도록 일부러 변형한 케이스를 넣어 최후 검증을 시험한다
        XCTAssertNil(Scrubber.scrub(&event), "PII가 남았는데 전송됐다")
    }
}

서버 규칙은 샘플 페이로드로 검증한다. 대부분의 백엔드가 규칙 테스트 기능을 제공하고, 없으면 스테이징 프로젝트에 실제로 보내보고 저장된 결과를 확인한다.

# 서버 스크러빙 검증 — 의도적으로 PII를 담은 이벤트를 스테이징에 보내고
# 저장된 결과를 조회해 제거됐는지 확인한다
curl -X POST "$STAGING_INGEST_URL" \
  -H 'Content-Type: application/json' \
  -d '{"message":"test scrub hong@example.com 010-1234-5678 4242424242424242"}'

sleep 5
# 저장된 이벤트를 조회해 원문이 남아 있는지 확인
fetch-event "$EVENT_ID" | grep -E 'hong@|010-1234|4242' \
  && echo "❌ 서버 스크러빙 실패" || echo "✅ 서버 스크러빙 동작"

프로젝트 적용

이중 방어를 실제로 구성하는 순서는 이렇다.

  1. 서버 스크러빙을 먼저 켠다. 즉시 효과가 있고 모든 버전에 적용되므로 가장 빠른 위험 감소다. 클라이언트 작업을 기다릴 이유가 없다.
  2. 클라이언트에 구조적 스크러빙을 넣는다. URL·헤더·본문·사용자 필드처럼 타입을 아는 것부터. 여기가 가장 효과가 크다.
  3. 허용 목록으로 전환한다. 차단 목록으로 시작했다면 허용 목록으로 바꾼다. 처음에 무엇이 필요한지 모를 때는 차단 목록으로 시작하는 것이 현실적이지만, 안정되면 뒤집는다.
  4. 최후 검증과 폐기 정책을 넣는다. 스크러빙 후에도 의심되면 버린다.
  5. 정기 점검을 절차화한다. Q1의 프록시 캡처를 분기마다.

그리고 스크러빙이 조사 능력을 얼마나 해치는지 측정해야 한다. 너무 공격적으로 지우면 이벤트가 쓸모없어진다 — 이게 Q6의 주제다.

⚠️ 흔한 오해

"서버에서 지우니 클라이언트는 신경 안 써도 된다"가 위험한 오해다. 전송 자체가 이미 일어났고, 전송 구간과 수신 직후 처리 과정에 데이터가 존재한다. "클라이언트에서 지우니 서버 규칙은 필요 없다"도 틀렸다 — 구버전 앱은 영원히 옛 규칙으로 보낸다. "정규식으로 다 잡을 수 있다"도 과신이다 — 이름·주소·자유 텍스트는 패턴이 없어서 못 잡는다. 그래서 구조적 스크러빙(타입 기반)이 패턴 스크러빙보다 우선이다. 또 "차단 목록에 다 넣었으니 안전하다"도 위험하다 — 새로 추가되는 필드는 자동으로 통과한다. 허용 목록이 안전한 기본값이다.

🧒 쉽게 이해하기

우편물을 보내기 전에 개인정보를 지우는 작업을 한다고 하자. 두 군데서 할 수 있다.

우체국에 가기 전, 집에서 지우기(클라이언트). 가장 확실하다. 아예 밖으로 안 나간다. 그런데 문제가 있다 — 어제 이미 부친 편지는 어쩔 수 없다. 그리고 "지우는 규칙"을 새로 배워도, 이미 집을 나선 사람들은 옛날 규칙대로 계속 부친다.

우체국 분류실에서 지우기(서버). 규칙을 바꾸면 오늘 들어온 편지 전부에 바로 적용된다. 몇 년 전 규칙을 배운 사람이 부친 편지도 여기서 걸러진다. 대신 편지가 이미 집을 나섰다는 사실은 바뀌지 않는다.

그래서 둘 다 한다. 집에서 최대한 지우고, 우체국에서 한 번 더 훑는다.

그리고 지우는 방식에도 차이가 있다. 집에서는 "이 칸이 주민번호 칸"이라는 걸 안다. 확실하게 지울 수 있다. 우체국은 글씨만 보고 "이거 주민번호 같은데?" 하고 추측해야 한다. 그래서 놓치기도 하고, 멀쩡한 숫자를 지우기도 한다.

마지막으로 중요한 원칙 하나 — "이건 지운다" 목록보다 "이것만 보낸다" 목록이 안전하다. 새 서식이 생겼을 때, 앞쪽은 자동으로 통과하고 뒤쪽은 자동으로 막힌다.

꼬리 질문

차단 목록 대신 허용 목록을 쓰면 어떤 비용이 생기는가?

안전한 대신 운영 부담이 생긴다.

(1) 새 필드가 조용히 사라진다. 개발자가 새 컨텍스트를 추가했는데 허용 목록에 안 넣으면, 대시보드에 안 나타난다. 그런데 에러도 안 나므로 왜 안 보이는지 한참 헤맨다. 대응은 차단된 키를 로그로 남기는 것이다 — "허용 목록에 없어 제거됨: order_debug" 같은 경고를 디버그 빌드에서 띄우면 즉시 알아챈다.

(2) 목록 관리 부담. 필드가 늘어날 때마다 목록을 고쳐야 한다. 이걸 줄이려면 네임스페이스 규칙을 쓸 수 있다 — safe_ 접두사가 붙은 키는 자동 허용, 나머지는 검토 대상. 규칙이 코드에 드러나므로 리뷰에서 걸린다.

(3) 서드파티 SDK 데이터. SDK가 자동으로 붙이는 컨텍스트까지 허용 목록으로 관리하려면 SDK 업데이트마다 확인해야 한다.

그럼에도 허용 목록이 나은 이유는 실패의 비대칭성 때문이다. 차단 목록의 실패는 유출이고 되돌릴 수 없다. 허용 목록의 실패는 데이터 누락이고 목록을 고치면 복구된다. 되돌릴 수 있는 실패를 택하는 것이 설계 원칙이다.

쉽게 말하면 "이건 빼고 다 넣기"는 편하지만, 새 물건이 생기면 자동으로 들어간다. "이것만 넣기"는 번거롭지만 새 물건이 자동으로 빠진다. 빠진 건 나중에 넣으면 되지만, 잘못 나간 건 되돌릴 수 없다.
이름이나 주소처럼 패턴이 없는 PII는 어떻게 다루는가?

패턴 매칭으로는 불가능하므로 구조적 접근이 유일한 답이다.

(1) 애초에 자유 텍스트에 안 넣는다. 이름이 이벤트 메시지에 들어가는 경로를 없앤다. Q1에서 본 대로 에러 메시지를 상수로 만들고 값은 구조화된 필드로 분리하면, 이름은 "이름 필드"에만 존재하게 되고 그 필드를 지우면 끝난다.

(2) 타입 시스템으로 강제한다. 민감한 값을 감싸는 타입을 만들고, 그 타입은 로깅·직렬화 시 자동으로 가려지게 한다.

/// 민감 값 래퍼 — 실수로 로그에 찍혀도 원문이 안 나간다
struct Sensitive<T>: CustomStringConvertible, CustomDebugStringConvertible {
    private let value: T
    init(_ value: T) { self.value = value }
    /// 명시적으로 꺼낼 때만 원문에 접근한다
    func reveal() -> T { value }
    var description: String { "<redacted>" }
    var debugDescription: String { "<redacted>" }
}

struct User {
    let id: String                    // 안전 — 내부 식별자
    let name: Sensitive<String>       // 문자열 보간해도 <redacted>
    let email: Sensitive<String>
}

let u = User(id: "u_123", name: Sensitive("김철수"), email: Sensitive("a@b.com"))
print("사용자 \(u.name) 처리 실패")   // "사용자 <redacted> 처리 실패"

이 방식의 장점은 실수의 기본값이 안전하다는 것이다. Apple의 통합 로깅이 동적 문자열을 기본 리댁션하는 것과 같은 발상이다.

(3) 서버 측에서는 필드 단위로 차단한다. 자유 텍스트에서 이름을 찾는 것은 포기하고, 이름이 들어갈 수 있는 필드 자체를 제거하거나 해시한다.

결론적으로 패턴 매칭은 보조 수단이고, 본질적 해법은 "민감 값이 자유 텍스트로 흘러 들어가는 경로를 구조적으로 차단하는 것"이다.

쉽게 말하면 주민번호는 모양이 정해져 있어서 찾아서 지울 수 있다. 그런데 이름은? "김철수"가 이름인지 가게 이름인지 글자만 봐서는 모른다. 그래서 애초에 이름을 쓰는 칸을 따로 만들고, 그 칸을 통째로 지우는 방식으로 가야 한다. 아무 데나 이름을 쓰게 두면 나중에 찾을 방법이 없다.
스크러빙 규칙을 원격 설정으로 두면 어떤 이점과 위험이 있는가?

이점은 클라이언트 스크러빙의 최대 약점 — 릴리스 없이 못 고친다 — 을 부분적으로 해소한다는 것이다. 새 유출 패턴을 발견했을 때 원격 설정으로 규칙을 내려보내면 이미 배포된 버전에도 적용된다. 서버 스크러빙과 달리 전송 자체를 막을 수 있다는 점이 크다.

위험은 셋이다.

(1) 설정이 도착하기 전 구간. 앱 시작 직후 원격 설정을 받기 전에 발생한 이벤트는 옛 규칙으로 처리된다. 그래서 내장 기본 규칙이 여전히 필요하고, 원격 설정은 그 위에 덧붙이는 형태여야 한다.

(2) 규칙 자체가 공격 대상. 원격 설정 채널이 탈취되면 스크러빙을 무력화하는 규칙을 내려보낼 수 있다. 설정 채널의 무결성(서명 검증)이 필요하고, "규칙을 끄는" 방향의 변경은 아예 불가능하게 설계하는 것이 안전하다 — 추가만 가능하고 제거는 불가한 구조.

(3) 정규식 성능. 원격에서 내려온 정규식이 백트래킹 폭발을 일으키면 메인스레드가 멈춘다(20장 Q5). 규칙을 적용할 때 타임아웃을 두거나, 정규식 대신 제한된 표현력의 규칙 언어를 쓰는 것이 안전하다.

실무적으로는 내장 기본 규칙 + 원격 추가 규칙 조합이 좋고, 원격 규칙은 패턴 추가에만 쓰고 구조적 스크러빙 로직은 코드에 둔다.

쉽게 말하면 "지울 항목 목록"을 매일 우편으로 받아서 갱신하는 방식이다. 새 위험이 생겨도 바로 대응할 수 있어 좋다. 그런데 우편이 오기 전에 부친 편지는 옛 규칙이고, 누가 가짜 우편으로 "아무것도 지우지 마"라고 보내면 큰일이 난다. 그래서 기본 규칙은 몸에 익혀두고, 우편으로는 추가할 항목만 받는 게 안전하다.

Q6. 최소 수집과 디버깅 능력을 어떻게 절충하는가?

🔑 30초 답변

절충의 축을 "데이터 자체"가 아니라 "그 데이터로 답하려는 질문"으로 옮기면 대부분 해결된다. 사용자 이메일이 필요한 게 아니라 "같은 사용자의 이벤트를 묶고 싶은 것"이고, 그건 익명 식별자로 된다. 검색어가 필요한 게 아니라 "결과가 0건인 검색이 느는지"이고, 그건 메타데이터로 된다. 이 치환을 거치면 수집량은 줄고 조사 능력은 거의 그대로인 경우가 대부분이다. 남는 진짜 절충은 소수인데, 그때는 보관 기간 단축·접근 통제·별도 동의로 위험을 낮춘다. 원칙은 하나다 — "이 필드가 없으면 못 고치는 버그를 실제로 겪은 적이 있는가?"

CS 원리

데이터 최소화(data minimization)는 필요한 만큼만 수집한다는 원칙이다. 그런데 "필요한 만큼"이 모호해서 실무에서 잘 작동하지 않는다. 이를 다루기 쉽게 만드는 방법이 질문 기반 역산이다.

절차는 이렇다.

  1. 답하려는 질문을 적는다. 22장 Q4의 샘플링 비율 결정과 같은 출발점이다.
  2. 그 질문에 필요한 최소 정보를 정한다.
  3. 그 정보를 얻는 가장 덜 민감한 표현을 고른다.

3번에서 쓰는 변환 기법을 정리하면 이렇다.

기법원본 → 변환보존되는 것잃는 것
가명화이메일 → 익명 UUID동일성 (같은 사람 판정)실제 신원
해시값 → 해시동일성 · 상관관계원문 (단, 후보가 적으면 역산 가능)
일반화정확한 나이 → 연령대분포 · 집계정밀도
버킷화정확한 바이트 → 크기 구간규모 감각정밀도 (핑거프린팅 위험 제거)
메타데이터 치환검색어 → 길이 · 결과 수행동 패턴내용
분류 치환에러 원문 → 에러 코드원인 분류세부 문구

여기서 버킷화가 Q3의 핑거프린팅과 직접 연결된다. 디스크 여유 공간을 정확한 바이트로 수집하면 엔트로피가 커서 식별에 쓰일 수 있지만, "1GB 미만 / 1~10GB / 10GB 이상"으로 버킷화하면 엔트로피가 2비트도 안 되어 식별에 쓸모가 없다. 그런데 "저장 공간 부족이 크래시와 상관있나"라는 질문에는 여전히 답할 수 있다.

"데이터가 필요한가"가 아니라 "이 질문에 답하려면 무엇이 필요한가" ① 답하려는 질문 "저장 공간이 문제인가" ② 최소 정보 "부족한가 아닌가" ③ 가장 덜 민감한 표현 버킷: 부족 / 보통 / 충분 정밀도를 낮추면 식별 위험은 급락하고 유용성은 거의 유지된다 정확한 바이트 18,349,208,064 B 사실상 고유값 기기마다 달라 핑거프린팅 가능 버킷화 3구간 버킷 <1GB / 1–10GB / >10GB 최대 1.6 bit 식별에 쓸모없음 그런데 답은 그대로 나온다 "저장 공간 부족 사용자에게 실패가 몰려 있나"에는 3구간이면 충분 같은 지렛대가 다른 데도 그대로 적용된다 이메일→익명 UUID · 검색어→길이·결과 수 · 응답 본문→실패 필드명·기대 타입
위험은 정밀도에 붙어 있다. 질문에 필요 없는 정밀도를 버리면 위험만 사라지고 답은 남는다.

정밀도를 낮추는 것만으로 위험은 크게 줄고 유용성은 거의 유지되는 경우가 많다. 이게 최소화 설계의 핵심 지렛대다.

iOS에서는

실무에서 자주 마주치는 절충 사례를 구체적으로 보자.

답하려는 질문순진한 수집최소화된 수집
같은 사용자의 이벤트를 묶고 싶다이메일 · 계정 ID설치 단위 익명 UUID
특정 사용자 문의를 조사하고 싶다이름 · 전화번호앱 내 표시되는 지원 코드
어느 화면에서 에러가 나나전체 URL · 화면 타이틀화면 식별자 열거형
어떤 데이터에서 파싱이 실패하나응답 본문 전체실패한 필드명 · 예상 타입 · 값의 종류
저장 공간이 문제인가정확한 여유 바이트버킷 (부족/보통/충분)
느린 사용자의 데이터 규모전체 항목 목록항목 수의 로그 스케일 버킷
검색 품질이 나쁜가검색어 원문길이 · 결과 수 · 클릭 여부

"실패한 필드명" 사례를 자세히 보면 원리가 잘 드러난다. 디코딩 실패를 조사하려면 응답 본문이 필요할 것 같지만, 실제로 알아야 할 것은 "어느 필드가, 어떤 타입을 기대했는데, 무엇이 왔나"다.

import Foundation

extension DecodingError {
    /// 값 자체를 노출하지 않고 진단에 필요한 정보만 뽑는다
    var safeDiagnostic: [String: String] {
        switch self {
        case .keyNotFound(let key, let ctx):
            return ["kind": "keyNotFound",
                    "key": key.stringValue,                       // 필드명은 스키마 정보 — 안전
                    "path": ctx.codingPath.map(\.stringValue).joined(separator: ".")]
        case .typeMismatch(let type, let ctx):
            return ["kind": "typeMismatch",
                    "expected": String(describing: type),         // 기대 타입 — 안전
                    "path": ctx.codingPath.map(\.stringValue).joined(separator: ".")]
        case .valueNotFound(let type, let ctx):
            return ["kind": "valueNotFound",
                    "expected": String(describing: type),
                    "path": ctx.codingPath.map(\.stringValue).joined(separator: ".")]
        case .dataCorrupted(let ctx):
            return ["kind": "dataCorrupted",
                    "path": ctx.codingPath.map(\.stringValue).joined(separator: ".")]
                    // ⚠️ ctx.debugDescription 은 값을 포함할 수 있어 넣지 않는다
        @unknown default:
            return ["kind": "unknown"]
        }
    }
}

// "orders[3].shippingAddress.zipCode 에서 String을 기대했는데 실패"
// → 값 없이도 원인 파악에 충분하다. 서버 스키마 변경이 범인이라는 것이 바로 보인다.

버킷화도 간단한 헬퍼로 강제할 수 있다.

/// 정밀도를 낮춰 핑거프린팅 위험을 제거하면서 규모 감각은 유지한다
enum Bucket {
    /// 로그 스케일 버킷 — 1, 2-5, 6-20, 21-100, 101-1000, 1000+
    static func count(_ n: Int) -> String {
        switch n {
        case 0:         return "0"
        case 1:         return "1"
        case 2...5:     return "2-5"
        case 6...20:    return "6-20"
        case 21...100:  return "21-100"
        case 101...1000:return "101-1k"
        default:        return "1k+"
        }
    }

    /// 저장 공간 — 정확한 바이트는 Q3의 핑거프린팅 위험이 있다
    static func diskSpace(_ bytes: Int64) -> String {
        let gb = Double(bytes) / 1_073_741_824
        switch gb {
        case ..<0.5:  return "critical"
        case ..<2:    return "low"
        case ..<10:   return "normal"
        default:      return "ample"
        }
    }
}

실험 · 도구

수집 항목을 검토하는 절차를 정기 회의 안건으로 만들면 실제로 작동한다. 판단 기준을 표로 만들어두면 논의가 빨라진다.

필드 검토표 (분기 1회)

┌──────────────────┬──────────────────┬─────────┬──────────┬────────────┐
│ 필드             │ 답하는 질문      │ 민감도  │ 최근 사용 │ 판정        │
├──────────────────┼──────────────────┼─────────┼──────────┼────────────┤
│ screen           │ 어디서 에러?     │ 낮음    │ 상시     │ 유지        │
│ user.anonymousID │ 같은 사용자 묶기 │ 낮음    │ 상시     │ 유지        │
│ user.email       │ (중복 — id로 대체)│ 높음    │ 없음     │ ❌ 제거     │
│ request.url      │ 어느 API?        │ 중간    │ 상시     │ 정규화 유지 │
│ request.body     │ 파싱 실패 원인   │ 높음    │ 2회/6개월 │ ❌ 진단 필드로 대체 │
│ disk.freeBytes   │ 공간 부족 상관?  │ 높음    │ 1회/6개월 │ ⚠️ 버킷화   │
│ search.query     │ 검색 품질        │ 높음    │ 없음     │ ❌ 메타데이터로 │
└──────────────────┴──────────────────┴─────────┴──────────┴────────────┘

판정 기준:
  - 최근 6개월 사용 이력이 없고 민감도가 높으면 → 제거
  - 사용은 있으나 정밀도가 과한 경우 → 버킷화 또는 일반화
  - 다른 필드로 답할 수 있으면 → 제거

"최근 사용"을 파악하는 방법은 18장 Q7 꼬리질문의 방법과 같다 — 대시보드 참조 여부, 쿼리 로그, 사고 회고에서의 인용 여부.

제거할 때는 단계적으로 한다.

// 1단계: 수집은 유지하되 사용 여부를 관찰한다 (deprecation 경고처럼)
if FeatureFlag.observePIIFieldUsage {
    Logger(subsystem: "com.example.shop", category: "privacy")
        .notice("deprecated 필드 접근: request.body")
}

// 2단계: 샘플링을 1%로 낮춘다 — 누군가 아쉬워하면 되돌린다
// 3단계: 수집 중단
// 4단계: 저장된 과거 데이터의 보관 기간 만료를 기다리거나 삭제 요청

4단계가 중요하다. 수집을 멈춰도 이미 저장된 데이터는 남아 있다. 보관 기간 정책을 확인하고, 필요하면 소급 삭제를 요청한다.

프로젝트 적용

최소화를 실제로 지속하는 방법 다섯.

마지막 항목이 문화적으로 중요하다. 관측 데이터는 "많을수록 안심"이라는 심리가 작동하는 영역이라, 의식적으로 반대 방향의 근거를 쌓지 않으면 계속 늘어난다.

⚠️ 흔한 오해

"혹시 모르니 일단 다 수집하자"가 가장 비싼 습관이다. 아무도 안 보는 데이터는 가치가 0인데 위험과 비용은 계속 발생하고, 그 비용을 사용자가 배터리·요금으로 낸다(18장 Q7). "해시하면 개인정보가 아니다"도 틀렸다 — 후보 집합이 작으면 역산 가능하다. 전화번호를 해시해도 가능한 번호를 전부 해시해 대조하면 원문이 나온다. "익명 ID면 완전히 안전하다"도 과신이다 — 다른 필드와 결합하면 재식별될 수 있다. "수집을 멈추면 문제가 해결된다"도 절반이다 — 이미 저장된 데이터가 남아 있다. 마지막으로 "최소화하면 디버깅이 어려워진다"는 대체로 사실이 아니다 — 질문 기반으로 치환하면 조사 능력은 거의 그대로인 경우가 많다.

🧒 쉽게 이해하기

병원에서 연구용 자료를 모은다고 하자. "혹시 모르니 진료 기록을 통째로 복사해두자"가 편하다. 그런데 그러면 보관할 것도 많고, 새면 큰일 나고, 관리 비용도 크다.

그래서 질문을 바꿔본다. "우리가 알고 싶은 게 정확히 뭐지?"

  • "이 약이 효과가 있나?" → 나이·성별·투약 여부·경과만 있으면 된다. 이름은 필요 없다.
  • "같은 환자의 기록을 이어보고 싶다" → 번호만 있으면 된다. 이름은 필요 없다.
  • "몇 살쯤에 많이 생기나?" → "40대"면 충분하다. 정확한 생년월일은 필요 없다.

여기서 마지막이 핵심이다. "1985년 3월 12일생"과 "40대"는 답할 수 있는 질문이 거의 같다. 그런데 위험은 완전히 다르다 — 앞의 것은 사람을 특정할 수 있고 뒤의 것은 못 한다.

그래서 대부분의 경우 정밀도만 낮춰도 위험은 확 줄고 쓸모는 거의 그대로다. "다 모아야 안심"은 실제로는 안심이 아니라 부담이다.

그리고 마지막으로 물어볼 질문 하나 — "이 자료가 없어서 못 밝힌 일이 실제로 있었나?" 없었다면, 그건 처음부터 필요 없던 자료다.

꼬리 질문

해시로 가리는 것이 안전하지 않은 경우는 언제인가?

가능한 원본의 집합이 작을 때다. 해시는 일방향이지만, 후보를 전부 해시해서 대조하는 무차별 대입(rainbow table)은 막지 못한다.

구체적 사례를 보면 명확하다.

  • 전화번호 — 한국 휴대폰 번호는 약 1억 개 미만이다. 전부 해시하는 데 몇 분이면 된다. 해시해도 사실상 평문이다.
  • 이메일 — 유출된 이메일 목록과 대조하면 상당수가 복원된다.
  • 주민번호 — 생년월일 범위와 뒷자리 규칙을 알면 후보가 크게 줄어든다.
  • 짧은 숫자 ID — 자릿수가 적으면 전수 대조가 쉽다.

반면 충분히 긴 무작위 값(예: 128비트 UUID)은 후보 공간이 천문학적이라 안전하다.

보완 수단이 둘 있다.

(1) 솔트(salt). 값에 비밀 문자열을 붙여 해시하면 미리 만든 테이블이 무용해진다. 다만 솔트가 유출되면 원점이고, 같은 값에 같은 해시가 나와야 상관관계 분석이 되므로 솔트를 값마다 다르게 할 수는 없다.

(2) 아예 다른 값으로 대체. 해시 대신 무관한 무작위 식별자를 생성해 매핑 테이블을 별도 보관하는 방식. 관측 데이터에는 무작위 값만 가고, 원문과의 연결은 접근이 통제된 곳에만 있다. 이게 진짜 가명화다.

실무 결론 — 해시는 "실수로 눈에 띄는 것"을 막는 수준으로 보고, 진짜 보호가 필요하면 대체 식별자를 쓴다. Apple의 mask.hash가 프로세스 한정 해시인 것도 이 한계를 인정한 설계다(18장 Q3).

쉽게 말하면 이름을 암호로 바꿔서 적었다. 그런데 우리 반 학생이 30명뿐이면, 30명 이름을 다 암호로 바꿔보고 대조하면 금방 알아낸다. 후보가 적으면 암호가 소용없다. 정말 가리려면 암호가 아니라 아예 다른 번호를 붙이고, 그 번호와 이름을 잇는 명부를 따로 잠가둬야 한다.
사용자 문의 대응에 필요한 최소 정보는 무엇인가?

필요한 것은 "이 사람의 이벤트를 찾는 방법"뿐이고, 개인정보는 필요 없다.

실무에서 잘 작동하는 방식은 앱 내 지원 코드다.

  • 앱이 설치 시점에 익명 식별자를 생성해 저장한다.
  • 설정 화면에 짧은 지원 코드(익명 ID의 앞 8자리 등)를 표시한다.
  • 사용자가 문의할 때 그 코드를 알려준다.
  • 지원팀이 코드로 이벤트를 조회한다.

이 방식의 장점이 여럿이다.

(1) 관측 데이터에 개인정보가 전혀 없다. 이메일도 이름도 필요 없다.

(2) 사용자가 통제한다. 앱을 지우면 식별자가 사라지므로, 사용자가 원하면 연결을 끊을 수 있다.

(3) 지원 시스템과 관측 시스템이 분리된다. 문의 내용(이메일 등 개인정보 포함)은 지원 시스템에, 기술 데이터는 관측 시스템에 있고, 코드로만 연결된다.

주의할 점은 재설치 시 연결이 끊긴다는 것이다. "앱을 지웠다 깔았는데 문제가 계속된다"는 문의에서는 이전 데이터를 못 찾는다. 이건 의도된 절충이다 — 재설치를 넘어 추적하려면 기기 식별이 필요하고, 그건 Q3의 핑거프린팅 영역이다.

계정 기반 앱이라면 계정 ID를 익명 식별자로 쓸 수 있다. 계정 ID는 내부 값이라 그 자체로는 개인을 특정하지 않고, 지원팀만 계정 ID ↔ 사용자 매핑에 접근하면 된다.

쉽게 말하면 병원에서 "접수번호 1234번"만 알면 그 사람 기록을 찾을 수 있다. 이름을 기록마다 적어둘 필요가 없다. 이름과 번호를 잇는 명부는 접수처에만 있으면 되고, 진료 기록에는 번호만 있으면 된다.
보관 기간을 짧게 하는 것이 왜 강력한 위험 감소 수단인가?

세 가지 이유가 있다.

(1) 노출 창(window)이 줄어든다. 데이터가 존재하는 기간이 짧을수록 유출·오용·잘못된 접근에 노출될 시간이 줄어든다. 90일 보관과 3년 보관은 위험이 12배 차이나는 셈이다.

(2) 삭제 요청 대응이 쉬워진다. 사용자가 데이터 삭제를 요청했을 때, 보관 기간이 짧으면 이미 대부분 사라져 있다. 긴 보관은 여러 백업·아카이브·파생 데이터셋까지 추적해야 해서 완전 삭제가 실질적으로 어렵다.

(3) 실제 사용 패턴과 맞는다. 관측 데이터는 대부분 최근 것만 쓴다. 크래시 조사는 며칠 안에, 성능 회귀 비교는 몇 개 릴리스 안에 이뤄진다. 1년 전 개별 이벤트를 여는 일은 거의 없다.

다만 장기 추세는 필요하다. 이 요구는 집계된 지표로 충족할 수 있다 — 개별 이벤트는 90일 보관하되, 일 단위 집계(크래시율, 행률, p95)는 몇 년을 남긴다. 집계에는 개인 데이터가 없으므로 위험이 거의 없다.

구성 예시는 이렇다.

  • 원본 이벤트 — 30~90일
  • 민감도 높은 필드 — 더 짧게 (가능하다면)
  • 일 단위 집계 지표 — 2년 이상
  • 이슈 메타데이터(제목·스택·첫 발생일) — 장기

이렇게 나누면 위험은 짧은 창에만, 장기 분석 능력은 그대로 유지된다.

쉽게 말하면 CCTV 영상을 3년 보관하면 위험하지만, "몇 월에 사고가 몇 건"이라는 요약 통계는 10년을 남겨도 아무 문제가 없다. 개별 영상은 한 달만 두고, 통계만 오래 남기면 조사도 되고 위험도 낮다.

출처 · 참고자료