본문 바로가기

AI

Qdrant + NCP Clova Embedding으로 AI 기반 매칭 시스템 구축하기

프로젝트 매칭 서비스를 개발하면서 "사용자 프로필과 게시글의 의미적 유사도를 계산하여 추천"하는 기능이 필요했습니다.
단순 키워드 매칭이 아닌, 의미 기반 유사도 검색구현하기 위해 벡터 임베딩벡터 데이터베이스를 도입하게 되었습니다.

이번 포스팅에서는 Qdrant 벡터 데이터베이스NCP Clova Embedding을 활용하여 AI 기반 매칭 시스템을 구축한 경험과 트러블슈팅 과정을 공유하려 합니다.


출처 : 위키독스

1. 왜 벡터 검색이 필요한가?

기존 방식의 한계

일반적인 키워드 기반 검색은 다음과 같은 문제가 있습니다:

- 동의어 처리 불가: "백엔드 개발자"와 "서버 개발자"를 다른 것으로 인식
- 의미 파악 불가: "React를 사용하는 프로젝트"와 "프론트엔드 프레임워크 프로젝트"의 연관성 파악 어려움
- 맥락 무시: "Java"가 언어인지 커피인지 구분 불가

벡터 임베딩의 장점

벡터 임베딩은 텍스트를 고차원 벡터 공간에 매핑하여 의미적으로 유사한 텍스트는 벡터 공간에서 가까운 위치에 배치됩니다. 이를 통해,

- 의미적 유사도 계산 가능
- 동의어, 유의어 자동 처리
- 맥락을 고려한 검색
 
 
출처 : 엔비디아
 

2. 기술 스택 선택: 왜 Qdrant인가?

벡터 데이터베이스 비교
 
옵션  장점  단점 
Pinecone 관리형 서비스, 사용 간편 비용 부담, 해외 서비스 (지연)
Weaviate 오픈소스 , GraphQL 지원 설정 복잡, 리소스 많이 필요 
Qdrant 오픈소스, 가벼움, REST API Java 클라이언트 미지원 


Java + SpringBoot 기반 프로젝트에서 Qdrant를 선택한 이유

1. 오픈소스 + 자체 호스팅 가능
   - Pinecone 같은 클라우드 서비스는 사용량에 따라 비용이 증가
   - NCP 서버에 직접 설치하여 비용 절감

2. REST API 제공
   - Java 공식 클라이언트가 없어도 HTTP로 연동 가능
   - 언어/프레임워크 독립적

3. HNSW 인덱스로 빠른 검색
   - 대량 벡터에서도 실시간 검색 가능
   - 코사인 유사도 검색 최적화

Qdrant 설정
# application-prod.yml - 운영환경
qdrant:
  host: ${QDRANT_HOST:localhost}
  port: 6333
  grpc-port: 6334
  timeout: 30
  collection-name: posts_embeddings
  vector-size: 1024  # NCP Clova Embedding 차원
  # 저희는 네이버클라우드의 크레딧 지원을 받아 클로바를 사용하였습니다.
  # 차원은 다양한 차원이 있으나, 추후 확장성을 고려하여 1024차원을 사용하였습니다.




3. 트러블슈팅: Java 클라이언트 호환성 문제

문제 상황

Qdrant 공식 Java 클라이언트를 사용하려 했지만..
( 사실 초창기에는 오픈소스라는 것에 현혹당해서 이 사실들을 몰랐습니다.. )

- Spring Boot 3.x와 호환성 문제
- gRPC 기반 클라이언트가 의존성 충돌
- 프로덕션 환경에서 연결 실패 및 타임아웃 이슈

해결: REST API 직접 구현

공식 클라이언트를 포기하고 OkHttp를 사용한 REST API 직접 구현을 선택했습니다.

VectorRepository 구현
@Slf4j
@Repository
@RequiredArgsConstructor
public class VectorRepository {

    private final ObjectMapper objectMapper;

    @Value("${qdrant.collection-name}")
    private String collectionName;

    @Value("${qdrant.vector-size}")
    private int vectorSize;

    @Value("${qdrant.host}")
    private String host;

    @Value("${qdrant.port}")
    private int port;

    private final OkHttpClient httpClient = new OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build();

