본문으로 건너뛰기

PickHound

구분내용
프로젝트명PickHound
개발 기간2022.08 ~ 현재 베타 서비스 중
가담 인원 (개발/비개발)4명/5명

개요

PickHound는 쉐퍼드23에서 개발한 Cafe24 온라인 쇼핑몰을 대상으로 하는 상품 추천 플러그인입니다.

Contextual Bandit에 기반해 팝업 배너를 통해 각 쇼핑몰의 상품을 사용자 개인의 선호, 취향 등을 고려한 개인화 상품 추천을 제공합니다.

베타 테스팅 단계를 거치고 있으며, 현재 60곳 이상의 실사용 고객사의 25k 가량의 쇼핑몰 방문객에게 상품 추천 서비스를 제공하고 있습니다. 서비스의 개발, 운영을 총체적으로 담당하면서, 심층 인터뷰 및 고객 데이터 수집을 통한 피드백 수집, 플러그인 및 추천 시스템 개발 및 유지보수, 서비스 운영을 진행하고 있습니다.

위와 같은 과업을 진행하며 현재도 수많은 기술적, 사업적 문제를 맞닥뜨리고, 이를 해결해나가는 과정에서 많은 배움과 성장을 얻고 있습니다.

Showcase

정보

아래 링크에서 PickHound의 실제 서비스를 확인하세요.

https://store.cafe24.com/kr/apps/17367

아키텍쳐 개요도

아래는 작성자가 PickHound 서비스의 개발 계획을 세우면서 작성한 아키텍쳐 개요도입니다. 아래의 아키텍쳐 구조는 현재까지도 대부분이 유지되고 있습니다.

하위 프로젝트 개략

정보

아래는 아키텍쳐 개요도의 각 프로젝트에 대한 개략적인 설명입니다.

Recomendation API

Recomendation API 는 Cafe24 API와 Firestore DB에서 정보를 읽어오고, 쇼핑몰 관리자와 쇼핑몰 방문객의 JS 클라이언트 (각각 Admin DashboardBanner Manager)를 위한 REST API를 제공합니다.

NestJS 로 개발되었고, 작성자가 API 일체를 개발했습니다. AI 팀이 웹개발을 전혀 다룰 수 없었기 때문에 굳이 Python 웹 프레임워크로 ML 모델을 포함하는 Monolithic API를 개발하기 보다는, ML 모델은 Flask 기반의 Bandit Engine Microservice로 분리하여 AI 팀이 오로지 ML 모델 개발 및 Fine Tuning에 집중할 수 있게끔 했습니다. HTTP 요청 핸들링 및 데이터 소싱, 포맷팅을 전부 Recommendation API 에서 대신 처리하므로 AI 팀은 Recommendation API 를 일종의 추상화 계층으로 받아들이고 ML 모델 개발에 집중할 수 있었습니다.

구분기술 스택
언어Typescript
프레임워크NestJS
DBFirestore
테스팅Jest
툴링 & 버전 관리Lerna, Husky, ESLint, Prettier
문서화Swagger, Typedoc
CI/CDGithub Actions, Cloud Run, Compute Engine, Cloud Build 외 7개 GCP 서비스

Bandit Engine

Bandit Engine은 Contextual Bandit 모델을 제공하는 내부 API입니다.

Flask 로 개발되었고, AI 팀이 머신러닝과 관련된 알고리즘 및 모델 작성을, 작성자는 ML모델을 제외한 REST API 를 개발했습니다. 언급했듯이 AI 팀이 웹개발을 전혀 다룰 수 없었기 때문에, 서비스의 각 엔드포인트는 주어진 입력값 이외에는 다른 DB 나 스토리지에서 데이터 소싱을 하지 않는 단순한 IPO(Input-Process-Output) 프로그램으로 구성하였고, 필요한 데이터는 전부 Recommendation API 에서 수집/정제 후 제공하였습니다. 이를 통해 AI 팀이 웹개발에 익숙치 않아 생기는 개발 병목을 사전에 방지할 수 있었습니다.

구분기술 스택
언어Python
프레임워크Flask
AI/MLnumpy, pandas, scikit-learn, openai
테스팅pytest
툴링 & 버전 관리poetry, commitizen, yapf, pylint, selenium
문서화Sphinx
CI/CDGithub Actions, Cloud Run, Cloud Build

Admin Dashboard

Admin Dashboard는 쇼핑몰 관리자가 쇼핑몰에서 이루어지는 상품 추천과 관련한 설정을 할 수 있는 SPA 입니다.

React 로 개발되었고, 작성자가 대시보드 일체를 개발했습니다. 개발 자원이 매우 부족한 상황에서, 팀의 제 1 목표는 모델의 실제 환경에서의 성능 검증이였기 때문에 대시보드는 단순히 DB의 데이터를 표시하고 업데이트하는 클라이언트의 역할을 하면 충분했습니다. 따라서 디자인 단계에서부터 불필요한 디테일은 제거하고, 컴포넌트 재사용 가능성을 염두에 두어 디자인하였습니다. 또한 react-toastify, react-select 등 여러 UI 라이브러리를 다수 검토하여 하드코딩 작업을 줄이는 데도 집중하였습니다.

구분기술 스택
언어Typescript
UI 라이브러리React, MUI, TailwindCSS, D3, ...
상태 관리 & 캐싱@reduxjs/toolkit, RTK Query
번들링vite
테스팅Cypress
툴링 & 버전 관리Commitlint, Husky, ESLint, Prettier
문서화Storybook
CI/CDGithub Actions, Firebase Hosting

Banner Manager는 플러그인을 사용하는 쇼핑몰에 Javascript 형태로 직접 삽입되어 Recommendation API 에 상품 추천을 요청하고 브라우저 환경에 따라 추천 상품을 팝업 배너의 형태로 표시하는 스크립트입니다.

StencilJS로 개발했으며, 작성자가 스크립트 일체를 개발했습니다. 기존에 사용하던 React 대신 StencilJS를 도입한 것은, PickHound의 스크립트가 기존의 쇼핑몰에 삽입이 되어야하는 상황에서 최대한 빠르고 가벼운 번들을 만들기 위해서는 브라우저가 기본으로 지원하는 Web Component API 를 활용하는 것이 가장 적합하다고 판단했기 때문입니다. 결과적으로 이러한 선택은 기능이 동일한 타사 추천 플러그인에 비해 본 팀의 스크립트 번들 사이즈가 60% 더 작게 나오는 등의 효과를 가져왔습니다. (See: StencilJS 도입을 통한 번들 크기 및 성능 최적화)

구분기술 스택
언어Typescript
UI 라이브러리StencilJS
번들링Rollup
툴링 & 버전 관리Lerna, Husky, ESLint, Prettier
CI/CDGithub Actions, Google Cloud Storage

업무 경험 및 성과

Common

