[Android] 로컬에서 AWS S3 핸들링

부나·2024년 7월 17일
2

안드로이드

목록 보기
11/12

Android에서 직접 AWS S3 를 다루는 업무가 생겨 문서로 정리합니다.

S3(Simple Storage Service) 란 AWS(Amazon Web Service)에서 제공하는 IaaS 수준의 클라우드 서비스입니다.
간단히 파일, 이미지 등 다양한 데이터를 보관할 수 있는 스토리지 서비스입니다.

자체 서버도 있는 경우, 클라이언트에서 자체 서버를 거쳐 사용자를 식별하고 적절한 S3 경로에 저장하고 관리합니다.
하지만, 서버가 존재하지 않는 환경에서는 Android에서 직접 S3에 접근해야 합니다.

S3 환경 설정

S3를 다루기 위한 환경 설정은 크게 두 가지 단계로 나뉩니다.

  1. IAM 사용자 권한 설정
  2. S3 버킷 생성

IAM 사용자 권한 설정

AWS의 IAM 서비스 웹사이트에 접속합니다.

사용자 섹션에 사용자 생성 버튼을 클릭하여 S3를 사용할 유저와 권한을 등록해야 합니다.

사용자 이름 은 어떤 것으로 설정해도 무관하지만, 이후에 파악하기 쉽게 역할에 따라 명명하는 것을 권장합니다.

만약 기존에 그룹이 있다면 그룹에 사용자 추가 를 선택해야 하지만, 처음 설정하는 것이기 때문에 직접 정책 연결 을 선택합니다.

그리고 권한 정책 섹션에서 해당 사용자에게 AmazonS3FullAccess 권한을 제공합니다.

만약 권한이 지정되어 있지 않으면, API를 호출했을 때 403 에러가 발생합니다.

사용자를 생성 완료했다면, 그 다음으로는 권한을 지정해야 합니다.
권한에 대한 엑세스 키 를 생성합니다.

Android 로컬 환경에서 S3를 다룰 것이기 때문에 로컬 코드 를 선택합니다.

엑세스 키를 발급하면, 이후에 Android에서 S3 API에 제공해야 하는 엑세스 키(Access Key)비밀 엑세스 키(Secret Key) 를 저장해둬야 합니다.

이후에 비밀 엑세스 키(Secret Key) 에 재접근이 불가능하므로, CSV 파일로 다운로드 받거나 별도 파일에 기록해야 합니다.

S3 버킷 생성

AWS의 S3 서비스 웹사이트에 접속합니다.

버킷 이름 또한 마찬가지로 사용 목적과 일치하게 명명하여 생성합니다.
나머지 설정 값은 필요한 개발 환경에 따라 선택해야 합니다.

Android S3 통신 구현

// build.gradle.kts (app)

// 2024.07.17 기준 최신 버전 : 2.16.13
implementation("com.amazonaws:aws-android-sdk-s3:2.16.13")

app 수준의 build.gradle에서 aws-android-sdk-s3 의존성 을 추가합니다.

import android.content.Context;
import android.text.TextUtils;

import androidx.annotation.Nullable;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.mobileconnectors.s3.transferutility.TransferListener;
import com.amazonaws.mobileconnectors.s3.transferutility.TransferObserver;
import com.amazonaws.mobileconnectors.s3.transferutility.TransferUtility;
import com.amazonaws.regions.Region;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ListObjectsRequest;
import com.amazonaws.services.s3.model.ObjectListing;
import com.amazonaws.services.s3.model.S3ObjectSummary;

import java.io.File;
import java.util.List;


public class S3Manager {

    private volatile static S3Manager instance;
    private volatile AmazonS3Client s3Client;

    private String accessKey = "";  // IAM AccessKey
    private String secretKey = "";  // IAM SecretKey
    private Region region;          // S3 Region


    private S3Manager() {
    }

    public static S3Manager getInstance() {
        if (instance == null) {
            synchronized (S3Manager.class) {
                if (instance == null) {
                    instance = new S3Manager();
                }
            }
        }

        return instance;
    }

    public S3Manager setKeys(String accessKey, String secretKey) {
        this.accessKey = accessKey;
        this.secretKey = secretKey;
        return this;
    }

    public S3Manager setRegion(Regions regionName) {
        this.region = Region.getRegion(regionName);
        return this;
    }

