App Group UserDefaults와 Keychain의 역할 차이

App Group UserDefaults와 Keychain의 역할 차이

한눈에 보기

App Group UserDefaults는 앱과 extension이 작은 설정·표시 데이터를 공유하는 저장소이고, Keychain은 password·token·암호 키 같은 비밀값의 접근 조건을 관리하는 저장소다. “여러 target에서 읽을 수 있다”와 “안전하게 보호된다”는 서로 다른 요구사항이다. 위젯에는 가능한 한 비밀값을 공유하지 말고, 정말 필요할 때만 Keychain access group과 잠금 상태별 accessibility를 명시한다.

목차

공유 저장소를 보안 저장소로 착각하는 문제

iOS 앱과 Widget extension 사이에 값을 공유하려고 App Group을 추가한 뒤 다음과 같은 코드까지 작성했다고 해 보자.

let sharedDefaults = UserDefaults(
    suiteName: "group.dev.example.widgets"
)

sharedDefaults?.set(
    accessToken,
    forKey: "session.access-token"
)

앱 전용 UserDefaults.standard가 아니라 entitlement로 보호된 App Group suite에 썼으니 안전해 보일 수 있다. 하지만 이 코드는 공유 범위만 정했을 뿐, token을 비밀값에 맞는 저장소에 넣은 것이 아니다.

App Group에 속한 앱과 extension은 shared defaults나 shared container를 읽을 수 있다. 그 자체가 App Group의 목적이다. Widget extension이 화면 문구를 읽는 데에는 유용하지만, 위젯이 필요로 하지 않는 인증 token까지 같은 경계로 넓힐 이유는 없다.

반대 실수도 있다. 비민감한 위젯 문구까지 Keychain에 넣고 “보안을 강화했다”고 생각하는 것이다. 기기 잠금 상태에서 Keychain item을 읽을 수 없어 위젯이 빈 화면이 되거나, 단순 표시 데이터 갱신에 복잡한 오류 처리가 붙는다.

먼저 구분할 질문

  1. 이 값은 다른 target과 공유해야 하는가?
  2. 이 값은 노출되면 안 되는 비밀인가?
  3. 기기가 잠겼을 때도 읽어야 하는가?
  4. 새 기기로 복원될 때 이동해야 하는가?

이 네 질문은 하나의 secure: true 옵션으로 합칠 수 없다.

이 글의 코드는 실제 프로젝트 구현이 아니라, 홈 화면 위젯이 요약 문구를 보여 주고 앱은 로그인 세션을 유지하는 가상의 상황을 기준으로 재구성했다.

저장소 선택에는 두 개 이상의 축이 있다

App Group과 Keychain을 단순히 “덜 안전함/더 안전함”으로 놓으면 설계가 흐려진다. 역할을 여러 축으로 나눠야 한다.

판단 축 질문 관련 설정
공유 범위 어떤 app·extension이 읽어야 하는가 App Group, Keychain access group
기밀성 password·token처럼 비밀로 보호해야 하는가 Keychain
잠금 상태 잠긴 기기나 background에서도 필요한가 kSecAttrAccessible
기기 이동 backup 복원 때 새 기기로 이동해도 되는가 ThisDeviceOnly 여부
데이터 성격 설정·snapshot인가, credential인가 UserDefaults/file vs Keychain
크기와 갱신 작은 scalar인가, 큰 binary인가 defaults vs shared file

대표적인 결정을 표로 정리하면 다음과 같다.

데이터 기본 선택 extension 공유 잠금 중 접근
위젯 제목·count·생성 시각 App Group snapshot 필요 파일 보호 정책에 따라
테마·선택된 widget ID App Group UserDefaults 필요 대개 허용 가능
앱 내부 UI preference UserDefaults.standard 불필요 보안 목적 아님
access token app-private Keychain 대개 불필요 요구사항에 따라
background refresh credential Keychain 최소 범위 After First Unlock 검토
암호화 key Keychain, 필요 시 access control 보통 불필요 가능한 제한적으로
썸네일 이미지 App Group file 필요 민감도와 protection 검토

