회원가입

MAU 600만 서비스의 쿠폰함 성능 개선기: 백엔드 개발자의 고군분투

Beany 2024-08-24

ChatGPT 요약

이 블로그 글은 대규모 서비스의 쿠폰 시스템 성능 개선에 대한 내용을 다루고 있습니다. 쿠폰함 성능 저하의 원인과 해결과정을 다루며, 쿠폰 사용자 경험을 개선하기 위한 TO-BE 쿠폰함 디자인에 대해 설명하고 있습니다. 주요 내용은 성능 저하의 근본 원인 파악, 유지보수성 고려한 설계, 그리고 성능 최적화의 중요성을 강조하고 있습니다. 유연한 검증 기능을 지원하고자 전략 패턴을 도입했으며, 데이터를 한 번에 효율적으로 가져와서 검증하여 성능을 개선했습니다. 이를 통해 시스템의 안정성과 사용자 경험을 향상시키는 데 성공했다는 결론을 이끌어내고 있습니다.

서론


오늘은 MAU 600만의 대규모 서비스에서 쿠폰 시스템을 담당하고 있는 4년차 백엔드 개발자로서, 제가 경험한 '쿠폰함 성능 개선' 여정에 대해 공유하고자 합니다.

대규모 서비스의 성장 과정에서 흔히 마주치게 되는 성능 이슈, 특히 오래된 시스템에서 발생할 수 있는 실제 문제와 그 해결 과정을 상세히 다뤄보려고 합니다.

서비스가 성장하고 복잡해지면서, 한때 안정적이었던 쿠폰함 시스템에도 점차 성능 이슈가 드러나기 시작했습니다.

이 글에서는 그 원인을 심층적으로 분석하고, 문제 해결을 위해 채택한 접근 방식을 단계별로 상세히 설명하겠습니다.

 

 

문제점: 쿠폰함 성능 저하의 근본 원인


쿠폰함 성능 개선 프로젝트에 뛰어들기 전, 제가 직면한 주요 문제점들을 명확히 파악하는 것이 중요했습니다.

분석 결과, 다음 세 가지 요인이 복합적으로 작용하여 성능 저하를 초래했음을 확인했습니다:

  1. 비즈니스 요구사항 변화: 고객 경험(UX) 전략의 변화에 따라 쿠폰 정책이 지속적으로 수정되었습니다. 이는 시스템에 새로운 부하를 가져왔습니다.
  2. 복잡해진 쿠폰 검증 로직: 쿠폰의 사용 조건과 제약 사항이 다양해졌고, 이로 인해 처리 로직이 복잡해졌습니다.
  3. 사용자당 쿠폰 수 증가: 장기 사용자들의 경우 쿠폰이 지속적으로 누적되는 현상이 발생했고, 이는 쿠폰함 조회 시 성능에 큰 영향을 미쳤습니다.

이러한 요인들이 상호작용하면서, 한때 안정적이었던 쿠폰함 시스템은 결국 성능의 한계에 도달하게 되었습니다. 이제 이 문제들을 어떻게 해결해 나갔는지 자세히 살펴보도록 하겠습니다.

 

AS-IS 쿠폰함

저희 서비스의 쿠폰함 크게 두 가지 상황에서 사용되었습니다:

  1. 마이페이지의 쿠폰함: 사용자가 자신의 모든 쿠폰을 확인할 수 있는 공간입니다. 여기서 사용자는 보유한 쿠폰의 전체 목록, 각 쿠폰의 상세 정보, 유효기간 등을 확인할 수 있었습니다.
  2. 주문 과정에서의 쿠폰함: 실제 주문을 진행하면서 적용 가능한 쿠폰을 선택하기 위해 사용되는 쿠폰함입니다. 사용자는 여기서 쿠폰들을 확인하고 선택할 수 있었습니다.

초기 설계 단계에서 이 두 쿠폰함의 UI/UX가 동일했다는 것입니다. 즉, 사용자는 마이페이지에서 쿠폰을 확인할 때나 주문 과정에서 쿠폰을 선택할 때 거의 같은 인터페이스를 경험했습니다.

 

