๐Ÿ” Local Authentication ์™„์ „์ •๋ณต

Face ID์™€ Touch ID๋กœ ์•ฑ์„ ์•ˆ์ „ํ•˜๊ฒŒ! ์ƒ์ฒด ์ธ์ฆ๋ถ€ํ„ฐ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋Œ€์ฒด๊นŒ์ง€ ์™„๋ฒฝ ๊ฐ€์ด๋“œ.

โœจ Local Authentication์ด๋ž€?

Local Authentication์€ Face ID, Touch ID, ๊ธฐ๊ธฐ ์•”ํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž๋ฅผ ์ธ์ฆํ•˜๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ, ๊ฒฐ์ œ ์Šน์ธ, ๋ฏผ๊ฐํ•œ ์ •๋ณด ์ ‘๊ทผ ์‹œ ์•ˆ์ „ํ•˜๊ณ  ํŽธ๋ฆฌํ•œ ์ธ์ฆ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋“  ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋Š” ๊ธฐ๊ธฐ ๋‚ด์—์„œ๋งŒ ์ฒ˜๋ฆฌ๋˜์–ด ์„œ๋ฒ„๋กœ ์ „์†ก๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ“ฆ ์ฃผ์š” ๊ธฐ๋Šฅ

๊ธฐ๋Šฅ ๊ฐœ์š”
โœ… Face ID / Touch ID ์ธ์ฆ
โœ… ๊ธฐ๊ธฐ ์•”ํ˜ธ ์ธ์ฆ (๋น„๋ฐ€๋ฒˆํ˜ธ)
โœ… ์ƒ์ฒด ์ธ์ฆ ๊ฐ€์šฉ์„ฑ ํ™•์ธ
โœ… ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ ๋ฐ ์žฌ์‹œ๋„
โœ… ์ปค์Šคํ…€ ์ธ์ฆ UI
โœ… ๋น„๋ฐ€๋ฒˆํ˜ธ ๋Œ€์ฒด ์ธ์ฆ

๐Ÿ” ์ƒ์ฒด ์ธ์ฆ ๊ฐ€์šฉ์„ฑ ํ™•์ธ

BiometryCheck.swift
import LocalAuthentication

