AWS 시작하기

Android 애플리케이션 구축

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

모듈 5: 이미지 저장 기능 추가

이 모듈에서는 앱에서 이미지를 메모에 연결하는 기능과 스토리지를 추가합니다.

소개

이제 메모 앱이 작동하므로 각 메모에 이미지를 연결하는 기능을 추가해보겠습니다. 이 모듈에서는 Amplify CLI와 라이브러리를 사용하여 Amazon S3 기반의 스토리지 서비스를 생성합니다. 마지막으로, 이미지 업로드, 가져오기 및 렌더링을 지원하도록 Android 앱을 업데이트합니다.

배우게 될 내용

  • 스토리지 서비스 생성
  • Android 앱 업데이트 - 이미지를 업로드하고 다운로드하는 로직
  • Android 앱 업데이트 - 사용자 인터페이스

주요 개념

스토리지 서비스 - 이미지, 동영상 등의 파일을 저장하고 쿼리하는 기능은 대부분의 애플리케이션에서 기본적으로 요구됩니다. 이 기능을 구현하는 방법 중 하나는 파일을 Base64로 인코딩하고 문자열로 전송해 데이터베이스에 저장하는 것입니다. 이 방법은 인코딩된 파일의 크기가 원본 바이너리보다 커지고, 작업을 수행하는 데 컴퓨팅 리소스가 많이 소요되며, 적절하게 인코딩하고 디코딩하는 데 따른 복잡성이 가중된다는 단점이 있습니다. 다른 방법으로, 파일 저장용으로 특별히 구축되고 최적화된 스토리지 서비스를 이용할 수 있습니다. Amazon S3와 같은 스토리지 서비스는 이 기능을 최대한 높은 성능으로 쉽고 저렴하게 구현하기 위해 제공됩니다.

 완료 시간

10분

 사용되는 서비스

