오늘도 개발

[2차 프로젝트 내일의 집] AWS s3로 글쓰기 api 구현하기 - 3. 리팩토링 본문

TIL & 프로젝트 회고

[2차 프로젝트 내일의 집] AWS s3로 글쓰기 api 구현하기 - 3. 리팩토링

Sueeeeeee 2022. 8. 9. 17:13

리팩토링 전 코드의 문제점

1) 사진 업로드 기능 문제

만약 사진이 5장 있는데 3장째 사진 url을 db에 넣는 중 오류가 나서 프로그램이 종료된다면?

가장 이상적인 결과는 프로그램 종료 후

s3에도 1, 2, 3, 4, 5 중 아무 사진이 없고

db에도 1, 2, 3, 4, 5 중 아무 url이 없는 것이다.

 

하지만 이전 코드는 여러 개의 사진 파일을 s3에 바로바로 업로드 하고, 업로드한 링크를 바로바로 db에 저장한다.

즉 프로그램을 끝까지 실행하지도 못했는데

s3에는 1, 2, 3번의 사진이 저장되어 있을 것이다.

db에는 1, 2번의 사진 url이 저장되어 있을 것이다.

 

해결 방안은 다음과 같다.

 

transaction 사용

db에 1, 2번의 사진이 들어가는 문제는 transaction으로 해결할 수 있다.

 

transaction은 쿼리 묶음이다.

DB는 transaction으로 묶인 쿼리를 실행하다가 만약 한 쿼리라도 오류가 나면 모든 쿼리를 rollback한다.

트랜잭션을 사용했다면 위와 같은 상황에서 db에는 어떤 사진의 url도 들어가 있지 않을 것이다.

 

배포 후 Linux crontab 사용

db에 url을 넣는 건 방지할 수 있어도 s3에 올라가는 사진을 막을 방법은 없다.

대신 리눅스에서 배포한 후 crontab을 사용해서 s3에 올라간 사진 중 db에 url이 없는 것을 

주기적으로 지워주면 된다. 

2) 캡슐화가 되어 있지 않다는 문제

이 코드로는 s3에만 업로드를 할 수 있다.

하지만 만약 s3가 아니라 다른 서비스를 이용하게 된다면 

전체 코드를 수정해야 한다.

 

PostWriteView 라는 함수 안에 s3 업로드 기능, db 저장 기능이 두 개 들어있다는 것도 문제다.

한 함수가 한 기능만 해야 테스트하기도 쉽고 코드를 재사용하기도 쉽다. 

 

s3에 업로드하는 부분을 클래스로 분리해서 사용한다면(캡슐화)

위와 같은 상황이 닥쳤을 때 클래스의 메서드만 수정하면 된다.

 

3) 파일이 하나일 때 파일 업로드가 되지 않는 문제

받은 파일을 files = request.FILES.getlist('photo')로 받고,

리스트를 for 문으로 iterate했는데 이 때 코드를 for i in range(len(files)-1)이라고 쓴 것이 문제였다.

파일이 하나인 경우 len(files)가 1이라서 for i in range(0)이 되기 때문이다.

 

이것은 for i in range(0, len(files))로 수정해서 바로 해결할 수 있었다.

 

<리팩토링 전 views.py>

class PostWriteView(View):
    @login_decorator
    def post(self, request):
        try:
            data        = json.loads(request.POST.get('data'))
            living_type = data.get('living_type')
            room_size   = data.get('room_size')
            family_type = data.get('family_type')
            work_type   = data.get('work_type')
            contents    = data.get('contents')
            user_id     = request.user.id

            post = Post.objects.create(
                living_type = living_type,
                room_size = room_size,
                family_type = family_type,
                work_type = work_type,
                user_id = user_id
            )
			
            s3_client = boto3.resource(
                's3',
                aws_access_key_id     = settings.MY_AWS_ACCESS_KEY_ID,
                aws_secret_access_key = settings.MY_AWS_SECRET_ACCESS_KEY
            )
            
            files = request.FILES.getlist('photo')

            # 파일을 s3에 저장하고, 파일 url을 db에 넣는 과정
            for i in range(len(files)-1):
                files[i]._set_name(str(uuid.uuid4()))
                key = f'posts/{user_id}{str(uuid.uuid4())}'  

                s3_client.Bucket('second-project-nhouse').put_object(
                    Key=key, Body=files[i], ContentType='jpeg')

                photo_url = settings.IMAGE_URL + key
                current_content = contents[i]

                photo = Photo.objects.create(
                    description = current_content['description'],
                    url = photo_url,
                    post = post
                )
                
                for tag in current_content['tags']:
                    Tag.objects.create(
                        point_x = tag['point_x'],
                        point_y = tag['point_y'],
                        photo = photo,
                        product_id = tag['product_id'] 
                    )

            return JsonResponse({'results': 'SUCCESS'}, status=201)
        
        except KeyError:
            return JsonResponse({'message':'KEY_ERROR'}, status=400)

 

해결 1. transaction 사용하기

우선 transaction으로 한 url이라도 db에 들어가다 오류가 나면 전체 쿼리를 롤백하도록 만들어보자.

transaction 모듈을 import하고 transaction의 atomic 메서드를 사용하면 된다.

(함수에 사용하는 경우 데코레이터로 사용할 수도 있다.)

 

이제 for문 실행 중 오류가 발생하면 Photo나, Tag에 실행된 쿼리가 롤백될 것이다.

