Ver4.0 앱 보안 기초 완전정복 : 데이터 암호화, 안전한 통신, 보안 저장소

앱 보안 기초 완전정복 : 데이터 암호화, 안전한 통신, 보안 저장소
"내 앱, 털리면 어쩌지?" 라는 불안을 3일치 야근으로 바꾸지 말고, 오늘 30분으로 끝내보자
모바일/앱 Android · iOS 암호화 · TLS · Keystore0. 시작하기 전에 : 우리 솔직해지자
친구야, 우리 솔직히 말해보자.
앱 만들 때 제일 재밌는 건 UI 예쁘게 뽑는 것이고, 제일 미루는 건 보안이지.
왜냐고? 보안은 잘 해도 티가 안 나고, 못 하면 회사가 없어지는 분야거든.
잘 만든 애니메이션은 "우와 예쁘다" 소리를 듣지만, 잘 짠 암호화 코드는 아무도 칭찬 안 해준다.
근데 말이야. 2020년대 들어서 상황이 완전히 바뀌었어.
한국은 개인정보 보호법이 있고, 유럽 서비스하면 GDPR이 기다리고, 앱스토어에 올리려면 애플의 App Privacy 심사를 통과해야 하고,
구글 플레이는 Data Safety 섹션을 강제로 작성하게 만들어.
이제 보안은 "여유 되면 하는 것"이 아니라 배포의 전제 조건이 된 거야.
이 글에서 다룰 것
1) 암호화의 진짜 기초 (대칭키 / 비대칭키 / 해시, 그리고 이걸 언제 쓰는지)
2) 안전한 통신 (HTTPS, TLS, 인증서 피닝, 그리고 흔한 삽질 모음)
3) 보안 저장소 (Android Keystore, iOS Keychain, Secure Enclave)
4) 실무에서 진짜 많이 터지는 사고 유형과 방어법
5) 배포 전 체크리스트
코드는 Kotlin / Swift / 일부 JS로 보여줄 거고,
개념 설명은 "전문용어 먼저, 비유 나중" 순서로 갈게. 그래야 나중에 문서 읽을 때 안 헤매거든.
1. 암호화 기초 : 3형제를 구분하자
암호화 얘기하면 사람들이 제일 많이 헷갈리는 게 이거야.
대칭키 · 비대칭키 · 해시. 이 셋은 용도가 완전히 다른데 그냥 "암호화"라고 뭉뚱그려 부르거든.
1-1. 대칭키 암호 (Symmetric) — 자물쇠 하나, 열쇠 하나
잠글 때 쓰는 키와 열 때 쓰는 키가 똑같은 방식이야.
대표 선수는 AES(Advanced Encryption Standard).
장점은 압도적으로 빠르다는 거야.
요즘 CPU에는 아예 AES 전용 명령어(AES-NI, ARM Cryptography Extensions)가 박혀 있어서,
기가바이트급 데이터도 순식간에 처리해.
단점? 키를 어떻게 상대방에게 안전하게 전달하느냐가 영원한 숙제야.
카톡으로 키 보내면? 그 카톡이 털리면 끝이잖아.
모드(Mode)가 진짜 중요하다
AES는 "블록 암호"라서 운영 모드를 정해야 해. 여기서 초보자들이 대참사를 일으켜.
| 모드 | 설명 | 권장 여부 |
|---|---|---|
| ECB | 블록을 독립적으로 암호화. 같은 평문 → 같은 암호문 | ❌ 절대 금지 |
| CBC | 이전 블록과 XOR. IV 필요. 무결성 검증 없음 | △ HMAC 필수 동반 |
| CTR | 스트림처럼 동작. 무결성 검증 없음 | △ 단독 사용 비권장 |
| GCM | 암호화 + 무결성 인증(AEAD)을 한 번에 | ✅ 기본 선택 |
ECB가 왜 금지냐면 — 똑같은 평문 블록이 똑같은 암호문 블록으로 나와.
그래서 이미지를 ECB로 암호화하면 원본 윤곽이 그대로 보인다는 유명한 펭귄 예시가 있지.
암호화했는데 뭐가 그려져 있는지 보인다? 그건 암호화가 아니라 모자이크야.
그래서 결론은 간단해. AES-256-GCM 쓰면 된다.
GCM은 AEAD(Authenticated Encryption with Associated Data)라서,
누가 암호문을 중간에 한 비트라도 바꾸면 복호화 시점에 예외가 터져. 이게 진짜 중요한 기능이야.
1-2. IV와 Nonce : 재사용하면 죽는다
IV(Initialization Vector) 혹은 Nonce(Number used ONCE).
이름에 이미 답이 있어. 단 한 번만 써야 하는 숫자라고.
같은 키 + 같은 Nonce로 GCM을 두 번 쓰면?
공격자가 두 암호문을 XOR하는 것만으로 평문의 관계를 뽑아낼 수 있고,
심하면 인증 키(H)까지 복구돼서 위조 메시지를 만들 수 있어. 진짜로 치명적이야.
⚠ 실무 사고 1순위
"IV를 상수로 박아놨어요." — 이건 암호화를 안 한 것보다 위험해. 안 한 줄 알면 조심이라도 하지.
IV/Nonce는 반드시 SecureRandom(Android) 또는 SecRandomCopyBytes(iOS)로 생성하고,
암호문 앞에 그냥 붙여서 저장하면 돼. IV는 비밀이 아니야. 유일하기만 하면 돼.
// Android / Kotlin — AES-256-GCM 표준 패턴
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import java.security.SecureRandom
object AesGcm {
private const val TRANSFORM = "AES/GCM/NoPadding"
private const val IV_SIZE = 12 // GCM 권장: 96비트
private const val TAG_BITS = 128 // 인증 태그 128비트
fun encrypt(key: SecretKey, plain: ByteArray): ByteArray {
val iv = ByteArray(IV_SIZE).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv))
val ct = cipher.doFinal(plain)
return iv + ct // [IV(12) || ciphertext || tag(16)]
}
fun decrypt(key: SecretKey, blob: ByteArray): ByteArray {
val iv = blob.copyOfRange(0, IV_SIZE)
val ct = blob.copyOfRange(IV_SIZE, blob.size)
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv))
return cipher.doFinal(ct) // 변조되면 AEADBadTagException
}
}
코드가 짧지? 어려운 게 아니야.
어려운 건 "이 key를 어디에 보관하느냐"인데, 그건 3장에서 제대로 다룰게.
1-3. 비대칭키 암호 (Asymmetric) — 공개키 우체통
키가 두 개야. 공개키(Public)와 개인키(Private).
공개키로 잠근 건 개인키로만 열리고, 개인키로 서명한 건 공개키로만 검증돼.
비유하자면 우체통이야.
투입구(공개키)는 누구에게나 열려 있어서 아무나 편지를 넣을 수 있지만,
꺼내는 열쇠(개인키)는 집주인만 갖고 있지.
| 알고리즘 | 특징 | 권장 파라미터 |
|---|---|---|
| RSA | 가장 오래되고 호환성 최고. 느리고 키가 큼 | 2048bit 이상, 4096 권장 |
| ECC (ECDSA/ECDH) | 짧은 키로 동일 강도. 모바일 친화적 | P-256 / Curve25519 |
| Ed25519 | 서명 전용. 빠르고 구현 실수가 적음 | 고정 256bit |
재밌는 사실: ECC P-256(256비트)의 보안 강도가 RSA 3072비트와 맞먹어.
키가 12배나 짧은데 같은 강도라니, 배터리 아쉬운 모바일에선 당연히 ECC가 유리하지.
⚠ 오해 바로잡기
"비대칭키가 더 좋은 거 아냐? 그럼 데이터 전부 RSA로 암호화하자!" — 안 돼.
RSA는 키 길이보다 긴 데이터를 못 넣어. RSA-2048은 OAEP 패딩 쓰면 실질 190바이트 정도가 한계야.
그리고 AES보다 수백~수천 배 느려.
1-4. 하이브리드 암호화 : 실제 세상의 정답
그래서 현실은 둘을 섞어 써. 이걸 하이브리드 암호화라고 해.
HTTPS도, 카카오톡 비밀채팅도, PGP 이메일도 전부 이 구조야.
1단계 — 랜덤한 AES 세션키를 하나 만든다 (빠른 대칭키)
2단계 — 실제 데이터는 그 AES 키로 암호화한다
3단계 — AES 키 자체만 상대방 공개키(RSA/ECDH)로 암호화해서 같이 보낸다
이러면 "빠른 속도"와 "안전한 키 전달"을 둘 다 가져가는 거지. 똑똑하지 않아?
1-5. 해시 : 암호화가 아니다!
자, 이건 진짜 중요한 구분이야.
해시(Hash)는 암호화가 아니야. 왜냐하면 되돌릴 수가 없거든.
암호화는 "잠그고 나중에 여는 것"이지만,
해시는 "지문을 뜨는 것"이야. 지문 보고 사람을 복원할 순 없잖아?
해시의 세 가지 성질:
① 일방향성 — 결과에서 입력을 못 구함
② 결정성 — 같은 입력은 항상 같은 출력
③ 충돌 저항성 — 다른 입력이 같은 출력을 내기 어려움
⚠ MD5와 SHA-1은 죽었다
MD5는 노트북으로 몇 초 만에 충돌을 만들 수 있고,
SHA-1은 2017년 구글의 SHAttered 공격으로 실제 충돌 PDF가 공개됐어.
지금 쓰면 보안 심사에서 바로 지적당해. SHA-256 이상을 쓰자.
1-6. 비밀번호 저장 : SHA-256도 부족하다
"그럼 비밀번호를 SHA-256으로 해시해서 저장하면 되겠네!"
… 아쉽지만 그것도 부족해. 왜냐면 SHA-256은 너무 빨라서야.
요즘 GPU는 초당 수십억 개의 SHA-256을 계산해.
"password123" 같은 건 0.0001초 만에 깨져. 레인보우 테이블이란 것도 있고.
그래서 비밀번호 전용 해시 함수를 써야 해. 이걸 KDF(Key Derivation Function)라고 불러.
| 함수 | 특징 | 2024+ 권장 설정 |
|---|---|---|
| Argon2id | 현재 최고 권장. 메모리 하드 | m=19MiB, t=2, p=1 이상 |
| scrypt | 메모리 하드. 검증된 대안 | N=2^17, r=8, p=1 |
| bcrypt | 오래됐지만 여전히 유효 | cost 12 이상 |
| PBKDF2 | 호환성 최고, 강도는 낮음 | SHA-256, 60만 회 이상 |
핵심 아이디어는 "일부러 느리게 만들기"야.
사용자는 로그인할 때 0.2초 기다리면 그만이지만,
공격자는 수억 개를 시도해야 하니까 0.2초 × 수억 = 사실상 불가능이 되는 거지.
그리고 Salt는 필수야. 사용자마다 다른 랜덤값을 붙여서 해시하면,
같은 비밀번호를 쓰는 사용자 둘이 있어도 해시값이 달라. 레인보우 테이블이 무력화되지.
💡 중요한 원칙
비밀번호 해싱은 서버에서 하는 거야. 앱에서 하는 게 아니고.
앱은 TLS로 보호된 채널로 원본 비밀번호를 보내고, 서버가 Argon2로 처리해.
앱에서 해싱해 보내면 "그 해시값 자체가 비밀번호"가 되어버려서 의미가 반감돼.
2. 안전한 통신 : HTTPS는 시작일 뿐
"저 HTTPS 쓰는데요?"
좋아. 근데 그건 숙제를 시작했다는 뜻이지, 끝냈다는 뜻이 아니야.
2-1. TLS가 실제로 하는 일
TLS(Transport Layer Security)는 세 가지를 보장해:
① 기밀성(Confidentiality) — 중간에서 엿들어도 내용을 모름
② 무결성(Integrity) — 중간에서 바꾸면 들킴
③ 인증(Authentication) — 상대가 진짜 그 서버인지 확인
이 중에서 초보자들이 가장 우습게 보는 게 ③ 인증이야.
근데 사실 ③이 무너지면 ①②는 자동으로 무너져. 왜냐고?
공격자한테 암호화해서 보내는 건 그냥 친절하게 포장해서 배달하는 거니까.
2-2. TLS 핸드셰이크, 진짜 간단히
TLS 1.3 기준으로 이렇게 흘러가:
1) 클라이언트가 "나 이런 암호 스위트 지원해" + 키 교환용 공개값(ECDHE)을 보냄
2) 서버가 스위트를 고르고, 자기 공개값 + 인증서를 보냄
3) 클라이언트가 인증서 체인을 검증 (신뢰할 수 있는 CA가 서명했나? 도메인 맞나? 만료 안 됐나?)
4) 양쪽이 각자 세션키를 계산 — 세션키는 네트워크를 절대 지나가지 않음
5) 이후 모든 통신은 AES-GCM 등으로 암호화
✅ TLS 1.3이 좋은 이유
· 핸드셰이크가 1-RTT로 줄어서 빠름 (1.2는 2-RTT)
· 취약한 알고리즘(RC4, 3DES, SHA-1, 정적 RSA 키교환)을 아예 제거
· Forward Secrecy가 기본 강제 — 서버 개인키가 나중에 유출돼도 과거 트래픽은 못 깜
· 핸드셰이크 대부분이 암호화됨
결론: 최소 TLS 1.2, 가능하면 1.3. TLS 1.0/1.1은 2021년에 주요 브라우저에서 전부 퇴출됐어.
2-3. 중간자 공격(MITM)이 실제로 일어나는 방식
MITM이 뭔지 감이 잘 안 오지? 실제 시나리오를 보자.
시나리오 A — 카페 와이파이
공격자가 "Starbucks_Free_WiFi_2"라는 AP를 열어둠.
연결한 사람들의 트래픽이 전부 공격자를 거쳐감. HTTP면 전부 평문으로 읽힘.
시나리오 B — 프록시 도구
Burp Suite, mitmproxy, Charles 같은 도구로 자기 폰에 커스텀 루트 CA를 설치.
그러면 앱이 보내는 HTTPS 트래픽도 전부 복호화해서 볼 수 있어.
API 명세, 토큰, 요청 파라미터가 다 노출돼.
시나리오 C — 악성 루트 CA
과거 실제로 여러 국가/기업이 자체 CA를 기기에 강제 설치한 사례가 있었어.
그러면 해당 CA가 서명한 모든 가짜 인증서가 "정상"으로 보여.
여기서 중요한 포인트. B 시나리오는 실제로 매일 일어나.
보안 연구자든, 리버싱하는 사람이든, 경쟁사 분석하는 사람이든 다 이 방법을 써.
2-4. 인증서 피닝(Certificate Pinning)
그래서 나온 게 피닝이야.
"나는 OS가 신뢰하는 CA 전부를 믿지 않겠다. 내가 지정한 이 인증서(또는 공개키)만 믿겠다"는 선언이지.
피닝에는 두 종류가 있어:
| 방식 | 고정 대상 | 장단점 |
|---|---|---|
| Certificate Pinning | 인증서 전체 해시 | 강력하지만 갱신 시마다 앱 업데이트 필요 |
| Public Key Pinning (SPKI) | 공개키 해시만 | ✅ 권장. 같은 키로 갱신하면 앱 수정 불필요 |
실무에선 SPKI 피닝이 정답이야. 그리고 반드시 백업 핀을 하나 더 넣어둬.
<!-- Android: res/xml/network_security_config.xml -->
<network-security-config>
<!-- 전역: 평문 HTTP 전면 차단 -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config>
<domain includeSubdomains="true">api.myapp.com</domain>
<pin-set expiration="2026-12-31">
<!-- 현재 운영 인증서 공개키 -->
<pin digest="SHA-256">base64PrimaryPinHere=</pin>
<!-- 백업 키 (필수!) -->
<pin digest="SHA-256">base64BackupPinHere=</pin>
</pin-set>
</domain-config>
</network-security-config>
// Android: OkHttp로 피닝 (코드 레벨)
val pinner = CertificatePinner.Builder()
.add("api.myapp.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.myapp.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
.build()
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.connectTimeout(15, TimeUnit.SECONDS)
.build()
⚠ 피닝의 무서운 함정 : 브릭(Brick)
인증서를 갱신했는데 앱에 새 핀이 없으면?
전 세계 모든 사용자가 동시에 접속 불가가 돼.
강제 업데이트 말고는 복구 방법이 없어. 실제로 대형 서비스들이 이걸로 몇 번 사고 쳤어.
방어책
① 백업 핀을 최소 1개 이상 포함 (다음에 쓸 키를 미리 생성해두기)
② 인증서 갱신 시 같은 키 페어 재사용(CSR 재사용)하면 SPKI 핀이 안 바뀜
③ 원격 설정으로 피닝을 긴급 해제할 수 있는 킬 스위치
④ 핀 만료일(expiration) 설정 — 만료 후엔 일반 검증으로 폴백
2-5. iOS는 ATS가 지켜준다
iOS는 ATS(App Transport Security)가 기본으로 켜져 있어.
평문 HTTP는 기본 차단, TLS 1.2 이상 + Forward Secrecy 암호 스위트 + SHA-256 인증서를 요구해.
iOS 14부터는 Info.plist에서 피닝을 선언적으로 할 수도 있어:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.myapp.com</key>
<dict>
<key>NSIncludesSubdomains</key><true/>
<key>NSPinnedLeafIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>base64PinHere=</string>
</dict>
</array>
</dict>
</dict>
</dict>
⚠ 절대 하지 말아야 할 것
· NSAllowsArbitraryLoads = true 를 전역으로 켜기
· Android에서 TrustManager의 checkServerTrusted()를 빈 함수로 두기
· HostnameVerifier { _, _ -> true } 로 도메인 검증 무력화
이거 셋 다 "개발 편하려고" 넣었다가 그대로 배포되는 사고 1등 공신들이야.
구글 플레이는 실제로 이런 코드를 탐지해서 앱 등록을 거부해.
2-6. 통신 보안 추가 체크포인트
· HSTS — 서버가 "나한테는 무조건 HTTPS로만 와"라고 선언하는 헤더. 웹뷰 쓸 때 특히 중요.
· Certificate Transparency — 잘못 발급된 인증서를 공개 로그로 탐지하는 체계. 지금은 사실상 필수.
· 토큰은 헤더로 — URL 쿼리스트링에 토큰 넣지 마. 서버 액세스 로그, 리퍼러, 브라우저 히스토리에 다 남아.
· 재생 공격 방어 — 중요 요청엔 타임스탬프 + nonce를 넣고 서버에서 검증.
· API 응답 최소화 — 필요 없는 필드는 아예 내려주지 마. 앱에서 숨기는 건 숨기는 게 아니야.
💡 은근히 많이 놓치는 것
앱의 이미지 CDN, 광고 SDK, 분석 SDK도 전부 네트워크를 쓴다는 걸 기억해.
내 코드는 완벽한데 서드파티 SDK가 평문 HTTP로 뭔가 보내고 있으면 그것도 내 책임이야.
mitmproxy 켜놓고 앱을 5분만 만져보면 "어? 이건 어디로 가는 요청이지?" 하는 걸 꼭 발견하게 돼.
3. 보안 저장소 : 키를 어디에 둘 것인가
자, 이제 1장에서 미뤄뒀던 최종 보스가 나왔어.
"암호화 키를 어디에 보관할 것인가?"
이건 암호학에서 가장 오래되고 가장 어려운 문제야.
왜냐면 키를 보호하려면 또 다른 키가 필요하고, 그 키를 보호하려면 또… 무한 루프거든.
3-1. 절대 하면 안 되는 것들 (명예의 전당)
❌ 소스 코드에 하드코딩
val API_KEY = "sk_live_abc123..."
APK를 apktool로 풀거나 IPA를 strings로 긁으면 1분 만에 나와.
GitHub에는 이런 키를 자동으로 긁는 봇이 돌아다녀. 커밋 후 몇 분 만에 악용된 사례가 수두룩해.
❌ BuildConfig / Info.plist
결국 바이너리에 상수로 박히는 건 똑같아. 난이도만 1초 늘어날 뿐.
❌ 평문 SharedPreferences / UserDefaults
루팅/탈옥된 기기에서는 그냥 XML/plist 파일 하나야. 열면 다 보여.
Android 백업 기능이 켜져 있으면 adb backup으로도 빠져나갈 수 있어.
❌ 로컬 SQLite에 평문 저장
DB 파일 그대로 복사해가면 끝. SQLite 뷰어는 무료로 널려 있어.
❌ 로그에 출력
Log.d("TAG", "token=$accessToken") — 디버그 코드 지우는 거 잊지 마.
Android는 앱별 로그 격리가 되어 있지만, 루팅 기기나 크래시 리포팅 도구를 통해 새어나갈 수 있어.
3-2. 하드웨어 보안의 세계 : TEE, SE, Secure Enclave
현대 스마트폰에는 메인 CPU와 물리적으로 분리된 보안 영역이 있어.
| 용어 | 플랫폼 | 설명 |
|---|---|---|
| TEE (Trusted Execution Environment) | Android (ARM TrustZone) | CPU 내부의 격리된 실행 모드. 일반 OS가 접근 불가 |
| SE / StrongBox | Android 9+ | 물리적으로 별개인 보안 칩. 변조 방지 하드웨어 |
| Secure Enclave | iOS (A7 이상) | Apple SoC 내 독립 코프로세서. 자체 부팅·메모리 |
핵심 개념이 뭐냐면 — 키가 이 영역 밖으로 절대 나오지 않는다는 거야.
앱은 "이 데이터 암호화해줘"라고 요청만 하고, 결과만 받아.
키 자체는 만져볼 수도 없어. 심지어 루팅해도 못 꺼내.
이게 왜 대단하냐면, 기존 방식은 "키 파일을 잘 숨기기" 게임이었는데
이제는 "키가 애초에 소프트웨어 영역에 존재하지 않는" 구조가 된 거야. 게임의 룰이 바뀐 거지.
3-3. Android Keystore 실전
Android는 AndroidKeyStore라는 특수한 Provider를 제공해.
여기서 키를 만들면 키 원본(raw bytes)을 앱이 가져올 수 없어. 오직 Cipher 객체로 사용만 가능해.
// Android: 하드웨어 보안 키 생성
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.KeyGenerator
fun createOrGetKey(alias: String): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val spec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// 생체인증/화면잠금 해제 후에만 사용 가능
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(30, KeyProperties.AUTH_BIOMETRIC_STRONG)
// 새 지문이 등록되면 키 자동 무효화 (매우 중요)
.setInvalidatedByBiometricEnrollment(true)
.apply {
// Android 9+ 전용 보안칩 사용 시도
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setIsStrongBoxBacked(true)
}
.build()
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
kg.init(spec)
return kg.generateKey()
}
💡 StrongBox 주의사항
setIsStrongBoxBacked(true)는 지원하지 않는 기기에서 예외를 던져.
반드시 try-catch로 감싸서 실패하면 일반 TEE로 폴백하도록 짜야 해.
그리고 StrongBox는 전용 칩이라 속도가 느려. 큰 데이터를 직접 암호화하지 말고,
DEK(Data Encryption Key)를 감싸는 KEK(Key Encryption Key) 용도로 쓰는 게 정석이야.
3-4. 봉투 암호화 (Envelope Encryption)
방금 나온 개념, 중요하니까 따로 설명할게. 실무 패턴의 핵심이거든.
DEK (Data Encryption Key) — 실제 데이터를 암호화하는 키. 랜덤 생성, 자주 교체 가능.
KEK (Key Encryption Key) — DEK를 암호화하는 키. Keystore/Enclave 안에 거주.
흐름은 이래:
1) 랜덤 DEK 생성 (메모리에만 존재)
2) DEK로 데이터 암호화 → 파일/DB에 저장
3) Keystore의 KEK로 DEK를 암호화(wrap) → 암호화된 DEK도 함께 저장
4) 메모리의 평문 DEK는 즉시 폐기
복호화할 땐 반대로 KEK로 DEK를 풀고, DEK로 데이터를 풀어.
왜 이렇게 번거롭게 하냐고?
키 교체(rotation)가 쉬워지기 때문이야.
KEK를 바꿔야 할 때 수십 GB의 데이터를 다시 암호화할 필요 없이, 작은 DEK만 다시 감싸면 끝나거든.
3-5. Jetpack Security와 그 이후
Android에는 EncryptedSharedPreferences, EncryptedFile이라는 편한 래퍼가 있었어.
내부적으로 Keystore + Tink를 써서 자동으로 암호화해주는 라이브러리지.
// androidx.security:security-crypto (Deprecated 상태 유의)
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("refresh_token", token).apply()
⚠ 2024년 이후 상황 변화
androidx.security:security-crypto는 Deprecated로 표시됐어.
구글은 Google Tink를 직접 사용하거나, Keystore API를 직접 다루는 방향을 권장하고 있어.
신규 프로젝트라면 Tink를 알아두는 게 좋고, 기존 프로젝트는 마이그레이션 계획을 세워둬야 해.
라이브러리 버전 정책은 계속 바뀌니까 반드시 최신 공식 문서를 확인하고.
3-6. iOS Keychain 실전
iOS의 Keychain은 OS가 관리하는 암호화된 데이터베이스야.
앱이 삭제돼도 남아있을 수 있고(설정에 따라), 아이클라우드 동기화도 선택 가능해.
Keychain에서 가장 중요한 건 접근성(Accessibility) 속성이야.
| 속성 | 접근 시점 | 백업 포함 |
|---|---|---|
kSecAttrAccessibleWhenUnlocked | 잠금 해제 상태에서만 | O |
...AfterFirstUnlock | 부팅 후 1회 해제 이후 | O |
...WhenUnlockedThisDeviceOnly | 잠금 해제 + 이 기기만 | ❌ (권장) |
...WhenPasscodeSetThisDeviceOnly | 패스코드 설정 필수 + 이 기기만 | ❌ (최강) |
민감한 토큰이라면 ThisDeviceOnly 계열을 써야 해.
그래야 아이튠즈/아이클라우드 백업을 통해 다른 기기로 복원되지 않거든.
// Swift: Keychain 저장 + Secure Enclave 연동
import Security
import LocalAuthentication
func saveToken(_ token: Data, account: String) throws {
// 생체인증 없이는 접근 불가하도록 ACL 설정
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, // 생체정보 변경 시 자동 무효화
&error
) else { throw error!.takeRetainedValue() as Error }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.myapp.auth",
kSecAttrAccount as String: account,
kSecValueData as String: token,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary) // 중복 방지
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.save(status) }
}
// Swift: Secure Enclave에 키 생성 (P-256, 추출 불가)
func createEnclaveKey(tag: String) throws -> SecKey {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
// 이 한 줄이 핵심: 키가 Secure Enclave 내부에서만 존재
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecAttrAccessControl as String: access
]
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
return key
}
💡 .biometryCurrentSet의 마법
이 플래그를 넣으면, 사용자가 새 지문/얼굴을 등록하는 순간 키가 자동으로 무효화돼.
왜 중요하냐면 — 누가 내 폰을 잠깐 가져가서 자기 지문을 추가로 등록하는 공격을 막을 수 있거든.
Android의 setInvalidatedByBiometricEnrollment(true)와 같은 역할이야.
3-7. 크로스플랫폼은 어떡하지?
React Native, Flutter 쓰는 친구들도 많지? 정리해줄게.
| 프레임워크 | 권장 방식 | 주의점 |
|---|---|---|
| Flutter | flutter_secure_storage | 내부적으로 Keystore/Keychain 사용. Android 옵션에서 encryptedSharedPreferences: true 설정 |
| React Native | react-native-keychain | AsyncStorage는 절대 평문이니 민감정보 금지 |
| Expo | expo-secure-store | 2048바이트 크기 제한 있음 |
⚠ JS 번들의 함정
React Native의 JS 번들은 index.android.bundle 파일로 APK 안에 그대로 들어가.
텍스트 에디터로 열면 문자열이 거의 그대로 보여.
.env 파일에 넣은 API 키? 빌드 타임에 번들로 인라인되니까 똑같이 노출돼.
진짜 비밀은 서버에 두고, 앱은 인증받아서 요청하는 구조로 가야 해.
4. 실무에서 진짜 터지는 것들
이제 좀 더 실전적인 얘기를 해보자.
이론은 알겠는데 "그래서 뭐부터 고쳐야 하냐"가 궁금하잖아.
4-1. 인증 토큰 관리
모바일 앱에서 가장 많이 다루는 비밀은 인증 토큰이야.
Access Token
· 수명을 짧게 (15분~1시간)
· 메모리에 보관하는 게 가장 안전 (앱 종료 시 사라짐)
· 유출돼도 금방 만료되니 피해 최소화
Refresh Token
· 수명이 길므로 반드시 Keystore/Keychain에
· Rotation 적용: 쓸 때마다 새 토큰 발급, 옛 토큰 즉시 폐기
· 재사용 탐지: 이미 쓴 토큰이 또 오면 = 탈취 의심 → 해당 사용자 전체 세션 무효화
Refresh Token Rotation + 재사용 탐지, 이 조합이 진짜 강력해.
공격자가 토큰을 훔쳐서 쓰면, 정상 사용자가 다음에 갱신할 때 "어? 이거 이미 쓴 토큰인데?" 하고 걸려.
그 순간 둘 다 로그아웃시키면 돼. 사용자는 불편하지만 계정은 지킨 거지.
4-2. 루팅 · 탈옥 탐지
루팅된 기기에서는 앱의 방어가 크게 약해져.
Frida 같은 도구로 런타임에 함수를 후킹해서 로직을 바꿀 수 있거든.
그래서 탐지를 하긴 하는데, 완벽한 탐지는 불가능하다는 걸 인정하고 시작해야 해.
탐지 코드를 우회하는 건 공격자 입장에서 그냥 또 하나의 단계일 뿐이야.
현실적인 접근
· 탐지되면 앱을 죽이지 말고 서버에 신호를 보내 위험 점수를 올린다
· 고위험 기능(송금, 결제)만 선택적으로 제한한다
· 최종 판단은 항상 서버에서 한다
Android는 Play Integrity API, iOS는 DeviceCheck / App Attest를 쓰면
플랫폼 차원의 무결성 증명을 서버가 검증할 수 있어. 자체 탐지 코드보다 훨씬 신뢰도가 높아.
4-3. 화면 보안
의외로 놓치기 쉬운 부분이야.
· 스크린샷 / 화면 녹화 차단 — Android는 WindowManager.LayoutParams.FLAG_SECURE
· 앱 전환 화면(Task Switcher) 가리기 — 백그라운드 진입 시 블러 뷰 오버레이
· 클립보드 — 비밀번호 필드는 복사 방지, 복사했다면 일정 시간 후 자동 삭제
· 키보드 캐시 — 민감 입력란은 textNoSuggestions / isSecureTextEntry
은행 앱 켤 때 앱 전환 화면이 뿌옇게 나오는 거, 그게 이 처리야.
별거 아닌 것 같지만 어깨너머 훔쳐보기(shoulder surfing)와 스크린샷 유출을 막아줘.
4-4. 난독화와 그 한계
Android는 R8/ProGuard, iOS는 컴파일러 최적화 + 서드파티 도구를 써.
# build.gradle — 릴리스 빌드 필수 설정
buildTypes {
release {
minifyEnabled true // 코드 축소 + 난독화
shrinkResources true // 미사용 리소스 제거
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
debuggable false // 절대 true로 두지 말 것
}
}
난독화는 클래스/메서드 이름을 a, b, c로 바꾸는 것이 주 기능이야.
문자열 상수는 기본적으로 그대로 남아. 그래서 API 키를 숨기는 용도로는 부족해.
💡 난독화의 진짜 가치
난독화는 "막는 것"이 아니라 "분석 비용을 올리는 것"이야.
공격자가 3시간 걸릴 걸 30시간 걸리게 만들면, 상당수는 포기하거든.
하지만 난독화만 믿고 비밀을 클라이언트에 두는 건 금물이야. 이건 정말 중요한 원칙.
4-5. 딥링크와 웹뷰
이 둘은 앱의 "열린 문"이라서 신경 써야 해.
딥링크
· Android는 App Links(assetlinks.json 검증), iOS는 Universal Links를 써서 도메인 소유권 검증을 거쳐
· 커스텀 스킴(myapp://)은 다른 앱이 가로챌 수 있어서 민감한 용도로 부적합
· 딥링크 파라미터는 항상 검증. 그대로 URL 로딩하면 오픈 리다이렉트 취약점이 돼
웹뷰
· setJavaScriptEnabled(true)는 정말 필요할 때만
· setAllowFileAccess(false), setAllowFileAccessFromFileURLs(false)
· addJavascriptInterface는 네이티브 권한을 JS에 노출하니 극도로 주의
· 로드할 URL은 화이트리스트 검증 필수
4-6. 서드파티 SDK와 공급망
요즘 앱은 의존성이 수백 개야. 그중 하나만 악성이어도 끝이지.
· 의존성 최소화 — 기능 하나 때문에 거대 SDK 넣지 말기
· 버전 고정 — 1.2.+ 같은 동적 버전은 재현 불가능하고 위험
· 취약점 스캔 — OWASP Dependency-Check, GitHub Dependabot 등 자동화
· 권한 감사 — SDK가 요구하는 권한이 과한지 확인
재능넷 같은 재능 공유 플랫폼에서 보안 컨설팅이나 코드 리뷰를 의뢰하는 것도 좋은 방법이야.
내부에 보안 전문가가 없는 소규모 팀이라면,
배포 전에 외부 눈으로 한 번 훑는 것만으로도 치명적인 실수를 상당히 걸러낼 수 있거든.
혼자 짠 코드는 자기 눈에 안 보이는 사각지대가 반드시 생기니까.
5. 가장 중요한 원칙 하나
여기까지 왔으면 이제 이 말이 이해될 거야.
"클라이언트는 신뢰할 수 없다(Client is Untrusted)"
사용자의 손에 있는 앱은 사용자가 완전히 통제할 수 있어.
메모리를 읽을 수도, 함수를 바꿔치기할 수도, 네트워크를 조작할 수도 있어.
그래서 앱에서 하는 모든 검증은 "UX를 위한 힌트"일 뿐이야.
진짜 보안 결정은 100% 서버에서 다시 해야 해.
구체적으로 이런 것들
❌ 앱에서 "관리자만 이 버튼 보이게" → 🟢 서버에서 관리자 권한 재확인
❌ 앱에서 가격 계산해서 결제 요청 → 🟢 서버가 상품ID로 가격 조회 후 계산
❌ 앱에서 "잔액 부족" 체크 → 🟢 서버가 트랜잭션 내에서 잔액 검증
❌ 앱에서 입력값 검증만 → 🟢 서버에서도 동일하게 검증
❌ 앱에서 "쿠폰 사용 가능" 판단 → 🟢 서버가 쿠폰 상태·소유권 검증
게임 업계에 유명한 말이 있어. "클라이언트를 믿으면 치터가 왕이 된다."
앱도 똑같아. 클라이언트를 믿으면 리버서가 왕이 되는 거지.
6. 배포 전 체크리스트
자, 실전 투입 직전에 한 번씩 훑어보는 리스트야.
프린트해서 모니터 옆에 붙여놔도 좋아.
암호화
☐ AES-256-GCM 사용 (ECB 모드 코드 검색해서 0건 확인)
☐ IV/Nonce를 SecureRandom으로 매번 새로 생성
☐ MD5, SHA-1을 보안 목적으로 사용하지 않음
☐ 비밀번호는 서버에서 Argon2id / bcrypt로 해싱 + 개별 Salt
☐ 자체 개발 암호 알고리즘 없음 (절대 직접 만들지 마)
☐ 난수는 Math.random() 아닌 암호학적 안전 난수 사용
통신
☐ 모든 통신이 HTTPS (평문 HTTP 0건)
☐ Android: cleartextTrafficPermitted="false"
☐ iOS: NSAllowsArbitraryLoads 미사용
☐ TLS 1.2 이상만 허용
☐ 인증서 검증 무력화 코드 없음 (빈 TrustManager, 항상 true인 HostnameVerifier)
☐ 피닝 적용 시 백업 핀 + 만료일 + 킬스위치 준비
☐ 토큰을 URL 쿼리스트링에 넣지 않음
☐ 서드파티 SDK 트래픽도 프록시로 실제 확인 완료
저장
☐ API 키·시크릿이 소스코드/리소스에 하드코딩되어 있지 않음
☐ 민감 데이터는 Keystore / Keychain 사용
☐ iOS Keychain은 ThisDeviceOnly 접근성 적용
☐ Android 자동 백업에서 민감 파일 제외 (backup_rules.xml)
☐ 로그에 토큰·개인정보 출력 없음 (릴리스 빌드 로그 비활성화)
☐ 외부 저장소(공용 영역)에 민감 파일 저장하지 않음
☐ 앱 삭제 시 민감 데이터 정리 로직 존재
빌드 & 운영
☐ minifyEnabled true, debuggable false
☐ 디버그용 백도어·테스트 계정 제거
☐ 의존성 취약점 스캔 통과
☐ 권한(Permission) 최소화 — 안 쓰는 권한 제거
☐ 개인정보처리방침 최신화, Play Data Safety / App Privacy 정확히 작성
☐ 보안 사고 대응 절차(누구에게 연락, 어떻게 롤백) 문서화
💡 무료 도구 추천
· MobSF (Mobile Security Framework) — APK/IPA 올리면 자동 정적 분석
· mitmproxy — 무료 트래픽 분석. 5분이면 세팅됨
· OWASP MASVS / MASTG — 모바일 보안 표준 문서. 업계 바이블
· testssl.sh — 서버 TLS 설정 점검
· Dependabot — GitHub 의존성 자동 감시
7. 마무리 : 보안은 문화다
여기까지 읽느라 고생했어. 진짜로.
마지막으로 하고 싶은 말은 이거야.
보안은 기능이 아니라 문화라는 것.
"보안 스프린트" 한 번 돌리고 끝나는 게 아니야.
새 기능 만들 때마다 "이거 누가 악용하면 어떻게 되지?"를 한 번씩 생각하는 습관,
그게 100줄짜리 보안 라이브러리보다 훨씬 강력해.
오늘 당장 할 수 있는 것 3가지
1) 프로젝트 전체에서 http:// 검색해보기 — 뭐가 나오는지 확인
2) API_KEY, SECRET, PASSWORD 문자열 검색해보기
3) mitmproxy 켜고 내 앱 5분 만져보기 — 뭐가 나가는지 눈으로 보기
이 셋만 해도 생각보다 많은 걸 발견하게 될 거야. 장담해.
그리고 완벽한 보안은 세상에 없어.
목표는 "뚫리지 않는 앱"이 아니라 "뚫기엔 비용이 너무 큰 앱"이야.
공격자도 사람이고, 시간과 노력을 계산해.
얻을 것보다 들일 노력이 크면 다른 먹잇감을 찾아 떠나거든.
혹시 혼자 하기 버겁다면, 재능넷 같은 플랫폼에서
모바일 보안 리뷰나 펜테스트 경험이 있는 개발자에게 도움을 받는 것도 충분히 합리적인 선택이야.
사고 한 번 터지고 대응하는 비용에 비하면 비교도 안 되게 저렴하니까.
정리하자면
· 암호화는 AES-256-GCM + 절대 재사용 안 하는 IV
· 통신은 TLS 1.3 + 평문 차단 + (필요시) 백업 핀 있는 SPKI 피닝
· 저장은 Android Keystore / iOS Keychain + 하드웨어 백업
· 그리고 클라이언트는 절대 신뢰하지 않기
이 네 줄만 지켜도 상위 몇 % 안에 드는 앱이 돼. 진심이야.
그럼 오늘도 안전한 코드 짜자. 화이팅! 🔐
관련 키워드
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.

댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.