Flask - 파일 업로드 엔드포인트

황인용·2020년 2월 15일
1

Flask

목록 보기
13/13

앞서 Flask를 사용하여 레이어드 아키텍쳐로 miniter 라는 어플리케이션을 구현하였다.
이제는 miniter에서 사용자의 프로필 사진, 또는 다양한 파일들을 업로드하고 읽을 수 있는 기능을 추가 하고자 한다.
더 나아가 다음 블로그에서는 AWS S3를 활용하여 더 효율적으로 파일 업로드와 읽을 수 있는 기능을 구현하고자 한다.

사용자 프로파일 사진 업로드 엔드포인트

앞서 miniter에서 사용자의 정보를 받아서 회원가입을 할 수 있는 엔드포인트를 구현하였다.
이어 사용자의 프로파일 즉, 프로필사진을 업로드하는 기능을 구현하고자 한다.

파일 업로드 request(요청)

파일 업로드시 request는 다음과 같다.

  • HTTP request
  • method : POST
  • JSON multipart/form-data
POST /profile-picture HTTP/1.1
Host : localhost
Content-Type: multipart/form-data; boundary---"WebkitFormBoundaryePkpFF7tjBAqx29L"

---WebkitFormBoundaryePkpFF7tjBAqx29L
Content-Disposition: form-data; name="profile_pic"; filename="profile_pic.jpg"
Content-Type: image/jpg
Content-Transfer-Encoding: binary

......
...
---"WebkitFormBoundaryePkpFF7tjBAqx29L---
  • Content-Type이 multipart/form-data로 구성
  • boundary : request의 body를 부분, 부분으로 나누는 역할
    (ex. ---<boundray값>)

프로파일 이미지 파일 업로드 엔드포인트

import os

from flask 			import Flask, request
from werkzeug.utils import secure_filename					# 1)

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = './profile_pictures'			# 2)

@app.route('/profile-picture', methods=['POST'])
def uplaod_profile_picture():
	if 'profile_pic' not in request.files:
    	return 'File is missing', 404						# 3)
        
    profile_pic = request.files['profile_pic']				# 4)
    
    if profile_pic.filename = '':
    	return 'File is missing', 404						# 5)
    
    filename = secure_filename(profile_pic.filename)		# 6)
    profile_pic.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))												   # 7)
    
    return '', 200

1) secure_filename 메소드를 임포트한다.
secure_filename은 업로드 된 파일의 이름이 안전한가를 확인해주는 함수이다.
또한, 해킹 공격에 대해 보안을 하고자 사용되기도 한다.
예를 들어 업로드 되는 디렉토리를 사용자 외 다른 누군가가 알게된다면, ../../ 몇번의 시도로 파일이 저장된 실제 디렉토리를 유추할 수 있다. 이것을 secure_filename이 디렉토리를 변경하여 리턴해준다.

2) 업로드된 파일을 저장할 경로를 지정해 준다.
해당 디렉터리가 이미 생성되어 있어야 한다.

3) request.files 딕셔너리에 profile_pic 파일이 있는지 확인한다.
HTTP request(요청)이 정상적으로 전송이 되었으면 프로파일 이미지 파일이 files딕셔너리에 있을 것이다.

4) 프로파일 이미지를 files 딕셔너리에서 읽어 들인다.

5) 4)읽어 들인 프로파일 이미지 데이터의 파일 이름 값이 비어 있다면, 사용자가 파일을 선택하지 않은 경우이다. 따라서 브라우저가 파일 이름이 비어 있는 상태에 또한 실제 파일 데이터도 비어 있는 경우로 전송되는 경우가 있으므로 확인한다.

6) 1)에서 임포트한 secure_filename 함수를 사용하여 파일 이름 보안성을 확인하고, 만일 보안적으로 위험한 경우를 대비하여 더 안전한 이름으로 변경해준다.

7) 프로파일 이미지 파일을 2)에서 정한 경로에 저장한다.

config.py 수정

