티스토리 뷰

저번에 서버리스 컴퓨팅에 대한 포스팅을 썼었다. 저 땐 사실 서버리스로 딱히 뭘 만들어보겠다는 생각은 없었고 그냥 "대학원 시절에 서버리스란 개념을 배웠었는데 직접 개발을 해보진 못했었지, 리뷰하면서 한번 람다 써보기나 해볼까" 같은 느낌으로 쓴 거였다.

 

그런데 람다를 써보면서 생각을 해보니까 전에 만들었던 (정확히는 얼추 만들긴 했는데 '완성' 하진 않고 있었던) EZ2AC 성과사진 분석기를 람다 기반으로 바꿔서 다시 만들면 더 낫지 않을까, 라는 생각이 들더라.

왜냐면 람다 같은 서버리스 컴퓨팅 서비스들의 특징이 종량제 과금, 그러니까 '요청을 보낸 횟수만큼만' 과금을 한다는 것인데, 이 성과 분석기라는게 많은 사람들이 쓰는 것도 아니고 딱 1명을 위해서 만들어진 서비스이기 때문에() EC2 서버를 계속 켜두는 것에 비해 돈을 절약할 수 있기 때문이다. 서비스 특성상 이 유저 1명이 언제 접속할지는 알 수 없지만 별로 오랜 시간동안 서비스를 사용하지는 않을 것이라는 건 확실한데, EC2를 계속 켜놓는건 사실 돈 낭비지만 그렇다고 시간대에 따라 껐다 켰다 하기도 뭐하다. 이런 경우 람다가 좋은 대안이 될 수 있을 것이라고 생각했다.

 

Keywords:

- API Gateway 기본 개념 및 사용법

- S3 엔드포인트, 퍼블릭화

- Cognito & IAM 권한 설정

- Android S3 Client 사용법

- Android ListView, ViewHolder 패턴

- Lambda 외부 라이브러리 사용법

- DynamoDB 기본 개념 및 사용법


1) API Gateway: 요청을 받을 엔드포인트를 만들자

 

이전 포스팅에서 람다 테스트를 할 때는 AWS 콘솔에서 S3에 직접 사진을 올리는 방법으로 람다 함수를 호출했다. 하지만 테스트가 아니라 실제 서비스라면 유저가 클라이언트에서 사진을 올려야 한다.

기존의 사진 분석기는 웹 앱이었기 때문에 사진을 HTML의 파일 업로드 폼을 이용해 Request에 담아 보내는 방식이었다. 

<form class="form-inline" action="http://3.225.40.45:8080/webapiexam/" method ="post" enctype="multipart/form-data">
	file : <input class="btn btn-outline-success my-2 my-sm-0" type = "file" name="upload" />
	<input class="btn btn-outline-success my-2 my-sm-0" type="submit" value="Upload" />
</form>

 

이렇게.

 

저기서 action에 들어가는 URL로 요청을 보내게 되는데, 저 주소처럼 HTML 요청을 받는 서버측 URL을 흔히 HTML 엔드포인트, API 엔드포인트 등으로 부른다. 근데 기존의 웹 앱은 EC2에 올려둔 웹 서버에 접속하는 방식이었기에 저런 식으로 요청을 보낼 수 있었지만, 이젠 서버리스다. EC2를 쓰지 않고 람다 함수를 호출해서 작업을 처리한다는 것인데, 그럼 요청을 어디로 보내야 할까?

 

이 의문을 해결해주는 서비스가 'API Gateway' 라는 서비스다. API Gateway를 이용하면 API 엔드포인트를 만들고, 들어온 요청을 Lambda를 비롯한 다른 AWS 서비스로 보내서 작업을 처리할 수 있다. 그러니까 위 코드에서 "http://3.225.40.45:8080/webapiexam/" 에 해당하는 부분을 AWS가 만들어준다는 뜻이다. 저 URL 뒤에 뭘 더 붙여서 그에 해당하는 요청을 처리하게 만들 수도 있다.

 

AWS 콘솔에서 API Gateway로 들어가서 API 생성 > REST API를 클릭한다. 그 다음 화면에서는 딱히 설정해줄 건 없고 그냥 API 이름만 입력해주고 확인을 누르자.

 

그러면 이런 화면이 나온다. '메서드 생성' 이랑 '리소스 생성' 이란 메뉴가 있는데, 리소스라는 건 URL 뒤에 예를 들면 '/webapiexam/main' 같은 식으로 붙는걸 정의해주는 거고 메서드는 GET, POST, PUT, DELETE 같은 요청에 각각 어떻게 반응할 것인지를 정의해주는 것이다.

 

일단 먼저 리소스 생성을 하자. /test 같은 식으로 이름만 적당히 정해주자. 그 다음 만들어진 '/test' 를 클릭한 뒤 '메서드 생성' 을 누르자. 그러면 메서드 타입을 고를 수 있는 드롭다운이 나오는데 GET이든 POST든 눌러보자. 다음으로 나오는 화면에서 '람다 함수' 를 선택하고 이미 만들어뒀던 람다 함수의 이름을 아래에 입력한다. 그러면 '(AWS가 주는 URL)/test' 라는 API 엔드포인트가 생기고 여기로 요청을 보내면 그 람다 함수가 실행되면서 요청을 받는 것이다.

 

'AWS가 주는 URL' 이라는건 어디서 확인할 수 있냐면... 일단 'API 배포' 라는 걸 해야 한다. 여기까진 API를 만들기만 한 것이고 '배포' 를 해야 접근 가능한 URL이 생긴다. 아까 봤던 작업 메뉴에서 'API 배포' 를 선택하고 적당한 배포 스테이지 이름 (beta라던지) 을 입력해서 배포한다.

그러고 나서 왼쪽의 메뉴에서 '스테이지' 를 클릭하고 방금 만들었던 배포 스테이지 이름을 클릭하면 화면 위쪽에 파란색 박스와 함께 'URL 호출: ~~~~' 같은 식으로 URL이 표시되어 있을 것이다. 이게 새로 만든 API 엔드포인트이다.

 

