GenPage를 실제 홈에 붙여 보니 모델보다 입력과 과부하 계약이 먼저 흔들렸다
목차
GenPage는 사용자 이력과 요청 맥락을 입력으로 받아 홈의 여러 행을 생성하는 추천 방식입니다. 이 프로젝트에서는 작은 모델을 만들어 홈 2쪽의 기존 규칙 행을 대체할 수 있는지 확인했습니다.
결과는 단순하지 않았습니다. 생성 중 마스크로 모델이 만든 상품 720개에서 규칙 위반은 0개였고, 홈 2쪽 p95는 80~121ms로 규칙 행(127ms)보다 빨랐습니다. 그러나 품질은 MAP@12 0.0209로 측정 전에 정한 기준선 0.0234를 넘지 못했습니다. 실제 서빙 경로에서는 요청 본문이 비어 모든 사용자가 이력 없는 사용자로 처리되는 문제도 발견했고, 과부하 실험에서는 모델보다 앱과 게이트 밖의 2쪽 호출이 먼저 병목이 됐습니다.
결론부터
- 모델 자체는 규칙을 지키고 기존 서빙 지연 안에 들어왔지만, 품질 기준선을 넘지 못했으므로 기본값으로 켜지 않았습니다.
- 오프라인 점수보다 먼저 확인할 것은 학습 입력과 서빙 입력이 같은지였습니다.
chunkedHTTP 본문을 모델 서버가 빈 값으로 읽은 탓에 첫 서빙 점수는 오프라인의 2%에 불과했습니다. - 구매 이력을 넣으면 다음 주 구매 recall은 0.0013에서 0.0251로 약 19배 좋아졌지만, 1쪽에서 방금 본 대분류를 2쪽이 따르는 비율은 75%에서 0%가 됐습니다. 모델 입력은 하나의 품질 목표만 올리지 않습니다.
- 과부하 게이트는 설정값이 아니라 실제 완료 호출 수로 대기 비용을 추정해야 했고, 홈 2쪽도 추천 행과 같은 동시 호출 자리를 공유해야 했습니다.
이 글은 모델을 만들었다는 소개가 아니라, 학습·서빙·입력·과부하 경계를 차례로 검증한 하나의 의사결정 기록입니다.
왜 작은 GenPage부터 만들었나
GenPage는 사용자 이력과 요청 맥락을 프롬프트로 두고 홈 전체를 한 번에 생성합니다. 이 저장소는 그 가운데 서빙 쪽 문제를 먼저 풀었습니다. 규칙을 생성 중에 지킬지 생성 뒤에 거를지, 생성 범위를 얼마나 넓힐지, 세션 신호를 어떻게 반영할지, 홈을 어떻게 쪽으로 나눌지입니다. 모델 쪽은 비어 있었습니다. 추천 모델 자리에는 스텁이 있었고 학습해 본 ALS는 선을 넘지 못했습니다.
이 글은 모델 쪽을 작동하는 가장 작은 형태로 채운 기록입니다. 목적은 생성형 추천이 기존 서빙 계약 안에 들어왔을 때 무엇이 드는지를 재는 것이었습니다. 성능을 겨루려는 작업은 아니었습니다.
GenPage에서 가져온 것과 뺀 것
| GenPage | 이번 구현 |
|---|---|
| 이력을 토큰 시퀀스로 두는 프롬프트 | 최근 구매 50개를 상품 토큰으로(어휘 82,759개) |
| 사전학습(다음 토큰 예측) | 2층 causal transformer, 파라미터 540만 개 |
| 후학습(가중 이진분류, 강화학습) | 하지 않았습니다. 노출과 보상 로그가 없습니다 |
| 행 토큰 생성 | 학습하지 않았습니다. 다음 상품 분포를 대분류별로 합쳐 행을 고릅니다 |
| 생성 중 규칙 강제(마스킹) | 이미 나온 상품, 앞 쪽에서 보여 준 상품, 다른 대분류 상품을 매 단계 막습니다 |
| 하이브리드 행 디코딩 | 행마다 앞 2개만 한 칸씩, 나머지는 한 번에 고릅니다 |
후학습을 뺀 이유는 데이터입니다. GenPage는 홈 노출과 그 뒤의 시청을 보상으로 써서 페이지 수준으로 최적화합니다. 이 저장소에는 실사용자의 노출 로그가 없고 합성 노출은 학습 근거로 쓰지 않는다는 데이터 계약이 있습니다. Hugging Face에서 받아 둔 Amazon 리뷰 데이터도 검토했지만 H&M 상품과 공통 ID가 없고 이 카테고리는 카테고리 메타가 0%라 섞지 않았습니다.
선은 측정 전에 정했다
넘어야 할 선은 ALS 때와 같은 MAP@12 0.023354(마지막에 산 상품을 다시 추천하는 규칙)로 이슈에 먼저 적었습니다. 채점도 같은 분할(홀드아웃 7일)과 같은 채점 함수로 했습니다. 서빙 기준은 홈 한 쪽 p95 300ms 이하였고 둘 중 하나라도 못 넘으면 코드는 남기고 기본값은 켜지 않기로 했습니다.
세 번 학습했고 세 번째에서 멈췄다
| 학습 | 바꾼 것 | MAP@12 | 이미 산 상품 제외 |
|---|---|---|---|
| 1차 | PyTorch 기본 초기화, 2에폭 | 0.0134 | 0.0015 |
| 2차 | 임베딩 초기화 표준편차 0.02 | 0.0185 | 0.0075 |
| 3차 | 4에폭 | 0.0209 | 0.0078 |
1차의 첫 손실이 16.8이었습니다. 어휘 82,760개를 무작위로 찍을 때의 손실이 ln(82,760) ≈ 11.3이니 무작위보다 나쁘게 시작한 것입니다. 출력 층이 임베딩을 그대로 쓰는데 임베딩 초기값의 표준편차가 1이라 로짓이 너무 컸습니다. 트랜스포머에서 흔히 쓰는 0.02로 고친 2차는 0.0185, 에폭을 4로 늘린 3차는 0.0209였습니다.
선까지 11.5%가 남았고 에폭을 두 배로 늘렸을 때 +13%였으니 한 번 더 늘리면 넘을 수도 있습니다. 그래도 멈췄습니다. 같은 홀드아웃을 보고 세 번 골랐기 때문입니다. 여기서 더 고쳐 선을 넘기면 모델이 나아졌다는 증거보다 홀드아웃에 맞춘 결과에 가깝습니다. 더 튜닝하려면 검증 기간을 따로 떼고 거기서 고른 뒤 홀드아웃은 한 번만 보는 것이 먼저입니다.
| 비교 | MAP@12 |
|---|---|
| 선: 마지막 구매 재추천 | 0.0234 |
| 작은 GenPage(3차) | 0.0209 |
| 최근 7일 인기 | 0.0087 |
| ALS 최고 구성 | 0.0076 |
순서를 보는 모델이 행렬 분해보다 훨씬 나았습니다(ALS의 2.8배). 그래도 재구매 규칙은 이기지 못했습니다. 이미 산 상품을 후보에서 빼면 점수가 63% 떨어집니다. 점수의 상당 부분이 재구매를 흉내 내는 데서 나오고 그 일은 규칙이 더 잘합니다. ALS에서 본 모양이 그대로 반복됐습니다.
검증 기간을 따로 떼고 다시 튜닝했다
세 번째에서 멈춘 뒤, 멈춘 이유를 그대로 풀었습니다. 학습 기간의 마지막 7일(2020-09-09~15)을 검증으로 떼고 그 이전 데이터로 학습해 검증 점수로만 설정을 골랐습니다. 범위(차원 64·128, 에폭 2·4·6·8)와 고르는 규칙(검증 최고에서 0.0005 안이면 작은 차원·적은 에폭)은 이번에도 측정 전에 이슈에 적었습니다.
| 차원 | 2에폭 | 4에폭 | 6에폭 | 8에폭 |
|---|---|---|---|---|
| 64 | 0.0186 | 0.0204 | 0.0211 | 0.0211 |
| 128 | 0.0204 | 0.0213 | 0.0213 | 0.0207 |
검증 최고는 차원 128·4에폭(0.0213)이었지만 규칙대로 차원 64·6에폭(0.0211)을 골랐습니다. 차원 128은 8에폭에서 내려가 더 키우거나 오래 학습할 근거도 없었습니다. 고른 설정으로 홀드아웃 이전 전체를 다시 학습하고 홀드아웃을 한 번만 채점했더니 0.0209였습니다. 선의 89.6%라 결론은 같습니다.
두 가지를 확인했습니다. 검증 예측(0.0211)과 홀드아웃(0.0209)의 차이가 0.7%라 검증 주가 제 역할을 했습니다. 그리고 앞서 홀드아웃을 보고 고른 값(0.0209)과 거의 같아서 “이 데이터에서 모델 단독으로는 재구매 규칙을 넘지 못한다”는 결론이 튜닝 방식에 기대지 않는다는 것이 드러났습니다.
참고로 재구매 규칙이 못 채운 칸을 모델로 채우는 혼합은 홀드아웃 0.0238로 규칙보다 1.8% 높았습니다. 모델이 더하는 몫은 있다는 뜻입니다. 그래도 채택 후보에서 뺐습니다. 이 혼합은 구매 이력이 있어야 하는데 서빙할 때 모델이 받는 온라인 컨텍스트에는 조회와 클릭만 있습니다.
그 사실을 확인하다가 결함 하나를 더 찾았습니다. 모델은 구매 시퀀스로 학습했는데 서빙에서는 조회·클릭 이력을 넣고 있었습니다. 학습 입력과 서빙 입력이 달랐고 첫 결정 문서를 머지할 때는 이것을 적지 않았습니다. 기본값이 꺼져 있어 나간 영향은 없지만 품질 선을 넘었더라도 이것이 풀리기 전에는 켤 수 없었습니다. 풀려면 주문 모듈이 사용자별 최근 구매를 내주는 경로를 만들거나 조회·클릭 로그로 학습해야 합니다.
서빙: 순전파 수가 곧 비용이었다
모델 서버를 따로 띄우고 앱은 기존 모델 인터페이스 뒤에서 HTTP로 부릅니다. 모델 서버가 느리거나 죽으면 추천 행은 인기 상품으로, 홈 다음 쪽은 규칙 행으로 물러서고 실패를 지표로 셉니다.
행 디코딩 방식별로 모델 서버를 직접 쟀습니다(3행 × 8개, CPU 2스레드).
| 행 디코딩 | 순전파 | p95 동시성 1 | p95 동시성 4 |
|---|---|---|---|
| 한 번에 고르기 | 3 | 9ms | 20ms |
| 하이브리드(앞 2개, 기본) | 9 | 19ms | 29ms |
| 전부 한 칸씩 | 27 | 46ms | 64ms |
한 칸씩 생성하면 상품 하나마다 순전파가 한 번 더 듭니다. GenPage가 하이브리드 디코딩을 쓰는 이유가 여기 있습니다. 행의 인상을 정하는 앞부분만 한 칸씩 비싸게 만들고 나머지는 한 번에 고릅니다.
앱을 거쳐 홈 2쪽을 사용자 30명으로 쟀을 때 p95는 한 번에 고르기 80ms, 하이브리드 100ms, 전부 한 칸씩 121ms였습니다. 세 방식 모두 규칙 행(127ms)보다 빨랐는데 규칙 행은 인기 표에 모자란 대분류를 신상품으로 채우는 조회가 붙기 때문입니다.
정정: 처음에는 이 자리에 p95 86~94ms, 모델 행 685개, 규칙 행 124ms로 적고 “디코딩 방식에 거의 반응하지 않는다”고 썼습니다. 앱이 모델 서버에 보낸 요청 본문이 빈 것으로 읽히던 상태의 값이었습니다. 모델이 이력 없이 생성했으니 지연도 항목 수도 실제와 달랐습니다. 결함과 다시 잰 과정은 ch50에 적었습니다.
규칙은 생성 중에, 품절은 생성 뒤에
규칙은 두 자리로 나눠 지킵니다.
- 모델이 생성 중에 지키는 것: 이미 나온 상품, 앞 쪽에서 보여 준 상품, 행의 대분류와 다른 상품. 모델 어휘 안의 사실이라 마스크로 막을 수 있습니다. 앱이 모델 행 720개를 DB와 다시 대조했더니 중복과 대분류 불일치가 모두 0이었습니다. 모델 서버도 스스로 센 위반 수를 응답에 넣고 0이 아니면 앱이 그 페이지를 쓰지 않습니다.
- 앱이 생성 뒤에 거르는 것: 품절. 모델은 교체 가능한 의존성이고 재고를 알 이유가 없습니다. 앞서 생성 중에 재고를 묻는 방식을 재 봤을 때 이득 없이 서빙이 6배 느려졌기 때문에 같은 결론을 유지했습니다.
오프라인 점수와 실제 서빙 점수가 달랐던 이유
모델을 앱의 실제 추천 경로에 연결하고 나서야 학습과 서빙 사이의 계약을 검증할 수 있었습니다. H&M 거래 데이터의 홀드아웃 주에서 구매 이력이 있는 사용자 5,000명을 골라 실제 주문 표에 과거 구매를 심고, 앱의 추천 경로로 12개를 받아 MAP@12를 계산했습니다.
| 경로 | 모델 단독 | 재구매 규칙 | 혼합 |
|---|---|---|---|
| 오프라인(엔진 직접) | 0.023295 | 0.025775 | 0.026373 |
| 서빙(첫 채점) | 0.000504 | 해당 없음 | 0.025823 |
첫 채점에서 사용자 5,000명의 추천 12개가 모두 같았습니다. 앱이 요청 본문을 Transfer-Encoding: chunked로 보냈지만, 파이썬 모델 서버가 Content-Length만 읽어 0바이트를 입력으로 처리한 것이 원인이었습니다. 입력이 없다는 오류를 내지 않고 빈 이력으로 계속 생성한 것이 더 위험했습니다.
앱은 본문을 먼저 직렬화해 길이를 붙이고, 모델 서버는 chunked 본문을 읽도록 고쳤습니다. history가 없는 요청은 빈 추천으로 처리하지 않고 400으로 거절하게 했습니다. 실제 HTTP 서버를 사이에 둔 테스트도 추가했습니다.
수정 뒤 모델 단독 서빙 점수는 0.023295로 오프라인과 같아졌고, 재구매 규칙을 앞에 두고 빈칸을 모델로 채우는 혼합은 0.026373이 됐습니다. 이 결과는 모델 품질이 좋아졌다는 뜻에 앞서, 오프라인과 서빙이 같은 입력을 받는다는 전제부터 확인해야 한다는 뜻입니다.
구매 이력을 넣자 품질과 세션 반응이 갈라졌다
모델은 구매 시퀀스로 학습했는데 홈 2쪽은 조회·클릭 8개를 입력하고 있었습니다. 구매 이력으로 통일하면 품질은 좋아질 수 있지만, 1쪽에서 방금 본 대분류를 2쪽이 반영해야 한다는 홈의 요구를 잃을 수 있었습니다.
| 변형 | 2쪽 입력 | recall@2쪽 | 세션 반영 | p95 |
|---|---|---|---|---|
| V0 세션 | 최근 조회·클릭 | 0.0013 | 75.0% | 79ms |
| V1 구매 | 결제 완료 상품 | 0.0251 | 0.0% | 86ms |
| V2 세션 + 구매 | 조회·클릭 뒤 구매 | 0.0191 | 12.8% | 88ms |
V1은 다음 주 실제 구매를 맞히는 recall을 약 19배 높였지만 세션 반영은 사라졌습니다. V2는 두 신호를 섞었지만 V1 recall의 0.95배와 세션 반영 20%p 증가라는 사전 기준을 모두 넘지 못했습니다. 그래서 구매 이력을 사용하는 쪽을 고르고, 조회·클릭을 앞에 붙이는 방식은 설정으로만 남겼습니다.
이 결과는 “구매 이력이 더 좋은 입력”이라는 단일 결론이 아닙니다. 어떤 품질을 최우선으로 둘지 먼저 정해야 하며, 모델이 실패해 규칙 행으로 물러날 때와 과거 구매가 없는 사용자에 대한 기준도 별도로 필요합니다.
실제 모델로 과부하 경계를 다시 측정했다
처음에는 스텁 모델의 처리 시간으로 과부하 게이트를 정했습니다. 실제 모델 서버를 붙여 보니 추천 행에서는 모델이 초당 약 1,000건을 처리했고 앱이 먼저 포화했습니다. 게이트가 설정값을 믿으면 실제 모델 p95가 5~6ms인데도 요청의 88%를 불필요하게 거절했습니다.
게이트가 최근 2초 동안 실제로 끝난 모델 호출 수를 기준으로 대기 가능량을 추정하도록 바꿨습니다. 설정값으로 모델 용량을 대신 추정하면 모델이 바뀔 때 어느 방향으로든 틀릴 수 있기 때문입니다.
더 큰 문제는 홈 2쪽이 모델을 부르는 /page 경로였습니다. 추천 행과 달리 과부하 게이트와 동시 호출 제한을 통과하지 않아 모델 서버의 스레드를 모두 차지했고, 추천 행까지 실패시켰습니다.
| 경로 | 동시성 1 | 동시성 2 | 동시성 4 | 동시성 8 |
|---|---|---|---|---|
/recommend 처리량 | 457/s | 719/s | 999/s | 867/s |
/page 처리량 | 37/s | 72/s | 121/s | 97/s |
2쪽이 추천 행과 같은 동시 호출 자리를 공유하도록 바꾸자 모델 실패는 0이 됐습니다. 다만 같은 부하에서 모델을 빼고 규칙 행만 만들어도 앱이 먼저 무너졌습니다. 2쪽 앱 경로 자체의 용량은 초당 30~60건 사이였고, 30건/초에서는 자리 공유 비용이 0.1% 이하였습니다. 따라서 기본값은 자리 공유와 즉시 폴백으로 두고, 더 큰 부하는 앱 쿼리 경로를 먼저 개선하기로 했습니다.
그래서 기본값으로 켜지 않은 이유
- 후학습을 하지 않았습니다. GenPage가 페이지 수준 보상으로 얻는 행 사이의 상호작용이나 다양성은 이 모델에 없습니다.
- 행 선택은 학습한 것이 아니라 상품 분포의 합입니다.
- 이력이 없는 신규 고객(홀드아웃의 8.1%)은 점수를 내지 못합니다. 콘텐츠 임베딩은 넣지 않았습니다.
- 서빙 실측의 이력은 합성이고 학습(구매)과 서빙(조회·클릭)의 입력이 다릅니다. 최종 판정은 온라인 A/B인데 실사용자가 없어 하지 않았습니다.
이번 구현은 모델을 서비스에 넣을 수 있다는 것까지 보여 줬지만, 기본 추천을 바꿀 근거까지 만들지는 못했습니다. 품질 기준선 0.0234를 넘지 못했고, 합성 사용자 기반 측정이라 온라인 A/B도 하지 못했습니다. 따라서 코드는 남기되 기본값은 기존 규칙에 두었습니다.
이어서 읽기
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.