from django.db import transaction

class PostWriteView(View):
    def post(self, request):
    ...
    with transaction.atomic():
        for i in range(0, len(files)):
            ...
            photo = Photo.objects.create(
                        description = current_content['description'],
                        url = photo_url,
                        post = post
                    )
                    
            for tag in current_content['tags']:
                Tag.objects.create(
                    point_x = tag['point_x'],
                    point_y = tag['point_y'],
                    photo = photo,
                    product_id = tag['product_id'] 
                )

 

해결 2. s3 사용 부분을 클래스로 분리하기

AWSFileUploader라는 클래스를 만들어 upload라는 메서드를 사용하면 파일을 업로드할 수 있게 했다.

그리고 FileHandler 클래스를 만들어 FileUploader 인스턴스들을 넣어 사용할 수 있게 했다.

 

AWSFileUploader만으로도 캡슐화가 되었지만 FileHandler 클래스로 또 감싸는 이유는 확장성 때문이다.

AWSFileUploader를 만들어 view에서 바로 upload 메서드를 호출하는 경우를 가정해보자.

이 때 사용하는 클라우드 서비스에 네이버가 추가된다면 AWSFileUploader를 수정해서 NaverFileUploader를 만들어야 한다.

하지만 FileHandler 클래스가 있으면, NaverFileUploader를 추가로 작성한 뒤 FileHandler 인스턴스에 넣어주기만 하면 된다.

 

이제 PostWriteView에서는

1) AWSFileUploader클래스의 인스턴스를 만들고,

2) 만든 인스턴스를 FileHandler 클래스에 넣어 FileHandler 인스턴스를 만든 다음,

3) FileHandler 인스턴스에 upload 메서드를 호출하면 파일을 업로드할 수 있다.

class AWSFileUploader:
    # upload, delete 등 다양한 메서드에 공통적으로 필요한 부분.
    def __init__(self, config):
        # config는 my_settings.py에 저장해놓고 settings.py에서 불러온다.
        # aws와 관련된 키, 버킷명, url 등등은 my_settings.py의 AWS_CONFIG 딕셔너리에 저장되어 있다.
        self.config = config
        self.client = boto3.resource(
            's3',
            aws_access_key_id     = self.config['access_key_id'],
            aws_secret_access_key = self.config['secret_access_key']
        )

    # 파일을 실제로 s3에 올리고 성공 시 파일 url을 반환하는 메서드
    def upload(self, file):
        try:
            file._set_name(str(uuid.uuid4()))
            key = f'posts/{user_id}{str(uuid.uuid4())}'  
            self.client.Bucket(self.config['bucket_name']).put_object(
                Key=key, Body=file, ContentType='jpeg')
            
            return self.config['image_url'] + key
            
        except: 
            return None

# FileHandler는 코드의 확장성을 위해 추가한 클래스이다.
# AWSFileUploader를 쓰다가 네이버에도 파일을 올려야 하는 경우가 생겼다면?
# NaverFileUploader를 작성한 후 
# NaverFileUploader의 인스턴스를 FileHandler의 file_uploader로 넣으면 된다. 
class FileHandler:
    def __init__(self, file_uploader):
        # file_uploader는 AWSFileUploader 인스턴스일 수도,
        # NaverFileUploader일 수도 있음
        self.file_uploader = file_uploader 

    def upload(self, file):
        # file_uploader로 받은 인스턴스가 가진 upload 메서드를 호출한다는 뜻
        return self.file_uploader.upload(file)


class PostWriteView(View):
    def post(self, request):
        try:
            data        = json.loads(request.POST.get('data'))
            living_type = data.get('living_type')
            room_size   = data.get('room_size')
            family_type = data.get('family_type')
            work_type   = data.get('work_type')
            contents    = data.get('contents')
            files       = request.FILES.getlist('photo')
            user_id     = 1

            if len(files) != len(contents):
                return JsonResponse({'message':'INVALID_LENGTH'}, status=400)

            post = Post.objects.create(
                title       = 'fake title',
                content     = 'fake contents',
                cover_image = 'fake image url',
                living_type = living_type,
                room_size   = room_size,
                family_type = family_type,
                work_type   = work_type,
                worker_type = 'fake worker type',
                user_id     = user_id
            )

            # 파일 업로드 준비
            file_uploader = AWSFileUploader(settings.AWS_CONFIG)
            file_handler = FileHandler(file_uploader)
            
            with transaction.atomic():
                for i in range(0, len(files)):
                    # 파일 업로드 실행
                    # 파일 업로드에 관한 코드는 위 클래스에 정의해두었기 때문에
                    # 여기서는 메서드를 호출하기만 하면 된다.
                    photo_url = file_handler.upload(files[i])
                    
                    if not photo_url:
                        return JsonResponse({'results': 'UPLOAD_FAILED'}, status=400)

                    current_content = contents[i]

                    photo = Photo.objects.create(
                        description = current_content['description'],
                        url         = photo_url,
                        post        = post
                    )
                    
                    tags = current_content.get('tags')
                    if tags:
                        for tag in tags:
                            Tag.objects.create(
                                point_x    = tag['point_x'],
                                point_y    = tag['point_y'],
                                photo      = photo,
                                product_id = tag['product_id']
                            )

            return JsonResponse({'results': 'SUCCESS'}, status=201)
        
        except KeyError:
            return JsonResponse({'message':'KEY_ERROR'}, status=400)