AWS 시작하기

Android 애플리케이션 구축

AWS Amplify를 사용하여 간단한 Android 애플리케이션 만들기

모듈 4: GraphQL API 및 데이터베이스 추가

이 모듈에서는 Amplify CLI와 라이브러리를 사용하여 GraphQL API를 구성하고 앱에 추가합니다.

소개

앱을 생성하고 사용자 인증으로 앱을 구성했으니 이제 API와 생성, 읽기, 업데이트, 삭제(CRUD) 작업을 데이터베이스에 추가하겠습니다.

이 모듈에서는 Amplify CLI와 라이브러리를 사용하여 앱에 API를 추가해봅니다. 여기서 생성할 API는 GraphQL API로, Amazon DynamoDB(NoSQL 데이터베이스) 기반의 AWS AppSync(관리형 GraphQL 서비스)를 활용합니다. GraphQL에 대한 자세한 소개는 이 페이지를 참조하십시오.

여기서 구축할 앱은 사용자가 메모를 작성하고 삭제하고 나열할 수 있는 메모 앱입니다. 이 예제를 통해 일반적인 유형의 CRUD+L(생성, 읽기, 업데이트, 삭제 및 나열) 애플리케이션을 구축하는 방법을 살펴볼 수 있습니다.

배우게 될 내용

  • GraphQL API 생성 및 배포
  • API와 상호 작용하는 프런트 엔드 코드 작성

주요 개념

API – 여러 소프트웨어 중개자 간의 통신과 상호 작용을 지원하는 프로그래밍 인터페이스를 제공합니다.

GraphQL – 애플리케이션의 유형이 지정된 표현을 기반으로 쿼리 언어 및 서버 측 API 구현 방식입니다. 이 API 표현 방식은 GraphQL 유형 시스템을 기반으로 한 스키마를 사용하여 선언됩니다. GraphQL에 대해 자세히 알아보려면 이 페이지를 참조하십시오.

 완료 시간

20분

 사용되는 서비스