    /**
     * S3 파일 업로드
     *
     * @param context    Context
     * @param bucketName S3 버킷 이름 (/(슬래쉬) 생략)
     * @param folder     버킷 내 폴더 경로 (맨 앞, 맨 뒤 /(슬래쉬) 생략)
     * @param file       Local 파일 경로
     * @param listener   AWS S3 TransferListener
     */
    public void upload(
            Context context,
            String bucketName,
            String folder,
            File file,
            TransferListener listener
    ) {
        upload(
                context,
                bucketName,
                folder,
                file,
                null, // Local 파일 이름 사용
                listener
        );
    }

    /**
     * S3 파일 업로드
     *
     * @param context    Context
     * @param bucketName S3 버킷 이름 (/(슬래쉬) 생략)
     * @param folder     버킷 내 폴더 경로 (맨 앞, 맨 뒤 /(슬래쉬) 생략)
     * @param fileName   파일 이름 (생략시 Local 파일 이름 사용)
     * @param file       Local 파일 경로
     * @param listener   AWS S3 TransferListener
     */
    public void upload(
            Context context,
            String bucketName,
            @Nullable String folder,
            File file,
            @Nullable String fileName,
            TransferListener listener
    ) {
        TransferUtility transferUtility = TransferUtility.builder()
                .s3Client(getS3Client())
                .context(context)
                .build();

        TransferObserver uploadObserver = transferUtility.upload(
                (TextUtils.isEmpty(folder))
                        ? bucketName
                        : bucketName + "/" + folder,
                (TextUtils.isEmpty(fileName))
                        ? file.getName()
                        : fileName,
                file
        );

        uploadObserver.setTransferListener(listener);

    }

    /**
     * S3 파일 다운로드
     *
     * @param context    Context
     * @param bucketName S3 버킷 내 폴더 경로 (이름포함, 맨 앞, 맨 뒤 /(슬래쉬) 생략)
     * @param fileName   파일 이름
     * @param file       저장할 Local 파일 경로
     * @param listener   AWS S3 TransferListener
     */
    public void download(
            Context context,
            String bucketName, String fileName, File file,
            TransferListener listener
    ) {
        TransferUtility transferUtility = TransferUtility.builder()
                .s3Client(getS3Client())
                .context(context)
                .build();

        TransferObserver downloadObserver = transferUtility.download(
                bucketName, fileName, file
        );

        downloadObserver.setTransferListener(listener);

    }

    /**
     * S3 파일 목록 조회
     *
     * @param bucketName S3 버킷 내 폴더 경로 (이름포함, 맨 앞, 맨 뒤 /(슬래쉬) 생략)
     * @param prefix     조회할 파일명 Prefix (e.g. xxx.txt 파일 조회 -> prefix = "xxx")
     */
    public List<S3ObjectSummary> getFiles(
            String bucketName,
            String prefix
    ) {
        ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
                .withBucketName(bucketName)
                .withPrefix(prefix);

        ObjectListing objectListing = getS3Client().listObjects(listObjectsRequest);

        return objectListing.getObjectSummaries();
    }

    private AmazonS3Client getS3Client() {
        invalidateKeys();
        invalidateRegion();

        if (s3Client == null) {
            synchronized (S3Manager.class) {
                if (s3Client == null) {
                    AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
                    s3Client = new AmazonS3Client(awsCredentials, region);
                }
            }
        }

        return s3Client;
    }

    private void invalidateKeys() {
        if (TextUtils.isEmpty(accessKey) || TextUtils.isEmpty(secretKey)) {
            throw new IllegalArgumentException(
                    "[ERROR] `AccessKey` & `SecretKey` 설정이 필요합니다."
            );
        }
    }

    private void invalidateRegion() {
        if (region == null) {
            throw new IllegalArgumentException(
                    "[ERROR] `Region` 설정이 필요합니다."
            );
        }
    }
}

해당 코드를 사용하시는 분들을 위해 전체 코드를 우선 작성하였습니다.
코드에 대한 상세 설명은 다음과 같습니다.

// S3Manager 생성, 빌더 파트
private S3Manager() {
}

public static S3Manager getInstance() {
    if (instance == null) {
        synchronized (S3Manager.class) {
            if (instance == null) {
                instance = new S3Manager();
            }
        }
    }

    return instance;
}

public S3Manager setKeys(String accessKey, String secretKey) {
    this.accessKey = accessKey;
    this.secretKey = secretKey;
    return this;
}

public S3Manager setRegion(Regions regionName) {
    this.region = Region.getRegion(regionName);
    return this;
}

S3Manager 객체는 하나면 충분하기 때문에 DCL 방식을 사용하여 싱글톤으로 생성하고 관리합니다.
또한, setKeys() / setRegion() 메서드를 통해 IAM 단계에서 얻은 권한 키를 설정할 수 있습니다.

