쿠폰 서비스

구현이 꽤나 까다로운 서비스의 한 예로 일반적인 이커머스에서 사용하는 쿠폰 서비스를 들 수 있다고 생각한다.

쿠폰은 구현할 때 생각해야 하는 특징들의 조합이 꽤나 많으며, 비즈니스 상황에 따라 새로운 종류의 쿠폰을 계속 추가해 나가야 하는 문제도 덩달아 안고 있는 편이다.

사례: 급하게 만든 쿠폰 서비스

다음과 같이 쿠폰 서비스를 갖추게 되는 밑바닥부터 시작하는 스타트업 쇼핑몰이 있다고 상상해 보자.

  1. 쇼핑몰에 쿠폰 기능이 필요해짐.
  2. 기획자가 최소한의 기능만 갖는 쿠폰 서비스를 기획한다.
  3. 개발자는 1~2달간 기획서의 내용대로 쿠폰 서비스를 개발한다.
  4. 일정에 맞춰 배포한다. 이제 쇼핑몰에 간단한 쿠폰 기능이 추가되었다.

이렇게 만든 쿠폰은 아주 간단한 형태로 다음과 같은 기능만 갖고 있다고 하자.

  • 특정 카테고리의 상품이 주문서에 있다면 사용 가능한 쿠폰을 만들 수 있다.
  • 쿠폰을 적용하면 쿠폰이 적용된 상품의 가격을 고정 금액으로 할인해준다.

다음은 쿠폰 적용 판별 함수를 대충 작성해 본 것이다.