주문 진행 중 쿠폰을 적용하고자 할 때, 사용자 경험은 다음과 같았습니다:

  1. 사용자가 쿠폰함을 열어 보유 중인 쿠폰 목록을 확인합니다.
  2. 사용하고자 하는 쿠폰을 선택합니다.
  3. 시스템이 해당 쿠폰의 사용 가능 여부를 즉시 검증합니다.
  4. 만약 선택한 쿠폰이 특정 조건으로 인해 사용할 수 없는 경우, 시스템은 구체적인 사유를 담은 에러 메시지를 사용자에게 표시합니다.

이러한 방식은 사용자에게 즉각적인 피드백을 제공하여 왜 특정 쿠폰을 사용할 수 없는지 명확히 이해할 수 있게 해주었습니다. 그러나 이 접근법에는 UI/UX 측면으로 한계가 있었습니다:

사용자는 여러 쿠폰 중 어떤 쿠폰이 현재 주문에 실제로 적용 가능한지 한눈에 파악할 수 없었습니다. 사용 가능한 쿠폰을 찾기 위해서는 각 쿠폰을 일일이 클릭하여 확인해야 했죠.

 

TO-BE 쿠폰함

사용자들이 여러 쿠폰 중에서 현재 주문에 실제로 적용 가능한 쿠폰을 확인하는 데 어려움을 겪고 있었습니다. 이 문제를 해결하기 위해, "주문 과정에서의 쿠폰함" UI/UX를 전면 개선했습니다.

이제 사용자가 쿠폰함에 들어가면, 사용 가능한 쿠폰은 가장 위에 정렬되며 기존처럼 쉽게 클릭할 수 있지만, 사용 불가능한 쿠폰은 배경이 비활성화되어 한눈에 구분할 수 있습니다.

또한, 해당 쿠폰이 왜 사용 불가능한지에 대한 명확한 정보도 함께 제공되도록 수정했습니다.

이러한 개선을 통해 사용자는 더 빠르고 직관적으로 주문에 적용 가능한 쿠폰을 확인할 수 있게 되었습니다.

그러나 이러한 개선이 이루어지면서 문제가 발생했습니다. 기존에는 사용자가 쿠폰을 클릭하면 그 쿠폰이 왜 사용 불가능한지 알 수 있었지만, 이제는 모든 쿠폰에 대해 주문에서 사용할 수 있는지 사전에 확인해야 하는 상황이 된 것입니다.

결국, 각 쿠폰이 사용 가능한지 여부를 검증하기 위해 총 N 번의 검증을 수행해야 했고, 이로 인해 전체 시스템의 부담이 크게 증가했습니다. 이 과정에서 성능 저하가 발생할 수밖에 없었던 것이죠.

잠시 다른 이야기를 해보겠습니다.

기존에는 사용자가 평균적으로 약 10개의 쿠폰을 가지고 있었습니다. 하지만 엎친데 덥친 격으로, "가게쿠폰" 유형의 쿠폰 생성으로 가사용자가 직접 여러 가게에서 쿠폰을 다운로드 받을 수 있는 횟수가 증가하며 사용자들의 쿠폰함에 저장된 쿠폰의 수가 기하급수적으로 증가하게 되었습니다. 이로 인해 앞서 설명한 N 번의 검증 로직이 더욱 자주 실행되면서 시스템의 성능이 크게 저하되었습니다.

결국, 쿠폰함에 쿠폰이 너무 많아지면 사용자가 쿠폰을 주문에 제대로 활용할 수 없을 정도로 심각한 성능 문제가 발생하게 되었습니다. 이 문제는 단순한 불편함을 넘어, 실제 사용자 경험에 큰 영향을 미치는 상황을 초래했습니다.
(하루에 CS 2번 이상 오는 경우가 있어 쿠폰함을 초기화 해주는 경우도 생겼습니다.)

이제 더 이상 이 이슈를 미룰 수 없었습니다. 사용자가 이러한 불편한 경험을 지속하면 우리 서비스를 떠날 가능성이 높아지기 때문에, 빠른 대응이 절실히 필요했습니다.

