들어가며: 추천 시스템의 숨은 CPU 먹는 하마
넷플릭스 홈 화면의 개인화된 추천 줄, 생각보다 훨씬 무거운 연산 위에서 돌아갑니다. 특히 'Serendipity Scoring'이라는 기능이 CPU의 7.5%를 잡아먹고 있었는데요, 이 기능은 단순히 '이 콘텐츠가 시청 기록과 얼마나 다른가?'를 측정하는 로직입니다.
이 글은 단순한 배치 최적화 아이디어에서 시작해, 메모리 레이아웃 재설계, BLAS 도입 실패, 그리고 최종적으로 JDK Vector API로 SIMD를 활용하기까지의 실전 삽질기입니다. 국내 SI/플랫폼 환경에서도 유사한 패턴의 CPU 병목을 겪고 있다면 특히 도움이 될 거예요.
참고: 이 글은 Netflix 기술 블로그 원문을 기반으로 실무 관점에서 재구성했습니다.

문제: M×N 개의 내적 연산이 만든 핫스팟
Serendipity Scoring의 원래 코드는 직관적이지만 비효율적이었습니다.
// 후보 영상 각각에 대해 시청 기록 전체를 순회하며 코사인 유사도 계산
for (Video candidate : candidates) {
Vector c = embedding(candidate); // D차원 임베딩
double maxSim = -1.0;
for (Video h : history) {
Vector v = embedding(h);
double sim = cosine(c, v); // dot(c, v) / (||c|| * ||v||)
maxSim = Math.max(maxSim, sim);
}
double serendipity = 1.0 - maxSim;
emitFeature(candidate, serendipity);
}
이 코드는 M(후보 수) × N(시청 기록 수) 개의 개별 내적 연산을 수행합니다. 하나하나는 가볍지만, 랭커 서비스 스케일에서는 CPU 프로파일 상위에 확연히 드러나는 핫스팟이었죠. 프로파일링 결과, 이 단일 피처가 전체 노드 CPU의 7.5% 를 소비하고 있었습니다.
흥미로운 트래픽 패턴
분석해보니 재미있는 사실이 드러났습니다:
- 요청의 98%는 단일 비디오 처리였지만,
- 나머지 2%의 대용량 배치 요청이 처리하는 전체 비디오 볼륨은 50:50에 달했습니다.
즉, 배치 최적화가 중간값(median) 요청에는 도움이 안 되더라도, 전체 시스템 효율에는 큰 영향을 줄 수 있는 구조였습니다.