    private String getBaseUrl() {
        return String.format("http://%s:%d", host, port);
    }

 
컬렉션 자동 생성
//컬렉션이 존재하는지 확인하고 없으면 생성
public void ensureCollectionExists() {
    try {
        Request request = new Request.Builder()
                .url(String.format("%s/collections/%s", getBaseUrl(), collectionName))
                .get()
                .build();

        Response response = httpClient.newCall(request).execute();
       
        if (response.code() == 404) {
            createCollection();
        } else if (!response.isSuccessful()) {
            log.error("컬렉션 확인 실패: {}", response.body().string());
            throw new IllegalStateException("컬렉션 확인 실패");
        }
       
        response.close();
    } catch (IOException e) {
        log.error("컬렉션 확인 중 오류", e);
        throw new IllegalStateException("컬렉션 확인 실패", e);
    }
}

private void createCollection() {
    try {
        Map<String, Object> body = Map.of(
                "vectors", Map.of(
                        "size", vectorSize,
                        "distance", "Cosine"  // 코사인 유사도 사용
                )
        );

        String json = objectMapper.writeValueAsString(body);

        Request request = new Request.Builder()
                .url(String.format("%s/collections/%s", getBaseUrl(), collectionName))
                .put(RequestBody.create(json, MediaType.get("application/json")))
                .build();

        Response response = httpClient.newCall(request).execute();
       
        if (!response.isSuccessful()) {
            String errorBody = response.body() != null ? response.body().string() : "";
            log.error("컬렉션 생성 실패: {}", errorBody);
            throw new IllegalStateException("컬렉션 생성 실패");
        }
       
        response.close();
        log.info("Qdrant 컬렉션 생성 완료: {}", collectionName);
    } catch (IOException e) {
        log.error("컬렉션 생성 중 오류", e);
        throw new IllegalStateException("컬렉션 생성 실패", e);
    }
}

 
벡터 검색 구현
/**
 * 벡터 검색 (유사도 기반)
 * @param queryEmbedding 검색 쿼리 벡터
 * @param limit 결과 개수
 * @return postId와 유사도 점수의 맵 (postId -> similarity score)
 */
public Map<Long, Double> searchSimilar(float[] queryEmbedding, int limit) {
    try {
        Map<String, Object> body = Map.of(
                "vector", queryEmbedding,
                "limit", limit,
                "with_payload", false
        );

        String json = objectMapper.writeValueAsString(body);

        Request request = new Request.Builder()
                .url(String.format("%s/collections/%s/points/search",
                    getBaseUrl(), collectionName))
                .post(RequestBody.create(json, MediaType.get("application/json")))
                .build();

        Response response = httpClient.newCall(request).execute();
       
        if (!response.isSuccessful()) {
            String errorBody = response.body() != null ? response.body().string() : "";
            log.error("벡터 검색 실패: {}", errorBody);
            throw new IllegalStateException("벡터 검색 실패");
        }
       
        String responseBody = response.body().string();
        response.close();

        // 응답 파싱 (ID와 유사도 점수 함께 추출)
        JsonNode root = objectMapper.readTree(responseBody);
        JsonNode result = root.get("result");
       
        Map<Long, Double> postSimilarities = new LinkedHashMap<>();
        if (result != null && result.isArray()) {
            for (JsonNode point : result) {
                JsonNode id = point.get("id");
                JsonNode score = point.get("score");
               
                if (id != null && id.isNumber() && score != null && score.isNumber()) {
                    Long postId = id.asLong();
                    Double similarity = score.asDouble();
                    postSimilarities.put(postId, similarity);
                }
            }
        }

        return postSimilarities;

    } catch (IOException e) {
        log.error("벡터 검색 중 오류", e);
        throw new IllegalStateException("벡터 검색 실패", e);
    }
}
 

REST API 직접 구현의 장점

- 제어권 확보 : 타임아웃, 에러 핸들링 직접 관리
- 안정성 향상 : 공식 클라이언트의 호환성 문제 회피
- 의존성 감소 : 불필요한 라이브러리 제거


4. NCP Clova Embedding 선택 이유

1. 한국어 특화 모델
   - 'nlp-embedding-ko-1.0.1' 모델 사용
   - 한국어 의미 이해 및 유사도 계산에 최적화

2. 1024차원 벡터
   - 충분한 표현력과 검색 성능의 균형

Clova Embedding Service 구현
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "ai.provider", havingValue = "ncp", matchIfMissing = false)
public class ClovaEmbeddingServiceImpl implements EmbeddingService {