핵심은 위젯이 보여 줄 완성된 snapshot을 앱이 만들어 주면 extension이 access token을 읽을 필요가 없다는 점이다. 네트워크 권한을 extension까지 넓히지 않는 구조가 가장 단순한 보안 설계다.

App Group UserDefaults가 해결하는 것

Apple의 App Group은 같은 개발 팀의 관련 앱과 extension이 shared container에 접근하도록 한다. UserDefaults(suiteName:)에 App Group 식별자를 전달하면 그 group의 preferences domain을 읽고 쓸 수 있다.

enum SharedDefaultsKey {
    static let snapshotMetadata = "widget.snapshot.metadata.v1"
    static let selectedProfile = "widget.selected-profile.v1"
}

guard let defaults = UserDefaults(
    suiteName: "group.dev.example.widgets"
) else {
    throw SharedDefaultsError.suiteUnavailable
}

defaults.set(
    "profile-demo",
    forKey: SharedDefaultsKey.selectedProfile
)

이 API가 잘 맞는 데이터는 작고 preference에 가까운 값이다.

반대로 다음 데이터에는 부적합하다.

UserDefaults는 값을 쉽게 읽고 저장하게 해 주지만 비밀 저장을 위한 API가 아니다. key 이름을 난독화하거나 value를 Base64로 바꿔도 기밀성이 생기지 않는다.

// Base64는 암호화가 아니다.
let encoded = Data(accessToken.utf8).base64EncodedString()
sharedDefaults.set(encoded, forKey: "a1")

암호화를 직접 추가한다면 암호화 key를 어디에 둘지라는 문제가 다시 생긴다. secret 저장이 목적이라면 먼저 Keychain을 사용한다.

Keychain이 해결하는 것

Keychain Services는 password, cryptographic key, token 같은 작은 비밀값을 저장하고 item별 접근 조건을 지정하게 한다. 앱은 기본적으로 자기 access group의 item만 접근한다. 같은 개발 팀의 여러 target이 공유해야 할 때는 Keychain Sharing capability와 공통 access group을 사용할 수 있다.

Keychain item은 값만 저장하는 상자가 아니다. query에 다음 속성이 함께 들어간다.

let attributes: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "dev.example.auth",
    kSecAttrAccount as String: "refresh-token",
    kSecAttrAccessible as String:
        kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    kSecValueData as String: tokenData
]

여기서 serviceaccount는 검색 조건이므로 버전 변경과 logout 삭제에서도 동일하게 사용해야 한다. 문자열이 여러 파일에 흩어지면 과거 item을 찾지 못하는 문제가 생긴다.

Access Group과 Accessibility는 다른 설정이다

이 둘은 이름이 비슷해서 자주 섞인다.

Access Group: 누가 읽을 수 있는가

앱은 여러 Keychain access group에 속할 수 있지만 Keychain item 하나는 정확히 하나의 group에 속한다. 공통 그룹을 지정한 item만 해당 그룹의 다른 target과 공유된다.

enum KeychainConfiguration {
    static let service = "dev.example.auth"
    static let sharedAccessGroup =
        "TEAMID.dev.example.shared-credentials"
}

실제 access group 문자열의 team identifier 처리 방식은 signing 설정과 capability를 기준으로 확인해야 한다. 문서 예제를 복사해 TEAMID를 literal로 넣거나, Debug와 Release가 서로 다른 entitlement를 가지게 두면 errSecMissingEntitlement 같은 오류를 만날 수 있다.

Apple 문서에 따르면 App Group 이름을 Keychain access group 이름으로 사용할 수도 있다. 그렇다고 모든 App Group 데이터를 Keychain에 넣어야 한다는 뜻은 아니다. shared container 권한과 Keychain item 저장 방식은 별도 API이며, 코드에서 item의 access group을 정확히 지정해야 한다.

Accessibility: 언제 읽을 수 있는가

kSecAttrAccessible은 기기의 잠금 상태에 따라 item을 언제 읽을 수 있는지, ThisDeviceOnly 변형이면 새 기기 복원 때 이동하지 않을지를 정한다.