여기까지 공부하고 이전에 만들었던 람다 함수를 테스트해보려고 했다.

처음엔 위의 코드를 그대로 쓰고 URL만 바꿔서 요청을 보낸 다음, 람다에 multipart upload 요청을 받아서 처리하는 코드를 넣었다. 하지만 헤더 인코딩이 틀렸다느니 하면서 자꾸 에러가 떴다.

 

차선책으로 람다로 파일을 보내는게 아니라 API Gateway에서 바로 S3으로 파일을 보내는 방법을 찾아봤다. 그런 게 있더라. S3에 일단 파일이 업로드가 되면 람다는 그걸 트리거로 실행되기 때문에 이렇게 해도 될 것이었다. 이 문서를 보고 따라해봤다.

요약하면 바로 위에 있는 사진에서 '람다 함수' 대신 'AWS 서비스' > 'S3' 을 연결한 다음 리소스 이름을 'folder/item' 같은 식으로 설정한 다음 그 밑에 PUT 메서드를 만들면 버킷 내에 객체를 생성할 수 있다는 것이다.

 

이걸 따라해봤는데, 처음엔 인증 문제로 500 에러가 떴다. 이 문제는 IAM에서 람다랑 연결된 역할을 찾아서 '신뢰 관계' > 'API Gateway' 를 추가해줘서 해결할 수 있었다.

 

근데 문제는 내가 만들었던 HTML 폼을 쓰려고 하니까... HTML에서 PUT 요청을 어떻게 보내냐는 문제가 있었다. 분명 REST API에 대해서 배울 때는 GET, POST, PUT, DELETE 등의 메서드가 있다고 배웠지만 HTML 폼에선 GET이랑 POST 요청밖에 지원하지 않는다. API Gateway에서 자체적으로 제공하는 테스트 기능이나, Postman 같은 테스트 도구를 사용하해서 PUT 메서드로 된 적당한 요청 (적당한 파일이름과 파일 내용을 넣은) 을 보내니까 진짜 S3에 파일이 올라가기는 한다. 하지만 HTML에서 PUT을 쓰는 방법을 찾지 못했다. 보안 문제 때문에 그걸 못하도록 막아놓은 것이라고도 하는데...


2) S3을 바로 엔드포인트로 쓰기

 

다른 방법을 찾아보다가 알아낸 사실은 S3에서는 버킷 자체를 엔드포인트로 사용할 수 있는 기능을 제공한다는 것이다. 즉 API Gateway 필요없이 바로 파일을 업로드할 수 있다는 것이다!

 

URL은 <https://버킷이름.s3.amazonaws.com> 이다.

 

단, 처음 버킷을 만들면 버킷을 만든 관리자만 접근할 수 있는 상태이다. URL만 안다고 아무나 내 버킷에 접근해서 파일을 올리고 지우고 할 수 있다면 곤란하기 때문이다. 웹 페이지에서 별도의 작업 없이 저기로 요청을 보내려면 버킷을 '퍼블릭' 상태로 오픈해야 한다. (사실 이것도 테스트니까 이렇게 하는거지 실제 서비스라면 완전 퍼블릭으로 오픈하는 것은 보안상 위험할 것이다)

 

버킷의 '권한 > 액세스 제어 목록' 으로 들어가서 '퍼블릭 액세스' 가 가능하도록 체크를 해주면 저 URL로 요청을 보낼 수가 있게 된다.

 

근데 테스트를 해보려니 제대로 업로드가 되지 않았다. 찾아보니 하나 더 해야할 일이 있었다. CORS (크로스 오리진 리소스 공유) 설정이라는 것인데, 사실 CORS가 뭔지는 잘 모르겠다... 이런 것까지 자세히 공부할 의욕은 없었다. 어쨌든 버킷의 '권한 > CORS 구성' 으로 들어가보면 이렇게 생긴 코드가 있다.

 

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>Authorization</AllowedHeader>
</CORSRule>
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>POST</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>Authorization</AllowedHeader>
</CORSRule>
</CORSConfiguration>

 

처음 버킷을 만들고 손을 안 댔다면 GET 부분만 있을 것이다. 아마 GET 요청을 받을 수 있게 한다는 뜻일 것이다. 그런데 여기서 보내야 할 요청은 파일 업로드를 위한 POST 요청이기 때문에 POST도 받을 수 있게 해야 한다. 그래서 위 코드와 같이 POST 부분도 추가해준다.

코드 편집기가 회색으로 되어있어서 못 고치나? 싶었는데 코드를 복붙하니까 고칠 수 있었다...

 

여기까지 하고 나면 요청이 가긴 간다. HTML 폼이 어떻게 생겼었는지 다시 한번 보자.

<form class="form-inline" action="https://버킷이름.s3.amazonaws.com" method ="post" enctype="multipart/form-data">
	file : <input class="btn btn-outline-success my-2 my-sm-0" type = "file" name="upload" />
	<input class="btn btn-outline-success my-2 my-sm-0" type="submit" value="Upload" />
</form>

 

요청이 가긴 가는데 에러가 여러번 떴다. 이 HTML 폼과 관련해서 마주쳤던 에러들은 다음과 같다.

 

- 파일 사이즈 초과 오류: 파일 사이즈가 너무 크다면서 에러가 뜬다. 이 문제를 해결하기 위해선 파일을 올리는 input 태그의 이름을 'file' 로 해야 한다. (위에는 upload라고 되어 있다)

- 'key' 가 없다는 오류: key라는 속성이 필요한 모양이다. HTML의 hidden input으로 'key' 라는 속성을 만들어서 넣어줬다. 이 key는 파일의 이름을 의미한다. 아래와 같이 태그를 만들어서 넣어주면 된다.

 

<input type="hidden" name="key" value="filename.jpg" />

여기까지 하면 드디어 파일 업로드가 제대로 된다.

그런데, 이렇게 업로드한 파일을 보면 어째 볼 수도 없고 다운로드받을 수도 없고 권한이 죄다 막혀있다. 그래서 파일이 올라왔을 때 람다 함수가 호출은 됐는데, 람다 함수에서 이 파일을 불러오려다 권한이 없다면서 에러가 떴다. 올릴 때 권한을 부여해줘야 할 것 같은데...

