무무의 선물상자 서비스에서 상품 추천 품질을 높이기 위해 의미 유사도 기반 검색이 필요했다. 지금까지는 단순 문자열 매칭(Jaccard, LIKE 검색 등)으로만 상품명을 비교했는데 이 방식은 “단어가 다르면 다른 상품으로 처리”하는 한계가 있었다.
예를 들어,
같이 실제로는 유사한 상품임에도 문자열이 다르면 전혀 관련 없는 상품으로 인식되었다. 이를 해결하기 위해 벡터 스토어(Vector Store)를 도입하기로 했고 오픈소스 벡터 DB 중 Qdrant를 선택했다.
먼저 로컬 환경에서 Qdrant를 Docker로 실행하고 Spring Boot(gRPC 클라이언트)로 연결해 end-to-end 파이프라인을 검증하는 스파이크 개발을 진행했다.
products
컬렉션 자동 생성 (1536차원, 코사인 거리 기반)핵심 포인트
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
만 알면 된다(서비스 디스커버리 단순화).
앱 기동 시 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();
}
}
title
/brand
/price
)를 같이 넣어두면 검색 결과만으로도 빠르게 응답/로그 확인이 가능하다(필요 시 RDB 조합).JsonWithInt.Value
를 사용하면 숫자/문자 타입이 분명해져 응답 파싱에서 오류 가능성이 낮다.embed(String)
만 교체하면 전체 플로우는 그대로 간다.앱 기동 시 자동으로 더미 상품 업서트 → 검색 → 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
curl
을, 애플리케이션 연동은 gRPC를 사용한다.PointId = RDB PK(상품ID)
Payload 동반 저장
멱등 초기화(@Order)
ON_DISK + 볼륨
헬스체크 제거(로컬 전용)
일관성(Consistency) 포인트
Qdrant 업서트 성공 후 MySQL 커밋 실패(또는 그 반대) 같은 분산 일관성 이슈가 있을 수 있다. 본 적용에서는 서비스 레벨 트랜잭션 설계(재시도/보상/리콘실리에이션 잡, 상태 플래그)로 해결 예정.
이번 스파이크로 문자열 매칭 → 임베딩 기반 검색 전환이 추천 품질을 구조적으로 끌어올릴 수 있음을 체감했다. Qdrant gRPC 클라이언트는 타입 안전하고 응답 모델이 명확해 Spring Boot 연동 난이도가 낮았고 컬렉션 자동 생성(@Order
)과 스모크 러너를 넣어 앱 기동=파이프라인 검증이 가능해져 개발 루프가 크게 짧아졌다. 로컬에서도 ON_DISK
와 볼륨을 챙기니 메모리/영속성 리스크를 조기에 제어할 수 있었고 “RDB 메타 + Vector Store”의 이중 저장 구조가 실제 추천 서비스의 현실적인 뼈대가 될 수 있다는 확신을 얻었다. 다음 단계는 실제 임베딩/배치 적재/운영 모니터링으로 품질과 안정성을 동시에 끌어올리는 것. 특히 분산 일관성과 후처리(브랜드 다양성, 가격 필터, 점수 임계값)를 정교하게 다듬으면 사용자 관점에서 “정말 똑똑한 추천”을 체감하게 만들 수 있을 것이다.