값의 성격 접근 시점 적합한 예
When Passcode Set, This Device Only passcode가 있고 기기가 unlocked 매우 민감한 foreground 비밀
When Unlocked 기기가 unlocked foreground에서만 필요한 credential
After First Unlock 재부팅 뒤 한 번 unlock한 이후 background 작업에 필요한 credential
ThisDeviceOnly 변형 위 조건 + 새 기기로 이동하지 않음 기기에 묶여야 하는 세션·키
Always 계열 잠금과 무관 deprecated 또는 권장되지 않음

가장 제한적인 옵션이 무조건 정답은 아니다. background에서 꼭 읽어야 하는 token을 When Unlocked로 저장하면 잠긴 동안 작업이 실패한다. 그렇다고 편의를 위해 모든 credential을 After First Unlock으로 낮추면 공격 표면이 넓어진다. 기능 요구사항을 만족하는 범위에서 가장 제한적인 값을 고른다.

Widget의 실행 시점

Widget extension은 앱 UI가 foreground인 동안만 실행되는 것이 아니다. 잠금 상태 접근이 필요한 기능이라면 실제 기기를 재부팅하고 첫 unlock 전·후까지 시험해야 한다. 가능하다면 extension이 credential을 읽지 않고 이미 저장된 표시 snapshot만 소비하게 만든다.

데이터를 먼저 분류하고 저장소를 고르기

저장 API부터 정하지 말고 데이터 inventory를 만든다.

데이터: refresh token
기밀성: 높음
필요한 주체: host app의 auth client
extension 필요: 없음
잠금 중 필요: background refresh 요구에 따라 결정
기기 이동: 정책상 재로그인 허용 여부로 결정
삭제 조건: logout, 계정 제거, 보안 폐기
데이터: widget summary
기밀성: 낮음 또는 표시 수준
필요한 주체: host app writer, widget reader
extension 필요: 있음
잠금 중 필요: 홈·잠금 화면 정책 고려
기기 이동: 다시 생성 가능
삭제 조건: logout, profile 제거, 만료

같은 사용자 기능에서 나온 데이터라도 저장 위치가 달라진다.

flowchart TD
    A[저장할 값] --> B{비밀인가?}
    B -- 예 --> C{extension도 꼭 필요한가?}
    C -- 아니오 --> D[App-private Keychain]
    C -- 예 --> E[공유 Keychain access group]
    E --> F[최소 권한과 accessibility 지정]
    B -- 아니오 --> G{target 간 공유가 필요한가?}
    G -- 아니오 --> H[App-private defaults or file]
    G -- 예 --> I{작은 preference인가?}
    I -- 예 --> J[App Group UserDefaults]
    I -- 아니오 --> K[App Group file]

“extension도 꼭 필요한가?”에서 대부분의 token은 아니오가 되어야 한다. host 앱이 authenticated 결과를 snapshot으로 내보내면 된다.

표시 데이터는 App Group에 최소한으로 저장하기

위젯이 필요한 정보만 하나의 Codable snapshot으로 묶는다.

struct WidgetDisplaySnapshot: Codable {
    let schemaVersion: Int
    let revision: Int
    let title: String
    let count: Int
    let generatedAt: Date
}

final class SharedWidgetPreferences {
    private let defaults: UserDefaults
    private let encoder: JSONEncoder
    private let decoder: JSONDecoder

    init(
        suiteName: String = "group.dev.example.widgets"
    ) throws {
        guard let defaults = UserDefaults(suiteName: suiteName) else {
            throw SharedDefaultsError.suiteUnavailable
        }
        self.defaults = defaults
        self.encoder = JSONEncoder()
        self.decoder = JSONDecoder()
    }

    func save(_ snapshot: WidgetDisplaySnapshot) throws {
        let data = try encoder.encode(snapshot)
        defaults.set(data, forKey: "widget.display-snapshot.v1")
    }

    func read() throws -> WidgetDisplaySnapshot? {
        guard let data = defaults.data(
            forKey: "widget.display-snapshot.v1"
        ) else {
            return nil
        }
        return try decoder.decode(
            WidgetDisplaySnapshot.self,
            from: data
        )
    }
}

여러 key를 순서대로 쓰는 것보다 Data 하나로 저장하면 snapshot 필드끼리 섞일 가능성이 줄어든다. 이미지나 큰 데이터는 shared file로 분리하고 snapshot에는 상대 경로와 revision만 둔다. 이 저장 구조는 Flutter와 iOS WidgetKit 사이에 데이터 공유하기에서 더 자세히 다뤘다.

