회사 상품 추천 모델 만들기 (2) 데이터를 다시 깔다, 후보풀 축소와 운영 안전 장치
첫 모델이 깨진 근본 원인 중 하나는 1.1M 상품 전체를 다루려 한 것이었다. 후보풀을 142K로 줄이는 결정과, prod DB SELECT-only 추출·시간 기반 split의 누수 가드 등 운영 안전 장치를 다룬다.
회사 상품 추천 모델 만들기 (2) 데이터를 다시 깔다, 후보풀 축소와 운영 안전 장치
앞 편 요약: #1에서 첫 모델이 구조적으로 깨져 있음을 확인했습니다. 근본 원인 중 하나는 1.1M개 아이템을 전부 다루려 한 것이었습니다.
이 편의 핵심 결정은 한 줄로 “후보풀(추천 점수를 계산할 대상이 되는 상품 집합)을 1.1M에서 142K로 줄이기로 했다”입니다. 부수적으로 데이터 추출 방식(CSV → prod DB SELECT-only)과 학습/검증 분리(random split → 시간 기반 split)도 함께 결정됐는데, 이 두 부수 결정은 처음에는 별것 아닌 줄 알았다가 나중에 학습 신뢰도를 좌우한다는 걸 알게 됐습니다.
1. 1.1M을 그대로 다루면 무엇이 문제인가
추천 시스템 자료를 처음 읽을 때는 “모델이 알아서 안 중요한 후보를 걸러내겠지”라고 막연히 생각했습니다. 142,000개든 1,100,000개든, 학습이 잘 되면 좋은 후보가 위로 올라올 것 같았습니다.
실제로 해보니 그렇지 않았습니다. 1.1M 전체를 후보로 두면 두 가지 문제가 동시에 터진다는 걸 알게 됐습니다.
문제 1: 후보풀 안에 의미 없는 상품이 너무 많다.
prod DB의 products 테이블에는 다음 상품들이 다 섞여 있었습니다.
- 품절(
soldout_at != 0)인 상품 - 비공개(
show_at = 0)인 상품 - 삭제 처리(
deleted_at != 0)된 상품 - 상태가 ‘정상’이 아닌 상품 (검수 대기, 일시 중지 등)
이 상품들에 추천 점수를 계산해봤자 어차피 화면에 노출되지 않습니다. 의미 없는 점수 계산에 학습·추론 자원을 다 쓰는 셈이었습니다.
문제 2: 학습 신호가 희석된다.
전체 1.1M 중 학습 데이터(약 80만 인터랙션)에 등장하는 상품은 일부에 불과했습니다. 나머지 대부분은 어떤 유저도 본 적 없는 상품인데, 모델은 이 상품들에도 임베딩(객체를 숫자 벡터로 표현한 것)을 가지고 점수를 계산합니다. 결과적으로 “안 본 상품의 점수가 본 상품의 점수보다 우연히 높게 나오는” 일이 흔히 벌어졌습니다.
2. 두 가지 선택지: 모델에 맡기기 vs 규칙으로 자르기
이 시점에서 두 가지 접근이 가능하다는 걸 자료에서 봤습니다.
| 접근 | 방식 | 강점 | 약점 |
|---|---|---|---|
| 모델에 맡기기 | 1.1M 전체에 점수 계산, 모델이 “별로인 상품은 낮은 점수”로 학습하도록 | 규칙 유지 비용 0, 데이터에서 자동 학습 | 학습 신호 희석, 추론 비용 1.1M, 품절·삭제 상품도 후보에 남음 |
| 규칙으로 자르기 | SQL WHERE로 142K 후보풀로 먼저 잘라낸 뒤 그 위에서만 학습·추론 | 학습/추론 비용 약 13% 수준으로 감소, 의미 있는 후보만 남음 | 규칙 유지 비용, 규칙 누락 시 좋은 후보를 잘라낼 위험 |
우리 서비스 도메인에서의 판단
비교 대상으로 본 두 회사 사례:
- Spotify: 전체 카탈로그 수억 곡에 대해 retrieval 모델을 돌리되, 서빙(추천 제공) 단계에서 지역/저작권/명시적 차단 필터로 후처리하는 방식이라고 합니다.
- Pinterest: 후보 생성 단계(retrieval) 자체에서 사전 필터를 적용해 후보풀을 줄이는 방식이라고 합니다. 이후 단계는 좁혀진 후보 위에서 작동.
Spotify처럼 “모델에 맡기고 사후 필터” 방식은 카탈로그가 안정적이고 GPU가 충분한 환경에 적합하다고 합니다. 우리 서비스는 churn(상품이 들어오고 빠지는 회전율)이 46~74%로 높고, MPS만 사용 가능한 환경에서 모델에 1.1M을 계속 학습시키는 비용이 너무 컸습니다.
결정은 Pinterest 쪽이었습니다. SQL WHERE 조건으로 후보풀을 먼저 자릅니다.
soldout_at = 0
AND show_at > 0
AND deleted_at = 0
AND update_status = '정상'
AND (wish_count > 0 OR order_week_price > 0)마지막 조건(wish_count > 0 OR order_week_price > 0)이 가장 고민한 부분이었습니다. 이걸 넣으면 “한 번도 찜·구매되지 않은 신상품”이 후보에서 빠집니다. 콜드 스타트 측면에서는 손해입니다.
이걸 넣은 이유는 두 가지였습니다.
- 데이터로 검증: 이 조건을 넣어도 후보풀이 142K 남았습니다. 활성 유저 9천 명이 보일 top-20을 뽑기에 충분한 다양성이라고 판단했습니다.
- 콜드 스타트는 별도 영역: 완전 콜드 상품은 retrieval보다는 “신상품 코너” 같은 별도 영역에서 처리하는 게 자연스럽다고 봤습니다. 한 모델이 모든 문제를 풀려고 하면 어느 것 하나 잘 못한다는 걸 첫 모델에서 배웠습니다.
결과적으로 후보풀 1.1M → 142K(약 13%로 축소). 학습·추론 비용은 그 비율 그대로 줄었습니다.
3. 부수 결정 1: CSV에서 prod DB SELECT-only 추출로
처음에는 데이터 분석가가 추출해준 CSV를 받아 학습했습니다. CSV 추출은 사람이 매번 쿼리를 작성하고, 결과 파일을 안전한 위치로 옮기고, 학습 환경에서 다시 읽는 식이었습니다.
문제가 두 가지 생겼습니다.
- 재현이 어려움: 어제 CSV와 오늘 CSV가 다를 수 있습니다. WHERE 조건을 누가 어떻게 썼는지 매번 기록을 남기지 않으면 결과를 재현하지 못합니다.
- 포함 컬럼이 들쭉날쭉: 어떤 CSV는
username이 들어 있고, 어떤 CSV는 없었습니다. PII(개인식별정보)가 우연히 학습 데이터에 들어갈 위험이 있었습니다.
해결은 prod DB에 직접 연결하되, 코드 레벨에서 SELECT 외 쿼리를 차단하는 SELECT-only(SELECT만 허용) 가드를 두는 방식이었습니다.
# 의사 코드 (실제 구현은 src/rec/db.py)
def assert_select_only(query: str) -> None:
stripped = query.strip().lower()
if not stripped.startswith("select") and not stripped.startswith("with"):
raise PermissionError("SELECT/WITH 외 쿼리는 차단됩니다")
# INSERT/UPDATE/DELETE/DDL 키워드도 추가 차단이중 방어로 DB 계정 자체도 readonly(읽기 전용) 권한만 부여하고, 코드 레벨 가드를 추가로 둡니다. 어느 한쪽이 무너져도 다른 쪽이 막습니다.
또한 PII BLOCKLIST를 두어 추출 단계에서 username, email, phone, address 등 식별 가능한 컬럼이 절대 빠져나오지 않도록 강제했습니다. SELECT 자체는 가능하지만, 특정 컬럼명이 결과에 포함되면 에러를 던집니다.
이 결정에서 배운 것: 운영 DB에 코드가 직접 붙는 건 본능적으로 두렵습니다. 하지만 사람이 매번 추출하는 CSV 방식은 재현성과 PII 안전에서 더 위험할 수 있다는 걸 알게 됐습니다. 위험을 분산하는 게 아니라 집중하고 강하게 막는 게 나은 경우도 있다는 걸 처음 체감했습니다.
4. 부수 결정 2: random split이 추천에서 왜 누수인가
예전에는 train/test split을 무지성으로 train_test_split(X, y, test_size=0.2, random_state=42)로 했습니다. 이 글을 읽는 분 중에도 그런 분이 많을 겁니다.
찾아보니 추천에서 random split은 데이터 누수(data leakage) 라고 합니다. 처음엔 “왜 누수지?” 싶었는데, 예시로 따라가보니 분명해졌습니다.
왜 누수인가
가상의 시나리오로 설명하면 이렇게 됩니다. 유저 A가 어떤 상품을 1월 1일에 처음 보고, 1월 10일에 구매했다고 가정합시다. 1월 1일 데이터가 학습셋에 들어가고 1월 10일 데이터가 검증셋에 들어가면, 그건 과거를 보고 미래를 맞히는 정상적인 검증입니다. 그런데 random split은 두 데이터를 무작위로 갈라서 1월 10일을 학습셋에, 1월 1일을 검증셋에 넣을 수 있습니다.
이러면 모델은 “미래(구매)를 보고 과거(첫 조회)를 맞히는” 셈입니다. 실제 서비스에서는 이런 일이 일어나지 않으므로, random split 평가 결과는 실제 성능보다 과장된다고 합니다.
시간 기반 split
대안은 시간을 기준으로 자르는 것이었습니다.
split_ts = max_ts - 14 * 86400 # 최근 14일을 검증셋으로
train = interactions[interactions["ts"] < split_ts]
val = interactions[interactions["ts"] >= split_ts]이러면 모델은 “과거 데이터만 보고, 향후 14일에 무엇을 살지” 예측하는 식으로 학습·평가됩니다. 실제 서비스 조건과 같습니다.
누수 가드는 한 곳만 막아서는 안 된다
처음에 시간 기반 split을 적용한 뒤에도 미묘한 누수가 또 발견됐습니다. 새로 추가한 피처(예: 유저의 브랜드 찜 목록 wishs_brands, 견적요청 request_product)는 각각의 created_at 컬럼이 있는데, 이걸 그대로 학습에 쓰면 검증 기간(최근 14일)에 만들어진 데이터까지 학습에 섞입니다.
해결은 모든 신규 신호에 동일한 누수 가드를 적용하는 것이었습니다.
wishs_brands = wishs_brands[wishs_brands["created_at"] < split_ts]
request_product = request_product[request_product["created_at"] < split_ts]이 누수 가드의 정확성을 검증하기 위해 전용 테스트(test_features_leakage.py)도 작성했습니다. “학습 데이터에 split_ts 이후 created_at이 단 1건이라도 있으면 fail” 같은 단순한 검사입니다. 단순하지만, 다음에 새 피처를 추가할 때 누수 가드를 잊지 않도록 안전망이 됩니다.
5. 부수 결정 3: text_emb를 interacted_only에서 candidate_pool 전체로
#1에서 진단한 결함 2번(“text_emb 90.7%가 0벡터”)의 처방이 이 결정이었습니다.
원래는 “인터랙션이 있었던 상품(약 103K개)만 텍스트 임베딩을 만들자”였습니다. 이유는 단순했습니다. 1.1M 전체 텍스트를 Ko-SBERT(한국어 문장 임베딩 모델)로 인코딩하면 디스크 용량과 시간이 부담스러웠습니다. fp32(32비트 부동소수점) 기준 약 3.4GB, MPS로 인코딩에 수 시간이 걸렸습니다.
후보풀을 142K로 줄이고 나니 상황이 달라졌습니다.
| 항목 | 원래 방식 | 변경 후 |
|---|---|---|
| 인코딩 대상 | 인터랙션 있는 103K개 | candidate_pool 전체 142K개 |
| dtype | fp32 | fp16 (16비트 부동소수점) |
| 저장 크기 | 약 3.4GB | 약 208MB (16배 절감) |
| 매칭률 | 1.1M 중 9.3% | 142K 중 100% |
fp16 양자화(정밀도를 절반으로 줄여 저장)는 정밀도가 절반이지만, retrieval 단계에서 cosine similarity(코사인 유사도) 계산에는 충분하다는 자료를 봤습니다. 학습/추론 결과를 fp32와 비교해 차이가 무시할 만하다는 걸 확인한 뒤 적용했습니다.
여기서 배운 것: “구현 결정”이 학습 결과를 좌우할 수 있다는 점. interacted_only는 모델 아키텍처와 무관한 단순 “구현 결정”이었는데, 그 결정이 #1에서 봤듯이 학습 전체를 망쳤습니다. 모델만 잘 짜면 된다는 감각은 실제 서비스에서는 절반의 진실이라는 걸 알게 됐습니다.
6. 이 편에서 결정된 것
- 후보풀 축소: 1.1M → 142K (SQL WHERE 4가지 조건). 학습/추론 비용 약 13% 수준으로
- DB 접근 방식: CSV 수동 추출 → prod DB SELECT-only 직접 추출 (코드 가드 + DB 권한 이중 방어 + PII BLOCKLIST)
- train/val split: random split → 시간 기반 split (
split_ts = max_ts − 14×86400) - 누수 가드: 모든 신규 신호의
created_at < split_ts적용 + 전용 테스트 - text_emb: interacted_only 103K(fp32, 3.4GB) → candidate_pool 전체 142K(fp16, 208MB)
이 편에서 새로 알게 된 것
- data leakage: 추천에서 random split은 “미래를 보고 과거 맞히기”가 되어 평가를 과장한다는 것
- 운영 DB 안전 장치: SELECT-only 가드 + DB 권한 + PII BLOCKLIST의 이중삼중 방어 패턴
- fp16 양자화: retrieval 단계에서 정밀도 손실은 거의 무시할 수 있고, 디스크는 16배 절약된다는 자료를 보고 적용
- “구현 결정”의 무게: 모델 구조가 아닌 단순 구현 결정(어떤 범위를 인코딩할지)이 학습 전체를 좌우할 수 있다는 것
여전히 모르는 것
- 후보풀 SQL의 마지막 조건
wish_count > 0 OR order_week_price > 0이 콜드 스타트 신상품을 정확히 얼마나 손해 보게 하는지는 분리 측정하지 못했다. 다른 회사가 어떻게 이 경계를 정하는지도 표준이 없는 듯하다. - 시간 기반 split의
val_days=14는 도메인 감각으로 정한 값이다. 7일이나 30일과 비교해 어떤 차이를 만드는지는 후속 실험 과제로 남았다.
다음 편 예고
데이터 기반이 깔렸으니, 다음은 이 위에 콘텐츠 Two-Tower를 세우는 일입니다. ID 임베딩을 버리면 무엇을 얻고 무엇을 잃는가, 그리고 #1에서 진단했던 logQ correction을 실제로 어떻게 구현하는가를 #3에서 다룹니다.