하지만 이 문제는 단순히 몇 가지 수정으로 해결될 수 있는 간단한 이슈가 아니었습니다. 상황이 복잡했기 때문에, 문제를 해결하기 위해 단기적인 접근과 장기적인 해결 방안을 나누어 고민하기로 했습니다.

단기적으로는 현재 가장 큰 문제를 빠르게 해결할 수 있는 방법을 찾아내어 즉각적인 조치를 취했습니다. 동시에, 장기적으로는 이 문제가 다시 발생하지 않도록 시스템을 근본적으로 개선하는 방안을 마련했습니다.

 

 

해결 과정


해결하면서 겪은 Transaction 개선 과정 시각화

 

개선 전: 평균 512 ms

개선 후: 평균 165 ms

  • [단기적 해결 방법] 많은 쿠폰을 가지고 있는 사용자 대응
    • 2023-08-03: 검증에 필요한 가게쿠폰 제외
  • [장기적 해결 방법] 쿠폰 검증 로직 & 쿠폰함 리팩토링
    • 2023-09-22: 쿠폰함 쿠폰 조회 리팩토링
    • 2023-10-10: 리팩토링 1단계 완료
    • 2023-10-17: 리팩토링 2단계 완료

 

자세한 개선 과정

이 문제를 순차적으로 해결하기 위해 두 가지 주요 단계를 거쳤습니다.

첫 번째 단계는 "많은 쿠폰을 가지고 있는 사용자 대응"에 대한 문제를 해결하는 것이었고, 두 번째 단계는 "쿠폰 검증 로직 & 쿠폰함 리팩토링"이었습니다. 그러나 쿠폰 검증 로직과 쿠폰함 리팩토링 작업은 시간이 많이 소요되며, 성급하게 처리할 경우 쿠폰 시스템 전체에 장애가 발생할 위험이 있었습니다. 따라서 이 부분은 장기적인 관점에서 개선하기로 결정했습니다.

우리는 먼저 "많은 쿠폰을 가지고 있는 사용자 대응" 문제를 신속하게 해결하는 데 집중했습니다. 이 접근 방식을 통해 긴급한 성능 문제를 완화하고, 사용자가 더 나은 경험을 할 수 있도록 조치를 취했습니다.

 

[ 많은 쿠폰을 가지고 있는 사용자 대응 ]

개선 전: 평균 550 ms

개선 후: 평균 350 ms

앞서 언급한 것처럼 "가게쿠폰" 유형의 쿠폰 생성으로 인해 사용자들의 쿠폰함에 쿠폰이 기하급수적으로 증가하게 되었습니다. 하지만 저희 서비스는 기본적으로 한 가게에서 주문이 이루어지는 구조이기 때문에, 다른 가게의 "가게쿠폰"을 굳이 보여줄 필요가 없다는 점을 깨달았습니다.

따라서 우선적으로, 사용자가 주문 시 쿠폰함에서 쿠폰을 선택하려고 할 때, 사용 불가능한 가게의 "가게쿠폰"은 검증 과정에서 제외되도록 로직을 수정했습니다. 이 작업은 위에서 언급한 2023-08-03 에서 완료되었으며, 이를 통해 평균 API 응답 속도가 기존 550ms에서 350ms로 크게 개선되었습니다.

이 개선 덕분에 하루에 두 번 이상 발생하던 고객 지원 요청(CS)이 사라졌습니다. 문제를 일시적으로 해결했지만, 언제든지 쿠폰이 많이 제공될 수 있는 상황이 발생할 수 있기에 이 방법은 임시적인 대처일 뿐이었습니다. 결국, 근본적인 해결을 위해 "코드 검증 로직 & 쿠폰함 리팩토링"이 필요했습니다.

특히, 서비스 리뉴얼이 예정되어 있었기 때문에, 리뉴얼 이후에는 사용자 쿠폰함에 가게쿠폰 외에도 다양한 일반 쿠폰들이 추가될 가능성이 높았습니다. 이를 염두에 두고, 장기적으로 안정적인 시스템을 구축하기 위한 개선이 필수적이었습니다.

 

[ 코드 검증 로직 & 쿠폰함 리팩토링 ]

개선 전: 평균 350 ms

개선 후: 평균 165 ms