앞서 miniter를 구현하면서 앤드포인트를 필요한 secret_key등을 hard-coding하기 보다는 보안적으로 별도로 관리가 필요하여 config.py라는 곳에 작성해서 관리했다.
마찬가지로 실제 파일이 업로드될UPLOAD_FOLDER에 대한 내용도 config.py에서 적용하여야 한다.

# config.py
db = {
    'user'     : 'test',
    'password' : 'test1234',
    'host'     : 'localhost',
    'port'     : 3306,
    'database' : 'minitter'
}

DB_URL                = f"mysql+mysqlconnector://{db['user']}:{db['password']}@{db['host']}:{db['port']}/{db['database']}?charset=utf8"
JWT_SECRET_KEY        = 'SOME_SUPER_SECRET_KEY'
JWT_EXP_DELTA_SECONDS = 7 * 24 * 60 * 60

UPLOAD_FOLDER = './profile_pictures'

view > init.py 수정

이번에는 우리가 레이어드 아키텍처를 구현하였으니, view와 service와 dao로 나누어 구현하고자한다.
제일먼저 view 디렉토리의 init.py를 수정하는데, 기존에 파일이 업로드되고 저장되는 부분을 간단하게 profile_pic.save~부분을 serivce에 넘겨주면 된다.

# view > __init__.py
@app.route('/profile-picture', methods=['POST'])
@login_required						# 1)
def upload_profile_picture():
    user_id = g.user_id

    if 'profile_pic' not in request.files:
        return 'File is missing', 404

    profile_pic = request.files['profile_pic']

    if profile_pic.filename == '':
        return 'File is missing', 404

    filename = secure_filename(profile_pic.filename)
    user_service.save_profile_picture(profile_pic, filename, user_id)		# 2)

    return '', 200

1) 오직 본인의 프로파일 이미지만 업로드 할 수 있어야 하므로 로그인을 한 사용자만 해당 엔드포인트를 호출할 수 있게 한다. (WITH decorator)

2) UserServicesave_profile_picture 메소드를 호출해서 해당 파일을 지정된 디렉토리에 저장한다.
이때 profile_pic, filename, user_id를 인자로 넘겨준다.

service > user_service.py 수정

serivice 디렉토리의 user_service.py을 수정한다.
UserService 클래스의 save_profile_picture 메소드를 추가한다.

# service > user_service.py
def save_profile_picture(self, picture, filename, user_id):
	profile_pic_path_and_name = os.path.join(self.config['UPLOAD_FOLDER'], filename)	# 1)
    picture.save(profile_pic_path_and_name)							# 2)
    
    return self.user_dao.save_profile_picture(profile_pic_and_name, user_id)			# 3)

1) 프로파일 이미지 파일이 저장될 디렉터리와 파일 이름을 더하여 온전한 경로(path)로 만들어준다.

2) 프로파일 이미지를 1)에서 지정된 경로와 파일 이름으로 저장한다.

3) UserDao 클래스의 save_profile_picture 메소드를 호출해서 저장한 프로파일 이미지 파일의 위치를 데이터베이스에 저장해야한다.
프로파일 이미지 파일 위치를 데이터베이스에 저장하는 이유는 나중에 사용자의 프로파일 이미지를 불러올 수 있도록 하기 위함이다.
따라서 user_dao.save_profile_pictureprofile_pic_and_name 그리고 user_id를 인자로 넘겨준다

SQL 수정 (profile_picture 컬럼추가)

지난 miniter의 스키마에서는 profile_picture의 컬럼이 없었다.
따라서 해당 컬럼을 LTER문을 사용하여 users 테이블에 적용한다.

mysql > ALTER TABLE users ADD COLUMN profile_picture VARCHAR(255);

