BytesIO로 성능향상하기

youngh·2024년 8월 17일

인턴일지

목록 보기
1/4
post-thumbnail

인턴 한달정도 되었을 때 사수님이 간단한 기능 개발을 맡기셨다..

"우리가 만든 퍼즐 문제를 워드 파일로 추출할 수 있는 기능하나만 추가해주세요~"

AWS S3에 저장되어있는 이미지에 4 X 4 퍼즐을 그리고 사용자가 요청한 수학 문제들을 각 칸에 찍은 후 완성된 퍼즐을 다운로드 할 수 있는 기능 구현을 진행해야했다.

(퍼즐 판에 문제가 있고 답이 찍힌 그림 퍼즐을 조각조각 맞춰서 그림을 완성해가는 구조)

구현 결과 예시:

이를 위해서 S3에 있는 이미지를 가져와 4 X 4 줄을 긋고 전달받은 데이터에서 answer와 problem 텍스트를 추출해서 해당 그림 위에 찍어야했다. 이후 완성된 퍼즐 파일을 S3에 저장한 후 URL을 반환해서 유저가 접속 시 바로 다운로드 할 수 있도록 해야했다.

이를 위해서 python의 docx 라이브러리를 사용해서 메모리상의 docx 파일에 작업을 하기로 했다.

어떻게 구현해야할까 생각하던 중 가장 먼저 든 생각은 (지금 생각해보면 진짜 말도 안되는 비효율적인 생각이었던 것 같지만..)

S3의 이미지를 유저의 disc에 다운로드 시키고 해당 이미지의 로컬 경로를 가지고 와서 메모리 상의 docx 파일에 찍고 작업을 하는 방법이었다.

구상도:


S3의 이미지를 클라이언트의 local PC에 저장하는 함수와 이를 호출해서 전체 파일 추출까지 하는 함수를 구현했다.

def generate_s3_problem_file(self, problems, subject):

		#problems 매개변수가 query 형태이기 때문에 반복문으로 돌면서 작업해줘야함
        for problem in problems:
		        
			#다운로드 받을 이미지의 S3 url 지정
            upload_key = f"export/puzzle/{problem.id}.png"
            font_num = 8 #그림에 찍을 글자의 크기 지정
            
        	#S3에서 해당 이미지 client의 로컬에 다운받기
            local_image_path = self.download_image_from_s3_to_local(upload_key)
            
            draw = ImageDraw.Draw(image)
            data = problem.answer #퍼즐에 찍을 답변들을 problem 객체에서 추출

			#외부에서 정의한 CreateMathPuzzle 클래스 -> 퍼즐의 선과 전달받은 answer 텍스트를 찍는 역할
        	#퍼즐선과 answer 텍스트를 찍고 해당 퍼즐 완성본을 local_image_path에 업데이트
            puzzleDrawer = CreateMathPuzzle()
            puzzleDrawer.completion_puzzle(
                data, draw, local_image_path, font_num
            )
						
			#위에서 업데이트 된 local_image_path의 퍼즐 완성본을 미리 선언해둔 document(python_docx) 객체에 찍기
            self.document.add_picture(local_image_path, width=Inches(6))
            self.document.add_page_break()
						
			#이미지 퍼즐 조각을 맞출 퍼즐판 제작하기
        	#CreateMathPuzzle 인스턴스를 호출해 퍼즐판 제작 메서드 호출 (퍼즐판 배경만 생성)
            base_image_url = puzzleDrawer.create_puzzle_base()
            
        	#퍼즐판 svg파일 url을 png 파일로 변환
            base_image = self.fetch_image(base_image_url)
            draw = ImageDraw.Draw(base_image)
            data = problem.problem_text #퍼즐판에 찍을 문제 텍스트 추출
            font_num = 15
            
        	#완성된 퍼즐판을 client local pc에 저장(지금보면 이 짓을 왜 했지 싶다..)
            local_base_image_path = self.download_complete_base_puzzle_to_local(base_image)

			#CreateMathPuzzle 인스턴스 호출 후 퍼즐판 선이랑 problem 텍스트 찍기
            puzzleDrawer.completion_puzzle(
                data, draw, local_base_image_path, font_num, True
            )
            
        	#퍼즐판 완성본 docx 객체에 찍기
            self.document.add_picture(local_base_image_path, width=Inches(6))

		#위에서 완성한 퍼즐 docx 파일을 S3에 저장하는 부분
        byte_io = BytesIO()
        self.document.save(byte_io)
        byte_io.seek(0)  # 스트림을 처음으로 되돌림
        content_type = (
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        )
        file_id = str(uuid.uuid4())
        file_key = f"export/puzzle/{file_id}.docx"

        export_file_url = ProblemExport.upload_file_to_s3_storage(
            file_key=file_key, file_path=byte_io, content_type=content_type
        )

		#S3 url 반환 (attachment 속성으로 접속시 바로 다운로드 가능)
        return export_file_url