지금까지 설명한 내용을 바탕으로, 현재 쿠폰함 로직이 어떤 상황인지 그림으로 표현해보겠습니다.

쿠폰함에서 쿠폰을 검증할 때, 각 쿠폰에 대해 동일한 검증 과정이 반복됩니다. 예를 들어, 쿠폰 A에 대해 모든 데이터를 조회하는 방식은 쿠폰 B, C 등 다른 모든 쿠폰에도 똑같이 적용됩니다.

이러한 방식으로 쿠폰이 사용 가능한지 여부를 판단하는 과정에서, 만약 해당 쿠폰을 사용할 수 없다면 그 정보를 즉시 비활성화 하면서 사용자에게 알려줍니다.

하지만 이러한 데이터 조회 기법으로 인해 여러 문제가 발생했습니다. 각 쿠폰에 대해 동일한 검증이 이루어지면서, DB에 다수의 쿼리문이 전송되는 것은 물론, 타 MS와의 통신도 빈번하게 일어나는 상황이 되었습니다. 이는 결국 시스템 성능 저하의 주요 원인 중 하나로 작용했습니다.

(어떤 사용자는 쿠폰함에 쿠폰을 900 장 이상 가지고 있어서 쿠폰함이 앱에서 보이지 않는 경우가 발생했습니다.)

 

> 이슈가 있던 AS-IS 코드

쿠폰의 검증 로직이 어떻게 작동했는지를 간단한 코드로 설명해보겠습니다. 아래 코드는 기존에 문제가 되었던 로직을 간략히 표현한 것입니다:

from pydantic import (
    BaseModel,
    Field,
)


class Coupon(BaseModel):
    id: int = Field(description='Coupon id')
    title: str = Field(description='Coupon title')
    description: str = Field(description='Coupon description')
    only_for_identified: bool = Field(description='Coupon only for identified')
    specific_cities: list[str] = Field(description='Coupon specific cities')

    def is_valid(self, user_id: int, order_city: str):
        if self.only_for_identified and not get_user_identity(user_id):  # 예) 타 MS 호출
            return False, 'This coupon is only for identified users.'

        if self.specific_cities and order_city not in self.specific_cities:  # 예) 데이터 베이스 조회
            return False, 'This coupon is not available in your city.'

        return True, ''

위와 같이 이렇게 쿠폰이 있다고 가정합시다.

이 코드에서 Coupon 클래스는 다음과 같은 속성을 가지고 있습니다:

  • id: 쿠폰의 고유 ID입니다.
  • title: 쿠폰의 제목입니다.
  • description: 쿠폰에 대한 설명입니다.
  • only_for_identified: 본인인증을 완료한 사용자만 사용할 수 있는 쿠폰인지 여부를 나타냅니다.
  • specific_cities: 특정 도시에서만 사용할 수 있는 쿠폰인지 여부를 나타내며, 해당 도시들의 리스트를 포함합니다.

is_valid 메서드는 사용자가 쿠폰을 사용할 수 있는지 검증하는 로직을 포함하고 있습니다. 이 메서드는 다음 두 가지 주요 조건을 확인합니다:

  1. 본인인증 여부: 쿠폰이 본인인증을 요구하는 경우, 사용자가 본인인증을 완료했는지 확인합니다. 이 과정에서 외부 서비스 호출이 발생할 수 있습니다.

  2. 특정 도시 여부: 쿠폰이 특정 도시에 한정된 경우, 사용자가 주문하려는 도시가 해당 도시에 포함되는지 확인합니다. 이 과정에서는 데이터베이스 조회가 필요할 수 있습니다.

이렇게 현재의 쿠폰 검증 로직은 각 쿠폰에 대해 위의 조건을 개별적으로 확인하면서, 다수의 DB 조회 및 외부 서비스 호출이 발생하게 됩니다. 이로 인해 성능 저하가 발생할 수 있으며, 특히 많은 수의 쿠폰이 있을 때 문제가 더 심각해집니다.

 

예를 들어, a 유저의 쿠폰함에 다음과 같은 3개의 쿠폰이 있다고 가정해보겠습니다.

