Spring Boot에서 Qdrant 연동 (Docker Compose + gRPC)

송현진·2025년 9월 19일
0

프로젝트 회고

목록 보기
20/22

무무의 선물상자 서비스에서 상품 추천 품질을 높이기 위해 의미 유사도 기반 검색이 필요했다. 지금까지는 단순 문자열 매칭(Jaccard, LIKE 검색 등)으로만 상품명을 비교했는데 이 방식은 “단어가 다르면 다른 상품으로 처리”하는 한계가 있었다.

예를 들어,

  • “무드등”과 “분위기 조명”
  • “다이어리”와 “노트북(문구)”

같이 실제로는 유사한 상품임에도 문자열이 다르면 전혀 관련 없는 상품으로 인식되었다. 이를 해결하기 위해 벡터 스토어(Vector Store)를 도입하기로 했고 오픈소스 벡터 DB 중 Qdrant를 선택했다.

먼저 로컬 환경에서 Qdrant를 Docker로 실행하고 Spring Boot(gRPC 클라이언트)로 연결해 end-to-end 파이프라인을 검증하는 스파이크 개발을 진행했다.

목표

  1. 로컬 Docker Compose로 Qdrant(v1.15.3) 실행 + 스토리지 영속화
  2. 앱 시작 시 products 컬렉션 자동 생성 (1536차원, 코사인 거리 기반)
  3. 상품 등록 시 → 임베딩 생성 → Qdrant Upsert 저장
  4. 검색 시 → 입력 텍스트 임베딩 생성 → Qdrant Top-K 검색 → MySQL 상품 상세와 조합
  5. Smoke Runner를 통해 전체 흐름 성공 로그 확인

구현 과정

1. Qdrant 실행 (Docker Compose)

핵심 포인트

  • 컨테이너 이름을 qdrant로 고정 → 앱에서 qdrant:6334 gRPC로 바로 연결
  • 헬스체크 제거로 로컬 대기 이슈 회피 (개발 속도 우선)
  • ON_DISK=true + 볼륨 마운트로 메모리 압박 완화 + 영속성 확보
services:
  app:
    image: gift-app:local
    build: .
    container_name: gift-app
    ports:
      - "18080:8080"
    env_file:
      - .env
    environment:
      - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-local}
      - TZ=${TZ:-Asia/Seoul}
      - SPRING_DATASOURCE_URL=${DB_URL}
      - SPRING_DATASOURCE_USERNAME=${DB_USERNAME}
      - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
      # Qdrant 연결은 코드에서 하드코딩하므로 환경변수 불필요
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway" # 로컬 ↔ 컨테이너 통신 편의
    # 헬스체크/대기조건 제거 → Waiting 이슈 회피
    depends_on:
      - qdrant
    networks: [localnet]

  qdrant:
    image: qdrant/qdrant:v1.15.3
    container_name: qdrant
    ports:
      - "6333:6333"   # REST (테스트/점검)
      - "6334:6334"   # gRPC (애플리케이션 연동용)
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      - QDRANT__STORAGE__STORAGE_PATH=/qdrant/storage
      - QDRANT__STORAGE__ON_DISK=true
      - QDRANT__STORAGE__DISABLE_FS_AVAILABILITY_CHECK=true
      - TZ=Asia/Seoul
    restart: unless-stopped
    # 헬스체크 완전 제거 (Waiting 방지)
    networks: [localnet]

networks:
  localnet:
    driver: bridge

로컬 개발은 속도/단순성이 중요하므로 healthcheck를 제거해 “기동=사용”을 빠르게 만들었다.
ON_DISK=true와 볼륨 마운트로 RAM 사용량을 억제하고 재기동해도 데이터 유지가 된다.
컨테이너명 qdrant = 도커 네트워크 내 호스트네임이므로 서비스 코드는 qdrant:6334만 알면 된다(서비스 디스커버리 단순화).

2. 컬렉션 자동 생성 Runner (멱등)

앱 기동 시 products 컬렉션이 없으면 생성한다. 이미 있으면 ALREADY_EXISTS 처리로 스킵한다. 코사인 거리 + 1536차원은 OpenAI text-embedding-3-small과 호환되는 기본 셋업했다.

@Component
@RequiredArgsConstructor
@Order(1) // 컬렉션 준비가 가장 먼저
public class VectorCollectionInitializer implements ApplicationRunner {