그걸 해주는 속성이 'acl' 이라는 속성이다. Access Control List의 약자다. acl 속성의 value로는 권한을 표시하는 정해진 값들이 들어갈 수 있는데, 예를 들어 'bucket-owner-full-control' 이라는 값을 주면 모든 권한을 얻는다.

 

<input type="hidden" name="acl" value="bucket-owner-full-control" />

처음 만들었던 폼에서 key라던가 acl이라던가 속성들이 좀 추가됐는데, 이런 식으로 S3에 요청을 보낼 때 넣어야 하는 (넣을 수 있는) 속성들에 대한 정보는 이 페이지에서 참조하면 된다.

 


3) 웹 페이지는 어떡하나?

 

테스트용으로 만든 HTML 폼을 이용해서 파일을 S3에 업로드할 수 있게 됐고, 람다 함수가 그걸 트리거로 사진을 분석하게 만들었다. 데이터베이스에 대해선 나중에 얘기하겠지만 boto3 API를 이용해서 분석 결과를 DB에 넣는 것까진 순탄할 것 같다.

 

그럼 남은 문제는... 클라이언트 쪽이다.

 

- 업로드 폼이 있는 웹 사이트에 어떻게 접속하게 만들 것인가?

여기까진 쉬운 문제였다. S3은 '정적 웹사이트' 를 호스팅할 수 있는 기능을 제공한다. 뭔 소리냐면 위에서 S3 버킷의 엔드포인트 URL을 이용해서 요청을 날렸듯, S3 버킷에 HTML 파일을 넣어두면 그 페이지로 URL을 통해 접근할 수 있다는 얘기다.

 

버킷의 속성으로 들어가면 위 사진과 같이 '정적 웹 사이트 호스팅' 이란 메뉴가 있다. 여기로 들어가서 간단한 설정을 해주면 아래와 같은 엔드포인트 URL이 생성되며, 이 주소로 웹 사이트에 접속할 수 있다.

 

<http://버킷이름.s3-website-리전.amazonaws.com/>

 

- 어떻게 기록을 받아와서 페이지를 갱신할 것인가?

문제는 이거였다. 나한테 필요한 건 정적 웹페이지가 아니라 DB로부터 기록을 받아와서 페이지에 띄워주는, 그러니까 서버 측 (서버리스라면서 서버 측이라고 하니까 뭔가 이상하지만 개념상) 과 통신을 해야 하는 동적 웹페이지다. 기존에 짜놨던 코드는 서블릿과 JSP를 이용해서 페이지를 띄워주는 거였고 걔네는 서버 사이드 기술이니 S3만으로 해결할 수 없다. 그럼 어떻게 해야하지?

 

> 람다 함수 안에서 HTML 코드를 print하도록 해서 새로운 HTML 파일을 생성하도록 한다 (...)

처음 머릿속에서 떠오른 방법이 이거였다. 나도 참 정신나간 것 같다.

 

> django나 Flask같은 웹 프레임워크를 사용해서 웹 사이트를 만들고 람다에 업로드한다

이 문제를 해결할 방법을 찾다가 어떤 블로그에서 발견한 방법. 그 블로거는 개발 고수여서 웹 프레임워크로 웹 사이트를 구축한 뒤 람다에 올려서 실제 서비스를 돌리기까지 이틀이면 됐다고 하는데 나는 이 포스팅에 쓰고 있는 내용을 구현하는 데 몇 주나 (물론 제대로 집중한 시간은 얼마 안되겠지만....) 걸려서 좀 자괴감이 들었다.

django는 잠깐 테스트 정도는 해본 적 있지만 다 까먹은데다 제대로 올리려면 공부해야 할 게 더 많을 것 같았고, Flask는 아예 몰랐다. 그리고 이 방법으로 올릴 때 그냥 람다에 올리면 무슨 문제가 있는건지 몰라도 서버리스 프레임워크라는 걸 또 많이 쓴다고 한다. 서버리스 프레임워크라는 건 람다 같은 서버리스 플랫폼에 좀 더 쉽게 서비스를 올릴 수 있도록 도와주는 도구인데, 대표적인 걸로 Serverless (프레임워크 이름이 말 그대로 Serverless다) 가 있고 Python용으로는 Zappa라는 것도 있다 한다.

웹 프레임워크에 서버리스 프레임워크까지 어느 세월에 다 공부하나 싶어서 이걸 따라해보는건 일단 포기했다.

 

> 자바스크립트용 AWS SDK를 사용해서 DB에 접근한다?

이 문제에 막혔을 때만 해도 이걸 생각을 못했었는데, 지금 글 쓰면서 생각해보니 자바스크립트를 어떻게 쓰면 될 것도 같아서 찾아봤더니 자바스크립트용 SDK가 있다 한다. HTML이랑 CSS, 자바스크립트까진 S3만으로 커버할 수 있다. 이걸 쓰면 어찌 될 것도 같다...

 

이런저런 생각을 해보다가 내가 내린 결론은 결국....


4) 안드로이드 앱으로 선회: 안드로이드에서 AWS S3 API & Cognito 사용하기

 

익숙한 자바랑 안드로이드로 돌아가기로 했다. 안드로이드라면 웹 페이지 역할은 폰에 직접 설치되는 앱이 해주고, Java API를 이용해서 DB에 접근하면 될 것이기 때문이다.

 

- S3 Client 사용법

일단 라이브러리를 사용하기 위해서 build.gradle 파일에 의존성을 추가해준다. S3이랑 Cognito, DynamoDB를 사용하기 위한 패키지들이다.

    implementation ('com.amazonaws:aws-android-sdk-mobile-client:2.6.+@aar') { transitive = true }
    implementation 'com.amazonaws:aws-android-sdk-s3:2.6.+'
    implementation 'com.amazonaws:aws-android-sdk-cognito:2.6.+'
    implementation 'com.amazonaws:aws-android-sdk-ddb:2.6.+'
    implementation 'com.amazonaws:aws-android-sdk-ddb-document:2.6.+'