전체적인 흐름을 정리하자면

  1. S3의 이미지를 로컬에 다운시키기 (미리 정해둔 경로로)
  2. 서버(장고)에서 해당 로컬 경로를 전달받아 경로에 있는 이미지에 퍼즐 그리는 작업하기
  3. 완성된 퍼즐 그림 메모리 상의 docx 파일에 찍기
  4. 서버(장고)에서 퍼즐판을 위한 흰색 배경 생성 후 정해둔 local 경로로 퍼즐판 배경 다운시키기
  5. 서버에서 해당 로컬 경로를 전달받아 경로에 있는 퍼즐판 배경에 퍼즐 그리는 작업하기
  6. 완성된 퍼즐판 메모리 상의 docx 파일에 찍기
  7. 최종본을 S3에 저장 및 url 반환


결과는...

이 모든 작업을 하고 클라이언트에게 url을 줄 때까지 무려 33초가 걸렸다..

33초..?

나 같아도 오류가 난 줄 알고 꺼버릴 것 같다. 이 결과를 보고 사수님한테 한소리 들었다. 당장 고치라고. 당연한 결과라 생각하고 이 문제를 어떻게 고쳐야 할지 생각해 보았다.

컴퓨터 구조 시간에 disc i/o 작업은 굉장한 리소스와 러닝타임을 가진다고 배웠다. 그런데 이 정도로 오래걸릴 줄은 상상도 못했다.

어떻게 하면 시간을 더 줄이고 disc i/o 작업을 하지 않을 수 있을까 하며 찾아보다가 BytesIO에 대해서 알게 되었다.

BytesIO란?

이는 파이썬에서 바이트 데이터를 메모리 버퍼에서 읽고(read) 쓰는 (write) 작업을 할 수 있게 만들어주는 도구이다.

즉, 로컬 디스크에 직접 다운로드 하지 않고 그냥 메모리 상에서 바로 작업할 수 있는 것이다. 메모리는 하드 디스크 드라이브(HDD)나 심지어 솔리드 스테이트 드라이브(SSD), 즉 disc i/o 작업보다 데이터 액세스 속도가 훨씬 빠르다고 배웠다.

구상도:


이제는 클라이언트의 PC의 개입 없이 오직 메모리에서 모든 작업이 이루어진다. 코드로 구현을 해보면

s3를 로컬에 다운하는 함수 대신 BytesIO 객체로 변환하는 함수이다.
다시 전체 코드를 위 함수의 추가에 따라 수정하였다.