func checkBiometryAvailability() -> (Bool, String) {
    let context = LAContext()
    var error: NSError?

    // ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
    let canEvaluate = context.canEvaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        error: &error
    )

    if canEvaluate {
        // ์ƒ์ฒด ์ธ์ฆ ํƒ€์ž… ํ™•์ธ
        switch context.biometryType {
        case .faceID:
            return (true, "Face ID ์‚ฌ์šฉ ๊ฐ€๋Šฅ")
        case .touchID:
            return (true, "Touch ID ์‚ฌ์šฉ ๊ฐ€๋Šฅ")
        case .opticID:
            return (true, "Optic ID ์‚ฌ์šฉ ๊ฐ€๋Šฅ")
        case .none:
            return (false, "์ƒ์ฒด ์ธ์ฆ ๋ฏธ์ง€์›")
        @unknown default:
            return (false, "์•Œ ์ˆ˜ ์—†๋Š” ํƒ€์ž…")
        }
    } else {
        // ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ
        if let error = error {
            switch LAError.Code(rawValue: error.code) {
            case .biometryNotEnrolled:
                return (false, "์ƒ์ฒด ์ธ์ฆ ๋ฏธ๋“ฑ๋ก")
            case .biometryNotAvailable:
                return (false, "์ƒ์ฒด ์ธ์ฆ ๋ฏธ์ง€์›")
            case .passcodeNotSet:
                return (false, "๊ธฐ๊ธฐ ์•”ํ˜ธ ๋ฏธ์„ค์ •")
            default:
                return (false, error.localizedDescription)
            }
        }
        return (false, "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜")
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
let (available, message) = checkBiometryAvailability()
print(message)  // "Face ID ์‚ฌ์šฉ ๊ฐ€๋Šฅ"

๐Ÿ” ๊ธฐ๋ณธ ์ƒ์ฒด ์ธ์ฆ

BasicAuth.swift
import LocalAuthentication

func authenticateUser() async -> Bool {
    let context = LAContext()
    var error: NSError?

    // ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
    guard context.canEvaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        error: &error
    ) else {
        print("์ƒ์ฒด ์ธ์ฆ ๋ถˆ๊ฐ€: \(error?.localizedDescription ?? "")")
        return false
    }

    do {
        // ์ธ์ฆ ์‹œ๋„
        let success = try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "์•ฑ์— ๋กœ๊ทธ์ธํ•˜๋ ค๋ฉด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
        )
        return success
    } catch let error {
        // ์ธ์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ
        if let laError = error as? LAError {
            switch laError.code {
            case .authenticationFailed:
                print("์ธ์ฆ ์‹คํŒจ (์–ผ๊ตด/์ง€๋ฌธ ๋ถˆ์ผ์น˜)")
            case .userCancel:
                print("์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œํ•จ")
            case .userFallback:
                print("๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ ์„ ํƒ")
            case .systemCancel:
                print("์‹œ์Šคํ…œ์ด ์ทจ์†Œ (์•ฑ ์ „ํ™˜ ๋“ฑ)")
            case .biometryLockout:
                print("๋„ˆ๋ฌด ๋งŽ์€ ์‹œ๋„๋กœ ์ž ๊น€")
            default:
                print("์ธ์ฆ ์˜ค๋ฅ˜: \(laError.localizedDescription)")
            }
        }
        return false
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
let authenticated = await authenticateUser()
if authenticated {
    print("๋กœ๊ทธ์ธ ์„ฑ๊ณต!")
}

๐Ÿ”‘ ๊ธฐ๊ธฐ ์•”ํ˜ธ ํฌํ•จ ์ธ์ฆ

PasscodeAuth.swift
import LocalAuthentication

func authenticateWithPasscode() async -> Bool {
    let context = LAContext()

    // ์ƒ์ฒด ์ธ์ฆ ์‹คํŒจ ์‹œ ๊ธฐ๊ธฐ ์•”ํ˜ธ ์ž…๋ ฅ ํ—ˆ์šฉ
    context.localizedFallbackTitle = "์•”ํ˜ธ ์ž…๋ ฅ"

    do {
        let success = try await context.evaluatePolicy(
            .deviceOwnerAuthentication,  // ์ƒ์ฒด ์ธ์ฆ + ๊ธฐ๊ธฐ ์•”ํ˜ธ
            localizedReason: "๊ณ„์ •์— ์ ‘๊ทผํ•˜๋ ค๋ฉด ์ธ์ฆํ•˜์„ธ์š”"
        )
        return success
    } catch {
        print("์ธ์ฆ ์‹คํŒจ: \(error.localizedDescription)")
        return false
    }
}

// ์ปค์Šคํ…€ ์ทจ์†Œ ๋ฒ„ํŠผ ํ…์ŠคํŠธ
func authenticateWithCustomButton() async -> Bool {
    let context = LAContext()
    context.localizedCancelTitle = "๋‚˜์ค‘์—"
    context.localizedFallbackTitle = "๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋กœ๊ทธ์ธ"

    do {
        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "๋ฏผ๊ฐํ•œ ์ •๋ณด์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค"
        )
    } catch {
        return false
    }
}

๐Ÿ“ฑ SwiftUI ํ†ตํ•ฉ

AuthView.swift
import SwiftUI
import LocalAuthentication

struct AuthenticationView: View {
    @State private var isAuthenticated = false
    @State private var showError = false
    @State private var errorMessage = ""

