AWS Lambda에 진짜 검색엔진 넣어보기: 서버리스 검색의 현실과 한계
"검색 트래픽이 있을 때만 비용을 내는 진짜 서버리스 검색엔진, 가능할까?" 이 질문에서 출발해 Lucene 기반 검색엔진(Nixiesearch)을 AWS Lambda 안에 욱여넣고, 인덱스는 S3와 EFS로 빼보는 실험을 한 이야깁니다.
결론부터 말하면, "돌아가긴 한다, 하지만 생각보다 많이 느리다"에 가깝습니다. 그래도 이 과정에서 서버리스 검색의 구조, Lambda 컨테이너의 진짜 동작 방식, S3·EFS·도커 이미지에 인덱스를 둘 때 각각 어떤 일이 벌어지는지 꽤 재미있는 인사이트들이 나왔습니다.
이 글에서는 그 실험을 한 번 같이 따라가 보면서, "대체 서버리스 벡터 검색을 제대로 만들려면 뭘 고려해야 할까?"를 정리해보겠습니다.
서버리스 검색은 왜 아직도 '진짜'가 아닌가
요즘 나오는 서버리스 검색 솔루션을 자세히 들여다보면, 사실 대부분은 "서버리스처럼 보이게 만든 클러스터"에 가깝습니다.
API 뒤에는 항상 어느 정도 크기의 검색 노드 풀이 떠 있고, 그 중 몇 개를 따뜻하게(워밍 상태) 유지합니다. 사용자는 함수만 호출하는 기분이지만, 실제로는 상시 대기 중인 인스턴스 비용이 고객 전체에게 나눠지고 있을 뿐입니다.
이렇게 하는 이유는 단순합니다.
검색엔진 컨테이너가 크고,
JVM이 느리게 뜨고,
인덱스와 클러스터 상태를 동적으로 맞춰야 해서
"필요할 때만 완전히 새로 띄운다"는 모델이 너무 느리기 때문입니다.
특히 Elasticsearch 같은 애들은:
도커 이미지가 700MB대라서 풀(pull)과 시작(start)에 시간이 꽤 걸리고
JVM 부팅에만 40초가 날아가고
각 노드가 전체 인덱스의 일부를 들고 있어서, 스케일 인/아웃 할 때마다 리밸런싱이 필요합니다.
이 구조에서는 "트래픽이 0이면 클러스터도 0으로 줄인다"는 진정한 의미의 서버리스가 사실상 불가능합니다. 그래서 나온 질문이 바로 이겁니다.
"그냥 진짜 검색엔진 하나를 Lambda 함수 안에 통째로 넣어버리면 안 될까?"
목표: 진짜 Lambda 기반 검색엔진, 어디까지 되나
이 실험의 목표는 생각보다 단순합니다.
요청이 들어올 때만 올라오는 검색엔진
트래픽이 없으면 완전히 0으로 내려가고
올라올 때는 1초 이내에 준비되고
실제 검색 속도도 "사용자 입장에서 봐줄 만한" 수준
물론 오늘날의 검색·추천 시스템에서는 여기에 보통 300~400ms 정도의 임베딩 모델 추론(LLM, 벡터 임베딩)이 추가됩니다. 그래서 검색 자체에 3ms냐 5ms냐 집착하는 건 큰 의미가 없고, 현실적으로 100~200ms 정도면 꽤 괜찮은 편입니다.
즉, 최소한 이런 그림을 기대해볼 수 있겠죠.
콜드 스타트(컨테이너 처음 부팅): 1초 이내
따뜻해진 상태에서의 검색: 100~200ms 수준
인덱스는 검색엔진 밖 어딘가(S3, EFS 등)에 두기
이 조건을 가지고 Lucene 기반 JVM 검색엔진인 Nixiesearch를 Lambda 안에 넣어보는 게 실험의 출발점입니다.
JVM 검색엔진을 네이티브로: GraalVM으로 컨테이너 다이어트
JVM을 그대로 Lambda에 올리면 콜드 스타트가 너무 느려집니다. 그래서 선택한 방법이 GraalVM native-image로의 AOT(Ahead-Of-Time) 컴파일입니다.
아이디어는 간단합니다.
기존: JAR + JVM
목표: glibc만 의존하는 단일 네이티브 바이너리
이렇게 하면:
자바 런타임(JVM) 자체를 도커 이미지에서 제거할 수 있고
JVM 워밍업(핫스팟 최적화) 시간도 완전히 사라집니다.
문제는 자바 세계에서 흔하게 쓰는 "리플렉션(reflection)"입니다.
런타임에 클래스 필드를 나열하거나
동적으로 클래스를 로드하는 코드들
GraalVM은 이런 것들을 미리 다 알아야 하기 때문에, 어떤 클래스/필드/메서드가 리플렉션으로 접근되는지 모두 나열한 설정 파일(reflect-config.json)이 필요합니다.
이걸 손으로 쓰는 건 사실상 불가능하니, GraalVM의 트레이싱 에이전트를 테스트 코드에 붙여서 "실제로 실행되는 모든 경로"에서 리플렉션 사용을 자동으로 수집했습니다.
그렇게 얻은 메타데이터를 기반으로 애플리케이션을 native-image로 컴파일하면:
빌드는 느립니다. 16코어 CPU로도 몇 분 걸립니다.
하지만 결과는 꽤 매력적입니다.
대략 이런 식으로 줄어듭니다.
기존 JVM 도커 이미지: 약 760MB
glibc 기반 네이티브 바이너리 + 최소 우분투: 약 338MB
musl 기반 빌드로 바꾸고, FROM scratch 이미지로 가벼운 컨테이너 구성: 약 200MB 초반
여기에 "AWS Java SDK가 코드베이스의 거의 30%를 차지한다"는 사실도 드러났습니다. 덕분에 "예쁜 SDK 대신, 그냥 S3 REST API를 직접 쓰는 게 낫겠다"는 결론도 덤으로 얻었습니다.
AWS Lambda 컨테이너는 실제로 이렇게 동작한다
처음에는 "AWS Lambda 도커 런타임 == 요청마다 docker run 한 번씩"이라고 단순하게 생각하기 쉽습니다. 하지만 실제 동작 방식은 꽤 다릅니다.
Lambda 컨테이너 라이프사이클을 아주 요약하면 이렇습니다.
배포 시
이미지는 ECR에서 가져와서 각 AZ에 한 번씩 풀고 캐시해 둡니다.
첫 요청이 들어오면 Init 단계
컨테이너가 Firecracker 기반 초경량 VM 위에서 실행되기 시작합니다.
Lambda 런타임은 컨테이너가 "Runtime API를 향해 폴링을 시작할 때까지" 기다립니다.
이 Init 구간은 과금 대상이라, 여기서 최대한 빨리 떠야 합니다.
Request 단계
컨테이너가 Runtime API에서 요청을 가져와 처리하고 응답을 돌려줍니다.
우리가 일반적으로 생각하는 "함수 실행 시간"이 여기에 해당합니다.
Freeze 단계
응답을 보내고 나서, 컨테이너가 다음 요청을 기다리기 위해 다시 Runtime API를 폴링하기 시작하면
Lambda는 해당 VM을 그대로 "동결(freeze)"시킵니다.
CPU는 0, 메모리는 디스크로 스왑되어 사실상 비용이 거의 들지 않는 상태가 됩니다.
Thaw 단계
새로운 요청이 들어오면, 동결된 VM을 다시 깨워(thaw)서 이어서 사용합니다.
일정 시간(보통 5~15분 정도) 동안 트래픽이 없으면 컨테이너는 완전히 제거됩니다.
실제로 실험해보면 이런 숫자가 나옵니다(대략적인 예):
콜드 요청
전체 수행 시간: 80ms대
빌링 기준 시간: 500ms대
Init 시간: 약 450ms
웜 요청(Freeze/Thaw 이후)
수행 시간: 3ms 안팎
즉 "검색엔진 실행 준비"까지를 500ms 이내로 줄이는 데는 성공한 셈입니다. 하지만 Lambda에는 또 다른 제약이 있습니다.
메모리: 기본 128MB ~ 최대 3GB 수준(요청해서 10GB까지 올릴 수는 있지만, 기본은 아님)
vCPU: 메모리를 많이 줄수록 더 받을 수 있지만, 보통 1~2 vCPU에 머무름
임시 디스크: 최대 10GB
S3 읽기 속도: 할당된 RAM에 비례해 올라가며, Lambda에서는 대략 100MB/s 정도가 상한
이 리소스 안에서 Lucene 인덱스를 읽어야 하니, 인덱스를 어디 두느냐가 성능을 좌우하게 됩니다.
검색 인덱스를 어디 둘 것인가: S3, 복제, EFS, 도커 이미지
Nixiesearch는 원래부터 S3 기반 세그먼트 저장을 지원합니다. 하지만 Lambda는 컨테이너가 휘발성이기 때문에, 인덱스를 로컬 디스크에 영구히 둘 수 없습니다.
선택지는 크게 네 가지 정도입니다.
S3에서 직접 인덱스를 읽으면서 검색하기
아이디어: Lucene이 인덱스 파일에 접근하는 모든 부분을 S3 GetObject로 감싸버린다.
장점: 콜드 스타트 시 인덱스를 다운로드할 필요가 없으니 "초기화 시간 0에 가깝다".
단점:
HNSW 벡터 검색은 그래프를 따라가는 구조라서, 작은 랜덤 읽기를 수백 번 반복합니다.
요청당 S3 읽기 500번 × 1M 요청 × (S3 요금)을 계산해보면, 한 달 200달러 정도가 금방 나옵니다.
레이턴시도 느리고, 비용도 애매합니다.
S3에서 세그먼트를 Lambda 임시 디스크로 복제한 뒤, 로컬에서 검색
아이디어: 콜드 스타트 때 인덱스를 통째로(또는 세그먼트 단위로) 내려받고, 이후에는 로컬 파일시스템만 읽는다.
장점:
한 번 내려받고 나면 검색 속도는 거의 로컬 디스크 수준.
트래픽이 꾸준하다면(예: 0.5 rps 이상) Lambda가 계속 warm 상태를 유지해서 인덱스를 자주 안 내려받아도 된다.
단점:
인덱스 사이즈 2GB, S3 읽기 100MB/s라고 치면 초기화 시간만 20초.
"엘라스틱 노드 뜨는 데 30초 걸린다"와 크게 다르지 않은 느낌이 되어 버립니다.
인덱스를 AWS EFS(NFS 스타일 네트워크 파일 시스템)에 두기
아이디어: Lambda에 EFS를 붙여놓고 인덱스 파일을 거기서 읽는다.
장점:
콜드 스타트 시 인덱스를 복제할 필요가 없으니 초기화 시간은 거의 0에 수렴.
스토리지 비용은 S3보다는 비싸지만, 요청당 읽는 데이터량 기준 계산을 하면 꽤 합리적.
예시 계산:
검색 한 번에 1MB 정도 읽는다고 가정
1M 요청 × 1MB = 1TB
EFS 읽기 단가는 대략 0.03$/GB 수준 → 한 달 약 30달러
단점:
NFS 특성상 랜덤 읽기 레이턴시가 높아서, HNSW처럼 작은 랜덤 I/O가 많은 알고리즘과 궁합이 좋지 않습니다.
인덱스를 도커 이미지 안에 "굽는" 방법
아이디어: 인덱스 파일을 도커 빌드 시점에 이미지 안에 포함시켜서, 컨테이너가 뜨면 바로 접근할 수 있게 하기.
장점:
이론상: 인덱스가 컨테이너 안에 있으니, 네트워크 없이 바로 읽을 수 있어야 한다.
현실:
Lambda는 도커 이미지를 그대로 실행하는 게 아니라, 내부적으로 이미지를 풀어서 AZ 로컬 S3 같은 블록 캐시에 올립니다.
결과적으로 "이미지 안에 인덱스 넣기 = AZ 로컬 S3에 인덱스 올려두고 거기서 랜덤 읽기하는 것"과 거의 같습니다.
테스트해보니, EFS보다도 더 느린 경우도 나타났습니다.
정리하면, 현재 기준으로는:
S3 직접 읽기: 이론상 가장 서버리스스럽지만, 레이턴시와 비용이 많이 아프고
세그먼트 복제: 빠르지만 초기화 20초는 감당하기 어렵고
도커에 굽기: 구조상 결국 S3와 비슷한 제약
EFS: 초기화는 빠르지만, 랜덤 읽기를 많이 하는 HNSW에는 병목이 심하게 온다
그래도 "초기화는 빠르게, 검색은 어떻게든 캐시가 쌓이면 100ms대" 정도는 EFS로 달성은 했습니다. 다만 첫 몇 번의 요청이 1.5초, 심하면 7초까지 느려지는 건 무시하기 어려운 수준입니다.
실험 결과: 왜 첫 검색은 1.5초나 걸릴까
실제 실험에서는 FineWiki 데이터(문서 30만 개)를 OpenAI text-embedding-3-small로 임베딩해 인덱스를 만들고, Nixiesearch를 Lambda + EFS 조합으로 올렸습니다.
프런트엔드는 간단한 웹 UI를 GitHub Pages에 올려서, 브라우저에서 검색 요청을 보내는 구조로 구성했습니다.
첫 번째 검색 요청을 날려보면 대략 이런 느낌이 나옵니다.
첫 요청: 1.5초 정도
그 뒤 몇 요청: 500ms → 300ms → 200ms…
캐시가 잘 쌓인 이후: 검색 자체는 약 120ms, 필드 읽기도 거의 즉시
왜 그럴까요?
핵심 이유는 "랜덤 읽기 + EFS 레이턴시"입니다.
HNSW 벡터 검색은
그래프의 특정 노드에서 인접 노드들의 임베딩을 하나씩 읽어 오고
각 임베딩과 쿼리의 거리를 계산한 뒤
더 가까운 노드로 이동하는 과정을 반복합니다.
이 과정은 대부분 "작은 파일 조각을 여기저기서 조금씩 읽는" 패턴이기 때문에
EFS에서 매번 1ms 정도 걸리는 랜덤 I/O를 수백 번 반복하게 됩니다.
그러면 순수히 I/O만으로 수백 ms가 날아갑니다.
도큐먼트 필드를 읽어올 때도 마찬가지입니다. 문서를 하나하나 가져오다 보면, 결국 또 작은 랜덤 읽기의 연속이 되어버립니다.
게다가 테스트 Lambda는 us-east-1 리전에 있었고, 사용자는 유럽에서 요청을 보냈습니다. 네트워크 왕복 시간까지 더하면 첫 요청이 1.5초까지 치솟는 건 크게 이상한 일도 아닙니다.
"S3 친화적인 검색"을 위한 다른 접근: IVF vs HNSW
이쯤 되면 이런 생각이 떠오릅니다.
"애초에 HNSW 같은 랜덤 읽기 많은 구조가, S3/EFS 같은 네트워크 스토리지에 안 맞는 거 아닌가?"
그래서 다른 솔루션들은 S3 위에서 동작하는 벡터 검색을 위해, HNSW 대신 IVF(또는 그와 유사한 구조)를 많이 택합니다.
IVF의 기본 아이디어:
전체 벡터를 여러 개의 클러스터로 묶고
쿼리 벡터와 가장 가까운 몇 개의 클러스터만 골라서
그 클러스터에 포함된 문서들만 대상으로 검색합니다.
장점:
HNSW처럼 노드를 하나씩 따라가며 읽는 게 아니라
"클러스터 단위로 덩어리 읽기"를 하기 때문에
S3 GetObject를 큰 단위로 몇 번만 호출하면 됩니다.
단점:
리콜(Recall)이 떨어질 가능성이 크고
필터링이 들어가는 복합 검색에서는 성능과 정확도 사이의 타협이 더 어렵습니다.
즉, "빠르게 하지만 조금 덜 정확한 검색"을 어느 정도 감수해야 합니다.
그렇다고 HNSW를 포기하고 IVF로 갈아타고 싶지는 않은 상황에서, "그럼 HNSW를 그대로 쓰되, I/O 패턴만 바꿔보면 어떨까?"라는 방향으로 생각이 이어집니다.
HNSW를 S3 위에서 빠르게 돌리기: 순차가 아닌 동시 I/O로
HNSW 검색 과정의 문제는 "순차적인 랜덤 읽기"입니다.
대략 이런 패턴이 반복됩니다.
엔트리포인트 노드에 도착
그 노드와 연결된 이웃 노드 M개의 ID를 확인
각 이웃 노드의 임베딩을 하나씩 읽어온 뒤(지금은 대부분 순차적으로)
거리를 계산하고, "다음에 갈 노드"를 정함
그 노드로 이동하고 다시 2~4 반복
여기서 굳이 "이웃 노드 임베딩을 하나씩 순서대로" 읽을 필요는 없습니다. 이 부분을 잘 뜯어고치면, 한 번에 여러 개의 S3 GetObject를 병렬로 날리는 방식으로 바꿀 수 있습니다.
가능한 개선 방향은 이렇습니다.
HNSW 그래프의 각 단계에서:
방문한 노드의 이웃들을 한 번에 모아서
예를 들어 32~64개 정도의 임베딩을 동시에 요청
S3는 동시 요청에 아주 강하므로, 전체 배치에 10~20ms 수준의 레이턴시만 들게 함
HNSW는 보통 레벨이 3개 정도이므로
엔트리포인트 1번 + 레벨 3개 = 약 4번의 배치 요청
배치당 15ms라고 가정하면, 순수 검색 단계가 60ms 내외에 끝날 수도 있습니다.
도큐먼트 필드 읽기도 비슷하게:
Top-K 도큐먼트의 필드를 한 번에 모아
병렬 S3 요청으로 미리 가져오기
이렇게 되면 HNSW의 구조를 완전히 갈아엎지 않고도
"S3 위에서 동작하지만"
"순차 랜덤 읽기가 아닌 동시 배치 읽기"로 바꾸면서
"리콜은 유지하되 레이턴시는 줄이는" 길이 열립니다.
물론 Lucene의 HnswGraphSearcher와 Scorer를 이런 방식으로 재구성해야 해서, 구현 난이도는 꽤 있습니다. 그래도 "IFV로 갈아타서 정확도를 포기하는 것"보다는 나은 중간 해법이 될 수 있습니다.
정리: 진짜 서버리스 검색엔진을 꿈꾼다면 알아야 할 것들
이번 실험에서 얻은 핵심 포인트는 크게 세 가지 정도로 정리할 수 있습니다.
첫째, AWS Lambda는 "도커를 실행하는 작은 서버"가 아니다 Freeze/Thaw 모델, AZ 로컬 이미지 캐시, 제한된 메모리/CPU, S3와 EFS의 특성까지 고려하면 Lambda에서 검색엔진을 돌리는 건 일반적인 컨테이너 러닝과는 다른 문제입니다.
둘째, HNSW + 네트워크 스토리지는 현재 그대로 쓰면 느리다 랜덤 읽기 패턴이 잦은 알고리즘을 EFS나 S3 위에서 그대로 돌리면, 첫 요청 1.5초, 심하면 7초까지도 가는 걸 체감하게 됩니다. 하지만 배치·병렬 I/O로 패턴을 바꾸면, 이 문제를 상당 부분 완화할 여지가 있습니다.
셋째, "진짜 서버리스 검색"은 아직 도전 과제지만, 이미 가능성은 보인다
네이티브 바이너리로 줄인 컨테이너
500ms 이하의 콜드 스타트
캐시가 쌓인 뒤 120ms 수준의 검색 레이턴시 여기까진 충분히 현실적인 지점입니다.
다만 "완전히 스케일-투-제로하면서도, 첫 몇 번의 검색까지 항상 빠른 검색"을 구현하려면
인덱스 구조(HNSW vs IVF 등)
I/O 패턴(순차 vs 병렬)
스토리지 선택(S3, EFS, 캐시 전략)
임베딩 추론 위치(로컬 vs 외부 API)
까지 모두 함께 설계해야 합니다.
개인적으로 추천하고 싶은 방향은 이렇습니다.
트래픽이 아주 적고 "완전한 스케일-투-제로"가 필수라면
IVF 같은 S3 친화적인 구조를 검토하면서
어느 정도 리콜 희생을 감수하는 전략도 냉정하게 고려해볼 가치가 있습니다.
정확도가 중요하고, 필터링이 많이 들어가는 검색이라면
HNSW + 병렬 I/O + EFS/S3 캐시 레이어를 적극 활용하는 방향에서
"첫 몇 번은 느려도, 이후에는 빠르게"를 어떻게 UX로 흡수할지 고민하는 편이 낫습니다.
비용과 속도를 동시에 잡고 싶다면
일정 수준 이상 트래픽에서는 여전히 "작게 잘 튜닝된 검색 클러스터"가 서버리스보다 경제적일 수도 있습니다. 이 부분은 냉정한 비용 계산이 필요합니다.
마지막으로, Lucene 기반 서버리스 검색엔진에 관심이 있다면 Nixiesearch 같은 프로젝트를 직접 돌려보는 걸 추천합니다. "검색엔진을 Lambda 안에 넣어 본 사람"의 삽질이 여러분의 시행착오를 조금이라도 줄여줄 수 있을지도 모릅니다.
출처 및 참고 : I put a real search engine into a Lambda, so you only pay when you search
이 노트는 요약·비평·학습 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