로그아웃 때는 snapshot에 token이 없더라도 사용자의 표시 데이터가 남지 않게 제거하거나 signed-out snapshot으로 교체한다.

func replaceWithSignedOutState() throws {
    let snapshot = WidgetDisplaySnapshot(
        schemaVersion: 1,
        revision: revision.next(),
        title: "앱에서 로그인해 주세요",
        count: 0,
        generatedAt: Date()
    )
    try save(snapshot)
}

비밀값을 Keychain에 저장하는 예제

Keychain 코드는 SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete의 status를 빠짐없이 처리해야 한다. 예제에서는 app-private refresh token 저장소를 만든다.

enum CredentialStoreError: Error {
    case encodingFailed
    case unexpectedData
    case duplicateItem
    case missingEntitlement
    case interactionNotAllowed
    case unhandledStatus(OSStatus)
}

struct CredentialKey {
    let service: String
    let account: String

    static let refreshToken = CredentialKey(
        service: "dev.example.auth",
        account: "refresh-token.v1"
    )
}

공통 query를 생성한다.

final class KeychainCredentialStore {
    private let accessibility: CFString
    private let accessGroup: String?

    init(
        accessibility: CFString =
            kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
        accessGroup: String? = nil
    ) {
        self.accessibility = accessibility
        self.accessGroup = accessGroup
    }

    private func baseQuery(
        for key: CredentialKey
    ) -> [String: Any] {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: key.service,
            kSecAttrAccount as String: key.account
        ]

        if let accessGroup {
            query[kSecAttrAccessGroup as String] = accessGroup
        }

        return query
    }
}

저장은 add와 update를 구분한다.

func save(
    _ value: String,
    for key: CredentialKey
) throws {
    guard let data = value.data(using: .utf8) else {
        throw CredentialStoreError.encodingFailed
    }

    var addQuery = baseQuery(for: key)
    addQuery[kSecAttrAccessible as String] = accessibility
    addQuery[kSecValueData as String] = data

    let status = SecItemAdd(addQuery as CFDictionary, nil)

    if status == errSecSuccess {
        return
    }

    if status == errSecDuplicateItem {
        let update: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String: accessibility
        ]
        let updateStatus = SecItemUpdate(
            baseQuery(for: key) as CFDictionary,
            update as CFDictionary
        )
        try validate(updateStatus)
        return
    }

    try validate(status)
}

읽기는 반환 타입을 검사한다.

func read(_ key: CredentialKey) throws -> String? {
    var query = baseQuery(for: key)
    query[kSecReturnData as String] = true
    query[kSecMatchLimit as String] = kSecMatchLimitOne

    var result: CFTypeRef?
    let status = SecItemCopyMatching(
        query as CFDictionary,
        &result
    )

    if status == errSecItemNotFound {
        return nil
    }
    try validate(status)

    guard
        let data = result as? Data,
        let value = String(data: data, encoding: .utf8)
    else {
        throw CredentialStoreError.unexpectedData
    }

    return value
}

삭제는 item not found를 멱등한 성공으로 다루는 편이 logout 구현에 편하다.

func delete(_ key: CredentialKey) throws {
    let status = SecItemDelete(
        baseQuery(for: key) as CFDictionary
    )

    guard status != errSecItemNotFound else {
        return
    }
    try validate(status)
}

private func validate(_ status: OSStatus) throws {
    switch status {
    case errSecSuccess:
        return
    case errSecMissingEntitlement:
        throw CredentialStoreError.missingEntitlement
    case errSecInteractionNotAllowed:
        throw CredentialStoreError.interactionNotAllowed
    default:
        throw CredentialStoreError.unhandledStatus(status)
    }
}

운영 로그에는 token 값과 query 전체를 남기지 않는다. OSStatus, 논리적 operation, accessibility class 정도면 원인 분류에 충분하다.

공유 Keychain이 정말 필요한지 먼저 묻기

Widget extension에서 서버 API를 직접 호출하려고 shared Keychain을 추가하면 다음 복잡성이 따라온다.