최적화 여정: 4단계에 걸친 개선
Step 1: 배치 처리 - 중첩 루프를 행렬 곱셈으로
첫 번째 아이디어는 '작은 내적 여러 개'를 '하나의 행렬 곱'으로 바꾸는 것이었습니다.
// 후보 임베딩을 M x D 행렬 A로, 기록 임베딩을 N x D 행렬 B로 구성
// 정규화 후 C = A × B^T 를 한 번에 계산
double[][] A = new double[M][D];
double[][] B = new double[N][D];
// ... 임베딩 채우기
normalizeRows(A);
normalizeRows(B);
double[][] C = matmul(A, B); // C[i][j] = cosine(candidates[i], history[j])
for (int i = 0; i < M; i++) {
double maxSim = max(C[i][0..N-1]);
emitFeature(candidates[i], 1.0 - maxSim);
}
수학적으로는 완벽했지만, 실제 카나리 테스트에서 오히려 5% 성능 하락을 경험했습니다. 이유는?
Step 2: 배치가 오히려 독이 된 이유
double[][]행렬을 매 배치마다 새로 할당 → GC 압력 증가double[][]는 메모리가 비연속적(non-contiguous) → 캐시 미스 증가- 순수 자바 행렬 곱셈은 SIMD를 활용하지 못함
교훈: 알고리즘 개선만으로는 부족하다. 메모리 레이아웃과 할당 전략이 함께 가야 한다.
Step 3: Flat Buffer + ThreadLocal로 GC 제거
// double[][] 대신 1차원 flat 배열 사용 (행 우선)
class BufferHolder {
double[] candidatesFlat = new double[0];
double[] historyFlat = new double[0];
double[] getCandidatesFlat(int required) {
if (candidatesFlat.length < required) {
candidatesFlat = new double[required];
}
return candidatesFlat;
}
// ... history도 동일
}
// ThreadLocal로 스레드당 재사용
private static final ThreadLocal<BufferHolder> threadBuffers =
ThreadLocal.withInitial(BufferHolder::new);
변경 사항:
double[M][N]→double[M*N]1차원 배열: 연속 메모리, 포인터 체이싱 제거ThreadLocal로 버퍼 재사용: 요청당 할당 제로, GC 부담 감소- 버퍼는 필요시만 커지고 줄어들지 않음
Step 4: BLAS 실패기 - 이론과 현실의 차이
BLAS(Basic Linear Algebra Subprograms)는 선형대수 연산의 표준 라이브러리입니다. 마이크로벤치마크에서는 훌륭했지만, 실제 통합 후에는 기대만큼의 성능 향상이 없었습니다.
netlib-java기본 경로는 F2J(Fortran-to-Java) BLAS 사용 → 네이티브가 아님- 네이티브 BLAS를 붙여도 JNI 전환 비용 존재
- 자바의 행 우선(row-major) 레이아웃과 BLAS의 열 우선(column-major) 불일치 → 변환/복사 오버헤드
- TensorFlow 임베딩 작업과 함께 실행될 때 추가 할당이 파이프라인 전체에 악영향
결론: 순수 자바로 SIMD를 활용할 방법이 필요했다.
Step 5: JDK Vector API - 순수 자바 SIMD의 구원투수
JDK Vector API는 인큐베이팅 피처로, JIT가 호스트 CPU의 SIMD 명령어(SSE/AVX2/AVX-512)에 자동 매핑해주는 포터블한 데이터 병렬 연산 API입니다. 핵심은 순수 자바라는 점 — JNI 없음, 네이티브 의존성 없음.
// Vector API를 사용한 내적 연산 (AVX2 기준 4 doubles, AVX-512 기준 8 doubles)
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
DoubleVector acc = DoubleVector.zero(SPECIES);
int k = 0;
// SPECIES.length()만큼 한 번에 처리
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); // fused multiply-add
}
double dot = acc.reduceLanes(VectorOperators.ADD);
// 나머지 tail 처리
similaritiesFlat[i*N + j] = dot;
}
}
Fallback 설계: Vector API는 아직 인큐베이팅이므로, --add-modules=jdk.incubator.vector 플래그가 없으면 자동으로 최적화된 스칼라 경로(루프 언롤 + Lucene 패턴)로 폴백됩니다. 서비스 안정성을 해치지 않으면서 최대 성능을 선택할 수 있는 구조입니다.

결과: CPU 7.5% → 1%, 그리고 실무 교훈
실제 프로덕션 카나리 테스트 결과:
- CPU 사용률 7% 감소
- 평균 지연 시간 12% 감소
- CPU/RPS(요청당 CPU) 10% 개선 — 동일 트래픽을 10% 적은 CPU로 처리
- Serendipity Scoring 피처의 CPU 점유율: 7.5% → 1%
한국 개발 생태계에서의 적용 맥락
국내 환경에서 이 사례가 주는 교훈은 분명합니다:
- Java 17+ 도입 시 Vector API 고려: 특히 추천 시스템, 검색 엔진, 금융 리스크 계산 등 대량의 수치 연산이 있는 서비스에서 효과적입니다.
- GC 최적화는 알고리즘만큼 중요:
double[][]대신 flat buffer + ThreadLocal 패턴은 어디서나 적용 가능한 기본기입니다. - 벤치마크와 실제 프로덕션은 다르다: BLAS가 마이크로벤치마크에서는 빛났지만, 실제 JNI 전환 비용과 메모리 레이아웃 불일치로 효과를 보지 못했습니다.
이 기술의 한계 및 주의사항
- Vector API는 아직 인큐베이팅: JDK 21에서도
--add-modules플래그가 필요하며, 프로덕션에 적용하려면 fallback 설계가 필수입니다. - AVX-512 지원 CPU 필요: 최대 성능을 보려면 최신 Intel/AMD CPU가 필요합니다. 구형 서버에서는 효과가 제한적일 수 있습니다.
- 모든 워크로드에 적합한 것은 아님: I/O 바운드 작업이나 복잡한 객체 그래프를 다루는 서비스에서는 효과가 미미합니다.
다음 단계 학습 방향
만약 이 글에서 배운 내용을 실제 프로젝트에 적용해보고 싶다면:
- JDK Vector API 공식 문서와 JEP 426, 438, 460을 살펴보세요.
- JMH(Java Microbenchmark Harness) 로 자신의 워크로드에 맞는 벤치마크를 작성해보세요.
- Project Panama(FFI) 와의 조합도 탐구해보면 더 넓은 최적화 가능성이 열립니다.