내 프로젝트에서는 서버 API(게시물, 팔로우, 좋아요 등)를
호출하기 위해 Access Token을 사용하고 있다.
하지만 Access Token의 유효시간이 5분으로 매우 짧아,
사용자가 반복적으로 로그아웃되는 불편함을 경험했다.
1️⃣ 발생한 문제
1. Access Token 만료 시 재로그인 요청
- 사용자가 매번 직접 로그인해야 하므로 사용성 저하
2. 자동 로그인 구현 시 보안 문제
- UserDefaults에 아이디 / 비밀번호를 저장하는 방식
- 암호화되지 않은 민감한 정보 노출 가능성
2️⃣ 접근 방식
고민했던 접근 방식은 2가지였는데
1. Access Token 만료 시 마다 재로그인
- ✅ 보안 강점: 사용자가 직접 재로그인
- ❌ 사용성 저하: 사용자가 매번 로그인해야 함
2. Refresh Token을 활용한 자동 갱신 (최종 선택)
- ✅ 보안과 사용성
- Refresh Token을 사용하여 자동으로 Access Token을 갱신
- ✅ Refresh Token 마저 만료 시 재로그인
- Access Token 자동 갱신
- Refresh Token이 만료될 경우에만 사용자에게 재로그인을 요청
3️⃣ 추가적인 고민
Refresh Token과 Access Token 저장하는 방식은 간단히 UserDefaults를 사용하는 방법이 있을 것이다.
하지만 Access Token은 유효기간이 짧고, Refresh Token은 상대적으로 긴 갱신 주기를 갖기 때문에,
민감한 데이터인 Refresh Token을 암호화 없이 UserDefaults에 저장하는 것은 보안상
적절하지 않다고 판단했다
이에 따라 Apple 디바이스의 암호화 데이터베이스인 Keychain에 저장하기로 했다.
하지만 보안 관련 자료를 추가로 조사한 결과, 보안에 민감한 데이터를 Keychain에 저장하기 전
추가적으로 암호화를 적용하는 것이 더욱 안전하다고 판단했다.
따라서 Apple의 하드웨어를 기반으로 키를 관리하는 Secure Enclave를 활용해 Refresh Token을
암호화한 뒤, 암호화된 데이터를 Keychain에 저장하는 방식으로 보안을 한층 더 강화하기로 했다.
Secure Enclave와 Keychain 관련 자료는 Apple 공식 문서를 참고했다.
https://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave
https://developer.apple.com/documentation/security/storing-keys-in-the-keychain
4️⃣ 아키텍쳐 설계
Refresh Token을 안전하게 관리하기 위해, Secure Enclave와 Keychain을 조합한 아키텍쳐를 설계했다.
📌 아키텍쳐 구성
3개의 독립적인 객체를 설계하여, 책임을 분리하고 보안을 강화
1. SecureTokenManager
- 토큰 암호화, 복호화 및 저장을 관리
- 의존성 주입(DI)을 통해 SecureEnclaveService와 KeychainService를 사용
2. SecureEnclaveService(암호화/복호화)
- Secure Enclave를 활용하여 Refresh Token을 비대칭 암호화/복호화
3. KeychainService(데이터 저장/읽기/삭제)
- Keychain을 활용하여 암호화된 Refresh Token을 저장/삭제
📌 예시코드
private func readOrCreateSecureEnclaveKey() -> SecKey? {
let tag = account.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag,
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecReturnRef as String: true
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
print("✅ 기존 Secure Enclave 키 사용")
return (item as! SecKey)
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrApplicationTag as String: tag,
kSecAttrIsPermanent as String: true
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
print("❌ Secure Enclave 키 생성 실패: \(error!.takeRetainedValue())")
return nil
}
print("🔑 새 Secure Enclave 키 생성 완료")
return privateKey
}
데이터 암호화
func encryptData(data: Data) -> Data? {
guard let privateKey = readOrCreateSecureEnclaveKey(),
let publicKey = SecKeyCopyPublicKey(privateKey) else { return nil }
var error: Unmanaged<CFError>?
guard let encryptedData = SecKeyCreateEncryptedData(
publicKey,
.eciesEncryptionStandardX963SHA256AESGCM,
data as CFData,
&error
) else {
print("❌ 데이터 암호화 실패: \(error!.takeRetainedValue())")
return nil
}
return encryptedData as Data
}
데이터 복호화
func decryptData(data: Data) -> Data? {
guard let privateKey = readOrCreateSecureEnclaveKey() else { return nil }
var error: Unmanaged<CFError>?
guard let decryptedData = SecKeyCreateDecryptedData(
privateKey,
.eciesEncryptionStandardX963SHA256AESGCM,
data as CFData,
&error
) else {
print("❌ 데이터 복호화 실패: \(error!.takeRetainedValue())")
return nil
}
return decryptedData as Data
}
키체인 새 데이터 저장
func storeData(_ data: Data) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "account",
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
print("✅ Keychain 데이터 저장 완료")
} else {
print("❌ Keychain 데이터 저장 실패")
}
}
키체인 기존 데이터 읽기
func retrieveData() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "account",
kSecReturnData as String: true
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let data = item as? Data {
print("✅ 기존 데이터 읽기 성공")
} else {
print("❌ 기존 데이터 읽기 실패")
}
}
NOTE:
공식문서에 따르면 Keychain에 새 데이터를 저장 시 주의할 점은
새 데이터 저장 전 SecItemDelete(_:) 함수를 활용하여
기존 데이터를 삭제해주는 것이 좋다고 한다.
5️⃣ Race Condition 방지
📌 Keychain은 멀티스레딩 환경에서 안전하지 않다.
만약 프로젝트에서 멀티스레딩을 통해 여러 서버 API를 요청한다면
즉, 어느 시점에 Keychain을 읽고, 쓰고, 삭제할 지 모른다는 것이다.
그렇다면 동시에 여러 API 요청이 발생하는 상황에서 새로운 데이터를 저장하고,
기존 데이터를 읽으려는 요청이 겹칠 경우 Race Condition이 발생할 가능성이
존재한다 라는 것이다.
하지만 내 프로젝트에서는 위의 상황이 생길 가능성은 거의 0에 가깝다.
내 프로젝트 시나리오 관점에서 봤을 때 Keychain에 새 데이터를 저장하는 경우는
재로그인 하는 경우밖에 없기 때문이다.
그럼에도 불구하고, Race Condition을 방지해야 한다고 생각하는데
그 이유는, 앱 충돌로 인한 비정상 종료가 발생할 경우
데이터가 손상될 가능성이 0은 아니라고 생각하기 때문이다.
"즉, 발생 가능성이 낮지만, 보안 데이터를 다룰 때는 안전성을 최우선으로 고려하는 것이 좋다고 생각이 들었다."
📌 Race Condition 해결 방안
Race Condition의 해결 방안으로 GCD API 내에는 여러가지 해결방식이 존재한다.
복잡한 로직이 필요했다면 아마 Barrier 혹은 Semaphore를 활용했겠지만
그렇지 않기 때문에 간단하고 직관적인 방식인 직렬화를 선택했다.
Keychain 접근 시 사용되는 Custom Global Serial Queue의 sink 내부에서
로직을 작동시켜 직렬화를 적용했다.
📌 예시코드
Keychain Queue
private let keychainQueue = DispatchQueue(label: "KEYCHAIN_QUEUE", attributes: .concurrent)
직렬화 보장을 위한 sync 사용
func storeData(_ data: Data, completion: @escaping (Result<Void, KeychainError>) -> Void) {
keychainQueue.sync { [weak self] in
guard let self else { return }
// 로직 작성
}
}
6️⃣ Unit Test를 통한 신뢰성 확보
마지막으로 Unit Test 를 통해
SecureTokenManager, SecureEnclaveService, KeychainService의 신뢰성을 보장하기 위함으로
Mock 객체를 활용하여 테스트를 진행했다.
📌 Unit Test 시나리오
내가 생각한 Unit Test 시나리오는 아래와 같다.
- 토큰 암호화 및 저장 키체인 저장 성공 / 실패 테스트
- 키체인 읽기 및 토큰 복호화 성공 / 실패 테스트
- 키체인 데이터 삭제 테스트
7️⃣ 결과
- 사용성 개선
- 자동 Access Token 갱신을 통한 사용자 경험 개선
- 보안 강화
- Secure Enclave + Keychain 활용으로 Refresh Token 암호화 및 저장
- 멀티스레딩 안전성 확보
- 직렬큐를 활용한 Race Condition 방지
- 신뢰성 확보
- Unit Test를 통한 신뢰성 보장
'프로젝트 > LookForRealBurger' 카테고리의 다른 글
UIKit 기반 프로젝트에서 Clean Architecture + MVVM-C 설계 (0) | 2025.01.22 |
---|---|
Unit Test - Mock 객체 메서드 내 분기처리 (0) | 2024.12.30 |
ViewController의 Lifecycle과 Modal Present 이슈 (0) | 2024.12.30 |
Pull To Refresh - 당겨서 새로고침 UX 개선 (0) | 2024.12.29 |
'LookForRealBurger' 라는 주제로 LSLP 를 진행하고 난 후 (3) | 2024.09.08 |