[python] 이미지 업로드 API(1) - 이미지 파일 업로드 요청 처리하기

apphia·2021년 10월 6일
post-thumbnail

메인 유저 시나리오
1. 유저는 이미지를 1개 이상 선택해 업로드한다.
2. 유저는 앱을 통해 이미지로부터 추출된 텍스트 정보를 받는다.
3. 유저는 원하는 ppt 템플릿을 고르고, 텍스트 내용, 위치, 폰트, 크기 등을 수정한 후 ppt 생성 요청을 보낸다.
4. 이후 유저는 원할 때마다 생성된 ppt 자료를 다운로드 받을 수 있다.

자료메이커의 메인 유저 시나리오는 위와 같고, 이 시나리오를 바탕으로 API 서버가 어떤식으로 구현되었는지 정리해 볼 예정이다.

이번 포스팅은 1번, 이미지 업로드에 대한 부분을 정리한다.

서버에서는 이미지 업로드 기능을 다음과 같은 과정으로 처리한다.

이미지 업로드 기능
1. 업로드 request를 보낸 유저의 정보를 얻는다.
2. 업로드 request에 대한 정보를 DB에 저장한다. (Upload)
3. request에 담긴 이미지 파일들을 서버 storage에 저장한다.
4. 서버 storage에 저장되어 있는 이미지 파일들을 S3 storage로 업로드하고, DB에 각 이미지 정보를 저장한다. (Image)

5. 텍스트 추출모델(AI 모델)과 연결하여 이미지로부터 텍스트를 추출하고, 결과를 response한다.
6. 업로드 후 일정 시간 이상 경과된 파일들은 서버에서 일괄 삭제한다.

우선 이미지 업로드에 대한 요청을 처리할 함수를 만들어준다. 이미지 업로드 요청 처리는 "생성"에 해당하는 작업을 수행하므로 POST로 받도록 한다.

처음 시작할 틀은 아래와 같다.

# files/views.py

class ImageView(generics.GenericAPIView):
    permission_classes = [IsAuthenticated, ]
    
    def post(self, request):
        # todo
        return JsonResponse()

이제 위에서 정리한 '이미지 업로드 기능'의 각 task에 대해 detail하게 정리해보도록 하자.

1. user_id와 upload_id 정보 얻기

task 1, 2번에 해당한다.

이미지 파일들이 저장될 경로 설정을 위해 user_id와 upload_id가 필요하다.

이 때 user_id가 사용자 아이디 정보이고, upload_id가 업로드 아이디 정보에 해당한다.

참고로 upload_id는 사용자가 이미지 업로드 요청을 보낼 때, 그 한 번의 요청에 대한 정보를 'Upload' 단위로 저장하는데, 이 때 각 Upload에 대해 부여되는 식별자를 의미한다.

예를 들어, 사용자가 여러 장의 이미지 파일들을 담아 이미지 업로드 요청을 했다고 하자. 매번 올라오는 업로드 요청마다 담겨있는 이미지 파일들의 개수가 다르며, DB에서는 이렇게 개수가 변화되는 이미지들을 한번에 동적으로 저장할 수 있는 방법이 없다.

따라서 DB에 이미지 파일들을 저장할 때는, 각 이미지에 대한 정보를 DB에 저장하고, 이 이미지들이 어느 request로부터 업로드된 파일인지를 식별하기 위해 한 번 더 묶어줄 필요가 있다.

이 때, 각 이미지 정보를 저장하는 객체를 Image (DB의 images 테이블과 매핑), 이미지 정보들을 request 단위로 묶어주는 객체를 Upload (DB의 uploads 테이블과 매핑)라고 지정하였다.

본론으로 돌아와서, user_id와 upload_id가 필요한 이유는, 이미지 파일들을 스토리지로 저장할 때 이미지 경로에 user_id와 upload_id가 포함되기 때문이다.

이미지 경로: ~/media/{user_id}/upload{upload_id}/images/{image file name}

예를 들어, user1이라는 아이디를 가진 사용자가 sample1.png, sample2.png라는 이미지들에 대해 업로드 요청을 했다고 하자. 이 때 user1의 업로드 request에 부여된 upload_id가 13이라고 하면, 각 이미지들의 저장 경로는 ~/media/user1/upload13/images/sample1.png과 ~/media/user1/upload13/images/sample2.png가 된다.

미디어 파일들(이미지, pptx)의 저장 구조에 대한 자세한 내용은 여기에 정리해 두었다.

