Search

Scalable Image/File Upload

Big Issues Around Image Upload
Image와 같은 큰 File들을 Server에 올려야할 때 어떻게 하면 좋을까?
데이터 베이스?
Image를 MongoDB와 같은 데이터 베이스에 직접 저장하는 방식도 있다. 각 데이터 베이스들은 ByteCode들을 저장할 수 있도록 되어 있기 때문에 이를 이용하면 쉽게 저장할 수 있다.
특히 MongoDB는 NoSQL이다보니 Image를 저장하더라도 별도의 Table을 두어 저장하는 것이 아니라, Blog 글에 모두 엮여 있다보니 Blog를 Fetch하면 Image도 한 번에 같이 올 수 있어 이는 생각보다 괜찮은 아이디어 일 수도 있다.
하지만 결론부터 말하면, Image를 데이터 베이스에 유지하는 것은 그리 좋은 방법은 아니다. 일단... 데이터 베이스 유지 비용 자체가 GB당 꽤나 많은 돈을 쓰게 되는데, Image가 용량을 많이 자치하게 되면 어마어마한 돈을 지불해야할 수 있다. (일반적인 하드 디스크 공간처럼 사용하는 Storage랑 데이터 베이스를 동일 선상으로 생각하면 안 된다. 데이터 베이스는 단순 저장 공간이 아니다.)
위 괄호 안에 내용을 통해서 유추해보면, Image를 저장하는 것은 Storage와 같은 곳에 두면 좋다는 것을 알 수 있다. 어떤 Storage에 둬야 하는 것일까? 이를 어떻게 이용하고, 어플리케이션에서 어떻게 활용할 수 있을까?
Hosting 되어 있는 Server의 Hard Drive
이는 Image를 데이터 베이스에 직접 저장하는 것이 아니라, Image를 Server가 돌아가고 있는 환경의 Hard Drive에 올리는 것이다. 이전 데이터 베이스를 이용하는 방법 보다는 조금 더 깔끔한 방식이다. 그리고 보편적으로도 쓰이는 방법이다. 하지만 이 방법에도 분명 문제는 있다.
만일 배포 환경이 1개의 Server를 쓰고 있다면 이는 나쁜 방법은 아니다. 하지만 배포 환경이 Multiple Machine에서의 Mutliple Distributed Server라면 이 방법은 그리 좋은 방법은 아니다.
아래 그림과 같이 Multiple Machine을 사용하는데 Load Balacer를 통해, 요청이 도착해야 하는 Machine을 지정 받았을 때 각각의 Hard Drive에는 원하는 File이 없을 수도 있다. (즉, 통합된 하나의 저장 공간에서 불러오는 것이 아니라 각 Machine에 연결되어 있는 Hard Drive에서 불러오는 식인 것이다. 요청이 도착하는 Machine에 저장하고 Fetch하도록 되어 있으니 말이다.)
Cloud Storage
Express.js가 돌아가고 있는 Local Machine의 Hard Drive가 아니라 별도의 통합된 Storage를 두면 된다. 이는 꼭 Cloud Storage가 아니더라도, 하나의 통합된 Storage 면 되고 여러 Machine을 두어도 통합된 Storage에 접근하여 File을 R/W만 할 수 있으면 된다. (대체적으로 이런 Storage 구성을 하는 것은 꽤나 시간도 오래 걸리고 그렇게 쉬운 작업은 아니기 때문에 편리하게 Cloud Service를 제공하는 업체의 Storage를 사용하면 된다.)
위와 같이 Cloud Storage를 이용하면 아래와 같이 Network Connection을 통해서 서로 Request, Response를 주고 받으면 File을 처리하게 된다.
별도의 작업 없이, Hosting Server에서 '이 File 저장해줘!'라고 요청을 보내면 S3라는 Cloud Storage에 해당 파일을 저장시켜준다. (S3는 대규모 저장 공간이다. 이 중의 일부 자원을 떼어 Hosting Server와 Connection을 맺어 사용할 수 있다.) 심지어 용량에 대한 걱정 없이 사용할 수 있다. 용량이 부족하다면 자동으로 Scale 된다. 각 Cloud Storage의 서비스 업체가 다르더라도 대체적으로 과정들이 비슷하다.
Upload Constraints
이미지를 업로드하는 과정을 Client → Express.js Server (Temp File을 통해서 저장을 해뒀다가 넘김) → S3 이런 식으로 2 Hopping Procedure로 생각하는 경우가 많고, 실제로 이렇게 짜기도 한다. 하지만 이는 그렇게 좋은 방식이 아니다. 만일 Image나 File 자체가 꽤나 커서 처리하는데 시간이 오래 걸린다면, 해당 File을 처리하기 전까지는 Express.js가 돌아가는 Server의 CPU와 RAM을 꽤나 사용할 확률이 높다는 소리가 된다. 또한 이전에 Node.js가 돌아가는 과정을 서술한 적이 있었는데, Node.js의 Event Loop는 Single Thread로 돌아가기 때문에 Blocking 코드가 되는 경우에는 꽤나 Server의 성능이 하락할 것이다.
(실제로 File Upload 관련해서 간단히 Server를 구성하여 해당 프로세스의 CPU 할당량을 관찰해보면, File 1개를 Upload하는데 CPU 사용량이 약 10%~15% 정도 된다. 이는 다시 말하지만, 한 명의 사용자가 1개의 File을 업로드 하는데 필요한 CPU 자원량이다. 이렇게 되는 경우 AWS S3를 쓰는 돈은 돈대로 내고 Server를 사용하고 있는 Virtual Machine 비용은 CPU 사용률에 따라 추가로 돈을 내게 되는데, 단순히 Server 비용은 그대로 두고 S3의 사용량이 많으면 값 싼 S3 비용으로만 낼 수 있는 방법은 없을까? Virtual Machine을 통한 Server는 JSON Data만 처리하도록 말이다.)
그렇다면 어떻게 처리해야 할까?
Upload Flow with Amazon S3
이전에 소개한 2 Hopping Procedure에 비하면 꽤나 많은 Hopping이 필요하지만, 자세히 살펴보면 그렇게 복잡한 절차는 아니다. 위 그림을 살펴보면 실제 Server에 요구되는 연산 자체는 그렇게 많지 않기 때문에 Server가 직접 이미지를 처리하는 것보다 부하량이 그렇게 크지 않음을 예상할 수 있다. (이렇게 보면 File을 처리하는 과정이 Server를 크게 사용하지 않기 때문에 굉장히 Scalable하다고 볼 수 있다.)
간단히 요약하면, 위 과정은 Uploading 연산 자체를 S3에게 Delegate하는 것이라 볼 수 있다.
그렇다면 위와 같을 때, Server 자체에 가해지는 연산을 줄일 수는 있는데 그 다음으로 문제 되는 것은 Security Process이다. 위 그림에 주어진 플로우에 대해서 찬찬히 살펴보고, Security Process는 어떻게 되는지 알아보자.
Details of the Presigned URL
우선 Presigned URL에 대해 언급하기 전에, S3에서 사용하는 Bucket이라는 개념을 잡고 갈 필요가 있다.
Bucket이라 함은 Amazon S3에서 사용하는 Data Storage Unit이라고 보면 된다.
일종의 Hard Drive라고 봐도 무방하고, S3 계정에 묶여서 사용된다. 이번 실습에서는 사용자들이 Upload하는 Image들을 하나의 Bucket에 두어 저장할 것이다.
따라서 이와 같은 그림이 될 텐데, S3는 돈을 지불하고 사용하는 Service므로 아무나 접근하여 데이터를 저장할 수 있으면 안 된다. 따라서 특정 사용자만 이용할 수 있도록 제한해야 하는데, S3를 사용할 때 이를 지정할 수 있다. (일반적으로는 Root User 빼고는 어떤 사용자도 이용할 수 없도록 모든 권한을 다 막는다.) 이런 역할을 돕는 것이 Presigned URL인 것이다. S3로부터 받은 Presigned URL이 없다면 마구잡이로 S3에 File을 올릴 수는 없는 것이다. (Presigned URL을 받는 역할은 S3 API를 통해서 Server가 요청을 보내서 얻게 된다. 이 과정에서 요구되는 API Key 값은 Server가 갖고 있기 때문에 Secure하다.)
그럼 실제로 Image가 업로드 될 때 어떻게 되는지 살펴보자.
아래와 같이 Server에서는 Image에 대해서 상세 정보를 받았으면, 이 정보를 갖고서 AWS S3 API를 호출하게 된다.
위와 같은 부가 정보를 담은 데이터를 AWS S3 API로 보내면 S3로부터 아래와 같이 Presigned URL을 받을 수 있다.
이 Presigned URL을 S3로부터 Response를 받으면, Server는 이 URL을 다시 Client에게 Response로 보내게 된다.
Client는 Response로 받은 해당 URL에 POST Request로 데이터를 보내게 되면 아래와 같이 S3 Bucket에 Image가 저장된다.
AWSAccessKeyId는 Presigned URL을 얻을 때 사용된 API Key이다.
Signature은 S3 API를 통해서 Presigned URL이 생성되었다는 것을 검증할 때 사용하는 Key라고 보면 된다. (가짜 Presigned URL을 만들어 요청을 날리는 악성 사용자들이 있기 때문이다. 따라서 할당된 Presigned URL을 Client 측에서 데이터를 담아 POST Request를 보내게 되면, AWS S3 측에서는 Signature를 통해서 Presigned URL에 대한 유효성 검사를 하게 된다.)
** Session과 Session Signature를 생각하면 이해가 좀 더 쉬울 것이다. Session을 생성하는 것 자체는 별도의 과정이 없었지만, Session에 추가 Salt가 붙으면서 Key 값으로 해슁하여 만든 값이 SIgnature 였다. 여기서도 KeyId, Signature도 마찬가지 역할을 한다고 보면 된다.
Security Issues Solved with Presigned URL's
Client Side Logic to Perform Image Upload with Creating Blog Post
작성된 기존의 submitBlog 로직은 다음과 같았다. 여기서 Image File을 업로드 할 수 있도록 S3로 요청을 보내는 로직을 추가해야 한다.
수정된 submitBlog의 로직은 아래와 같다. 가장 먼저 Image FIle의 업로드를 S3에 시도하게 된다. 여기서 실패하게 되면, Blog 글 생성을 수행하지 않도록 한다. (Image Upload가 S3 Bucket에 성공적으로 수행될 시, Blog 글을 생성한다.)
초록색으로 칠해진 로직들은 Presigned URL을 이용하는 과정들이다. 첫 번째 초록색은 Presigned URL을 받도록 Backend로 API Call을 하는 것이고, 두 번째 초록색은 첫 번째 초록색 과정으로 받은 Presigned URL에 Image Upload를 하기 위해 POST 요청을 보는 것이다.
그렇다면 Presigned URL을 받기 위해서 AWS를 어떻게 이용하는지 살펴보고, 변경된 React 코드를 살펴보자.
AWS Credentials with IAM
AWS를 통해 다양한 서비스들을 이용할 수 있는데, 각 Service들을 동일한 Key를 통해서 운영하면 Key 노출 시 매우 안 좋기 때문에 IAM (Identity Access Management)라는 것을 이용하여 각 서비스의 Key들을 별도로 관리한다.
따라서 S3를 이용하기 위해선 IAM을 통해서 S3용 Credential을 만들어서 이용해야 한다.
작업 대상 생성
1.
우선 S3 Bucket을 만드는 것부터 해보자. aws.amazon.com 에 접속하여 S3를 검색하면 해당 서비스를 이용하고 관리하는 페이지로 이동이 가능하다. 여기서 Bucket을 생성할 수 있다.
2.
Bucket Name이 Presigned URL에 이용된다. Region은 나와 가까운 지역을 이용하면 된다.
3.
다음을 누르면 Bucket에 대한 옵션을 설정할 수 있는데, 일단 이용하지 않아도 되고 필요하다면 필요한 것만 선택하면 된다.
4.
다음을 통해 Permission을 설정하는 페이지로 이동하게 된다. 여기선 위의 Bcuket 옵션처럼 별도의 설정은 하지 않지만, 필요하다면 선택한다.
5.
생성한 Bucket에 접근할 수 있는 Credential을 IAM을 통해서 생성해야 한다.
작업 대상 범위 설정
IAM은 2가지 구조를 갖는다.
따라서 특정 서비스에 대해서 General한 Policy와 특정 유저에 대한 Policy를 만들 수 있다.
1.
IAM 생성을 위해서 S3 Bucket을 만들 때와 마찬가지로 IAM을 검색한다.
2.
Policy 탭의 Create Policy를 클릭한다.
3.
아래와 같이 S3 서비스 연결을 지정하고, 작업에 대해선 필요한 것만 선택한다. 이후 리소스에 대한 추가 작업을 해야하는데, Bucket 중에서 어떤 Bucket에 대해서 사용할 것인지와 Object에 대해서 정해줘야 한다.
4.
어떤 Bucket을 사용할지는 ARN 추가를 통해서 설정할 수 있다. Bucket의 이름을 기록하면 된다. 또한 Object (Bucket 내에 있는 실제 파일)에 대한 ARN도 추가해야 하는데, Bucket Name은 Bucket의 이름을 주고 Object Name은 Any로 설정한다.
5.
마지막으로 Policy 이름을 확정 지으면 IAM이 생성된다. (생성된 정책의 Attachment를 보면 0이라고 나올텐데, 이는 Entry, 사용자 Resource가 없다는 소리이다.)
작업 대상 접근 방법 설정
위와 같이 IAM을 생성하고 나면, IAM으로 접속시킬 사용자에 대해서 설정할 수 있다.
1.
AWS Access 유형은 API Key를 사용하므로 Programmatic Access를 사용한다.
2.
사용자의 권한을 Attach existing policies directly를 눌러 기존에 생성한 Policy에 입힌다.
3.
다음을 계속 눌러 넘어가면, Access Key ID와 Secret Key ID가 생성된 것을 볼 수 있다.
위 과정으로 Bucket과 IAM에 대한 생성이 모두 되었다면, Access Key ID와 Secret Key 를 환경 변수로 두어 요청을 보낼 수 있도록 만들어 줘야한다. awsConfig.json의 내용은 다음과 같다.
{ "accessKeyId": "", "secretAccessKey": "", "region": "" }
JSON
복사
환경변수로 빼는 작업이 끝났다면, Access Key와 Secret Key를 Node.js에서 사용할 수 있도록 AWS SDK를 설치해줘야 한다. 아래의 명령어를 통해 수행할 수 있다.
npm install —save aws-sdk
Upload Route Files
Bucket의 생성, IAM의 생성 및 설정을 해보았다. 그리고 얻어낸 Key 값을 통해서 Uploading 작업을 수행하도록 할 수 있는데, 이 Uploading 작업은 Presigned URL을 통해서 이뤄진다고 했었다. Key를 이용하여 Presigned URL을 받는 작업을 찾을 수 있다. Presigned URL을 얻어내는 Route를 Server에 추가해보자.
AWS SDK for JavaScript를 통해 API Documentation을 통해 S3에 대한 설명을 볼 수 있다. 그 중에서도 Presigned URL을 어떻게 받을 수 있는지 확인하면 된다. (getSignedUrl 함수) AWS에서는 모든 File을 Object라고 판단한다.
AWS S3의 경우 Flat File Structure로써 File만 인식할 수 있기 때문에 Directory에 대한 개념을 알지 못한다. AWS S3가 File만 인식하고 Directory에서 모른다고 했지만, 경로에 '/'와 같은 기호를 주게 되면 이는 Directory구나 라고 짐작하여 하위에 있는 File에 접근하게 된다. Directory에 대해서 어차피 인식을 못하는데 File의 경로를 '/'로 구분하여 사용하는 이유는 무엇일까? 비록 S3 내부에서 Directory를 사용할 수는 없지만, 개념적으로 File들을 Isolate 시킬 수 있다. (예를 들어, 123이라는 유저의 a 사진을 지운다고 해서 456이라는 유저의 a 사진을 지울리가 없다는 것이다. 또한 만일 계정을 지우게 되었다고 했을 때, 해당 계정의 하위 File들만 지우면 된다.)
Key는 File의 이름, ContentType은 File의 Type이 되는데, ContentType은 인자를 Request에서 받아서 바로 사용하면 되지만 Key는 별도의 조정이 필요하다. 사용자마다 동일한 File 이름을 갖고 있을 수도 있고, 이에 대해서 Uploading을 진행할 수도 있기 때문이다. 따라서 위의 Isolation을 고려하여 Key 값을 지정하여 Params로 넘겨주면 되겠다. ('$userId/$someRandomHashedString.$contentType'과 같은 형태가 되겠다. 여기서 someRandomHashedString을 만들기 위해서 uuid라는 Package를 사용하면 되겠다. → Random Characters & Random Letters의 Grouping을 통한 Unique ID 제조 가능 / v1, v2, v3 등 다양하게 있으므로 선택하여 사용)
S3에 File을 올리는 모든 작업들은 Post가 아닌 Put이다. 따라서 operationName 역시 puObject가 되겠다.
routes Directory에 uploadRoutes.js를 추가한 코드는 아래와 같다.
const uuid = require('uuid/v1'); const requireLogin = require('../middlewares/requireLogin'); const AWS = require('aws-sdk'); AWS.config.loadFromPath(__dirname + '/../config/awsConfig.json'); const s3 = new AWS.S3(); module.exports = (app) => { app.get('/api/upload', requireLogin, (req, res) => { const fileType = req.query.type; const type = fileType.split('/')[1]; const key = `${req.user.id}/${uuid()}.${type}`; s3.getSignedUrl( 'putObject', { Bucket: 'bigpel66-blogster', ContentType: fileType, Key: key, }, (err, url) => { if (err) { throw err; } return res.send({ key, url }); } ); }); };
JavaScript
복사
Modified React App Code to Upload Image on S3
Bucket과 IAM의 생성은 위의 과정과 같았다면, React App의 코드는 Image를 입력 받는 Form을 생성하고 이를 처리하는 로직을 따로 추가해야 한다.
Image를 고르고 Blog 글 생성을 위해 Form내에 작성한 Value들을 제출하는 Frontend 코드는 아래와 같다. (주석 부분이 기존 코드에서 추가된 것이다.)
// BlogFormReview shows users their form inputs for review import _ from 'lodash'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import formFields from './formFields'; import { withRouter } from 'react-router-dom'; import * as actions from '../../actions'; class BlogFormReview extends Component { /* Added To Handle Image */ state = { file: null }; renderFields() { const { formValues } = this.props; return _.map(formFields, ({ name, label }) => { return ( <div key={name}> <label>{label}</label> <div>{formValues[name]}</div> </div> ); }); } renderButtons() { const { onCancel } = this.props; return ( <div> <button className="yellow darken-3 white-text btn-flat" onClick={onCancel} > Back </button> <button className="green btn-flat right white-text"> Save Blog <i className="material-icons right">email</i> </button> </div> ); } onSubmit(event) { event.preventDefault(); const { submitBlog, history, formValues } = this.props; /* Need To Edited To Submit File*/ submitBlog(formValues, this.state.file, history); } /* Added To Handle Image */ onFileChange(event) { this.setState({ file: event.target.files[0] }); } render() { return ( <form onSubmit={this.onSubmit.bind(this)}> <h5>Please confirm your entries</h5> {this.renderFields()} {/* Added To Handle Image */} <h5>Add An Image</h5> <input onChange={this.onFileChange.bind(this)} type="file" accept="image/*" /> {this.renderButtons()} </form> ); } } function mapStateToProps(state) { return { formValues: state.form.blogForm.values }; } export default connect(mapStateToProps, actions)(withRouter(BlogFormReview));
JavaScript
복사
또한 위에서 사용되는 submitBlog는 글 생성만 하던 것에서 Image File도 받아서 요청을 날리도록 바뀌었으므로, 이에 대한 로직도 바뀌었을 것이다. 아래 그림의 로직에 따라 수정된 submitBlog 함수의 코드는 다음과 같다.
/* Need To Edited To Submit File*/ export const submitBlog = (values, file, history) => async (dispatch) => { const uploadConfig = await axios.get('/api/upload', { params: { type: file.type }, }); await axios.put(uploadConfig.data.url, file, { headers: { 'Content-Type': file.type }, }); const res = await axios.post('/api/blogs', { ...values, imageUrl: uploadConfig.data.key, }); history.push('/blogs'); dispatch({ type: FETCH_BLOG, payload: res.data }); };
JavaScript
복사
submitBlog에서 Blog 글 생성 시 imageUrl을 받도록 되었으므로 blogRoutes.js에서도 Blog 글 생성 부분의 코드를 아래와 같이 고친다.
app.post('/api/blogs', requireLogin, cleanCache, async (req, res) => { const { title, content, imageUrl } = req.body; const blog = new Blog({ title, content, imageUrl, _user: req.user.id, }); try { await blog.save(); res.send(blog); } catch (err) { res.send(400, err); } });
JavaScript
복사
** S3에 저장한 imageUrl을 데이터 베이스에 저장할 때는 IP Address의 Domain 정보는 빼는 것이 보안상으로도 좋고, Bucket Name이 수정 되었을 때도 별 타격이 없다.
** axios의 response는 data에 담겨서 온다.
** axios.put을 통해 File을 S3에 올릴 때, 3번째 인자로 headers 옵션을 설정해야 한다. 설정해야 하는 headers의 옵션은 Content-Type이 되겠다. 여기의 Content-Type은 application/json, x-www-form-urlencoded와 같은 것이 아닌, Presigned Url을 받을 때 getSignedUrl함수의 ContentType으로 주었던 값을 넣어야 한다. (위의 Presigend URL 부분을 읽어보면 알겠지만, AWS에 File을 올릴 때 Presigned URL에 등록된 Content Type과 동일한지 확인한다고 했었다. headers에 Content-Type을 등록하는 것이 바로 이 과정이다.)
** AWS의 S3를 이용하는 경우, 요청을 보내는 Frontend의 IP와 S3의 IP가 다르기 때문에 (자원을 제공하는 주체와 자원을 요구하는 주체의 주소가 다름) CORS Error가 발생할 수 있다. 이번 실습의 CORS Error 해결하는 방법은 아래와 같다.
1.
AWS Management Console의 S3에 들어간다.
2.
Bucket의 Permission 탭에 들어간다.
3.
CORS configuration 탭에 들어간다.
4.
아래 Text를 추가한다. (Server URL로부터 PUT Request를 받겠다는 것이다.)
<CORSConfiguration> <CORSRule> <AllowedOrigin>http://localhost:3000</AllowedOrigin> <AllowedMethod>PUT</AllowedMethod> <MaxAgeSeconds>3000</MaxAgeSeconds> <AllowedHeader>*</AllowedHeader> </CORSRule> </CORSConfiguration>
JavaScript
복사
** 만일 위 과정이 잘 안 된다면 아래 2개의 링크를 잘 확인해보자.
** 만일 S3 이미지를 올리는데는 성공 했지만, 이미지가 보이지 않는다면 S3 정책에 대한 수정을 해야한다. (Resource에 대한 GET 작업은 Public하게 돌리는 것이다.) S3 관리 Console에서 권한 → 정책에 들어가서 정책 생성기를 통해 정책을 생성한다. 아래와 같은 코드가 나오면 된다.
{ "Version": "2012-10-17", "Id": "Policy1600358762773", "Statement": [ { "Sid": "Stmt1600358755952", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::bigpel66-blogster/*" } ] }
JSON
복사
Displaying Images
위에서 작업한 것과 같이, Image를 선택하여 글을 생성할 수 있게 했고 Image를 저장하기도 했다. 마지막으로, 저장한 Image를 List에서 글과 함께 확인할 수 있도록 만들어보자.
코드는 아래와 같다.
import React, { Component } from 'react'; import { connect } from 'react-redux'; import { fetchBlog } from '../../actions'; class BlogShow extends Component { componentDidMount() { this.props.fetchBlog(this.props.match.params._id); } /* Added To Show Image */ renderImage() { if (this.props.blog.imageUrl) { return ( <img alt="alt" src={ 'https://s3-ap-northeast-2.amazonaws.com/bigpel66-blogster/' + this.props.blog.imageUrl } /> ); } } render() { if (!this.props.blog) { return ''; } const { title, content } = this.props.blog; return ( <div> <h3>{title}</h3> <p>{content}</p> {/* Added To Handle Image */} {this.renderImage()} </div> ); } } function mapStateToProps({ blogs }, ownProps) { return { blog: blogs[ownProps.match.params._id] }; } export default connect(mapStateToProps, { fetchBlog })(BlogShow);
JavaScript
복사