메인 콘텐츠로 건너뛰기

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는 컨테이너가 휘발성이기 때문에, 인덱스를 로컬 디스크에 영구히 둘 수 없습니다.

선택지는 크게 네 가지 정도입니다.

  1. S3에서 직접 인덱스를 읽으면서 검색하기

  • 아이디어: Lucene이 인덱스 파일에 접근하는 모든 부분을 S3 GetObject로 감싸버린다.

  • 장점: 콜드 스타트 시 인덱스를 다운로드할 필요가 없으니 "초기화 시간 0에 가깝다".

  • 단점:

    • HNSW 벡터 검색은 그래프를 따라가는 구조라서, 작은 랜덤 읽기를 수백 번 반복합니다.

    • 요청당 S3 읽기 500번 × 1M 요청 × (S3 요금)을 계산해보면, 한 달 200달러 정도가 금방 나옵니다.

    • 레이턴시도 느리고, 비용도 애매합니다.

  1. S3에서 세그먼트를 Lambda 임시 디스크로 복제한 뒤, 로컬에서 검색

  • 아이디어: 콜드 스타트 때 인덱스를 통째로(또는 세그먼트 단위로) 내려받고, 이후에는 로컬 파일시스템만 읽는다.

  • 장점:

    • 한 번 내려받고 나면 검색 속도는 거의 로컬 디스크 수준.

    • 트래픽이 꾸준하다면(예: 0.5 rps 이상) Lambda가 계속 warm 상태를 유지해서 인덱스를 자주 안 내려받아도 된다.

  • 단점:

    • 인덱스 사이즈 2GB, S3 읽기 100MB/s라고 치면 초기화 시간만 20초.

    • "엘라스틱 노드 뜨는 데 30초 걸린다"와 크게 다르지 않은 느낌이 되어 버립니다.

  1. 인덱스를 AWS EFS(NFS 스타일 네트워크 파일 시스템)에 두기

  • 아이디어: Lambda에 EFS를 붙여놓고 인덱스 파일을 거기서 읽는다.

  • 장점:

    • 콜드 스타트 시 인덱스를 복제할 필요가 없으니 초기화 시간은 거의 0에 수렴.

    • 스토리지 비용은 S3보다는 비싸지만, 요청당 읽는 데이터량 기준 계산을 하면 꽤 합리적.

  • 예시 계산:

    • 검색 한 번에 1MB 정도 읽는다고 가정

    • 1M 요청 × 1MB = 1TB

    • EFS 읽기 단가는 대략 0.03$/GB 수준 → 한 달 약 30달러

  • 단점:

    • NFS 특성상 랜덤 읽기 레이턴시가 높아서, HNSW처럼 작은 랜덤 I/O가 많은 알고리즘과 궁합이 좋지 않습니다.

  1. 인덱스를 도커 이미지 안에 "굽는" 방법

  • 아이디어: 인덱스 파일을 도커 빌드 시점에 이미지 안에 포함시켜서, 컨테이너가 뜨면 바로 접근할 수 있게 하기.

  • 장점:

    • 이론상: 인덱스가 컨테이너 안에 있으니, 네트워크 없이 바로 읽을 수 있어야 한다.

  • 현실:

    • 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 검색 과정의 문제는 "순차적인 랜덤 읽기"입니다.

대략 이런 패턴이 반복됩니다.

  1. 엔트리포인트 노드에 도착

  2. 그 노드와 연결된 이웃 노드 M개의 ID를 확인

  3. 각 이웃 노드의 임베딩을 하나씩 읽어온 뒤(지금은 대부분 순차적으로)

  4. 거리를 계산하고, "다음에 갈 노드"를 정함

  5. 그 노드로 이동하고 다시 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

이 노트는 요약·비평·학습 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.