참고로 user1은 본인 아이디로 된 폴더(user1/)으로만 접근할 수 있으며, 이를 위해서는 request의 header에 있는 토큰 정보를 이용해 현재 접근한 유저가 누구인지 식별할 필요가 있다. (자료메이커 프로젝트에서는 세션이 아니라 JWT 토큰 인증으로 구현해둔 상태이므로, request header에 담겨오는 토큰의 payload 부분에서 user pk 정보를 추출해 올 수 있다.)

# files/views.py

class ImageView(generics.GenericAPIView):
    permission_classes = [IsAuthenticated, ]
    
    def post(self, request):
        user = request.user
                
        # Create Upload
        upload = Upload.objects.create(user=user)
        upload.upload_path = get_path(user.user_id, upload.id)
        upload.save()
        
        return JsonResponse()

Upload는 다음과 같은 구성정보를 포함한다.

Upload
- id : primary key, bigInt, automatically increasing, 업로드 아이디(식별자)
- user : foreign key, string, 기존 User 객체와 매핑, 업로드 요청을 한 유저의 아이디
- upload_path : string, 업로드한 이미지 파일들이 저장되는 폴더 경로

Image는 다음과 같은 구성정보를 포함한다.

Image
- id : primary key, bigInt, automatically increasing, 이미지 아이디(식별자)
- upload : foreign key, bigInt, 기존 Upload 객체와 매핑, 이미지가 딸려온 업로드의 아이디
- image_path : string, 이미지 파일이 저장되는 폴더 경로
- image_name : string, 스토리지에 저장된 이미지 파일의 이름
- image_type : string, 해당 이미지에 주로 포함된 내용이 영어인지 한글인지, 필기체인지 인쇄체인지에 대한 타입 지정

+) Upload와 Image는 files/models.py에 구현되어 있다.


2. 폼데이터로 전송한 이미지 파일을 스토리지에 저장하기

task3, 4에 해당한다.

request.FILES로부터 업로드 요청된 이미지들의 리스트를 얻을 수 있다.

이미지들은 key-value형태로 넘어오게 되는데, key에는 image0, image1, image2와 같이 image + 인덱스(0부터 시작)의 형태로 되어있고, value는 이미지 파일 자체가 들어있다.

이미지 파일은 FileSystemStorage를 이용해 로컬 서버에 저장하고, 이후 boto3 모듈을 이용해 s3로도 업로드되도록 하였다. s3 업로드에 대한 자세한 내용은 추후에 정리하고, 우선은 s3_upload라는 함수는 s3 버킷으로 이미지 파일들을 업로드할 수 있도록 구현해 둔 함수라는 점만 알고 넘어가자.

+) s3_upload 함수와 boto3 모듈 이용에 대한 구체적인 구현 내용은 여기에 정리해두었다.

# files/views.py

class ImageView(generics.GenericAPIView):
    permission_classes = [IsAuthenticated, ]
    
    def post(self, request):
        user = request.user
                
        # Create Upload
        upload = Upload.objects.create(user=user)
        upload.upload_path = get_path(user.user_id, upload.id)
        upload.save()
                
        # Get image data
        images = request.FILES 
        image_type = request.POST['image_type']
        s3_path = get_path(user.user_id, upload.id) + "images/"
        local_path = MEDIA_DIR + s3_path
        
        # Save images into storage
        fs = FileSystemStorage(location=local_path, base_url=local_path)
        for i in range(len(images)): 
            image = images['image'+str(i)]                          # Get image file
            save_image = fs.save(image.name, image)                 # save image into local storage
            image_name = fs.url(save_image).split('/')[-1]     
            s3_upload(local_path+image_name, s3_path+image_name)  # save image into S3 storage 
        return JsonResponse()

3. 이미지 정보를 데이터베이스에 저장하기

task4에 해당한다.

앞서 언급한 바와 같이, 여러 이미지 정보를 한 번에 DB에 저장할 수는 없고, 개별 이미지에 대해서 저장해주어야 한다. Image에 하나씩 저장해두도록 한다.

+) serializer를 이용해서 깔끔하게 구현하고 싶었으나, 우선 반환하려는 정보가 Image에 대한 json 정보가 아니고, create() 함수에 validated_data를 넘길 때, 원하는 값들만 넘겨주는 것이 안돼서 우선은 views.py에 전부 구현해두었다. 이건 뭔가 다른 방법을 찾아봐야겠다.

# files/views.py