심층 인터뷰를 통한 수요 파악 및 제품 방향성 수립

  • 약 1달에 걸쳐 다양한 분야에 종사하는 12명의 쇼핑몰 관리자를 대상으로 인터뷰 진행함.
  • 인터뷰를 통한 피드백 수집으로 쇼핑몰의 규모에 따른 서비스의 수요, 운영 방식의 차이 등을 파악할 수 있었음.
  • => 정리한 피드백에 기반한 불필요한 개발 단계 제거로 사내 개발 자원 낭비 방지.

아래 링크에서 위와 관련한 더 자세한 내용을 확인할 수 있습니다.

https://01joseph-hwang10.github.io/posts/deep-interview

Lerna에 기반한 프로젝트 관리 및 공유 코드 라이브러리화

  • Lerna를 활용한 모노리포 구성, 패키지 버저닝 및 commitlint를 활용한 커밋 메시지 규칙 적용.
    • Github Actions, Bash Script, Husky를 활용해 버저닝 자동화 및 커밋 메시지 규칙 강제.
  • 공유가 필요한 코드는 따로 패키지로 분리해 및 Google Artifact Registry를 활용해 private npm 패키지 배포.
  • => 프로젝트 관리 체계화 및 자동화
  • => 비효율적인 복사 붙여넣기 방식의 코드 공유를 최소화.

불필요한 코드 빌드 최소화

  • 대부분의 CI/CD 빌드 트리거가 Github Actions의 push 이벤트에 의해 발생함.
  • 모노리포 상황에서 하나의 패키지에만 변경사항이 있는 경우에도 모든 패키지의 빌드가 발생하는 문제가 있었음.
  • Lerna를 이용한 버저닝 관리와 함께 패키지 레지스트리에 배포되어 있는 패키지의 버전과 현재 레포지토리의 패키지 버전을 비교해 변경사항이 있는 경우에만 빌드가 발생하도록 구성함.
    • => 불필요한 빌드 최소화 및 CI/CD 자원 소비 최소화
publish_if_needed
function publish_if_needed () {
package_name="@$namespace/$1"
package_dir="packages/$1"
published_version=$(npm view $package_name version)
current_version=$(node -pe "require('./package.json').version")
if [ "$published_version" = "$current_version" ]; then
echo "$package_name is already published"
else
echo "Publishing $package_name"
npm publish -w $package_dir
fi
}

Client Side 에러 트래킹을 위한 로깅 시스템 개발

  • 서버에서 발생하는 에러와 달리 클라이언트 애플리케이션은 에러가 발생해도 팀 내에서는 인지하기 어려움.
  • 고객의 오류 문의를 처리하기 위해서는 오류 당시의 자세한 상황을 알아야 하는데, 이를 고객의 설명에만 의존해 파악하기 어려움.
  • 이에 Cloud Run에 POST request의 body를 console.log 를 통해 로깅하는 간단한 로그 서버와 이에 대한 클라이언트 라이브러리를 개발함.
    • 클라이언트는 uuid 를 활용해 고유의 에러 ID를 생성하고, 이를 로그 서버에 전송하며, 에러 ID를 에러 메시지와 함께 고객에게 전달.
    • 로그 서버는 Cloud Logging으로 로그를 스트림하고, 개발팀은 위에서 생성한 에러 ID를 통해 Cloud Logging에서 해당 에러를 검색.
  • => 고객의 에러 문의에 발행된 에러 ID를 통해 즉각적인 문의 대응을 통한 고객 경험 향상.
에러 트래킹 시스템 개요도

Jest, Pytest, Cypress를 활용한 TDD로 개발 흐름 효율화

  • Jest, Pytest를 활용해 주요 엔드포인트 및 서비스 로직에 대한 Unit/E2E 테스트 작성.
  • Insomnia 등의 툴을 이용한 수동 테스트 시보다 훨씬 효율적인 개발 흐름을 구성할 수 있었음
    • => 기존 수동으로 테스트를 진행했을 때와 비교해 작업 시간 약 90% 감소. (기존 약 20분 → TDD 도입 후 최대 2분)
    • => 다른 팀과 버그 수정 관련한 불필요한 대화가 오고 가는 빈도 약 80% 감소. (기존 평균 4 ~ 5회 → TDD 도입 후 0 ~ 1회)
  • Cypress를 활용해 복잡한 인증/인가 로직을 포함하는 컴포넌트에 대한 Unit 테스트 작성.
    • => 테스트를 통한 Iteration 속도 향상으로 빠른 기능 구현 및 버그 수정.

정적 웹페이지 보호를 위한 Express 애플리케이션 개발

  • 정적 웹페이지를 회사 내부 인원만 열람할 수 있도록 하는 Private File Server 구축.
  • static-webpage-with-auth 로 도커라이즈 하여 여러 도큐멘테이션 페이지에 쉽게 적용할 수 있도록 만듬.
  • => 비교적 적은 개발 자원 소비로 Netlify의 유료 솔루션 등을 사용하지 않고도 사내 인원만 접근할 수 있는 도큐멘테이션 페이지 구축.
See Also

Google Sheets를 활용한 간이 어드민 대시보드 구축

  • NestJS는 Django처럼 Admin Dashboard를 제공하지 않고, Thrid Party 라이브러리 (e.g. AdminJS) 도 Firestore 를 DB로 사용하는 경우를 고려한 라이브러리는 없었음.
  • 이에 Google Sheets, Sheets API, Cloud Functions 를 활용해 사용자 데이터 시각화 및 쇼핑몰 관리 기능을 포함하는 간이 대시보드 구축함.
  • => UI 구현 필요 없이 간단한 로직 구현만 필요했기에 빠른 대시보드 구현이 가능했음.
  • => 엑셀에 기반한 대시보드 구현으로 비개발 인원도 쉽게 지표를 확인하고 쇼핑몰을 관리할 수 있었음.

Cloud Logging & Slack을 활용한 에러 모니터링 시스템 구축

  • 에러 확인을 위해 주기적으로 GCP 대시보드에 접속하는 일이 매우 번거롭다는 문제가 있었음.
  • 이에 GCL 로그 라우터와 Cloud Functions를 활용해 에러가 발생할 때마다 Slack으로 알림을 전송하는 시스템 구축함.
  • => 에러 확인을 위해 주기적으로 GCP 대시보드에 접속하는 비효율 제거.
  • 4XX 에러나 5XX 코드를 가지는 로그도 라우터에 포함되어 알림이 전송되는 문제가 있었음.
  • GCL의 Logging Query Language 를 이용해 알림을 받을 필요가 없는 로그를 필터링하는 기능을 추가함.
  • => 불필요한 알림 방지.