    var body: some View {
        VStack(spacing: 20) {
            if isAuthenticated {
                // ์ธ์ฆ ํ›„ ํ™”๋ฉด
                VStack(spacing: 16) {
                    Image(systemName: "checkmark.circle.fill")
                        .font(.system(size: 80))
                        .foregroundStyle(.green)

                    Text("์ธ์ฆ ์„ฑ๊ณต")
                        .font(.title)
                        .bold()

                    Text("์•ˆ์ „ํ•˜๊ฒŒ ๋กœ๊ทธ์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
                        .foregroundStyle(.secondary)

                    Button("๋กœ๊ทธ์•„์›ƒ") {
                        isAuthenticated = false
                    }
                    .buttonStyle(.borderedProminent)
                }
            } else {
                // ๋กœ๊ทธ์ธ ํ™”๋ฉด
                VStack(spacing: 16) {
                    Image(systemName: "faceid")
                        .font(.system(size: 80))
                        .foregroundStyle(.blue)

                    Text("์ƒ์ฒด ์ธ์ฆ ํ•„์š”")
                        .font(.title)
                        .bold()

                    Text("Face ID๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ๋กœ๊ทธ์ธํ•˜์„ธ์š”")
                        .foregroundStyle(.secondary)

                    Button("์ธ์ฆํ•˜๊ธฐ") {
                        Task {
                            await authenticate()
                        }
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
        }
        .padding()
        .alert("์ธ์ฆ ์‹คํŒจ", isPresented: $showError) {
            Button("ํ™•์ธ", role: .cancel) { }
        } message: {
            Text(errorMessage)
        }
        .onAppear {
            // ์•ฑ ์‹œ์ž‘ ์‹œ ์ž๋™ ์ธ์ฆ ์‹œ๋„
            Task {
                await authenticate()
            }
        }
    }

    func authenticate() async {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthentication,
            error: &error
        ) else {
            errorMessage = error?.localizedDescription ?? "์ธ์ฆ ๋ถˆ๊ฐ€"
            showError = true
            return
        }

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "์•ฑ ์ ‘๊ทผ์„ ์œ„ํ•ด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
            )

            await MainActor.run {
                isAuthenticated = success
            }
        } catch let error {
            await MainActor.run {
                errorMessage = error.localizedDescription
                showError = true
            }
        }
    }
}

๐Ÿ”’ ์‹ค์ „ ์˜ˆ์ œ: ๋ฏผ๊ฐ ์ •๋ณด ๋ณดํ˜ธ

SecureDataView.swift
import SwiftUI
import LocalAuthentication

struct SecureDataView: View {
    @State private var isUnlocked = false
    @State private var sensitiveData = "****-****-****-1234"

    var body: some View {
        List {
            Section("๊ณ„์ขŒ ์ •๋ณด") {
                HStack {
                    Text("๊ณ„์ขŒ๋ฒˆํ˜ธ")
                    Spacer()
                    Text(isUnlocked ? "1234-5678-9012-3456" : "****-****-****-3456")
                        .font(.system(.body, design: .monospaced))
                }

                HStack {
                    Text("์ž”์•ก")
                    Spacer()
                    Text(isUnlocked ? "1,234,567์›" : "*******์›")
                }
            }

            Section {
                Button(isUnlocked ? "์ •๋ณด ์ˆจ๊ธฐ๊ธฐ" : "์ •๋ณด ๋ณด๊ธฐ") {
                    if isUnlocked {
                        isUnlocked = false
                    } else {
                        Task {
                            await authenticate()
                        }
                    }
                }
            }
        }
        .navigationTitle("๋‚ด ๊ณ„์ขŒ")
    }

    func authenticate() async {
        let context = LAContext()

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "๋ฏผ๊ฐํ•œ ๊ณ„์ขŒ ์ •๋ณด๋ฅผ ๋ณด๋ ค๋ฉด ์ธ์ฆํ•˜์„ธ์š”"
            )

            if success {
                await MainActor.run {
                    withAnimation {
                        isUnlocked = true
                    }
                }
            }
        } catch {
            print("์ธ์ฆ ์‹คํŒจ")
        }
    }
}

โฑ๏ธ ์ธ์ฆ ์ปจํ…์ŠคํŠธ ์žฌ์‚ฌ์šฉ

ContextReuse.swift
import LocalAuthentication

class AuthenticationManager {
    private let context = LAContext()

    init() {
        // ์ธ์ฆ ์œ ํšจ ์‹œ๊ฐ„ ์„ค์ • (๊ธฐ๋ณธ 10์ดˆ)
        context.touchIDAuthenticationAllowableReuseDuration = 30  // 30์ดˆ
    }

