Netflix 홈페이지에 보여지는 개인화된 영화 추천 로우를 만드는 Ranker 서비스는 복잡하고 규모가 큰 서비스입니다. 이 서비스의 CPU 프로파일을 분석하던 중, '비디오 세렌디피티 스코어링'이라는 하나의 기능이 전체 CPU의 약 7.5%를 소모하는 핫스팟으로 드러났습니다. 단순히 '배치 처리하면 되겠지'라는 생각에서 시작한 이 최적화 작업은, 결국 알고리즘, 메모리 레이아웃, 그리고 최신 JDK 기능을 활용한 근본적인 해결책을 찾는 여정이 되었습니다. 이 글은 그 과정에서 얻은 실전 인사이트를 담고 있습니다. 자세한 근거자료는 Netflix Tech Blog에서 확인할 수 있어요.

문제: O(M×N)의 비효율적인 점곱 연산
세렌디피티 점수는 간단히 말해, '이 새로운 콘텐츠가 사용자가 지금까지 본 것과 얼마나 다른가?'를 계산하는 것입니다. 후보 영화(M개)와 시청 기록(N개) 각각을 벡터 임베딩으로 표현하고, 모든 조합(M×N)에 대해 코사인 유사도를 계산해야 했죠.
초기 구현은 직관적이지만 비효율적이었습니다. 각 후보마다 임베딩을 가져오고, 기록을 루프로 돌리며 한 쌍씩 유사도를 계산하는 방식이었어요. 이는 반복적인 메모리 접근과 캐시 지역성 저하를 초래했습니다.
// 단순한 중첩 루프 방식 (의사 코드)
for (Video candidate : candidates) { // M번
Vector c = embedding(candidate);
double maxSim = -1.0;
for (Video h : history) { // N번
Vector v = embedding(h);
double sim = cosine(c, v); // 점곱 연산 발생
maxSim = Math.max(maxSim, sim);
}
double serendipity = 1.0 - maxSim;
emitFeature(candidate, serendipity);
}
// 총 M x N 번의 점곱 연산

최적화 5단계: 근본적인 접근법의 중요성
- 배치 처리 및 행렬 연산화: M×N개의 작은 점곱 연산을 하나의 행렬 곱셈(C = A * B^T)으로 변환했습니다. 이는 CPU가 최적화하기 좋은 형태입니다.
- 배치만으로는 부족했다: 오히려 5%의 성능 저하가 발생했습니다. 원인은
double[][]할당으로 인한 GC 부담과 불연속 메모리 접근이었습니다. - 평탄화 버퍼와 ThreadLocal 재사용:
double[]평탄 버퍼와 스레드별 재사용 풀을 도입해 할당 오버헤드와 캐시 효율을 개선했습니다. - BLAS의 함정: 순수 Java 환경에서 JNI 전환 오버헤드와 레이아웃 변환 비용으로 인해 기대한 성능 향상을 얻지 못했습니다.
- JDK 벡터 API의 등장: 이것이 결정적이었습니다. 순수 Java 코드로 SIMD(단일 명령어 다중 데이터) 연산을 표현할 수 있게 해주었죠. 기존의 스칼라 연산을 벡터화된 FMA(Fused Multiply-Add) 연산으로 대체하여 CPU의 벡터 유닛을 최대한 활용할 수 있게 되었습니다.
// JDK Vector API를 활용한 내부 루프 (간략화)
DoubleVector acc = DoubleVector.zero(SPECIES_PREFERRED);
for (; k + SPECIES.length() <= D; k += SPECIES.length()) {
DoubleVector a = DoubleVector.fromArray(SPECIES, candidatesFlat, i*D + k);
DoubleVector b = DoubleVector.fromArray(SPECIES, historyFlat, j*D + k);
acc = a.fma(b, acc); // 벡터화된 FMA 연산!
}
double dot = acc.reduceLanes(VectorOperators.ADD);
| 최적화 단계 | 핵심 변경사항 | 주요 이점 | 주의사항 |
|---|---|---|---|
| 1. 배치 처리 | 중첩 루프 → 행렬 곱셈 | 알고리즘적 효율성 | 데이터 레이아웃 설계 필요 |
| 2/3. 메모리 최적화 | double[][] → double[] + ThreadLocal | 캐시 지역성 향상, GC 감소 | 버퍼 관리 로직 추가 |
| 5. JDK 벡터 API | 스칼라 연산 → SIMD 벡터 연산 | CPU 하드웨어 효율 극대화 | 인큐베이팅 모듈 의존성 |

결론: 가장 빠른 라이브러리가 답이 아니다
이번 작업을 통해 '가장 빠른 라이브러리'를 찾는 것보다 계산 형태, 데이터 레이아웃, 오버헤드 제거라는 기본기에 충실하는 것이 더 중요하다는 것을 깨달았습니다. 이 기본기가 갖춰진 후, JDK 벡터 API는 JNI 오버헤드 없이 순수 Java로 SIMD 성능을 끌어낼 수 있는 완벽한 도구였습니다.
결과적으로, CPU 사용률은 약 7% 감소, 평균 지연 시간은 12% 감소했으며, 핫스팟이었던 기능의 CPU 점유율은 7.5%에서 약 1% 수준으로 크게 낮아졌습니다. 이는 단순한 코드 최적화를 넘어, 문제를 근본적으로 재구성한 덕분이었습니다. 실무에서 성능 개선을 고민한다면, 라이브러리 선택보다 먼저 데이터의 흐름과 형태를 점검해보는 것을 추천합니다.