코드주석과 Notion을 연계한 사내 도큐멘테이션 작성 효율화 및 생성 자동화

  • 기존에는 비개발 인원이 개발 상황을 파악하기 위해 개발자에게 직접 문의하거나, 개발자가 이미 코드 주석으로 작성된 내용을 Notion에 다시 작성하는 등의 비효율이 존재했음.
  • 이에 Swagger, Typedoc, Sphinx 등의 툴을 활용해 도큐멘테이션을 생성하고, 노션 페이지와 도큐멘테이션 페이지의 웹 링크를 연계하는 도큐멘테이션 작성 컨벤션 수립.
    • Github Actions, Cloud Build, Cloud Run을 활용해 도큐멘테이션 페이지 배포 자동화.
  • => 중복되는 주석 작성을 최소화하고, 개발 인원의 개입 없이도 비개발 인원이 개발 상황을 파악할 수 있도록 함.
See Also

StencilJS 도입을 통한 번들 크기 및 성능 최적화

  • Cafe24로 제작된 쇼핑몰은 이미 많은 Javascript를 사용하며, 여기에 타사의 플러그인 또한 여럿 설치해 사용했음
    • => 큰 번들 사이즈는 엔드 유저의 UX 경험에 악영향을 미칠 수 있음을 인지함
  • 기존의 React 대신 StencilJS를 도입하여 Web Components API의 이점을 활용하며, 번들 사이즈를 60% 이상 줄임
    • => 기능이 동일한 타사 추천 플러그인에 비해 60% 작은 번들 사이즈. (본사 35KB, 타사 85KB)

다수의 IIFE 번들 빌드 프로세스 구성 및 CI/CD 자동화

  • 카페24에 배포해 쇼핑몰에 삽입되어 실행되는 스크립트는 브라우저에서 바로 실행될 수 있는 VanillaJS로써, 페이지마다 하나의 IIFE 번들이 존재해야 하는 제약조건으로 기존 SPA의 번들링 프로세스를 그대로 사용할 수 없는 상황이었음.
  • StencilJS CLI와 Rollup, 배시 스크립트를 활용해 여러 개의 Entrypoint를 동시에 IIFE로 빌드하는 프로세스 구성함.
    • Webpack과 비교해, Scope Hosting 기능, 단순한 번들러 설정 및 CLI 활용성 등을 고려하여 Rollup을 도입함.
  • Github Actions를 활용해 Banner Manager의 배포 자동화
  • => 복잡한 빌드 및 배포 과정의 인적 자원 소비 최소화

Admin Dashboard

Redux를 이용한 FLUX/MVC 기반의 React & StencilJS 상태 연동

  • React와 StencilJS로 빌드된 Web Component의 상태 관리 방식이 다르기 때문에 각각의 경우에 맞는 상태 관리를 구현해야 했음.
  • React, @reduxjs/toolkitcreateSlice() API를 활용해 FLUX 패턴을 기반으로 디자인 수정 페이지 구현
  • MVC 패턴에 기반해 StencilJS Web Component의 style을 관리하는 Banner Manager 객체 작성
  • FLUXStore에 해당하는 Redux SliceMVCV에 대응하여 두 컴포넌트의 상태를 연동.
  • => 적절한 코드 패턴의 적용으로 간결하고 직관적인, 유지보수에 용이한 상태 관리 코드 작성.
    • => 확장성 있는 코드 작성으로 유저가 피드백한 배너의 위치 변경 기능을 하루 만에 구현, 테스트 및 배포할 수 있었음.
  • => MVC, FLUX의 각 요소와 요소 간의 상호작용에 관한 구체적인 이해 함양.

아래 링크에서 위와 관련한 더 자세한 내용을 확인할 수 있습니다.

https://01joseph-hwang10.github.io/posts/react-stenciljs-integration

Figma Admin Dashboard Kit에 기반한 디자인 시스템 구현

  • Figma Admin Dashboard Kit에 기반해 플러그인의 어드민 대시보드 개발을 위한 디자인 시스템 구현.
  • 간결하고 직관적인 React 컴포넌트를 작성하기 위해 깊이 고민하고 노력함.
    • => <Callout /> 컴포넌트를 통해 10분만에 서비스 사용자를 위한 새로운 메시지 배너 작성 후 배포.
    • => <RoundedCard /><Container />, <HeadingWithLine />을 활용해 간결한 코드로 UI의 영역을 구분.
    • => colors 상수로 Figma Admin Dashboard Kit 디자인 시스템의 색상 팔레트를 일관성 있게 관리 및 활용.

아래 링크에서 위와 관련한 더 자세한 내용을 확인할 수 있습니다.

https://01joseph-hwang10.github.io/posts/admin-dashboard-ui-kit-implementation

Cafe24 API Auth Code 추출 자동화

  • 실제 카페24 API를 활용해 개발을 진행하기 위해서는 카페24 API의 Authorization Code Flow 인증 과정을 거쳐야 함.
  • 이에 작업을 위해 매번 브라우저를 열고 로그인 후 auth code를 추출하는 과정을 반복해야 하는 불편함이 존재했음.
  • 이러한 비효율을 없애기 위해 Selenium을 활용해 로그인 및 auth code 추출 과정을 자동화함.
  • => 개발 생산성 크게 향상. 더 많은 Iteration을 가능하게 함.

Admin Dashboard의 Auth Flow 테스트 효율화

  • Cafe24 API Auth Code 추출 자동화를 통해 개발 Iteration의 속도가 향상되었으나, auth code나 access token의 만료 등의 이유로 지속적으로 Auth Code 추출 스크립트를 실행해야 하는 불편함이 존재했음.
  • 또한 Auth Flow가 복잡했기 때문에 어느 부분에서 에러가 발생했는지 파악하기 어려웠음.
  • 이러한 문제를 해결하고자 Admin Dashboard의 Auth Flow를 테스트하기 위해 Cypress를 활용한 테스트 작성함.
  • => Iteration 속도 향상. 개발 생산성 향상.

Code Split과 <React.Suspense />를 활용한 초기 로딩 UX 개선

  • MUI, D3등 다소 무거운 라이브러리 활용으로 Admin Dashboard의 번들 크기가 커지는 문제가 있었음.
  • Code Split과 React.Suspense 를 활용해 초기 로딩 UX 개선.
    • 별도로 분리된 번들이 다운로드 되는 동안 fallback 인자로 <Loader fill />를 전달해 로딩 화면을 렌더링.
  • => 초기 로딩 화면을 렌더링하는 동안 사용자가 멈춰있는 빈 화면을 보는 것을 방지 -> 사용자 경험 향상의 효과.
AppRouter
const Overview = lazy(() => import("@src/features/overview"));
const Dashboard = lazy(() => import("@src/features/dashboard"));
const Store = lazy(() => import("@src/features/store"));
const DesignEditorScaffold = lazy(
() => import("@src/features/design/scaffold"),
);
const DefaultBannerDesign = lazy(
() => import("@src/features/design/default-banner-design"),
);
const ListingBannerDesign = lazy(
() => import("@src/features/design/listing-banner-design"),
);

