티스토리 뷰

Flask로 개발된 대학교 커뮤니티를 취미삼아 운영중이다. 이전에는 XE로 개발되어 있었는데, Flask로 새로 개발하면서 기존의 글들에 등록되어 있던 첨부 파일들을 이용하지 못하는 문제를 최근에 해결하였다. 그 과정에서 있었던 이야기들을 포스팅해볼까 한다. 혹시나 XE에서 새로운 프레임워크로 직접 개발한 사이트로 마이그레이션하고자 하는 분들에게 참고가 됐으면 한다.

Gevent가 들어가는 부분은 content disposition을 직접 변경하는 요청을 보내는 부분인데, 이 부분은 사실 직접 코딩하지 않아도 awscli를 이용하여 처리할 수 있음을 사전에 알린다. 대신 이 글을 통해 gevent를 이용하여 다수 요청을 병렬적으로 보내는 방법을 소개하고자 한다.


## XE에서의 파일 관리

XE에서는 `xe_files` 테이블을 통해 첨부 파일을 관리한다.  `uploaded_filename` 컬럼에 업로드 된 파일의 경로가 저장되고, `source_filename` 컬럼에 원본 파일 이름이 저장되며, `direct_download` 컬럼은 따로 다운로드 가능한 파일 목록에 보여줘야 하는지의 여부를 알려주는 것으로 보인다. 이미지 업로드 같은 것도 파일 업로드이므로 `xe_files ` 테이블에 추가되지만 글 내용에서 이미지를 띄워주기 위한 용도로는 그냥 `img` 태그의 `src` 속성에 업로드 된 파일 경로를 넣어주면 바로 이용 가능하므로 직접 다운로드 받는 파일은 `direct_download` 라는 컬럼을 이용하여 구분한 것으로 생각된다.

나 같은 경우, EC2에 Flask로 개발된 웹 애플리케이션을 배포하는 과정에서 기존 호스팅 사이트에 저장된 XE의  `files` 디렉토리를 통째로 S3에 업로드 해뒀었다. `xe_files` 테이블의 존재는 알고 있었으니 나중에 고치면 될 것이라 생각했기 때문이다. 하지만 내가 놓친 것이 있었는데, 바로 다운로드 되는 파일의 이름 문제였다.



## Anchor tag의 download 속성 이용

`<a>` tag에 `download` 속성을 이용하면 다운로드 되는 파일 이름을 설정할 수 있다고 w3schools에 나와있길래 시도해봤다. 하지만 이 방법은 작동하지 않았는데, 그 이유는 `download` 속성이 동일한 origin에 대해서만 적용되기 때문이다. S3는 다른 도메인이므로 적용될 수 없는 것이다. 또한, `download` 속성은 사파리에서 작동되지 않는다는 문제도 있다. 따라서 이 방법으로의 해결은 불가능하다.



## Response Header 오버라이딩 

S3에서는 Response Header를 오버라이딩할 수 있는 방법을 제공한다. 예를 들어 `LINK_TO_S3_OBJECT?response-content-disposition=new_name`와 같은 식으로 URL 파라미터를 넘겨주면 해당 값으로 결과를 돌려주는 방식이다. 이 방법에 대한 자세한 내용은 공식 문서를 참고. 하지만, 이 방법 또한 내 상황에서는 해결 방법이 될 수 없었다. 왜냐하면 익명 GET 요청에는 이 방법이 유효하지 않기 때문이다. 하지만 이와 비슷한 방법으로 Response Header 오버라이딩을 할 수 있다는 것을 알게 되어 다른 해결 방법을 더 찾아볼 수 있었다. 이에 대한 좀 더 자세한 설명을 덧붙인다.


### HTTP 프로토콜을 통한 파일 다운로드와 업로드