    private final QdrantClient qdrant;
    private static final String COLLECTION = "products";
    private static final int DIM = 1536; // text-embedding-3-small

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 컬렉션 생성 시도, 이미 있으면 ALREADY_EXISTS 처리
        Collections.VectorParams vectorParams = Collections.VectorParams.newBuilder()
                .setSize(DIM)                                   // 벡터 차원
                .setDistance(Collections.Distance.Cosine)       // 거리 측정: 코사인
                .build();

        Collections.VectorsConfig vectorsConfig = Collections.VectorsConfig.newBuilder()
                .setParams(vectorParams)                        // 단일 벡터 스페이스
                .build();

        Collections.CreateCollection createReq = Collections.CreateCollection.newBuilder()
                .setCollectionName(COLLECTION)
                .setVectorsConfig(vectorsConfig)
                .build();

        try {
            qdrant.createCollectionAsync(createReq).get(20, TimeUnit.SECONDS);
            System.out.println("Qdrant collection '" + COLLECTION + "' created");
        } catch (ExecutionException ex) {
            Throwable cause = ex.getCause();
            if (cause instanceof StatusRuntimeException) {
                Status.Code code = ((StatusRuntimeException) cause).getStatus().getCode();
                if (code == Status.ALREADY_EXISTS.getCode()) {
                    System.out.println("Collection already exists. Skip create.");
                    return; // 멱등: 이미 있으면 통과
                }
            }
            throw ex; // 다른 오류는 그대로 전파하여 빠르게 실패 인지
        }
    }
}
  • @Order(1): 서비스 로직 전에 스키마 보장
  • 코사인 거리: 텍스트 임베딩에 표준적 선택
  • 멱등성 보장: 재기동/재배포 시 안전

벡터 업서트 서비스 (더미 임베딩 → 나중에 실제로 교체)

현재는 파이프라인 검증을 위해 더미 임베딩을 사용한다. 이후 OpenAI 임베딩으로 교체 예정이다. Qdrant에 PointId=상품ID로 저장하고 Payload에 조회용 메타(제목/브랜드/가격)를 같이 넣는다.

@Service
@RequiredArgsConstructor
public class ProductVectorService {

    private final QdrantClient qdrant;
    private static final String COLLECTION = "products";
    private static final int DIM = 1536;

    // TODO: 이후 OpenAI 임베딩으로 교체 (현재는 더미 벡터로 파이프라인 확인)
    private float[] embed(String text) {
        float[] v = new float[DIM];
        Arrays.fill(v, 0.001f); // 모든 차원 동일값으로 채워 end-to-end만 검증
        return v;
    }

    /**
     * 상품 1건을 Qdrant에 Upsert
     * - PointId: 상품 id (숫자)
     * - Vectors: 단일 벡터
     * - Payload: 조회/디버깅 편의를 위한 메타데이터
     */
    public void upsertProduct(long id, String title, String brand, int price, String textForEmbedding) throws Exception {
        float[] vec = embed(textForEmbedding);

        // Vector -> Points.Vector
        Points.Vector vector = Points.Vector.newBuilder()
                .addAllData(toFloatList(vec))  // float[] → List<Float>
                .build();

        // 단일 벡터이므로 setVector 사용
        Points.Vectors vectors = Points.Vectors.newBuilder()
                .setVector(vector)
                .build();

        // (선택) Struct payload 예시 — 현재는 JsonWithInt로 putPayload 사용
        Struct payload = Struct.newBuilder()
                .putFields("productId", Value.newBuilder().setNumberValue(id).build())
                .putFields("title", Value.newBuilder().setStringValue(title).build())
                .putFields("brand", Value.newBuilder().setStringValue(brand).build())
                .putFields("price", Value.newBuilder().setNumberValue(price).build())
                .build();

        // Point 구성 (id + vectors + payload)
        Points.PointStruct point = Points.PointStruct.newBuilder()
                .setId(Points.PointId.newBuilder().setNum(id).build()) // 포인트 id = 상품 id
                .setVectors(vectors)
                // JsonWithInt로 타입 명확히 저장 (응답 파싱이 간편)
                .putPayload("productId", jsonInt(id))
                .putPayload("title", jsonStr(title))
                .putPayload("brand", jsonStr(brand))
                .putPayload("price", jsonInt(price))
                .build();

        // Upsert 요청
        Points.UpsertPoints upsert = Points.UpsertPoints.newBuilder()
                .setCollectionName(COLLECTION)
                .addPoints(point)
                .build();

        qdrant.upsertAsync(upsert).get(10, TimeUnit.SECONDS);
    }

    private static List<Float> toFloatList(float[] arr) {
        return IntStream.range(0, arr.length).mapToObj(i -> arr[i]).collect(Collectors.toList());
    }