const AppRouter = () => {
return (
<BrowserRouter>
<Routes>
<Route path={URI.root} element={<Initializer />} />
<Route path={URI.requestAccessToken} element={<AccessTokenFetcher />} />
<Route path={URI.confirmPayment} element={<ConfirmPayment />} />
<Route path={URI.error} element={<ErrorPage />} />
<Route
path={URI.dashboard.root}
element={
<Suspense fallback={<Loader fill />}>
<Dashboard />
</Suspense>
}
>
<Route
path={URI.dashboard.overview}
element={
<Suspense fallback={<Loader fill />}>
<Overview />
</Suspense>
}
/>
<Route
path={URI.dashboard.store}
element={
<Suspense fallback={<Loader fill />}>
<Store />
</Suspense>
}
/>
<Route
path={URI.dashboard.design.root}
element={
<Suspense fallback={<Loader fill />}>
<DesignEditorScaffold />
</Suspense>
}
>
<Route
path={URI.dashboard.design.defaultBanner}
element={
<Suspense fallback={<Loader fill />}>
<DefaultBannerDesign />
</Suspense>
}
/>
<Route
path={URI.dashboard.design.listingBanner}
element={
<Suspense fallback={<Loader fill />}>
<ListingBannerDesign />
</Suspense>
}
/>
</Route>
</Route>
</Routes>
</BrowserRouter>
);
};
See Also

Recommendation API

class-validator 데코레이터 디버깅 및 소스 코드 분석

  • 모노리포 구성으로 클라이언트와 공유가 필요한 DTO를 공통 라이브러리로 분리해 사용중인 상황.
  • class-validator를 통해 Decorate 된 공통 라이브러리의 DTO가 NestJS의 ValidationPipe 에 의해 타입이 검증되지 않는 문제 발견.
    • class-validator의 작동 방식을 알기 위해 소스 코드를 들여다보는 과정에서 내부적으로 MetadataStorage 객체를 이용해 Typescript -> Javascript 컴파일 과정에서 데코레이터 정보를 저장하고, 모노리포 내부의 라이브러리가 각각 별도의 class-validator 패키지를 설치하고 있어 발생하는 문제임을 확인.
  • class-validatorpeerDependencies로 추가해 모든 패키지가 하나의 MetadataStorage 객체를 공유하도록 함.
  • => 디버깅 과정을 통해 모노리포 상황이나 npm 패키지 퍼블리싱 상황에서 패키지 간의 호환성 이슈를 고려한 공유 라이브러리 구성이 중요함을 배움.

아래 링크에서 위와 관련한 더 자세한 내용을 확인할 수 있습니다.

https://01joseph-hwang10.github.io/posts/class-validator-debugging

상품 정보에 대한 Caching 구현

  • 추천에 필요한 상품 정보를 Firestore에서 가져오는 과정에서 발생하는 불필요한 읽기 과정을 줄이기 위해 인메모리 캐시 도입.
  • 상품 정보에 대한 캐싱 및 캐시 무효화 기능을 제공하는 NestJS 모듈 ActionProviderModule 구현.
  • => 도큐먼트 읽기 횟수 30,000,000/일 -> 1,500,000/일로 감소 -> 매우 큰 비용 절감 효과
ActionProviderService
@Injectable()
export class ActionProviderService {
constructor(
@Inject(MEMORY_CACHE) private readonly cache: Cache,
@InjectCollection(collection.mallSnapshot)
private readonly mallSnapshot: FirestoreService<MallSnapshotSchema>,
) {}

static actionsCacheKeyPrefix(mallId: string) {
return `actions-${mallId}`;
}

/**
* @description
* Returns actions of a given `mallId`
*
* If the actions are already cached, it will return the cached actions.
* If not, it will fetch the actions from Firestore and cache it.
*
* This method invalidates the cache if the lastSyncedAt or lastPopularityUpdatedAt
* of the mallSnapshot is different from the cached one.
*/
async getActions(mallSnapshot: MallSnapshot): Promise<ActionCache> {
// Get mallId from mallSnapshot
const mallId = mallSnapshot.mallId;

// Get cached actions
const actionCache = await this.cache.get<ActionCache>(
ActionProviderService.actionsCacheKeyPrefix(mallId),
);

// If the lastSyncedAt or lastPopularityUpdatedAt of the mallSnapshot is different
// from the cached one, invalidate the cache.
// Else, return the cached actions
if (this.cacheIsValid(actionCache, mallSnapshot)) {
return actionCache;
}

// Get new actions from Firestore and build a new cache
const actions = await mallSnapshot.actions.get();
const newCache = {
lastSyncedAt: mallSnapshot.lastSyncedAt,
lastPopularityUpdatedAt: mallSnapshot.lastPopularityUpdatedAt,
actions: actions.map((action) => action.toJSON()),
};

// Set new cache
await this.cache.set(
ActionProviderService.actionsCacheKeyPrefix(mallSnapshot.mallId),
newCache,
);

// Return new cache
return newCache;
}

private cacheIsValid(
actionCache: ActionCache,
mallSnapshot: MallSnapshot,
): boolean {
return (
actionCache &&
actionCache.lastSyncedAt === mallSnapshot.lastSyncedAt &&
actionCache.lastPopularityUpdatedAt ===
mallSnapshot.lastPopularityUpdatedAt
);
}
}
See Also

Firestore 복합 인덱스 관리를 위한 명시적 메서드 네이밍 컨벤션 수립

  • Firestore를 도입하고 여러 필드를 기준으로 쿼리를 수행하는 경우가 늘어나면서, 복합 인덱스에 포함된 프로퍼티, 정렬 기준 등을 혼동하는 경우가 빈번히 발생.
  • 모든 프로퍼티 이름을 포함하는 네이밍 컨벤션을 통해 복합 인덱스 관리를 명시적으로 하도록 함.
  • => 복합 인덱스가 필요한 쿼리를 수행하는 데 발생하는 휴먼 에러 발생률 감소 (컨벤션 수립 전 휴먼 에러 11번 -> 수립 후 2번)
  • => 복합 인덱스가 필요한 쿼리 구현에 필요한 시간 감소.
노트
네이밍 컨벤션 예시: listRewardRecordByMallIdIssuanceTypeTimerange
  • list: 목록을 반환
  • rewardRecord: reward-record 컬렉션에서
  • by: 다음의 필드를 기준으로
  • MallId: mallId 필드
  • IssuanceType: issuanceType 필드
  • Timerange: issuedDate 필드를 기준으로 class Timerange 클래스에 정의된 startDate, endDate 프로퍼티를 사용해 필터링

점진적인 코드베이스 이관을 통한 DB 마이그레이션

  • 버져닝, JSDoc Block Tags, backwardCompatible* 등의 패턴을 활용한 점진적인 코드베이스 이관으로 일괄 DB 마이그레이션이 불가능한 스키마에 새로운 변경 사항을 반영
See Also

아래 링크에서 위와 관련한 블로그 포스트를 확인할 수 있습니다.

Insert link here