// AmazonS3Client 생성 부분
private AmazonS3Client getS3Client() {
    invalidateKeys();
    invalidateRegion();

    if (s3Client == null) {
        synchronized (S3Manager.class) {
            if (s3Client == null) {
                AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
                s3Client = new AmazonS3Client(awsCredentials, region);
            }
        }
    }

    return s3Client;
}

private void invalidateKeys() {
    if (TextUtils.isEmpty(accessKey) || TextUtils.isEmpty(secretKey)) {
        throw new IllegalArgumentException(
                "[ERROR] `AccessKey` & `SecretKey` 설정이 필요합니다."
        );
    }
}

private void invalidateRegion() {
    if (region == null) {
        throw new IllegalArgumentException(
                "[ERROR] `Region` 설정이 필요합니다."
        );
    }
}

S3와 통신하기 위해서는 AmazonS3Client 객체가 필요합니다.
해당 객체를 생성하는 과정에서 accessKey와 secretKey를 사용하여 AWSCredentials 객체와 region이 필요합니다.

만약, key와 region이 지정되어 있지 않다면 예외를 발생시킵니다.

// S3에 파일 업로드 부분
public void upload(
        Context context,
        String bucketName,
        @Nullable String folder,
        File file,
        @Nullable String fileName,
        TransferListener listener
) {
    TransferUtility transferUtility = TransferUtility.builder()
            .s3Client(getS3Client())
            .context(context)
            .build();

    TransferObserver uploadObserver = transferUtility.upload(
            (TextUtils.isEmpty(folder))
                    ? bucketName
                    : bucketName + "/" + folder,
            (TextUtils.isEmpty(fileName))
                    ? file.getName()
                    : fileName,
            file
    );

    uploadObserver.setTransferListener(listener);

}

S3에 파일을 업로드하기 위해서는 TransferUtility 객체를 사용해야 합니다.

내부적으로 ApplicationContext 를 사용하기 때문에 Activity Context를 사용해도 메모리 누수가 발생하지 않습니다.

TransferUtility.upload() 메서드에 버킷명 / 키 / 업로드 파일을 전달합니다.

또한, TransfterListener를 등록하여 업로드 진행 정도, 업로드 상태 변화를 감지할 수 있습니다.
감지된 상태와 진행 정도를 바탕으로 적절하게 UI를 갱신하는 로직을 작성할 수 있습니다.

TransferUtility.upload() 메서드의 내부 코드는 다음과 같습니다.

    /**
     * 지정된 키를 사용하여 지정된 버킷에 파일 업로드를 시작합니다.
     * 파일은 유효한 파일이어야 합니다. 디렉터리는 지원되지 않습니다.
     *
     * @param bucket 새 객체를 업로드할 버킷의 이름입니다.
     * @param key    새 객체를 저장할 지정된 버킷의 키입니다.
     * @param file   업로드할 파일입니다.
     * @return 업로드 진행 상황 및 상태를 추적하는 데 사용되는 Observer입니다.
     */
    public TransferObserver upload(String bucket, String key, File file) {
        return upload(bucket, key, file, new ObjectMetadata());
    }

일반적으로 key는 파일명을 사용합니다.

// S3에서 파일 다운로드 부분
/**
* S3 파일 다운로드
*
* @param context    Context
* @param bucketName S3 버킷 내 폴더 경로 (이름포함, 맨 앞, 맨 뒤 /(슬래쉬) 생략)
* @param fileName   파일 이름
* @param file       저장할 Local 파일 경로
* @param listener   AWS S3 TransferListener
*/
public void download(
        Context context,
        String bucketName, String fileName, File file,
        TransferListener listener
) {
    TransferUtility transferUtility = TransferUtility.builder()
            .s3Client(getS3Client())
            .context(context)
            .build();

    TransferObserver downloadObserver = transferUtility.download(
            bucketName, fileName, file
    );

    downloadObserver.setTransferListener(listener);
}

upload() 메서드와 동일하게 TransferUtility를 생성합니다.
해당 코드에서는 별도로 처리하지 않았으나, TransferUtility 또한 싱글톤으로 생성하여 매번 객체를 생성하지 않도록 구현하는 것이 좋습니다.

TransferUtility.download() 는 업로드와 인자, 리스너 등록이 동일하기 때문에 설명을 생략합니다.

