← Study Hub

11 · 보안과 암호학

민감 정보를 어디에·어떻게 저장/전송/은닉하고, 무엇이 실제로 보호되는지를 CS 원리와 iOS 구현으로 잇는다
진행률
0 / 0 완료

← 챕터 목록

보안 질문은 대부분 "무엇을, 누구로부터, 어느 시점에 지키는가"로 환원된다. 이 챕터는 용어 혼동(인코딩·해싱·암호화)에서 시작해 저장 위치 선택, 토큰 수명, 전송 보안의 경계, 클라이언트 시크릿의 근본적 한계, 로그·크래시 유출까지 이어지는 여섯 개의 핵심 판단을 다룬다.

Q1. Encoding, Hashing, Encryption은 무엇이 다른가?

🔑 30초 답변

세 가지를 가르는 축은 "되돌리는 데 무엇이 필요한가"다. 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)로 갈린다. 기밀성을 지키지만 그 안전은 키 관리로 옮겨간다 — 키가 새면 끝이다.

Encoding 원본 encode Base64 문자열 decode 원본 키 없이 복원 Hashing 원본 SHA-256 32B 다이제스트 ✕ 복원 불가 Encryption 평문 암호화 암호문 복호화 평문 키로만 복원
세 변환의 결정적 차이는 "복원 조건"이다: 공개 규칙(Encoding) · 불가능(Hashing) · 키(Encryption).

iOS에서는

Encoding은 Foundation으로 처리한다 — Data.base64EncodedString(), Data(base64Encoded:), UTF-8 변환은 String(data:encoding:), URL 이스케이프는 addingPercentEncoding(withAllowedCharacters:). 해싱·암호화는 CryptoKit이 표준이다: 해시는 SHA256.hash(data:), 키가 있는 무결성은 HMAC, 대칭 암호화는 AES.GCM.seal/openChaChaPoly, 비대칭은 P256·Curve25519. 특히 개인키를 Secure Enclave에서 생성하면 키가 앱 메모리로 절대 빠져나오지 않는다. CryptoKit에는 Argon2/bcrypt가 없으므로, 비밀번호 KDF는 CommonCrypto의 CCKeyDerivationPBKDF(PBKDF2)를 쓰거나 별도 라이브러리를 붙인다.

실험 · 도구

세 변환을 한 파일에서 돌려보면 성질이 손에 잡힌다. Base64는 왕복이 되고, SHA-256은 입력 1글자만 바꿔도 출력이 완전히 달라지며(눈사태), AES-GCM은 같은 키로만 열린다는 것을 확인한다.

세 변환 비교 (Swift · CryptoKit)
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)   // 같은 키로만 복호화

프로젝트 적용

⚠️ 흔한 오해

"해시는 암호화의 한 종류다"라는 말은 틀렸다. 해시는 복호화 자체가 불가능한 단방향 함수이고 목적도 다르다(무결성·지문). 또 "Base64는 데이터를 안 보이게 해준다"는 것도 착각이다 — 인코딩은 표현 형식만 바꿀 뿐 누구나 즉시 디코딩한다.

꼬리 질문

Q2. 민감 정보별로 Keychain, 파일, Database 중 어디에 저장해야 하는가?

🔑 30초 답변

기준은 민감도 · 크기 · 접근 패턴이다. 토큰·비밀번호·암호키 같은 작은 비밀은 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 항목 작은 secret 파일 (Data Protection) UserDefaults 평문 · 통제 없음 ↑ 보호 계층 미적용 클래스 키 = Passcode + 하드웨어 키 파생 Secure Enclave · 하드웨어 UID 키
Keychain과 보호된 파일은 하드웨어 키 + passcode에서 파생된 클래스 키로 잠긴다. UserDefaults는 이 계층 바깥이라 secret을 두면 안 된다.
데이터 유형Keychain파일 + 보호DBUserDefaults
토큰 · 키 · 비밀번호권장가능(직접 암호화)비권장금지
대용량 민감 파일부적합(크기)권장(complete)가능(BLOB)금지
대량 구조화 데이터부적합가능권장(+보호클래스)금지
비민감 설정 · 플래그과함가능가능적합

프로젝트 적용

⚠️ 흔한 오해

"Keychain에 넣으면 탈옥 기기에서도 절대 못 빼간다"는 과신은 위험하다. 기기가 잠금해제된 상태이고 탈옥돼 있으면, 접근성 클래스에 따라 항목이 읽힐 수 있다. 그래서 접근성 속성과 생체인증 게이트를 목적에 맞게 좁히는 것이 핵심이지, "Keychain = 무적"이 아니다.

꼬리 질문

Q3. Access token과 Refresh token은 어떻게 저장하고 갱신해야 하는가?

🔑 30초 답변

둘 다 소지자에게 권한을 주는 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를 붙들고 있다가, 동시에 들어온 호출들이 모두 그 하나의 결과에 합류하게 만든다.

클라이언트 리소스 API 인증 서버 ① 요청 + access token ② 401 (만료) ③ refresh token 제출 ④ 새 access + refresh (rotation) ⑤ 재요청 + 새 access ⑥ 200 OK 동시 401은 single-flight로 refresh 1회만 실행 → 나머지 요청은 결과를 공유
401 → refresh → 재시도의 표준 흐름. rotation으로 refresh token도 매번 교체되고, 동시 만료는 single-flight로 한 번만 갱신한다.
actor 기반 single-flight 토큰 갱신 (Swift Concurrency)
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에 덮어쓰기
        // 실패(무효화)면 로그아웃 처리
        ...
    }
}