그리고 아래는 S3 Client를 이용해서 파일을 업로드하는 부분의 코드이다.

AmazonS3Client라는 객체에 인증 관련 정보 (아래에서 설명) 를 던져준 다음, TransferUtility라는 S3 SDK에 들어있는 클래스를 사용한다. upload 메서드에 들어가는 MY_BUCKET은 버킷 이름, OBJECT_KEY는 파일 이름, MY_FILE은 업로드할 파일 객체이다 (메타데이터랑 액세스 컨트롤 어쩌구도 아래에서 설명).

그리고 이 upload 메서드가 리턴하는 걸 TransferObserver라는 객체에다가 던져준 다음 이걸로 TransferListener를 만들면 업로드 현황을 모니터링하면서 몇 퍼센트 업로드가 완료됐는지, 또는 무엇 때문에 업로드에 실패했는지 등을 알 수가 있다.

            TransferUtility transferUtility;
            CognitoCachingCredentialsProvider credentialsProvider = new CognitoCachingCredentialsProvider(
                    getApplicationContext(),
                    "us-east-1:-----------------------", // 자격 증명 풀 ID
                    Regions.US_EAST_1 // 리전
            );
            AmazonS3 s3 = new AmazonS3Client(credentialsProvider);
            transferUtility = new TransferUtility(s3, getApplicationContext());

            TransferObserver observer = transferUtility.upload(MY_BUCKET, OBJECT_KEY, MY_FILE, metadata, CannedAccessControlList.PublicRead);
            observer.setTransferListener(new TransferListener() {
                @Override
                public void onStateChanged(int id, TransferState state) {
                }

                @Override
                public void onProgressChanged(int id, long bytesCurrent, long bytesTotal) {
                    int percentage = (int) (bytesCurrent/bytesTotal * 100);
                    if (percentage >= 100)
                        Toast.makeText(MainActivity.this, "업로드 성공!", Toast.LENGTH_SHORT).show();
                }

                @Override
                public void onError(int id, Exception ex) {
                    Toast.makeText(MainActivity.this, "error:" + ex.toString(), Toast.LENGTH_SHORT).show();
                    ex.printStackTrace();
                }
            });

 

- Cognito 등록, 역할 부여, 자격 증명 가져오기

위에서 AmazonS3Client 객체를 만들 때 credentialsProvider라는게 들어갔는데 이게 Cognito라는 AWS 서비스랑 관련있는 부분이다. 사실 이건 처음 들어보는 서비스였는데 S3 Client 사용법에 대해서 찾아보다보니 이런걸 쓰라고 하더라. 자격 증명 서비스라니 좀 생소한데 일단 따라해보기로 했다.

 

콘솔에서 Cognito에 들어가보면 '사용자 풀 관리' 이랑 '자격 증명 풀 관리' 두 가지 메뉴가 있다. '자격 증명 풀 관리' 를 선택하고 '새 자격 증명 풀 만들기' 버튼을 클릭하자.

(대충 찾아보니 아마 '사용자 풀 관리' 쪽은 웹 사이트에 회원가입을 하면 아이디, 비밀번호, 기타 회원 정보 등이 저장되듯 그런 식의 관리를 웹 서버 없이 할 수 있게 해주는 기능인 것 같다. 이 앱에서는 그런 것까지는 필요없고 그냥 API 요청을 할 수 있는 권한만 주면 되므로 '자격 증명 풀 관리' 를 선택했다.)

 

적당한 자격 증명 풀 ID를 입력하고 '인증되지 않은 자격 증명에 대한 액세스 활성화' 를 체크하자. 딱히 로그인이나 그런걸 하지 않아도 그냥 접근할 수 있게 만들어주겠다는 것이다.

다음 화면으로 넘어가면 뭔가 영어로 된 안내가 나오고 아래쪽의 상세 보기를 눌러보면 IAM 어쩌구 하는 내용이 나온다. 건드릴 건 없는데 IAM이랑 뭔가 상관이 있다는 걸 알 수 있다. '허용' 을 누르고 그냥 다음으로 넘어가자. 그러면 아래와 같은 화면이 나온다.

아래의 회색 박스에 나오는 코드를 그대로 복사해서 갖다붙인 게 아까 봤던 위의 코드이다.

 

근데 여기서 끝이 아니다. 그냥 저 코드만 달랑 붙여넣고 실행을 하려고 하면 권한이 없다며 실패할 것이다. 만들어준 '자격 증명' 이라는 녀석은 자기 자신만으로 특정한 서비스에 대한 접근 권한을 얻는게 아니라, IAM이랑 연동이 돼서 IAM의 권한을 따라가기 때문에 IAM에서 권한 설정을 해줘야 S3, DynamoDB API를 사용할 수가 있다.

IAM으로 가서 '역할' 을 눌러보자.

 

내가 만들어준 적 없는 이런 역할들이 생겨있다. 이 중에서 Unauth_Role (아까 인증되지 않는 사용자에게 접근 권한을 주겠다고 했으므로) 을 선택하고, 여기서 '정책 연결' 을 통해 S3과 DynamoDB에 대한 Access 권한을 주면 된다. 

 

- 메타데이터 가져와서 넣기: ExifInterface

이전 버전을 만들었을 땐 기록을 저장할 때 '날짜' 는 그냥 캘린더 클래스를 이용해서 오늘 (기록을 업로드한 날) 날짜로 넣어줬었는데, 사진의 메타데이터를 가져와서 사진을 찍은 날짜 즉 실제로 그 기록을 낸 날짜로 저장할 수 있으면 더 좋겠다는 생각이 들어서 메타데이터를 어떻게 가져오는지 찾아봤다.

 

            ExifInterface meta = new ExifInterface(real_path);
            String date = meta.getAttribute(ExifInterface.TAG_DATETIME);

            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentType("plain/text");
            metadata.addUserMetadata("date", date);

 