상품 추천 속도 향상을 위한 로그 데이터 분석 및 코드 인스펙션

  • 특정 쇼핑몰에서 상품 추천 API의 응답 속도가 느린 것을 발견함.
  • GCL 로그를 내려받아 numpy, pandas, matplotlib을 활용해 각종 지표를 살펴보았으나, 위 쇼핑몰을 제외하고는 응답 속도가 예상대로 나타남.
  • 이에 상품 추천 API의 코드를 인스펙션하며, Date.now() 를 활용해 각 단계의 응답 속도를 측정함.
  • => Firestore로의 count 쿼리가 응답 지연의 원인임을 발견하고, count 쿼리를 제거해 응답 지연 문제 해결.
당시 로그 분석에 대한 시각화 자료
See Also

Leaky Bucket이 적용된 엔드포인트를 위한 HTTP Client 구현

  • 데이터 요청이 필요한 API의 Leaky Bucket Policy로 인해 1초에 2회 수준으로 API 호출이 제한됨.
  • 각 상품의 상세 정보를 하나씩 전부 요청해야 하는 상황에서 단순 API 호출로는 HTTP 429 에러 발생.
  • LeakyBucketClient 클래스를 구현해 호출의 빈도를 제한하고, 429 에러 발생 시 리퀘스트 재시도.
    • HTTP 에러 코드별로 Exception 클래스를 만들고 instanceof 연산자를 활용해 에러를 구분하고 대기 시간을 조절.
  • => 상품 추천에 필요한 데이터인 상품의 상세 정보를 전부 요청하는 작업을 가능케 함.
LeakyBucketClient
interface LeakyBucketClientOptions<T> {
mallId: string;
requests: (() => Promise<AxiosResponse<T>>)[];
maxLimit: number;
leakInterval?: number;
leakRate?: number;
/**
* @description
* If timeout is set, the function will be terminated after the timeout.
*/
timeout?: number;
}

/**
* @description
* This class is used to fetch data from an endpoint with leaky bucket policy.
*/
class LeakyBucketClient<T> {
public results: T[];
private currentAPIUsage: number;
private timeoutExceeded: boolean;
private state: "idle" | "running" | "finished";

constructor(private readonly options: LeakyBucketClientOptions<T>) {
this.currentAPIUsage = 0;
this.timeoutExceeded = false;
this.state = "idle";
}

private async getAPIUsageFromHeaders(
headers: AxiosResponse["headers"],
): Promise<number> {
const rawUsage = String(headers["x-api-call-limit"]).split("/")[0];
const parsedUsage = Number.parseInt(rawUsage);
return Number.isNaN(parsedUsage) ? 0 : parsedUsage;
}

private startTimeout() {
const timer = setTimeout(() => {
clearTimeout(timer);
this.timeoutExceeded = true;
}, this.options.timeout);
}

/**
* @description
* Compute wait time for the leak.
*
* @param decrementGoal The amount of decrementing the current limit.
*/
private computWaitTime(decrementGoal: number) {
return (decrementGoal / this.options.leakRate) * this.options.leakInterval;
}

/**
* @description
* Wait for the wait time, calculated by `computWaitTime` method.
*
* @param decrementGoal The amount of decrementing the current limit.
*/
private async waitForTheLeak(decrementGoal: number) {
// Define wait time and wait
const waitTime = this.computWaitTime(decrementGoal);
await wait(waitTime);

// Decrement current limit
this.currentAPIUsage -= decrementGoal;
}

/**
* @description
* We consider 502 and 429 errors as overflow errors.
* While considering 502 as overflow error is not strictly correct,
* we consider 502 as overflow error because it is highly likely that
* 502 error is caused by the same reason as 429 error.
*/
private async safeExecute<T>(
request: () => Promise<AxiosResponse<T>>,
): Promise<LeakyBucketFetchResponse<T>> {
try {
const result = await request();
this.currentAPIUsage = await this.getAPIUsageFromHeaders(result.headers);
return {
success: true,
data: result.data,
overflow: false,
badGateway: false,
};
} catch (error) {
this.currentAPIUsage = await this.getAPIUsageFromHeaders(error.headers);
if (error instanceof TooManyRequestsException)
return {
success: false,
data: null,
overflow: true,
badGateway: false,
};
if (error instanceof BadGatewayException)
return {
success: false,
data: null,
overflow: false,
badGateway: true,
};
throw error;
}
}

/**
* @description
* Execute @type {LeakyBucketClientOptions['requests']} under the leaky bucket policy.
*
* Note that this method is one-time use only.
*
* After the execution, the state of the client will be set to "finished",
* and any further execution will throw an error.
*
* If any further execution is needed, create a new instance of the client.
*/
async execute(): Promise<T[]> {
// Check if state is idle
if (this.state === "finished") {
throw new Error("[LeakyBucketClient] This client has already finished.");
}
if (this.state === "running") {
throw new Error("[LeakyBucketClient] This client is already running.");
}

// Set state to running
this.state = "running";

// Start timeout for preventing infinite loop
if (this.options.timeout) this.startTimeout();

// Request resources
while (this.results.length < this.options.requests.length) {
// If timeout is exceeded, throw an error
if (this.timeoutExceeded) {
throw new ServiceUnavailableException(
"[LeakyBucketClient] Couldn't finish the process in time.",
);
}

// Decide paginating amount based on current api limit
const pagination = this.options.maxLimit - this.currentAPIUsage;

// Wait for the leak for further requests
if (pagination < 0) {
// Set decrement goal
const decrementGoal = Math.abs(pagination) + this.options.maxLimit;

// Wait for bucket to be leaked
await this.waitForTheLeak(decrementGoal);
continue;
}

// Paginate promises to be sent
const chunk = this.options.requests.slice(
this.results.length,
Math.min(
this.results.length + pagination,
this.options.requests.length + 1,
),
);

// Invoke promises
const chunkResults = await Promise.all(
chunk.map((each) => this.safeExecute(each)),
);

// Check if there's a 429 or 502 response and count
// 429 response is counted as 1 and 502 response is counted as 5
// (i.e. we wait 2 seconds for 429 response and 10 seconds for 502 response)
const overflowedRequestCount = chunkResults
.map(({ overflow, badGateway }) => (overflow ? 1 : badGateway ? 5 : 0))
.reduce((acc, cur) => acc + cur, 0);

// If there's a 429 response, wait for the leak interval with amount of the number of overflowed requests
if (overflowedRequestCount > 0) {
// Calculate remaining request and set decrement goal
const remainingRequest = this.options.requests.slice(
this.results.length,
);
const decrementGoal =
overflowedRequestCount +
Math.min(this.options.maxLimit, remainingRequest.length);

// Wait for bucket to be leaked
await this.waitForTheLeak(decrementGoal);
continue;
}

// Push results to the result array
this.results.push(...chunkResults.map(({ data }) => data));
}

// Set state to finished
this.state = "finished";

return this.results;
}
}
See Also

Bandit Engine 서비스와의 연동을 위한 HTTP Client 구현