// S3 파일 목록 조회 부분
/**
* S3 파일 목록 조회
*
* @param bucketName S3 버킷 내 폴더 경로 (이름포함, 맨 앞, 맨 뒤 /(슬래쉬) 생략)
* @param prefix     조회할 파일명 Prefix (e.g. xxx.txt 파일 조회 -> prefix = "xxx")
*/
public List<S3ObjectSummary> getFiles(
        String bucketName,
        String prefix
) {
    ListObjectsRequest listObjectsRequest = new ListObjectsRequest()
            .withBucketName(bucketName)
            .withPrefix(prefix);

    ObjectListing objectListing = getS3Client().listObjects(listObjectsRequest);

    return objectListing.getObjectSummaries();
}

S3 버킷 내에 존재하는 파일을 조회해야 하는 경우가 있습니다.
request에 버킷명과 조회하고자 하는 파일명의 prefix를 전달하여 필터링할 수 있습니다.

List<S3ObjectSummary>를 반환하여 적절히 활용할 수 있습니다.

S3Manager 클래스 활용

실습에 사용할 Activity를 생성합니다.

S3에 파일 업로드

File uploadFile = new File(getFilesDir(), "파일명.txt");

if (!uploadFile.exists()) {
    boolean fileCreated = uploadFile.createNewFile();
    if (!fileCreated) {
        Log.e("buna", "[ERROR] 파일 생성에 실패했습니다.");
    }
}

S3Manager.getInstance()
        .setKeys("{IAM AccessKey}", "{IAM SecretKey}")
        .setRegion("{S3 Region}")
        .upload(
                this,
                "{버킷명}",
                "{폴더명}", // 폴더명 양 끝에 슬래시 생략 : /my-folder (X) my-folder (O)
                uploadFile,
                new TransferListener() {
                    @Override
                    public void onStateChanged(int id, TransferState state) {
                        Log.d("buna", "전송 상태 : " + state.name());
                    }

                    @Override
                    public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) {
                        Log.d("buna", "전송 진행 : " + bytesCurrent + " / " + bytesTotal);
                    }

                    @Override
                    public void onError(int id, Exception ex) {
                        Log.e("buna", "[Error] : " + ex.getMessage());
                    }
                }
        );

S3에서 파일 다운로드

File downloadFile = new File(getFilesDir(), "파일명.txt");

S3Manager.getInstance()
        .setKeys("{IAM AccessKey}", "{IAM SecretKey}")
        .setRegion("{S3 Region}")
        .download(
                this,
                "{버킷명}",
                "{파일명}",
                downloadFile,
                new TransferListener() {
                    @Override
                    public void onStateChanged(int id, TransferState state) {
                        Log.d("buna", "전송 상태 : " + state.name());
                    }

                    @Override
                    public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) {
                        Log.d("buna", "전송 진행 : " + bytesCurrent + " / " + bytesTotal);
                    }

                    @Override
                    public void onError(int id, Exception ex) {
                        Log.e("buna", "[Error] : " + ex.getMessage());
                    }
                }
        );

S3 파일 목록 조회

new Thread(() -> {
    List<S3ObjectSummary> files = S3Manager.getInstance()
            .setKeys("{IAM AccessKey}", "{IAM SecretKey}")
            .setRegion("{S3 Region}")
            .getFiles("{버킷명}", "{prefix}");

    for (S3ObjectSummary file : files) {
        Log.d("buna", file.getKey());
    }
}).start();

S3ObjectSummary는 S3 버킷에 저장된 파일에 대한 일부 메타데이터를 포함하고 있습니다.
자세한 정보는 다음과 같습니다.

// S3ObjectSummary 필드
/**
 * Amazon S3 버킷에 저장된 객체의 요약을 포함합니다.
 * 이 개체에는 개체의 전체 메타데이터나 해당 콘텐츠가 포함되어 있지 않습니다.
 *
 * @see S3Object
 */
public class S3ObjectSummary {

    /** 이 객체가 저장된 버킷의 이름 */
    protected String bucketName;

    /** 이 객체가 저장되는 키 */
    protected String key;

    /** Amazon S3에서 계산된 이 객체 콘텐츠의 16진수로 인코딩된 MD5 해시 */
    protected String eTag;

    /** 이 객체의 크기(바이트 단위) */
    protected long size;

    /** Amazon S3에서 이 객체가 마지막으로 수정된 날짜입니다. */
    protected Date lastModified;

    /** 이 객체를 저장하기 위해 Amazon S3에서 사용하는 스토리지 클래스 */
    protected String storageClass;

    /**
     * 이 개체의 소유자 - 요청자가 개체 소유권 정보를 볼 수 있는 권한이 없는 경우 null일 수 있습니다.
     */
    protected Owner owner;

Refs.

profile
망각을 두려워하는 안드로이드 개발자입니다 🧤

1개의 댓글

comment-user-thumbnail
2024년 7월 17일

굿굿입니다!!

답글 달기