안드로이드에서 파일의 메타데이터를 관리하기 위해서는 ExifInterface라는 클래스를 사용한다. 생성자의 파라미터로 들어간 real_path는 파일의 경로다. 저렇게 만들어진 'meta' 객체에 파일의 각종 메타데이터들이 들어가게 되는데, meta.getAttribute() 를 이용해서 각 데이터들을 가져올 수 있다. 날짜를 가져오기 위해서는 위와 같이 하면 된다.

 

아래 세 줄은 AWS S3에 파일을 업로드할 때 메타데이터를 붙여주기 위한 코드다. S3 API에서 제공하는 ObjectMetadata 클래스를 사용하며, addUserMetadata() 라는 메서드를 이용해서 위에서 얻어온 date 값을 넣어주면 된다. 이렇게 메타데이터를 넣어서 파일을 업로드했으면 S3 콘솔에서 해당 파일의 속성을 살펴보면 아래 사진처럼 메타데이터가 들어가있는 것을 확인할 수 있다.

 

 

람다 함수에서는 아래와 같이 받아서 쓰면 된다. 메타데이터 이름을 'date' 라고 줬으므로 metadata['Metadata'] 안에서 'date' 를 찾는다 (이중 딕셔너리로 되어있다). 위 사진에 'x-amz-meta-date' 라고 되어있어서 이 이름으로 찾아야 하는게 아닌가 싶을 수도 있지만 안드로이드에서 업로드할 때 지정해줬던 이름인 'date' 로 찾는게 맞다.

if-else를 써준 이유는 간혹 사진에 메타데이터가 누락되어 있는 경우에 대한 예외처리를 해주기 위함이다.

 

	metadata = s3client.head_object(Bucket=bucket, Key=key);
	if 'date' in metadata['Metadata']:
		print ('metadata exists');
		datetime = metadata['Metadata']['date'];
		date = datetime.split(" ")[0];
	else:
		print ('metadata not exists');
		date = '0000:00:00';

- PutObjectAcl 권한 이슈

아까 봤던 업로드 코드 중에서 밑줄 친 부분은 처음에는 넣지 않았었다.

 

TransferObserver observer = transferUtility.upload(MY_BUCKET, OBJECT_KEY, MY_FILE, metadata, CannedAccessControlList.PublicRead);

근데 파일이 제대로 업로드는 됐는데, 업로드된 사진을 다시 불러오질 못해서 기록을 눌러도 사진이 안뜨는 문제가 있었다. 아무런 설정 없이 그냥 파일을 업로드하면 기본적으로 퍼블릭하게 접근할 수 없는 상태로 업로드되기 때문이다.

이를 해결할 수 있는 방법이 저 밑줄 친 부분으로, ACL (Access Control List) 설정이라 한다. PublicRead 말고도 여러가지 설정이 있어서 원하는대로 접근 권한 설정을 해줄 수가 있다.

 

근데 그냥 저 밑줄 친 코드만 추가해서 실행을 시키면 또 안된다. 왜냐면 '파일의 접근 권한을 설정하는 행위' 에도 권한이 필요하기 때문이다.... 그 권한은 IAM에서 줄 수 있다. 아까 Cognito에서 설정했었던 역할로 들어가서 'PutObjectAcl' 이라는 권한을 연결해주면 해결된다.


5) ListView와 사진 띄우기, ViewHolder 패턴

리스트에서 기록을 누르면 사진을 보여주는 기능에 대한 부분이다.

기존에 웹으로 만들었을 땐 부트스트랩을 이용해서 Collpase Button을 리스트로 만들고, 그 안에 img 태그를 넣는 식으로 구현을 했었다. 안드로이드에선 어떻게 사진을 보여줄까?

 

- 이미지 라이브러리: Picasso

안드로이드에서 웹 상의 이미지를 불러와서 띄우기 위해서 흔히 라이브러리가 사용되는데 Picasso랑 Glide라는 게 제일 잘 알려져 있더라. 이 중에서 Picasso를 사용해보기로 했다. 기본 사용법 자체는 굉장히 간단했다.

일단 의존성을 추가해주고,

 

implementation 'com.squareup.picasso:picasso:2.71828'

코드 한 줄이면 이미지를 띄울 수 있다.

 

Picasso.get().load(IMAGE_PATH).into(ImageView);

일단 메인 액티비티 위에다 띄우는 것까지는 성공했다. 근데 이미지뷰가 메인 액티비티의 특정 부분을 계속 차지하고 있는 건 역시 화면 낭비다. 두 가지 선택지를 생각했다.

 

> 팝업창을 띄워서 그 위에 이미지뷰를 띄운다

> 웹에서 했던 것처럼 Collapse Button (클릭하면 내용물이 펼쳐졌다 접혔다 하는 것) 을 구현하는 방법을 찾아본다

 

그동안 많이 해본게 팝업창 띄우는 거였기 때문에 (이전에 많이 우려먹었던 코드가 이미 있었다) 팝업창 위에 띄우는 방법을 먼저 시도해봤다.

근데 이미지가 뜨질 않았다. 메인 액티비티 위에서는 잘 떴는데 팝업창 위에선 안 떴다. 한참동안 이것 때문에 헤맸는데, Picasso를 쓰는 코드가 지금은 저렇게 간소화되어 있지만 옛날 버전 코드를 보니까 액티비티 컨텍스트? 를 파라미터로 넘겨주는 부분이 있더라. 아마 팝업창이 뜨면서 그게 바뀌어서 그런 것 같기는 한데... 사실 아직도 내가 안드로이드의 Context에 대한 개념을 확실하게 잘 모른다. 그래서 이걸 해결하는건 포기하고 두번째 방법으로 넘어가기로 했다.

 

- ListView 만들기 & ImageView의 Visibility 속성

처음엔 펼쳐졌다 접혔다 하는 ListView를 어떻게 만들까 하다가 ExpandableListView라는 게 있다는 걸 알았다. 근데 이걸로 ImageView를 띄우는 예제가 좀처럼 보이지 않아서 다른 쪽으로 찾아보다가, 그냥 ListView를 쓰고 ListView의 아이템 안에 ImageView를 넣되 ImageView의 Visibility라는 속성을 사용해서 보였다가 안보였다가 하게 만들 수 있다는 걸 알았다.

 

리스트뷰에서 하나의 아이템 안에 여러개의 요소들 (TextView라던지 ImageView라던지) 을 넣을 수 있는 방법은 layout xml 파일을 하나 만들어서 아이템 안에 들어갈 요소들을 표현해준 다음, Adapter를 만들 때 그 레이아웃 파일을 연결해주는 것이다. 이 앱에서는 하나의 기록에 대해 곡 제목, 난이도, 레벨, 정확도, 점수, 그리고 사진을 보여줘야 하므로 이것들이 들어간 레이아웃 파일 (list_layout.xml) 을 만든 뒤,

 

m_adapter = new MyAdapter(MainActivity.this, R.layout.list_layout, records, max_width);

이런 식으로 Adapter를 만들어주면 되는 것이다.

 

MyAdapter라는게 나왔는데 여러 개의 속성들이 하나의 아이템에 들어가야 하다보니 안드로이드에서 기본적으로 제공되는 Adapter (ArrayAdapter, CursorAdapter 등) 로는 값들을 넣을 수가 없기 때문에 이런걸 만들었다. 그러고보면 지난 번에도 이런 걸 한 적이 있었다. 이 MyAdapter에는 ListView의 아이템이 화면에 뜰 때 호출되는 (?) getView() 라는 메서드가 오버라이드 되어있어서, 그 안에서 list_layout 안에 있는 레이아웃 요소들을 객체로 만들어주고 setText() 등으로 값을 넣어준다.

 

그리고 리스트뷰에 OnClickListener를 연결해서, 아이템을 클릭했을 때 MyAdapter 안에 만들어놓은 아래와 같은 메서드가 호출되도록 만들었다.

 

   public void set_image_visibility(int position, int width){
        // ...
        Picasso.get().load(path).into(img);
        if (img.getVisibility() == View.GONE)
            img.setVisibility(View.VISIBLE);
        else
            img.setVisibility(View.GONE);
    }

 

img가 ImageView이다. ImageView의 Visibility 속성은 말 그대로 이미지뷰가 화면에 보일지 안 보일지를 결정하는데, 레이아웃 파일에서 기본값은 GONE으로 두고, 클릭할 때마다 setVisibility() 메서드를 이용해서 상태를 VISIBLE <-> GONE 변환하도록 만들면 원하는 대로 리스트뷰의 아이템을 클릭할 때마다 이미지뷰가 떴다 사라졌다 하게 만들 수 있었다.

 

여기까지였으면 좋았을텐데... 테스트를 하다보니 문제가 있었다.

처음 기록을 몇 개 넣었을 땐 잘 동작하는 줄 알았다. 근데 기록이 많아져서 리스트뷰가 스크롤되기 시작했을 때 문제가 생겼다. 화면이 한번 스크롤된 이후부터, 기록을 눌렀을 때 엉뚱한 다른 사진이 나타나는 현상이 발생했다.

 

문제는 아마도 리스트뷰와 getView() 라는 메서드의 동작원리에 있는 것 같았다. 안드로이드에서 리스트뷰의 아이템이 100개가 있는데 한 화면에 10개만 표시된다고 하면, 리스트뷰는 100개의 아이템을 한번에 로드하는게 아니라 10개만 로드하고 그에 대한 뷰를 생성한다. 그리고 스크롤을 내리면 기존에 있었던 뷰를 재사용하고 내용물만 갈아끼운다고 한다. 이 과정에서 뭔가 문제가 생긴 모양이다.

 

- ViewHolder 패턴

이걸 해결하기 위해서 StackOverflow를 한참 뒤져다보다가 알게 된 게 ViewHolder 패턴이라는 것이다. 이름 그대로 해석하면 View를 꽂아두는 꽂이라는 의미인데... 그러니까 아까처럼 뷰를 만들었는데 스크롤되는 순간 자기 위치를 잃어버리고 막 딴데로 날아가는게 아니라 자기 위치에 딱 꽂혀있게 만들어줄 수 있다는 의미인 것 같다.

이 ViewHolder 패턴이라는 것의 주요 골자는 다음과 같다.

 

> ViewHolder라고 하는 클래스를 하나 만들어서 표시할 뷰 요소들 (TextView, ImageView) 을 정의해둔다.

> getView() 가 받는 convertView라는 파라미터가 null이면 새로 만들어야 하는 뷰라는 얘기고, null이 아니면 이미 한번 그렸던 뷰이므로 재사용할 수 있다는 얘기다.

> convertView가 null이면 새로운 ViewHolder 객체를 만들어서 뷰 요소들을 넣은 뒤 (findViewById) convertView.setTag() 라는 메서드를 사용해서 저장을 한다. 아마 내부적으로 아이템을 재사용할 때 Tag라는 값을 통해서 찾는 모양이다.

> convertView가 null이 아니면, convertView.getTag() 메서드를 사용해서 저장해놨던 뷰홀더를 가져온다.

> 그 다음 뷰홀더의 각 뷰 요소들에 대해 setText() 라던지 setVisibility() 같은 처리들을 해준다.

 