BanditClient
@Injectable()
export class BanditClient {
private endpoints: Record<string, string>;

constructor(options: BanditClientOptions) {
this.endpoints = {
reward: AppURL.bandit("model", options.type, "reward"),
getRecommendations: AppURL.bandit(
"model",
options.type,
"get-recommendations",
),
processProducts: AppURL.bandit(
"model",
options.type,
"data_processor",
"products_to_actions",
),
updatePopularities: AppURL.bandit(
"model",
options.type,
"data_processor",
"update_popularities",
),
};
}
/**
* @description
* Bandit Engine에 리퀘스트를 보낼 적의 헤더입니다.
* 헤더에는 Bandit Engine API Key가 포함됩니다.
*/
private defaultHeaders: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
"X-API-KEY": process.env.BANDIT_ENGINE_API_KEY,
};

/**
* @description
* Bandit Model을 통해 추천 상품을 가져옵니다.
*
* @param {GetRecommendationsPayload} payload 추천 상품을 요청하기 위해 필요한 정보
* @returns {Promise<RecommendationInfo[]>} 추천 상품 ID (`productNo`) 목록
*/
async getRecommendedActions(
payload: GetRecommendationsPayload,
): Promise<RecommendationMetadata[]> {
const debugInfo = {
mallId: payload.mallId,
rootClickNo: payload.rootClickNo,
productToIgnore: payload.productToIgnore,
lastClickedProductNos: payload.lastClickedProductNos.join(",") || null,
postClickNum: payload.postClickNum,
};
const response = await this.createRequest<
GetRecommendationsPayload,
GetRecommendationsResponse
>(this.endpoints.getRecommendations, payload, debugInfo);
return response.recommendedActions;
}

/**
* @description
* Bandit Model에 리워드를 feed 합니다.
*
* @param {RewardPayload} payload 리워드를 feed 하기 위해 필요한 정보
* @returns {Promise<MallContext>} 업데이트된 쇼핑몰의 context 정보
*/
async reward(payload: RewardPayload): Promise<MallContext> {
const debugInfo = {
mallId: payload.mallId,
};
const response = await this.createRequest<RewardPayload, RewardResponse>(
this.endpoints.reward,
payload,
debugInfo,
);
return response.mallContext;
}

/**
* @description
* Bandit 모델을 통해 상품을 context로 변환합니다.
*
* 이는 추천을 요청할 때마다 상품의 context를 계산하는 것이 아니라,
* 상품의 context를 미리 계산하여 저장해두고, 추천 요청 시에는 저장된 context를 사용하도록 하기 위함입니다.
* 이를 통해 추천 요청 시에 상품의 context를 계산하는 시간을 줄일 수 있어, 추천 속도를 높일 수 있습니다.
*
* @param {ProcessProductsPayload} payload 상품을 context로 변환하기 위해 필요한 정보
* @returns {Promise<ProcessProductsResponse>} 상품의 context 정보
*/
async processProductsToContextVector(
payload: ProcessProductsPayload,
): Promise<KeysToCamelCase<ProcessProductsResponse>> {
const debugInfo = {
mallId: payload.mallId,
};
const response = await this.createRequest<
ProcessProductsPayload,
ProcessProductsResponse
>(this.endpoints.processProducts, payload, debugInfo);
return response;
}

/**
*
* @param {UpdatePopularitiesPayload} payload 상품의 인기도를 업데이트하기 위해 필요한 정보
* @returns
*/
async updatePopularities(
payload: UpdatePopularitiesPayload,
): Promise<ReturnType<ProductContext["toJSON"]>[]> {
const debugInfo = {
mallId: payload.mallId,
};
const response = await this.createRequest<
UpdatePopularitiesPayload,
UpdatePopularitiesResponse
>(this.endpoints.updatePopularities, payload, debugInfo);
return response.actions;
}

/**
* @description
* Bandit Engine 서버에 리퀘스트를 보내는 메소드입니다.
*
* @param url 요청을 보낼 HTTP URL
* @param payload 요청의 payload
* @param params 요청의 query string.
* 모든 요청이 HTTP POST로 이루어지기 때문에 query string은 요청에 영향을 주지는 않습니다.
* GCL에서 모니터링을 위해 사용됩니다.
* @returns
*/
private async createRequest<P = any, R = any>(
url: string,
payload: P,
params?: Record<string, string | number | boolean>,
): Promise<KeysToCamelCase<R>> {
try {
const { data } = await axios.post<R>(
url,
objectFromCamelToSnake(payload),
{ headers: this.defaultHeaders, params },
);
return objectFromSnakeToCamel(data);
} catch (error) {
const status: Maybe<string> = error?.response?.status;
if (status?.toString()?.at(0) === "4") {
throw new BadRequestException(error);
}
throw new BanditException(error);
}
}
}
  • snake_case를 사용하는 Flask 서비스와 camelCase를 사용하는 NestJS 서비스 간의 데이터 교환을 위해 objectFromSnakeToCamel, objectFromCamelToSnake 함수를 구현.
objectFromSnakeToCamel
export type KeysToSnakeCase<T> = T extends (infer U)[]
? U extends Record<string, any>
? KeysToCamelCase<U>[]
: U[]
: {
[K in keyof T as SnakeCase<string & K>]: T[K] extends Record<string, any>
? KeysToSnakeCase<T[K]>
: T[K];
};

export const camelToSnake = (str: string): string => {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
};

/**
* @description
* Converts object's keys from camel case to snake case recursively.
*/
export const objectFromCamelToSnake = <
T extends Record<string, any> = Record<string, any>,
>(
obj: T,
): KeysToSnakeCase<T> => {
return Object.keys(obj).reduce((acc, key) => {
const value = obj[key];
if (isPrimitive(value)) {
acc[camelToSnake(key)] = value;
return acc;
}
if (Array.isArray(value)) {
acc[camelToSnake(key)] = value.map((item) =>
isPrimitive(item) ? item : objectFromCamelToSnake(item),
);
return acc;
}
if (typeof value === "object") {
acc[camelToSnake(key)] = objectFromCamelToSnake(value);
return acc;
}
}, {} as any) as KeysToSnakeCase<T>;
};
objectFromSnakeToCamel
export type KeysToCamelCase<T> = T extends (infer U)[]
? U extends Record<string, any>
? KeysToCamelCase<U>[]
: U[]
: {
[K in keyof T as CamelCase<string & K>]: T[K] extends Record<string, any>
? KeysToCamelCase<T[K]>
: T[K];
};

export const snakeToCamel = (str: string): string => {
return str.replace(/([-_][a-z])/g, (group) =>
group.toUpperCase().replace("-", "").replace("_", ""),
);
};

/**
* @description
* Converts object's keys from snake case to camel case recursively.
*/
export const objectFromSnakeToCamel = <
T extends Record<string, any> = Record<string, any>,
>(
obj: T,
): KeysToCamelCase<T> => {
return Object.keys(obj).reduce((acc, key) => {
const value = obj[key];
if (isPrimitive(value)) {
acc[snakeToCamel(key)] = value;
return acc;
}
if (Array.isArray(value)) {
acc[snakeToCamel(key)] = value.map((item) =>
isPrimitive(item) ? item : objectFromSnakeToCamel(item),
);
return acc;
}
if (typeof value === "object") {
acc[snakeToCamel(key)] = objectFromSnakeToCamel(value);
return acc;
}
}, {} as any) as KeysToCamelCase<T>;
};
See Also

