본문 바로가기

프로젝트/LookForRealBurger

Secure Enclave와 Keychain을 활용한 Refresh Token 보안 관리

내 프로젝트에서는 서버 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 TokenAccess 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)을 통해 SecureEnclaveServiceKeychainService를 사용

2. SecureEnclaveService(암호화/복호화)

  • Secure Enclave를 활용하여 Refresh Token비대칭 암호화/복호화

3. KeychainService(데이터 저장/읽기/삭제)

  • Keychain을 활용하여 암호화Refresh Token저장/삭제

📌 예시코드

더보기
Secure Enclave 키 생성
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를 통한 신뢰성 보장