외부 모듈을 위한 Python Microservice 구축기 (1)

FastAPI, Celery 분산 작업 관리자를 사용한 Python 클라이언트 서버 구현하기

파이썬만 지원하는 외부 서비스의 클라이언트 모듈을 지원하기 위해 작은 마이크로서비스 하나를 띄워야 했다.

Python 은 거의 코딩테스트용 언어로 사용했던 나에게 약간의 챌린지로써 좋은 기회인것 같아 이 작업을 맡았다.

그리하여 Core Server 에서 Python 클라이언트의 작업 결과를 수신하기 위해 새로운 서비스를 구성하기로 했다.

FastAPI 웹 프레임워크를 선택했고, 선정한 이유는 다음과 같다.

  • 친절한 Docs

  • 입문하기 좋은 익숙한 구조

  • 비동기로 요청을 처리하는 ASGI 웹 서버

  • Flask 보다는 필요한 모듈이 잘 구성되어 있고, Django 보다는 가볍다는 생각

배포 파이프라인 구축

기존에도 AWS ECR, ECS (Fargate) 를 사용한 서버리스 컨테이너를 주로 사용했기 때문에, 이번에도 빠른 파이프라인 구축을 위해 사용하게 되었다.

Github Action 을 사용하여 CI 를 만들었고 Docker 빌드 후 ECR 레지스트리 등록, ECS Task 개정을 거쳐 배포하도록 했고 하루만에 Dev 배포환경을 구축했다.

마이크로서비스간 통신 하기

MSA 구조를 만드는 데 있어 꽤 고민이 드는 부분은 통신 규칙을 정하는 것이 아닐까 싶다. 우리는 Python Client 에게 데이터를 변경할 때 메시지를 전달하기로 했다. 그리고 반대로 데이터를 페칭하는 경우는 직접 호출로 처리했다.

직접 통신 방식

Spring Boot Server (OpenFeign) == Request => Python Server

파이썬 서버로 직접 데이터를 쿼리하기 위한 통신은 OpenFeign 을 사용했다.

gRPC 같은 마이크로서비스에서 사용하기 좋은 프로토콜이 있지만, 학습곡선이 있다는 점과 성능적으로 큰 차이가 없으며 인터페이스 또한 별도 구성이 필요하기 때문에 오히려 번거롭다는 점을 고려해서, 생산성이 좋은 Spring Cloud 진영의 OpenFeign 을 팀원들과 회의를 통해 합의해서 도입했다.

이벤트 드리븐 통신 방식

Spring Boot Server (Send Message) => AWS SQS => Celery Worker (Receive Message)

Python Server (Send Message) => AWS SQS => Spring Boot Server (Receive Message)

중요한 데이터의 정합성을 위해, AWS SQS FIFO Queue 를 사용해서 통신 구조를 만들었다. 작성일 기준으로 AWS SQS 만 사용이 가능했기 때문에 (제한된 AWS 크레딧을 사용하는 상황) AWS SNS 같은 pub/sub 서비스까지는 도입하지 못했지만, 추후에는 도입하더라도 서비스가 가능하도록 구현해두었다.

메시지를 수신하는 곳이 Celery Worker 인 이유는 다음에서 설명한다.

SQS 메시지 수신을 위한 Listening Worker 설계

Spring Boot 에서는 starter-sqs 라이브러리가 @SqsListener 어노테이션 기능을 제공하기 때문에 자체적인 Polling 기능이 내장되어 있어 통합이 매우 쉽지만, Python 진영에서는 이와 같은 Production Ready 라이브러리를 찾기 쉽지 않았다.

기본적인 AWS SDK 인 boto3 를 사용하면 메시지 수신을 할 수는 있지만 ”직접“ 큐에서 꺼내와야 하기 때문에, 기본적으로는 메시지가 큐에 전송된 후 목적지에서 메시지를 자동으로 Polling 하지 못한다.

따라서 @SqsListener 와 비슷한 Long-Polling 기능을 구현하기 위해 Celery 를 사용한 Worker Process 를 추가로 띄우는 작업을 수행했다.