    @Value("${clova.embedding.api-key:}")
    private String apiKey;

    @Value("${ncp.api-key:}")
    private String ncpApiKey;

    @Value("${clova.embedding.base-url}")
    private String baseUrl;

    @Value("${clova.embedding.model}")
    private String model;

    private final RestTemplate restTemplate;

    @Override
    public float[] embed(String text) {
        log.info("Clova Embedding API 호출 시작");

        // Embedding 키가 없으면 기본 NCP 키 사용
        String actualApiKey = !apiKey.isEmpty() ? apiKey : ncpApiKey;

        try {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + actualApiKey);
            headers.set("X-NCP-CLOVASTUDIO-REQUEST-ID", generateRequestId());

            Map<String, Object> body = Map.of("text", text);

            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
            ResponseEntity<Map> response = restTemplate.postForEntity(baseUrl, entity, Map.class);

            return extractEmbedding(response.getBody());

        } catch (HttpClientErrorException e) {
            log.error("HTTP 에러 발생! 상태 코드: {}", e.getStatusCode());
            log.error("응답 본문: {}", e.getResponseBodyAsString());
            throw new IllegalStateException("Clova Embedding API 호출 실패: " + e.getMessage());
        } catch (Exception e) {
            log.error("예외 발생", e);
            throw new IllegalStateException("Clova Embedding API 처리 실패: " + e.getMessage());
        }
    }
트러블슈팅: 응답 구조 파싱

Clova Embedding API의 응답 구조가 문서와 다를 수 있어 Fallback 로직을 구현했습니다.
/**
 * 응답에서 임베딩 벡터 추출
 * OpenAI 호환 형식 지원
 */
@SuppressWarnings("unchecked")
private float[] extractEmbedding(Map<String, Object> responseBody) {
    if (responseBody == null) {
        throw new IllegalStateException("Clova 응답이 비어있습니다");
    }

    // OpenAI 호환 형식 시도
    if (responseBody.containsKey("data")) {
        List<Map<String, Object>> data = (List<Map<String, Object>>) responseBody.get("data");
        if (data != null && !data.isEmpty()) {
            Map<String, Object> firstItem = data.get(0);
            List<Double> embedding = (List<Double>) firstItem.get("embedding");
            if (embedding != null && !embedding.isEmpty()) {
                return convertToFloatArray(embedding);
            }
        }
    }

    // Clova 고유 형식 시도
    if (responseBody.containsKey("result")) {
        Map<String, Object> result = (Map<String, Object>) responseBody.get("result");
        if (result != null) {
            List<Double> embedding = (List<Double>) result.get("embedding");
            if (embedding != null && !embedding.isEmpty()) {
                return convertToFloatArray(embedding);
            }
        }
    }

    // 응답 구조가 다를 수 있으므로 전체 로깅
    log.error("Clova Embedding 응답 구조를 파악할 수 없습니다. 응답: {}", responseBody);
    throw new IllegalStateException("Clova 응답에서 embedding을 찾을 수 없습니다");
}

private float[] convertToFloatArray(List<Double> embedding) {
    float[] embeddingArray = new float[embedding.size()];
    for (int i = 0; i < embedding.size(); i++) {
        embeddingArray[i] = embedding.get(i).floatValue();
    }
    log.info("임베딩 생성 완료: {}차원", embeddingArray.length);
    return embeddingArray;
}



5. 자연어 추출 및 매칭 로직

텍스트 추출 유틸리티

저는 유저의 프로필과 게시글을 임베딩 가능한 텍스트로 변환하는 유틸리티를 구현했습니다.
 
public class TextExtractor {