구현

  • GraphQL API 서비스 및 데이터베이스 생성

    GraphQL API와 지원 데이터베이스를 생성하려면 터미널을 열고 프로젝트 디렉터리에서 다음 명령을 실행합니다.

    amplify add api
    
    ? Please select from one of the below mentioned services: select GraphQL and press enter
    ? Provide API name: select the default, press enter
    ? Choose the default authorization type for the API: use the arrow key to select Amazon Cognito User Pool and press enter
    ? Do you want to configure advanced settings for the GraphQL API: select the default No, I am done and press enter
    ? Do you have an annotated GraphQL schema? keep the default N and press enter
    ? What best describes your project: choose any model, we are going to replace it with our own anyway. Press enter
    ? Do you want to edit the schema now? type Y and press enter

    프로젝트를 초기화(amplify init)할 때 선택한 기본 텍스트 편집기가 열리고 미리 구축된 데이터 스키마가 표시됩니다.

    스키마를 삭제하고 앱 GraphQL 스키마로 바꿉니다.

    type NoteData
    @model
    @auth (rules: [ { allow: owner } ]) {
        id: ID!
        name: String!
        description: String
        image: String
    }

    이 데이터 모델은 NoteData 클래스 1개와 4개의 속성으로 구성됩니다. ID와 이름은 필수입니다. 설명과 이미지는 선택적 문자열입니다.

    @model 변환기는 이러한 데이터를 저장할 데이터베이스를 생성합니다.

    @auth 변환기는 이 데이터에 대한 액세스를 허용하는 인증 규칙을 추가합니다. 이 프로젝트에서는 NoteData의 소유자만 데이터에 액세스할 수 있습니다.

    작업이 완료되면 꼭 저장한 후 터미널로 돌아가 Amplify CLI에서 작업을 완료했음을 확인합니다.

    ? Press enter to continue, press enter.

    몇 초 후에 다음과 같은 성공 메시지가 표시됩니다.

    GraphQL schema compiled successfully.
  • 클라이언트 측 코드 생성

    Amplify는 방금 생성된 GraphQL 데이터 모델 정의에 따라 앱에서 데이터를 표시할 클라이언트 측 코드(Swift 코드)를 생성합니다.

    코드를 생성하려면 터미널에서 다음 명령을 실행합니다.

    amplify codegen models

    이렇게 하면 java/com/amplifyframework.datastore.generated.model 디렉터리에 Java 파일이 생성됩니다.

    ➜  Android Getting Started git:(master) ✗ ls -al app/src/main/java/com/amplifyframework/datastore/generated/model 
    total 24
    drwxr-xr-x  4 stormacq  admin   128 Oct  7 15:27 .
    drwxr-xr-x  3 stormacq  admin    96 Oct  7 15:27 ..
    -rw-r--r--  1 stormacq  admin  1412 Oct  7 15:27 AmplifyModelProvider.java
    -rw-r--r--  1 stormacq  admin  7153 Oct  7 15:27 NoteData.java

    이 파일은 자동으로 프로젝트로 가져와집니다.

  • API 서비스 및 데이터베이스 배포

    방금 생성한 백엔드 API와 데이터베이스를 배포하려면 터미널로 이동하여 다음 명령을 실행합니다.

    amplify push
    # press Y when asked to continue
    
    ? Are you sure you want to continue? accept the default Y and press enter
    ? Do you want to generate code for your newly created GraphQL API type N and press enter

    몇 분 후에 다음과 같은 성공 메시지가 표시됩니다.

    ✔ All resources are updated in the cloud
    
    GraphQL endpoint: https://yourid.appsync-api.eu-central-1.amazonaws.com/graphql
  • Android Studio 프로젝트에 API 클라이언트 라이브러리 추가

    코드 작업을 시작하기 전에 Android Studio로 돌아가 이전에 추가한 다른 ‘amplifyframework’ 구현과 함께 모듈의 ‘build.gradle’에 다음 종속 구성 요소를 추가하고 메시지가 표시되면 [지금 동기화(Sync Now)]를 클릭합니다.

    dependencies {
        implementation 'com.amplifyframework:aws-api:1.4.0'
        implementation 'com.amplifyframework:aws-auth-cognito:1.4.0'
    }
  • 런타임 시 Amplify 라이브러리 초기화

    Backend.kt를 열고 Amplify 초기화 시퀀스의 initialize() 메서드에 줄을 추가합니다. 완성된 try/catch 블록은 다음과 같습니다.

    try {
        Amplify.addPlugin(AWSCognitoAuthPlugin())
        Amplify.addPlugin(AWSApiPlugin())
        Amplify.configure(applicationContext)
    
        Log.i(TAG, "Initialized Amplify")
    } catch (e: AmplifyException) {
        Log.e(TAG, "Could not initialize Amplify", e)
    }
  • GraphQL 데이터 모델과 앱 모델 사이에 브리징 추가

    프로젝트에는 노트를 나타내는 데이터 모델이 이미 있습니다. 이 자습서에서는 해당 모델을 계속해서 사용하고 NoteData를 Note로 간편하게 변환하는 것이 좋습니다. UserData.kt를 열고 두 가지 구성 요소를 추가합니다. 그중 하나는 UserData.Note에서 NoteData 객체를 반환하는 동적 속성이고, 다른 하나는 반대로 API NoteData를 받아 Userdata.Note를 반환하는 정적 메서드입니다.

    데이터 클래스 Note에 다음을 추가합니다.

    // return an API NoteData from this Note object
    val data : NoteData
        get() = NoteData.builder()
                .name(this.name)
                .description(this.description)
                .image(this.imageName)
                .id(this.id)
                .build()
    
    // static function to create a Note from a NoteData API object
    companion object {
        fun from(noteData : NoteData) : Note {
            val result = Note(noteData.id, noteData.name, noteData.description, noteData.image)
            // some additional code will come here later
            return result
        }
    }     

    생성된 코드에서 NoteData 클래스를 가져와야 합니다.

  • Backend 클래스에 API CRUD 메서드 추가

    API를 호출하는 3개의 메서드(Note 쿼리 메서드, 새 Note 생성 메서드 및 Note 삭제 메서드)를 추가합니다. 이러한 메서드는 앱 데이터 모델(Note)에서 작동하므로 사용자 인터페이스에서 쉽게 상호 작용할 수 있습니다. 이러한 메서드는 Note를 자동으로 GraphQL의 NoteData 객체로 변환합니다.

    Backend.kt 파일을 열고 Backend 클래스 끝에 다음 조각을 추가합니다.

    fun queryNotes() {
        Log.i(TAG, "Querying notes")
    
        Amplify.API.query(
            ModelQuery.list(NoteData::class.java),
            { response ->
                Log.i(TAG, "Queried")
                for (noteData in response.data) {
                    Log.i(TAG, noteData.name)
                    // TODO should add all the notes at once instead of one by one (each add triggers a UI refresh)
                    UserData.addNote(UserData.Note.from(noteData))
                }
            },
            { error -> Log.e(TAG, "Query failure", error) }
        )
    }
    
    fun createNote(note : UserData.Note) {
        Log.i(TAG, "Creating notes")
    
        Amplify.API.mutate(
            ModelMutation.create(note.data),
            { response ->
                Log.i(TAG, "Created")
                if (response.hasErrors()) {
                    Log.e(TAG, response.errors.first().message)
                } else {
                    Log.i(TAG, "Created Note with id: " + response.data.id)
                }
            },
            { error -> Log.e(TAG, "Create failed", error) }
        )
    }
    
    fun deleteNote(note : UserData.Note?) {
    
        if (note == null) return
    
        Log.i(TAG, "Deleting note $note")
    
        Amplify.API.mutate(
            ModelMutation.delete(note.data),
            { response ->
                Log.i(TAG, "Deleted")
                if (response.hasErrors()) {
                    Log.e(TAG, response.errors.first().message)
                } else {
                    Log.i(TAG, "Deleted Note $response")
                }
            },
            { error -> Log.e(TAG, "Delete failed", error) }
        )
    }

    생성된 코드에서 ModelQuery, ModelMutation 및 NoteData 클래스를 가져와야 합니다.

    마지막으로, 애플리케이션이 시작될 때 API를 호출하여 현재 로그인한 사용자의 노트 목록을 쿼리해야 합니다.

    Backend.kt 파일에서 updateUserData(withSignInStatus: Boolean) 메서드를 다음과 같이 업데이트합니다.

    // change our internal state and query list of notes 
    private fun updateUserData(withSignedInStatus : Boolean) {
        UserData.setSignedIn(withSignedInStatus)
    
        val notes = UserData.notes().value
        val isEmpty = notes?.isEmpty() ?: false
    
        // query notes when signed in and we do not have Notes yet
        if (withSignedInStatus && isEmpty ) {
            this.queryNotes()
        } else {
            UserData.resetNotes()
        }
    }

    이제, 새 Note를 생성하고 목록에서 Note를 삭제할 수 있는 사용자 인터페이스를 생성하기만 하면 됩니다.

  • Add Note에 Edit 버튼 추가

    백엔드 및 데이터 모델 조각이 준비되었으니 이 섹션의 마지막 단계로 사용자가 새 Note를 생성하고 삭제할 수 있는 기능을 제공하면 됩니다.

    a. Android Studio의 res/layout에서 새 레이아웃을 생성합니다. [레이아웃(layout)]을 마우스 오른쪽 버튼으로 클릭하고 [새로 만들기(New)]를 선택한 다음 [레이아웃 리소스 파일(Layout Resource File)]을 선택합니다. 이름을 activity_add_note로 지정하고 다른 모든 기본값을 사용합니다. [확인(OK)]을 클릭합니다.

    AndroidAppTutorial_Modiule4_Image1

    방금 만든 activity_add_note 파일을 열고 아래의 코드를 붙여 넣어 생성된 코드를 대체합니다.

    <?xml version="1.0" encoding="utf-8"?>
    <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true"
        android:fillViewport="true">
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="8dp">
    
            <TextView
                android:id="@+id/title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:text="Create a New Note"
                android:textSize="10pt" />
    
            <EditText
                android:id="@+id/name"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:hint="name"
                android:inputType="text"
                android:lines="5" />
    
            <EditText
                android:id="@+id/description"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:hint="description"
                android:inputType="textMultiLine"
                android:lines="3" />
    
            <Space
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1" />
    
            <Button
                android:id="@+id/addNote"
                style="?android:attr/buttonStyleSmall"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"
                android:backgroundTint="#009688"
                android:text="Add Note" />
    
            <Button
                android:id="@+id/cancel"
                style="?android:attr/buttonStyleSmall"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"
                android:backgroundTint="#FFC107"
                android:text="Cancel" />
    
        </LinearLayout>
    </ScrollView>

    이는 노트 제목과 설명을 입력할 수 있는 매우 간단한 레이아웃입니다.

    b. AddNoteActivity 클래스를 추가합니다.

    java/com.example.androidgettingstarted에서 새 kotlin 파일 AddActivityNote.kt를 열고 다음 코드를 추가합니다. 

    package com.example.androidgettingstarted
    
    import android.os.Bundle
    import androidx.appcompat.app.AppCompatActivity
    import kotlinx.android.synthetic.main.activity_add_note.*
    import java.util.*
    
    class AddNoteActivity : AppCompatActivity()  {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_add_note)
    
            cancel.setOnClickListener {
                this.finish()
            }
    
            addNote.setOnClickListener {
    
                // create a note object
                val note = UserData.Note(
                    UUID.randomUUID().toString(),
                    name?.text.toString(),
                    description?.text.toString()
                )
    
                // store it in the backend
                Backend.createNote(note)
    
                // add it to UserData, this will trigger a UI refresh
                UserData.addNote(note)
    
                // close activity
                this.finish()
            }
        }
    
        companion object {
            private const val TAG = "AddNoteActivity"
        }
    }    

    마지막으로, 매니페스트에서 AndroidManifest.xml을 열고 이 작업 요소를 애플리케이션 노드 내의 아무 곳에나 추가합니다.

    <activity
        android:name=".AddNoteActivity"
        android:label="Add Note"
        android:theme="@style/Theme.GettingStartedAndroid.NoActionBar">
        <meta-data
            android:name="android.support.PARENT_ACTIVITY"
            android:value="com.example.androidgettingstarted.MainActivity" />
    </activity>

    c. Main Activity에 "Add Note" FloatingActionButton을 추가합니다. res/layout에서 activity_main.xml을 열고 기존 Floating Action 버튼 위에 다음을 추가합니다.

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fabAdd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentRight="true"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        android:visibility="invisible"
        android:src="@drawable/ic_baseline_post_add"
        app:fabCustomSize="60dp"
        app:fabSize="auto"/>

    res/drawable에 "Add Note" 아이콘을 추가합니다. drawable을 마우스 오른쪽 버튼으로 클릭하고 [새로 만들기(New)], [벡터 자산(Vector Asset)]을 선택합니다. 이름으로 ic_baseline_add를 입력하고 클립아트에서 add 아이콘을 선택합니다. [다음(Next)]과 [마침(Finish)]을 차례로 클릭합니다.

    AndroidAppTutorial_Modiule4_Image2

    d. "Add Note" 버튼을 처리할 코드를 추가합니다.

    "Add Button"을 사용하기 위해 마지막으로 해야 할 두 가지 일은 버튼이 isSignedIn 값에 따라 표시되거나 사라지도록 하는 것과 버튼의 탭을 처리할 코드를 추가하는 것입니다.

    mainActivity.kt를 열고 onCreate() 메서드의 끝에 다음을 추가합니다.

    // register a click listener
    fabAdd.setOnClickListener {
        startActivity(Intent(this, AddNoteActivity::class.java))
    }

    그런 다음 여전히 onCreate() 메서드에 있는 상태에서 UserData.isSignedIn.observe를 다음으로 바꿉니다.

    UserData.isSignedIn.observe(this, Observer<Boolean> { isSignedUp ->
        // update UI
        Log.i(TAG, "isSignedIn changed : $isSignedUp")
    
        //animation inspired by https://www.11zon.com/zon/android/multiple-floating-action-button-android.php
        if (isSignedUp) {
            fabAuth.setImageResource(R.drawable.ic_baseline_lock_open)
            Log.d(TAG, "Showing fabADD")
            fabAdd.show()
            fabAdd.animate().translationY(0.0F - 1.1F * fabAuth.customSize)
        } else {
            fabAuth.setImageResource(R.drawable.ic_baseline_lock)
            Log.d(TAG, "Hiding fabADD")
            fabAdd.hide()
            fabAdd.animate().translationY(0.0F)
        }
    })    

    정상적으로 작동하는지 확인하기 위해 프로젝트를 빌드합니다. [빌드(Build)] 메뉴를 클릭하고 [프로젝트 만들기(Make Project)]를 선택하거나 Mac에서 ⌘F9을 입력합니다. 오류가 발생하지 않아야 합니다.

    애플리케이션을 실행하면 사용자가 로그인할 때 "Add Note" 버튼이 나타나고 사용자가 로그아웃할 때 사라집니다. 이제 노트를 추가할 수 있습니다.

  • Delete 동작에 Swipe 추가

    노트의 목록에 터치 핸들러를 추가하여 swipe-to-delete 동작을 추가할 수 있습니다. 터치 핸들러는 빨간색 배경과 삭제 아이콘을 그리고 터치가 해제될 때 Backend.delete() 메서드를 호출하는 역할을 담당합니다.

    a. SimpleTouchCallback이라는 새 클래스를 생성합니다. java/com에서 esample.androidgettingstarted를 마우스 오른쪽 버튼으로 클릭하고 [새로 만들기(New)], Kotlin 파일을 차례로 선택한 후 SwipeCallback을 이름으로 입력합니다.

    AndroidAppTutorial_Modiule4_Image3

    아래 코드를 새 파일에 붙여 넣습니다.

    package com.example.androidgettingstarted
    
    import android.graphics.Canvas
    import android.graphics.Color
    import android.graphics.drawable.ColorDrawable
    import android.graphics.drawable.Drawable
    import android.util.Log
    import android.widget.Toast
    import androidx.appcompat.app.AppCompatActivity
    import androidx.core.content.ContextCompat
    import androidx.recyclerview.widget.ItemTouchHelper
    import androidx.recyclerview.widget.RecyclerView
    
    
    // https://stackoverflow.com/questions/33985719/android-swipe-to-delete-recyclerview
    class SwipeCallback(private val activity: AppCompatActivity): ItemTouchHelper.SimpleCallback(
        0,
        ItemTouchHelper.LEFT
    ) {
    
        private val TAG: String = "SimpleItemTouchCallback"
        private val icon: Drawable? = ContextCompat.getDrawable(
            activity,
            R.drawable.ic_baseline_delete_sweep
        )
        private val background: ColorDrawable = ColorDrawable(Color.RED)
    
        override fun onChildDraw(
            c: Canvas,
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            dX: Float,
            dY: Float,
            actionState: Int,
            isCurrentlyActive: Boolean
        ) {
            super.onChildDraw(
                c,
                recyclerView,
                viewHolder,
                dX,
                dY,
                actionState,
                isCurrentlyActive
            )
            val itemView = viewHolder.itemView
            val backgroundCornerOffset = 20
            val iconMargin = (itemView.height - icon!!.intrinsicHeight) / 2
            val iconTop = itemView.top + (itemView.height - icon.intrinsicHeight) / 2
            val iconBottom = iconTop + icon.intrinsicHeight
            val iconRight: Int = itemView.right - iconMargin
            if (dX < 0) {
                val iconLeft: Int = itemView.right - iconMargin - icon.intrinsicWidth
                icon.setBounds(iconLeft, iconTop, iconRight, iconBottom)
                background.setBounds(
                    itemView.right + dX.toInt() - backgroundCornerOffset,
                    itemView.top, itemView.right, itemView.bottom
                )
                background.draw(c)
                icon.draw(c)
            } else {
                background.setBounds(0, 0, 0, 0)
                background.draw(c)
            }
        }
    
        override fun onMove(
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            target: RecyclerView.ViewHolder
        ): Boolean {
            Toast.makeText(activity, "Moved", Toast.LENGTH_SHORT).show()
            return false
        }
    
        override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {
    
            Toast.makeText(activity, "deleted", Toast.LENGTH_SHORT).show()
    
            //Remove swiped item from list and notify the RecyclerView
            Log.d(TAG, "Going to remove ${viewHolder.adapterPosition}")
    
            // get the position of the swiped item in the list
            val position = viewHolder.adapterPosition
    
            // remove to note from the userdata will refresh the UI
            val note = UserData.deleteNote(position)
    
            // async remove from backend
            Backend.deleteNote(note)
        }
    }

    이 코드의 중요한 줄은 onSwiped() 메서드에 있습니다. 이 메서드는 스와이프 제스처가 완료되면 호출됩니다. 목록에서 스와이프된 항목의 위치를 수집하고, UI를 업데이트하는 UserData 구조와 클라우드 백엔드에서 해당 노트를 제거합니다.

    b. 이제 클래스가 시작되었으므로 res/drawable에 "Delete" 아이콘을 추가하겠습니다. drawable을 마우스 오른쪽 버튼으로 클릭하고 [새로 만들기(New)], [벡터 자산(Vector Asset)]을 선택합니다. ic_baseline_delete_sweep를 이름으로 입력하고 클립아트에서 "delete sweep" 아이콘을 선택합니다. [다음(Next)]과 [마침(Finish)]을 차례로 클릭합니다.

    AndroidAppTutorial_Modiule4_Image4

    c. 아래 코드를 새 파일에 붙여 넣습니다. 스와이프하여 삭제 제스처 핸들러를 RecyclerView에 추가합니다.

    Java/com/ample.androidgettingstarted에서 MainActivity.kt를 열고 setupRecyclerView에 다음 두 줄의 코드를 추가합니다.

    // add a touch gesture handler to manager the swipe to delete gesture
    val itemTouchHelper = ItemTouchHelper(SwipeCallback(this))
    itemTouchHelper.attachToRecyclerView(recyclerView)
  • 구축 및 테스트

    정상적으로 작동하는지 확인하기 위해 프로젝트를 빌드하고 실행합니다. 도구 모음에서 [실행(Run)] 아이콘 ▶을 클릭하거나 ^ R을 입력합니다. 오류가 발생하지 않아야 합니다.

    아직 로그인되어 있다면 앱이 빈 목록에서 시작됩니다. Note를 추가할 수 있는 Add Note 버튼이 포함되어 있습니다. Add Note 기호를 누르고 제목을 입력한 후 설명을 입력하고 Add Note 버튼을 누르면 노트가 목록에 나타납니다.

    행을 왼쪽으로 밀어 메모를 삭제할 수 있습니다.

    전체 흐름은 다음과 같습니다.

    AndroidAppTutorial_Modiule4_Image5
    AndroidAppTutorial_Modiule4_Image6
    AndroidAppTutorial_Modiule4_Image7
    AndroidAppTutorial_Modiule4_Image8

결론

이제 Android 계정이 생성되었습니다! AWS Amplify를 사용하여 앱에 GraphQL API를 추가하고 생성, 읽기 및 삭제 기능을 구성했습니다.

다음 모듈에서는 사진 관리를 위한 UI 및 동작을 추가합니다.

이 모듈이 유용했습니까?

감사합니다.
좋아하는 사항을 알려주세요.
닫기
실망을 드려 죄송합니다.
오래되었거나 혼란스럽거나 부정확한 사항이 있습니까? 피드백을 제공하여 이 자습서를 개선할 수 있도록 도와주십시오.
닫기

스토리지 추가