mysql> explain users;
+-----------------+---------------+------+-----+-------------------+-----------------------------+
| Field           | Type          | Null | Key | Default           | Extra                       |
+-----------------+---------------+------+-----+-------------------+-----------------------------+
| id              | int(11)       | NO   | PRI | NULL              | auto_increment              |
| name            | varchar(255)  | NO   |     | NULL              |                             |
| email           | varchar(255)  | NO   | UNI | NULL              |                             |
| hashed_password | varchar(255)  | NO   |     | NULL              |                             |
| profile         | varchar(2000) | NO   |     | NULL              |                             |
| created_at      | timestamp     | NO   |     | CURRENT_TIMESTAMP |                             |
| updated_at      | timestamp     | YES  |     | NULL              | on update CURRENT_TIMESTAMP |
| profile_picture | varchar(255)  | YES  |     | NULL              |                             |
+-----------------+---------------+------+-----+-------------------+-----------------------------+
8 rows in set (0.00 sec)

model > user_dao.py 수정

데이터베이스를 위와 같이 수정하였다면, 마지막으로 user_dao.py에서 save_profile_picture_name 메소드를 구현한다.

# model > user_dao.py
def save_profile_picture(self, profile_pic_path, user_id):
    return self.db.execute(text("""
        UPDATE users 
        SET profile_picture = :profile_pic_path
        WHERE id            = :user_id
    """), {
        'user_id'          : user_id,
        'profile_pic_path' : profile_pic_path
    }).rowcount

프로파일 이미지 GET 엔드포인트

사용자의 프로파일 업로드 엔드포인트를 구현하고 나서, 제데로 올라갔는지 확인 하기위하여 이미지 GET 엔드포인트를 구현하고자 한다.

  • HTTP request / wget
  • method : GET
  • JSON multipart/form-data

view > init.py 수정

# view > __init__.py
    @app.route('/profile-picture/<int:user_id>', methods=['GET'])	# 1)
    def get_profile_picture(user_id):
        profile_picture = user_service.get_profile_picture(user_id)	# 2)

        if profile_picture:						# 3)
            return jsonify({'img_url' : profile_picture})
        else:
            return '', 404

1) 프로파일 이미지 업로드 엔드포인트와 주소는 동일하게 /profile-picture로 정해준다. 대신 methods는 GET으로 설정한다.
그리고 사용자 프로파일의 이미지는 누구나 볼 수 있어야 하므로 로그인이 되어 있지 않아도 접근 가능하도록 한다.
대신에 프로파일 이미지가 속해 있는 사용자의 아이디는 URL 주소에 포함되어 요청하도록 한다.
(ex. 아이디 1의 프로파일을 확인하고자 한다면... => http -v GET /profile-picture/1 )

2) UserService 클래스의 get_profile_picture 메소드를 호출하여 해당 사용자의 프로파일 이미지 경로를 받는다. user_id를 인자로 넘겨준다.

3) 만일 해당 사용자가 프로파일 이미지를 사전에 업로드했다면 해당 파일을 HTTP 응답(response)으로 전송한다.

4) 만일 해당 사용자가 프로파일 이미지가 없다면 404 Not Found 응답을 전송하도록 한다.

service > user_service.py 수정

다음은 service 모듈의 UserService클래스에 get_profile_piceture메소드를 구현한다.
특별한 비즈니스 로직 없이 데이터베이스로 부터 저장된 프로파일 이미지 경로를 읽어 들이는 것으로 구현한다.

# service > user_service.py
    def get_profile_picture(self, user_id):
        return self.user_dao.get_profile_picture(user_id)	# 1)

1) UserDao 클래스의 get_profile_picture 메소드를 호출하여 데이터베이스에 저장되어 있는 사용자의 프로파일 이미지를 리턴하도록 한다. user_id를 인자로 넘겨준다.

model > user_dao.py 수정

마지막으로 model 모듈의 UserDao 클래스에 동일한 이름의 get_profile_picture 메소드를 구현한다.

# model > user_dao.py
    def get_profile_picture(self, user_id):			# 1)
        row = self.db.execute(text("""
            SELECT profile_picture
            FROM users
            WHERE id = :user_id
        """), {
            'user_id' : user_id 
        }).fetchone()

        return row['profile_picture'] if row else None		# 2)