예를 들어 S3에 `./files/attach/binaries/(생략)/cf8a0af2ed6202bab6dfcf0fdd296f30` 라는 경로로 Object가 존재한다고 해보자. 이 경로로 접근하면 파일이 다운로드 되는데, `cf8a0af2ed6202bab6dfcf0fdd296f30` 라는 이름의 파일이 다운로드 된다. 당연히 사용자 입장에서는 이상한 파일이 다운로드 되었다고 볼 수 밖에 없는 상황이다. 딱 보면 알겠지만, 다운로드 되는 파일의 key와 동일한 이름으로 파일이 다운로드 된다. 왜냐하면 S3에서는 기본적으로 특별한 Response Header를 주지 않고, 브라우저에서는 파일 이름에 대한 특별한 지시가 없는 경우 해당 URL을 통해 다운로드 될 파일 이름을 결정하게 되기 때문이다.

다른 파일 다운로드의 예시를 보자. 다음은 학교 홈페이지에 첨부된 파일에 대한 다운로드 요청이다. 브라우저에서 이 URL로 접근하여 파일을 다운로드 받으면 파일 이름이 정상적으로 만들어진다. 통신을 통해 어떤 데이터가 오고가는지 보여주기 위해 `curl`을 이용했다. 명령어에 여러 옵션들은 특별한 것이 없고, HTTP Header를 보여주고 Body를 숨기는 것과 추가적인 HTTP Header에 대한 것들이다. 그냥 요청을 날리면 요청을 거절하길래 `User-Agent`를 추가해줬다.


마법은 바로 Response의 `Content-Disposition` 헤더가 일으킨다. 사실 특별한 것은 아닌데, 브라우저에서 파일 이름 설정과 관련해서는 `Content-Disposition` 에 명시된 `filename` 값으로 파일 이름을 설정하게 되어 있는 것이다. `Content-Disposition` 헤더는 파일 업로드시에도 비슷한 역할을 한다. 다음은 `file`이라는 이름으로 `IMG_1464.PNG` 파일을 업로드하는 HTTP Request이다. POST 메소드를 이용하여 `multipart/form-data` Content-Type으로 파일을 전송한다.


마찬가지로 `Content-Disposition` 정보가 해당 파일의 multipart boundary에  함께 주어지는 것을 볼 수 있다. 이를 통해 서버에서는 원본 파일 이름을 알 수 있게 된다. 자세한 것은 RFC6266을 참고하자.



## Presigned URL 활용

S3에서 object에 대한 Presigned URL(미리 서명된 URL)를 만들 수 있다. Presigned URL은 해당 URL을 생성할 때 몇 가지 속성을 미리 지정해놓을 수 있다. boto3 문서에 따르면 `Params`를 통해 이를 지정할 수 있음을 알 수 있고, github 이슈에서도 이 방법을 제안하고 있다. 하지만 이유를 알 수 없게도, `ResponseContentDisposition`이나 `ResponseContentType`을 지정하면 `SignatureDoesNotMatch` 에러가 발생하여 문제가 여전히 해결되지 않았다.



## S3 Object에 Metadata로 Content-Disposition 적용

S3의 Object에 Metadata로 직접 몇 가지 Header 설정이 가능하다. 다음 그림을 참고하자.

출처 : http://interconnection.tistory.com/52


이 방법을 통해 Metadata 설정이 가능하다. 여기서 발견한 S3의 버그(?)가 있는데, 콘솔에서 유니코드를 직접 Metadata로 입력이 불가능하다는 것이다. 콘솔에서 유니코드를 입력하여 저장하면 에러가 발생했고, `boto3` 라이브러리를 이용하여 강제로 유니코드를 입력하였더니 콘솔에서 Properties 메뉴에 에러가 떠서 문제가 발생했다. 따라서, 콘솔에서 에러를 만나지 않으면서 유니코드를 쓰고 싶으면 Percent Encoded 문자열을 써야 한다. 