이와 같은 ViewHolder 패턴을 적용해서 getView() 메서드를 다시 만들었다. 이렇게 만들어주고 나니 스크롤이 생겨도 문제없이 제대로 된 위치의 사진을 가져올 수 있게 되었다.

 

    public View getView(int position, View convertView, ViewGroup parent) {
        final ViewHolder holder;
        
        if(convertView == null) {
            LayoutInflater inflater = ((Activity) m_context).getLayoutInflater();
            convertView = inflater.inflate(layoutResourceId, parent, false);

            holder = new ViewHolder();
            holder.textView_level = (TextView) convertView.findViewById(R.id.text1);
            holder.textView_title = (TextView) convertView.findViewById(R.id.text2);
            // ...
            holder.m_resultimage = (ImageView) convertView.findViewById(R.id.imginlist);
            convertView.setTag(holder);
        }
        else{
            holder = (ViewHolder) convertView.getTag();
        }

        EZ2Record record = m_records.get(position);

        holder.textView_level.setText(record.getLevel());
        // ...
        holder.textView_score.setText(record.getScore());

        String path = "(URL)";
        path = path.replace(" ", "");

        holder.m_resultimage.getLayoutParams().width = m_width;
        holder.m_resultimage.getLayoutParams().height = m_width*2/3;

        final String finalPath = path;
        convertView.setOnClickListener(new View.OnClickListener(){
            @Override
            public void onClick(View v) {
                Picasso.get().load(finalPath).into(holder.m_resultimage);
                if (holder.m_resultimage.getVisibility() == View.GONE)
                    holder.m_resultimage.setVisibility(View.VISIBLE);
                else
                    holder.m_resultimage.setVisibility(View.GONE);
            }
        });

        return convertView;
    }

 


6) Lambda에서 Google Vision API 사용하기

 

이제 Lambda 차례다. 사실 다 끝났다고 생각했다. 기존에 썼던 Google Vision API 대신 AWS Rekognition을 쓰면 될거라 생각했고, 실제로 Rekognition으로 텍스트를 인식하고 파싱하는 방법은 Google Vision API를 쓸 때랑 크게 다르지 않았다.

 

근데, 다 만들고 텍스트 인식 테스트를 하다보니 문제가 있었다. 바로 한글 인식이 안된다는 것이다. 이지투에는 곡 제목이 한글인 곡도 꽤 여럿 있어서 한글 인식도 필요한데, AWS Rekognition에선 라틴 문자 이외엔 지원하지 않고 있었다. 처음엔 클라우드 1위 업체인 AWS에서 그럴 리가 없을거라 생각해서 방법을 찾아봤지만...

 

AWS 대실망...

그래서 다시 Google Vision API를 사용하기로 했다. 기존에 썼던 Java 코드를 Python으로 바꾸는거야 큰 문제가 아닐 것 같은데, 문제는 Lambda에서 외부 라이브러리인 Google Vision API 라이브러리 (SDK) 를 어떻게 가져와서 쓰냐는 것이다. 혹시나 싶어 그냥 'from google.cloud import vision' 을 써봤지만 될 리가 없다. boto3은 AWS 라이브러리라서 람다에서 기본으로 지원할 뿐이었다.

 

- Lambda에서 외부 라이브러리 Import하기

그래서 외부 라이브러리를 Import하는 방법을 찾아봤다. 이 문서에 따르면 다음과 같이 하면 된다.

 

- 람다 함수를 만드는 방법으로는 콘솔에서 인라인 편집기를 써서 함수를 작성하는 방법 이외에, 필요한 파일들을 묶어서 만든 패키지를 zip 파일로 압축해서 올리는 방법이 있다. 외부 라이브러리를 사용하려면 이 방법을 써야 한다.
- [pip install --target (폴더경로) (패키지이름)] 을 사용해서 라이브러리를 설치한다.
- 라이브러리를 설치한 해당 폴더에 lambda_function.py를 넣고 다 함께 zip 파일로 압축해서 Lambda에 업로드한다.

잘 따라했다고 생각했는데 실행을 해보니까 자꾸 뭐가 없단다.

 

[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': cannot import name 'cygrpc' from 'grpc._cython' (/var/task/grpc/_cython/__init__.py)

이 에러를 해결하려고 StackOverflow를 뒤져봤는데, grpcio라는 패키지를 재설치하면 된다는 글이 있었지만 해당 패키지는 멀쩡하게 존재하고 있었다. 그러다가 진짜 문제를 찾아냈는데, 이 글에서 영어로 상세하게 설명하고 있다.

 

요약하면 pip는 라이브러리를 설치할 때 해당 컴퓨터의 환경에 맞춰서 설치를 해주는데, 나는 Windows에서 설치를 했는데 람다 함수가 돌아가는 환경은 Amazon Linux라서 환경이 일치하지 않아 발생하는 문제라는 것이다.

 

즉 문제를 해결하려면 람다 함수가 돌아가는 환경과 같은 환경에서 라이브러리를 설치한 다음 그걸 zip에 넣어줘야 한다는 소리다. 그 과정은 다음과 같다.

 

- EC2로 가상머신을 하나 만든다. 이미지는 람다가 돌아가는 환경과 같은 Amazon Linux를 사용해서 만든다.
- EC2에 접속한 다음 pip로 google-cloud-vision, protobuf를 서로 다른 폴더에 설치한다.
- 내 컴퓨터 (윈도우) 에서 scp를 이용해서 위 라이브러리를 설치한 폴더들을 가져온다. scp가 리눅스에서만 쓸 수 있는 건줄 알았는데 윈도우 10 명령 프롬프트에서는 scp를 기본으로 지원한다.
- 'protobuf > google > protobuf' 폴더를 'google-cloud-vision > google' 폴더 안에 붙여넣는다. (이걸 하지 않고 그냥 google-cloud-vision만 zip에 넣어서 실행하면 protobuf를 찾지 못했다는 에러가 뜬다)
- google-cloud-vision 폴더에 lambda 함수 파일을 넣고 전체를 zip으로 압축해서 올린다.

(위의 StackOverflow 원본 글에는 google-cloud-vision이랑 protobuf 말고 다른 라이브러리도 설치하고 뒤에 무슨 과정이 더 있는데 나는 여기까지만 해도 됐다)

 

그리고 파이썬 버전도 맞춰줘야 한다. EC2를 만들었을 때 기본으로 깔려있는 Python 버전이 2.7이었는데 람다 런타임이 Python 3.7이니까 또 에러가 났다. 람다 런타임을 Python 2.7로 바꿔주니까 드디어 실행이 됐다.

 

이제 Google Vision API를 import하는 데 성공했으니 이걸 가지고 코드만 짜주면 된다.

import json
import urllib
import re
import os
import io
from google.cloud import vision
from google.cloud.vision import types

print('Loading function')

os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = './google_secret.json'
vision_client = vision.ImageAnnotatorClient()

def lambda_handler(event, context):

	bucket = event['Records'][0]['s3']['bucket']['name']
	key = urllib.unquote_plus(event['Records'][0]['s3']['object']['key'].encode('utf8'))
	s3 = boto3.resource('s3', region_name='us-east-1')
	bk = s3.Bucket(bucket)
	obj = bk.Object(key)
	
	file_stream = io.BytesIO()
	obj.download_fileobj(file_stream)
	
	image = types.Image(content=file_stream.getvalue())
	response = vision_client.text_detection(image=image)
	document = response.text_annotations

 

알아야 하는게 더 있긴 했는데 이 부분은 그나마 어렵지 않았다.

 

- 환경 변수 설정: Google Vision API를 사용할 때 'GOOGLE_APPLICATION_CREDENTIALS' 라는 환경 변수를 이용해서 액세스 키가 들어있는 json 파일을 넣어줘야 한다. zip 압축할 때 json 파일까지 같이 넣어준 다음, 파이썬의 'os.environ' 을 이용해서 환경 변수를 세팅해주면 된다.

 

- S3으로부터 파일 객체 가져오기: AWS Rekognition을 사용할 때는 bucket이랑 key (파일 이름) 를 알면 S3의 해당 파일에 접근해서 이미지 인식을 할 수 있었는데, Google API가 AWS S3의 내부 구조에 대해서 알 턱이 없으니 파일 객체를 직접 가져와야 했다. 가져오는 방법은 위 코드를 보면 알 수 있는데 주목해야 할 건 'io.BytesIO()' 라는 메서드다. Java로 치면 InputStream이랑 비슷한 역할을 하는 것으로 보인다. boto3으로 가져온 객체를 다운로드받아서 BytesIO에 넣고, getvalue() 를 이용해 Google Vision API의 파라미터로 넣어주면 된다.

 


7) DynamoDB 다루기

 