1) user_id 값과 동일한 아이디 값을 가지고 있는 사용자의 프로파일 이미지 경로 값을 users 테이블에서 읽어 들인다.

2) 사용자의 프로파일 이미지 경로 값이 존재하면 해당 값을 리턴하고, 아니면 None을 리턴한다.

파일업로드/GET 엔트포인트 테스트

runserver

해당 프로젝트는 setup.py를 통해 서버를 구동하도록 설정하였다.
-d 는 디버깅 모드 사용하고자 한다면 해당옵션을 추가하면된다.

$ python setup.py runserver -d

로그인 및 파일 업로드

자신의 계정에 프로파일 이미지를 업로드 하는 기능으로써, 로그인 후 JWT를 통해 파일 업로드를 할 수 있다.

# 로그인
$ http -v POST localhost:5000/login name=test01 password=1234


HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Length: 166
Content-Type: application/json
Date: Sat, 15 Feb 2020 13:53:54 GMT
Server: TwistedWeb/19.10.0

{
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMiwiZXhwIjoxNTgxODYxMjM0fQ.xORgm9LLaL5gvvR5IhKCtBWQlna63Zrv-t6Y5jT7l1Q",
    "user_id": 22
}
# 파일 업로드 httpie

$ http -v --form localhost:5000/profile-picture profile_pic@/home/inyong/pictures/pang.jpg "Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMiwiZXhwIjoxNTgxODUzMjQzfQ.g0YHNIKL1I46T41YLN5w7xrJt3bjKVgbv1hBKAfpSOg"		# 1)

POST /profile-picture HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMiwiZXhwIjoxNTgxODUzMjQzfQ.g0YHNIKL1I46T41YLN5w7xrJt3bjKVgbv1hBKAfpSOg
Connection: keep-alive
Content-Length: 107925
Content-Type: multipart/form-data; boundary=befb0f15619f46a583b365f1d2263c57
Host: localhost:5000
User-Agent: HTTPie/0.9.8



+-----------------------------------------+
| NOTE: binary data not shown in terminal |
+-----------------------------------------+

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Length: 0
Content-Type: text/html; charset=utf-8
Date: Sat, 15 Feb 2020 13:54:12 GMT
Server: TwistedWeb/19.10.0

1) profile_pic@ 다음에 나오는 경로가 내가 업로드하고자 하는 파일의 경로와 파일이름이다.
그리고 Authorization에 내가 받은 JWT access_token 넣어 http request를 POST로 보낸다.

파일 GET 테스트

이어 profile-picture 엔드포인트에 프로파일 요청을 보내는 테스트를 해보자.
여기서 httpie는 실제 파일을 boundary 형태로 표현하기 때문에 제데로 받았는지 확인이 힘들다.
따라서 wget을 통해 실제 파일을 다운받도록 한다.

$ wget localhost:5000/profile-picture/22 pang.JPG

--2020-02-15 23:00:10--  http://localhost:5000/profile-picture/22Resolving localhost (localhost)... 127.0.0.1
접속 localhost (localhost)|127.0.0.1|:5000... 접속됨.
HTTP request sent, awaiting response... 200 OK
Length: 94 [application/json]
Saving to: ‘22’

22                100%[==========>]      94  --.-KB/s    in 0s      

2020-02-15 23:00:10 (2.99 MB/s) - ‘22’ saved [94/94]

--2020-02-15 23:00:10--  http://pang.jpg/
Resolving pang.jpg (pang.jpg)... 접속 실패: 이름 혹은 서비스를 알 수 없습니다.
wget: unable to resolve host address ‘pang.jpg’
FINISHED --2020-02-15 23:00:10--
Total wall clock time: 0.4s
Downloaded: 1 files, 94 in 0s (2.99 MB/s)
profile
dev_pang의 pang.log

0개의 댓글