GCE 인스턴스의 퍼블릭 도메인 연결 실패 문제 해결

  • GCE 인스턴스 그룹 (MIG) 이 외부 API의 웹주소를 resolve 하지 못하는 문제 발생.
    • nslookup을 통해 NestJS 서버의 문제가 아닌 GCE 인스턴스의 문제임을 확인함.
    • resolv.conf 파일 확인 결과 Public DNS 네임서버가 등록되어 있지 않고 VPC 네트워크의 네임서버만 등록되어 있었음.
    • VPC에 대해 더 알아보는 과정에서 인스턴스 생성 시, 외부 IP 주소를 할당해야만 인스턴스가 외부와 통신할 수 있음을 발견함.
    • => Autoscaler에 의한 수평 확장 시, 프로젝트의 외부 IP 할당량 부족으로 인스턴스가 외부 IP를 할당받지 못하는 문제 -> 외부 IP 할당량을 늘려 문제 해결.
  • => DNS 네임서버, VPC, 방화벽 등 네트워크에 관한 심화된 이해 함양.
References

긴 프로세스를 담당하는 Worker 인프라 구축 및 배포 자동화

  • 기존에는 모든 서비스를 Google Cloud Run을 이용해서 배포했음.
    • 데이터 요청이 필요한 API의 Leaky Bucket Policy로 인해, 상품 정보를 자사 Firestore DB로 가져오는 프로세스가 길어지면서 Cloud Run의 프로세스 제한 시간인 1시간을 초과하는 문제 발생.
  • 프로세스 제한 시간이 없는 GCE 인스턴스 그룹과 Cloud Load Balancer를 활용한 Worker 인프라를 구축하여 이슈 해결.
    • Cloud Build Substitution Variable 과 Docker Build Args를 활용해, Cloud Run과 Worker 인프라 배포 코드 일반화.
  • => Cloud Build와 Docker를 이용한 CI/CD 자동화로 인력 소비 최소화 및 GCE Instance Group과 GCLB를 활용한 시스템의 유연성 확보.
  • => 인프라 구축 과정에서 외부 API의 제약사항을 고려한 Worker 인프라 구축과정에서 실제 프로덕션 환경에서의 문제 해결 능력 향상
cloudbuild.yaml
# Following substitutes are required:
#
# - _PROJECT: project name (folder name among source code)
# - _DEPLOY_TO: 'GCR' | 'GCE' | 'None'
# - _SERVICE_NAME: service name (Service name used among GCP. It's the name related with Container Registry)
# - _INSTANCE_GROUP_NAME: GCE instance group name (only needed when deploy to GCE)

steps:
# Pull the cached image
- name: "gcr.io/cloud-builders/docker"
entrypoint: "bash"
args:
- "-c"
- "docker pull ${_IMAGE_URL}:latest || exit 0"

# Build docker image for the project
- name: gcr.io/cloud-builders/docker
id: Build
args:
- build
- "--build-arg"
- "PROJECT=${_PROJECT}"
- "--cache-from=${_IMAGE_URL}:latest"
- "--network=cloudbuild"
- "-t"
- "${_IMAGE_URL}:${COMMIT_SHA}"
- "-t"
- "${_IMAGE_URL}:latest"
- .
- "-f"
- ci/api/Dockerfile

# Push the image
- name: gcr.io/cloud-builders/docker
id: Push
args:
- push
- --all-tags
- ${_IMAGE_URL}

# Deploy the image
- name: "gcr.io/google.com/cloudsdktool/cloud-sdk:slim"
id: Deploy
entrypoint: bash
env:
- "SERVICE_NAME=${_SERVICE_NAME}"
- "DEPLOY_TO=${_DEPLOY_TO}"
- "IMAGE_URL=${_IMAGE_URL}:${COMMIT_SHA}"
- "REGION=${_DEPLOY_REGION}"
- "PROJECT_ID=${PROJECT_ID}"
- "INSTANCE_GROUP_NAME=${_INSTANCE_GROUP_NAME}"
args:
- ./ci/api/deploy.sh

images:
- ${_IMAGE_URL}

options:
substitutionOption: ALLOW_LOOSE

substitutions:
_GCR_HOSTNAME: asia.gcr.io
_IMAGE_URL: ${_GCR_HOSTNAME}/${PROJECT_ID}/${REPO_NAME}/${_SERVICE_NAME}
_DEPLOY_REGION: asia-northeast3

tags:
- ${_SERVICE_NAME}
deploy.sh
#!/bin/bash

# Following environment variables are given:
#
# - PROJECT_ID: project id of GCP project
# - SERVICE_NAME: service name (GCR service name, only required when "DEPLOY_TO" is 'GCR')
# - DEPLOY_TO: 'GCR' | 'GCE' | 'None'
# - IMAGE_URL: image url (GCR image url)
# - REGION: region to deploy (only required when "DEPLOY_TO" is 'GCR')
# - INSTANCE_GROUP_NAME: instance group name (only required when "DEPLOY_TO" is 'GCE')

# Exit on any error
set -e

# If DEPLOY_TO is 'None', exit
if [ "$DEPLOY_TO" == "None" ]; then
echo "Nothing to deploy"
exit 0
fi

# If DEPLOY_TO is 'GCR', deploy to GCR
if [ "$DEPLOY_TO" == "GCR" ]; then
echo "Deploying to GCR"
gcloud run services update $SERVICE_NAME --platform=managed --image=$IMAGE_URL --region=$REGION --quiet
exit 0
fi

# If DEPLOY_TO is 'GCE', deploy to GCE
if [ "$DEPLOY_TO" == "GCE" ]; then
echo "Deploying to GCE"
# Replace VMs
# We replace VMs as we need to update the docker image inside the template.
# Replacing VMs will update the docker image inside the template.
gcloud compute instance-groups managed rolling-action replace \
$INSTANCE_GROUP_NAME \
--max-surge=3 \
--max-unavailable=3 \
--replacement-method=substitute \
--region=$REGION
exit 0
fi

# If DEPLOY_TO is not 'GCR' or 'GCE', exit
echo "Invalid DEPLOY_TO: $DEPLOY_TO"
exit 1
Dockerfile
ARG PROJECT

# Pull node image as build
FROM node:16-alpine as build
ARG PROJECT

# Set working directory
WORKDIR /app

# Copy package.json, and .npmrc
COPY package*.json ./
COPY .npmrc .

# Install dependencies
RUN npm run registry:login
RUN npm ci

# Set working directory
WORKDIR /app

# Copy source files
COPY [ "nest-cli.json", "tsconfig.json", "tsconfig.build.json", "./"]
COPY libs ./libs
COPY apps/${PROJECT} ./apps/${PROJECT}

