Dev/Flask

Flask(python) - Image 사이즈 별 S3 저장 및 URL Link 저장하기

sincerely10 2020. 8. 31. 02:18
반응형

안녕하세요. 이번 포스트는 Flask(Python)에서 Image(이미지)를 사이즈 별로 AWS S3에 저장하고 각 URL Link를 MYSQL(RDB)에 저장하는 과정 및 코드를 포스팅 해보겠습니다.

1. 프로젝트 및 구조 소개

1-1. 프로젝트 소개

지난번 포스트에서도 비슷한 글을 올렸지만 변경된 부분도 있고 조금 더 자세히 설명 드리고자 합니다. 본 프로젝트는 Brandi라는 다양한 브랜드를 모아 놓은 여성 쇼핑몰 브랜디의 사용자 및 관리자 페이지를 클론하는 프로젝트 입니다.

1-2. Layered Architecture

프로젝트의 Layered Atchitecture는 아래 Tree 구조와 다음과 같습니다.(Layered Architecture에 대한 자세한 설명은 위의 링크 포스트에 기술되어 있습니다.)

├── app.py
├── connection.py
├── controller
├── manage.py

│   ├── __init__.py
│   ├── order_controller.py
│   ├── product_controller.py
│   └── user_controller.py
├── service
│   ├── __init__.py
│   ├── order_service.py
│   ├── product_service.py
│   └── user_service.py
├── model
│   ├── __init__.py
│   ├── order_dao.py
│   ├── product_dao.py
│   └── user_dao.py

├── utils.py
└─
─ requirements.txt

구조는 Controller(View의 확장기능), Service, Model로 구성된 Functional Structure로 되어 있습니다.
Admin과 Service를 별도로 분리하는 Divisional Structure로 구성하지 않은 이유는 전체 기능을 클론하는 것이 아니기 때문에 함수가 많이 생성되지 않았기 때문입니다.

Github 코드는 다음과 같습니다.
github.com/rudqo14/brandi-project

1-3. Data Modeling(상품이미지 관련된 부분만)

모델링을 한 테이블은 17개의 테이블이지만 이 포스트에서 다룰 테이블은 3개 (products, product_images, images) 테이블 입니다.

이 3개의 테이블의 ERD는 아래 사진과 같습니다.

테이블에 대해 간단히 소개하면 다음과 같습니다.

- 상품 등록 시, 상품의 PK를 갖는 테이블 products

- 상품의 이미지 링크를 자체적으로 정한 사이즈 별(Large, Medium, Small)로 저장하는 images

- 상품과 이미지의 정보를 Mapping 하는 Many to Many Table product_images

2. 이미지 S3 저장 및 이미지 URL 링크 저장 구조

이 포스트에서 구현에 대해 소개하는 파트는 크게 세 파트 입니다.

1) 상품 사진(이미지)을 Request로 받고 유효성(사진크기, 이미지 파일 확인)을 확인하는 파트

2) 이 이미지를 자체적으로 정한 사이즈인 (Large, Medium, Small)에 따라 Resize -> S3 저장하는 파트

3) 상품 상세 정보 등록 시, 이미지의 링크 기록하는 테이블과 상품과 이미지의 정보를 Mapping 하는 Many to Many Table에 insert 하는 파트

로 구성되어 있습니다.

파일 순서로 소개하는 것 보다 위의 구조로 소개하는 것이 더 와 닿을 것이라 생각하기 때문에  구조 순으로 소개하겠습니다.

3. 이미지 파일 HTTP Request로 받고 유효성 확인하기

3.1 이미지 파일 HTTP Request 받기

먼저 이미지 파일을 받아와야 합니다.
저는 POSTMAN(포스트맨)을 사용하는데 포스트맨에서 form-data를 사용하면 이 파일을 보낼 수 있습니다.

API 입력에서 Body > form-data를 클릭 해줍니다. 체크박스와 같이 key값을 입력할 수 있고 그 옆에 Value를 기입할 수 있습니다. 여기서 key 입력란 우측에 File이 있습니다.
기본은 Text인데 File로 변경이 가능합니다. File을 선택 하시고 두 번째 줄에 product_image_2와 같이 'Select_Files'라고 파일을 고를 수 있습니다.

여기서 똑같이 파일 브라우저로 사진을 고를 수 있습니다. 고르면 첫 번째 줄과 같이 파일명과 확장자가 표시됩니다.
이 방법이 form-data를 활용한 파일 보내기 입니다. Local에서 테스트 하기 편한 환경입니다.