DynamoDB는 NoSQL 데이터베이스로 MySQL과 같은 기존의 관계형 DB와 비교했을 때 '정형화' 나 '무결성' 같은 단어와는 거리가 먼 대신 데이터 타입으로부터 자유롭고 비정형 데이터를 빠르고 쉽게 관리할 수 있다... 고 한다.

사실 NoSQL이란 개념에 대해서 아직 확실히 이해하진 못했고, 내가 관리할 기록 데이터는 RDB로도 충분히 쉽게 관리할 수 있는 데이터라 굳이 DynamoDB를 써야하나 싶기는 하다. 그래도 흔히 서버리스를 쓴다고 하면 NoSQL 데이터베이스를 같이 다루는 경우가 많다고 하니 경험삼아 한번 써보기로 했다.

 

dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('results');
response = table.put_item(
	Item={'title':title,
    	'diff':diff,
        'level':level,
        'rate':rate,
        'score':score,
        'date':date,
        'path':key});

DynamoDB에 데이터를 넣는 예시이다. MySQL을 다룰 때랑 비교해서 달랐던 점은, 'results' 라는 테이블만 미리 생성해두면 '테이블 내의 어떤 속성들이 있고 각각 데이터 타입이 무엇이다' 같은 정의를 따로 하지 않아도 바로 데이터 삽입이 가능하다는 것이다.

저렇게 데이터를 넣어놨다가 속성을 추가해야 할 일이 생기면 위 코드의 Item 딕셔너리 안에 key-value 쌍을 하나 더 추가해주기만 하면 끝이다. 꽤 편하다.

 

단 DynamoDB에도 '키' 개념은 존재하며 테이블을 만들 때 '파티션 키' 라고 하는, RDB로 따지면 기본 키와 비슷한 역할을 하는 속성 하나는 같이 만들어줘야 한다.

파티션 키 이외에도 '정렬 키' 라는 것도 선택적으로 만들어줄 수 있어, DynamoDB에서는 이 두 개의 키를 이용해서 쿼리를 다룬다. 정렬 키를 같이 만들어주면 '파티션 키-정렬 키' 쌍이 기본 키와 같은 역할을 하게 된다. 예를 들어 위의 예시에서 나는 title을 파티션 키, diff를 정렬 키로 설정했다. 이 경우 'title-diff' 가 기존의 레코드와 둘 다 같은 새로운 데이터가 들어올 경우 기존 데이터가 새로운 데이터로 알아서 갱신된다. 같은 곡의 같은 난이도에 대한 새로운 기록이 들어올 경우 기존 기록을 없애고 새로운 기록으로 갱신하겠다는 것이다.

 

DB에서 데이터를 가져올 때는 어떻게 할까? RDB에서는 'select * from results' 라는 쿼리를 날려주면 기록을 다 가져올 수 있었다. DynamoDB에서는 'query' 랑 'scan' 이라는 두 가지 방법으로 데이터를 가져올 수 있는데, query는 파티션 키 (+선택: 정렬 키) 를 지정해서 해당되는 데이터를 가져오는 것이고 scan은 테이블 내의 모든 데이터를 가져오는 것이다. 따라서 위와 같이 모든 데이터를 다 가져오기 위해서는 query가 아니라 scan을 사용해야 한다. 기존의 RDB에서 '쿼리' 라는 용어를 썼던 것에 익숙해져 있었기에 당연히 query를 사용해서 모든 데이터를 가져올 수 있을거라 생각했는데 그게 아니어서 잠깐 헤맸다.

 

AmazonDynamoDBClient dbClient = new AmazonDynamoDBClient(credentialsProvider[0]);
ScanRequest scanRequest = new ScanRequest().withTableName("records");
ScanResult result = dbClient.scan(scanRequest);
for (Map<String, AttributeValue> item: result.getItems()){
	String title = item.get("title").toString();
	String level = item.get("level").toString();
    ...

가져온 데이터 묶음 (ScanResult) 은 getItems() 메서드를 이용해서 하나씩 뺄 수 있고 각각의 레코드는 자바의 Map 자료구조를 이용해서 다룰 수 있다.


8) 정렬 기능 만들기

 

별도로 포스팅했다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함