class ImageView(generics.GenericAPIView):
    permission_classes = [IsAuthenticated, ]
    
    # Save images, Return text
    def post(self, request):    
        user = request.user
                
        # Create Upload
        upload = Upload.objects.create(user=user)
        upload.upload_path = get_path(user.user_id, upload.id)
        upload.save()
                
        # Get image data
        images = request.FILES
        image_type = request.POST['image_type']
        s3_path = get_path(user.user_id, upload.id) + "images/"
        local_path = MEDIA_DIR + s3_path
        
        # Save images into storage & database
        fs = FileSystemStorage(location=local_path, base_url=local_path)
        for i in range(len(images)): 
            image = images['image'+str(i)]                          # Get image file
            save_image = fs.save(image.name, image)                 # save image into local storage
            image_name = fs.url(save_image).split('/')[-1]
            
            s3_upload(local_path+image_name, s3_path+image_name)  # save image into S3 storage 
            Image.objects.create(                                   # save image into database
                upload = upload,
                image_path = s3_path,
                image_name = image_name,
                image_type = image_type,
            )
        return JsonResponse()

4. 텍스트 추출 모델과 연결

task5에 해당한다.

# files/views.py

class ImageView(generics.GenericAPIView):
    permission_classes = [IsAuthenticated, ]
    
    def post(self, request):    
        user = request.user
                
        # Create Upload
        upload = Upload.objects.create(user=user)
        upload.upload_path = get_path(user.user_id, upload.id)
        upload.save()
                
        # Get image data
        images = request.FILES
        image_type = request.POST['image_type']
        s3_path = get_path(user.user_id, upload.id) + "images/"
        local_path = MEDIA_DIR + s3_path
        
        # Save images into storage & database
        fs = FileSystemStorage(location=local_path, base_url=local_path)
        for i in range(len(images)): 
            image = images['image'+str(i)]                          # Get image file
            save_image = fs.save(image.name, image)                 # save image into local storage
            image_name = fs.url(save_image).split('/')[-1]
            
            s3_upload(local_path+image_name, s3_path+image_name)  # save image into S3 storage 
            Image.objects.create(                                   # save image into database
                upload = upload,
                image_path = s3_path,
                image_name = image_name,
                image_type = image_type,
            )
        
        # Extract text from images 
        result = detect_recognition(local_path, image_type)

        # convert result into json format, and return it
        data = get_json(result, upload.id)
    
        return JsonResponse(data=data, status=status.HTTP_200_OK)

detect_recognition
텍스트 추출 모델과 연결해주는 함수이다. models/views.py에 구현되어있다.
이미지들이 저장된 스토리지 경로(image_path)와 해당 이미지들의 타입(image_type)을 parameter로 넘겨주면, 해당 이미지들로부터 추출한 텍스트 정보를 bytes 객체로 반환한다.
따라서 result에는 추출된 텍스트 정보가 bytes으로 저장되어있다.

JsonFormat
bytes 객체를 string type으로 변환하고, 여기서 적절한 데이터들을 split하여 dict 형태의 반환 값을 만들어준다. 즉, 앱에 response할 json 데이터 형태를 만들고 반환해주는 함수이다. JsonFormat은 함수 구현 내용은 여기에 정리해 두었다.


이슈

ImageView 코드 정리하기

task4와 5가 병렬로 처리될 수 있도록 구현하기

  • 특히 task5는 response time이 매우 길어서 비동기적으로 처리해 줄 필요가 있음
  • django에서는 단순히 asyncio만 이용해서 구현하기 힘듦 > 방법 찾아보기 : channels, websocket...

detect-recog 모델 연결 시 서버 스토리지의 path를 넘겨주기

  • s3로 바로 업로드 > s3로 접근해서 이미지 파일 가져와서 텍스트 추출하기를 해버리면 시간이 너무 오래걸림 && 이미지들을 직접 넘겨주면 메모리 사용량 급↑.

따라서

  • 서버에 이미지들 저장 > 해당 경로를 detect-recog모델에 전달 > 경로에 있는 이미지들을 불러와서 텍스트 추출 > response
  • 서버에 있는 이미지들 s3로 업로드 && DB에 정보 저장
    이 두가지 task를 병렬적으로 처리하는 방법을 생각해보기
    (django orm에 비동기적으로 잘못 접근하면 conflict 발생할 수 있으니 조심하기)

(task6) 이후 서버에 쌓여있는 이미지 파일들은 일괄 삭제

  • 파일 삭제 조건 걸어두기 (예를 들어, 업로드된지 24시간 이상 된 이미지 파일들은 스토리지에서 제거 > 서버에 계속 쌓아두면 이후에 서버 스토리지 용량 문제가 생길 수 있음)
profile
내가 보려고 정리하는 공부 블로그

0개의 댓글