siino's 개발톡

프로젝트 아키텍처 리팩토링 (+비동기 통신) 본문

프로젝트

프로젝트 아키텍처 리팩토링 (+비동기 통신)

siino 2024. 3. 4. 21:51

이번에는 제가 프로젝트 때 구성했던 아키텍처의 문제점을 발견하여 리팩토링하는 과정에서 어떤 고민을 했는지, 어떤 부분에 초점을 맞춰 개선하였는지에 대한 글을 써보려고 합니다.

 

저희 프로젝트의 기존 아키텍처는 다음과 같았습니다.

기존 아키텍처

 

간단하게 각 서비스에 대해 설명을 하면,

백엔드 코어(Spring boot) 서버는 Main backend 서버로서 대부분의 API, 사용자 인증/인가 작업을 처리합니다.

객체탐지 서버(Flask)에서는 YOLO 객체 탐지모델를 통해 사용자가 촬영한 식단을 분석합니다. (메뉴 이름, 영양소)

식단 추천 서버(FastAPI)에서는 사용자의 부족 영양소 또는 입맛에 기반한 음식들을 추천해줍니다. 

 

현재는 Client에서 식단 이미지 분류 요청을 직접적으로 Flask서버에 요청 보내고 있습니다.

따라서 로그인한 사용자에 한해서 해당 기능을 이용할 수 있기 때문에 flask서버에서 추가로 JWT토큰을 검증한 이후에, 이미지 분류 AI기능을 사용할 수 있습니다.(Object Detection-yolo)

 

이 중, 제가 담당했던 부분은 백엔드 코어와 객체탐지 서버였습니다. 

이제부터 이 "객체 탐지 서버" 관련해서 집중적으로 다루려고 합니다. (이하 객체 탐지 서버 = yolo서버)

 

나는 처음에 왜 이렇게 구성했나?

1. Yolo모델을 돌리는 AI 서버이다 보니 속도가 조금 느렸습니다. 따라서 메인 백엔드 서버를 거치지 않고 조금이라도 빠르게 클라이언트에게 응답을 보내어 응답시간을 최대한 빠르게 하기 위함이었습니다. (Network latency 최소화)

2. 인증 관련 로직을 추가로 작성하더라도(JWT토큰을 검증하기 위해서 Flask에서도 JWT토큰을 검증하는 로직이 들어가야 함) 메인 백엔드 서버에 요청이 집중되지 않게 하기 위해서였습니다.

 

문제점 파악

하지만 이 방식의 문제점은 다음과 같습니다.

1. 실제 요청이 고려했던 것 만큼 많이 발생하지 않아 백엔드 서버에 요청의 부하가 걸리지 않고,

2. 만약 그렇게 많은 요청이 실제로 발생한다고 해도, scale-out을 하기 위해 인증서버와 API-gateway 서버를 따로 두어야 하기 때문에 인증관련 로직은 한 곳에서 처리하는 것이 맞다고 판단했습니다.

 

따라서 현재 아키텍처를 프론트엔드에서 직접적으로 객체탐지 서버로 요청을 보내는 것이 아닌 FE core -> BE core -> 객체 탐지 서버로 변경하는 작업을 하게 되었습니다.

 

변경 후 아키텍처

수정 아키텍처

 

**추가 개선 사항(핵심)

변경을 진행하기에 앞서 전체적인 아키텍처 구조를 다시 생각해보았습니다.

현재는 동기 방식으로 yolo서버 이미지처리를 수행하고 있는데, 이 과정이 AI 모델을 돌리는 작업이다 보니, 시간이 오래걸렸습니다. 따라서 서버의 처리량과 yolo서버의 응답속도 개선을 위해 어떤 방법이 있을지 고민했고 다음과 같은 결론을 내릴 수 있었습니다.

 

1. 스프링 -> 객체 탐지 서버는 비동기 통신을 수행한다.

2. 객체 탐지 서버는 Flask가 아닌 FastAPI로 프레임워크를 변경한다.

 

1번의 이유는 다음과 같습니다.

스프링에서 yolo서버로 비동기 통신이 아닌 동기방식으로 로직을 처리하게 되면 시간이 오래걸리는 yolo작업을 수행하는 동안 스프링 서버의 해당 스레드는 다른 작업을 수행하지 못하고 블로킹되게 됩니다.

따라서 가용할 수 있는 스레드의 양이 적어지고 이는 결과적으로 서버 전체의 처리량이 적어지게 될 것입니다.

하지만, 외부 서버와 비동기 통신을 수행하게되면 네트워크 I/O를 기다리는 동안 스레드가 블로킹되지 않고 다른작업을 처리할 수 있게 되며 이는 서버 리소스를 효율적으로 사용하면서 처리량을 향상시킬 수 있습니다.

 

2번의 이유는 다음과 같습니다.

Flask는 기본적으로 동기, 싱글스레드로 동작합니다.

제가 처음 생각했던 것은 yolo 서버의 로직이 CPU bound 작업이므로 yolo서버 내의 함수들을 비동기로 처리하는 것이 큰 효과가 없을 것이라 생각했습니다. 하지만 yolo 모델을 돌리기 위한 I/O작업(ex. 이미지 파일 저장/로드)이 있기 때문에 yolo서버에서도 비동기 메서드를 활용하는 것이 효율적일 수 있다고 판단했습니다.

 

또한 해당 프로젝트 당시, 싱글 코어 환경이 아닌 멀티 코어 서버도 제공받을 수 있었습니다.

기존의 Flask 프레임워크 즉, 동기/싱글스레드 모델을 적용한다면 멀티 코어 환경에서 큰 이점을 얻을 수 없습니다.