프로젝트 적용

⚠️ 흔한 오해

"refresh token은 만료가 길거나 없으니 한 번 저장하면 끝"이라는 생각은 위험하다. 오래 살수록 표적 가치가 커지므로, rotation과 재사용 탐지가 없으면 한 번의 유출이 영구 세션 탈취로 이어진다. 반대로 "access token을 길게 잡으면 갱신 로직이 필요 없어 편하다"도 함정이다 — 유출 시 악용 가능한 시간 창이 그만큼 넓어진다.

꼬리 질문

Q4. HTTPS는 무엇을 보호하고 무엇은 보호하지 못하는가?

🔑 30초 답변

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는 "회선"만 다루므로 회선 양 끝의 안전은 별개 문제다.

클라이언트 (앱 · 기기) 서버 (엔드포인트) TLS 터널 기밀성 · 무결성 · 서버 인증 at-rest 미보호 저장 · 로직 별개 TLS가 못 지키는 것 엔드포인트 취약점 · 저장 데이터(at-rest) · 메타데이터(SNI · IP · 크기/타이밍)
TLS는 두 점 사이의 파이프를 지킨다. 파이프 양 끝의 안전과 파이프의 "겉모습"(메타데이터)은 TLS 밖의 문제다.

iOS에서는

iOS는 App Transport Security(ATS)로 기본을 강제한다 — TLS 1.2 이상, forward secrecy, 유효한 인증서. 예외는 Info.plist에 명시해야 하고 리뷰 대상이 된다. URLSession은 시스템 신뢰 저장소를 쓰므로, 사용자가 프로파일로 설치한 커스텀 CA도 신뢰될 수 있다는 점이 함정이다. 이를 막으려면 URLSessionDelegateurlSession(_:didReceive:completionHandler:)에서 SecTrust를 검사해 인증서/공개키 pinning을 건다 — pinning은 정상적으로 신뢰되는 rogue CA(기업 프록시·중간자)까지 차단한다. 클라이언트 인증서로 서버가 클라이언트도 검증하는 mTLS, SNI를 감추는 ECH도 최신 iOS에서 지원된다.

프로젝트 적용

⚠️ 흔한 오해

"HTTPS면 MITM(중간자)은 절대 불가능하다"는 말은 조건부로만 맞다. 사용자가 신뢰하는 CA(기업 MDM 프로파일 등)가 기기에 깔려 있고 앱이 pinning을 하지 않으면, 그 CA로 서명된 가짜 인증서로 트래픽이 합법적으로 가로채질 수 있다. TLS는 "신뢰 앵커가 정직하다"는 가정 위에서 동작한다.

꼬리 질문

Q5. 앱 Binary에 Secret이나 API key를 안전하게 숨길 수 있는가?

🔑 30초 답변

근본적으로 불가능하다. 앱 바이너리는 공격자의 기기에서 실행되므로, 문자열 추출·디스어셈블·런타임 후킹으로 임베드된 어떤 시크릿도 결국 복구된다. 난독화·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는 기기별 상태 비트를 다룬다. 시크릿을 숨기는 게 아니라 "요청 자체를 인증"하는 접근이다.

프로젝트 적용

⚠️ 흔한 오해

"난독화하면 안전하다" 혹은 "환경변수·xcconfig에 넣으면 코드에 안 보인다"는 착각이 흔하다. 난독화는 추출 시간을 늘릴 뿐 결국 뚫리고, 빌드 설정에 넣은 값도 컴파일 시 바이너리에 박혀 strings 한 줄에 노출될 수 있다. "안 보이게"와 "못 가져가게"는 전혀 다른 이야기다.

꼬리 질문

Q6. 민감 정보가 Log와 Crash report에 남지 않게 하려면 어떻게 하는가?

🔑 30초 답변

로그와 크래시 리포트는 시크릿이 새는 가장 흔한 뒷문이다. 원칙은 기본값을 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) 로깅은 반드시 개발 빌드에 한정한다.

Logger 프라이버시 사용 (Swift · os)
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)")   // ✗
크래시 리포터 스크러빙 (개념 · Sentry 예시)
SentrySDK.start { options in
    options.sendDefaultPii = false                 // 자동 PII 수집 차단
    options.beforeSend = { event in
        event.request?.headers?["Authorization"] = nil   // 토큰 헤더 제거
        // breadcrumb/extra에서 민감 필드 마스킹
        return event
    }
}

프로젝트 적용

⚠️ 흔한 오해

"os_log/Logger는 알아서 다 가려준다"는 말은 부분적으로만 맞다. 동적 문자열은 기본 private지만 숫자 같은 스칼라는 기본 public이고, privacy: .public을 명시하면 무엇이든 노출된다. 또 "릴리스 빌드면 로그가 안 남는다"는 절반만 맞다 — NSLog는 통합 로깅으로 릴리스 시스템 로그에 영속 저장되어 sysdiagnose/Console로 회수되고, print는 stdout으로 나가 릴리스 디바이스에선 시스템 로그에 남지 않지만 개발 중 Xcode 콘솔에 그대로 노출되고 privacy 가림이 없어, 둘 다 시크릿에 써선 안 된다.

꼬리 질문

출처 · 참고자료