3.2 이미지 파일 flask에서 request로 받아오기

이제 부터 중요한 부분이 시작 됐습니다. 조금 전 Send를 누르면 파일이 전송이 됩니다. 그 Request를 받고 처리 해주어야만 이미지 파일을 컨트롤 할 수 있습니다.

request를 다루는 controller에서 이 request를 받고 처리 해보겠습니다.

# controller/product_controller.py
from connection import get_connection, get_s3_connection

def create_admin_product_endpoints(product_service):

    # 'admin/product' end point prefix 설정
    admin_product_app = Blueprint('product_app', __name__, url_prefix='/admin/product')

    # 상품등록 Function
    @admin_product_app.route('', methods=['POST'])
    def product_register():
	##중략##
	# 사이즈 별(Large, Medium, Small) 상품이미지 저장 위한 S3 Connection Instance 생성
	s3_connection = get_s3_connection()
	images        = request.files

	# 상품이미지를 사이즈 별로 S3에 저장하는 Function 실행  
	product_image_upload_result  = product_service.upload_product_image(images, s3_connection)

	if db_connection:

		# form-data request를 product_info라는 Dictionary 변수에 담기
		product_info = request.form.to_dict(flat=False)

		# 1-5번의 사이즈 별 상품이미지를 product_info에 추가
		product_info['image_url'] = product_image_upload_result

		# 상품정보를 DB에 저장하는 Function 실행
		product_service.create_product(product_info, db_connection)
		db_connection.commit()

Request로 파일을 받을 때는 request.files를 통해서 받을 수 있습니다. 저는 파일은 이미지만 받았기 때문에 images 라는 변수에 담았습니다.

이제 유효성을 체크하는 과정을 진행하는데 product_service의 upload_product_image라는 만든 함수에 Args로 넣어줍니다.

3.3 사진 유효성 체크

함수 이름인 upload_product_image에서 알 수 있듯이 image 유효성 체크가 목적이 아닌 upload까지가 최종 목적인 함수 입니다.

제가 체크하려는 이미지 파일의 유효성은