구현

  • 이미지 스토리지 기능을 추가하려면 Amplify 스토리지 카테고리를 사용합니다.

    amplify add storage
    
    ? Please select from one of the below mentioned services: accept the default Content (Images, audio, video, etc.) and press enter
    ? Please provide a friendly name for your resource that will be used to label this category in the project: type image and press enter
    ? Please provide bucket name: accept the default and press enter
    ? Who should have access: accept the default Auth users only and press enter
    ? What kind of access do you want for Authenticated users? select all three options create/update, read, delete using the space and arrows keys, then press enter
    ? Do you want to add a Lambda Trigger for your S3 Bucket? accept the default No and press enter

    잠시 후 다음 메시지가 표시됩니다.

    Successfully added resource image locally
  • 방금 생성한 스토리지 서비스를 배포하려면 터미널로 이동하여 다음 명령을 실행합니다.

    amplify push

    Y를 눌러 확인합니다. 잠시 후 다음 메시지가 표시됩니다.

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

    dependencies {
     ...
        // Amplify core dependency
        implementation 'com.amplifyframework:core:1.4.0'
        implementation 'com.amplifyframework:aws-auth-cognito:1.4.0'
        implementation 'com.amplifyframework:aws-api:1.4.0'
        implementation 'com.amplifyframework:aws-storage-s3:1.4.0'
    }
  • Android Studio로 돌아가 java/example.androidgettingstarted에서 Backend.kit를 열고, initialize() 메서드의 Aremplify 초기화 시퀀스에 코드 줄을 추가합니다. 완성된 코드 블록은 다음과 같습니다.

    try {
        Amplify.addPlugin(AWSCognitoAuthPlugin())
        Amplify.addPlugin(AWSApiPlugin())
        Amplify.addPlugin(AWSS3StoragePlugin())
    
        Amplify.configure(applicationContext)
    
        Log.i(TAG, "Initialized Amplify")
    } catch (e: AmplifyException) {
        Log.e(TAG, "Could not initialize Amplify", e)
    }
  • 아직 Backend.kt에 있습니다. Backend 클래스의 아무 곳에나 다음 세 가지 메서드를 추가하여 스토리지에서 이미지를 업로드, 다운로드 및 삭제합니다.

    fun storeImage(filePath: String, key: String) {
        val file = File(filePath)
        val options = StorageUploadFileOptions.builder()
            .accessLevel(StorageAccessLevel.PRIVATE)
            .build()
    
        Amplify.Storage.uploadFile(
            key,
            file,
            options,
            { progress -> Log.i(TAG, "Fraction completed: ${progress.fractionCompleted}") },
            { result -> Log.i(TAG, "Successfully uploaded: " + result.key) },
            { error -> Log.e(TAG, "Upload failed", error) }
        )
    }
    
    fun deleteImage(key : String) {
    
        val options = StorageRemoveOptions.builder()
            .accessLevel(StorageAccessLevel.PRIVATE)
            .build()
    
        Amplify.Storage.remove(
            key,
            options,
            { result -> Log.i(TAG, "Successfully removed: " + result.key) },
            { error -> Log.e(TAG, "Remove failure", error) }
        )
    }
    
    fun retrieveImage(key: String, completed : (image: Bitmap) -> Unit) {
        val options = StorageDownloadFileOptions.builder()
            .accessLevel(StorageAccessLevel.PRIVATE)
            .build()
    
        val file = File.createTempFile("image", ".image")
    
        Amplify.Storage.downloadFile(
            key,
            file,
            options,
            { progress -> Log.i(TAG, "Fraction completed: ${progress.fractionCompleted}") },
            { result ->
                Log.i(TAG, "Successfully downloaded: ${result.file.name}")
                val imageStream = FileInputStream(file)
                val image = BitmapFactory.decodeStream(imageStream)
                completed(image)
            },
            { error -> Log.e(TAG, "Download Failure", error) }
        )
    }

    이 세 가지 메서드는 Amplify의 해당 메서드를 호출합니다. Amplify 스토리지에는 세 가지 파일 보호 수준이 있습니다.

    • 공용 모든 사용자가 액세스할 수 있습니다.
    • 보호됨 모든 사용자가 읽을 수 있지만 생성하는 사용자만 쓸 수 있습니다.
    • 비공개 생성하는 사용자만 읽고 쓸 수 있습니다.

    이 앱의 경우 노트 소유자만 이미지를 사용할 수 있도록 해야 하므로, StorageAccessLevel.PRIVATE 속성을 사용합니다.

  • 다음 단계로, AddNoteACtivity에서 [이미지 추가(Add image)] 버튼을 클릭할 때 사용자가 전화 라이브러리에서 이미지를 선택할 수 있도록 UI를 수정합니다.

    두 가지를 변경해야 합니다. [노트 추가(Add Note)] 작업 레이아웃을 변경하여 [이미지 추가(Add image)] 버튼과 이미지 보기를 추가하고, 작업 클래스에 처리기 코드를 추가합니다.

    Android Studio의 res/layout에서 activity_add_note.xml 파일을 열고 addNote 버튼 바로 위에 이 버튼을 추가합니다.

    <!-- after the description EditText -->
    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/image"
        android:layout_width="match_parent"
        android:layout_height="280dp"
        android:layout_margin="16dp"
        android:scaleType="centerCrop" />
    
    <!-- after the Space -->
    <Button
        android:id="@+id/captureImage"
        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 image" />

    Android Studio의 java/example.androidgettingstarted에서 AddNoteACtivity.kt 파일을 열고 onCreate() 메서드에 이 코드를 추가합니다.

    // inside onCreate() 
    // Set up the listener for add Image button
    captureImage.setOnClickListener {
        val i = Intent(
            Intent.ACTION_GET_CONTENT,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        )
        startActivityForResult(i, SELECT_PHOTO)
    }
    
    // create rounded corners for the image
    image.shapeAppearanceModel = image.shapeAppearanceModel
        .toBuilder()
        .setAllCorners(CornerFamily.ROUNDED, 150.0f)
        .build()

    Intent, MediaStore 및 CornerFamily에 필요한 가져오기를 추가합니다.

    또한 지원 객체에 이 상수 값을 추가합니다.

    // add this to the companion object 
    private const val SELECT_PHOTO = 100

    마지막으로 선택한 이미지를 받아 저장하는 코드를 임시 파일에 추가합니다.

    아래 코드를 AddNoteACtivityclass의 아무 곳에나 추가합니다.

    //anywhere in the AddNoteActivity class
    
    private var noteImagePath : String? = null
    private var noteImage : Bitmap? = null
    
    override fun onActivityResult(requestCode: Int, resultCode: Int, imageReturnedIntent: Intent?) {
        super.onActivityResult(requestCode, resultCode, imageReturnedIntent)
        Log.d(TAG, "Select photo activity result : $imageReturnedIntent")
        when (requestCode) {
            SELECT_PHOTO -> if (resultCode == RESULT_OK) {
                val selectedImageUri : Uri? = imageReturnedIntent!!.data
    
                // read the stream to fill in the preview
                var imageStream: InputStream? = contentResolver.openInputStream(selectedImageUri!!)
                val selectedImage = BitmapFactory.decodeStream(imageStream)
                val ivPreview: ImageView = findViewById<View>(R.id.image) as ImageView
                ivPreview.setImageBitmap(selectedImage)
    
                // store the image to not recreate the Bitmap every time
                this.noteImage = selectedImage
    
                // read the stream to store to a file
                imageStream = contentResolver.openInputStream(selectedImageUri)
                val tempFile = File.createTempFile("image", ".image")
                copyStreamToFile(imageStream!!, tempFile)
    
                // store the path to create a note
                this.noteImagePath = tempFile.absolutePath
    
                Log.d(TAG, "Selected image : ${tempFile.absolutePath}")
            }
        }
    }
    
    private fun copyStreamToFile(inputStream: InputStream, outputFile: File) {
        inputStream.use { input ->
            val outputStream = FileOutputStream(outputFile)
            outputStream.use { output ->
                val buffer = ByteArray(4 * 1024) // buffer size
                while (true) {
                    val byteCount = input.read(buffer)
                    if (byteCount < 0) break
                    output.write(buffer, 0, byteCount)
                }
                output.flush()
                output.close()
            }
        }
    }

    위의 코드는 선택한 이미지를 InputStream으로 두 번 사용합니다. 첫 번째 InputStream은 UI에 표시할 비트맵 이미지를 생성하고, 두 번째 InputStream은 백엔드로 보낼 임시 파일을 저장합니다.

    이 모듈에서는 Amplify API가 Fileobjects를 사용하기 때문에 임시 파일을 거칩니다. 가장 효율적인 설계는 아니지만 코드가 간단합니다.

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

  • 노트가 생성되면 Backend에서 스토리지 메서드를 호출해보겠습니다. AddNoteActivity.kt를 열고 addNote.setOnClickListener() 메서드를 수정하여 Note 객체가 생성된 이후 위치에 아래 코드를 추가합니다.

    // add this in AddNoteACtivity.kt, inside the addNote.setOnClickListener() method and after the Note() object is created.
    if (this.noteImagePath != null) {
        note.imageName = UUID.randomUUID().toString()
        //note.setImage(this.noteImage)
        note.image = this.noteImage
    
        // asynchronously store the image (and assume it will work)
        Backend.storeImage(this.noteImagePath!!, note.imageName!!)
    }
  • 이미지를 로드하기 위해 Note 데이터 클래스의 메서드에서 정적 데이터를 수정합니다. 이렇게 하면 API에서 반환된 NoteData 객체가 Note 객체로 변환될 때마다 이미지가 병렬로 로드됩니다. 이미지가 로드되면 LiveData의 UserData에 알려 옵저버가 변경 사항을 알도록 합니다. 그러면 UI 새로 고침이 트리거됩니다.

    UserData.kt를 열고 다음과 같이 Note 데이터 클래스의 도우미 개체를 수정합니다.

    // 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)
            
            if (noteData.image != null) {
                Backend.retrieveImage(noteData.image!!) {
                    result.image = it
    
                    // force a UI update
                    with(UserData) { notifyObserver() }
                }
            }
            return result
        }
    }
  • 마지막 단계는 자체 정리 단계, 즉 사용자가 노트를 삭제할 때 클라우드 스토리지에서 이미지를 삭제하는 것입니다. 스토리지 공간을 절약하기 위해 이 작업을 수행할 필요가 없더라도, 저장된 데이터의 월별 Gb당 Amazon S3 요금을 줄이기 위해서 수행합니다. 첫 5Gb는 무료이며, 이 자습서를 실행하는 데는 비용이 들지 않습니다.

    SwipeCallback.kt를 열고 onSwipe() 메서드의 끝에 아래 코드를 추가합니다.

    if (note?.imageName != null) {
        //asynchronously delete the image (and assume it will work)
        Backend.deleteImage(note.imageName!!)
    }
  • 정상적으로 작동하는지 확인하기 위해 프로젝트를 빌드하고 실행합니다. 도구 모음에서 [실행(Run)] 아이콘 ▶을 클릭하거나 ^ R을 입력합니다. 오류가 발생하지 않아야 합니다.

    아직 로그인되어 있다면 이전 섹션에서 삭제하지 않은 노트 목록에서 앱이 시작됩니다. [노트 추가(Add Note)] 버튼을 다시 사용하여 노트를 생성합니다. 이번에는 로컬 이미지 스토어에서 선택한 사진이 추가됩니다.

    앱을 종료하고 다시 시작하여 이미지가 올바르게 로드되었는지 확인합니다.

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