분산 태스크 관리자로 Celery 를 선택한 이유

  1. 압도적인 Star 와 풍부한 문서 내용

  2. 비교적 입문하기 쉬움

  3. 다양한 옵션과 Task 작업을 관리하기 좋은 구조

@app.task 의 역할을 두 Celery App 에 분할해서 할당했는데, Python Server ⇒ Spring Server 로 전송해야 할 Task 는 "Sender App" 에서 전담하고, 반대로 Spring Server ⇒ Python Server 로 수신하는 Listener 는 "Worker App" 으로 만들어서 별도의 Worker Process 로 띄워 주기적으로 Queue 를 Polling 해서 메시지가 있는지 확인하고 처리하는 역할을 수행한다.

Spring Server 에서 Message Publishing 하기

Python 서버 측에서 메시지 수신을 받기 위해 Spring 측에서는 Python 서버에서 즉 Celery 에서 요구하는 메시지 Protocol 에 맞춰서 보내야 한다. 하지만 Celery 를 사용하는 어플리케이션 끼리 주고받는 상황이 일반적인 사용 사례인것으로 보여서 우리처럼 특이한, Spring 와 Celery App 과의 통신을 위한 사례가 있는지 열심히 찾아봤다.

작은 오픈소스로 몇가지 공개된 사례가 있어서 이를 바탕으로 Spring to Celery Message for SQS 메시지 생성기를 만들기로 했다. (해당 오픈소스는 RabbitMQ 위주로 작성한 것이라 사용할 수 없었다)

Celery 의 기능을 세부적으로 이용하기 위해서는 메시지의 옵션을 적절히 설정해 주어야 하지만, 꼭 필요한 정보(Queue 이름, Task 이름, ID 등)만 사용해서 생성기를 만들었다. 이렇게 하면 메시지를 생성해서 Queue 에 보내게 되고, 이를 기반으로 Polling 하고 있던 Worker 가 수신하여 각 Task 를 처리한다.

// Celery (protocol v2) Message Generator Helper Function
public static String generate(String taskName, String queueName, String payload) throws JsonProcessingException {
    return CeleryMessageProtocolV2.of(taskName, queueName, payload);
}

코드가 불필요하게 지면을 차지하기 때문에 별도의 gist 로 공유합니다.

https://gist.github.com/kwt1326/4c33dd703977f8e69eb26f98ce7be832

끝난줄 알았지만

Celery 를 사용한 분산 작업 처리는 분명 매우 효율적으로 작동한다. 하지만 사용하다 보니 치명적인 문제를 발견했는데, 위의 내용을 읽어봤을 때 아마 갸우뚱할 부분이 있을 것이다. 반대의 경우 (Python => Spring) 메시지 생성기 구현이 없는 이유는 설계상 비효율적인 부분이 발견되어서 중단했고 그것은 메시지 페이로드 인터페이스 때문이었다.

Celery 를 사용하면 요구하는 JSON 메시지 포맷을 맞춰서 보내야만 Celery 가 메시지의 내용을 분석하고 어느 Task 에 작업을 전달할 지 알 수 있다. 반면 Spring 에서의 SQS 라이브러리는 그런 인터페이스를 별도로 요구하지 않고 작업을 처리하는데 필수적인 데이터만 전달하면 되기 때문에, Spring 서버를 작업하는 개발자의 입장에서는 이 메시지 인터페이스가 매우 불편할 수 있다.

처음 Celery 로 구현할 때는 이런 인터페이스가 당연한 걸로 생각했었는데, 비교해서 분석해보니 Celery 에 강하게 결합되는 메시지가 지금 당장 사용할 수 있다고 해도 지속적인 개선에 걸림돌이 될 수 있을 것이라 판단했다.

그래서 고민 끝에, 작업한 내용은 아까웠지만 팀원들과 이 내용에 대해서 회의 후 전면적으로 수정하기로 결정했다. (2부에서 계속…)

Last updated