대부분의 표시형 위젯은 이 비용을 감수할 이유가 없다.

flowchart LR
    subgraph Preferred["권장 경계"]
        A[Host app: Keychain token] --> B[API 동기화]
        B --> C[비민감 snapshot]
        C --> D[App Group]
        D --> E[Widget extension]
    end

정말 extension이 인증된 요청을 해야 한다면 별도의 threat model을 작성한다.

  1. extension이 refresh token까지 필요한가, 짧은 범위의 별도 credential로 충분한가
  2. token이 탈취됐을 때 가능한 행위는 무엇인가
  3. access group에 들어오는 모든 target을 신뢰하는가
  4. 잠금 상태에서 실패하면 stale snapshot으로 fallback할 수 있는가
  5. host와 extension의 refresh를 단일 writer로 만들 수 있는가

공유 Keychain은 기술적으로 가능하다는 이유가 아니라 최소 권한으로도 기능을 구현할 수 없을 때 선택한다.

Flutter 코드에서 저장 기술을 숨기기

Flutter UI가 flutter_secure_storage 같은 패키지의 key 이름이나 iOS accessibility 옵션을 직접 알면 플랫폼 정책이 화면 코드로 퍼진다. 인증 repository 뒤에 숨긴다.

abstract interface class SessionStore {
  Future<void> saveRefreshToken(String token);
  Future<String?> readRefreshToken();
  Future<void> clear();
}

final class AuthRepository {
  AuthRepository(
    this.sessionStore,
    this.widgetSnapshotBridge,
  );

  final SessionStore sessionStore;
  final HomeWidgetBridge widgetSnapshotBridge;

  Future<void> completeLogin(AuthSession session) async {
    await sessionStore.saveRefreshToken(session.refreshToken);
    await widgetSnapshotBridge.writeSignedInSnapshot(
      displayName: session.displayName,
    );
  }
}

이 예제에서 token 저장과 widget snapshot 저장은 물리적으로 다른 저장소다. 앱의 도메인 계층은 “session 비밀”과 “표시 projection”이라는 의미만 안다. iOS에서는 Keychain과 App Group, Android에서는 Keystore-backed storage와 widget용 저장소로 구현을 달리할 수 있다.

패키지를 사용하더라도 다음 옵션을 확인해야 한다.

“secure”라는 패키지 이름만으로 제품의 보안 요구사항이 충족되지는 않는다.

로그아웃과 계정 전환을 transaction처럼 처리하기

로그아웃은 token 하나를 지우는 동작이 아니다. 앱과 extension에 남은 사용자 상태를 일관되게 바꿔야 한다.

sequenceDiagram
    participant UI
    participant Auth as AuthRepository
    participant Keychain
    participant Group as App Group
    participant Widget as WidgetCenter

    UI->>Auth: logout()
    Auth->>Auth: 새 인증 요청 차단
    Auth->>Keychain: credential 삭제
    Auth->>Group: signed-out snapshot 교체
    Auth->>Widget: timeline reload 요청
    Auth-->>UI: local logout 완료

순서는 제품에 따라 달라질 수 있지만 중간 실패 정책은 반드시 있어야 한다.

Future<void> logout() async {
  sessionGate.blockNewAuthenticatedRequests();

  Object? credentialDeleteFailure;
  try {
    await sessionStore.clear();
  } catch (error) {
    credentialDeleteFailure = error;
  }

  await widgetSnapshotBridge.writeSignedOutSnapshot();
  await localSessionState.clear();

  if (credentialDeleteFailure != null) {
    throw LogoutFailure.credentialCleanup(
      credentialDeleteFailure,
    );
  }
}

실제 구현에서 삭제 실패 뒤 UI만 로그인 화면으로 바꾸고 끝내면 다음 실행 때 남은 credential로 session이 복원될 수 있다. 삭제 재시도나 강제 무효화 정책이 필요하다. 서버 refresh token revoke도 별도 네트워크 실패를 가질 수 있다.

계정 A에서 B로 전환할 때 shared snapshot이 잠시 A를 보여 주지 않게 signed-out 또는 loading snapshot을 먼저 확정하는 방법도 있다. 민감한 화면이라면 stale 정책보다 즉시 제거가 우선이다.