결론

AWS Amplify를 사용하여 Android 애플리케이션을 구축했습니다. 사용자가 가입하고 로그인하고 계정을 관리할 수 있도록 앱에 인증을 추가했습니다. 이 앱에는 Amazon DynamoDB를 사용하여 구성된 확장형 GraphQL API도 추가되어 사용자가 메모를 생성하고 삭제할 수 있습니다. 또한 Amazon S3를 사용한 파일 스토리지도 추가하여 사용자가 이미지를 업로드하고 앱에서 볼 수 있게 했습니다.

마지막 섹션에서는 방금 만든 백엔드를 재사용하거나 삭제하는 방법을 알아봅니다.

  • Amplify를 사용하면 여러 프런트엔드 애플리케이션 간에 단일 백엔드를 손쉽게 공유할 수 있습니다.

    터미널에서 다른 프로젝트 디렉터리로 이동하고 다음 명령을 실행합니다.

    mkdir other-project
    cd other-project
    
    amplify pull
    
    ? Do you want to use an AWS profile? accept the default Yes and press enter
    ? Please choose the profile you want to use select the profile you want to use and press enter
    ? Which app are you working on? select the backend you want to share and press enter
    ? Choose your default editor: select you prefered text editor and press enter
    ? Choose the type of app that you're building select the operating system for your new project and press enter
    ? Do you plan on modifying this backend? most of the time, select No and press enter. All backend modifications can be done from the original iOS project.

    몇 초 후에 다음 메시지가 표시됩니다.

    Added backend environment config object to your project.
    Run 'amplify pull' to sync upstream changes.

    구성 파일 2개를 가져온 것을 알 수 있습니다. 'Do you plan on modifying this backend?'라는 질문에 'Yes'라고 답하면 amplify 디렉터리도 표시됩니다.

    ➜  other-project git:(master) ✗ ls -al
    total 24
    drwxr-xr-x   5 stormacq  admin   160 Jul 10 10:28 .
    drwxr-xr-x  19 stormacq  admin   608 Jul 10 10:27 ..
    -rw-r--r--   1 stormacq  admin   315 Jul 10 10:28 .gitignore
    -rw-r--r--   1 stormacq  admin  3421 Jul 10 10:28 amplifyconfiguration.json
    -rw-r--r--   1 stormacq  admin  1897 Jul 10 10:28 awsconfiguration.json
  • 테스트 또는 프로토타입용 백엔드를 생성하거나 학습용 백엔드를 생성하는 경우 이 자습서를 따를 때와 마찬가지로 생성된 클라우드 리소스를 삭제해야 합니다.

    이 자습서의 맥락에서 리소스를 사용할 때는 프리 티어가 적용되지만 클라우드에서 사용되지 않는 리소스를 정리하는 것이 모범 사례입니다.

    amplify 프로젝트를 정리하려면 터미널에서 다음 명령을 실행합니다.

    amplify delete

    잠시 후에 모든 클라우드 리소스가 삭제되었음을 확인하는 아래의 메시지가 표시됩니다.

    ✔ Project deleted in the cloud
    Project deleted locally.

이 자습서를 끝까지 진행해 주셔서 감사합니다. 아래의 도구 또는 Github 리포지토리에 대한 pull 요청을 사용하여 피드백을 보내 주십시오.

이 모듈이 유용했습니까?

축하합니다!

AWS에서 Android 애플리케이션을 구축했습니다. 다음 단계로, 특정 AWS 기술을 자세히 살펴보고 애플리케이션의 수준을 한 차원 높여보겠습니다.