a_user_coupon_box = [
    Coupon(
        id=1,
        title='coupon1',
        description='coupon1 description',
        only_for_identified=True,
        specific_cities=['Seoul', 'Busan'],
    ),
    Coupon(
        id=2,
        title='coupon2',
        description='coupon2 description',
        only_for_identified=False,
        specific_cities=[],
    ),
    Coupon(
        id=3,
        title='coupon3',
        description='coupon3 description',
        only_for_identified=True,
        specific_cities=['Seoul'],
    ),
]
  • 쿠폰 1: 본인인증이 필요하며, 'Seoul', 'Busan' 지역에서만 사용 가능한 쿠폰
  • 쿠폰 2: 본인인증이 필요하지 않으며 모든 지역에서 사용 가능한 쿠폰
  • 쿠폰 3: 본인인증이 필요하며, 'Seoul'에서만 사용할 수 있는 쿠폰

 

이제 a 유저가 'Busan'에서 주문을 진행한다고 가정해보겠습니다. 이러한 상황에서 쿠폰이 실제로 사용 가능한지 검증하는 로직을 작성해보겠습니다.

validated_coupons = []

for a_user_coupon in a_user_coupon_box:
    is_valid, message = a_user_coupon.is_valid(user_id=100, order_city='Seoul')
    validated_coupons.append(
        {
            'coupon': a_user_coupon,
            'is_valid': is_valid,
            'message': message,
        }
    )

이런 식으로 N번에 걸쳐 호출이 이루어지게 됩니다.

지금은 간단한 코드로 작성했지만, 실제로는 이러한 if 문이 20개 이상 존재하며, 각 쿠폰을 검증할 때마다 최대 20개 이상의 DB 쿼리가 실행되었습니다. 여기에는 타 MS와의 통신도 포함되어 있습니다.

 

성능을 개선하기 위해 페이지네이션을 도입하고 싶어도, 앞서 설명한 것처럼 사용자 경험을 최적화하기 위해서는 모든 쿠폰을 한 번에 검증해야 했습니다. 사용자가 보유한 모든 쿠폰 중에서 현재 사용할 수 있는 쿠폰을 가장 상단에 노출시키는 로직이 필요했기 때문에, 페이지네이션을 적용하는 것이 의미가 없어지게 되었습니다.

validated_coupons.sort(key=lambda x: x['is_valid'], reverse=True)

 

현재 쿠폰함에서는 쿠폰이 사용 가능한지 확인하기 위해 다양한 조건을 모두 검증하고 있습니다. 그러나 이 검증 로직은 쿠폰함 외의 다른 곳에서도 사용되고 있습니다. 각 상황에 따라 필요한 검증 조건이 달라질 수 있습니다.

예를 들어, A라는 기능에서는 사용자가 있는 특정 도시에서만 쿠폰이 사용 가능하도록 설정되어 있어야 하지만, 이 조건을 굳이 확인하지 않고 넘어가는 경우도 있습니다. B라는 기능에서는 본인 인증이 완료된 사용자만 쿠폰을 사용할 수 있도록 설정되어 있지만, 이 조건을 무시하고 다른 조건만 검증할 수도 있습니다. 반면에, C라는 기능에서는 모든 조건을 빠짐없이 검증해야 할 때도 있습니다.

즉, 상황에 따라 특정 검증 로직을 적용하거나 생략하는 방식으로, 검증이 유연하게 달라지는 것입니다.

 

현재와 같은 코드 구조에서는 if 문이 여기저기 중복되어 난잡하게 작성될 수밖에 없습니다.

class Coupon(BaseModel):
    ...

    def is_valid(self, user_id: int, order_city: str, section: str = None):
        if not section == 'B':  # A, C 에서 본인인증 확인
            if self.only_for_identified and not get_user_identity(user_id):  # 예) 타 MS 호출
                return False, 'This coupon is only for identified users.'

        if not section == 'A':  # B, C 에서 사용 지역 확인
            if self.specific_cities and order_city not in self.specific_cities:  # 예) 데이터 베이스 조회
                return False, 'This coupon is not available in your city.'

        return True, ''

또는 a, b, c와 같은 조건별로 별도의 메서드를 만들어야 할 것입니다.