    /**
     * 프로필을 텍스트로 변환
     * 포맷: "기술: 스택1 스택2 ... / 직군: 역할1 역할2 ... / 관심사: 키워드1 키워드2 ... / 활동방식: 온라인"
     */
    public static String extractFromProfile(Profile profile) {
        StringBuilder sb = new StringBuilder();

        // 기술 스택
        if (profile.getTechSkills() != null && !profile.getTechSkills().isEmpty()) {
            String techs = profile.getTechSkills().stream()
                    .map(tech -> tech.getName())
                    .collect(Collectors.joining(" "));
            sb.append("기술: ").append(techs).append(" ");
        }

        // 직군
        if (profile.getRoles() != null && !profile.getRoles().isEmpty()) {
            String roles = profile.getRoles().stream()
                    .map(role -> role.getName())
                    .collect(Collectors.joining(" "));
            sb.append("직군: ").append(roles).append(" ");
        }

        // 관심 키워드
        if (profile.getInterestKeywords() != null && !profile.getInterestKeywords().isEmpty()) {
            String interests = profile.getInterestKeywords().stream()
                    .map(interest -> interest.getName())
                    .collect(Collectors.joining(" "));
            sb.append("관심사: ").append(interests).append(" ");
        }

        // 활동 방식
        if (profile.getActivityMode() != null) {
            sb.append("활동방식: ").append(profile.getActivityMode().name());
        }

        return sb.toString().trim();
    }

    /**
     * 게시글을 텍스트로 변환
     * 포맷: "제목 / 기술: 스택1 스택2 ... / 직군: 역할1 역할2 ... / 분야: 키워드1 키워드2 ... / 내용: ..."
     */
    public static String extractFromPost(Post post) {
        StringBuilder sb = new StringBuilder();

        // 제목
        if (post.getTitle() != null) {
            sb.append(post.getTitle()).append(" ");
        }

        // 기술 스택
        if (post.getStacks() != null && !post.getStacks().isEmpty()) {
            String techs = post.getStacks().stream()
                    .map(stack -> stack.getTechSkill().getName())
                    .collect(Collectors.joining(" "));
            sb.append("기술: ").append(techs).append(" ");
        }

        // 모집 직군
        if (post.getRoleRequirements() != null && !post.getRoleRequirements().isEmpty()) {
            String roles = post.getRoleRequirements().stream()
                    .map(req -> req.getRole().getName())
                    .collect(Collectors.joining(" "));
            sb.append("직군: ").append(roles).append(" ");
        }

        // 관심 분야
        if (post.getFields() != null && !post.getFields().isEmpty()) {
            String fields = post.getFields().stream()
                    .map(field -> field.getInterestKeyword().getName())
                    .collect(Collectors.joining(" "));
            sb.append("분야: ").append(fields).append(" ");
        }

        // 내용 (200자 제한)
        if (post.getContent() != null) {
            String content = post.getContent();
            if (content.length() > 200) {
                content = content.substring(0, 200) + "...";
            }
            sb.append("내용: ").append(content);
        }

        return sb.toString().trim();
    }
}

프로필 추천 서비스

게시글 기반으로 유사한 프로필을 추천하는 서비스도 구현했습니다.
(저희 서비스 기반에 대한 맞춤 코드입니다. )
@Service
@RequiredArgsConstructor
@Slf4j
public class ProfileRecommendationService {

    private static final int DEFAULT_SIZE = 10;  // Qdrant에서 가져올 개수
    private static final int FINAL_LIMIT = 5;    // 최종 반환 개수

