보안 질문은 대부분 "무엇을, 누구로부터, 어느 시점에 지키는가"로 환원된다. 이 챕터는 용어 혼동(인코딩·해싱·암호화)에서 시작해 저장 위치 선택, 토큰 수명, 전송 보안의 경계, 클라이언트 시크릿의 근본적 한계, 로그·크래시 유출까지 이어지는 여섯 개의 핵심 판단을 다룬다.
Q1. Encoding, Hashing, Encryption은 무엇이 다른가?
세 가지를 가르는 축은 "되돌리는 데 무엇이 필요한가"다. Encoding(Base64·URL 인코딩)은 데이터를 다른 표현 형식으로 바꾸는 것으로, 목적은 호환·전송이지 보안이 아니며 공개된 규칙만 알면 누구나 원본으로 복원한다. Hashing은 임의 길이 입력을 고정 길이 출력으로 바꾸는 단방향 함수라 원리적으로 되돌릴 수 없고, 무결성 검증·지문·비밀번호 검증에 쓴다. Encryption은 키로 잠그고 키로만 여는 양방향 변환으로, 기밀성을 지킨다. "Base64로 인코딩했으니 안전하다"는 흔한 착각이 이 구분을 놓친 결과다.
CS 원리
Encoding은 규칙만 알면 원본으로 완전히 되돌릴 수 있는 가역(invertible) 매핑이다(엄밀히는 모든 문자열로의 전사가 아니라, 원본 바이트열에 대해 역함수가 존재하는 단사 매핑에 가깝다). Base64는 3바이트(24비트)를 6비트 단위 4문자로 옮겨 바이너리를 텍스트 채널로 깨짐 없이 흘려보낸다. 스킴이 공개돼 있으니 비밀이 없고 보안을 제공하지 않는다.
Hashing은 결정적 단방향 함수다. 암호학적 해시는 세 가지 저항성을 요구한다 — 역상 저항성(출력으로 입력 못 찾음), 제2역상 저항성(같은 해시 나오는 다른 입력 못 만듦), 충돌 저항성(임의의 두 입력 충돌 못 찾음). 입력 1비트만 바뀌어도 출력이 절반쯤 뒤집히는 눈사태 효과(avalanche)가 특징이다. 여기서 중요한 실무 함정: 비밀번호에는 "빠른" 해시(SHA-256)가 오히려 위험하다. GPU로 초당 수십억 번 시도가 가능하기 때문에, 비밀번호는 salt를 섞고 일부러 느린 KDF(Argon2, scrypt, bcrypt, PBKDF2)를 써야 한다.
Encryption은 키 기반 가역 변환이다. 같은 키로 잠그고 여는 대칭키(AES, ChaCha20)와, 공개키로 잠그고 개인키로 여는 비대칭키(RSA, 타원곡선 ECC)로 갈린다. 기밀성을 지키지만 그 안전은 키 관리로 옮겨간다 — 키가 새면 끝이다.
iOS에서는
Encoding은 Foundation으로 처리한다 — Data.base64EncodedString(), Data(base64Encoded:), UTF-8 변환은 String(data:encoding:), URL 이스케이프는 addingPercentEncoding(withAllowedCharacters:). 해싱·암호화는 CryptoKit이 표준이다: 해시는 SHA256.hash(data:), 키가 있는 무결성은 HMAC, 대칭 암호화는 AES.GCM.seal/open과 ChaChaPoly, 비대칭은 P256·Curve25519. 특히 개인키를 Secure Enclave에서 생성하면 키가 앱 메모리로 절대 빠져나오지 않는다. CryptoKit에는 Argon2/bcrypt가 없으므로, 비밀번호 KDF는 CommonCrypto의 CCKeyDerivationPBKDF(PBKDF2)를 쓰거나 별도 라이브러리를 붙인다.
실험 · 도구
세 변환을 한 파일에서 돌려보면 성질이 손에 잡힌다. Base64는 왕복이 되고, SHA-256은 입력 1글자만 바꿔도 출력이 완전히 달라지며(눈사태), AES-GCM은 같은 키로만 열린다는 것을 확인한다.
import CryptoKit
let raw = Data("hello".utf8)
// 1) Encoding — 보안 아님, 규칙만 알면 복원
let b64 = raw.base64EncodedString() // "aGVsbG8="
let back = Data(base64Encoded: b64) // 원본 복원, 키 불필요
// 2) Hashing — 단방향, 무결성/지문
let digest = SHA256.hash(data: raw) // 256비트(32바이트) 고정
// 비밀번호엔 이대로 쓰지 말 것: salt + 느린 KDF 필요
// 3) Encryption — 키로 잠그고 키로 연다
let key = SymmetricKey(size: .bits256)
let sealed = try AES.GCM.seal(raw, using: key)
let plain = try AES.GCM.open(sealed, using: key) // 같은 키로만 복호화프로젝트 적용
- 비밀번호를 SHA-256 한 방으로 저장하지 말 것 — salt + 느린 KDF가 원칙이다.
- Base64/URL 인코딩을 "암호화"라고 부르지도, 그렇게 취급하지도 말 것.
- 무결성 검증이 필요하면 순수 해시가 아니라 HMAC을 써라. 키 없는 해시는 공격자가 데이터와 해시를 함께 바꿔치기할 수 있다.
- AES-GCM 사용 시 같은 키로 nonce를 재사용하면 기밀성만이 아니라 무결성까지 무너진다 — 두 평문의 XOR이 새는 것을 넘어 인증 키(GHASH의 H)가 복구돼 임의 메시지 위조가 가능해진다. 매번 새 nonce를 쓰거나 CryptoKit 기본 생성에 맡겨라.
"해시는 암호화의 한 종류다"라는 말은 틀렸다. 해시는 복호화 자체가 불가능한 단방향 함수이고 목적도 다르다(무결성·지문). 또 "Base64는 데이터를 안 보이게 해준다"는 것도 착각이다 — 인코딩은 표현 형식만 바꿀 뿐 누구나 즉시 디코딩한다.
꼬리 질문
- MD5·SHA-1은 어떤 성질이 깨졌고, 어떤 용도라면 그래도 써도 되는가(예: 비암호학적 체크섬)?
- salt와 pepper는 각각 무엇을 막는가, 왜 pepper는 DB 밖에 둬야 하는가?
- HMAC이 단순히
hash(key + message)보다 나은 이유는? (length-extension attack)
Q2. 민감 정보별로 Keychain, 파일, Database 중 어디에 저장해야 하는가?
기준은 민감도 · 크기 · 접근 패턴이다. 토큰·비밀번호·암호키 같은 작은 비밀은 Keychain에 넣는다 — 하드웨어 지원 암호화와 세밀한 접근 제어(잠금 상태·생체인증·기기 한정)를 붙일 수 있다. 큰 민감 데이터(문서·이미지)는 파일 시스템에 두되 Data Protection 클래스로 at-rest 암호화를 건다. 대량의 구조화 데이터는 DB(Core Data/SQLite)에 두는데, DB는 기본이 무암호화이므로 파일 보호 클래스를 걸거나 SQLCipher 같은 암호화 계층을 얹어야 한다. UserDefaults에는 민감 정보를 절대 넣지 않는다 — 평문 plist다.
CS 원리
at-rest 보안의 핵심은 키 계층(key hierarchy)이다. 데이터를 암호화하는 키를 다시 상위 키가 감싸고, 그 최상위는 기기에 붙박인 하드웨어 키와 사용자 인증(passcode)에서 파생된다. 그래서 "기기가 잠겨 있을 때는 못 읽음" 같은 접근 제어가 성립한다. 저장 위치 결정은 결국 "이 데이터가 어떤 위협(기기 분실·백업 유출·다른 앱/프로세스·탈옥)에서, 언제 접근 가능해야 하는가"를 각 저장소가 제공하는 통제와 맞추는 작업이다.
iOS에서는
iOS의 파일·Keychain은 위 키 계층 위에 얹혀 있다. Keychain 항목은 접근성 속성(kSecAttrAccessible…)으로 "언제 읽을 수 있는지"를, kSecAccessControl로 생체인증 게이트를 건다. 파일은 FileProtectionType으로 보호 등급을 정한다 — complete(잠기면 못 읽음), completeUnlessOpen, completeUntilFirstUserAuthentication(부팅 후 첫 잠금해제 이후 접근, iOS의 사실상 기본값), none. Core Data/SQLite는 스토어 파일에 이 보호 클래스를 지정할 수 있지만 항목 단위 제어는 어렵다. UserDefaults는 앱 컨테이너의 평문 plist라 접근 제어가 없다.
| 데이터 유형 | Keychain | 파일 + 보호 | DB | UserDefaults |
|---|---|---|---|---|
| 토큰 · 키 · 비밀번호 | 권장 | 가능(직접 암호화) | 비권장 | 금지 |
| 대용량 민감 파일 | 부적합(크기) | 권장(complete) | 가능(BLOB) | 금지 |
| 대량 구조화 데이터 | 부적합 | 가능 | 권장(+보호클래스) | 금지 |
| 비민감 설정 · 플래그 | 과함 | 가능 | 가능 | 적합 |
프로젝트 적용
- 자격증명은 Keychain으로 몰되, 접근성 속성을 최소 권한으로 골라라 — 백그라운드 갱신이 필요 없으면
WhenUnlockedThisDeviceOnly. - 큰 민감 파일은
FileProtectionType.complete로. 다만 백그라운드에서 열어야 하면 잠금 중 접근 불가로 실패할 수 있으니 접근 시점을 설계에 반영하라. - Core Data/SQLite는 "쓰면 자동으로 암호화된다"는 착각을 버리고, 스토어 파일 보호 클래스 또는 SQLCipher를 명시적으로 적용하라.
- Keychain 항목은 앱을 삭제해도 남을 수 있다 — 재설치 첫 실행 시 잔존 자격증명 처리 로직을 넣어라.
"Keychain에 넣으면 탈옥 기기에서도 절대 못 빼간다"는 과신은 위험하다. 기기가 잠금해제된 상태이고 탈옥돼 있으면, 접근성 클래스에 따라 항목이 읽힐 수 있다. 그래서 접근성 속성과 생체인증 게이트를 목적에 맞게 좁히는 것이 핵심이지, "Keychain = 무적"이 아니다.
꼬리 질문
AfterFirstUnlock과WhenUnlocked는 백그라운드 네트워크 관점에서 어떻게 갈리는가?- App Group으로 Keychain을 여러 앱/확장과 공유하려면 무엇이 필요하고 위험은?
- 기기 백업(iCloud/암호화 백업)에 어떤 Keychain 항목이 포함되고 어떤 것은 빠지는가?
Q3. Access token과 Refresh token은 어떻게 저장하고 갱신해야 하는가?
둘 다 소지자에게 권한을 주는 bearer 자격증명이므로 Keychain에 저장한다. Access token은 수명을 짧게(보통 분 단위) 잡아 유출 시 피해 창을 줄이고, 실사용 시에는 메모리에 캐시해 두고 Keychain을 백업으로 둔다. Refresh token은 수명이 길고 더 민감하므로 기기 한정으로 저장하며, access token이 만료(401)되면 이걸로 새 access token을 발급받는다. 갱신 때 refresh token도 함께 교체(rotation)하고 서버가 재사용을 탐지하게 한다. 여러 요청이 동시에 401을 받을 때는 refresh를 한 번만 실행하는 single-flight로 묶어야 한다.
CS 원리
이 구조는 OAuth 2.0의 핵심 트레이드오프다. Bearer token은 "가진 사람이 곧 권한자"이므로 유출이 치명적이다. 그래서 access token을 짧게 만들어 노출 시간을 줄이되, 매번 로그인시키지 않으려고 오래 사는 refresh token으로 조용히 재발급한다. 여기서 refresh token은 더 오래 살고 더 강력하므로 표적이 된다. OAuth 2.0 Security BCP는 refresh token rotation과 재사용 탐지를 권고한다 — 갱신할 때마다 새 refresh를 발급하고 이전 것을 무효화하며, 폐기된 refresh가 다시 등장하면 탈취로 간주해 계열 전체를 무효화한다.
iOS에서는
토큰은 Keychain에 저장한다. 백그라운드 갱신이 필요한 access token은 kSecAttrAccessibleAfterFirstUnlock을, 더 민감한 refresh token은 …ThisDeviceOnly 계열을 고려한다(백업·타 기기 이전 차단). 네트워크 계층에서는 인터셉터/어댑터 패턴으로 401을 잡아 갱신 후 재시도한다. Swift Concurrency의 actor는 이 동시성 문제를 깔끔하게 푼다 — 진행 중인 refresh Task를 붙들고 있다가, 동시에 들어온 호출들이 모두 그 하나의 결과에 합류하게 만든다.
actor TokenProvider {
private var cached: String?
private var inflight: Task<String, Error>?
// 유효한 access token 반환 — 동시 호출은 하나의 refresh에 합류한다
func validToken() async throws -> String {
if let t = cached { return t }
if let task = inflight { return try await task.value } // 진행 중 갱신에 합류
let task = Task { try await self.refresh() }
inflight = task
defer { inflight = nil }
let token = try await task.value
cached = token
return token
}
private func refresh() async throws -> String {
// Keychain에서 refresh token 로드 → 서버에 갱신 요청
// 새 refresh token(rotation)도 Keychain에 덮어쓰기
// 실패(무효화)면 로그아웃 처리
...
}
}프로젝트 적용
- access token은 메모리 캐시를 1차 소스로, Keychain을 백업으로. 매 요청마다 Keychain을 읽지 마라(성능·마모).
- refresh는 반드시 single-flight로 묶어라 — 앱 복귀 순간 수십 개 요청이 동시에 401을 받는 thundering herd를 막는다.
- refresh 실패(만료·무효)는 곧 세션 종료다 → 토큰을 지우고 로그인 화면으로. 무한 재시도 루프를 만들지 마라.
- 토큰을 URL 쿼리·로그·UserDefaults에 절대 남기지 마라(Q4·Q6 참고).
"refresh token은 만료가 길거나 없으니 한 번 저장하면 끝"이라는 생각은 위험하다. 오래 살수록 표적 가치가 커지므로, rotation과 재사용 탐지가 없으면 한 번의 유출이 영구 세션 탈취로 이어진다. 반대로 "access token을 길게 잡으면 갱신 로직이 필요 없어 편하다"도 함정이다 — 유출 시 악용 가능한 시간 창이 그만큼 넓어진다.
꼬리 질문
- refresh token reuse detection은 서버에서 어떻게 구현하며, 오탐(정상 동시성)과 어떻게 구분하는가?
- 클라이언트가 로그아웃할 때 서버 측 token revoke가 왜 필요한가? 안 하면 무엇이 남는가?
- 모바일 앱에서 PKCE는 인가 코드 흐름의 어느 지점을, 어떤 공격으로부터 보호하는가?
Q4. HTTPS는 무엇을 보호하고 무엇은 보호하지 못하는가?
HTTPS(TLS)는 전송 중(in-transit)의 세 가지를 지킨다 — 기밀성(중간자가 내용을 못 읽음), 무결성(변조를 탐지), 서버 인증(인증서로 접속 상대가 진짜인지 확인). 즉 클라이언트와 서버 사이 "회선"을 보호한다. 하지만 양 끝(엔드포인트)은 지키지 못한다 — 서버가 데이터를 어떻게 저장·처리하는지(at-rest·로직), 클라이언트 기기가 안전한지는 TLS의 소관이 아니다. 또 메타데이터(접속한 호스트명 SNI, IP, 패킷 크기·타이밍)는 대체로 노출된다. "HTTPS니까 다 안전하다"는 회선과 엔드포인트를 혼동한 것이다.
CS 원리
TLS 1.3 핸드셰이크는 인증서 체인으로 서버 신원을 검증하고(신뢰 앵커는 OS/브라우저의 루트 CA), 임시(ephemeral) 키 교환으로 세션 키를 만든다. 이 임시성이 forward secrecy를 준다 — 나중에 서버 개인키가 털려도 과거 트래픽은 복호화되지 않는다. 이후 데이터는 AEAD로 암호화되어 기밀성과 무결성을 동시에 얻는다. 그런데 TLS가 명시적으로 안 지키는 것들이 있다: 접속 대상 호스트명(SNI)은 ECH(Encrypted Client Hello) 이전에는 ClientHello에 평문으로 실렸고, DNS 조회도 DoH/DoT가 아니면 평문이며, 패킷의 크기·타이밍 같은 트래픽 패턴은 암호화해도 관측된다. 무엇보다 TLS는 "회선"만 다루므로 회선 양 끝의 안전은 별개 문제다.
iOS에서는
iOS는 App Transport Security(ATS)로 기본을 강제한다 — TLS 1.2 이상, forward secrecy, 유효한 인증서. 예외는 Info.plist에 명시해야 하고 리뷰 대상이 된다. URLSession은 시스템 신뢰 저장소를 쓰므로, 사용자가 프로파일로 설치한 커스텀 CA도 신뢰될 수 있다는 점이 함정이다. 이를 막으려면 URLSessionDelegate의 urlSession(_:didReceive:completionHandler:)에서 SecTrust를 검사해 인증서/공개키 pinning을 건다 — pinning은 정상적으로 신뢰되는 rogue CA(기업 프록시·중간자)까지 차단한다. 클라이언트 인증서로 서버가 클라이언트도 검증하는 mTLS, SNI를 감추는 ECH도 최신 iOS에서 지원된다.
프로젝트 적용
- ATS 예외는 최소화하라. 예외 하나가 그 도메인의 다운그레이드 공격 창을 연다.
- 고민감 앱은 public key pinning을 고려하되, 인증서 교체(rotation)로 앱이 벽돌이 되지 않게 backup pin과 만료 계획을 함께 설계하라.
- "HTTPS로 보냈으니 서버 입력 검증은 안 해도 된다"는 착각을 버려라 — TLS는 회선이지 서버 로직이 아니다.
- 토큰·PII를 URL 쿼리스트링에 넣지 마라. TLS로 암호화되긴 하지만 서버 접근 로그·Referer·브라우저 히스토리·프록시에 평문으로 남는다.
"HTTPS면 MITM(중간자)은 절대 불가능하다"는 말은 조건부로만 맞다. 사용자가 신뢰하는 CA(기업 MDM 프로파일 등)가 기기에 깔려 있고 앱이 pinning을 하지 않으면, 그 CA로 서명된 가짜 인증서로 트래픽이 합법적으로 가로채질 수 있다. TLS는 "신뢰 앵커가 정직하다"는 가정 위에서 동작한다.
꼬리 질문
- 인증서 pinning의 최대 리스크(서버 인증서 교체 시 앱 마비)와 이를 줄이는 방법(공개키 pinning, backup pin)은?
- SNI는 관측자에게 정확히 무엇을 알려주며, ECH는 그걸 어떻게 가리는가?
- mTLS는 일반 HTTPS 대비 어떤 위협을 추가로 막는가, 모바일에서 클라이언트 인증서는 어디에 보관하나?
Q5. 앱 Binary에 Secret이나 API key를 안전하게 숨길 수 있는가?
근본적으로 불가능하다. 앱 바이너리는 공격자의 기기에서 실행되므로, 문자열 추출·디스어셈블·런타임 후킹으로 임베드된 어떤 시크릿도 결국 복구된다. 난독화·XOR·키 분할은 리버싱 비용을 올릴 뿐 결정적 방어가 아니다("security by obscurity"). 진짜 해법은 시크릿을 클라이언트에 두지 않는 것이다 — 제3자 API 키는 백엔드 프록시 뒤로 옮기고, 클라이언트에는 사용자별 단명 토큰만 준다. 요청의 정당성이 필요하면 시크릿을 숨기려 하지 말고 App Attest로 정품 앱·정품 기기임을 서버가 검증하게 한다.
CS 원리
공격자가 바이너리와 실행 환경을 모두 통제하는 클라이언트 사이드에서는, 정적 분석(디스어셈블·문자열 스캔)과 동적 분석(메모리 덤프·API 후킹)으로 실행에 필요한 모든 값이 언젠가 평문으로 드러난다. 암호가 아무리 좋아도, 그 암호를 푸는 키가 같은 바이너리 안에 있으면 함께 추출된다. 이는 Kerckhoffs 원리("시스템 안전은 키의 비밀성에만 의존해야 하고 설계의 비밀성에 의존하면 안 된다")를 뒤집은 상황 — 여기서는 설계·데이터가 모두 공격자 손에 있으니 "숨김"에 기댈 수 없다.
iOS에서는
도구가 이미 성숙해 있다. strings로 평문 문자열을, class-dump로 Objective-C 메타데이터를, Hopper·Ghidra·IDA로 디스어셈블을, Frida·Cycript로 런타임 후킹을 한다. App Store 바이너리는 FairPlay DRM으로 암호화되지만, 탈옥 기기에서 프로세스가 메모리에 로드된 시점의 복호화된 이미지를 덤프하면 그대로 풀린다. Info.plist·asset catalog·xcconfig에 넣은 값도 결국 바이너리에 임베드되어 평문에 가깝다. 대신 Apple은 방향을 바꾼 해법을 준다 — App Attest는 이 앱이 Apple이 서명한 정품이며 정품 기기에서 돈다는 증명을 서버에 제출하게 하고, DeviceCheck는 기기별 상태 비트를 다룬다. 시크릿을 숨기는 게 아니라 "요청 자체를 인증"하는 접근이다.
프로젝트 적용
- 결제·지도·푸시 등 제3자 API 키는 클라이언트에 넣지 말고 백엔드 프록시를 세워 서버가 대신 호출하게 하라.
- 백엔드를 App Attest/DeviceCheck로 보호해, 리버싱한 클라이언트나 봇의 요청을 걸러라.
- 난독화는 defense-in-depth의 한 겹으로만 취급하라 — 시간은 벌지만 유일 방어로 삼으면 안 된다.
- 키를 소스·plist·git 히스토리에 커밋하지 말고 secret 스캐닝(예: git hook, CI)으로 회귀를 막아라. 이미 커밋된 키는 rotate하라(히스토리에 영구히 남는다).
"난독화하면 안전하다" 혹은 "환경변수·xcconfig에 넣으면 코드에 안 보인다"는 착각이 흔하다. 난독화는 추출 시간을 늘릴 뿐 결국 뚫리고, 빌드 설정에 넣은 값도 컴파일 시 바이너리에 박혀 strings 한 줄에 노출될 수 있다. "안 보이게"와 "못 가져가게"는 전혀 다른 이야기다.
꼬리 질문
- App Attest와 DeviceCheck는 각각 무엇을 증명하며, 어떤 상황에 어느 쪽을 쓰는가?
- 불가피하게 대칭키를 임베드해야 한다면 피해를 어떻게 최소화하는가(키 회전·서버 발급·용도 제한)?
- white-box cryptography나 키 분할(split key)이 주는 보호의 실제 한계는 어디까지인가?
Q6. 민감 정보가 Log와 Crash report에 남지 않게 하려면 어떻게 하는가?
로그와 크래시 리포트는 시크릿이 새는 가장 흔한 뒷문이다. 원칙은 기본값을 redaction(가림)으로 두는 것이다. iOS의 os.Logger는 문자열 보간의 동적 값을 릴리스에서 기본적으로 <private>로 가려주니 이를 활용하고, 토큰·PII·요청 바디를 print/NSLog로 찍지 않는다. 크래시 리포터(Sentry/Crashlytics)는 자동 PII 수집을 끄고 beforeSend에서 헤더·breadcrumb·커스텀 키를 스크러빙한다. 그리고 코드 리뷰·정적 스캔·린트 룰로 회귀를 막아 사람의 실수에 의존하지 않게 한다.
CS 원리
민감 데이터는 로그·텔레메트리·크래시·백업 같은 부수 경로로 계속 확산된다(data spillage). 방어 원칙은 "최소 노출"과 "발생 지점에서 가림"이다 — 어딘가에 남긴 뒤 나중에 지우는 게 아니라, 애초에 남기지 않거나 남기는 순간 마스킹한다. 로그는 개발·운영에서 폭넓게 열람·수집·장기 보관되므로, 한 줄의 토큰이 여러 사람과 시스템으로 퍼진다는 전제로 설계해야 한다.
iOS에서는
os.Logger의 프라이버시 모델을 정확히 알아야 한다. 문자열 보간에서 동적 문자열·객체는 기본 private(릴리스에서 <private>로 가려짐)지만, 숫자·불리언 같은 스칼라는 기본 public이라 그대로 찍힌다. 그래서 민감한 값은 명시적으로 privacy: .private를, 상관관계 추적만 필요하면 .private(mask: .hash)를 지정한다. 반면 NSLog는 이런 프라이버시 개념이 없이 통합 로깅(os_log)으로 브리지되어 릴리스 디바이스의 시스템 로그에 영속 저장되고 sysdiagnose/Console로 회수되며, print는 stdout으로 나가 디버거가 붙지 않은 릴리스 디바이스에서는 시스템 로그에 남지 않는다(대신 개발 중 Xcode 콘솔에 그대로 노출되고 privacy 가림도 없다). 크래시의 경우, Apple 심볼리케이션 리포트에 앱 문자열이 직접 담기진 않지만 스레드 상태·레지스터·힙 조각에 값이 남을 수 있고, 제3자 SDK는 breadcrumb·네트워크 캡처·커스텀 키로 훨씬 많은 것을 수집한다 — beforeSend와 PII 옵트아웃이 필수다. 개발 중 프록시(Charles/Proxyman) 로깅은 반드시 개발 빌드에 한정한다.
import os
let log = Logger(subsystem: "co.finda.app", category: "auth")
// 동적 문자열은 릴리스에서 기본 <private>로 가려진다
log.debug("access token = \(token)")
// 스칼라(숫자/불리언)는 기본 public — 민감하면 명시적으로 가릴 것
log.info("status = \(statusCode, privacy: .private)")
// 상관관계 추적은 하되 원문은 숨기기
log.notice("user = \(userId, privacy: .private(mask: .hash))")
// 절대 이렇게 찍지 말 것 — NSLog는 통합 로깅으로 릴리스 시스템 로그에 남고,
// print는 stdout(개발 중 Xcode 콘솔)에 노출된다. 둘 다 privacy 가림이 없다
print("token=\(token)") // ✗SentrySDK.start { options in
options.sendDefaultPii = false // 자동 PII 수집 차단
options.beforeSend = { event in
event.request?.headers?["Authorization"] = nil // 토큰 헤더 제거
// breadcrumb/extra에서 민감 필드 마스킹
return event
}
}프로젝트 적용
- 토큰·비밀번호·PII·요청 바디를
print/NSLog로 찍지 말고,Logger+ 명시적privacy로 통일하라. - 상세 네트워크 로깅은
#if DEBUG로 감싸 릴리스 빌드에서 컴파일 자체를 배제하라 — 런타임 플래그로만 끄면 코드가 바이너리에 남는다. - 크래시 리포터는 PII 자동 수집을 끄고
beforeSend에서 민감 필드를 스크러빙하라. breadcrumb/커스텀 키에 원문 시크릿을 넣지 마라. - 로그 문자열에 대한 린트 룰이나 CI 스캐너로 "토큰/이메일/카드번호 패턴" 로깅을 자동 차단해 회귀를 막아라.
"os_log/Logger는 알아서 다 가려준다"는 말은 부분적으로만 맞다. 동적 문자열은 기본 private지만 숫자 같은 스칼라는 기본 public이고, privacy: .public을 명시하면 무엇이든 노출된다. 또 "릴리스 빌드면 로그가 안 남는다"는 절반만 맞다 — NSLog는 통합 로깅으로 릴리스 시스템 로그에 영속 저장되어 sysdiagnose/Console로 회수되고, print는 stdout으로 나가 릴리스 디바이스에선 시스템 로그에 남지 않지만 개발 중 Xcode 콘솔에 그대로 노출되고 privacy 가림이 없어, 둘 다 시크릿에 써선 안 된다.
꼬리 질문
Logger보간에서 기본private인 타입과 기본public인 타입은 각각 무엇이고, 왜 그렇게 나뉘었는가?- 크래시 리포트의 힙·레지스터에 남는 문자열 유출을 줄이려면 어떤 코딩 습관이 필요한가(민감 값의 조기 zeroing 등)?
- PII를 원문 대신 해시로 로깅하는 것이 정당화되는 경우와, 그럼에도 위험한 경우(저엔트로피 값의 역추적)는?