class Coupon(BaseModel):
    ...

    def is_a_valid(self, user_id: bool, order_city: str):
        if self.only_for_identified and not user_identified:
            return False, 'This coupon is only for identified users.'
        return True, ''

    def is_b_valid(self, order_city: str):
        if self.specific_cities and order_city not in self.specific_cities:
            return False, 'This coupon is not available in your city.'
        return True, ''

    def is_c_valid(self, user_id: bool, order_city: str):
        if self.only_for_identified and not get_user_identity(user_id):
            return False, 'This coupon is only for identified users.'

        if self.specific_cities and order_city not in self.specific_cities:
            return False, 'This coupon is not available in your city.'
        return True, ''

이러한 이유로 인해 유지보수가 어려워지고, 엄청난 중복 코드가 발생하여 관리에 많은 어려움이 따릅니다. 결국, 이런 문제들로 인해 다양한 이슈가 지속적으로 발생하고 있습니다.

 

하지만 이러한 문제들을 해결하기 위해 전략 패턴 / Lazy Evaluation 패턴 을 도입할 수 있었습니다.

현재 가장 큰 문제는 하나의 쿠폰에 대해 N개의 쿼리와 MS 호출을 통해 정보를 가져와야 한다는 점입니다. 이를 해결하기 위해 이제는 N개의 데이터를 한 번에 효율적으로 가져오는 방법을 적용하려고 합니다.

 

 

> TO-BE 코드

간단하게 파일 구조는 아래와 같이 정의했습니다.

/my_coupon_validator
    /models
        coupon.py
        validate_result.py
        user_info.py
        prefetch_data.py
    validators.py
    validate_handlers.py
    main.py

 

models/coupon.py

from pydantic import (
    BaseModel,
    Field,
)
from typing import List


class Coupon(BaseModel):
    id: int = Field(description='Coupon id')
    title: str = Field(description='Coupon title')
    description: str = Field(description='Coupon description')
    only_for_identified: bool = Field(description='Coupon only for identified')
    specific_cities: List[str] = Field(description='Coupon specific cities')

이 파일에는 이전에 정의된 Coupon 클래스가 포함되어 있습니다. 클래스는 다음과 같은 속성을 가집니다:

  • id: 쿠폰의 고유 ID
  • title: 쿠폰 제목
  • description: 쿠폰 설명
  • only_for_identified: 오직 본인인증을 완료한 사용자에게만 적용 가능 여부
  • specific_cities: 특정 지역에서만 사용할 수 있는 쿠폰 정보

 

models/validate_result.py

from collections import defaultdict


class ValidateResult(defaultdict):
    def __init__(self):
        super(ValidateResult, self).__init__(list)

    def add_result(self, key, value) -> None:
        if isinstance(value, Exception):
            self[key].append(value)
        else:
            self[key] = value

이 파일에는 검증 로직에서 발생한 오류를 기록하기 위한 클래스를 정의한 코드가 포함되어 있습니다. 쿠폰 검증 시 오류가 발생하면, 해당 오류 정보를 이 클래스 객체에 저장합니다.

 

models/user_info.py

from functools import cached_property
from typing import Optional


class UserInfo:
    def __init__(self, user_id: Optional[int] = None, is_identified: Optional[bool] = None, meta: Optional[dict] = None):
        self._user_id = user_id
        self._is_identified = is_identified
        self._meta = meta

    @property
    def user_id(self):
        if self._user_id is not None:
            return self._user_id
        return self.meta.get('user_id')

    @property
    def is_identified(self):
        if self._is_identified is not None:
            return self._is_identified
        return self.meta.get('is_identified')

    @cached_property
    def meta(self):
        if self._meta is not None:
            return self._meta
        # 외부 API 호출
        return {
            'user_id': 1000,
            'is_identified': False,
        }

이 파일에는 쿠폰 사용을 검증할 때 필요한 사용자 정보를 관리하는 클래스를 정의하고 있습니다. 이 클래스는 타 MS와 통신할 때 필요한 정보를 제공합니다.

특히, 클래스의 meta 속성은 Lazy Evaluation 방식을 활용하기 위해 cached_property를 사용했습니다. 이를 통해 불필요하게 여러 번 사용자의 정보를 호출하지 않도록 최적화했습니다.