# Build app
RUN npm run ${PROJECT}:build:prod

# Install only production dependencies
RUN npm prune --production

# Create another build stage
FROM node:16-alpine
ARG PROJECT
ENV PROJECT=${PROJECT}

# Set working directory
WORKDIR /app

# Copy from build image
COPY --from=build /app/package*.json ./
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules

# Expose port
EXPOSE 3000

# Start app
CMD npm run $PROJECT:start:prod
See Also

GCE MIG Autoscaler 의 인스턴스 종료로 인한 프로세스 중단 문제 해결

  • GCE 관리형 인스턴스 그룹 (MIG) Autoscaler가 수평 축소를 하면서 인스턴스를 종료하는 과정에서, 종료되는 instance에 할당된 프로세스를 중단하는 문제가 발생함.
    • 수평 축소 제어를 사용하지 않는 Autoscaler의 경우 최근에 관찰된 부하를 처리하는 데 필요한 인스턴스만 유지. (Reference)
    • 긴 시간이 소요되는 프로세스의 인스턴스가 종료의 대상이 되어 프로세스가 중단되는 문제 발생.
  • NestJS의 OnApplicationShutdown 인터페이스를 구현하여 프로세스 종료 시점에 진행중인 작업을 재시작하는 로직을 구현해 이슈 해결.
    • Fire and forget 방식으로 request를 보내 종료 프로세스에 영향을 주지 않도록 함.
  • => GCE MIG와 Autoscaler에 관한 깊은 이해를 얻음. 각 Cloud Service의 특성을 고려한 시스템 구현.
MallSnapshotService.onApplicationShutdown
@Injectable()
export class MallSnapshotService implements OnApplicationShutdown {
constructor(/* Injected dependencies */) {}

async onApplicationShutdown() {
const cacheKeys = await this.cache.store.keys();
const syncCacheKeys = cacheKeys.filter((key) => key.startsWith("sync-"));
await Promise.all(syncCacheKeys.map((key) => this.cache.del(key)));
for (const key of syncCacheKeys) {
this.logger.log(
`Requesting to available instance to sync the mall for ${key} before shutdown`,
);
const payload = { mallId: key.replace("sync-", "") };
const headers = {
"X-API-KEY": process.env.WORKER_API_KEY,
};
// Fire and forget to make sure it does not block the shutdown process
axios.post(AppURL.worker("/feature/update/snapshot"), payload, {
headers,
});
}
}
}
See Also

Bandit Engine

모델 파라미터 압축을 통한 성능 향상

  • picklebase64를 활용한 Bandit Model 파라미터 직렬화 과정에서 데이터의 크기가 너무 커 HTTP 413 에러를 반환하는 문제를 발견함.
  • numpy.array가 기본으로 np.float64 데이터 타입을 사용하는 것을 확인하고 np.float32, gzip 을 도입하고 파라미터를 압축, 압축해제하는 로직을 추가함.
  • => 모델 파라미터 크기 10배 축소, 상품 추천 응답 시간 2배 증가

점진적인 코드베이스 이관을 통한 서비스 장애 방지

  • Bandit 모델의 버전이 많아짐에 따라 각 모델 버전을 engines 폴더 아래에 디저트 이름을 붙여 모듈화함.
  • 이와 함께 엔드포인트 구조를 체계적으로 변경하는 과정에서 기존 엔드포인트를 제공하는 blueprint 가 사라지면 서비스에 장애가 발생함을 인지함.
  • 기존의 코드베이스와의 호환성을 유지하기 위해 기존 코드베이스에 존재하는 엔드포인트를 새로운 엔드포인트로 Redirect 하도록 구현함.
  • => 서비스 간의 업데이트 속도 차이로 인해 발생하는 서비스의 일시적인 장애 없이 코드베이스 이관할 수 있었음.

데코레이터 패턴을 활용한 AI 팀과의 협업 효율화

  • 데코레이터 패턴을 활용해 각 엔드포인트의 핸들러 함수를 데코레이터로 감싸 에러 핸들링, 인증 등의 공통된 로직을 분리함.
  • TypedDict를 활용한 DTO 작성 및 Python Type Hint 기능을 활용해 각 핸들러 함수의 인자와 반환값을 명시함.
  • => 웹과 관련한 요청/응답 로직을 데코레이터를 통해 추상화하여 단순한 IPO (Input-Process-Output) 프로그램으로 문제를 단순화.
  • => 신입 AI 팀원도 별도의 웹 관련 교육 없이 바로 업무에 참여 가능한 수준으로 웹에 익숙하지 않은 AI 팀의 업무 효율화.
Before
@bp.route("/get-recommendations", methods=["POST", "OPTIONS"])
def get_recommended_actions():
if request.method == 'OPTIONS':
response = jsonify('Preflight Response')
add_headers(response)
return response

if request.method == 'POST':
try:
authenticate_request(request)

payload = request.get_json()
context: MallContext = payload["mall_context"]
post_click_num: int = int(payload["post_click_num"])
product_to_ignore = int(payload["product_to_ignore"])
root_click_no: int = int(payload["root_click_no"])
actions = payload["actions"]
actions = [Action.from_json(action) for action in actions]
latest_clicked_product_nos: List[int] = payload["last_clicked_product_nos"]
pca_bool: bool = actions[0].pca

final_recommended_product_nos = engine.get_recommendations(
actions=actions,
last_clicked_product_nos=latest_clicked_product_nos,
root_click_id=root_click_no,
context=context,
post_click_num=post_click_num,
product_to_ignore=product_to_ignore,
pca=pca_bool,
)

response: GetRecommendationsOutput = {
"recommended_actions": final_recommended_product_nos,
}

response = jsonify(response)
add_headers(response)
return response

except AuthenticationError:
response = make_response('Authentication failed', 401)
add_headers(response)
return response

except ActionNotFoundError:
response = make_response(
'Action not found. Please sync your mall product', 400)
add_headers(response)
return response

except:
print_exc()
response = make_response('Internal server problems', 500)
add_headers(response)
return response
After
@bp.route("/get-recommendations", methods=["POST", "OPTIONS"])
@handler.default
def get_recommended_actions(payload: GetRecommendationsInput) -> GetRecommendationsOutput:
context: MallContext = payload["mall_context"]
post_click_num: int = int(payload["post_click_num"])
product_to_ignore = int(payload["product_to_ignore"])
root_click_no: int = int(payload["root_click_no"])
actions = payload["actions"]
actions = [Action.from_json(action) for action in actions]
latest_clicked_product_nos: List[int] = payload["last_clicked_product_nos"]
pca_bool: bool = actions[0].pca

final_recommended_product_nos = engine.get_recommendations(
actions=actions,
last_clicked_product_nos=latest_clicked_product_nos,
root_click_id=root_click_no,
context=context,
post_click_num=post_click_num,
product_to_ignore=product_to_ignore,
pca=pca_bool,
)

return {
"recommended_actions": final_recommended_product_nos,
}
See Also