boolean isAvailableCoupon(Coupon c, OrderSheet o) {
  if (CATEGORY_ALLOW == c.type && c.category != null && o.size() > 0) {
    // 카테고리 허용 쿠폰이라면 주문서에 해당 카테고리 상품이 있나 확인한다
    for (OrderProduct product : o) {
      if (c.category.equals(product.getCategory()) {
        return true;
      }
    }
    // 주문서에 해당 카테고리 상품이 없으므로 쿠폰을 사용할 수 없음
    return false;
  }
}

그런데 문제는 이 다음부터이다. 마케팅 부서가 약 3주마다 새로운 종류의 쿠폰을 만들 수 있게 해달라는 요청을 보내게 된 것이다.

  • A 신용카드사와 계약을 했습니다. A 신용카드로 결제하는 경우에만 쓸 수 있는 쿠폰이 필요해요.
  • 음반 쿠폰이 필요합니다. 이 쿠폰은 주문서에 2020년에 발매된 음반들만 있고 다른 상품이 전혀 없을 때에만 쓸 수 있어야 해요.
  • 추석 선물세트가 들어있을 경우 배송비를 무료로 해주는 쿠폰이 필요해요.
  • 10만원 이상 구매하는 경우에만 1000원을 할인해주는 쿠폰이 필요해요.

계속해서 새로운 종류의 쿠폰을 만들어 달라고 하는 것은 쿠폰이라는 도메인의 특성이다. 그런데 문제는 이런 종류의 작업들은 마케팅 비즈니스와 관련이 있고, 시기를 잘 타야 이익을 극대화할 수 있다는 특징이 있다.

플로우 차트 모델: 끝없이 추가되는 if / else

즉 처음엔 최소한의 기능만 구현해놓고 배포가 완료된 이후 서서히 새로운 요구 조건들이 쏟아지게 된다. 따라서 이런 요구 사항들을 빠르게 들어주기 위해 엄청난 양의 if/else가 쿠폰 사용 조건을 판별하는 모듈에 들어가게 될 수 있다.

boolean isAvailableCoupon(Coupon c, OrderSheet o, Payment p) {
  // if 카테고리 허용 쿠폰이라면 주문서에 해당 카테고리 상품이 있나?
    return ...;

  // if 특정 결제수단 쿠폰이라면 결제를 A 신용카드로 진행했나?
    return ...;

  // if 특정 연도에 판매된 상품들로 구성된 주문서인가?
    return ...;

  // 결제 금액이 특정 가격을 넘어서는가?
    return ...;

  // ... 그 외에 수없이 이어지는 if / else
}

위의 예제는 간단하게 주석으로 표기했지만 isAvailableCoupon 메소드는 시간이 지남에 따라 굉장히 큰 코드가 되어간다. 단순한 메소드 추출로는 근본적인 문제를 해결할 수 없다. 모듈을 여러 클래스로 분리한다 가정해도 막대한 양의 if/else를 관리하고 테스트 코드를 작성하는 것도 쉬운 일이 아니기 때문이다.

따라서 간단한 종류의 쿠폰만을 만들고 쿠폰의 종류나 조합의 경우의 수를 증가시키지 않는 방향으로 핸들링하기도 한다. 쿠폰에 새로운 조건을 추가하게 된다면 모든 if 문을 읽고 어떤 위치에 새로운 if 문을 추가해야 할 지 판단해야 할 텐데, 조건이 추가될수록 난이도가 급상승하기 때문이다.

이러한 복잡도의 원인은 쿠폰의 적용/검증 과정을 하나의 플로우 차트 모델로 생각하고 구현했기 때문이다. 새로운 요구사항이 도착하면 플로우 차트에 새로운 if를 추가하는 것이 문제인 것.

그리고 이런 과정을 통해 수십~수백개의 if/else가 쌓이게 되면 쿠폰을 구성하는 다양한 조건들의 우선순위와 처리 과정을 명확하게 이해하는 사람이 점점 줄어들게 된다. 관리자 화면에 보이는 여러 데이터들의 우선순위를 모르면 의도와 다르게 작동하는 엉뚱한 쿠폰이 만들어질 수도 있다.

왜 이렇게 구현하게 되나?

DB에 쿠폰의 로직이 작동할 기준이 되는 값들을 저장한다고 생각해보자. RDB는 예제를 만들기엔 좀 골치아프니 다음과 같이 json 형태로 Document DB에 저장한다고 하자.

{
  "id": "B2E0DF55-3C57-493D-A866-3FDF4B4FDA9",
  "name": "가전제품 10% 할인 쿠폰",
  "condition": {
    "allowType": "CATEGORY",
    "allowList": [112], // 가전제품,
    "disallowType": "NONE",
    "disallowList": []
  },
  "discount": {
    "type": "RATIO",
    "value": 10,    // 10% 할인
    "max": 10000    // 최대 1만원까지만 할인
  }
}

이렇게 저장한 데이터에는 쿠폰과 관련된 판단의 기준이 되는 값들은 포함되어 있지만, 쿠폰과 관련된 로직은 포함되어 있지 않다. 로직을 전부 소스코드에 위치시킬 수 밖에 없으므로 쿠폰의 종류가 늘어날수록 소스코드 또한 복잡해지게 된다.

만약 쿠폰을 DB에 저장할 때 해당 쿠폰의 판별, 할인 적용 로직도 함께 저장할 수 있다면 쿠폰과 관련된 코드는 단순해질 것이다. 잘 구현한다면 if는 대폭 줄어들고, 한 두개의 for만 남게될 수도 있다.

파이프라인 모델

나는 과거에 레거시 쿠폰 서비스를 리팩토링하다가 다음과 같이 [[/pattern/pipeline]] 패턴과 로직을 DB에 저장하는 기법을 조합해 응용하는 방법을 떠올린 적이 있다.

녹색 네모칸, 즉 체커와 할인 적용기를 컴포넌트라 부르도록 하자. 각 컴포넌트는 다음과 같은 간단한 인터페이스의 구현체로 만든다.

interface CouponComponent {
  /**
   * 파이프라인을 통과할 수 있다면 true를 리턴합니다.
   */
  default boolean isPass(Context c) {
    return true;
  }

  /**
   * 파이프라인을 적용한 결과를 리턴합니다.
   */
  default Context apply(Context c) {
    return c;
  }
}

이제 이 인터페이스의 구현체를 목적에 따라 다양하게 만들면 된다. 예를 들어 다음과 같이 만들 수 있을 것이다.

  • 예: 카테고리 체크 컴포넌트
class CategoryComponent implements CouponComponent {
  // 생성자 생략
  private Set<Category> categories;

  boolean isPass(Context c) {
    // 해당하는 카테고리 상품이 있다면 true
    for (OrderProduct p : c.getOrderSheet().getOrderProducts()) {
      if (categories.contains(p.getCategory()) {
        return true;
      }
    }
    // 해당 카테고리가 없다면 false
    return false;
  }
}
  • 예: 최소금액 체크 컴포넌트
class MinimumPriceComponent implements CouponComponent {
  // 생성자 생략
  private int minPrice;

  boolean isPass(Context c) {
    return c.getOrderSheet().getTotalPrice() >= minimumPrice;
  }
}
  • 예: 고정금액 할인 컴포넌트
class DiscountFixed implements CouponComponent {
  // 생성자 생략
  private int discountPrice;

  boolean isPass(Context c) {
    return c.getOrderSheet().getTotalPrice() >= discountPrice;
  }

  Context apply(Context c) {
    c.discount(discountPrice);
    return c;
  }
}

이렇게 파이프라인 패턴을 적용하면 각 쿠폰을 다음 예제들과 같이 만들 수 있다.

예제: 가전제품 카테고리 10% 할인 쿠폰

쿠폰을 만드는 관리자는 미리 만들어진 여러 컴포넌트들 중에서 적절한 것을 선택해 드래그해서 순서대로 배치한 다음, 옮겨놓은 각 컴포넌트들에 참고값을 입력하면 된다.

이 쿠폰은 DB에 이렇게 저장하면 된다.

{
  "id": "59C6880A-C360-4978-97BA-989A0F5DE85C",
  "name": "가전제품 10% 할인 쿠폰",
  "components": [
    {
      // 카테고리 체커
      "type":     "CategoryComponent",
      "category": [112]
    },
    {
      // 비율 할인 적용기
      "type": "RatioDiscountComponent",
      "ratio": 10,
      "maxDiscount": 10000,
    }
  ]
}

쿠폰을 불러올 때 components 리스트를 읽고 각 컴포넌트에 해당하는 인스턴스들의 리스트를 만들고 순서대로 isPassapply 메소드를 불러주기만 하면 된다.

for (CouponComponent component : components) {
  if (component.isPass(context)) {
    context = component.apply(context);
  } else {
    context.setFail(component);
    break;
  }
}

이렇게 만들면 수십~수백개가 되는 if/else의 대부분이 사라지게 된다.

예제: 쿠폰 관리자가 복잡한 쿠폰을 만들어가는 과정

이 섹션은 관리자가 복잡한 조건을 갖는 쿠폰을 만들어가는 과정을 모형화하여 소개한다.

쿠폰 관리자 홍길동 대리는 실버 등급 회원을 위한 쿠폰을 구상하고 있다.

그래서 그는 다음과 같이 '회원등급' 컴포넌트를 드래그해서 쿠폰을 만들기 시작한다.

홍길동 대리가 생성하고자 하는 쿠폰은 가전제품에 적용할 수 있는 쿠폰이다. 그런데 냉장고는 최근 회사가 적자를 보며 팔고 있으므로, 냉장고에 대해서는 쿠폰을 적용하지 않기로 했다.

따라서 '적용 가능 카테고리' 컴포넌트를 드래그해서 쿠폰에 가져다 놓고, '가전제품' 카테고리를 선택한다. 그리고 '적용 불가능 카테고리' 컴포넌트도 드래그한 다음 '냉장고'를 선택한다.

이제 실버 회원 등급이어야 쓸 수 있는, 냉장고 아닌 가전제품을 구매할 때만 사용 가능한 쿠폰이 되었다.

이번에는 '결제방법' 컴포넌트를 갖다놓고 Visa 카드를 사용하는 조건을 추가한다. 쿠폰 사용 조건이 점점 까다로워지고 있다. 실버 회원이면서 Visa 카드도 갖고 있어야만 이 쿠폰을 쓸 수 있다.

이제 '주문서 최소 금액' 컴포넌트를 써서 10만원 미만 구매하는 경우의 쿠폰 사용을 금지한다.

마지막으로 '할인 적용' 컴포넌트를 드래그해서 할인 금액 5000을 설정한다.

홍길동 대리는 이렇게 만든 쿠폰 파이프라인을 위에서 아래로 읽으며 자신이 의도한대로 작동하는 쿠폰인지를 검토한다. 의도대로 만들었다는 것을 확인하고 나서는 쿠폰을 저장한다. 이 쿠폰은 머지 않아 실버 회원들에게 제공될 것이다.

파이프라인 모델의 장점

  • '상태값을 검증하는 수많은 if 문의 고정된 순서 모델'을 폐기하게 된다.
  • 쿠폰 관리자는 새로운 쿠폰을 구상해 개발자에게 요구하기보다 이미 존재하는 컴포넌트들을 조합해 만드는 방법을 1차적으로 고민하게 된다.
    • 컴포넌트 조합으로 해결이 안 되는 경우에만 개발 요청을 하면 된다.
  • 새로운 쿠폰 기능이 필요할 때 개발자는 다음 두 가지를 만들면 된다.
    • 해당 기능만을 표현하는 컴포넌트 클래스 (백엔드, 프론트엔드)
    • 쿠폰 기능을 추가할 때마다 수십개의 if 문을 읽고 어디에 끼워넣을지 고민하지 않아도 된다.
  • 관리자와 개발자 모두 위에서 아래로의 흐름으로 쿠폰의 로직을 이해할 수 있다.
    • 쿠폰 기준값을 DB에 저장하는 방식에서는 플로우 차트를 관리자에게 공개하지 않는다면(소스코드를 보여주지 않는다면) 각 값들이 어떤 순서대로, 어떤 우선순위를 갖고 적용되는지를 알 수 없는데, 파이프라인에서는 그냥 위에서 아래로 읽으면 된다.
  • 각 컴포넌트 하나하나에 해당하는 테스트코드를 만들 수 있다.
  • 컴포넌트 하나가 작고 간결하므로 하나하나를 이해하기 어렵지 않다.
    • 이해하기 어렵다 하더라도 if/else 가 수십개 있어 서로 영향을 주고받는 방식보다 사이드 이펙트가 적다.

물론 단점도 있다.

  • 일단 만들어 배포한 컴포넌트는 지울 수 없다.

하지만 필요한 컴포넌트의 수는 많아봐야 20개 남짓할 것으로 생각한다. 내 예상을 뛰어넘는다 해도 40개 정도면 어지간한 온라인 쇼핑몰의 필요는 대부분 만족시킬 것이라 본다.

한 걸음 더 나아가는 방법: AST를 DB에 저장한다

여기에서 한 걸음 더 나아가, 아예 컴포넌트 없이 람다 함수 또는 AST를 DB에 함께 저장하는 방법도 있을 것이다. 이렇게 한다면 극도로 유연한 쿠폰 시스템을 만들 수 있을 것이고 개발자는 쿠폰 컴포넌트를 만들 필요조차 사라지게 된다. 그냥 isPassapply를 위한 DSL을 만들어서 관리자들에게 이 언어를 가르쳐주면 되기 때문이다.

하지만 과도한 방법일 거라 생각한다. 굳이 이렇게 할 필요는 없다. 일단 자유로운 코드가 가능하다는 점에서 보안 위험이 있고, 무한 루프가 가능한 코드를 관리자가 실수로 또는 고의로 만들어버리면 시스템 장애도 발생할 수 있다.

나는 CouponComponent 인터페이스를 사용하는 방법 정도로 충분하다고 생각한다.

함께 읽기

2022-09-12 Woojin Kim님의 글

로직을 데이터 모양으로 표현한 간단한 스킬 시스템

2022-09-05 내가 트위터에 쓴 글

다음은 내가 트위터에 쓴 메모를 옮겨와 정리한 것이다.

요즘 하는 생각 중 하나: 소프트웨어가 별로 소프트하지 않음(물론 이 이름의 근원인 하드웨어에 비해서는 소프트하다). 코드가 쌓일수록 수정하거나 추가하는 게 점점 더 어려워지기 때문. 운영되며 수정과 추가를 누적한 역사를 갖는 코드는 대체로 갓 만든 코드에 비해 별로 소프트하지 않다.

그래서 보통 이런 상황에 빠진 코드가 누적된 것을 기술 부채의 일종으로 부른다. 사회가 바뀌고 비즈니스도 바뀌고 보안 기준이 바뀌고 사람들의 욕망도 바뀌므로 소프트웨어를 가만히 놔둘 수가 없다. 아무튼 계속 고치고 수정하고 새로운 기능을 추가해야 소프트웨어는 살아남는다.

여기까지는 원론적 이야기. 소프트웨어는 하드웨어보다 부드럽다. 다만 이 표현은 발명된지 오랜 시간이 흘렀으니 넘겨두고, 회사에서 일하면서는 하드웨어가 딱딱하다는 생각은 하지 않는다. 그냥 소프트웨어만 머릿속에 있으니 소프트웨어도 딱딱하다는 생각이 드는 것. 그렇다면 부드러운 것은 무엇?

부드러운 것은 DB. 똑같은 장바구니라 하더라도 DB 속 장바구니 테이블에 들어있는 내용이 다르므로 A가 보는 장바구니 화면에는 야구공과 진라면이 있고, B가 보는 장바구니 화면에는 운동화와 장갑이 있을 수 있다. DB에 무엇이 저장되어 있는지에 따라 결과가 부드럽게 바뀌는 것.

DB에 저장되는 값들은 다양한 얼굴과 역할을 갖고 있지만 그 중 빼놓을 수 없는 역할은 코드의 분기지점을 의미하는 분기값이 된다는 것. 어떤 회원이 실버 등급이라고 DB에 저장되어 있다면 로그인했을 때 은색 메달을 보여주고, 골드 등급이라고 저장되어 있으면 금색 메달을 보여주는 그런 것.

데이터의 다양한 역할 중 이런 플래그로서의 역할을 두고 생각해보면 역시 무척 아쉽다. 그래서 고민하다가 생각한게 이 구조(더 나은 쿠폰 서비스에 대한 아이디어 기록)였는데, 사실 일종의 타협으로 본래는 AST를 직접 DB에 저장해서 "어떤 코드 조각"은 DB에 저장하는 것이 낫지 않을까 하는 아이디어라 할 수 있다.

코드를 DB에 저장하는 것은 생각보다 널리 쓰이고 있는 아이디어이기도 하다. 예를 들어 이더리움의 솔리디티라던가. 다만 무한루프나 보안 같은 잡다한 문제를 핸들링해야 하고, DB에 코드가 저장된다는 사실과 DSL을 만들어야 할지, 유효성 검증에 대한 고민 등을 처리해야 한다.

이런 식의 생각할 문제가 많다 보니 대부분은 그냥 DB에 분기 기준값을 저장하고 서비스 코드에서 if/else/for 로 걍 "모든 경우를" 처리하는 방식으로 일하는 경우가 많았던 것 같다. 떠올려보면 좀 후회스럽기도 한데, 수백개의 if를 일일이 만드는 것보다 더 나은 방식으로 일할 기회도 많았다.

쿠폰 글에서는 모듈화한 코드 조각을 파일로 분리하고, 각 파일의 네임스페이스를 순서있는 리스트로 구성해서 DB에 저장하는 것을 이야기했다. 글의 마지막에서도 이 정도로도 충분하다고 작성했다. 그런데 만약 더 유연한 구조가 필요하다면 DB에 코드를 저장하는 것도 선택할 수 있다고 본다.

물론 이런 결정을 하는 시점에 도달하면 추가로 고민할 문제들이 있다.

  1. JS처럼 널리 알려진 언어를 쓸 것인가? 새로운 언어를 만들 것인가? AST를 lisp 스타일로 표현해 저장할 것인가?
  2. DB에 저장하는 코드는 불변을 보장할 것인가? 업데이트/삭제를 허용할 것인가?
  3. 언어 교육은 어떻게?

일단 생각나는 것은 튜링 완전하지 않게 만들 것. 분기는 가능하나(if만 있음) 위에서 아래로 떨어지는 방식으로만 작동(for 없음), 변수 재할당 금지, stack은 2단계까지만 허용해서(if를 써야 하니까) 변수 네임스페이스 frame은 2개만 사용 가능…

첫 트윗으로 돌아가 이야기하자면 소프트웨어가 소프트하려면 핵심 코드만을 남기고 나머지는 최대한 분리해서 물렁한 곳으로 옮기는 것이 바람직할 것 같다는 말. 사실 이 관점에서 "항상 프레임워크 관점으로 접근하라"는 말도 나온 것일 텐데 나는 좀 다르게 본다.

제품은 기능이 적으면 적을수록 더 고장없이 잘 작동한다. 그냥 작고 각각 한 가지 역할만 하도록 관리해주는 것이 엔지니어링 관점에서 적절하지 않을까 하는 생각. 그래야 삭제하기도 쉽고…

[[/article/hierarchical-controller-package-structure]]

아무튼 언어는 해결책이 아니라고 결론. 자잘하게 파일을 분리하고 그렇게 분리한 파일들을 계층형으로 관리하는 것이 내 취향에 맞는 것 같다.

@mazycat 님의 멘션

@mazycat : 비슷한 내용을 예전에 어디선가 논의되는 걸 본 적이 있어요. 책에서도 있었고(제 기억력의 한계로 정확한 출처를 기억하지 못합니다 흑흑흑…) 하지만 늘 문제는 '잘' 만들기 어렵다는 거였습니다. json의 경우는 스키마가 유연하다보니 스키마 관리가 제대로 되지 않고, 테이블 형태 DB에 넣기에는

@mazycat : 경우의 수가 너무 늘어나거나 스키마 관리가 안 되기도 하고,DB와 서비스의 커뮤니케이션이 너무 늘어나면서 생기는 부하도 우려되는 등등의 이야기가 있었던 것으로 기억해요. 하지만 이렇게 하면 서비스를 보다 유연하게(부드럽게) 만들 수 있고 확장성에도 좋다 등의 이야기를 들은 기억이 납니다.

@John_Grib : 꼬젯님 좋은 말씀 너무 감사합니다. 저도 열심히 생각해봤지만 위험이 너무 크다는 결론을 내렸어요. 그래서 코드는 역시 하던대로(…) 소스코드에 저장하고 DB에는 코드의 네임스페이스만 저장하는 정도로 적절하다고 생각했어요.

@if1live 님의 멘션

@if1live : 게임프로그래밍에서 스크립트를 어떻게 써먹나 찾아보시면 재밌는걸 많이 찾으실수 있을거같습니다

@John_Grib : 오 좋은 힌트 감사합니다!

@if1live : [GPG스터디] 1.0 데이터 주도적 설계의 마법
대충 슬라이드 뜯어서 보시면 문제를 파는 시작지점으로 써먹을수 있을겁니다

2022-08-29 @dylayed 님의 멘션