보안 질문은 대부분 "무엇을, 누구로부터, 어느 시점에 지키는가"로 환원된다. 이 챕터는 용어 혼동(인코딩·해싱·암호화)에서 시작해 저장 위치 선택, 토큰 수명, 전송 보안의 경계, 클라이언트 시크릿의 근본적 한계, 로그·크래시 유출까지 이어지는 여섯 개의 핵심 판단을 다룬다.
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가 원칙이다.
import Foundation
import CryptoKit
// SHA-256 한 방은 초당 수십억 번 시도할 수 있어 무차별 대입에 무력하다.
// 비밀번호 저장은 '느린 것'이 기능이다.
// ❌ 절대 하지 말 것
func hashBad(_ password: String) -> Data {
Data(SHA256.hash(data: Data(password.utf8)))
}
// ✅ 비밀번호 검증은 원칙적으로 서버 몫이다 (bcrypt / scrypt / Argon2)
// 클라이언트가 직접 해야 한다면 PBKDF2를 충분한 반복 횟수로 쓴다
import CommonCrypto
func derive(password: String, salt: Data, rounds: UInt32 = 600_000 // PBKDF2-HMAC-SHA256 기준 OWASP 권고치(SHA-512 PRF면 210,000)) -> Data {
var out = Data(count: 32)
let passwordData = Data(password.utf8)
// ⚠️ 반환값(CCStatus)을 버리면 실패 시 out이 0으로 채워진 채 '파생 키'로 쓰인다
let status = out.withUnsafeMutableBytes { outBuf in
salt.withUnsafeBytes { saltBuf in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
password, passwordData.count,
saltBuf.bindMemory(to: UInt8.self).baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
rounds,
outBuf.bindMemory(to: UInt8.self).baseAddress, 32)
}
}
guard status == kCCSuccess else { fatalError("PBKDF2 실패: \(status)") }
return out
}
// ✅ salt는 사용자마다 새로 만든다 (레인보우 테이블 방어)
func newSalt() -> Data {
var bytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes)
}
// ✅ 비교는 타이밍 공격에 안전하게 (조기 종료가 없는 비교)
func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var diff: UInt8 = 0
for (x, y) in zip(a, b) { diff |= x ^ y }
return diff == 0
}
// ⚠️ 반복 횟수는 기기 성능에 맞춰 조정한다 — 로그인에 0.5~1초 정도가 기준이다② Base64/URL 인코딩을 "암호화"라고 부르지도, 그렇게 취급하지도 말 것.
import Foundation
// 인코딩은 '표현 형식 변환'이다. 키가 없고, 누구나 즉시 되돌린다.
func demonstrateEncoding() {
let secret = "Bearer eyJhbGciOiJIUzI1NiJ9"
let encoded = Data(secret.utf8).base64EncodedString()
print(encoded) // "QmVhcmVyIGV5SmhiR2NpT2lKSVV6STFOaUo5"
// ✅ 누구나 한 줄로 복원한다 — 키도, 권한도 필요 없다
let decoded = String(data: Data(base64Encoded: encoded)!, encoding: .utf8)
print(decoded!) // "Bearer eyJhbGciOiJIUzI1NiJ9"
}
// ❌ "인코딩해서 저장했으니 안전하다"는 착각
func storeBad(_ token: String) {
let obscured = Data(token.utf8).base64EncodedString()
UserDefaults.standard.set(obscured, forKey: "token") // 💥 평문과 다를 바 없다
}
// ✅ 기밀성이 필요하면 암호화(키 필요) 또는 Keychain(OS가 보호)
import CryptoKit
func encryptProperly(_ token: String, using key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(Data(token.utf8), using: key)
return sealed.combined! // nonce + 암호문 + 태그
}
// ⚠️ 같은 오해가 반복되는 다른 사례들
// · URL 인코딩 → 표현 형식일 뿐
// · JWT의 payload → Base64URL 인코딩이라 그냥 읽힌다 (서명은 위조만 막는다)
// · hex 문자열 → 마찬가지
//
// ✅ JWT payload에 민감 정보를 넣지 않는다 — 누구나 디코딩할 수 있다
func inspectJWT(_ jwt: String) -> String? {
let parts = jwt.split(separator: ".")
guard parts.count == 3 else { return nil }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while payload.count % 4 != 0 { payload += "=" }
return Data(base64Encoded: payload).flatMap { String(data: $0, encoding: .utf8) }
}③ 무결성 검증이 필요하면 순수 해시가 아니라 HMAC을 써라. 키 없는 해시는 공격자가 데이터와 해시를 함께 바꿔치기할 수 있다.
import Foundation
import CryptoKit
// 키 없는 해시는 "데이터와 해시를 함께" 바꿔치기하면 검증을 통과한다.
// 검증자만 아는 키가 들어가야 위조를 막을 수 있다.
// ❌ 순수 해시 — 공격자가 payload와 digest를 동시에 교체하면 그만이다
struct BadEnvelope {
let payload: Data
let digest: Data // SHA256(payload)
var isValid: Bool { Data(SHA256.hash(data: payload)) == digest }
}
// ✅ HMAC — 키를 모르면 유효한 태그를 만들 수 없다
struct Envelope {
let payload: Data
let tag: Data
init(payload: Data, key: SymmetricKey) {
self.payload = payload
self.tag = Data(HMAC<SHA256>.authenticationCode(for: payload, using: key))
}
func isValid(key: SymmetricKey) -> Bool {
// ✅ 이 API는 상수 시간 비교를 내부에서 해 준다
HMAC<SHA256>.isValidAuthenticationCode(
tag, authenticating: payload, using: key)
}
}
// ✅ 웹훅 서명 검증이 대표적인 사용처
func verifyWebhook(body: Data, signatureHeader: String, secret: SymmetricKey) -> Bool {
guard let provided = Data(base64Encoded: signatureHeader) else { return false }
return HMAC<SHA256>.isValidAuthenticationCode(
provided, authenticating: body, using: secret)
}
// ✅ 순수 해시가 맞는 용도: 무결성이 아니라 '식별·중복 제거'
func contentAddress(of data: Data) -> String {
Data(SHA256.hash(data: data)).base64EncodedString() // 캐시 키·파일 지문
}
// ⚠️ 다운로드 파일 검증도 해시만으로는 부족하다.
// 해시 자체가 같은 채널로 오면 둘 다 바꿔치기당한다.
// → 해시를 서명된 채널(TLS + 서버 인증)로 받거나, 코드 서명을 검증한다④ AES-GCM 사용 시 같은 키로 nonce를 재사용하면 기밀성만이 아니라 무결성까지 무너진다 — 두 평문의 XOR이 새는 것을 넘어 인증 키(GHASH의 H)가 복구돼 임의 메시지 위조가 가능해진다. 매번 새 nonce를 쓰거나 CryptoKit 기본 생성에 맡겨라.
import Foundation
import CryptoKit
// AES-GCM에서 같은 키로 nonce를 재사용하면 기밀성만이 아니라
// 무결성까지 무너진다 — 인증 키가 복구되어 임의 메시지 위조가 가능해진다.
// ❌ 고정 nonce — 치명적이다
func encryptBad(_ data: Data, key: SymmetricKey) throws -> Data {
let fixed = try AES.GCM.Nonce(data: Data(repeating: 0, count: 12))
return try AES.GCM.seal(data, using: key, nonce: fixed).combined! // 💥
}
// ✅ ① 가장 안전: nonce를 지정하지 않고 CryptoKit이 매번 새로 만들게 둔다
func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(data, using: key) // nonce 자동 생성
return sealed.combined! // nonce가 함께 담긴다
}
func decrypt(_ combined: Data, key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: combined)
return try AES.GCM.open(box, using: key)
}
// ✅ ② 직접 관리해야 한다면 카운터로. 단 아래처럼 세션마다 새로 뽑는 4바이트 prefix + 0부터 시작하는 카운터로는 부족하다 —
// prefix가 겹치는 순간(2^16 세션 부근에서 50%) nonce가 그대로 재사용된다.
// prefix와 카운터를 키와 함께 영속화해 키 수명 전체에서 유일함을 보장하거나, 그냥 ①을 써라
actor NonceSequence {
private var counter: UInt64 = 0
private let prefix: Data // 세션마다 무작위 4바이트
init() {
var bytes = [UInt8](repeating: 0, count: 4)
_ = SecRandomCopyBytes(kSecRandomDefault, 4, &bytes)
prefix = Data(bytes)
}
func next() throws -> AES.GCM.Nonce {
counter += 1
var value = counter.bigEndian
let counterData = Data(bytes: &value, count: 8)
return try AES.GCM.Nonce(data: prefix + counterData) // 4 + 8 = 12바이트
}
}
// ✅ ③ 키를 자주 바꾸는 것도 방어가 된다 (같은 키로 쓰는 메시지 수를 제한)
func rotateKeyIfNeeded(messageCount: Int) -> Bool {
messageCount > 1_000_000 // GCM 권장 한계 훨씬 아래에서 교체
}
// ⚠️ ChaChaPoly도 같은 제약이 있다. AEAD 전반의 규칙이다."해시는 암호화의 한 종류다"라는 말은 틀렸다. 해시는 복호화 자체가 불가능한 단방향 함수이고 목적도 다르다(무결성·지문). 또 "Base64는 데이터를 안 보이게 해준다"는 것도 착각이다 — 인코딩은 표현 형식만 바꿀 뿐 누구나 즉시 디코딩한다.
셋이 헷갈리는 건 겉모습이 똑같이 "글자가 이상하게 변한 것"이기 때문이다. 갈라지는 건 되돌리는 방법이다.
- Encoding은 짐을 택배 상자에 다시 포장한 것. 상자 여는 법이 설명서에 공개돼 있어서 누구나 연다. 숨기려는 게 아니라 옮기기 편하게 만드는 작업이다.
- Hashing은 사과를 믹서기에 갈아 주스로 만든 것. 같은 사과를 갈면 늘 똑같은 주스가 나오지만, 주스를 사과로 되돌릴 방법은 없다.
- Encryption은 금고에 넣고 잠근 것. 열쇠가 있으면 물건이 그대로 나온다. 그래서 안전은 "열쇠를 어디에 두느냐"로 옮겨간다.
여기서 제일 많이 걸리는 질문. 되돌릴 수 없다면서 비밀번호 해시는 왜 뚫릴까? 공격자는 되돌리는 게 아니라 흔한 비밀번호를 하나씩 직접 갈아보고 나온 주스가 같은지 맞춰본다. SHA-256은 갈아내는 속도가 너무 빨라서 그래픽카드로 초당 수십억 번 시도할 수 있다. 그래서 사람마다 다른 재료(salt)를 섞어 남이 미리 갈아둔 결과를 못 쓰게 하고, 한 번 가는 데 일부러 시간이 걸리는 믹서기(Argon2·bcrypt 같은 KDF)를 쓴다. 비유가 깨지는 곳도 하나 짚자면, 진짜 주스는 성분을 분석하면 무슨 과일이었는지 짐작이 가지만 좋은 해시는 결과만 봐서는 입력에 대한 힌트가 전혀 새지 않는다.
꼬리 질문
MD5·SHA-1은 어떤 성질이 깨졌고, 어떤 용도라면 그래도 써도 되는가(예: 비암호학적 체크섬)?
둘 다 깨진 건 충돌 저항성이다. MD5는 2004년 이후 초 단위로 충돌쌍을 만들 수 있고, SHA-1도 2017년 SHAttered에서 실제 충돌 PDF가 나왔다. 반면 역상 저항성(해시만 보고 원본 찾기)은 아직 실용적으로 뚫리진 않았다.
그래서 서명·인증서·비밀번호처럼 "다른 입력이 같은 해시를 갖게 만들면 안 되는" 용도에는 절대 금지지만, 공격자가 개입할 수 없는 순수 데이터 무결성 체크(예: 캐시 키, 비악의적 파일 변경 감지, dedup)에는 여전히 쓸 수 있다. 다만 새 코드라면 굳이 MD5를 고를 이유가 없어, 비암호용도 xxHash나 CRC가 더 빠르고 목적에 맞다.
xxHash가 더 빠르니 굳이 MD5를 고를 이유가 없다.salt와 pepper는 각각 무엇을 막는가, 왜 pepper는 DB 밖에 둬야 하는가?
salt는 사용자마다 다른 공개 랜덤값이라, 같은 비밀번호가 서로 다른 해시가 되게 만든다. 이걸로 rainbow table(미리 계산한 해시 사전)과 "한 번 깨면 동일 비밀번호 전부 깨지는" 배치 공격을 막는다. salt는 비밀이 아니어도 되므로 해시 옆 DB에 같이 저장한다.
pepper는 모든 사용자에게 공통으로 섞는 비밀값이다. 막으려는 위협이 다르다 — DB만 통째로 유출된 상황을 대비한다. pepper가 DB에 있으면 함께 새서 무의미하므로, 앱 서버 환경변수나 HSM/KMS 등 DB와 다른 신뢰 경계에 둬야 방어가 성립한다. 즉 salt는 "테이블 내부의 다양성", pepper는 "테이블 밖의 비밀"로 역할이 갈린다.
HMAC이 단순히 hash(key + message)보다 나은 이유는? (length-extension attack)
SHA-256(key ‖ message)처럼 키를 앞에 붙이는 방식은 Merkle-Damgård 계열 해시의 length-extension 공격에 취약하다. 이 해시들은 내부 상태를 그대로 다음 블록 입력으로 이어가기 때문에, 공격자가 원본 메시지를 몰라도 유효한 태그와 메시지 길이만 알면 message ‖ padding ‖ 임의데이터에 대한 정당한 태그를 이어 만들 수 있다.
HMAC은 H((key⊕opad) ‖ H((key⊕ipad) ‖ message))로 해시를 두 번 중첩해 이 연장을 원천 차단한다. iOS에서는 이 함정을 신경 쓸 필요 없이 HMAC<SHA256>.authenticationCode(for:using:)를 쓰면 된다. 참고로 SHA-3(Keccak)나 BLAKE2 같은 최신 해시는 구조상 length-extension이 없어 hash(key ‖ message)가 안전하지만, 상호운용성 때문에 여전히 HMAC이 표준이다.
+ 내가 쓴 문장을 이어 눌러 새 태그를 만들어 낼 수 있다 — 원본 메시지를 몰라도 된다. HMAC은 그 계산을 한 겹 더 감싸서 남이 이어 누를 화면 자체를 없앤 것이고, iOS에선 그냥 HMAC을 쓰면 이 함정을 볼 일이 없다.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…)으로 "언제 읽을 수 있는지"를, kSecAttrAccessControl(SecAccessControlCreateWithFlags로 만든 SecAccessControl 값)로 생체인증 게이트를 건다. 파일은 FileProtectionType으로 보호 등급을 정한다 — complete(잠기면 못 읽음), completeUnlessOpen, completeUntilFirstUserAuthentication(부팅 후 첫 잠금해제 이후 접근, iOS의 사실상 기본값), none. Core Data/SQLite는 스토어 파일에 이 보호 클래스를 지정할 수 있지만 항목 단위 제어는 어렵다. UserDefaults는 앱 컨테이너의 평문 plist라 접근 제어가 없다.
| 데이터 유형 | Keychain | 파일 + 보호 | DB | UserDefaults |
|---|---|---|---|---|
| 토큰 · 키 · 비밀번호 | 권장 | 가능(직접 암호화) | 비권장 | 금지 |
| 대용량 민감 파일 | 부적합(크기) | 권장(complete) | 가능(BLOB) | 금지 |
| 대량 구조화 데이터 | 부적합 | 가능 | 권장(+보호클래스) | 금지 |
| 비민감 설정 · 플래그 | 과함 | 가능 | 가능 | 적합 |
프로젝트 적용
① 자격증명은 Keychain으로 몰되, 접근성 속성을 최소 권한으로 골라라 — 백그라운드 갱신이 필요 없으면 WhenUnlockedThisDeviceOnly.
import Foundation
import Security
// 접근성 속성이 "언제 이 항목을 읽을 수 있는가"를 정한다. 좁을수록 안전하다.
enum Accessibility {
// ⚠️ 하나의 축으로 줄 세울 수 없다 — 두 축이 독립이다.
// ① 시간: WhenPasscodeSet ≈ WhenUnlocked → AfterFirstUnlock 순으로 넓어진다
// ② 기기 한정: ThisDeviceOnly가 붙으면 백업·이전이 막힌다
// 그래서 WhenUnlocked와 AfterFirstUnlockThisDeviceOnly는 우열을 매길 수 없다.
// kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly 기기에 암호가 설정된 경우만, 백업 불가
// kSecAttrAccessibleWhenUnlockedThisDeviceOnly 잠금 해제 상태 + 이 기기만
// kSecAttrAccessibleWhenUnlocked 잠금 해제 상태 (백업 이동 가능)
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 첫 해제 후 (백그라운드 작업용)
// kSecAttrAccessibleAfterFirstUnlock 가장 넓음
}
func store(token: String, account: String, needsBackgroundAccess: Bool) throws {
let accessible: CFString = needsBackgroundAccess
? kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly // 백그라운드 갱신이 필요하면
: kSecAttrAccessibleWhenUnlockedThisDeviceOnly // 아니면 더 좁게
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: Data(token.utf8),
kSecAttrAccessible as String: accessible
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
// ✅ 고민감 항목은 생체인증 게이트를 건다
func storeWithBiometry(_ secret: Data, account: String) throws {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, // 지문·얼굴이 바뀌면 무효화된다
&error) else { throw KeychainError.accessControl }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: secret,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
enum KeychainError: Error { case status(OSStatus), accessControl }② 큰 민감 파일은 FileProtectionType.complete로. 다만 백그라운드에서 열어야 하면 잠금 중 접근 불가로 실패할 수 있으니 접근 시점을 설계에 반영하라.
import Foundation
// 파일 보호 클래스는 "기기가 어떤 상태일 때 이 파일을 열 수 있는가"를 정한다.
// 잠금 상태에서는 키가 메모리에서 지워져 아예 읽히지 않는다.
// ✅ 클래스별 의미
// .complete 잠기면 접근 불가 (가장 강함)
// .completeUnlessOpen 잠기기 전에 열어 뒀으면 계속 접근 가능
// .completeUntilFirstUserAuthentication 재부팅 후 첫 해제 이후로는 접근 가능
// .none 보호 없음
func saveSensitiveDocument(_ data: Data, to url: URL) throws {
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
// ✅ 기존 파일의 보호 클래스를 바꾸려면 FileManager를 쓴다
// (URLResourceValues.fileProtection은 읽기 전용이라 대입할 수 없다)
func upgradeProtection(_ url: URL) throws {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: url.path)
}
// ✅ 현재 보호 클래스 확인은 URLResourceValues로 (읽기는 가능하다)
func currentProtection(_ url: URL) throws -> URLFileProtection? {
try url.resourceValues(forKeys: [.fileProtectionKey]).fileProtection
}
// ⚠️ 백그라운드에서 열어야 하는 파일에 .complete를 걸면
// 잠금 중 백그라운드 작업이 실패한다
func chooseProtection(needsBackgroundAccess: Bool) -> Data.WritingOptions {
needsBackgroundAccess
? [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
: [.atomic, .completeFileProtection]
}
// ✅ 접근 가능 여부를 먼저 확인해 우아하게 실패한다
func isProtectedDataAvailable() -> Bool {
// UIApplication.shared.isProtectedDataAvailable (메인에서)
// 또는 알림 구독:
// .protectedDataDidBecomeAvailableNotification
// .protectedDataWillBecomeUnavailableNotification
true
}
func readWhenAvailable(_ url: URL) throws -> Data? {
guard isProtectedDataAvailable() else { return nil } // 잠금 중이면 나중에
return try Data(contentsOf: url)
}③ Core Data/SQLite는 "쓰면 자동으로 암호화된다"는 착각을 버리고, 스토어 파일 보호 클래스 또는 SQLCipher를 명시적으로 적용하라.
import CoreData
import Foundation
// Core Data·SQLite 파일은 그냥 파일이다. 아무것도 안 하면 보호 클래스만
// 기본값이고, 내용은 평문으로 저장된다.
// ✅ ① 스토어 파일에 보호 클래스를 명시한다 (가장 간단한 방어)
func makeProtectedContainer(name: String, storeURL: URL) -> NSPersistentContainer {
let container = NSPersistentContainer(name: name)
let description = NSPersistentStoreDescription(url: storeURL)
description.setOption(FileProtectionType.complete as NSObject,
forKey: NSPersistentStoreFileProtectionKey)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in precondition(error == nil) }
return container
}
// ⚠️ -wal, -shm 파일도 함께 보호되는지 확인해야 한다
// ✅ ② 더 강한 보호가 필요하면 DB 자체를 암호화한다 (SQLCipher 등)
// 키는 Keychain에 두고, 앱 시작 시 꺼내 DB를 연다
func openEncryptedDatabase(keyFromKeychain key: String) throws {
// SQLCipher: PRAGMA key = '...';
// GRDB + SQLCipher 조합이 흔하다
}
// ✅ ③ 컬럼 단위 암호화 — DB 전체가 아니라 민감 필드만
import CryptoKit
struct EncryptedField {
static func encrypt(_ value: String, key: SymmetricKey) throws -> Data {
try AES.GCM.seal(Data(value.utf8), using: key).combined!
}
static func decrypt(_ data: Data, key: SymmetricKey) throws -> String {
let box = try AES.GCM.SealedBox(combined: data)
return String(data: try AES.GCM.open(box, using: key), encoding: .utf8) ?? ""
}
}
// ⚠️ 암호화한 컬럼은 인덱스·정렬·LIKE 검색이 안 된다. 검색이 필요하면
// 별도의 결정적 해시 컬럼(HMAC)을 두고 그걸로 조회한다.
// ✅ 파일 보호 클래스 확인
func checkProtection(_ url: URL) throws -> URLFileProtection? {
try url.resourceValues(forKeys: [.fileProtectionKey]).fileProtection
}④ Keychain 항목은 앱을 삭제해도 남을 수 있다 — 재설치 첫 실행 시 잔존 자격증명 처리 로직을 넣어라.
import Foundation
import Security
// Keychain 항목은 앱을 지워도 남는다. 재설치하면 이전 사용자의 자격증명이
// 그대로 읽혀 "지웠는데 로그인돼 있다"가 된다.
enum FirstRunCleaner {
private static let flagKey = "hasRunBefore"
/// 앱 시작 시 가장 먼저 호출한다
static func cleanIfFreshInstall() {
// UserDefaults는 앱 삭제와 함께 사라진다 → 이걸 설치 감지에 쓴다
guard !UserDefaults.standard.bool(forKey: flagKey) else { return }
purgeAllKeychainItems()
UserDefaults.standard.set(true, forKey: flagKey)
}
private static func purgeAllKeychainItems() {
let classes: [CFString] = [
kSecClassGenericPassword,
kSecClassInternetPassword,
kSecClassCertificate,
kSecClassKey,
kSecClassIdentity
]
for cls in classes {
let query: [String: Any] = [kSecClass as String: cls,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny // ✅ iCloud 동기화 항목까지 포함해야 진짜 전체 삭제다]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
print("Keychain 정리 실패(\(cls)): \(status)")
}
}
}
}
// AppDelegate / App 진입점에서
func applicationDidFinishLaunching() {
FirstRunCleaner.cleanIfFreshInstall() // ✅ 다른 초기화보다 먼저
}
// ⚠️ 판단이 갈리는 지점이다
// · 정리한다 → 재설치 시 반드시 재로그인. 보안상 안전하고 예측 가능.
// · 남겨 둔다 → 재설치 후에도 로그인 유지. 편의성은 좋지만 기기 양도 시 위험.
//
// ✅ 남기기로 했다면 최소한 ThisDeviceOnly 접근성을 써서
// 백업을 통해 다른 기기로 넘어가지 않게 한다.
// ⚠️ App Group·Keychain access group을 쓰면 다른 앱·확장의 항목까지
// 지울 수 있으므로, 정리 쿼리에 access group을 명시해 범위를 좁힌다."Keychain에 넣으면 탈옥 기기에서도 절대 못 빼간다"는 과신은 위험하다. 기기가 잠금해제된 상태이고 탈옥돼 있으면, 접근성 클래스에 따라 항목이 읽힐 수 있다. 그래서 접근성 속성과 생체인증 게이트를 목적에 맞게 좁히는 것이 핵심이지, "Keychain = 무적"이 아니다.
집 안에서 물건 두는 자리를 떠올리면 정리가 된다. Keychain은 벽에 박아 놓은 작은 금고다 — 도장이나 통장 비밀번호처럼 작고 귀한 것만 들어가고, 대신 "집에 들어와 있을 때만 열림", "지문을 대야 열림" 같은 조건까지 걸 수 있다. 파일 + Data Protection은 잠금 서랍장이라 큰 서류나 사진을 넣기 좋다. DB는 창고 선반이다 — 물건이 많아도 척척 찾아 꺼낼 수 있지만 기본은 그냥 열린 선반이라 잠금장치를 따로 달아야 한다. UserDefaults는 냉장고 문에 붙인 포스트잇이다. 편하지만 지나가는 사람이 다 읽는다.
그럼 금고 열쇠는 어디 있을까. 이게 이 문항에서 제일 안 그려지는 부분인데, 열쇠가 또 다른 열쇠 안에 겹겹이 싸여 있고 맨 아래 열쇠는 이 기기에 용접돼 있어 뽑아갈 수가 없다. 게다가 내 passcode(잠금 비밀번호)를 넣어야 그 열쇠가 완성된다. 그래서 "화면이 잠겨 있는 동안은 아예 못 읽음"이 그냥 규칙이 아니라 물리적으로 성립한다. 뒤집으면, 기기가 켜져 있고 잠금이 풀려 있으면 그 열쇠는 이미 꺼내져 있다는 뜻이다 — Keychain이 무적이 아닌 이유가 여기 있다.
꼬리 질문
AfterFirstUnlock과 WhenUnlocked는 백그라운드 네트워크 관점에서 어떻게 갈리는가?
둘의 차이는 "잠긴 화면에서 그 항목을 읽을 수 있느냐"다. WhenUnlocked는 사용자가 지금 화면을 열어 둔 순간에만 클래스 키가 메모리에 있어 읽힌다. 화면이 잠기면 키가 폐기돼 못 읽는다. AfterFirstUnlock은 부팅 후 한 번만 잠금해제하면 그 뒤로는 잠겨 있어도 계속 읽힌다.
그래서 background fetch, silent push, URLSession background 전송처럼 사용자가 화면을 안 보는 동안 토큰이 필요한 경우엔 AfterFirstUnlock이어야 한다. WhenUnlocked면 백그라운드 작업이 errSecInteractionNotAllowed로 실패한다. 대신 AfterFirstUnlock은 잠긴 채 압수·분석당하는 시나리오에 더 노출되므로, 백그라운드 접근이 필요 없는 최고민감 항목은 WhenUnlocked로 좁히는 게 맞다.
WhenUnlocked는 내가 폰을 보고 있는 동안만 열리는 서랍이고, AfterFirstUnlock은 아침에 한 번 잠금을 풀었으면 그 뒤로 화면이 꺼져 있어도 계속 열려 있는 서랍이다. background fetch나 silent push는 내가 폰을 안 보고 있을 때 도는 일이라 앞쪽 서랍을 고르면 문이 안 열려서 실패한다. 대신 뒤쪽 서랍은 잠긴 폰을 통째로 압수당한 상황에서도 열려 있다는 게 치르는 값이다.App Group으로 Keychain을 여러 앱/확장과 공유하려면 무엇이 필요하고 위험은?
Keychain 공유는 Keychain Access Group 단위로 이뤄지는데, 앱이 속하는 그룹 목록은 keychain-access-groups entitlement + application-identifier(기본 그룹) + com.apple.security.application-groups(App Group)를 이어 붙인 것이다. 즉 App Group 이름은 keychain-access-groups에 따로 넣지 않아도 kSecAttrAccessGroup 값으로 바로 쓸 수 있다(다만 App Group은 기본 그룹이 될 수 없다). 항목 저장 시 kSecAttrAccessGroup을 지정하면 같은 그룹의 앱·확장(위젯, share extension 등)이 함께 읽는다. 위젯/확장과 토큰을 공유하는 전형적 패턴이다.
위험은 공유 경계가 곧 공격면이라는 점이다. 그룹의 어느 한 타깃이라도 취약하면 공유된 자격증명 전체가 노출된다. 확장은 메모리·수명 제약이 다르고 별도로 침해될 수 있으므로, 공유 항목은 꼭 필요한 것만 최소 접근성으로 넣고, 확장에는 refresh token 같은 강한 자격증명 대신 단명 토큰만 흘려보내는 설계가 안전하다.
기기 백업(iCloud/암호화 백업)에 어떤 Keychain 항목이 포함되고 어떤 것은 빠지는가?
가르는 축은 접근성 속성의 ThisDeviceOnly 접미사다. ThisDeviceOnly가 붙지 않은 항목(kSecAttrAccessibleAfterFirstUnlock 등)은 백업에 포함되어 복원 시 새 기기로 넘어간다. 반대로 …AfterFirstUnlockThisDeviceOnly처럼 ThisDeviceOnly가 붙으면 이 기기의 하드웨어 키에 묶여 백업에서 빠지고 이전되지 않는다.
여기에 더해 iCloud Keychain 동기화는 별개 축이다. kSecAttrSynchronizable을 켠 항목만 사용자의 다른 Apple 기기로 종단간 암호화되어 동기화된다. 정리하면 refresh token처럼 기기에 묶이길 원하는 값은 ThisDeviceOnly + 비동기화로, 여러 기기에서 이어 쓰고 싶은 값은 동기화 플래그를 켜서 다룬다.
ThisDeviceOnly가 붙었냐 아니냐, 그 한 조각으로 갈린다. 안 붙어 있으면 이삿짐(백업) 상자에 같이 담겨 새 폰까지 따라가고, 붙어 있으면 이 폰에 용접돼 있어서 상자에 아예 안 들어간다. 그리고 "내 아이패드에서도 같이 쓰기"는 또 다른 스위치(kSecAttrSynchronizable)라 백업 여부와 별개로 켜고 끈다.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을 읽지 마라(성능·마모).
import Foundation
// 매 요청마다 Keychain을 읽으면 느리고(SecItemCopyMatching은 IPC다)
// 플래시에도 좋지 않다. 메모리를 1차 소스로 둔다.
actor TokenStore {
private var cachedAccess: String?
private var expiresAt: Date?
/// 유효한 access token을 돌려준다
func accessToken() async throws -> String {
// ① 메모리에 유효한 게 있으면 그대로
if let cachedAccess, let expiresAt, expiresAt > Date().addingTimeInterval(30) {
return cachedAccess
}
// ② 없으면 갱신 (Keychain의 refresh token 사용)
return try await refresh()
}
func store(access: String, expiresIn: TimeInterval, refresh: String) {
cachedAccess = access
expiresAt = Date().addingTimeInterval(expiresIn)
Keychain.save(refresh, account: "refreshToken") // ✅ 긴 것만 디스크에
// ⚠️ access token은 수명이 짧으므로 굳이 Keychain에 안 써도 된다.
// 앱 재시작 시 refresh로 새로 받으면 그만이다.
}
func clear() {
cachedAccess = nil
expiresAt = nil
Keychain.delete(account: "refreshToken")
}
private func refresh() async throws -> String { "" }
}
// ✅ 만료를 서버 응답으로 관리한다 — 클라가 추측하지 않는다
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
let expiresIn: TimeInterval // 초 단위
}
// ✅ 만료 30초 전에 미리 갱신하면 요청 중 만료를 피할 수 있다
func needsRefresh(expiresAt: Date) -> Bool {
expiresAt <= Date().addingTimeInterval(30)
}
enum Keychain {
static func save(_ v: String, account: String) {}
static func delete(account: String) {}
}② refresh는 반드시 single-flight로 묶어라 — 앱 복귀 순간 수십 개 요청이 동시에 401을 받는 thundering herd를 막는다.
import Foundation
// 앱 복귀 순간 수십 개 요청이 동시에 401을 받으면, 각자 refresh를 시도해
// refresh token rotation이 꼬이거나 서버가 전부 거부한다.
actor TokenProvider {
private var cached: String?
private var refreshTask: Task<String, Error>?
func token() async throws -> String {
if let cached { return cached }
// ✅ 이미 진행 중인 갱신이 있으면 그 결과에 '합류'한다
if let existing = refreshTask {
return try await existing.value
}
let task = Task<String, Error> { try await self.performRefresh() }
refreshTask = task
defer { refreshTask = nil } // 성공·실패 모두 정리
let fresh = try await task.value
cached = fresh
return fresh
}
/// 401을 받은 쪽이 무효화를 알린다
func invalidate() { cached = nil }
private func performRefresh() async throws -> String {
guard let refresh = Keychain.read("refreshToken") else {
throw AuthError.noSession
}
let pair = try await api.refresh(refresh)
Keychain.write(pair.refresh, "refreshToken") // rotation
return pair.access
}
}
// 사용 — 401을 받으면 무효화 후 한 번만 재시도한다
func authorizedRequest(_ request: URLRequest, provider: TokenProvider) async throws -> Data {
var req = request
req.setValue("Bearer \(try await provider.token())", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 401 else { return data }
await provider.invalidate()
var retry = request
retry.setValue("Bearer \(try await provider.token())", forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: retry).0 // ✅ 한 번만
}
enum AuthError: Error { case noSession, sessionExpired }
struct TokenPair { let access: String; let refresh: String }
enum api {
static func refresh(_ t: String) async throws -> TokenPair {
TokenPair(access: "", refresh: "")
}
}
enum Keychain {
static func read(_ k: String) -> String? { nil }
static func write(_ v: String, _ k: String) {}
}③ refresh 실패(만료·무효)는 곧 세션 종료다 → 토큰을 지우고 로그인 화면으로. 무한 재시도 루프를 만들지 마라.
import Foundation
// refresh가 무효(만료·회수·재사용 탐지)면 더 해 볼 게 없다.
// 여기서 재시도 루프를 만들면 배터리를 태우고 서버 부하만 늘린다.
actor SessionManager {
private var cached: String?
func token() async throws -> String {
if let cached { return cached }
do {
let fresh = try await refresh()
cached = fresh
return fresh
} catch AuthError.invalidGrant {
// ✅ 세션이 끝났다 — 정리하고 로그인으로 보낸다
await endSession()
throw AuthError.sessionExpired
} catch {
// ⚠️ 네트워크 오류는 세션 종료가 아니다 — 구분해야 한다
throw error
}
}
private func endSession() async {
cached = nil
Keychain.deleteAll()
await MainActor.run { AppRouter.shared.showLogin() }
}
private func refresh() async throws -> String { "" }
}
// ✅ 에러를 계층으로 구분한다 — 이걸 뭉치면 잘못된 로그아웃이 생긴다
enum AuthError: Error {
case invalidGrant // refresh token 무효 → 세션 종료
case sessionExpired
case network(Error) // 일시적 → 재시도 가능, 로그아웃하면 안 된다
}
func classify(status: Int, error: Error?) -> AuthError? {
if let urlError = error as? URLError { return .network(urlError) }
switch status {
case 400, 401: return .invalidGrant // OAuth는 보통 400 invalid_grant
default: return nil
}
}
// ❌ 하지 말 것: 무한 재시도
// while true { if let t = try? await refresh() { return t } }
// ✅ 재사용 탐지에 걸린 경우도 즉시 세션 종료다
// (서버가 "이 refresh token은 이미 쓰였다"고 하면 탈취 정황이므로
// 그 세션 전체를 무효화하는 것이 표준 대응이다)
enum Keychain { static func deleteAll() {} }
@MainActor final class AppRouter {
static let shared = AppRouter()
func showLogin() {}
}④ 토큰을 URL 쿼리·로그·UserDefaults에 절대 남기지 마라(Q4·Q6 참고).
import Foundation
import OSLog
private let log = Logger(subsystem: "com.app", category: "auth")
// ❌ ① 쿼리스트링 — TLS로 암호화돼도 서버 접근 로그·Referer·프록시에 평문으로 남는다
func badURL(_ token: String) -> URL {
URL(string: "https://api.example.com/me?access_token=\(token)")!
}
// ✅ 헤더로 보낸다
func request(_ url: URL, token: String) -> URLRequest {
var r = URLRequest(url: url)
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return r
}
// ❌ ② 로그에 그대로
func badLog(_ token: String, _ request: URLRequest) {
print("token=\(token)") // stdout
NSLog("headers=%@", request.allHTTPHeaderFields ?? [:]) // 시스템 로그에 영속
}
// ✅ 마스킹하거나 아예 찍지 않는다
func safeLog(_ request: URLRequest) {
var redacted = request.allHTTPHeaderFields ?? [:]
redacted["Authorization"] = "[redacted]"
log.debug("headers \(redacted, privacy: .private)")
}
func masked(_ token: String) -> String {
guard token.count > 8 else { return "***" }
return token.prefix(4) + "…" + token.suffix(4)
}
// ❌ ③ UserDefaults — 평문 plist. 백업·파일 접근으로 노출된다
func badStore(_ token: String) {
UserDefaults.standard.set(token, forKey: "token")
}
// ❌ ④ 놓치기 쉬운 자리들
// · URLCache — 응답에 토큰이 담기면 디스크 캐시에 남는다
// · 크래시 리포트의 breadcrumb·커스텀 키
// · 스크린샷(앱 전환 시 스냅샷) — 토큰이 화면에 보이면 안 된다
// · 웹뷰 쿠키·로컬스토리지
// ✅ 민감 화면은 백그라운드 스냅샷을 가린다
func obscureOnBackground(_ window: UIWindow?) {
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blur.frame = window?.bounds ?? .zero
window?.addSubview(blur)
}
// ✅ 인증 응답은 캐시하지 않는다
func noCacheConfig() -> URLSessionConfiguration {
let c = URLSessionConfiguration.ephemeral // 디스크 캐시·쿠키 없음
c.urlCache = nil
return c
}
import UIKit"refresh token은 만료가 길거나 없으니 한 번 저장하면 끝"이라는 생각은 위험하다. 오래 살수록 표적 가치가 커지므로, rotation과 재사용 탐지가 없으면 한 번의 유출이 영구 세션 탈취로 이어진다. 반대로 "access token을 길게 잡으면 갱신 로직이 필요 없어 편하다"도 함정이다 — 유출 시 악용 가능한 시간 창이 그만큼 넓어진다.
회사 방문객 출입증을 떠올려 보자. access token은 30분짜리 임시 출입증이고, refresh token은 그 출입증을 다시 받아올 수 있는 회원카드다. 임시 출입증을 일부러 짧게 만드는 이유는 간단하다 — 누가 주워도 30분만 지나면 종잇조각이 되니까. 반대로 회원카드는 오래 살아서 훨씬 귀하다. 그래서 금고(Keychain)에 넣고, 아예 이 기기 밖으로는 못 나가게 묶어 둔다. 재발급받을 때 회원카드까지 새것으로 바꿔주는 게 rotation(회전)이고, 폐기된 옛날 카드가 창구에 다시 나타나면 "누가 훔쳐서 쓰는구나" 하고 그 사람 카드 계열을 통째로 정지시킨다.
single-flight(한 번만 실행)는 창구 줄서기 문제다. 앱을 다시 열면 화면 여러 개가 동시에 서버를 부르고, 다 같이 "출입증 만료"라는 말을 듣는다. 이때 각자 창구로 달려가면 재발급이 네 번 겹치는데, rotation 때문에 나중 카드가 앞 카드를 무효로 만들어 멀쩡하던 사람이 로그아웃당한다. 그래서 대표 한 명만 줄을 서고 나머지는 그 결과를 나눠 받게 묶는다. Swift에서 actor(한 번에 한 명만 들어갈 수 있는 방)가 이 창구 역할을 맡는다.
꼬리 질문
본문 actor의 validToken()은 cached 토큰을 만료 검사 없이 그대로 반환한다 — access token 만료(및 clock skew로 인한 만료 임박)를 어떻게 감지하며, 진행 중인 refresh Task가 실패하거나 취소되면 그 하나에 합류해 대기하던 동시 호출들에는 무엇이 전파되는가?
지금 코드엔 만료 감지가 아예 없다 — validToken()이 cached를 무조건 돌려주므로, 만료를 알려면 발급 응답의 expires_in이나 JWT의 exp에서 만료 시각을 뽑아 토큰과 함께 저장하고 "지금 > 만료 − 여유"로 판정해야 한다. 이때 서버·기기 시계가 어긋날 수 있으므로 clock skew와 왕복 지연을 감안한 여유(보통 30~60초)를 빼 두고, 401을 기다리기보다 그 시점에 선제적으로 갱신하는 편이 실패 경로가 짧다.
합류 쪽에서는 대기하던 동시 호출이 모두 하나의 inflight Task에 try await task.value로 묶이므로, 그 Task가 던진 에러가 전원에게 똑같이 재전파된다(refresh 실패면 다 함께 로그아웃 경로로 수렴하고, defer로 inflight이 비워져 다음 호출은 새로 갱신을 시도한다). 다만 refresh는 Task {}로 띄운 비구조적 작업이라 호출자 하나가 취소돼도 공유 refresh는 살아남아 single-flight가 유지되며, refresh 자체를 끊으려면 Task 핸들을 직접 취소해야 하고 그러면 합류한 전원이 CancellationError로 함께 실패한다.
클라이언트가 로그아웃할 때 서버 측 token revoke가 왜 필요한가? 안 하면 무엇이 남는가?
클라이언트가 로컬에서 토큰을 지워도, 그 토큰 자체는 만료 전까지 서버가 여전히 유효하다고 인정한다. 특히 JWT처럼 자기완결(self-contained) access token은 서버가 상태를 안 들고 검증만 하므로, 지우기 전에 이미 유출된 사본이 있으면 로그아웃과 무관하게 만료까지 그대로 통한다. refresh token은 더 오래 살아서 피해가 크다.
그래서 서버 측 revoke(refresh token 무효화, 필요하면 세션/토큰 블록리스트)가 있어야 "로그아웃 = 실제 세션 종료"가 성립한다. 안 하면 도난 기기·유출 사본이 access token 만료 시간만큼(또는 refresh 수명만큼) 계속 살아 있는 창이 남는다. access token을 짧게 잡는 이유가 바로 이 revoke 지연을 좁히기 위함이다.
모바일 앱에서 PKCE는 인가 코드 흐름의 어느 지점을, 어떤 공격으로부터 보호하는가?
PKCE(Proof Key for Code Exchange)는 authorization code를 token으로 교환하는 마지막 단계를 지킨다. 앱은 시작할 때 랜덤 code_verifier를 만들어 그 해시(code_challenge)만 인가 요청에 실어 보내고, 나중에 토큰 교환 시 원본 verifier를 제시한다. 서버는 둘이 일치할 때만 코드를 토큰으로 바꿔준다.
막는 공격은 authorization code interception이다. 모바일에서 리다이렉트는 커스텀 URL scheme이나 유니버설 링크로 돌아오는데, 악성 앱이 같은 scheme을 가로채 코드를 낚아챌 수 있다. verifier는 처음 요청을 보낸 앱 메모리에만 있으므로, 코드만 훔친 공격자는 verifier가 없어 교환에 실패한다. 그래서 client secret을 안전하게 둘 수 없는 public 클라이언트(네이티브 앱)에서 사실상 필수다. iOS에서는 ASWebAuthenticationSession으로 이 흐름을 구현한다.
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 예외는 최소화하라. 예외 하나가 그 도메인의 다운그레이드 공격 창을 연다.
import Foundation
/* ATS(App Transport Security)는 기본으로 평문 HTTP를 차단하고 TLS 1.2+를 요구한다.
예외를 열면 그 도메인에 대한 다운그레이드 공격 창이 열린다.
❌ 최악 — 전체를 연다 (심사에서 정당화를 요구받는다)
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/>
</dict>
✅ 꼭 필요하면 도메인 단위로, 필요한 항목만
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy.partner.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key><true/>
<key>NSIncludesSubdomains</key><false/>
</dict>
</dict>
</dict>
✅ TLS 버전만 낮춰야 하면 그것만 낮춘다 (평문 허용까지 열지 않는다)
<!-- TLS 1.0/1.1은 RFC 8996이 금지했고 Apple도 iOS 15부터 deprecated로 선언해 제거를 예고했다.
낮춰야 한다면 여기까지가 현실적 하한이고, 만료일과 담당자를 함께 못 박아 둔다. -->
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string> */
// ✅ 현재 설정이 실제로 어떻게 동작하는지 검증한다
// nscurl --ats-diagnostics --verbose https://api.example.com
// ✅ 예외를 코드로도 추적 가능하게 남긴다
enum ATSExceptions {
/// 2026-12-31까지 한시적. 파트너사 TLS 업그레이드 완료 시 제거.
/// 담당: 인프라팀 / 티켓: SEC-1234
static let temporary = ["legacy.partner.com"]
}
// ✅ 예외 도메인으로 가는 요청에는 민감 정보를 싣지 않는다
func isSafeToSendCredentials(to host: String) -> Bool {
!ATSExceptions.temporary.contains(host)
}② 고민감 앱은 public key pinning을 고려하되, 인증서 교체(rotation)로 앱이 벽돌이 되지 않게 backup pin과 만료 계획을 함께 설계하라.
import Foundation
import CryptoKit
// 피닝은 강력하지만 잘못 다루면 앱이 통째로 접속 불가가 된다.
// 도입 전에 로테이션 절차를 먼저 정해야 한다.
enum Pinning {
/// 현재 + 차기 공개키 해시. 인증서 교체 전에 백업 핀을 먼저 배포해 둔다.
static let pins: Set<String> = [
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // 현재
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // 백업(차기)
]
static func validate(_ trust: SecTrust) -> Bool {
// ① 시스템 기본 검증(체인·호스트명·만료)을 먼저 통과시킨다
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else { return false }
// ② 그다음 공개키를 핀과 대조한다
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
else { return false }
for cert in chain {
guard let key = SecCertificateCopyKey(cert),
let data = SecKeyCopyExternalRepresentation(key, nil) as Data?
else { continue }
// ⚠️ SecKeyCopyExternalRepresentation은 SPKI DER가 아니라 원시 키 바이트를 준다
// (RSA: PKCS #1, EC: 04||X||Y). RFC 7469의 sha256/… 핀과 대조하려면
// 키 알고리즘·크기에 맞는 ASN.1 SubjectPublicKeyInfo 헤더를 앞에 붙여 DER를
// 복원한 뒤 해시해야 한다. 이 단계를 빼면 정상 서버에서도 전 연결이 막힌다.
let hash = "sha256/" + Data(SHA256.hash(data: data)).base64EncodedString()
if pins.contains(hash) { return true }
}
return false
}
}
/* 로테이션 절차 (문서로 못 박아 둘 것)
1) 차기 키 생성 → 그 핀을 앱에 '추가'해 배포 (현재 + 백업 둘 다 유효)
2) 사용자 대부분이 그 버전으로 올라올 때까지 대기 (보통 수 주)
3) 서버 인증서를 차기 키로 교체
4) 다음 릴리스에서 옛 핀 제거 + 새 백업 핀 추가
⚠️ 강제 업데이트 수단(원격 최소 버전 설정)이 없으면 피닝을 켜지 않는다.
⚠️ 강제 업데이트 안내 화면 자체는 피닝된 API를 거치지 않아야 한다. */
// ✅ 공개키(SPKI)를 핀한다 — 인증서를 갱신해도 키를 유지하면 핀이 유효하다
// 인증서 자체를 핀하면 갱신마다 앱을 올려야 한다③ "HTTPS로 보냈으니 서버 입력 검증은 안 해도 된다"는 착각을 버려라 — TLS는 회선이지 서버 로직이 아니다.
import Foundation
// TLS가 지키는 것: 회선(기밀성·무결성·서버 신원)
// TLS가 지키지 않는 것: 클라이언트가 보낸 내용의 타당성
// ❌ "HTTPS로 보냈으니 서버는 그대로 믿어도 된다"
struct BadCheckoutRequest: Encodable {
let itemID: String
let price: Decimal // 💥 클라이언트가 가격을 정한다
let discount: Decimal // 💥 할인율도
}
// ✅ 서버가 스스로 계산할 수 있는 값은 클라이언트에서 받지 않는다
struct CheckoutRequest: Encodable {
let itemID: String
let quantity: Int
let couponCode: String? // 코드만 받고 유효성·할인율은 서버가 판정
}
// ✅ 권한도 마찬가지 — 클라이언트가 보낸 역할을 믿지 않는다
struct BadDeleteRequest: Encodable {
let postID: String
let isAdmin: Bool // 💥 누구나 true로 보낼 수 있다
}
struct DeleteRequest: Encodable {
let postID: String // ✅ 권한은 서버가 토큰에서 확인한다
}
// ⚠️ 앱은 공격자의 기기에서 돈다. 프록시·리버싱·수정된 클라이언트로
// 어떤 값이든 보낼 수 있다. 클라이언트 검증은 UX이지 보안이 아니다.
// ✅ 클라이언트 검증은 '빠른 피드백'을 위해 유지하되, 서버가 다시 한다
func validateLocally(_ email: String) -> Bool {
email.contains("@") // 사용자에게 즉시 알려 주기 위한 것
}
// ✅ 요청의 정당성이 필요하면 App Attest로 '정품 앱'임을 서버가 검증한다
// (그것도 클라 값을 믿는 게 아니라, 하드웨어 증명을 검증하는 것이다)④ 토큰·PII를 URL 쿼리스트링에 넣지 마라. TLS로 암호화되긴 하지만 서버 접근 로그·Referer·브라우저/웹뷰 히스토리, 그리고 TLS를 종단하는 중간 지점(리버스 프록시·API 게이트웨이·CDN, 사설 CA를 심은 기업 프록시)의 로그에 평문으로 남는다.
import Foundation
// TLS는 URL 경로·쿼리를 암호화한다. 하지만 그 URL은 여러 곳에 평문으로 남는다.
// · 서버 접근 로그 (거의 항상 전체 URL을 기록한다)
// · 프록시·게이트웨이 로그
// · Referer 헤더 (외부 링크로 이동 시)
// · 브라우저·웹뷰 히스토리
// · 앱 내 URL 로깅·크래시 리포트
// ❌ 쿼리스트링에 민감 정보
func badRequests(token: String, email: String, ssn: String) -> [URL] {
[
URL(string: "https://api.example.com/me?token=\(token)")!,
URL(string: "https://api.example.com/search?email=\(email)")!,
URL(string: "https://api.example.com/verify?ssn=\(ssn)")!
]
}
// ✅ 인증은 헤더로
func authorized(_ url: URL, token: String) -> URLRequest {
var r = URLRequest(url: url)
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return r
}
// ✅ 민감한 조회 조건은 본문으로 (POST 또는 POST-as-query)
func search(email: String) throws -> URLRequest {
var r = URLRequest(url: URL(string: "https://api.example.com/search")!)
r.httpMethod = "POST"
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.httpBody = try JSONEncoder().encode(["email": email])
return r
}
// ✅ 경로 파라미터도 조심 — /users/010-1234-5678 같은 건 로그에 남는다
// 불투명한 식별자를 쓴다: /users/{uuid}
// ⚠️ 딥링크·유니버설 링크도 같은 문제다
// ❌ myapp://reset?token=abc123 → 다른 앱이 가로챌 수 있고 로그에 남는다
// ✅ 짧은 일회용 코드를 쓰고 서버에서 교환한다
// ✅ 웹뷰에 토큰을 넘겨야 하면 URL이 아니라 헤더나 postMessage로
import WebKit
func loadAuthenticated(_ webView: WKWebView, url: URL, token: String) {
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
webView.load(request)
}"HTTPS면 MITM(중간자)은 절대 불가능하다"는 말은 조건부로만 맞다. 사용자가 신뢰하는 CA(기업 MDM 프로파일 등)가 기기에 깔려 있고 앱이 pinning을 하지 않으면, 그 CA로 서명된 가짜 인증서로 트래픽이 합법적으로 가로채질 수 있다. TLS는 "신뢰 앵커가 정직하다"는 가정 위에서 동작한다.
HTTPS는 우리 집과 가게 사이를 오가는 방탄 택배차라고 보면 그림이 맞는다. 차 안의 상자는 아무도 열어보지 못하고(기밀성), 몰래 바꿔치기하면 바로 티가 나며(무결성), 출발 전에 가게 간판이 진짜인지 확인한다(서버 인증). 여기까지가 TLS(Transport Layer Security, 전송 구간을 암호화하는 규약)가 해주는 일 전부다.
못 하는 것도 딱 셋으로 정리된다. 첫째, 차가 어느 가게 앞에 섰는지, 상자가 얼마나 크고 몇 시에 갔는지는 길에서 다 보인다 — 이게 SNI·IP·크기/타이밍 같은 메타데이터다. 내용은 못 봐도 "이 사람 그 사이트에 들어갔네"는 읽힌다. 둘째, 상자가 가게 안으로 들어간 다음 어떻게 보관되는지는 택배차 소관이 아니다. 셋째, 우리 집이 이미 털려 있으면 상자를 싣는 순간부터 소용없다. 그리고 "간판 확인"은 결국 누가 발급한 신분증을 믿느냐에 달려 있어서, 회사에서 깔아준 프로파일(CA)이 기기에 들어 있으면 그 회사가 만든 가짜 간판도 진짜로 보인다. pinning은 "우리 가게 간판은 딱 이 모양이어야 해" 하고 사진을 미리 외워두는 것이다.
꼬리 질문
TLS 1.3의 0-RTT(early data)는 forward secrecy를 제공하는 정식 핸드셰이크를 거치지 않기에 왜 유독 replay 공격에 노출되며, 그래서 어떤 종류의 요청(예: 상태를 바꾸는 비멱등 요청)은 절대 early data로 실어 보내면 안 되는가?
0-RTT는 이전 세션에서 받아 둔 PSK로 파생한 키로, 그 세션 고유의 임시(ephemeral) 키 교환이 끝나기 전에 첫 패킷에 애플리케이션 데이터를 실어 보낸다. 서버가 매번 새로 기여하는 nonce가 이 데이터에 묶이지 않으므로, 회선 중간의 공격자가 early data 뭉치를 붙잡아 서버에 그대로 다시 던져도 서버는 원본인지 재생인지 구분할 수단이 없다 — 정식 핸드셰이크만 갖는 신선도와 forward secrecy가 이 구간엔 빠져 있어, 나중에 PSK가 새면 early data는 복호화까지 된다.
그래서 early data에는 멱등하고 부수효과 없는 요청(순수 조회 GET류)만 싣고, 결제·이체 같은 비멱등·상태 변경 요청은 절대 넣지 않는다 — 재생되면 같은 연산이 두 번 실행되기 때문이다. 서버가 anti-replay 윈도를 두긴 해도 시간·노드 경계에서 완벽하지 않은 best-effort라, 실질적 안전은 "무엇을 0-RTT에 태우지 않을지"로 지킨다.
SNI는 관측자에게 정확히 무엇을 알려주며, ECH는 그걸 어떻게 가리는가?
SNI(Server Name Indication)는 TLS ClientHello에 담기는 접속 대상 호스트명이다. 한 IP가 여러 도메인을 호스팅할 때 서버가 어떤 인증서를 줄지 고르라고 넣는 값인데, ClientHello는 세션 키 협상 전이라 평문이다. 그래서 회선 중간의 관측자(ISP·Wi-Fi·검열자)는 내용은 못 봐도 "이 사람이 example.com에 접속 중"이라는 사실은 그대로 읽는다. IP와 DNS 조회까지 합치면 방문 도메인은 사실상 드러난다.
ECH(Encrypted Client Hello)는 이 민감한 ClientHello 부분을, DNS로 미리 배포된 공개키로 암호화해 감춘다. 겉으로는 공용 프론트엔드 이름(예: CDN의 공유 도메인)만 평문으로 보이고, 진짜 대상 호스트명은 암호문 안에 있다. 다만 효과는 여러 사이트가 같은 프론트엔드 뒤에 모여 있을 때 익명 집합이 커져야 제대로 나므로, ECH 단독으로 완벽한 은닉이 되는 건 아니다.
mTLS는 일반 HTTPS 대비 어떤 위협을 추가로 막는가, 모바일에서 클라이언트 인증서는 어디에 보관하나?
일반 HTTPS는 서버만 인증한다(클라이언트는 서버가 진짜인지 확인). mTLS는 여기에 클라이언트 인증을 더해, 서버도 상대가 정당한 클라이언트인지 인증서로 검증한다. 그래서 bearer token만으로는 못 막는 위협 — 훔친 토큰의 다른 기기 재생, 미승인 클라이언트/봇의 API 직접 호출 —을 추가로 차단한다. 토큰이 "무엇을 알고 있나"라면 클라이언트 인증서는 "누구의 어느 기기인가"라는 두 번째 요소가 된다.
모바일에서 클라이언트 개인키는 Keychain에, 이상적으로는 Secure Enclave에서 생성해 키가 앱 메모리로 나오지 않게 둔다. 인증 시엔 URLSessionDelegate의 didReceive challenge에서 NSURLAuthenticationMethodClientCertificate에 응답해 SecIdentity를 제출한다. 다만 개인 CA·인증서 프로비저닝·폐기 인프라가 필요해, 보통 B2B/기업·고민감 앱에서 채택한다.
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 키는 클라이언트에 넣지 말고 백엔드 프록시를 세워 서버가 대신 호출하게 하라.
import Foundation
// 클라이언트에 박힌 키는 반드시 추출된다. 유일한 구조적 해법은
// 그 키를 클라이언트에 두지 않는 것이다.
// ❌ 지도·결제·번역 API 키를 앱에
enum BadConfig {
static let mapsAPIKey = "AIzaSyD-1234567890abcdefghijk" // strings 한 줄에 노출
}
// ✅ 우리 서버가 대신 호출하고, 앱은 우리 서버만 부른다
struct PlacesClient {
func search(_ query: String) async throws -> [Place] {
var request = URLRequest(url: URL(string: "https://api.myapp.com/proxy/places")!)
request.httpMethod = "POST"
request.setValue("Bearer \(try await session.token())",
forHTTPHeaderField: "Authorization") // ✅ 우리 인증만
request.httpBody = try JSONEncoder().encode(["q": query])
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode([Place].self, from: data)
}
private let session = SessionProvider()
}
/* 프록시가 주는 이득
· 제3자 키가 서버에만 존재한다
· 사용량·비용을 서버에서 통제한다 (레이트리밋, 쿼터)
· 키 로테이션이 앱 배포 없이 가능하다
· 응답을 캐시해 제3자 호출 자체를 줄일 수 있다
· 어떤 사용자가 얼마나 썼는지 추적된다 */
// ⚠️ 프록시 자체가 공개돼 있으면 그것도 남용된다 →
// 반드시 우리 앱의 인증 + App Attest로 보호한다(다음 항목)
// ✅ 클라이언트에 둬도 되는 것: '공개된 식별자'
// 예) Firebase의 apiKey는 사실 식별자다(보안은 규칙으로 한다).
// 하지만 그 판단은 서비스 문서를 확인하고 내려야 한다.
struct Place: Decodable {}
struct SessionProvider { func token() async throws -> String { "" } }② 백엔드를 App Attest/DeviceCheck로 보호해, 리버싱한 클라이언트나 봇의 요청을 걸러라.
import Foundation
import DeviceCheck
import CryptoKit
// App Attest는 "이 요청이 정품 앱, 정품 기기에서 왔다"를 하드웨어로 증명한다.
// 리버싱한 클라이언트나 스크립트 봇을 걸러낼 수 있다.
actor AttestationService {
private var keyID: String?
/// 최초 1회 — 키를 만들고 서버에 등록한다
func register() async throws {
let service = DCAppAttestService.shared
guard service.isSupported else { throw AttestError.unsupported }
let keyID = try await service.generateKey()
self.keyID = keyID
// 서버가 준 challenge에 대해 증명서를 만든다
let challenge = try await api.challenge()
let hash = Data(SHA256.hash(data: Data(challenge.utf8)))
let attestation = try await service.attestKey(keyID, clientDataHash: hash)
try await api.registerAttestation(keyID: keyID, attestation: attestation)
UserDefaults.standard.set(keyID, forKey: "attestKeyID")
}
/// 이후 요청마다 — 훨씬 가벼운 assertion을 붙인다
func assertion(for body: Data) async throws -> Data {
guard let keyID = keyID ?? UserDefaults.standard.string(forKey: "attestKeyID")
else { throw AttestError.notRegistered }
let hash = Data(SHA256.hash(data: body))
return try await DCAppAttestService.shared
.generateAssertion(keyID, clientDataHash: hash)
}
}
// 요청에 붙인다
func attestedRequest(_ body: Data, service: AttestationService) async throws -> URLRequest {
var r = URLRequest(url: URL(string: "https://api.myapp.com/proxy/places")!)
r.httpMethod = "POST"
r.httpBody = body
let assertion = try await service.assertion(for: body)
r.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Attest")
return r
}
// ⚠️ 서버가 반드시 검증해야 의미가 있다 — 클라가 헤더를 붙이는 것만으로는 아무것도 아니다.
// ⚠️ 시뮬레이터·일부 기기에서는 지원되지 않는다 → 폴백 경로가 필요하다.
enum AttestError: Error { case unsupported, notRegistered }
enum api {
static func challenge() async throws -> String { "" }
static func registerAttestation(keyID: String, attestation: Data) async throws {}
}③ 난독화는 defense-in-depth의 한 겹으로만 취급하라 — 시간은 벌지만 유일 방어로 삼으면 안 된다.
import Foundation
// 난독화는 추출 '시간'을 늘린다. 방어선이 아니라 속도 조절 장치다.
// ❌ 이런 걸 '보안'이라고 부르면 안 된다
enum WeakObfuscation {
private static let parts = ["sk_", "live_", "abc", "123"]
static var key: String { parts.joined() } // 문자열 결합
}
enum XORObfuscation {
private static let cipher: [UInt8] = [0x12, 0x34, 0x56]
private static let mask: UInt8 = 0x5A
static var key: String {
String(bytes: cipher.map { $0 ^ mask }, encoding: .utf8) ?? ""
}
// 디버거로 이 함수의 반환값을 한 번 찍으면 끝난다
}
/* 실제로 얼마나 쉬운지
strings MyApp.app/MyApp | grep -i 'sk_\|api\|key'
class-dump / Hopper / Ghidra 로 로직 확인
Frida로 런타임에 함수 반환값 후킹:
Interceptor.attach(addr, { onLeave(retval) { console.log(retval) } })
→ 난독화된 키는 '메모리에 평문으로 존재하는 순간'에 그대로 잡힌다. */
// ✅ 난독화가 정당한 위치: 다층 방어의 '한 겹'
// ① 키를 서버로 옮긴다 ← 근본 해결
// ② App Attest로 요청을 검증한다 ← 남용 차단
// ③ 서버에서 레이트리밋·이상 탐지 ← 피해 제한
// ④ 난독화 ← 여기. 앞의 셋이 있을 때만 의미가 있다
// ✅ 정말 클라이언트에 있어야 하는 값이면 '유출을 전제로' 설계한다
// · 그 값으로 할 수 있는 일의 범위를 최소화 (읽기 전용, 특정 엔드포인트만)
// · 서버에서 사용량 상한을 건다
// · 유출 탐지 시 로테이션할 수 있는 구조를 미리 만든다④ 키를 소스·plist·git 히스토리에 커밋하지 말고 secret 스캐닝(예: git hook, CI)으로 회귀를 막아라. 이미 커밋된 키는 rotate하라(히스토리에 영구히 남는다).
# 이미 커밋된 키는 히스토리에 영구히 남는다. 지워도 과거 커밋에서 복구된다.
# → 유출된 순간 rotate가 유일한 대응이다.
# ✅ ① 로컬 pre-commit 훅으로 1차 차단
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
if git diff --cached | grep -nE '(sk_live_|AIza[0-9A-Za-z_-]{35}|-----BEGIN.*PRIVATE KEY)'; then
echo "❌ 시크릿으로 보이는 문자열이 있습니다. 커밋을 중단합니다."
exit 1
fi
HOOK
chmod +x .git/hooks/pre-commit
# ✅ ② 훅은 우회 가능하므로 CI에서도 검사한다 (이게 진짜 방어선)
# GitHub Actions 예:
# - uses: gitleaks/gitleaks-action@v2
# 또는
brew install gitleaks
gitleaks detect --source . --redact
# ✅ ③ 설정 파일은 커밋하지 않고 템플릿만 둔다
# Secrets.xcconfig → .gitignore
# Secrets.xcconfig.sample → 커밋 (키 자리는 비워 둠)
echo "Secrets.xcconfig" >> .gitignore
# ✅ ④ CI에서는 환경변수·시크릿 저장소에서 주입한다
# xcodebuild ... API_BASE="$API_BASE"
# ⚠️ xcconfig·Info.plist에 넣은 값도 결국 바이너리에 박힌다.
# "소스에 안 보인다"와 "앱에서 추출 불가"는 전혀 다른 이야기다.
strings MyApp.app/MyApp | grep -i 'api_key'
# ✅ ⑤ 이미 커밋된 키를 발견했다면
# 1) 즉시 해당 키를 서비스 콘솔에서 폐기(rotate)
# 2) 새 키는 서버로 옮기거나 시크릿 저장소에 둔다
# 3) 히스토리 정리는 부차적이다 — 이미 노출됐다고 가정하고 대응한다
git log -S 'sk_live_' --oneline # 언제 들어왔는지 확인"난독화하면 안전하다" 혹은 "환경변수·xcconfig에 넣으면 코드에 안 보인다"는 착각이 흔하다. 난독화는 추출 시간을 늘릴 뿐 결국 뚫리고, 빌드 설정에 넣은 값도 컴파일 시 바이너리에 박혀 strings 한 줄에 노출될 수 있다. "안 보이게"와 "못 가져가게"는 전혀 다른 이야기다.
앱을 배포한다는 건 자판기를 통째로 남의 집 거실에 갖다 놓는 것과 같다. 그 자판기 안에 금고 열쇠를 숨겨두면 어떻게 될까. 집주인은 시간이 무제한이고 공구도 마음대로 쓸 수 있으니, 뜯어보다 결국 찾아낸다. 나사를 특이한 걸로 박든(난독화), 열쇠를 세 조각으로 나눠 다른 칸에 숨기든 마찬가지다 — 자판기가 물건을 내주려면 언젠가는 그 열쇠를 꺼내 자물쇠에 꽂아야 하고, 집주인은 그 순간만 지켜보면 되기 때문이다.
그래서 방향을 아예 바꾼다. 진짜 열쇠는 우리 가게(서버)에 두고, 자판기는 주문서만 우리 가게로 보낸다. 그럼 "아무 자판기나 주문서를 보내면 어쩌지?"가 남는데, 이건 열쇠를 숨겨서 푸는 문제가 아니라 제조사가 발급한 정품 인증서를 매번 붙여 보내게 해서 푼다. 그게 App Attest다. 오해 하나만 짚자면, 난독화가 쓸모없다는 말은 아니다. 도둑이 뜯는 데 걸리는 시간을 늘리는 자물쇠 한 겹은 되지만, 그것 하나만 믿고 진짜 열쇠를 넣어두면 안 된다.
꼬리 질문
App Attest와 DeviceCheck는 각각 무엇을 증명하며, 어떤 상황에 어느 쪽을 쓰는가?
App Attest는 "이 요청이 Apple이 서명한 정품 앱 바이너리에서, 정품 기기의 Secure Enclave 하드웨어 키로 만들어졌다"를 서버에 증명한다. 서버가 nonce를 주면 앱이 Secure Enclave 키로 attestation(어테스테이션) 객체를 만들고, 서버는 이를 Apple의 App Attest 인증 체인으로 검증한 뒤 거기서 추출한 공개키를 저장한다. 이후 요청마다 앱이 그 키로 assertion을 서명하면, 서버는 Apple 체인이 아니라 저장해 둔 공개키로 검증한다. 즉 앱 무결성·환경 정품성을 확인해, 리버싱한 클라이언트나 봇의 위조 요청을 걸러낼 때 쓴다.
DeviceCheck는 성격이 다르다. 기기당 서버가 세팅할 수 있는 비트 두 개(+마지막 갱신 시각)를 Apple 서버에 보관하는, 앱 재설치나 데이터 초기화에도 살아남는 영속 플래그다. "이 기기는 이미 무료 체험을 썼다" 같은 남용 방지 상태를 앱을 지워도 유지하고 싶을 때 쓴다. 정리하면 요청마다 무결성을 검증하려면 App Attest(및 후속 assertion), 기기 단위 남용 상태를 오래 들고 있으려면 DeviceCheck다.
불가피하게 대칭키를 임베드해야 한다면 피해를 어떻게 최소화하는가(키 회전·서버 발급·용도 제한)?
전제는 "임베드한 키는 언젠가 새 나온다"이므로, 목표는 방지가 아니라 새 나갔을 때의 피해 반경을 좁히는 것이다. 첫째, 용도를 최대한 제한한다 — 그 키로 서버가 허용하는 동작을 좁게(읽기 전용, 특정 엔드포인트, 낮은 rate limit) 잡아 유출돼도 할 수 있는 게 적게 한다. 둘째, 정적으로 박지 말고 부트스트랩만 임베드해 실제 키는 서버가 발급하는 단명 키로 바꾸고, App Attest로 발급 요청을 게이트한다.
셋째, 키를 회전 가능하게 설계해 유출 탐지 시 원격으로 폐기·교체하고, 앱 버전/키 버전을 태깅해 오래된 키를 서버가 거부하게 한다. 넷째, 유출을 관측할 수 있게 이상 사용 모니터링을 붙인다. 이런 통제 없이 "영구·전권 키"를 박는 게 가장 위험한 형태다.
white-box cryptography나 키 분할(split key)이 주는 보호의 실제 한계는 어디까지인가?
white-box crypto는 키를 알고리즘 룩업 테이블 안에 녹여 "키라는 별도 값"이 메모리에 뜨지 않게 하고, split key는 키를 여러 조각·여러 시점으로 나눠 어느 한 순간에 온전한 키가 존재하지 않게 한다. 둘 다 정적 추출과 단순 메모리 덤프를 어렵게 만들어 리버싱 비용과 시간을 올린다.
한계는 근본적이다. 결국 그 키로 정당한 연산을 해야 하고, 공격자는 그 연산 지점을 Frida 등으로 후킹해 입출력을 관찰하거나, 소프트웨어 white-box를 대상으로 한 DFA(differential fault analysis)·차분 계산 분석 같은 전용 공격을 걸 수 있다. 상용 white-box도 반복적으로 학술 공격에 뚫려 왔다. 그래서 이들은 "결정적 방어"가 아니라 defense-in-depth의 한 겹일 뿐이고, 진짜 해법은 여전히 키를 클라이언트에 두지 않고 서버 검증·App Attest로 옮기는 것이다.
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로 통일하라.
import Foundation
import OSLog
private let log = Logger(subsystem: "com.app", category: "network")
// os.Logger는 동적 값을 기본적으로 <private>로 가려 준다.
// 하지만 스칼라는 기본 public이고, .public을 명시하면 무엇이든 노출된다.
func logging(_ url: URL, status: Int, token: String, userID: Int) {
// ✅ 동적 문자열 — 기본 private이지만 의도를 명시하는 편이 안전하다
log.debug("요청 \(url.absoluteString, privacy: .private)")
// ⚠️ 숫자는 기본 public이다 — 민감하면 반드시 명시해야 한다
log.info("상태 \(status, privacy: .public)")
log.info("사용자 \(userID, privacy: .private)") // ✅ ID도 PII일 수 있다
// ✅ 원문은 감추고 동일성(상관관계)만 남기는 해시 마스킹
log.debug("토큰 \(token, privacy: .private(mask: .hash))")
}
// ❌ 이런 것들은 릴리스에서도 남는다
func badLogging(_ token: String) {
NSLog("token=%@", token) // 통합 로깅 → sysdiagnose로 회수된다
print("token=\(token)") // 릴리스 기기에선 시스템 로그에 안 남지만
// Xcode 콘솔·디바이스 로그로 노출된다
}
// ✅ 로그 레벨도 의미에 맞게 쓴다
// .debug 개발 중에만. 릴리스에서는 기본적으로 저장되지 않는다.
// .info 문제 진단에 도움. 메모리에만 남는다.
// .notice 기본. 디스크에 영속 저장된다.
// .error / .fault 항상 저장된다.
//
// ⚠️ .notice 이상은 디스크에 남으므로 민감 정보를 절대 넣지 않는다
// ✅ 카테고리를 나눠 필요할 때만 켠다
private let authLog = Logger(subsystem: "com.app", category: "auth")
// 콘솔에서 필터: subsystem:com.app category:auth② 상세 네트워크 로깅은 #if DEBUG로 감싸 릴리스 빌드에서 컴파일 자체를 배제하라 — 런타임 플래그로만 끄면 코드가 바이너리에 남는다.
import Foundation
import OSLog
// 런타임 플래그로만 끄면 코드와 문자열이 바이너리에 그대로 남는다.
// strings로 추출되고, 후킹으로 다시 켤 수도 있다.
// ❌ 런타임 스위치 — 코드가 남는다
enum BadLogger {
static var isVerbose = false
static func dump(_ request: URLRequest) {
guard isVerbose else { return }
print(request.allHTTPHeaderFields ?? [:]) // 이 문자열이 바이너리에 있다
}
}
// ✅ 컴파일 단계에서 제거 — 릴리스 바이너리에 흔적이 없다
enum NetworkLogger {
#if DEBUG
static func dump(_ request: URLRequest) {
let logger = Logger(subsystem: "com.app", category: "net")
logger.debug("""
\(request.httpMethod ?? "GET", privacy: .public) \
\(request.url?.absoluteString ?? "", privacy: .private)
headers: \(request.allHTTPHeaderFields ?? [:], privacy: .private)
""")
}
static func dump(_ response: URLResponse, body: Data) {
let logger = Logger(subsystem: "com.app", category: "net")
logger.debug("body \(String(data: body, encoding: .utf8) ?? "", privacy: .private)")
}
#else
@inline(__always) static func dump(_ request: URLRequest) {}
@inline(__always) static func dump(_ response: URLResponse, body: Data) {}
#endif
}
// ✅ 검증: 릴리스 바이너리에 로깅 문자열이 남지 않았는지 확인한다
// strings MyApp.app/MyApp | grep -i 'headers:'
// (결과가 없어야 한다)
// ⚠️ 서드파티 네트워크 로거(Alamofire의 EventMonitor 등)도 마찬가지로
// DEBUG에서만 붙인다
func makeSession() -> URLSession {
let config = URLSessionConfiguration.default
#if DEBUG
// 디버그 전용 프로토콜·모니터를 여기서만 붙인다
#endif
return URLSession(configuration: config)
}③ 크래시 리포터는 PII 자동 수집을 끄고 beforeSend에서 민감 필드를 스크러빙하라. breadcrumb/커스텀 키에 원문 시크릿을 넣지 마라.
import Foundation
// 크래시 리포터는 자동으로 많은 걸 수집한다. 기본값 그대로 두면
// 요청 헤더·사용자 정보·breadcrumb에 시크릿이 실려 나간다.
// Sentry 예시 (다른 SDK도 개념은 같다)
func configureCrashReporter() {
/*
SentrySDK.start { options in
options.dsn = "https://…"
// ✅ ① 자동 PII 수집을 끈다 (IP·사용자 정보 등)
options.sendDefaultPii = false
// ✅ ② 전송 직전에 민감 필드를 지운다
options.beforeSend = { event in
event.request?.headers?["Authorization"] = "[redacted]"
event.request?.headers?["Cookie"] = "[redacted]"
event.request?.queryString = nil // 쿼리에 토큰이 있을 수 있다
event.user?.email = nil
event.user?.ipAddress = nil
// 커스텀 컨텍스트도 훑는다
if var extra = event.extra {
for key in extra.keys where key.lowercased().contains("token") {
extra[key] = "[redacted]"
}
event.extra = extra
}
return event
}
// ✅ ③ breadcrumb도 스크러빙한다 — 여기가 특히 새기 쉽다
options.beforeBreadcrumb = { crumb in
if crumb.category == "http", var data = crumb.data {
data["url"] = (data["url"] as? String)
.map { $0.components(separatedBy: "?").first ?? $0 } // 쿼리 제거
crumb.data = data
}
return crumb
}
}
*/
}
// ✅ 우리가 직접 넣는 컨텍스트도 규칙을 정한다
enum CrashContext {
/// 허용 목록 방식 — 넣어도 되는 것만 명시한다
static let allowedKeys: Set<String> = ["screen", "feature_flag", "build_number"]
static func set(_ key: String, _ value: String) {
guard allowedKeys.contains(key) else {
assertionFailure("허용되지 않은 크래시 컨텍스트 키: \(key)")
return
}
// SentrySDK.configureScope { $0.setTag(value: value, key: key) }
}
}
// ⚠️ 로그를 크래시 리포트에 첨부하는 기능이 있으면 그 로그도 스크러빙 대상이다④ 로그 문자열에 대한 린트 룰이나 CI 스캐너로 "토큰/이메일/카드번호 패턴" 로깅을 자동 차단해 회귀를 막아라.
# 사람의 주의력에 의존하면 반드시 새어 나간다. 자동화가 유일한 방어선이다.
# ✅ ① 위험한 로깅 호출 자체를 금지한다 (SwiftLint 커스텀 룰)
cat >> .swiftlint.yml <<'YAML'
custom_rules:
no_print_in_production:
name: "print 사용 금지"
regex: '(^|\s)print\('
match_kinds: [identifier]
message: "print 대신 Logger를 쓰세요. 디버그 전용이면 #if DEBUG로 감싸세요."
severity: error
excluded: ".*Tests?\.swift"
no_nslog:
name: "NSLog 사용 금지"
regex: 'NSLog\('
message: "NSLog는 릴리스 시스템 로그에 영속 저장됩니다. Logger를 쓰세요."
severity: error
no_public_privacy_on_secrets:
name: "민감 값에 privacy: .public 금지"
regex: '(token|password|secret|apiKey|email|ssn)[^)]*privacy:\s*\.public'
message: "민감 값을 public으로 로깅하고 있습니다."
severity: error
YAML
# ✅ ② 커밋되는 코드에서 시크릿 패턴을 잡는다
gitleaks detect --source . --redact --exit-code 1
# ✅ ③ 빌드 산출물에서 실제로 문자열이 남았는지 검사한다 (가장 확실하다)
strings "$BUILT_PRODUCTS_DIR/$EXECUTABLE_PATH" \
| grep -nE 'sk_live_|AIza[0-9A-Za-z_-]{35}|BEGIN PRIVATE KEY' \
&& { echo "❌ 바이너리에 시크릿 문자열이 있습니다"; exit 1; }
# ✅ ④ CI 파이프라인에 전부 묶어 머지 게이트로 만든다
# - swiftlint --strict
# - gitleaks detect
# - 위 strings 검사를 Run Script Phase 또는 CI 스텝으로
# ⚠️ 룰은 만들어 놓고 warning으로 두면 아무도 안 고친다.
# error + 머지 차단이어야 실제로 작동한다."os_log/Logger는 알아서 다 가려준다"는 말은 부분적으로만 맞다. 동적 문자열은 기본 private지만 숫자 같은 스칼라는 기본 public이고, privacy: .public을 명시하면 무엇이든 노출된다. 또 "릴리스 빌드면 로그가 안 남는다"는 절반만 맞다 — NSLog는 통합 로깅으로 릴리스 시스템 로그에 영속 저장되어 sysdiagnose/Console로 회수되고, print는 stdout으로 나가 릴리스 디바이스에선 시스템 로그에 남지 않지만 개발 중 Xcode 콘솔에 그대로 노출되고 privacy 가림이 없어, 둘 다 시크릿에 써선 안 된다.
로그는 내 일기장이 아니라 공장 CCTV에 가깝다. 나 혼자 잠깐 보고 지우는 게 아니라 자동으로 수집되고, 여러 사람이 열어보고, 몇 달씩 보관된다. 그러니 토큰 한 줄이 찍히는 순간 그건 이미 여러 서버로 복사되는 중이라고 보는 게 맞다. 나중에 지우려면 이미 퍼진 사본을 전부 쫓아다녀야 하는데 그건 사실상 불가능하다. 그래서 원칙이 "나중에 지우기"가 아니라 "찍히는 순간 모자이크"가 된다.
여기서 제일 잘 걸려 넘어지는 지점이 하나 있다. os.Logger(애플이 만든 로그 도구)에는 자동 모자이크 기능이 있는데, 이게 글자만 가리고 숫자나 true/false는 그냥 내보낸다. 이름이나 토큰 같은 문자열은 알아서 <private>로 덮이지만, 계좌번호나 위도·경도처럼 숫자로 된 민감한 값은 내가 직접 privacy: .private를 붙여줘야 가려진다. 그리고 print와 NSLog에는 모자이크 기능 자체가 없다 — 렌즈 뚜껑이 아예 안 달린 카메라인 셈이다.
꼬리 질문
Logger 보간에서 기본 private인 타입과 기본 public인 타입은 각각 무엇이고, 왜 그렇게 나뉘었는가?
보간에 들어가는 값이 동적 문자열이나 객체(String, NSObject 등 임의 텍스트)면 기본 private라 릴리스에서 <private>로 가려진다. 반면 Int·Bool 같은 스칼라·고정 크기 값은 기본 public이라 그대로 찍힌다.
이 구분의 논리는 "어디에 PII가 실릴 확률이 높은가"다. 사용자 이름·토큰·URL 같은 개인정보는 대부분 자유형 문자열로 들어오니 안전한 쪽(가림)을 기본값으로 잡았고, 상태 코드·카운트·플래그 같은 스칼라는 진단에 필수적이고 그 자체론 식별력이 낮다고 보아 열어 뒀다. 문제는 스칼라도 얼마든지 민감할 수 있다는 것(계좌번호, 좌표)이라, 그런 값엔 privacy: .private를 명시해야 한다. 반대로 정적 리터럴(포맷 문자열 자체)은 항상 그대로 보인다.
privacy: .private를 붙여 줘야 가려진다.크래시 리포트의 힙·레지스터에 남는 문자열 유출을 줄이려면 어떤 코딩 습관이 필요한가(민감 값의 조기 zeroing 등)?
핵심은 민감 값의 메모리 수명을 짧게, 흔적을 적게 만드는 것이다. 토큰·키를 쓰고 나면 곧바로 버퍼를 0으로 덮어(zeroing) 힙에 오래 방치하지 않는다. Swift의 String은 copy-on-write에 불변이라 명시적 zeroing이 어려우니, 진짜 민감한 원문(키·비밀번호)은 Data나 [UInt8] 같은 가변 버퍼로 다뤄 resetBytes(in:)/memset_s로 지울 수 있게 하고, 가능하면 SymmetricKey·Secure Enclave처럼 값이 앱 메모리에 평문으로 상주하지 않는 API를 쓴다.
또 민감 값을 함수 인자로 길게 물고 다니면 레지스터·스택·스필 슬롯에 사본이 흩어져 크래시 덤프에 남을 확률이 커지므로, 스코프를 좁게 유지하고 필요 지점에서만 잠깐 복호화한다. 다만 컴파일러 최적화가 zeroing을 "쓰이지 않는 쓰기"로 제거할 수 있어 memset_s처럼 제거 방지 보장이 있는 API를 쓰는 게 안전하다. 이건 잔여 위험을 줄이는 방어 심화지, at-rest 통제를 대체하진 않는다.
String은 지우개가 안 먹으니 진짜 민감한 값은 Data나 [UInt8]로 들고 다닌다. 함정 하나 — 컴파일러가 "어차피 다시 안 읽을 값을 왜 덮어?" 하고 그 지우는 코드를 통째로 없앨 수 있어서, 안 지워진다고 보장된 memset_s 같은 걸 쓴다.PII를 원문 대신 해시로 로깅하는 것이 정당화되는 경우와, 그럼에도 위험한 경우(저엔트로피 값의 역추적)는?
정당화되는 건 "값 자체는 필요 없고 상관관계만 필요할 때"다. 같은 사용자의 로그를 이어 보거나 중복을 세는 용도라면, 원문 대신 안정적 해시(Logger의 .private(mask: .hash) 등)로 동일성만 유지하면 충분하다. 고엔트로피 값(랜덤 UUID, 긴 토큰)은 해시에서 원문을 되돌리기 사실상 불가능하다.
위험한 건 입력 공간이 좁은(저엔트로피) 값이다. 전화번호·이메일·주민번호·카드번호처럼 가능한 값의 집합이 작거나 형식이 정해진 데이터는, 공격자가 그 공간을 통째로 해시해 사전(rainbow table)을 만들어 즉시 역추적한다. 해시는 비밀이 아니라 결정적 함수라 salt 없이는 익명화가 아니다. 그래서 이런 값은 사용자마다 다른 salt/pepper를 섞거나, 아예 원문 일부만 남기는 부분 마스킹(****1234)이 더 안전하고, 규제상으로도 단순 해시를 익명화로 인정하지 않는 경우가 많다.
****1234처럼 뒷자리만 남기는 편이 낫다.