- 대표사진(Tumbnail) 미 등록시
- 5개의 사진 중 중간이 비어 있는데 등록할 경우(예를 들어 1, 3, 4번에만 이미지 파일을 생성하는 경우
- 이미지 파일이 아닌 경우
- 이미지 사이즈가 너무 큰 경우
입니다.

이미지 업로드 폼의 예시는 다음과 같습니다.

관련 로직을 코드로 확인해보겠습니다.

# service/product_service.py
from PIL import Image

	def upload_product_image(self, images, s3_connection):

        # product image
        product_images = {}

        try:
            for idx in range(1,6):

                # 대표사진 미등록 시 예외처리
                if images.get(f'product_image_1', None) == None :
                    raise Exception('THUMBNAIL_IMAGE_IS_REQUIRED')

                # 상품사진 미정렬 시 예외처리
                if idx > 1  :
                    if (images.get(f'product_image_{idx}', None) != None) and (images.get(f'product_image_{idx-1}', None) == None) :
                        raise Exception('IMAGE_CAN_ONLY_REGISTER_IN_ORDER')

                # 상품사진 있는 경우 product_images Dictionary에 저장
                if images.get(f'product_image_{idx}', None) != None :
                    product_images[images[f'product_image_{idx}'].name] = images.get(f'product_image_{idx}', None)

                    # 파일이 Image가 아닌 경우 Exception 발생
                    image         = Image.open(product_images[f'product_image_{idx}'])
                    width, height = image.size

                    # 사이즈가 너무 작은 경우 예외처리
                    if width < 640 or height < 720 :
                        raise Exception('IMAGE_SIZE_IS_TOO_SMALL')

            # 상품 이미지 받아오기 및 유효성 검사 이후 S3 upload
            resizing = ResizeImage(product_images, s3_connection)
            return resizing()

        except Exception as e:
            raise e

1번 부터 5번까지 이미지 업로드 폼이 정해져 있기 때문에 for loop를 1~5까지 설정하였습니다.

가장 먼저 대표사진(1번 사진)이 입력되지 않으면 예외처리를 발생시켰습니다.

그리고 1번 사진 이후의 사진의 경우 중간이 비는 경우를 별도로 예외처리 해주었습니다.(if idx >1 : 부분)

저 같은 경우는 유효한 사진만 담고자 하여 product_images라는 Dictionary 변수를 선언 하였습니다. 따라서 이 Dictionary 변수에 담기지 않는다면 유효하지 않은 사진 또는 예외처리가 발생한 경우입니다.

이미지 파일이 있는 사진만 담고, pillow라는 모듈을 활용하여 이미지를 열어 줍니다.(Image.open(~))
여기서 이미지 파일이 아닌 경우 에러가 발생하고 예외처리를 해줍니다.

정상적으로 열리는 사진의 경우 image라는 객체가 생성됩니다. 그리고 이미지가 너무 작은 경우를 예외처리 해주기 위해 width와 height를 구합니다.

마지막 코드블록에서와 같이 width와 height가 특정 사이즈 미만인 경우 사진의 사이즈가 너무 작다는 예외를 발생시켜 줍니다.

4. 이미지 파일 Resize(리사이즈) 하고 S3에 저장하기

이 포스트의 하이라이트인 이미지 파일의 리사이즈와 S3 저장하는 파트입니다.
먼저 조금 전 product_service.py 파일에서 아래와 같이 최종적으로 ResizeImage라는 클래스의 Instance를 실행한 것을 확인 하실 수 있을 겁니다.

# 상품 이미지 받아오기 및 유효성 검사 이후 S3 upload 
resizing = ResizeImage(product_images, s3_connection)
return resizing()

 ResizeImage 라는 클래스는 utils.py(여러 기능을 수행하는 함수를 모아놓은 파일)에 선언하였습니다. 코드로 로직을 확인해 보겠습니다.(많이 잘라냈는데도 길어졌네요..)

# utils.py
import time, jwt, io, uuid
from PIL        import Image

from config import SECRET, S3

class ResizeImage:

    def __init__(self, images, s3_connection):
        self.images         = images
        self.s3_connection  = s3_connection
        self.large_width    = 1080
        self.medium_width   = 640
        self.small_width    = 480
        self.resize_images  = {}

    def resizing(self):

        for key, values in self.images.items():

            # image 파일 open
            image = Image.open(values)

            # image 사이즈 측정
            width, height = image.size

            # uuid 생성
            unique_id = str(uuid.uuid4().int)

            # large size로 resizing 하는 메소드 실행
            large_size_image = self.resize_image_to_large(values, width, height, unique_id, self.s3_connection)

            # medium size로 resizing 하는 메소드 실행
            medium_size_image = self.resize_image_to_medium(values, width, height, unique_id, self.s3_connection)

            # small size로 resizing 하는 메소드 실행
            small_size_image = self.resize_image_to_small(values, width, height, unique_id, self.s3_connection)

            # resize_images dictionary에 결과 저장
            self.resize_images[key] = {
                'product_image_L' : large_size_image,
                'product_image_M' : medium_size_image,
                'product_image_S' : small_size_image
            }

    def resize_image_to_large(self, image_file, width, height, unique_id, s3_connection):

        # image 파일 open
        image = Image.open(image_file)

        # image resize
        image_L = image.resize((self.large_width,int((height*self.large_width)/width)))

        buffer = io.BytesIO()
        image_L.save(buffer, "JPEG")
        buffer.seek(0)

        s3_connection.put_object(
            Body        = buffer,
            Bucket      = 'brandi-project',
            Key         = f"{unique_id}_{image_file.name.split('product_')[1]}_L",
            ContentType = image_file.content_type
        )

        image_L_url = f"{S3['aws_url']}{unique_id}_{image_file.name.split('product_')[1]}_L"

        return image_L_url

    def __call__(self):
        self.resizing()
        return self.resize_images

4.1 Arguments에 대한 resizing 수행

이 클래스의 Arguments 값으로 이미지 파일(Dictionary)와 s3_connection이 있습니다. s3 Connection은 본 포스트 6번 항목에서 간단하게 다루겠습니다.
__init__을 수행하면 images 변수, s3_connection 그리고 각 Size 별 resizing 될 width가 선언됩니다. 참조 하실 점은 width만으로 사진의 비율을 구해 height를 계산한다는 것 입니다.

그리고 객체(Instance)를 호출하면 클래스 내 resizing 이라는 function이 실행 됩니다. 이 함수에서 다시 한 번 실제적으로 resize하는 함수를 실행해줍니다.

각 사이즈로 실제적으로 변경해주는 resize_image_to_large 함수를 예시로 살펴보겠습니다.(medium과 small은 동일한 기능에 변수만 다르기 때문에 생략 하였습니다.
먼저 image 파일을 조금전 설명 드린 pillow의 Image라는 method로 이미지 파일을 열어줍니다. pillow의 resize라는 method를 활용해 실제 변경하고자 하는 width와 height를 Arguments로 넣어줍니다.

여기서 변수로 받는 image_L이 resize가 완료된 이미지 객체 입니다.

4.2 S3에 이미지 저장

이어서 각 사이즈의 이미지를 S3에 저장하는 구현 부분을 설명 하겠습니다. 동일하게 resize_image_to_large 함수로 이어서 설명하겠습니다.
Flask의 경우 이미지 객체만 담으려 하면 빈 이미지 파일이 저장되는 이슈가 있었습니다. 찾아보니 이미지 파일이 Byte로 되어 있는데 pillow의 Image라는 method로 읽어 들이면 객체의 Byte가 가장 뒤를 참조하기 때문이라고 합니다.

이런 이슈 때문에 python의 기본 모듈인 io를 활용하였습니다. 먼저 io 객체를 선언 해줍니다.
buffer = io.BytesIO()

buffer라는 io 객체를 통해 이제 사이즈를 변경한 이미지 객체를 JPEG 확장자로 담아보겠습니다.
image_L.save(buffer, "JPEG")

이제 buffer에 Large 사이즈로 변경된 객체가 담겼습니다. 이 객체가 가리키는 위치는 조금 전 말씀드렸듯이 가장 마지막을 가리키고 있습니다. 이를 맨 앞으로 조정하기 위해 seek 이라는 method를 사용 해보겠습니다. seek method에 Arguments로 0 즉, 가장 앞부분을 가리키게 만듭니다.
buffer.seek(0)

이제 가장 앞을 가리키는 buffer라는 객체를 S3에 넣을 수 있습니다.

s3_connection이라는 s3의 connection 객체의 method인 put_object를 활용하겠습니다.
put_object의 Arguments는 
Body: 담을 파일
Bucket: 저장할 Bucket(최상위의 Directory 같은 존재)
Key: 저장할 파일의 이름
ContentType: 파일의 content Type

특별히 설명드릴 것은 Key 입니다. 조금 전 resizing 함수에서 각자의 uuid를 만들고 Arguments로 넣어줬습니다. 같은 사진이라면 같은 uuid를 가지게끔 했습니다.
물론 원래는 각 사이트에서 생성되는 로직에 맞게(등록순서나 사용자의 id 등) unique 한 id를 만들 수 있지만, 프로젝트의 특성상 그 로직까지 알지는 못 하기에 uuid를 활용 했습니다.

이제 각 이미지 크기에 맞게 URL을 return 할 것이고, init에서 선언한 self.resize_images라는 Dictionary 변수에 담습니다. 참고하실 점은 for loop가 Arguments로 받아진 Dictionary를 돌기 때문에 1~5번 사진까지 순차적으로 수행됩니다.

이 resing 함수의 최종 Return은 1~5번의 이미지(혹은 1번 이상의) 사진 크기별로 S3 URL이 담긴 Dictionary 객체겠네요!

5. 이미지 URL의 RDB 저장

마지막 파트로 저장한 image URL을 RDB에 저장하고 상품과 image URL 간의 Mapping Table(Many To Many Table)에 저장하는 것을 다뤄보겠습니다.

5.1 상품 이미지 URL 저장

product_controller.py 코드에 보면 image를  s3에 업로드 하는 upload_product_image 함수 외에도 product_service.create_product(product_info, db_connection) 라는 함수를 실행시킵니다.
상품정보에는 이미지 URL 말고도 여러 함수가 있는데 이 정보는 product_info라는 Dictionary 변수에 저장합니다. 물론 image_url도 바로 위에 product_info['image_url'] = product_image_upload_result 코드로 추가된 상태입니다.

create_product라는 함수를 다 확인하기 어렵고 관련 로직을 구현하는 코드만 확인해보겠습니다.

# controller/product_controller.py
	def create_product(self, product_info, db_connection):
    
    	# 사진크기 별 product_image(최대 5개)에 대해 image URL insert & product_images(매핑테이블) insert
        for product_image_no, image_url in product_info['image_url'].items() :
            image_no = self.product_dao.insert_image(image_url, db_connection)
            self.product_dao.insert_product_image(product_info['product_id'], image_no, product_image_no, db_connection)

Dictionary 형태로 들어온 product_info['image_url']에 대해 for loop을 수행합니다. 이 때 각 key는 'product_image_number'로 value는 Dictionary 구조인 image URL이 적힙니다. 이제 model 즉, product_dao에 이 image URL을 insert하는 insert_image 함수를 확인해보겠습니다.

    def insert_image(self, image_url, db_connection):
            try:
            with db_connection.cursor() as cursor:

                insert_images_query = """
                INSERT INTO images (
                    image_large,
                    image_medium,
                    image_small
                ) VALUES (
                    %(product_image_L)s,
                    %(product_image_M)s,
                    %(product_image_S)s
                )
                """

                affected_row = cursor.execute(insert_images_query, image_url)

                if affected_row <= 0 :
                    raise Exception('QUERY_FAILED')

                # 등록한 images 테이블의 row id Return
                return cursor.lastrowid

코드를 확인해보면 Arguments로 for Loop에서 받았던 image_url과 pymysql을 통해 mysql과 연결된 db_connection을 받습니다.
그리고 쿼리를 정의 합니다. 한 Row에 각 image URL이 다 저장되는 테이블 입니다. 마지막으로 cursor 객체를 execute method를 통해 실행 해줍니다.

결과로는 lastrowid 즉, images 테이블에 가장 마지막으로 insert한 row id(PK)가 return 됩니다.

5.2 상품과 상품 이미지의 Mapping 테이블에 insert

마지막 과정은 상품 테이블(products)과 상품 이미지 URL 테이블(images)의 중간 테이블인 product_images 테이블에 insert 하는 것 입니다.

self.product_dao.insert_product_image(product_info['product_id'], image_no, product_image_no, db_connection)

insert_product_image 함수를 통해 이 insert 작업이 이뤄집니다.
바로 위의 라인에서 image_no라는 PK를 받았고 이 image_no가 Argument가 됩니다. 그리고 상품의 row id도 알아야 하기 때문에 Arguments에 포함 됩니다. product_image_no는 조금 전 image URL의 Dictionary 객체의 key 값입니다.

결국 이 4개의 Arguments로 Mapping 테이블의 insert가 가능합니다.

코드를 확인해보겠습니다.

    def insert_product_image(self, product_id, image_id, product_image_no, db_connection):

        try:
            with db_connection.cursor() as cursor:

                # Thumbnail(대표) 사진의 구분
                is_main = 1 if product_image_no == 'product_image_1' else 0

                insert_product_images_query = """
                INSERT INTO product_images (
                    product_id,
                    image_id,
                    is_main
                ) VALUES (
                    %s,
                    %s,
                    %s
                )
                """

                affected_row = cursor.execute(
                    insert_product_images_query,
                    (product_id, image_id, is_main)
                )

                if affected_row <= 0 :
                    raise Exception('QUERY_FAILED')

                return None

가장 먼저 이 image가 Thumbnail(대표사진)인지를 확인합니다. 만약 첫 번째 사진 즉 product_image_1이면 첫 번째 사진이기 때문에 is_main을 1 즉 , True로 설정합니다.

그리고 insert Query를 선언 합니다.
여기서 특별한 것은 없고 Arguments에서 받아온 값을 대입해주는 과정입니다.
최종으로 return은 아무것도 하지 않습니다. Return을 통해 그 이후의 과정을 진행하지 않기 때문입니다.

6. S3 Connection 객체 생성하기

s3 connection 객체(Instance)를 생성하는 과정에 대해 소개하겠습니다.

product_controller.py 파일에 보면 아래와 같은 코드가 첫 줄에 있습니다.

from connection import get_connection, get_s3_connection

여기서 connection은 직접 생성한 python 파일입니다. 이 get_s3_connection을 Instance로 받아오고 이 객체를 통해 이미지 파일을 저장까지 할 수 있었던 것입니다.

저의 경우 connection.py를 다음과 같이 구성했습니다.

import pymysql, boto3

from config import S3

def get_s3_connection():
    s3_connection = boto3.client(
        's3',
        aws_access_key_id     = S3['aws_access_key_id'],
        aws_secret_access_key = S3['aws_secret_access_key']
    )

    return s3_connection

boto3는 aws S3 객체를 생성해주는 python module 입니다. boto3의 client method로 Arguments는 서비스 이름(여기서는 s3), aws_access_key_id, aws_secret_access_key를 기입해줍니다.
최종적으로 생성된 s3 connection Instance(객체)를 Return 해줍니다.

참조하실 점은 저의 경우 .gitignore에 공개 되서는 안 될 secret 내역을 기재하고 key, value로 불러 왔다는 것 입니다.

p.s 처음에 블로그를 작성할 때, 파트를 분리 했었는데 통일성이나 넘겨보기에 복잡한 이유 등으로 하나로 통합했습니다.

반응형

'Dev > Flask' 카테고리의 다른 글

Flask - Basic + Layered Architecture  (3) 2020.08.23