모바일 구현 가이드
이 가이드는 AgeKit+ 위젯을 모바일 애플리케이션에 통합하는 모범 사례를 다룹니다. 웹에서는 위젯이 일반적으로 iframe에 임베드되지만, 모바일 앱은 위젯 URL을 효과적으로 표시하기 위해 다른 접근 방식이 필요합니다.
개요
AgeKit+ 위젯은 완전한 연령 확인 인터페이스를 포함하는 API 호출에서 반환되는 URL입니다. 이러한 위젯을 모바일 애플리케이션에 통합할 때 표시할 수 있는 여러 옵션이 있으며, 각각 다른 기능과 트레이드오프가 있습니다. 주요 고려 사항은 다음과 같습니다:
- AgeKeys 지원: 사용자가 향후 확인을 위해 AgeKeys(FIDO 기반 패스키)를 생성하고 사용할 수 있는지 여부
- 결과 통신: 확인 결과가 앱으로 전달되는 방식
- 사용자 경험: 통합 수준과 네이티브 느낌
모바일 구현 방법
Android 옵션
Android는 위젯 URL을 표시하는 세 가지 주요 방법을 제공합니다:
-
Chrome Custom Tabs ⭐ 권장 - 앱의 브랜딩을 유지하는 맞춤형 Chrome 브라우저 탭에서 위젯 URL을 엽니다. 이를 통해 사용자를 앱의 컨텍스트 내에 유지하면서 전체 브라우저 기능을 제공합니다. Custom Tabs는 Chrome과 쿠키 및 인증 상태를 공유하여 원활한 경험을 가능하게 합니다.
-
WebView - Android의 네이티브 WebView 컴포넌트를 사용하여 앱 내에 웹 콘텐츠를 직접 임베드합니다. 구현이 간단하지만 WebView는 최신 웹 표준에 대한 지원이 제한적이며 특정 브라우저 기능에 액세스할 수 없습니다.
WebView는 AgeKeys에 필요한 WebAuthn을 지원하지 않습니다. WebView를 사용하여 위젯을 임베드하는 경우 사용자는 AgeKeys를 생성하거나 사용할 수 없습니다. 전체 AgeKeys 지원을 갖춘 최상의 사용자 경험을 위해서는 대신 Chrome Custom Tabs를 사용하세요.
- Trusted Web Activity (TWA) - 주로 Progressive Web Apps용으로 설계된 전체 화면 모드로 웹 콘텐츠를 표시합니다. TWA는 앱과 k-ID 도메인 간에 디지털 자산 링크를 설정해야 합니다. 디지털 자산 링크가 감지되지 않으면 TWA는 자동으로 Chrome Custom Tabs로 폴백합니다.
Trusted Web Activities는 위젯 통합에 지원되지 않습니다. k-ID의 도메인에서 디지털 자산 링크를 구성해야 하지만 이는 사용할 수 없습니다. 이 경우 TWA가 Chrome Custom Tabs로 폴백하므로 더 간단한 구현을 위해 Chrome Custom Tabs를 직접 사용하는 것이 좋습니다.
iOS 옵션
iOS는 위젯 URL을 표시하는 세 가지 주요 방법을 제공합니다:
-
ASWebAuthenticationSession ⭐ 권장 - 보안 인증 흐름을 위해 특별히 설계된 이 방법은 시스템 관리 브라우저 보기에서 웹 콘텐츠를 표시합니다. Safari와 쿠키를 공유하고 WebAuthn과 같은 최신 웹 기능에 대한 액세스를 제공하므로 확인 흐름에 이상적입니다.
-
SFSafariViewController - Safari와 쿠키 및 인증 상태를 공유하는 Safari와 유사한 인터페이스에서 웹 콘텐츠를 표시합니다. 이를 통해 앱 컨텍스트를 유지하면서 친숙한 브라우징 경험을 제공합니다.
-
WKWebView - 앱 내에 웹 콘텐츠를 임베드하는 Apple의 최신 웹 보기 컴포넌트입니다. Android의 WebView와 유사하게 WKWebView는 특정 웹 표준에 제한이 있으며 모든 브라우저 기능에 액세스할 수 없습니다.
WKWebView는 AgeKeys에 필요한 WebAuthn을 지원하지 않습니다. WKWebView를 사용하여 위젯을 임베드하는 경우 사용자는 AgeKeys를 생성하거나 사용할 수 없습니다. 전체 AgeKeys 지원을 갖춘 최상의 사용자 경험을 위해서는 대신 ASWebAuthenticationSession을 사용하세요.
AgeKeys 지원 제한
AgeKeys는 FIDO 및 WebAuthn 표준을 기반으로 하는 재사용 가능한 익명 연령 증명 자격 증명입니다. 사용자는 한 번 연령을 확인하고 개인 정보를 공개하지 않고 다른 서비스 간에 해당 확인을 재사용할 수 있습니다.
AgeKeys는 WebAuthn 지원이 필요하며, 이는 Android WebView 또는 iOS WKWebView에서 사용할 수 없습니다. 이러한 컴포넌트를 사용하여 위젯을 임베드하는 경우 사용자는 확인 중에 AgeKeys를 옵션으로 볼 수 없으며 확인 성공 후 AgeKeys를 생성할 수 없습니다.
사용자에게 AgeKeys를 활성화하려면 다음 방법 중 하나를 사용해야 합니다:
- Android: Chrome Custom Tabs
- iOS: ASWebAuthenticationSession 또는 SFSafariViewController
확인 결과 수신
모바일 앱은 사용자가 위젯 흐름을 완료한 후 확인 결과를 수신해야 합니다. 두 가지 접근 방식이 있으며 각각 다른 가용성이 있습니다:
콜백 URL(범용 방법)
모든 구현 방법에 권장되는 접근 방식은 콜백 URL을 사용하는 것입니다. 위젯 URL을 생성하기 위해 API를 호출할 때 redirectUrl 매개변수를 포함하세요. 확인이 완료되면 위젯이 결과를 쿼리 매개변수로 포함하여 이 URL로 리디렉션합니다.
콜백 URL의 장점:
- 모든 구현 방법에서 작동
- DOM 메시지보다 더 안정적
- 모바일 앱의 표준 딥 링크 패턴
- 앱이 백그라운드로 이동해도 결과가 항상 전달됨
콜백 URL 작동 방식
- 앱에 딥 링크 핸들러를 등록합니다(예:
myapp://verification-complete) - API를 호출할 때 딥 링크를
redirectUrl로 포함합니다 - 위젯이 완료 후 딥 링크로 리디렉션합니다
- 앱이 딥 링크를 처리하고 결과를 추출합니다
리디렉션은 위젯 URL이 브라우저 또는 웹 보기에서 직접 열릴 때만 발생하며 iframe에 임베드된 경우에는 발생하지 않습니다.
콜백 URL 매개변수
위젯이 콜백 URL로 리디렉션할 때 다음 쿼리 매개변수가 포함됩니다:
verificationId: 이 확인의 고유 식별자result:PASS또는FAIL
콜백 URL 예시:
myapp://verification-complete?verificationId=7854909b-9124-4bed-9282-24b44c4a3c97&result=PASS
콜백 URL 구현
AgeKit+ API를 호출할 때 options 개체에 redirectUrl을 포함하세요. URL은 다음 중 하나일 수 있습니다:
- HTTPS URL:
https://example.com/verification-complete - 사용자 지정 딥 링크:
myapp://verification-complete
redirectUrl 매개변수에 대한 자세한 내용은 워터폴 흐름 가이드를 참조하세요.
DOM 메시지(WebView/WKWebView만)
Android WebView 또는 iOS WKWebView를 사용하는 경우 위젯에서 전송된 JavaScript 메시지를 수신 대기할 수 있습니다. 이를 통해 다음이 가능합니다:
- 확인 결과를 실시간으로 수신
- 웹 보기를 닫는 시점 제어
- 위젯 이벤트를 기반으로 앱의 UI 업데이트
DOM 메시지는 네이티브 코드에서 가로챌 수 있는 postMessage 이벤트로 전송됩니다. 사용 가능한 이벤트에 대한 자세한 내용은 DOM 이벤트 개요를 참조하세요.
DOM 메시지는 WebView와 WKWebView에서만 작동합니다. Chrome Custom Tabs, Trusted Web Activity, ASWebAuthenticationSession 또는 SFSafariViewController에서는 사용할 수 없습니다.
방법 비교
| 방법 | 플랫폼 | AgeKeys | DOM 메시지 | 콜백 URL | 최적 용도 |
|---|---|---|---|---|---|
| WebView | Android | ❌ | ✅ | ✅ | 권장하지 않음(AgeKeys 지원 없음) |
| Chrome Custom Tabs | Android | ✅ | ❌ | ✅ | 대부분의 사용 사례(권장) |
| Trusted Web Activity | Android | ✅ | ❌ | ✅ | 권장하지 않음(디지털 자산 링크 필요) |
| WKWebView | iOS | ❌ | ✅ | ✅ | 권장하지 않음(AgeKeys 지원 없음) |
| ASWebAuthenticationSession | iOS | ✅ | ❌ | ✅ | 대부분의 사용 사례(권장) |
| SFSafariViewController | iOS | ✅ | ❌ | ✅ | Safari와 유사한 경험 |
권장 구현
Android: Chrome Custom Tabs
기능과 사용자 경험의 최적 균형을 위해 콜백 URL과 함께 Chrome Custom Tabs를 사용하세요.
Chrome Custom Tabs를 선택하는 이유:
- WebAuthn을 통한 전체 AgeKeys 지원
- 모든 최신 웹 기능에 대한 액세스
- 앱 브랜딩을 통한 원활한 사용자 경험
- 안정적인 콜백 메커니즘
- Chrome과 인증 상태 공유
구현 단계:
- 콜백 URL에 대한 딥 링크 핸들러를 등록합니다
- API 요청에
redirectUrl을 포함합니다 - Chrome Custom Tabs를 사용하여 위젯 URL을 엽니다
- 확인 결과로 딥 링크 콜백을 처리합니다
iOS: ASWebAuthenticationSession
안전하고 네이티브한 느낌의 확인 흐름을 위해 콜백 URL과 함께 ASWebAuthenticationSession을 사용하세요.
ASWebAuthenticationSession을 선택하는 이유:
- WebAuthn을 통한 전체 AgeKeys 지원
- 모든 최신 웹 기능에 대한 액세스
- 시스템 관리 보안 UI
- Safari와 쿠키 공유
- 안정적인 콜백 메커니즘
구현 단계:
- 콜백 URL에 대한 URL 스킴 핸들러를 등록합니다
- API 요청에
redirectUrl을 포함합니다 - ASWebAuthenticationSession을 사용하여 위젯 URL을 표시합니다
- 확인 결과로 URL 스킴 콜백을 처리합니다
전체 구현 예제
권장 접근 방식을 구현하는 단계별 예제와 전체 코드 샘플은 다음과 같습니다:
1단계: 딥 링크 핸들러 등록
- iOS (Swift)
- Android (Kotlin)
Info.plist에 URL 스킴을 등록합니다:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
AndroidManifest.xml에 인텐트 필터를 추가합니다:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="verification-complete" />
</intent-filter>
</activity>
2단계: 콜백이 있는 위젯 URL 생성
- iOS (Swift)
- Android (Kotlin)
import Foundation
func generateWidgetUrl(completion: @escaping (URL?) -> Void) {
guard let url = URL(string: "https://game-api.k-id.com/api/v1/age-verification/perform-access-age-verification") else {
completion(nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer YOUR_API_KEY", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody: [String: Any] = [
"jurisdiction": "US-CA",
"criteria": [
"ageCategory": "DIGITAL_YOUTH_OR_ADULT"
],
"options": [
"redirectUrl": "myapp://verification-complete"
]
]
guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else {
completion(nil)
return
}
request.httpBody = httpBody
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil)
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let widgetUrlString = json["url"] as? String,
let widgetUrl = URL(string: widgetUrlString) else {
completion(nil)
return
}
completion(widgetUrl)
}.resume()
}
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
fun generateWidgetUrl(callback: (String?) -> Unit) {
val client = OkHttpClient()
val mediaType = "application/json".toMediaType()
val requestBody = JSONObject().apply {
put("jurisdiction", "US-CA")
put("criteria", JSONObject().apply {
put("ageCategory", "DIGITAL_YOUTH_OR_ADULT")
})
put("options", JSONObject().apply {
put("redirectUrl", "myapp://verification-complete")
})
}.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url("https://game-api.k-id.com/api/v1/age-verification/perform-access-age-verification")
.post(requestBody)
.addHeader("Authorization", "Bearer YOUR_API_KEY")
.addHeader("Content-Type", "application/json")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
callback(null)
return
}
response.body?.use { body ->
val responseBody = body.string()
try {
val jsonResponse = JSONObject(responseBody)
val widgetUrl = jsonResponse.optString("url", null)
callback(widgetUrl)
} catch (e: Exception) {
callback(null)
}
} ?: callback(null)
}
override fun onFailure(call: Call, e: IOException) {
callback(null)
}
})
}
3단계: 위젯 표시
- iOS (Swift)
- Android (Kotlin)
import AuthenticationServices
// 세션을 속성으로 저장하여 할당 해제 방지
var authSession: ASWebAuthenticationSession?
func displayWidget(widgetUrl: URL) {
authSession = ASWebAuthenticationSession(
url: widgetUrl,
callbackURLScheme: "myapp"
) { callbackURL, error in
if let error = error {
// 오류 처리(사용자 취소 등)
return
}
if let callbackURL = callbackURL {
handleVerificationCallback(callbackURL)
}
}
authSession?.presentationContextProvider = self
authSession?.start()
}
import android.app.Activity
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
fun displayWidget(activity: Activity, widgetUrl: String?) {
if (widgetUrl == null) {
// 오류 처리: 위젯 URL 생성 실패
return
}
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
val uri = Uri.parse(widgetUrl)
customTabsIntent.launchUrl(activity, uri)
}
4단계: 콜백 처리
- iOS (Swift)
- Android (Kotlin)
func handleVerificationCallback(_ callbackURL: URL) {
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
return
}
let verificationId = queryItems.first(where: { $0.name == "verificationId" })?.value
let result = queryItems.first(where: { $0.name == "result" })?.value
// 확인 결과에 따라 UI 업데이트
if result == "PASS" {
// 확인 성공 처리
} else if result == "FAIL" {
// 확인 실패 처리
}
// 선택 사항: 서버 측에서 확인
if let verificationId = verificationId {
verifyResultServerSide(verificationId: verificationId)
}
}
import android.content.Intent
import android.net.Uri
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // 중요: 새 인텐트가 사용되도록 보장
val data: Uri? = intent.data
if (data != null && data.scheme == "myapp" && data.host == "verification-complete") {
val verificationId = data.getQueryParameter("verificationId")
val result = data.getQueryParameter("result")
// 확인 결과에 따라 UI 업데이트
if (result == "PASS") {
// 확인 성공 처리
} else {
// 확인 실패 처리
}
// 선택 사항: 서버 측에서 확인
if (verificationId != null) {
verifyResultServerSide(verificationId)
}
}
}
보안 및 데이터 무결성을 위해 클라이언트 측 데이터에만 의존하지 말고 /age-verification/get-status 엔드포인트를 사용하여 서버 측에서 확인 결과를 항상 확인하세요.