    private final PostRepository postRepository;
    private final ProfileRepository profileRepository;
    private final VectorRepository vectorRepository;
    private final EmbeddingService embeddingService;
    private final MatchedRepository matchedRepository;
    private final SuggestionRepository suggestionRepository;

    
    //게시글 기반으로 추천 프로필 목록 반환
    public RecommendationProfileListResponse recommendProfiles(Long postId, Long cursor, Integer size) {
        log.info("프로필 추천 요청: postId={}, cursor={}, size={}", postId, cursor, size);

        // 1. 게시글 조회
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다."));

        // 2. 임베딩 서비스 확인
        if (embeddingService.isEmpty() || vectorRepository.isEmpty()) {
            log.warn("벡터 DB 미사용 환경 - 추천 기능 비활성화");
            return RecommendationProfileListResponse.of(new ArrayList<>(), null, false);
        }

        // 3. 게시글 → 텍스트 → 임베딩
        String postText = TextExtractor.extractFromPost(post);
        float[] postEmbedding = embeddingService.get().embed(postText);
        log.info("게시글 임베딩 생성 완료: {}차원", postEmbedding.length);

        // 4. Qdrant에서 유사 프로필 검색
        Map<Long, Double> profileSimilarities = vectorRepository.get()
                .searchSimilarProfiles(postEmbedding, DEFAULT_SIZE);
        log.info("유사 프로필 검색 완료: {}개", profileSimilarities.size());

        // 5. 필터링 및 상세 정보 조회
        List<RecommendationProfileResponse> recommendations = filterAndEnrichProfiles(
                profileSimilarities, post, postId
        );

        // 6. 커서 기반 페이징 ( 6번, 7번은 구현하신 페이지네이션에 맞춰서 하시면 됩니다. )
        int limit = (size != null && size > 0) ? size : FINAL_LIMIT;
        List<RecommendationProfileResponse> paginated = recommendations.stream()
                .limit(limit)
                .toList();

        // 7. nextCursor 계산
        Long nextCursor = paginated.isEmpty() ? null :
                paginated.get(paginated.size() - 1).profileId();
        boolean hasNext = paginated.size() == limit;

        return RecommendationProfileListResponse.of(paginated, nextCursor, hasNext);
    }

   
    //검색된 프로필들을 필터링하고 상세 정보 추가
    private List<RecommendationProfileResponse> filterAndEnrichProfiles(
            Map<Long, Double> profileSimilarities, Post post, Long postId
    ) {
        List<RecommendationProfileResponse> result = new ArrayList<>();

        // 이미 매칭된 멤버 제외
        Set<Long> existingMemberIdSet = new HashSet<>(
                matchedRepository.findUserIdsByPostId(postId)
        );

        for (Map.Entry<Long, Double> entry : profileSimilarities.entrySet()) {
            Long userId = entry.getKey();
            Double similarity = entry.getValue();

            // 이미 멤버인 경우 제외
            if (existingMemberIdSet.contains(userId)) {
                log.debug("이미 멤버인 유저이므로 추천 제외: userId={}", userId);
                continue;
            }

            // 프로필 조회
            Profile profile = profileRepository.findByUserId(userId)
                    .orElse(null);

            if (profile == null) {
                log.warn("프로필을 찾을 수 없음: userId={}", userId);
                continue;
            }

            // 필터링: 공개 프로필만, 게시글 작성자 제외
            if (!shouldIncludeProfile(profile, post)) {
                continue;
            }

            // 태그 생성
            List<String> tags = generateTags(profile, post);

            // 제안 ID 여부 확인
            List<SuggestionStatus> validStatuses = List.of(
                    SuggestionStatus.SENT,
                    SuggestionStatus.ACCEPTED
            );
            Long suggestionId = suggestionRepository
                    .findValidSuggestionId(postId, userId, validStatuses)
                    .orElse(null);

            // 실제 유사도 점수 사용 (Qdrant에서 반환된 Cosine Similarity 점수)
            RecommendationProfileResponse response = RecommendationProfileResponse.from(
                    profile, similarity, tags, suggestionId
            );
            result.add(response);
        }

        return result;
    }
}


6. "벡터 검색 vs RAG: 왜 RAG가 아닌가?"
 
 
2025년 11월 29일 선릉역에서 열린 컨퍼런스에 참가하여 발표 후, 위와 같은 질문을 받았습니다.
출처 : 스위프
출처 : 스위프 - 질의응답시간

A. RAG 방식을 구현한다고 가정해보면 아래와 같은 플로우가 완성됩니다.

  • 게시글/프로필 → 임베딩 → 벡터 검색 → LLM에 컨텍스트 제공 → LLM이 추천 설명 생성
A. 현재 시스템 (벡터 검색)
 
  • 게시글/프로필 → 임베딩 → Qdrant 벡터 검색 → 유사도 점수 반환
 
A. 벡터 검색이 RAG보다 나은 이유?
 
현장에서 답변한 내용과 제가 알고 있는 사실을 근거를 바탕으로 비교해보았습니다.
 