def generate_s3_problem_file(self, problems, subject):

		#problems 매개변수가 query 형태이기 때문에 반복문으로 돌면서 작업해줘야함
        for problem in problems:
		        
			#변환할 이미지의 S3 url 지정
            upload_key = f"export/puzzle/{problem.id}.png"
            font_num = 8 #그림에 찍을 글자의 크기 지정
            
			#S3 이미지를 BytesIO 객체로 변환
			bytes_img_memory = self.save_s3_image_to_memory(self, upload_key)
            
            draw = ImageDraw.Draw(image)
            data = problem.answer #퍼즐에 찍을 답변들을 problem 객체에서 추출

			#외부에서 정의한 CreateMathPuzzle 클래스 -> 퍼즐의 선과 전달받은 answer 텍스트를 찍는 역할
        	#퍼즐선과 answer 텍스트를 찍고 해당 퍼즐 완성본을 bytes_img_memory에 업데이트
			#이 메서드는 기존 local에서 처리하던 것을 bytesIO로 처리하게끔 따로 수정
            puzzleDrawer = CreateMathPuzzle()
            puzzleDrawer.completion_puzzle(
                data, draw, bytes_img_memory, font_num
            )
						
			#위에서 업데이트 된 bytes_img_memory의 퍼즐 완성본을 미리 선언해둔 document(python_docx) 객체에 찍기
            self.document.add_picture(bytes_img_memory, width=Inches(6))
            self.document.add_page_break()

			#파이썬은 자동으로 메모리 해제를 해주지만 명시하여 자원 해제 명확화
			bytes_img_memory.close()
						
			#이미지 퍼즐 조각을 맞출 퍼즐판 제작하기
        	#CreateMathPuzzle 인스턴스를 호출해 퍼즐판 제작 메서드 호출 (퍼즐판 배경만 생성)
            base_image_url = puzzleDrawer.create_puzzle_base()
            
        	#퍼즐판 svg파일 url을 png 파일로 변환
            base_image = self.fetch_image(base_image_url)
						
			#퍼즐판도 BytesIO 객체로 생성
			base_img_memory = BytesIO()
            base_image.save(base_img_memory, format="PNG")
            base_img_memory.seek(0)

            draw = ImageDraw.Draw(base_image)
            data = problem.problem_text #퍼즐판에 찍을 문제 텍스트 추출
            font_num = 15
            
			#CreateMathPuzzle 인스턴스 호출 후 퍼즐판 선이랑 problem 텍스트 찍기
            puzzleDrawer.completion_puzzle(
                data, draw, base_img_memory, font_num, True
            )
            
        	#퍼즐판 완성본 docx 객체에 찍기
            self.document.add_picture(base_img_memory, width=Inches(6))

			#메모리 해제 명시
			base_img_memory.close()

		#위에서 완성한 퍼즐 docx 파일을 S3에 저장하는 부분
        byte_io = BytesIO()
        self.document.save(byte_io)
        byte_io.seek(0)  # 스트림을 처음으로 되돌림
        content_type = (
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
        )
        file_id = str(uuid.uuid4())
        file_key = f"export/puzzle/{file_id}.docx"

        export_file_url = ProblemExport.upload_file_to_s3_storage(
            file_key=file_key, file_path=byte_io, content_type=content_type
        )

		#S3 url 반환 (attachment 속성으로 접속시 바로 다운로드 가능)
        return export_file_url

수정된 전체 흐름을 정리하자면

  1. S3의 이미지를 BytesIO 객체로 변환 (메모리로 이미지 로드)
  2. 서버(장고)에서 메모리 상에서 바로 퍼즐선, answer 찍기
  3. 완성된 퍼즐 그림 메모리 상의 docx 파일에 찍기
  4. 서버(장고)에서 퍼즐판을 위한 흰색 배경 생성 후 BytesIO 객체로 로드
  5. 서버에서 메모리 상에서 바로 배경에 퍼즐 그리는 작업하기
  6. 완성된 퍼즐판 메모리 상의 docx 파일에 찍기
  7. 최종본을 S3에 저장 및 url 반환

결과는...!

16초..! 나름 거의 50% 이상으로 속도를 향상 시켰지만.. 아직 너무 길다..🥲🥲 또 지적 당할 것이 분명했다..

지금 생각해보면 쉬운 문제를 너무 복잡하게 구상했다는 생각이 들었다. 그리고 CS의 중요성도 느꼈다. 데이터 접근 시간과 리소스 사용의 비효율성이 이렇게 체감될 정도였다니.. 유저가 많아지면 이런 문제 때문에 서비스의 질이 한순간에 떨어질 수도 있겠다는 생각이 들었다.
아직 CS 과목을 많이 듣지는 않았지만 복학하면 더 열심히 들어야겠다..😄😄 나름 이번 경험으로 이론으로만 듣던 disc i/o 작업의 리소스 접근 시간과 비효율성을 조금 체감할 수 있게 되었다는 것에 의의를 두고자 한다..🥹🥹

어쨋든 16초에서 더 고민해야할 필요성을 느꼈고 그 과정은 다음 포스트에서 작성하도록 하겠다…!

P.S. BytesIO를 잘 사용하고 있나 메모리 로그를 찍어본 결과..


사용량이 80 바이트에서 582804 바이트로 증강되었다! 잘 사용되고 있고만..(메모리 해제를 반드시 해주는 것을 잊지 말자🥹)

profile
개발은 그저 도구일 뿐

0개의 댓글