    func authenticate(reason: String) async -> Bool {
        do {
            // 30์ดˆ ๋‚ด ์žฌ์ธ์ฆ ์‹œ ์ƒ์ฒด ์ธ์ฆ ์ƒ๋žต
            return try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: reason
            )
        } catch {
            return false
        }
    }

    func invalidate() {
        // ์ปจํ…์ŠคํŠธ ๋ฌดํšจํ™” (์ฆ‰์‹œ ์žฌ์ธ์ฆ ํ•„์š”)
        context.invalidate()
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
let authManager = AuthenticationManager()

// ์ฒซ ์ธ์ฆ (์ƒ์ฒด ์ธ์ฆ ํ•„์š”)
let success1 = await authManager.authenticate(reason: "๊ฒฐ์ œ ์Šน์ธ")

// 30์ดˆ ๋‚ด ์žฌ์ธ์ฆ (์ƒ์ฒด ์ธ์ฆ ์ƒ๋žต)
let success2 = await authManager.authenticate(reason: "์ถ”๊ฐ€ ๊ฒฐ์ œ")

// ๋กœ๊ทธ์•„์›ƒ ์‹œ ์ปจํ…์ŠคํŠธ ๋ฌดํšจํ™”
authManager.invalidate()

๐ŸŽฏ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ: ๋„๋ฉ”์ธ ์ƒํƒœ

DomainState.swift
import LocalAuthentication

class BiometricStateMonitor {
    private var domainState: Data?

    func saveBiometricState() {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            return
        }

        // ํ˜„์žฌ ์ƒ์ฒด ์ธ์ฆ ์ƒํƒœ ์ €์žฅ
        domainState = context.evaluatedPolicyDomainState
        UserDefaults.standard.set(domainState, forKey: "BiometricState")
    }

    func hasBiometricChanged() -> Bool {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            return true
        }

        // ์ €์žฅ๋œ ์ƒํƒœ์™€ ๋น„๊ต
        guard let savedState = UserDefaults.standard.data(forKey: "BiometricState") else {
            return true
        }

        let currentState = context.evaluatedPolicyDomainState
        return savedState != currentState
    }

    func handleBiometricChange() {
        if hasBiometricChanged() {
            print("โš ๏ธ ์ƒ์ฒด ์ธ์ฆ ์ •๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!")
            print("๋ณด์•ˆ์„ ์œ„ํ•ด ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•˜์„ธ์š”.")

            // ์•ฑ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋˜๋Š” ์žฌ๋กœ๊ทธ์ธ ์š”๊ตฌ
            logoutUser()
            saveBiometricState()
        }
    }

    func logoutUser() {
        // ๋กœ๊ทธ์•„์›ƒ ๋กœ์ง
        UserDefaults.standard.set(false, forKey: "IsLoggedIn")
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
let monitor = BiometricStateMonitor()
monitor.saveBiometricState()  // ๋กœ๊ทธ์ธ ์‹œ

// ์•ฑ ์‹œ์ž‘ ์‹œ ํ™•์ธ
monitor.handleBiometricChange()

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

HIG ๊ถŒ์žฅ์‚ฌํ•ญ
โœ… DO
1. ๋ช…ํ™•ํ•œ ์ด์œ  ์ œ๊ณต
   - "๊ฒฐ์ œ๋ฅผ ์Šน์ธํ•˜๋ ค๋ฉด Face ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"
   - ์‚ฌ์šฉ์ž์—๊ฒŒ ์™œ ์ธ์ฆ์ด ํ•„์š”ํ•œ์ง€ ๋ช…ํ™•ํžˆ ์„ค๋ช…

2. ๋Œ€์ฒด ์ˆ˜๋‹จ ์ œ๊ณต
   - ์ƒ์ฒด ์ธ์ฆ ์‹คํŒจ ์‹œ ๊ธฐ๊ธฐ ์•”ํ˜ธ ํ—ˆ์šฉ
   - .deviceOwnerAuthentication ์ •์ฑ… ์‚ฌ์šฉ

3. ์ ์ ˆํ•œ ํƒ€์ด๋ฐ
   - ๋ฏผ๊ฐํ•œ ์ž‘์—… ์ง์ „์—๋งŒ ์š”์ฒญ
   - ๋„ˆ๋ฌด ์ž์ฃผ ์š”์ฒญํ•˜์ง€ ์•Š๊ธฐ

4. ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ
   - ์ธ์ฆ ์‹คํŒจ ์‹œ ๋ช…ํ™•ํ•œ ํ”ผ๋“œ๋ฐฑ
   - ์žฌ์‹œ๋„ ์˜ต์…˜ ์ œ๊ณต

โŒ DON'T
1. ์•ฑ ์‹œ์ž‘๋งˆ๋‹ค ์ธ์ฆ ์š”๊ตฌ (๋ถˆํ•„์š”ํ•œ ๊ฒฝ์šฐ)
2. ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ฒ„๋กœ ์ „์†ก
3. ์ธ์ฆ ์—†์ด๋„ ๊ธฐ๋ณธ ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ
4. "Touch ID"๋ผ๊ณ  ๋ช…์‹œ (๊ธฐ๊ธฐ๋ณ„๋กœ ๋‹ค๋ฆ„)

๐Ÿ”ง ์‹ค๋ฌด ํ™œ์šฉ ํŒ

์‹ค์ „ ํŒจํ„ด
import LocalAuthentication

// 1. ์ธ์ฆ ํ—ฌํผ ํด๋ž˜์Šค
class BiometricAuth {
    static let shared = BiometricAuth()
    private init() {}

    var biometricType: LABiometryType {
        let context = LAContext()
        _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
        return context.biometryType
    }

    var biometricName: String {
        switch biometricType {
        case .faceID: return "Face ID"
        case .touchID: return "Touch ID"
        case .opticID: return "Optic ID"
        case .none: return "์ƒ์ฒด ์ธ์ฆ"
        @unknown default: return "์ƒ์ฒด ์ธ์ฆ"
        }
    }

    func authenticate(reason: String) async throws -> Bool {
        let context = LAContext()
        return try await context.evaluatePolicy(
            .deviceOwnerAuthentication,
            localizedReason: reason
        )
    }
}

// 2. SwiftUI์—์„œ ์‚ฌ์šฉ
struct SettingsView: View {
    @AppStorage("useBiometric") private var useBiometric = false

    var body: some View {
        Form {
            Section("๋ณด์•ˆ") {
                Toggle("\(BiometricAuth.shared.biometricName) ์‚ฌ์šฉ", isOn: $useBiometric)
            }
        }
    }
}

// 3. ๊ฒฐ์ œ ์Šน์ธ ์˜ˆ์‹œ
func processPayment(amount: Int) async -> Bool {
    do {
        let authenticated = try await BiometricAuth.shared.authenticate(
            reason: "\(amount)์› ๊ฒฐ์ œ๋ฅผ ์Šน์ธํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"
        )

        if authenticated {
            // ๊ฒฐ์ œ ์ฒ˜๋ฆฌ
            await performPaymentTransaction(amount)
            return true
        }
    } catch {
        print("๊ฒฐ์ œ ์ทจ์†Œ๋จ")
    }
    return false
}

func performPaymentTransaction(_ amount: Int) async {
    // ์‹ค์ œ ๊ฒฐ์ œ API ํ˜ธ์ถœ
}

๐Ÿ” Info.plist ์„ค์ •

Info.plist
<!-- Face ID ์‚ฌ์šฉ ์‹œ ํ•„์ˆ˜ -->
<key>NSFaceIDUsageDescription</key>
<string>์•ˆ์ „ํ•œ ๋กœ๊ทธ์ธ์„ ์œ„ํ•ด Face ID๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค</string>

<!-- ๋˜๋Š” ํ•œ๊ตญ์–ด๋กœ -->
<key>NSFaceIDUsageDescription</key>
<string>๊ณ„์ • ๋ณด์•ˆ์„ ์œ„ํ•ด Face ID ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค</string>

๐Ÿ’ก Local Authentication ํ•ต์‹ฌ
โœ… ๋ชจ๋“  ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋Š” ๊ธฐ๊ธฐ ๋‚ด์—์„œ๋งŒ ์ฒ˜๋ฆฌ
โœ… Secure Enclave์— ์•ˆ์ „ํ•˜๊ฒŒ ์ €์žฅ
โœ… ์ƒ์ฒด ์ธ์ฆ ์‹คํŒจ ์‹œ ๊ธฐ๊ธฐ ์•”ํ˜ธ ๋Œ€์ฒด ์ˆ˜๋‹จ ์ œ๊ณต
โœ… ์•ฑ์€ ์ธ์ฆ ์„ฑ๊ณต/์‹คํŒจ๋งŒ ํ™•์ธ ๊ฐ€๋Šฅ
โœ… ์‹ค์ œ ์ƒ์ฒด ๋ฐ์ดํ„ฐ๋Š” ์•ฑ์—์„œ ์ ‘๊ทผ ๋ถˆ๊ฐ€

๐Ÿ“ฆ ํ•™์Šต ์ž๋ฃŒ

๐Ÿ’ป
GitHub ํ”„๋กœ์ ํŠธ
๐Ÿ“–
Apple ๊ณต์‹ ๋ฌธ์„œ
๐ŸŽจ
HIG ์ธ์ฆ ๊ฐ€์ด๋“œ