예제로는 user_id를 1000으로, is_identifiedFalse로 정의했습니다.

 

models/prefetch_data.py

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
)
from .user_info import UserInfo


class CouponValidationPrefetchData(BaseModel):
    user_info: UserInfo = Field(description='사용자 정보')
    current_city: str = Field(description='현재 도시')

    model_config = ConfigDict(arbitrary_types_allowed=True)

이 파일은 기존에 필요한 정보들을 미리 가져와 저장해두고, 이후 검증 로직에서 효율적으로 사용할 수 있도록 값들을 관리하는 데 사용됩니다.

 

validators.py

from typing import List

from models.coupon import Coupon
from models.prefetch_data import CouponValidationPrefetchData
from models.validate_result import ValidateResult


class CouponUsageValidator(object):
    def __init__(self):
        self.result_data = ValidateResult()

    def validate(self, coupons: List[Coupon], prefetch_data: CouponValidationPrefetchData):
        raise NotImplementedError("Each Validator must implement the 'validate' method.")


class CouponCitiesUsageValidator(CouponUsageValidator):
    def validate(self, coupons: List[Coupon], prefetch_data: CouponValidationPrefetchData):
        for coupon in coupons:
            if coupon.specific_cities and prefetch_data.current_city not in coupon.specific_cities:
                self.result_data.add_result(coupon.id, ValueError('This coupon is not available in your city.'))
        return self.result_data


class CouponIdentifiedUsageValidator(CouponUsageValidator):
    def validate(self, coupons: List[Coupon], prefetch_data: CouponValidationPrefetchData):
        for coupon in coupons:
            if coupon.only_for_identified and not prefetch_data.user_info.is_identified:
                self.result_data.add_result(coupon.id, ValueError('This coupon is only for identified users.'))
        return self.result_data

이 파일에서는 쿠폰을 사용할 수 있는지 여부를 판단하는 다양한 검증 로직을 관리합니다.

기존에는 쿠폰을 하나씩 개별적으로 확인했지만, 이제는 여러 개의 쿠폰(coupons)을 한 번에 받아서 검증할 수 있도록 개선되었습니다.

CouponIdentifiedUsageValidator: 본인인증을 완료한 사용자만 사용할 수 있는 쿠폰을 검증하는 클래스.

CouponCitiesUsageValidator: 지정된 유효 지역에서만 사용할 수 있는 쿠폰을 검증하는 클래스.

 

validate_handlers.py

from typing import List

from models.coupon import Coupon
from models.prefetch_data import CouponValidationPrefetchData
from models.validate_result import ValidateResult
from validators import (
    CouponIdentifiedUsageValidator,
    CouponCitiesUsageValidator,
)


class CouponValidateHandler(object):
    validate_modules = []

    def __init__(self, data: List[Coupon], prefetch_data: CouponValidationPrefetchData):
        self.prefetch_data = prefetch_data
        self.data = data
        self.result_data = ValidateResult()

    def _validate_modules(self) -> None:
        if not self.validate_modules:
            raise ValueError('Need to define validate_modules.')

        for validate_module in self.validate_modules:
            data = list(filter(lambda x: x.id not in self.result_data, self.data))

            if not data:
                break

            for key, value in validate_module().validate(data, self.prefetch_data).items():
                self.result_data.add_result(key, value)

        for coupon in list(filter(lambda x: x.id not in self.result_data, self.data)):
            self.result_data.add_result(coupon.id, coupon)

    def validate(self) -> ValidateResult:
        self._validate_modules()
        return self.result_data


class CheckingCouponBoxValidateHandler(CouponValidateHandler):
    validate_modules = [
        CouponIdentifiedUsageValidator,
    ]


class CheckingCouponBoxValidateV2Handler(CouponValidateHandler):
    validate_modules = [
        CouponCitiesUsageValidator,
    ]


class UsingCouponBoxCouponValidateHandler(CouponValidateHandler):
    validate_modules = [
        CouponIdentifiedUsageValidator,
        CouponCitiesUsageValidator,
    ]
이 파일에서는 앞서 정의한 검증기(validator)들을 원하는 대로 선택하여, 필요한 곳에서 원하는 검증 로직을 수행할 수 있도록 합니다.