항목  Vector 검색 RAG 
응답 속도 수십~수백 ms 1~3초 이상
비용 임베딩만 (1회) 매 추천마다 LLM 호출
투명성 유사도 점수 제공 자연어 설명만
확정성 수학적 계산 (재현 가능), O(log N) 확률적 생성 (불확실), 선형 증가 
필터링 비즈니스 로직과 분리 프롬프트에 포함

A. 왜 RAG가 아닌 벡터 검색을 선택했는가?

1. 매칭 서비스의 특성
   - 추천 목록이 필요하지, 설명 생성이 필요 없음
   - 사용자는 "왜 추천되었는지"보다 "누가 추천되었는지"가 중요

2. 실시간성 요구사항
   - 수천 명 동시 접속에도 빠른 응답 필요
   - LLM 호출은 병목 지점이 될 수 있음

3. 비용 효율성
   - LLM 비용 부담 ( GPT-4 기준 추천당 $0.01~0.03 )
   - 일일 1,000건 추천 시, 월 $300~900

4. 투명성과 신뢰성
   - 유사도 점수를 직접 제공하여 사용자가 판단 가능
   - LLM의 불확실한 답변보다 수치 기반 결과가 신뢰도 높음

 


7. 전체 플로우
 
사용자 요청 -> PostService ( 게시글 조회 ) -> TextExtractor ( 텍스트 추출 ) -> ClovaEmbedding ( 벡터생성 , 1024차원 )
-> VectorRepository ( Qdrant 검색, 코사인 유사도 ) -> 필터링 및 정렬 -> 추천 결과 반환 (res객체에 유사도 점수 반환)


8. 성능 및 결과

성능 지표

- 임베딩 생성: 평균 200~300ms (Clova API 호출)
- 벡터 검색: 평균 50~100ms (Qdrant HNSW 인덱스)
- 전체 추천 응답: 평균 300~400ms

사용자 경험 개선

- 의미 기반 매칭으로 추천 정확도 향상
- 유사도 점수 제공으로 투명성 확보
- 실시간 검색으로 빠른 응답



9. 배운 점 및 향후 개선 사항
 
배운 점

1. 공식 클라이언트가 없어도 REST API로 충분히 구현 가능
   - 오히려 더 세밀한 제어 가능
   - 의존성 문제 회피

2. 한국어 서비스는 한국어 특화 모델이 필수
   - OpenAI보다 NCP Clova가 한국어 성능 우수
   - 네트워크 지연도 최소화

3. RAG는 필요할 때만 사용
   - 매칭 서비스는 벡터 검색만으로 충분
   - LLM은 설명/요약이 필요한 경우에만 활용

향후 개선 사항

1. 임베딩 캐싱
   - 동일 텍스트 재임베딩 방지
   - Redis 활용

2. 하이브리드 검색
   - 벡터 검색 + 키워드 검색 결합
   - 더 정확한 결과 제공

3. A/B 테스트
   - 추천 알고리즘 효과 측정
   - 사용자 피드백 기반 개선


마무리

Qdrant와 NCP Clova Embedding을 활용하여 AI 기반 매칭 시스템을 구축했습니다.
공식 클라이언트 호환성 문제를 REST API 직접 구현으로 해결하고, 한국어 특화 모델을 선택하여 의미 기반 추천을 구현했습니다.

벡터 검색만으로도 충분히 효과적인 매칭 시스템을 구축할 수 있으며, RAG는 정말 필요한 경우에만 사용하는 것이 비용과 성능 측면에서 유리합니다

이번 경험을 통해 벡터 데이터베이스와 임베딩 기술에 대한 이해를 깊게 할 수 있었고, 실제 프로덕션 환경에서의 트러블슈팅 경험도 쌓을 수 있었습니다.
 
 
전체 소스코드는 깃허브에서 확인 가능합니다
 

https://github.com/hoonjo123/dodream-be

 

 

 

GitHub - swyp-dodream/dodream-be

Contribute to swyp-dodream/dodream-be development by creating an account on GitHub.

github.com

 


 

 

'AI' 카테고리의 다른 글

이원 분산 분석(Two-way ANOVA)  (0) 2026.05.14
일원분산분석( one-way ANOVA ) with python  (0) 2026.05.14
분산분석 ANOVA  (0) 2026.05.13
카이제곱 검정  (0) 2026.05.11