기기 잠금과 백그라운드 실행을 함께 테스트하기

Keychain 접근성은 simulator에서 단순 unit test만 돌려서는 검증하기 어렵다. 다음 상태 행렬을 실제 기기에서 확인한다.

상태 기대 결과
앱 foreground, 기기 unlocked credential 읽기 성공
앱 background, 화면 locked 선택한 accessibility에 따른 성공 또는 명시적 fallback
기기 재부팅 후 첫 unlock 전 After First Unlock item 접근 실패
첫 unlock 이후 다시 lock After First Unlock item 접근 가능
app/extension entitlement 불일치 missing entitlement로 진단
logout 직후 widget 실행 signed-out snapshot만 표시
backup을 새 기기에 복원 ThisDeviceOnly item은 없음

failure를 무조건 “로그인 만료”로 매핑하지 않는다. errSecInteractionNotAllowed는 잠금 상태일 수 있고, errSecItemNotFound는 로그아웃 또는 기기 이동일 수 있다. 사용자 경험은 비슷해 보여도 telemetry에서 원인을 구분해야 한다.

enum CredentialReadOutcome {
    case value(String)
    case missing
    case temporarilyUnavailable
}

background 작업에서 temporarily unavailable이면 인증 정보를 삭제하지 않고 다음 unlock 이후 재시도할 수 있다. 잠금 상태를 token 손상으로 오해해 logout 처리하면 불필요하게 session을 잃는다.

흔한 오해와 실패 패턴

“App Group은 entitlement가 있으니 암호화 저장소다”

entitlement는 접근 가능한 주체를 제한한다. 저장 데이터의 용도와 기밀성에 맞는 API를 선택하는 문제까지 대신하지 않는다.

“Keychain에 넣으면 모든 target이 자동으로 읽는다”

기본 Keychain item은 앱의 private access group에 속한다. 공유하려면 양쪽 target의 capability와 item의 access group이 맞아야 한다.

“가장 강한 accessibility를 고르면 끝이다”

기능이 background 접근을 요구하는데 When Unlocked를 선택하면 정상 동작이 깨진다. 가장 강한 옵션이 아니라 요구사항을 만족하는 가장 제한적인 옵션을 고른다.

“UserDefaults의 key를 숨기면 안전하다”

난독화, Base64, 짧은 key 이름은 암호화나 접근 제어가 아니다.

“위젯이 API를 호출하니 token을 공유해야 한다”

먼저 host 앱이 완성된 snapshot을 공유하는 구조로 바꿀 수 있는지 검토한다. credential 공유를 제거하는 것이 가장 확실한 최소 권한이다.

“logout은 Keychain delete 한 줄이다”

App Group snapshot, memory cache, 진행 중 요청, 서버 revoke까지 사용자 identity가 남는 모든 위치를 함께 처리해야 한다.

구현 체크리스트

데이터 분류

Keychain

App Group

운영과 테스트

마무리

App Group UserDefaults와 Keychain은 경쟁하는 두 저장소가 아니다. 서로 다른 질문에 답한다.

App Group은 어떤 앱과 extension이 비민감 데이터를 공유할지 정하고, shared defaults는 작은 preference와 metadata를 전달하는 데 적합하다. Keychain은 credential의 기밀성과 접근 조건을 관리한다. Keychain을 공유할 때도 access group은 “누가”, accessibility는 “언제”, ThisDeviceOnly는 “다른 기기로 이동하는가”를 각각 결정한다.

WidgetKit 연동에서는 이 차이가 더 중요하다. extension이 독립적으로 실행된다는 이유로 host 앱의 인증 권한까지 복제하지 않는다. host 앱이 Keychain의 token으로 데이터를 동기화하고, 위젯에는 표시 가능한 최소 snapshot만 App Group으로 내보내는 구조가 기본이다.

보안은 저장 API 이름에서 나오지 않는다. 데이터의 민감도, 필요한 주체, 실행 시점, 삭제 수명주기를 명시하고 그 요구사항에 맞는 가장 좁은 경계를 선택할 때 만들어진다.

관련 노트

참고 자료