추천 시스템의 심장, Feature Store 이야기 (1)—혼란 속의 질서 찾기: Feature Store를 구축하다
Intro
안녕하세요. ML Data Platform 팀에서 서버 엔지니어로 일하고 있는 Miti입니다!
“머신러닝”, “개인화”, “추천”.
이제는 모두에게 익숙한 단어들일 텐데요. 요즘은 유튜브, 인스타그램, 틱톡, X 등 대부분의 서비스가 머신러닝을 활용한 콘텐츠 추천 기능을 제공하고 있어요. 당근도 수년 전부터 유저들이 더 좋아하고 관심 가질 만한 콘텐츠를 제공하기 위해 추천 시스템을 구축해 왔고, 현재도 계속 고도화해나가고 있어요.
보통 추천 시스템의 핵심 요소로 머신러닝 모델이나 딥러닝 알고리즘만을 떠올리곤 하는데요. 추천 시스템에는 그에 못지않게 중요한 핵심 요소가 하나 더 존재해요. 바로 Feature Store라는 플랫폼이죠. Feature Store는 추천 모델과 알고리즘의 추론 과정에 필요한 수많은 데이터들을 제공하는 역할을 해요. 모델과 알고리즘이 추천 시스템의 두뇌라면, Feature Store는 추천 시스템의 심장인 셈이죠!
이번 시리즈에서는 Feature Store를 만들게 된 배경과 Feature Store를 구축하는 과정에서 사용했던 기술들, 그리고 시스템을 설계하고 운영하면서 내렸던 여러 가지 의사결정들을 공유해보려고 합니다. 이 시리즈는 다음 세 가지 주제에 따라 총 세 개의 글로 나누어 작성할 예정이에요.
- In-house Feature Store를 만들게 된 계기와 그 여정.
- 한계에 다다른 Feature Store, 한계 돌파를 위한 새로운 설계.
- 더 빠르고, 더 견고한 플랫폼을 위한 여러 가지 최적화 기법들.
첫 번째 파트에서는 Feature Store를 만들게 된 배경과 요구사항 수집, 설계, 개발까지 이르는 전반적인 Feature Store 구축 과정을 이야기하려고 해요. 두 번째 파트에서는 계속 늘어나는 트래픽으로 인해 겪었던 성능적인 한계와 운영 중에 겪었던 기능적인 한계를 설명하고, 이를 극복하기 위해 새롭게 개발한 Feature Platform(Feature Store의 v2 버전이라고 생각하시면 돼요)을 다뤄보려고 해요. 마지막 세 번째 파트에서는 Feature Store와 Feature Platform의 성능을 견고하게 개선하기 위해 적용했던 여러 가지 최적화 기법과 의사결정을 소개할 예정이에요.
그럼 이제 본격적으로 첫 번째 주제인 Feature Store 개발기에 관한 이야기를 해볼까요? 과거로 돌아가 Feature Store를 만들게 된 배경을 가볍게 되짚어 본 다음, In-house Feature Store를 구축해 온 여정을 함께 살펴보도록 합시다!
니즈 (Needs)
당근에서 Feature Store의 필요성을 느낀 건 2021년 초반, 추천 시스템이 도입된 지 약 2년 정도 지난 시기였어요. 당시 당근은 추천 시스템을 적극적으로 활용하면서 여러 지표에서 큰 성장을 이루고 있었어요. 최신순 피드를 넘어 각 유저에게 맞춤화된 피드를 보여주게 되면서 유저들의 만족도 또한 높아졌죠. 추천 시스템이 만들어내는 임팩트가 점점 커지면서 자연스럽게 추천 시스템 고도화라는 과제를 마주하게 됐죠.
크게 두 가지 측면에서 추천 시스템 고도화를 생각해 볼 수 있어요. 하나는 추천 ML 모델 또는 알고리즘 자체를 고도화하는 방법, 다른 하나는 모델의 입력값으로 넣어주는 피처의 종류 혹은 개수를 튜닝하는 방법이에요. 중고거래 게시글 추천 모델을 예시로 생각해 볼까요? 과거에 클릭했던 중고거래 게시글 목록뿐만 아니라 최근에 검색했던 검색 키워드 목록처럼 보다 능동적인 피드백 피처까지 함께 입력 피처로 사용한다면, 좀 더 개인화된 추천 결과를 제공할 수 있어요.
위 예시는 저희가 당시 실제로 고민하고 계획했던 내용인데요. 검색 키워드 목록뿐만 아니라 여러 가지 다양한 유저 피처들을 추가 및 변경하면서 실험을 해보고 싶다는 니즈가 생겼어요. 곧바로 여러 유저 피처들을 추가하고 변경해 가며 실험을 진행했죠. 그런데 그 과정에서 다음과 같은 몇 가지 불편한 점을 발견했어요.
- 추천 로직의 중고거래 서버 의존성.
- 추천 로직과 Feature의 확장성.
- Feature 데이터 소스의 신뢰도.
추천 로직의 중고거래 서버 의존성
당근의 초기 추천 시스템은 별도의 서버가 아닌 중고거래 웹앱 서버의 내부 라이브러리 형태로 존재했었어요. 때문에 추천 로직을 변경하거나 피처를 추가할 때마다 웹앱의 거대한 소스 코드를 건드려야 했고, 다른 기능들의 업데이트가 동시에 이루어질 경우 자유롭게 배포할 수도 없었어요.
또한 중고거래 API 서빙에 최적화된 아키텍처와 코드 베이스를 공용으로 사용하면서 중고거래 API 서버와 같은 파드에서 트래픽을 처리했기 때문에, 추천 시스템의 리소스 사용량이나 레이턴시를 예측하고 최적화하기가 어렵다는 문제도 있었어요.
추천 로직과 Feature의 확장성
두 번째 문제는 추천 로직과 피처 관리의 확장성이 부족하다는 점이었습니다.
초기 추천 시스템은 중고거래 게시글만을 고려했기 때문에, 나중에 생겨난 동네생활, 알바, 부동산, 중고차 등 다른 게시글 타입에는 추천 로직을 적용할 수 없었어요. 물론 중고거래 웹앱이 다른 게시글에 대한 추천을 제공하는 것 자체가 이미 적절하진 않았죠.
또한 유저 피처와 중고거래 게시글 피처들의 정의, 피처를 조회할 때 사용하는 각종 파라미터(개수, 조회 기간, 구분자 등), 피처를 변환하는 방식들이 코드 곳곳에 하드코딩되어 있었어요. 때문에 피처를 탐색, 추가, 변경, 튜닝하는 과정들이 상당히 번거롭고 직관적이지 않았어요.
Feature 데이터 소스의 신뢰도
마지막 문제는 피처의 데이터 소스를 신뢰하기 어렵다는 점이었습니다.
초기 추천 시스템에서는 피처를 다양한 저장소에서 직접 조회했는데요. 유저 정보 피처는 중고거래 DB, 행동 로그 피처는 Redis, 임베딩 벡터 피처는 S3에서 조회하는 등 여러 타입의 저장소가 존재했고, 각 저장소에서는 저마다 다른 스키마와 형태로 피처를 저장하고 있었어요. 즉 피처에 대한 일관된 스키마와 수집 파이프라인, 조회 인터페이스가 모두 부재했다는 의미죠.
이처럼 저장소와 스키마, 피처 수집 파이프라인들이 파편화됐기 때문에, 피처를 사용하는 입장에서는 각 피처가 어떤 원천 데이터를 기반으로 어떻게 수집되는지 파악하기가 어려웠어요. 이는 데이터 소스뿐만 아니라 최신성, 정합성 등 피처의 품질을 신뢰할 수 없다는 문제를 야기했어요.
위와 같은 이유들로 독립적인 추천 시스템의 필요성을 느끼게 됐어요. 하나로 합쳐져 있던 추천 모듈과 피처 모듈을 각각 추천 서버와 피처 저장소 서버로 나누어, 추천 로직과 피처 데이터를 디커플링 하기로 했어요. 이로써 파편화된 피처들을 일관된 스키마와 파이프라인으로 수집하고 서빙하기 위한 Feature Store 프로젝트를 시작하게 됐어요.
인하우스로 만들게 된 이유는?
이미 Industry에는 이름이 같은 Feature Store라는 컨셉과 여러 가지 오픈소스 프로젝트가 있었어요. 하지만 당시에는 Feature Store라는 컨셉의 존재를 알지 못했고, 디테일한 내부 요구사항에 최적화된 설계를 구상하면서 자연스럽게 인하우스 플랫폼으로 만들게 됐어요.
요구사항 정리
Feature Store를 독립적인 서비스로 분리하면서 기존 요구사항뿐만 아니라 몇 가지 요구사항들이 추가됐어요. 그렇게 나온 기능적인 요구사항 목록은 다음과 같아요.
- 사용자가 최근 수행한 top N개의 action을 기록하고 빠르게 서빙할 수 있어야 함.
- 단, top N 값과 함께 조회 기간도 파라미터로 받을 수 있어야 함. - Action 피처뿐만 아니라 현재 지역 정보 같은 사용자 특성 피처들도 고려해야 함.
- 중고거래 타입의 게시글뿐만 아니라 임의의 타입의 게시글 피처들도 고려해야 함.
- 모든 피처는 임의의 타입 (primitive type, list, set, map…)의 값을 가질 수 있음.
- Action 피처와 특성 피쳐들은 모두 실시간으로 업데이트돼야 함.
(참고로 유저의 행동 이벤트와 CDC 이벤트 큐는 이미 존재하는 상황이었어요.) - 피처의 목록과 개수, 조회 기간은 매 피처 요청마다 달라질 수 있음.
정리하자면, 임의의 값을 갖는 피처들을 실시간으로 수집하여 일관된 스키마로 저장한 다음, 이를 다양한 형태의 쿼리로 조회하여 빠르게 서빙해 줄 수 있는 플랫폼이 요구사항이었어요. 언뜻 보면 꽤 추상적으로 보이는 요구사항이었지만, 오히려 명확한 그림이 그려지기도 했는데요. 데이터 수집 파이프라인, 저장 및 서빙 스키마를 설계할 때, 처음부터 일반화된 구조로 설계하면 됐기 때문이죠.
기능적 요구사항 외에도 다음과 같은 기술적 요구사항이 있었는데요.
- 서빙 트래픽: 1.5k+ rps
- 수집 트래픽: 400+ wps
- top N 값: 30~50
- 피처 값 크기: ~8KB
- 총 피처 개수: 3B+
당시에는 아무래도 사용하던 피처의 종류와 개수, 추천 모델의 종류가 적었기 때문에 기술적 요구 수준이 높지 않았어요. 하지만 당시의 성장 속도를 고려하면 앞으로 요구 수준이 높아질 게 뻔한 상황이었죠. 따라서 실제 자체 달성 목표는 위의 수준보다 더 높게 잡았어요.
실제로 현재 기준으로 트래픽의 경우에는 서빙과 수집 모두 당시 요구 수준의 약 90배, 총 피처 개수는 수백 배 이상 커졌답니다.
시스템 디자인
요구사항들을 어느 정도 정리하고 나서는 본격적으로 Feature Store를 설계했어요. 시스템 디자인의 경우에는 1) 전반적인 시스템 구조와 컴포넌트 배치 등의 고수준 아키텍처를 설계한 다음, 2) 데이터 저장 방식을 모델링하고, 3) 이를 기반으로 상세 인터페이스 및 기술 스택과 같은 디테일을 채워갔어요. 그럼 먼저 아키텍처 설계부터 살펴보도록 할게요.
아키텍처 설계
Feature Store는 다음과 같은 세 가지 컴포넌트로 구성할 수 있어요.
- Feature 레지스트리
- Feature 서빙 서버
- Feature 수집 워커
Feature 레지스트리
Feature 레지스트리는 유저가 정의한 피처의 스펙을 등록하고 관리하기 위한 컴포넌트예요. 이 레지스트리는 서빙 서버와 워커 서버에 모두 접근할 수 있어야 하며, 요구사항에 따라 다양한 구현 방식이 존재할 수 있어요. 당시에는 레지스트리에 대한 명시적인 요구사항은 없었지만, 배포 없이 피처를 추가하고 수집할 수 있도록 원격 관리가 가능한 형태로 구상했어요.
Feature 서빙 서버
Feature 서빙 서버는 클라이언트로부터 피처 조회 쿼리를 받아 스토리지로부터 피처 목록을 빠르게 조회하여 서빙해 주는 컴포넌트예요. 필요한 경우 단순 조회뿐만 아니라 피처 간의 조인 또한 서빙 서버에서 이루어져요. 또한 실시간 비즈니스 로직 연산이 필요하여 Feature Store 저장소에 저장하기 어려운 온디맨드 피처들도 있는데요. 서빙 서버는 이러한 피처를 해당 피처의 원본 데이터를 가지고 있는 다른 마이크로 서비스로부터 조회해오기도 해요. 예를 들어, 유저의 차단 유저 목록과 같은 피처가 있어요
피처 조인이란?
말 그대로 서로 다른 피처 간의 조인을 의미해요. 예를 들어, 과거에 클릭했던 중고거래 게시글 목록과 같은 피처들은 보통 게시글의 아이디만을 가지고 있어요. 따라서 게시글 자체에 대한 정보는 알 수 없죠. 이때, 이 게시글 아이디를 참조키로 사용하여 게시글 피처를 조회한 뒤 게시글 정보를 채워주게 되면, 게시글 정보를 포함한 완전한 형태의 피처를 만들 수 있어요. (물론, 조인은 원하는 경우에만 선택적으로 이루어져요.)
빠른 응답이 중요한 요구사항 중 하나였기 때문에 메인 스토리지와 함께 캐시 서버를 두는 형태로 구상했어요.
Feature 수집 워커
피처를 수집하는 작업을 Ingestion이라고 부르는데, Ingestion에는 두 가지 방식이 있어요. 하나는 이벤트 스트림에서 실시간으로 소스 이벤트를 컨슘하여 수집하는 Stream Ingestion, 다른 하나는 대량의 소스 데이터를 배치로 밀어 넣어 수집하는 Batch Ingestion인데요. 나중에는 두 Ingestion 방식 모두 지원하게 되었지만, 초반에는 Batch Ingestion에 대한 니즈가 없었기 때문에 Stream Ingestion만 고려했어요.
서비스에서 발행하는 여러 이벤트들이 쌓이는 큐가 이미 있었기 때문에, 다행히도 스트림 자체를 새로 구축할 필요는 없었어요. 다만 전사적으로 사용되는 이벤트 큐가 목적별로 세 가지로 나뉘어 있었고, 각 큐의 타입도 서로 달랐어요(Kafka, Kinesis). 따라서 처음부터 다양한 타입의 큐를 지원할 수 있도록 Consumer Group 인터페이스를 설계해야 했어요.
피처는 컨슘한 이벤트를 기반으로 만들어지며, 여러 이벤트를 통해 생성돼요. 또한 하나의 이벤트는 하나의 피처가 아닌 여러 피처의 소스 이벤트로 사용될 수 있어요. 즉 피처와 이벤트는 M:N의 관계를 가지기 때문에, 이벤트를 컨슘한 후 여러 피처 가공 로직으로 디스패치(dispatch)하는 구조가 필요했어요.
디스패치를 해주는 컴포넌트를 Dispatcher, 이벤트를 피처로 가공하는 컴포넌트를 Aggregator라고 부르게 됐어요. Dispatcher가 여러 개의 Consumer Group으로부터 이벤트를 받아 피처별 Aggregator로 이벤트를 전파하면, Aggregator가 가공 로직을 수행하는 형태로 구상했어요.
이렇게 각 컴포넌트들을 구상한 후, 이들을 통합하여 다음과 같은 아키텍처를 구상했어요.
데이터 모델링
이후에는 피처 데이터 모델링을 진행했어요. 피처 데이터는 피처를 다루는 레이어에 따라 표현 방식이 달라지는데, 총 세 개의 레이어가 존재해요. 이 레이어들은 위에서 봤던 시스템 아키텍처의 각 컴포넌트의 역할과도 어느 정도 매칭돼요.
- 피처 저장 레이어: 피처의 물리적 저장을 위한 스토리지 레벨의 스키마를 담당.
- 피처 정의 레이어: 피처와 Ingestion스펙 정의를 선언하기 위한 스키마를 담당.
- 피처 서빙 레이어: 클라이언트와의 통신을 위한 API 레벨의 스키마를 담당.
Feature 저장 데이터 모델링
피처를 정의하고 사용하기 위해서는 먼저 피처라는 개념의 데이터 구조를 모델링해야 해요. 피처의 데이터 구조는 피처가 저장되는 방식과도 밀접하게 관련되기 때문에, 먼저 스토리지 레이어에서 피처를 저장하는 방식부터 모델링했어요.
스토리지 레이어의 데이터 구조는 한 번 데이터가 쌓이기 시작하면 중간에 변경하기가 어렵기 때문에 처음부터 확장성 있게 잘 설계해야 했어요. 먼저 상술한 요구사항을 살펴보면, 시계열 데이터와 단순 key value 데이터를 함께 고려해야 함을 알 수 있어요.
- 사용자가 최근 수행한 top N개의 action을 기록하고 빠르게 서빙할 수 있어야 함.
- 단, top N 값과 함께 조회 기간도 파라미터로 받을 수 있어야 함. - Action 피처뿐만 아니라 현재 지역 정보 같은 사용자 특성 피처들도 고려해야 함.
- 중고거래 타입의 게시글뿐만 아니라 임의의 타입의 게시글 피처들도 고려해야 함.
- 모든 피처는 임의의 타입 (primitive type, list, set, map…)의 값을 가질 수 있음.
여기서 특성 피처를 저희는 static 피처라고 지칭했어요. 이해를 위해 먼저 각 피처 타입의 예시를 먼저 들어볼게요.
Action 피처
- 사용자가 최근에 클릭한 중고거래 게시글 목록
- 사용자가 최근에 채팅한 중고거래 게시글 목록
- …
Static 피처
- 사용자의 os 타입
- 사용자가 체크인한 지역의 아이디
- 사용자가 걸어 둔 키워드 알람 목록
- …
DB 키 디자인
우선 두 피처 타입 모두 유저를 식별할 수 있는 아이디 식별자가 필요하죠. 또한 피처 자체도 식별해야 하기 때문에 피처의 이름 또한 식별자로 들어가야 해요. 그러면 다음과 같이 키를 디자인해볼 수 있어요.
<user_id>#<feature_name>
그리고 다음은 위 키 디자인에 기반한 피처 테이블의 예시인데요. 여기에는 한 가지 큰 문제가 있어요.
Static 피처의 경우에는 값 자체가 overwrite 되기 때문에 문제가 없지만, Action 피처의 경우에는 action이 발생할 때마다 리스트에 append 해 나가는 구조라 시간이 갈수록 리스트가 무한하게 커지는 문제가 발생할 수 있어요. 또한 최대 길이를 충분히 큰 값으로 제한한다 해도, 조회 기간이나 조회 개수에 상관없이 항상 전체 리스트를 조회한 뒤 후처리를 해야만 해요. 불필요한 네트워크 및 연산 비용이 생길 수밖에 없는 구조인 거죠.
다시 생각해 보면, Action 피처는 time series 데이터이기 때문에 (user_id, feature_name)
과 함께 timestamp
를 조합하면, 정확히 하나의 데이터 포인트로 식별할 수 있어요. 하지만 단순히 위의 키 스키마 뒤에 timestamp를 붙일 순 없어요. Timestamp는 user_id, feature_name과는 달리 조회 시 정확한 값을 알기가 어려우며, 범위와 개수로 조회하는 액세스 패턴상 정확한 값을 알 필요도 없기 때문이에요.
<user_id>#<feature_name>#<timestamp>
결국 특정 action 피처를 나타내는 row 집합에서 timestamp로 범위 기반의 조회를 하면 됐기 때문에, (user_id, feature_name)
을 파티션 키로, timestamp
를 정렬 키로 하는 복합키를 사용하기로 했어요.
(<user_id>#<feature_name>, <timestamp or null>)
Static 피처는 정렬 키가 필요 없기 때문에 정렬 키를 null로 두어 두 케이스 모두 대응할 수 있도록 했어요.
이렇게 하면 sort key 기준으로 범위 조회가 가능해서 불필요한 비용이 발생하지 않아요. row의 사이즈도 예측할 수 있게 돼 운영 비용 또한 낮출 수 있죠.
그리고 action 피처는 특성상 최신성이 중요하기 때문에, 조회 시 최신순으로 역정렬하여 조회하는 액세스 패턴을 가져요. 다시 말해, 과거에 쌓인 row들은 시간이 갈수록 히트율이 급격히 낮아지기 때문에 어느 정도 시간이 지나면 삭제해도 무방하다는 의미예요. 데이터를 row별로 쌓으면 row마다 TTL을 지정할 수 있어서 스토리지 비용도 절감할 수 있죠.
하지만 위 키 디자인은 아이디 값이 “사용자”의 아이디임을 가정하고 있기 때문에 다음의 요구사항은 만족하지 못해요.
- 중고거래 타입의 게시글뿐만 아니라 임의의 타입의 게시글 피처들도 고려해야 함.
다행히 이 문제는 키 값 앞에 엔티티의 타입을 나타내는 구분자를 추가하고 아이디 값으로 엔티티의 아이디를 사용할 수 있게 일반화함으로써 쉽게 해결했죠.
(<prefix>#<entity_id>#<feature_name>, <timestamp or null>)
(u:<user_id>#<feature_name>, <timestamp or null>)
(c:flea#<article_id>#<feature_name>, null)
(c:jobs#<article_id>#<feature_name>, null)
...
캐시 값 디자인
앞서 설명했듯이 Feature Store의 경우 많은 양의 요청량이 예상되기 때문에 초반부터 캐시 서버를 고려했는데요. Static 피처의 경우 단순 key value이기 때문에, 기본적으로 key value 구조를 가지는 캐시 서버에서도 정확히 동일한 형태로 캐싱하면 돼요. 그러나 action 피처의 경우는 정렬 키 기반의 범위 조회가 필요하죠. 따라서 단순 key value 형태로 캐싱할 경우에는 위에서 언급했던 문제들이 또다시 발생할 수 있었어요.
따라서 Memcache와 같은 단순 key value 데이터 구조만을 지원하는 캐시 솔루션은 사용하기 어렵다고 판단했어요. 대신 범위 조회가 가능한 sorted set이라는 데이터 구조를 지원하는 Redis를 캐시 솔루션으로 채택했죠.
Sorted set은 각 member를 score로 정렬할 수 있어요. member를 row의 value로, score를 timestamp로 두면, timestamp 기반의 범위 조회가 가능해요. 뿐만 아니라 count까지 지정할 수 있어, 정확히 동일한 동작을 수행할 수 있어요.
다만 key value DB에서의 설계와는 달리 member별로 서로 다른 TTL을 지정할 수 없고, sorted set의 크기를 제한하는 옵션도 없었어요. large key 문제가 여전히 남아 있는 거죠. TTL 같은 경우는 sorted set의 크기만 일정 수준으로 제한된다면 큰 문제는 아니었어요. 결국 sorted set이 무한정으로 커지는 것만 방지하면 됐기 때문에, member를 append 할 때 항상 상위 스코어 N개 이하의 member들은 remove 하는 동작을 함께 수행하도록 구성했어요.
ZADD 1111#recent_clicked_articles 1700000000 {"article_id": 1}
ZREMRANGEBYRANK 1111#recent_clicked_articles 0 -maxCount
EXPIREAT 1111#recent_clicked_articles expiryTime
ZADD 1111#recent_clicked_articles 1710000000 {"article_id": 2}
ZREMRANGEBYRANK 1111#recent_clicked_articles 0 -maxCount
EXPIREAT 1111#recent_clicked_articles expiryTime
maxCount 값도 크지 않고, 커맨드들도 파이프라이닝을 통해 실행했기 때문에 추가 동작에 대한 오버헤드는 크지 않았어요. 이렇게 Redis이기 때문에 가능한 전략을 사용하여 캐시 또한 key value DB에서와 동일한 동작을 수행하도록 만들었죠.
Feature 정의 데이터 모델링
이렇게 피처 데이터 모델링을 완성했어요. 피처가 어떻게 구성되며 피처를 정의할 때 어떤 정보들이 필요한지 알 수 있게 됐죠. 이제 실제로 피처를 사용하기 위해 피처의 스펙을 정의해야 했고, 이를 수집하기 위한 스키마와 규칙들도 필요했어요.
피처 정의에는 피처의 이름과 타입, 그리고 피처의 원천 데이터가 되는 이벤트에 대한 소스 정보가 필요했어요. 그리고 이벤트 소스 정보에는 구독 대상이 되는 스트림 목록의 이름, 토픽, 서브 토픽, 그리고 추출할 필드의 목록이 필요했어요. 여기서 추출한 필드들이 곧 피처의 값 또는 필드로 저장되는 구조인 셈이죠.
features:
user:
<피쳐 이름>:
featureType: <static | action>
containerType: <single | set | map>
events: <ingestion할 이벤트 목록>
- streamName: <대상 이벤트 스트림 이름>
topic: <이벤트 토픽>
subTopic: <특정 이벤트 필터링용 서브 토픽>
filter: <필터 모록>
- name: <필터링용 필드 이름>
type: <string | int | float | ...>
condition: <필터 조건>
optype: <equal | not_equal | contain | ...>
values: <조건값>
fields: <추출할 필드 목록>
- name: <추출할 필드 이름>
rename: <rename할 이름>
type: <string | int | float | ...>
실제로는 조인 설정, 변환 규칙 등 더 많은 스펙과 규칙이 존재하지만, 기본이 되는 핵심 스펙들은 위와 같아요.
다음 예시는 recent_view_articles
라는 가상의 피처를 정의한 예시인데요. 이 피처는 client_event
라는 스트림의 client_event_cleansed
라는 토픽으로부터 view_article_v1
또는 view_article_v2
라는 타입의 이벤트를 컨슘한 뒤, 이벤트 데이터의 country_code
필드값이 kr
인 경우에만 article_id
필드값을 추출한다는 의미예요. 이렇게 추출된 필드는 recent_view_articles
피처의 필드로 저장돼요. (위의 키 디자인 파트에서 봤던 예시를 생각하면 돼요.)
recent_view_articles:
featureType: action
events:
- streamName: client_event
topic: client_event_cleansed
subTopic: view_article_v1
filter:
- name: country_code
type: string
condition:
optype: equal
values:
- kr
fields:
- name: params.article_id_v1
rename: article_id
type: int
- streamName: client_event
topic: client_event_cleansed
subTopic: view_article_v2
filter:
- name: country_code
type: string
condition:
optype: equal
values:
- kr
fields:
- name: params.article_id_v2
rename: article_id
type: int
Feature 서빙 데이터 모델링
이렇게 피처의 저장, 정의, 수집을 위한 데이터 구조 모델링이 완성되어 stream ingestion을 수행할 수 있는 기반은 모두 마련되었어요.
마지막으로 이렇게 정의하고 수집한 피처들을 클라이언트로 서빙할 때 필요한 데이터 모델링을 진행했어요. 서빙 레이어에서의 데이터 모델링이라고 하면 보통 클라이언트와의 통신을 위한 API를 디자인하는 작업을 의미해요.
API 디자인
API의 경우 다음 요구사항만 충족된다면 어떤 형태든 상관없었는데요. 사실 초반에는 자유도에 대한 니즈 자체가 적었기 때문에, 이미 사용하고 있는 피처의 묶음을 하나의 피처 세트로 정의하여 제공해 줄 생각이었어요. 그리고 클라이언트에서 요청 시 피처 세트 버전만 명시하면, 피처 세트에 있는 피처들을 서빙해 주는 방식이었죠.
- 피처의 목록과 개수, 조회 기간은 매 피처 요청마다 달라질 수 있음.
featureset:
v1:
embedding_vector_ver:
count: 1
region_id:
count: 1
notification_keywords:
count: 30
recent_clicked_articles:
count: 50
recent_search_queries:
count: 50
v2: ...
하지만 피처 요청 방식이 바뀔 때마다 피처 세트를 수정 또는 추가해야 했기 때문에 한계가 명확하다는 문제가 있었어요. 때문에 SQL처럼 피처와 조건 (count, time range)을 동적으로 선언해서 데이터를 조회할 수 있는 형태의 쿼리 언어를 고민했어요. 하지만 당시 쿼리 언어를 설계하고 구축하기에는 시간적 여유가 부족했으며, 임팩트 대비 오버엔지니어링이 될 가능성이 높았어요.
SELECT
os,
notificaiton_keywords,
recent_clicked_articles(50),
recent_search_queries(50),
...
FROM
feature_store
WHERE
user_id=1;
하지만 마침 이러한 요구사항을 충족시켜 줄 수 있는 기술이 있었는데, 그건 바로 GraphQL이었죠. GraphQL을 사용하여 피처를 하나의 필드로 만들면, 필요한 피처만을 동적으로 쿼리할 수 있었어요. 또 필드마다 파라미터를 정의할 수 있기 때문에 action 피처에도 count, time range 같은 범위 조건 파라미터를 쉽게 지원했죠.
type User {
id: Int!
regionID: Int!
notification_keywords: String!
...
recentClickedArticles(input: ActionFeatureInput = {}): [Article!]
recentSearchQueries(input: ActionFeatureInput = {}): [Search!]
...
}
input ActionFeatureInput {
count: Int
lastNSec: Second
filter: Map
}
이 스키마를 사용하면 클라이언트에서는 필요한 피처의 목록과 파라미터 값으로 동적인 쿼리를 작성할 수 있어요.
{
user(id: 1) {
os
region_id
notification_keywords
recent_clicked_articles(input: {count: 50, lastNSec: 604800}) {
id
}
recent_search_queries(input: {count: 50, lastNSet: 604800}) {
query
}
}
}
사실 다양하고 복잡한 API가 필요한 상황은 아니었어요. 쿼리를 받을 수 있는 API 하나만 필요했기 때문에 API 디자인은 비교적 쉽게 끝낼 수 있었어요.
기술 스택 선택
아키텍처와 데이터 모델링 작업이 끝난 다음에는 실제 구현을 위한 기술 스택을 고민했어요. 사실 일부 기술들은 이미 데이터 모델링 단계에서 정해졌고, 큐 같은 경우는 기존 시스템에서 이미 사용 중인 기술들이 있었죠. 덕분에 메인으로 사용할 DB와 Feature Registry를 위한 원격 저장소 솔루션만 결정하면 됐어요.
DB
데이터베이스는 키 디자인에서 제시한 column family 형태의 key 모델링을 지원하면서 스케일링이 용이해야 했어요. 리서치를 통해 요구사항을 만족시키는 데이터베이스로 다음 세 가지 후보를 추렸죠.
- HBase
- Cassandra
- DynamoDB
결론부터 말하면 저희가 선택한 데이터베이스는 DynamoDB였는데요. 가장 큰 이유는 당시 Feature Store를 개발하는 사람이 저 혼자였기 때문에 HBase나 Cassandra 클러스터를 직접 띄우고 운영하기에는 부담이 상당히 컸어요. 반면 DynamoDB의 경우 완전 관리형이기 때문에 인프라 레벨에서의 관리 포인트가 전혀 없었으며, 오토 스케일아웃뿐만 아니라 오토 스케일인까지 지원했어요. 덕분에 트래픽 추이에 따른 비용 최적화도 알아서 챙겨줬죠. 또한 이미 Amazon 내부에서도 Prime Day때 초당 1억 건의 요청을 한 자리 수의 밀리 초로 처리했던 성능을 보였기 때문에 성능적인 측면에서도 부족할 게 없어 보였어요.
Cache
캐시는 캐시 값 디자인에서 제시했던 대로 다양한 자료 구조를 지원하는 Redis를 사용하기로 했어요. Redis도 마찬가지로 자체 클러스터가 아닌 관리형 서비스인 ElastiCache를 사용해 클러스터 운영 관리 부담을 줄였어요.
Queue
큐 같은 경우 초반에는 Kinesis와 2대의 Kafka (MSK)를 활용했어요. Kinesis는 클라이언트 액션 이벤트, Kafka는 서버 액션 이벤트 및 데이터베이스 CDC 이벤트를 쌓기 위해 사용했죠. 나중에는 데이터 파이프라인이 개선되면서 Kinesis 대신 PubSub을 사용했고요.
Remote Config
Feature Registry는 라인에서 만든 Central Dogma라는 오픈소스 원격 설정 저장소를 사용했어요. 기능도 단순하며, 단독 설치가 가능했어요. 무엇보다 Git 미러링 기능 덕분에 PR 기반으로 피처 설정을 원격 업데이트할 수 있다는 게 큰 장점이었죠.
Feature Store의 탄생
이렇게 각 구현 파트에서 필요한 기술 스택까지 모두 선정한 다음에는 본격적으로 Feature Store를 개발했어요. 1차 버전을 완성하기까지 약 3–4개월이 걸렸죠. 마침 추천 로직을 담당하는 추천 서버 또한 비슷한 시기에 개발이 완료되어 추천 시스템 전체를 새로운 시스템으로 전환할 수 있었어요.
새로운 추천 시스템을 도입하고 추천 로직과 Feature Store가 분리되면서 여러 가지 장점을 경험했어요.
먼저 피처의 정의와 수집 방식을 한 곳에서 관리하여 투명성을 확보하고 일정 수준의 피처 품질을 유지했어요. 모든 피처들을 동일한 스키마로 저장하고 동일한 인터페이스로 서빙할 수 있게 되면서 피처의 파편화를 방지했죠.
또한 홈피드에서의 중고거래 게시글 추천뿐만 아니라 다양한 지면, 다양한 게시글 타입에 대해 다양한 피처 조합으로 추천 로직을 적용하게 됐어요. 여러 가지 추천 로직을 동적으로 실험할 수 있는 기반을 갖게 된 셈이죠.
마지막으로 추천 로직 서버와 Feature Store 서버가 분리되면서, 각자 워크로드에 맞는 리소스를 할당하고 개별로 스케일링을 하게 됐어요. 덕분에 리소스를 더 효율적으로 사용하고, 최적화 작업 또한 적극적으로 시도해 볼 수 있었죠.
초기에는 4개뿐이었던 피처가 23년 12월 기준으로 약 70개를 돌파하고, 요청량은 피크 기준으로 약 130K/s, 피처 서빙량은 약 4M/s 수준으로 올라왔어요. Feature Store가 추천 시스템의 핵심 컴포넌트 중 하나로 자리매김한 거죠.
마무리
Feature Store가 도입된 이후로 다양한 피처를 추가하고, 튜닝하고, 실험하는 전반적인 이터레이션 과정이 훨씬 빨라졌어요. 덕분에 사용자에게 최적의 사용자 경험을 제공하는 데 크게 기여했죠. 물론 현재도 다양한 피처를 추가하면서 더 나은 사용자 경험을 제공하기 위해 여러 가지 실험을 활발히 진행하고 있어요.
하지만 실험과 이로 인한 피처 요청량이 늘어나면서 슬슬 성능적인 한계가 나타났어요. 리소스 사용량은 계속 증가하고, p99 레이턴시는 느려졌죠. 여러 가지 최적화 작업을 시도해 봤지만 일정 수준 이하로는 레이턴시를 줄일 수 없었어요. 이는 Feature Store를 처음 설계할 때부터 존재했던 근본적인 한계 때문이었는데, 레이턴시를 줄이기 위해서는 이 한계를 돌파해야 했어요. 그리고 Feature Store가 도입된 지 약 1년 반이 지나자, 팀에서는 Feature Store를 아예 새로 설계해 보자고 논의했죠.
그로부터 약 1년 뒤, Feature Store의 v2 버전인 Feature Platform이 개발됐어요. 결과적으로 리소스 사용량은 줄이고 레이턴시는 약 절반 수준으로 낮출 수 있었죠. 또한 Feature Platform을 설계하는 과정에서 성능뿐만 아니라 Feature Store의 기능적인 한계도 함께 개선할 수 있었어요.
과연 Feature Platform에서는 어떤 문제를 어떻게 해결했을까요? 이에 대한 이야기는 Part 2에서 다룰 예정이니, 앞으로의 글도 많이 기대해주세요!
또 저희는 당근 추천 시스템과 ML 데이터 플랫폼을 함께 만들어 나갈 동료를 찾고 있어요. 아래 링크를 통해 각 팀에 합류할 수 있으니 많은 관심 부탁드려요!