요기요 기술블로그에 작성한 글입니다.
개요
2022년 12월부터 CPS(Cost Per Sale)기반 신규 광고 시스템이 도입되었는데, 서비스를 운영하는 과정에서 발생한 퍼포먼스 이슈와 그에 대한 해결 사례를 간단하게 소개해 드리고자 합니다.
배경 지식
사례를 소개해 드리기 전 배경지식을 먼저 말씀드리겠습니다. 실제로는 아래에 설명드리는 로직 외에도 추가적인 복잡한 로직이 존재하지만, 설명을 위해 본 포스팅의 주제에 부합하는 핵심 로직들만 설명드린 점 감안해 주시길 바랍니다.
신규 광고 시스템의 경우 헥사고날 아키텍처를 기반으로 이루어져 있고, 각 애그리거트(Aggregate)에 속해있는 유즈케이스는 저마다의 역할에 충실하도록 구현되어 있는 상태였습니다.
예를 들어 광고/레스토랑/캠페인이라는 3개의 애그리거트가 존재한다고 가정하겠습니다. 저희는 최초 이벤트 스토밍(Event Storming)을 통해 바운디드 컨텍스트(Bounded Context)를 도출할 당시 레스토랑을 하나의 애그리거트로 분리해뒀습니다. 따라서 레스토랑 목록을 조회하는 책임은 레스토랑 애그리거트에 존재하며, 광고 애그리거트의 경우 직접 레스토랑 목록을 구할 수 없기에 레스토랑 애그리거트에 요청하여 받아오는 과정이 필요합니다.
캠페인을 조회하는 책임 또한 캠페인 애그리거트에 존재하기에 해당 애그리거트에 요청을 보내서 캠페인 목록을 받아오는 과정이 필요합니다. 이러한 로직에 대해 조금 더 자세하게 살펴보겠습니다.
첫 번째 로직은 현재 위치에 기반한 배달 가능한 레스토랑 목록을 가져오는데, 이를 간단하게 설명하면 아래와 같습니다.
- 광고 송출 유즈케이스는 Output Port를 통해 레스토랑 애그리거트에 레스토랑 조회 요청을 보냅니다.
- 해당 요청은 레스토랑 애그리거트의 Input Port를 통해 수신되며, 구현체인 유즈케이스가 요청을 받습니다.
- 레스토랑 조회 유즈케이스에서는 Output Port를 통해 레스토랑 조회 요청을 보냅니다.
- 어댑터는 실제 레스토랑을 담당하는 MS에 요청을 보내고 받아온 응답을 통해 레스토랑 목록을 반환합니다.
광고 송출 유즈케이스의 경우, 현재 위치에 기반한 배달 가능 레스토랑 목록이 필요합니다. 이를 위해 레스토랑 애그리거트에는 송출 가능한 레스토랑 목록을 응답하는 get_available_restaurants() 라는 이름의 메서드가 존재합니다.
두 번째 로직은 실제 광고를 송출할 수 있는지 캠페인을 통해 걸러내는 작업입니다.
- 광고 송출 유즈케이스는 배달 가능한 레스토랑 목록을 담아 Output Port를 통해 캠페인 애그리거트에 캠페인 조회 요청을 보냅니다.
- 해당 요청은 캠페인 애그리거트의 Input Port를 통해 수신되며, 구현체인 use case가 요청을 받습니다.
- 캠페인 조회 유즈케이스는 Output Port를 통해 캠페인 조회 요청을 보냅니다.
- 어댑터는 캠페인 DB를 조회하여 받아온 응답을 통해 캠페인 목록을 반환합니다.
- 광고 송출 유즈케이스는 4번 캠페인 목록을 통해 실제 광고 송출이 가능한 레스토랑 목록을 필터링합니다.
위 로직을 기반으로 몇가지 튜닝 사례를 소개해드리겠습니다.
튜닝 사례
첫 번째로 다수의 O(N) 코드를 제거함으로써 성능을 향상 시킨 사례입니다. 배경 지식에서 첫 번째 로직을 통해 레스토랑 목록을 가져온다고 했습니다. 그리고 두 번째 로직을 통해 캠페인을 조회하고 광고 송출이 가능한 상태의 레스토랑만을 필터링합니다.
이를 코드로 살펴보면 다음과 같습니다.
async def execute(self, *, request_dto: GetAdsRequestDTO):
# 위치 기반 배달 가능 레스토랑 목록 조회
restaurants: list[Restaurant] = await self.output_port.get_available_restaurants(
lat=request_dto.lat, lng=request_dto.lng,
)
restaurant_ids = [restaurant.id for restaurant in restaurants]
# 레스토랑ID 기반 캠페인 목록 조회
campaigns: list[Campaign] = await self.output_port.get_available_campaigns(
restaurant_ids=restaurant_ids,
)
# {레스토랑ID: 레스토랑} Map
restaurant_maps: dict[int, Restaurant] = {
restaurant.id: restaurant
for restaurant in restaurants
}
# {레스토랑ID: 캠페인} Map
restaurant_id_campaign_maps: dict[int, Campaign] = {
campaign.restaurant_id: campaign
for campaign in campaigns
}
# 캠페인을 가지고 있는(광고 송출 가능한) 레스토랑만 필터링
available_restaurants: list[Restaurant] = []
for restaurant_id in restaurant_id_campaign_maps.keys():
available_restaurants.append(restaurant_maps[restaurant_id])
레스토랑/캠페인 애그리거트에 각각 요청하여 레스토랑 리스트와 캠페인 리스트를 반환받습니다. 그리고 해당 리스트 들을 통해 광고 송출이 가능한 상태의 레스토랑만을 필터링하기 위해 내부적으로 Map을 생성하고 활용합니다. 쉽게 생각해서 어플리케이션 조인을 사용한다고 보면 되는데요. 이 과정에서 Iteration이 발생하게 됩니다.
대다수의 경우 단건이므로, 성능 저하가 발생하지 않습니다. 하지만 현재 다루고 있는 레스토랑 리스트의 경우 몇천 건의 데이터를 다루고 있고 내부적으로 무거운 객체 변환 과정을 동반하고 있기에, 이러한 Iteration은 성능 저하의 요인이 될 수 있습니다. 따라서 기존 레스토랑 애그리거트는 레스토랑 리스트만을 반환받았지만, 어플리케이션 조인에 사용할 레스토랑 ID Map도 같이 생성하여 반환하도록 수정해 줬습니다. 이러한 과정을 통해 O(N) 코드를 제거할 수 있었고, 실제 사용 시 O(1)로 변경되는 효과를 가져갔습니다.
async def execute(self, *, request_dto: GetAdsRequestDTO):
# 위치 기반 배달 가능 레스토랑 정보 조회
restaurants_info: RestauransInfo = await self.output_port.get_available_restaurants_info(
lat=request_dto.lat, lng=request_dto.lng,
)
# 레스토랑ID 기반 캠페인 목록 조회
campaigns: list[Campaign] = await self.output_port.get_available_campaigns(
restaurant_ids=restaurants_info.restaurant_ids,
)
# {레스토랑ID: 캠페인} Map
restaurant_id_campaign_maps: dict[int, Campaign] = {
campaign.restaurant_id: campaign
for campaign in campaigns
}
# 캠페인을 가지고 있는 레스토랑만 필터링
available_restaurants: list[Restaurant] = []
for restaurant_id in restaurant_id_campaign_maps.keys():
available_restaurants.append(restaurants_info.restaurant_maps[restaurant_id])
코드를 보면 레스토랑 리스트를 조회하는 메서드의 이름은 get_available_restaurants() 입니다. 네이밍만 보고 반환값을 정확히 유추할 수 있을까요? 대다수의 사람들은 레스토랑 리스트만을 리턴하는 메서드라고 생각할 것입니다.
따라서 기존 네이밍 또한 get_available_restaurants_info() 로 바꿔줌으로써 여러 가지 정보를 포함한다는 의미를 가지도록 변경하였습니다. 물론 info라는 네이밍 또한 모호하다고 생각합니다. 다만, 전자의 네이밍을 봤을 때는 유추할 수 없는 추가적인 정보를 가지고 있다고 판단할 수 있습니다.
각 레이어와 유즈케이스의 책임을 정하는 건 쉬운 일이 아니라고 생각합니다. 위 사례에서 레스토랑 ID Map을 생성하는 과정은 레스토랑 애그리거트의 책임이 아니라고 볼 수도 있습니다. 그렇기에 저희 또한 최초에는 광고 송출 유즈케이스에서 해당 작업을 진행해 주었는데요. 다만 성능적인 이점을 극대화해야 할 때, 어느 정도 규약에 대한 완화는 필수불가결한 요소가 아닐까 싶습니다.
본 예제에서는 하나의 케이스만 다뤘지만, 실제로 다수의 O(N)이 발생하는 코드가 존재하였고 적절한 책임의 분산을 통해 latency 150ms 정도 감소하는 효과를 가져갈 수 있었습니다.
두 번째로 객체 변환에 대한 이야기입니다. 요기요는 현재 다양한 MS들로 구성되어있으며 각 서비스별로 담당하는 도메인이 존재합니다. 레스토랑 데이터 또한 담당하는 MS가 존재하기 때문에 광고 서버에서 해당 데이터를 사용하려면, 외부 통신을 통해 레스토랑 데이터를 받아오는 작업을 수행해야 합니다.
저희는 외부에서 받아온 데이터를 내부에서 안전하게 사용하기 위해 Pydantic을 통해 객체화 시키고 있습니다. Pydantic 라이브러리는 타입 체크, validator를 통한 hook 등 강력한 기능을 제공하지만 그만큼 Pure 클래스보다 성능 저하가 발생할 수밖에 없습니다.
FastAPI에서 기본적으로 Pydantic을 채택하여 사용하고 있었고 대다수의 경우 해당 라이브러리를 사용하여도 성능 저하를 느끼는 경우는 없었습니다만, 이번 케이스의 경우 수천 개에 달하는 객체를 변환하는 과정이 수반되기에 성능에 대한 아쉬움을 겪었습니다.
따라서 해당 로직에서 Pydantic을 제거하고 파이썬에서 기본적으로 제공해 주는 dataclass를 사용하기로 했는데요. 그 과정에서는 다음과 같은 이유가 있었습니다.
- 레스토랑 데이터는 외부에서 가져오는 데이터이기 때문에 정해진 인터페이스 규약이 존재하고, 우리가 컨트롤할 수 없다.
- 따라서 Pydantic을 통한 타입 체킹 중요도가 비교적 낮다.
- 슬라이스, 통합 테스트 등을 통해 사전에 검증할 수 있다.
위 과정을 통해 Pydantic → dataclass로 변경하였고 latency 100ms 정도 감소에 더불어서 CPU/Memory 사용량 또한 감소하는 효과를 볼 수 있었습니다.
세 번째로 레이어의 책임에 대한 이야기입니다. 두 번째 과정에서 설명드렸듯이 배달 가능한 레스토랑 목록의 경우 외부 MS에 요청해서 데이터를 받아옵니다.
그리고 어댑터는 받아온 데이터를 내부적으로 정의한 도메인으로 변환하여 반환합니다. 이러한 과정에서 대량의 객체 변환이 일어나며 이로 인해 CPU Bound → 쓰레드 블락킹(Thread Blocking)으로 이어져 성능 저하가 발생되고 있었습니다.
그렇기에 저희는 해당 로직에서만 아키텍처의 주요 원칙을 어느 정도 무시하고 데이터 변환 과정 없이 받아온 상태 그대로 타 레이어로 전파하기로 결정했습니다. 이를 통해 객체 변환으로 인한 CPU Bound 및 O(N) 코드를 또 한 번 제거할 수 있었습니다.
이외에도 본문에서는 다루지 않았지만, GC Threshold 값 조정 등 내부적으로 코드 최적화 과정이 있었고 성공적으로 응답시간 최적화를 수행할 수 있었습니다.
마치며
이번에 소개 드린 내용은 기술적으로 어려운 과정은 아닙니다. 다만 특정 아키텍처가 가지고 있는 규약과 팀 내부적으로 정한 컨벤션 사이에서 어떻게 절충안을 가져갈지 결정하는 과정은 쉬운 과정은 아니었습니다.
클린 코드를 위해 널리 알려진 아키텍처를 도입하는 건 좋지만 해당 아키텍처에 너무 매몰되진 않았으면 합니다. “은 탄환은 없다”라는 말이 있듯이 현재의 상황에 맞춰 최선의 해결 방법을 도출해 나가는 게 저희 엔지니어의 역할이라고 생각합니다. 본 포스팅이 여러분의 코드에 조금이나마 도움이 됐으면 하는 바람으로 글을 마칩니다. 감사합니다.
'Coding > 설계 | 경험' 카테고리의 다른 글
MSA 환경에서 선착순 쿠폰 발급 설계해보기 (0) | 2024.08.14 |
---|---|
SQLAlchemy와 AsyncIO를 사용할 때 발생한 문제점 (0) | 2022.11.17 |
동시성을 고려한 쿠폰 재고 시스템 설계 (0) | 2022.07.28 |
FastAPI에서 SQLAlchemy Session 다루는 방법 (1) | 2022.03.23 |
당근 서버 밋업 1회 정리 (0) | 2021.12.21 |