    private static JsonWithInt.Value jsonStr(String s) {
        return JsonWithInt.Value.newBuilder().setStringValue(s).build();
    }
    private static JsonWithInt.Value jsonInt(long n) {
        return JsonWithInt.Value.newBuilder().setIntegerValue(n).build();
    }

}
  • PointId = RDB PK(상품ID)로 맞추면 후처리가 단순해지고 정합성 관리가 쉬워진다.
  • Payload에 핵심 메타(title/brand/price)를 같이 넣어두면 검색 결과만으로도 빠르게 응답/로그 확인이 가능하다(필요 시 RDB 조합).
  • Qdrant Java gRPC의 JsonWithInt.Value를 사용하면 숫자/문자 타입이 분명해져 응답 파싱에서 오류 가능성이 낮다.
  • 지금은 더미 임베딩으로 “배선 테스트”만 통과시킨 상태. 이후 embed(String)만 교체하면 전체 플로우는 그대로 간다.

4. Smoke Runner (업서트 → 검색 → 결과 파싱 자동 검증)

앱 기동 시 자동으로 더미 상품 업서트 → 검색 → Payload 파싱까지 수행하여 연결 상태를 즉시 확인한다. WithPayloadSelector.include로 필요한 필드만 내려받아 응답 크기 최소화했다.

@Component
@RequiredArgsConstructor
@Order(2) // 컬렉션 생성 이후 실행
public class VectorSmokeRunner implements ApplicationRunner {

    private final ProductVectorService productVectorService;
    private final QdrantClient qdrant;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 1. 업서트(더미 벡터)
        productVectorService.upsertProduct(
                1L,
                "무드등",
                "BrandA",
                19900,
                "따뜻한 조명 무드등 선물"
        );
        System.out.println("테스트 product upsert 성공");

        // 2. 검색 질의 벡터 (더미) — 실제에선 입력 텍스트 임베딩
        float[] queryVec = new float[1536];
        Arrays.fill(queryVec, 0.001f);

        // 필요한 payload만 포함해서 응답 크기 최소화
        Points.WithPayloadSelector withPayload = Points.WithPayloadSelector.newBuilder()
                .setInclude(
                        Points.PayloadIncludeSelector.newBuilder()
                                .addFields("title")
                                .addFields("brand")
                                .addFields("price")
                                .addFields("productId")
                                .build()
                ).build();

        // 3. 검색 요청 구성
        Points.SearchPoints searchReq = Points.SearchPoints.newBuilder()
                .setCollectionName("products")
                .addAllVector(toFloatList(queryVec))
                .setLimit(5)
                .setWithPayload(withPayload)
                .build();

        // 4. 검색 실행
        List<Points.ScoredPoint> res = qdrant.searchAsync(searchReq)
                .get(10, TimeUnit.SECONDS);

        // 5. 결과 출력 (payload 파싱)
        System.out.println("Search hits:");
        for (Points.ScoredPoint sp : res) {
            System.out.println("Raw payload map: " + sp.getPayloadMap());
            Map<String, JsonWithInt.Value> pm = sp.getPayloadMap();
            String title = pm.containsKey("title") ? pm.get("title").getStringValue() : "(no title)";
            String brand = pm.containsKey("brand") ? pm.get("brand").getStringValue() : "(no brand)";
            long productId = pm.containsKey("productId") ? pm.get("productId").getIntegerValue() : -1L;
            long price = pm.containsKey("price") ? pm.get("price").getIntegerValue() : -1L;

            System.out.printf("id=%d, score=%.4f, title=%s, brand=%s, price=%d%n",
                    sp.getId().getNum(), sp.getScore(), title, brand, price);
        }
    }

    private static List<Float> toFloatList(float[] arr) {
        List<Float> list = new ArrayList<>(arr.length);
        for (float v : arr) {
            list.add(v);
        }
        return list;
    }
}
  • 앱을 켜는 순간 ‘기동=검증’이 되게 해서 개발 루프를 짧게 만든다(실패는 빨리, 성공은 자동 로그로 확인).
  • include 셀렉터로 필요 필드만 내려받아 네트워크 비용/로그 노이즈를 줄였다.