(동기/싱글스레드 모델을 적용한 flask 애플리케이션의 스레드는 한시점에 한개의 코어에서 동작하며 I/O작업에 의해 스레드가 자주 block되어 정작 중요한 CPU-bound Job을 수행할 수 없기 때문입니다.)

 

이에 반해, FastAPI는 비동기 프레임워크이며 ASGI규격을 따르기 때문에 --workers 명령어 옵션 하나만으로 여러 워커 프로세스 생성을 통해 멀티 코어 환경에서도 애플리케이션을 병렬로 처리할 수 있습니다.

따라서 File I/O, 이미지 처리 함수 내부의 다양한 I/O작업을 비동기로 처리하고 --workers 옵션을 통해 multicore 환경에서 cpu-bound 작업을 수행하는 구조가 훨씬 효율적인 아키텍처라고 판단했습니다.

 

Flask vs FastAPI (https://web-frameworks-benchmark.netlify.app/compare?f=flask,fastapi)

(framework의 성능을 차트를 통해 비교합니다.)

Threads: 8, timeout: 8, duration: 15 seconds

Hardware used for the benchmark:

  • CPU: 8 Cores (AMD FX-8320E Eight-Core Processor)
  • RAM: 16 GB
  • OS: Linux

즉, 동기/싱글스레드 모델을 적용할 것이 아니면 FastAPI로 바꾸는게 훨씬 효율적이다! 라는 판단을 내렸습니다.

Native로 비동기 프로그래밍을 지원하기도 하며, 속도도 우수합니다.

 따라서 기존의 Flask 대신, 내부적으로 비동기 프로그래밍을 기본적으로 지원하는 FastAPI로 yolo서버의 프레임워크를 변경하게 되었습니다.

 

수정 후 spring -> flask 요청 코드 (비동기 통신)

public Mono<List<DetectResponse>> detectImage(MultipartFile file) {
        MultipartBodyBuilder builder = new MultipartBodyBuilder();
        builder.part("image", file.getResource());

        Mono<List<DetectResponse>> responseList = webClient.post()
                                                           .uri("/detect")
                                                           .contentType(
                                                               MediaType.MULTIPART_FORM_DATA)
                                                           .body(BodyInserters.fromMultipartData(
                                                               builder.build()))
                                                           .retrieve()
                                                           .bodyToMono(
                                                               new ParameterizedTypeReference<>() {
                                                               });
        return responseList;
    }

 

최종 아키텍처

최종 아키텍처

성능 측정 및 비교

자, 그럼 성능개선 비교를 해보면서 확인해보겠습니다.

 

version 1: 기존의 아키텍처 모델 (client -> 객체탐지 서버)

version 2: client -> BE core -> 객체탐지 서버 (동기 요청/ 객체탐지 서버(Flask))

version 3: client -> BE core -> 객체탐지 서버 (비동기 요청/ 객체탐지 서버 (Flask))

version 4: client -> BE core -> 객체탐지 서버 (비동기 요청/ 객체탐지 서버 (FastAPI), I/O 작업 비동기 처리, worker 프로세스 4)

 

해당 기능의 경우 식단을 기록하는 기능이므로(아침, 점심, 저녁에 자신이 먹은 식사 촬영 후 기록)

해당 기능을 사용하는 사용자 수의 peak가 그렇게 높지 않을 것이라 판단했기 때문에 user수는 30명으로 제한했습니다.

(+ 추가로 해당 서버 CPU는 Quad core이며, AI에 특화된 GPU 서버가 아니었기 때문에 User의 수를 크게 잡을 수 없었습니다.)

t3.xlarge, 4 core, RAM: 16GB

아래는 약 2분간 user 1명에서 시작해서 30명까지의 성능 측정 그래프입니다.

 

1. version 1: 기존의 아키텍처 모델 (client -> 객체탐지 서버)

(평균 응답시간: 15s)

 

 

2. version 2: client -> BE core -> 객체탐지 서버 (동기 요청/ 객체탐지 서버(Flask))

(평균 응답시간 13s)

 

 

3. version 3: client -> BE core -> 객체탐지 서버 (비동기 요청/ 객체탐지 서버 (Flask))

(평균 응답시간 12s)

 

 

4. version 4: client -> BE core -> 객체탐지 서버 (비동기 요청/ 객체탐지 서버 (FastAPI), I/O 작업 비동기 처리, worker 프로세스 4)

(평균 응답시간 7s)

 

User수 30명의 요청에 대해서 평균 응답속도 7.5초로 측정이 되었습니다.

해당 응답속도가 빠르다고 할 순 없지만, 한정된 자원 내에서 모델을 변경하지 않고 아키텍처의 구조 변경만으로 기존 15s에서 7.5s로 개선되었고, 이는 약 50%의 성능 향상이었습니다.

 

응답속도를 더 최적화하기 위해서는 GPU 서버 사용, scale-out과 로드밸런싱, scale-up 등이 있을 수 있습니다. 하지만, 이렇게 한정된 자원 내에서도 성능 최적화를 진행하는 것이 백엔드 엔지니어의 진정한 역할과 책임이 아닐까 생각합니다:)

 

프로젝트 전반적인 성능 향상을 위해 전체 아키텍처에 대한 고민을 해보면서 정말 많은 것을 배우고 느낄 수 있었습니다.

서비스 유저 수, 프레임워크의 장단점 및 특징, 동기/비동기 프로그래밍, 멀티스레드, 멀티 프로세스, CPU bound / IO bound .. 등 이론적으로만 공부했던 개념들을 실제로 프로젝트에 적용하며 다시끔 공부하고 체득할 수 있었고, 성능향상을 위해 여러 요소를 복합적으로 생각하고 고려해야함을 느낄 수 있었습니다.

 

긴 글 봐주셔서 감사합니다 :)