CheckingCouponBoxValidateHandler, CheckingCouponBoxValidateV2Handler, UsingCouponBoxCouponValidateHandler는 각각 특정 상황에 맞게 유연하게 검증 로직을 적용할 수 있도록 설계되었습니다.

예를 들어, A 검증에서는 specific_cities 로직을 제외하고 검증을 진행하며, B 검증에서는 only_for_identified 로직을 제외합니다. 반면, C 검증에서는 모든 조건을 포함하여 검증하도록 구성할 수 있습니다. 이 방식은 다양한 검증 요구 사항에 맞게 유연하게 대응할 수 있도록 합니다.

 

main.py

from models.coupon import Coupon
from models.prefetch_data import CouponValidationPrefetchData
from models.user_info import UserInfo
from validate_handlers import CheckingCouponBoxValidateHandler


if __name__ == '__main__':
    validated = CheckingCouponBoxValidateHandler(
        data=[
            Coupon(
                id=1,
                title='coupon1',
                description='coupon1 description',
                only_for_identified=True,
                specific_cities=['Seoul', 'Busan'],
            ),
            Coupon(
                id=2,
                title='coupon2',
                description='coupon2 description',
                only_for_identified=False,
                specific_cities=[],
            ),
            Coupon(
                id=3,
                title='coupon3',
                description='coupon3 description',
                only_for_identified=True,
                specific_cities=['Seoul'],
            ),
        ],
        prefetch_data=CouponValidationPrefetchData(
            user_info=UserInfo(),
            current_city='Seoul'
        )
    ).validate()
    print(validated)


# 결과
# validated
# {
#     1: [ValueError('This coupon is only for identified users.')],
#     3: [ValueError('This coupon is only for identified users.')],
#     2: Coupon(id=2, title='coupon2', description='coupon2 description', only_for_identified=False, specific_cities=[])
# }

이렇게 해서 한 번에 필요한 모든 검증 데이터를 단일 쿼리로 가져오고, 이를 기반으로 검증을 수행하여 성능을 개선할 수 있었습니다.

 

위의 개선을 통해 평균 응답 시간이 350ms에서 165ms로 대폭 개선되었습니다.

 

마무리


이번 '쿠폰함 성능 개선' 프로젝트를 통해 오래된 서비스에서 지속적으로 개발하면 발생할 수 있는 성능 이슈를 해결하기 위한 중요한 경험을 할 수 있었습니다. 특히, 시스템의 복잡도가 증가하면서 발생하는 문제들을 구조적이고 효율적인 방식으로 해결하는 경험을 쌓았습니다.

1. 문제의 본질을 파악하고 우선순위를 정하라: 성능 저하의 근본 원인을 정확히 분석하고, 긴급한 문제와 장기적인 문제를 구분하여 접근하는 것이 중요합니다. 저는 사용자 경험을 해치지 않으면서도 빠르게 대응할 수 있는 방법을 먼저 적용했고, 장기적인 해결책을 지속적으로 준비했습니다.

2. 유지보수성을 고려한 설계: 전략 패턴을 도입하여 유지보수가 쉽고 확장 가능한 구조를 만들었습니다. 이러한 패턴들은 반복적인 코드와 복잡한 로직을 단순화하고, 다양한 요구사항에 유연하게 대응할 수 있는 기반을 제공했습니다.

3. 성능 최적화의 중요성: 많은 수의 데이터를 다루는 시스템에서는 성능 최적화가 필수적입니다. 단일 쿼리를 통해 데이터를 미리 가져와 검증하는 방식으로 시스템 성능을 크게 향상시킬 수 있었습니다.

이 프로젝트는 단순히 기술적 문제를 해결하는 것뿐만 아니라, 시스템 전체의 안정성과 사용자 경험을 개선하는 데 큰 역할을 했습니다. 앞으로도 지속적으로 시스템을 개선하고, 사용자에게 더 나은 서비스를 제공하기 위해 노력할 것입니다.

0 0
잡담
자유로운 생각과 일상에 관해 나누는 게시판입니다.
Yesterday: 456
Today: 110