설명 없는 부분에 대한 의도 정리

  • QdrantClient 호스트/포트는 코드에서 하드코딩(qdrant:6334)
    도커 네트워크 내 서비스명으로 고정해 환경변수/설정 의존성을 줄였다. 운영 전환 시에는 SPRING_QDRANT_HOST/PORT 같은 외부화로 바꿀 수 있다.

  • extra_hosts: host.docker.internal
    컨테이너에서 호스트로의 접근이 필요할 때(예: 로컬 툴/프록시) 즉시 활용할 수 있도록 기본값으로 넣어두었다.

  • 타임아웃(10~20s) 명시
    네트워크 이슈 시 영원히 대기하지 않고 조기 실패하도록 했다.

  • Struct payload vs JsonWithInt
    Struct는 자유도가 높지만 타입 보장이 약하다. 여기서는 검색 응답 파싱의 안전성을 위해 JsonWithInt를 채택했다(숫자/문자 혼동 방지).

  • Upsert 단건 API
    스파이크 단계라 단건 흐름으로 단순화했다. 본 적용에서는 배치 업서트(bulk upsert)로 전환할 계획이다.

  • DIM=1536/코사인
    OpenAI text-embedding-3-small의 차원/스케일과 맞추기 위한 표준 조합. 모델 교체 시 컬렉션 스키마(차원/거리) 변경 필요.

설계 의도 & 트레이드오프

  • gRPC vs REST

    • 장점: 이진 프로토콜로 대역폭↓/성능↑, Java용 공식 클라이언트와 타입 안정성 확보.
    • 트레이드오프: 디버깅/탐색성은 REST가 더 편하다. 그래서 포트를 둘 다 열어두고(6333/6334) 로컬 점검은 REST UI/curl을, 애플리케이션 연동은 gRPC를 사용한다.
  • PointId = RDB PK(상품ID)

    • 장점: MySQL과 조합 시 키 매핑이 직관적이고 조회/정합성이 쉽다.
    • 트레이드오프: 키 정책 변경(예: 복합키) 시 재지표화/재적재 비용이 생긴다.
  • Payload 동반 저장

    • 장점: 검색 결과만으로도 빠른 응답/로그가 가능하고 경우에 따라 DB 조회를 생략할 수도 있다.
    • 트레이드오프: 중복 데이터 관리 필요(원천 변경 시 재동기화/업서트 필요).
  • 멱등 초기화(@Order)

    • 장점: 재배포/재시작 시 안정적이고 실패 지점이 스키마 준비로 한정되어 진단이 쉽다.
    • 트레이드오프: 초기 스키마가 변경되면 마이그레이션/재생성 절차가 필요.
  • ON_DISK + 볼륨

    • 장점: 로컬/소형 인스턴스에서 메모리 압박 완화, 재기동 시 데이터 유지.
    • 트레이드오프: I/O 병목이 생길 수 있어 운영에서는 디스크 성능/캐시 튜닝이 중요.
  • 헬스체크 제거(로컬 전용)

    • 장점: 개발 속도와 단순성 확보.
    • 트레이드오프: 운영에서는 반드시 헬스체크/대기조건을 넣어야 한다(예: 앱이 컬렉션 미생성 상태에서 질의 시작하는 이슈 방지).
  • 일관성(Consistency) 포인트
    Qdrant 업서트 성공 후 MySQL 커밋 실패(또는 그 반대) 같은 분산 일관성 이슈가 있을 수 있다. 본 적용에서는 서비스 레벨 트랜잭션 설계(재시도/보상/리콘실리에이션 잡, 상태 플래그)로 해결 예정.

배운점

이번 스파이크로 문자열 매칭 → 임베딩 기반 검색 전환이 추천 품질을 구조적으로 끌어올릴 수 있음을 체감했다. Qdrant gRPC 클라이언트는 타입 안전하고 응답 모델이 명확해 Spring Boot 연동 난이도가 낮았고 컬렉션 자동 생성(@Order)과 스모크 러너를 넣어 앱 기동=파이프라인 검증이 가능해져 개발 루프가 크게 짧아졌다. 로컬에서도 ON_DISK와 볼륨을 챙기니 메모리/영속성 리스크를 조기에 제어할 수 있었고 “RDB 메타 + Vector Store”의 이중 저장 구조가 실제 추천 서비스의 현실적인 뼈대가 될 수 있다는 확신을 얻었다. 다음 단계는 실제 임베딩/배치 적재/운영 모니터링으로 품질과 안정성을 동시에 끌어올리는 것. 특히 분산 일관성과 후처리(브랜드 다양성, 가격 필터, 점수 임계값)를 정교하게 다듬으면 사용자 관점에서 “정말 똑똑한 추천”을 체감하게 만들 수 있을 것이다.

profile
개발자가 되고 싶은 취준생

0개의 댓글