하지만 Percent Encoded 문자열을 이용할 때는 `Content-Disposition`에 filename을 추가로 명시해줘야 한다. 크롬에서는 `filename=`으로 명시한 Percent Encoded 문자열을 잘 해석해서 파일 이름을 설정해주지만, 사파리와 같이 다른 브라우저에서는 적용이 안 된다. 그럼 어떻게 해야할까? `filename*=UTF-8''` 뒤에 똑같이 Percent Encoded 문자열을 넣어주면 된다. 다소 지저분해보이지만, S3 콘솔에서 에러를 만나지 않으면서도 여러 브라우저에서 문제 없이 파일 이름이 설정되는 방법이다. 이 방법으로 안드로이드의 몇 가지 브라우저에서도 잘 작동되는 것을 확인했다. 따라서 예시 Content-Disposition은 다음과 같다. `%EC%8B%9C%ED%97%98%EB%AC%B8%EC%A0%9C.hwp`은 `시험문제.hwp`를 Percent Encoding한 것이다.

Content-Disposition: attachment; filename=%EC%8B%9C%ED%97%98%EB%AC%B8%EC%A0%9C.hwp; filename*=UTF-8''%EC%8B%9C%ED%97%98%EB%AC%B8%EC%A0%9C.hwp


업데이트 해줘야 할 파일이 한 두개도 아니고, 하나하나 손으로 직접 업데이트 해주는 것은 어불성설. 이를 위한 스크립트를 만들기로 했다. `python`에서 `boto3` 라이브러리를 이용하여 S3 Object들을 수정해주는 것이다. 주의할 점은, Object의 Header에서 Metadata를 수정하면 안 된다. 그러면 `x-amz-meta-<name>`에 해당되는 Metadata가 생성된다. 이 말인 즉슨, 해당 Object를 직접 수정하는 것은 안 되고, Copy를 하면서 `copy_object`의 `ContentDisposition` 인자를 활용해야 한다는 것이다. 아래 샘플 코드를 참고하자.


`MetadataDirective` 를 `REPLACE`로 지정해주지 않으면 `An error occurred (InvalidRequest) when calling the CopyObject operation: This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes.` 와 같은 에러가 발생한다. 유의하도록 하자.



## Gevent로 병렬 처리하기

위 샘플 코드의 `update_object` 함수로 S3 Object를 업데이트할 수 있다. 하지만, 많은 수의 Object를 수정하려고 할 때 위 함수를 일반적인 for-loop를 통해 실행하면 굉장히 오랜 시간이 걸린다. 왜냐하면 `boto3`의 메소드를 실행하는 것은 하나 하나가 AWS에 대한 네트워크 통신을 하는 `blocking operation`이기 때문이다. 한 요청이 끝날 때까지 매번 기다려야하니 오래 걸릴 수 밖에 없다. 큰 용량의 파일을 다운로드 받는 것과 같은 작업이 아니라면 여러건의 요청을 동시에 보내는 것이 효율적이다.

`Gevent`를 이용하면 이와 같은 작업을 쉽게 할 수 있다. `Gevent`에 대한 설명은 이 글에서 다루기 어렵고, `Gevent`의 `Pool`을 이용하여 `greenlet`을 Pooling 하는 것이 핵심이다. 보통 `Gevent` 를 이용할 때 `greenlet`을 spawn하여 join하면 각각의 `greenlet`을 실행하게 되는데, 요청을 보내야 할 것이 수천, 수만 개이므로 모든 `greenlet`을 동시에 실행하는건 어렵다. 이를 해결하기 위해 제한된 갯수의 `greenlet`만 실행하도록 하기 위해 `Pool`을 사용한다. 말이 어렵지 코드로는 매우 간단하다. 샘플 코드는 다음과 같다. `Gevent`를 사용하는 코드 외에 CSV 파일로부터 파일 목록을 불러오는 코드도 추가되었다.


자! 드디어 준비가 끝났다. 카페에서 작업한 것이라 양심이 찔려 Pool의 크기를 조금 작게 잡았다. 이 스크립트를 실행시켜 놓고 잠깐 커피를 즐기고 나니 처리가 끝난 것을 확인할 수 있었다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday