블로그로 돌아가기
Engineering

Soul Score 알고리즘 — 코사인 유사도로 성격 궁합을 계산하는 방법

두 사람의 OCEAN 성격 벡터, 가치관, 애착유형을 하나의 매칭 벡터로 합산하고 코사인 유사도로 궁합 점수를 산출하는 썸블링의 Soul Score 알고리즘을 상세히 설명합니다.

Team Somebling ·
#Soul Score #코사인 유사도 #매칭 알고리즘 #OCEAN

Soul Score란 무엇인가

썸블링에서 두 사람이 매칭될 때 화면에 표시되는 숫자가 하나 있습니다. 0에서 100 사이의 이 값이 Soul Score입니다. 프로필 사진의 외모가 아닌, 두 사람의 내면이 얼마나 잘 맞는지를 수치로 나타낸 것입니다.

Soul Score는 세 가지 요소로 구성됩니다.

이 글에서는 각 요소가 어떻게 벡터화되고, 코사인 유사도를 통해 하나의 점수로 합산되며, 최종적으로 PostgreSQL 쿼리와 Spring Scheduler로 매일 실행되는지 기술적으로 상세히 살펴봅니다.


1단계: OCEAN 5요인을 벡터로 만들기

소울 테스트 50문항의 응답이 완료되면 서버는 각 문항의 카테고리별로 점수를 집계합니다. OCEAN 모델의 다섯 요인(개방성 Openness, 성실성 Conscientiousness, 외향성 Extraversion, 친화성 Agreeableness, 신경성 Neuroticism)은 각 0에서 100 사이의 실수로 계산됩니다.

이 다섯 값이 첫 번째 벡터를 구성합니다.

V_ocean = [openness, conscientiousness, extraversion, agreeableness, neuroticism]
         = [72.0, 58.5, 81.0, 65.0, 34.5]  (예시)

점수 계산은 리커트 5점 척도 응답을 0~100 범위로 정규화합니다. 역문항(역채점 항목)의 경우 (6 - answerValue)로 변환 후 집계합니다.

// aro-matching 모듈의 ScoreCalculator.java (의사코드)
double rawScore = answers.stream()
    .filter(a -> a.getCategory() == category)
    .mapToInt(a -> isReversed(a) ? (6 - a.getValue()) : a.getValue())
    .average()
    .orElse(3.0);

double normalized = (rawScore - 1.0) / 4.0 * 100.0;  // 1~5 → 0~100

2단계: 가치관 10항목과 애착유형 인코딩

가치관 10항목(성장, 안정, 모험, 가족, 커리어, 로맨스, 우정, 창의성, 공동체, 영성)도 마찬가지로 리커트 척도 응답을 0~100으로 정규화합니다. 이 10개 값이 두 번째 하위 벡터를 이룹니다.

V_values = [growth, stability, adventure, family, career, romance,
            friendship, creativity, community, spirituality]

애착유형은 범주형(SECURE, ANXIOUS, AVOIDANT, DISORGANIZED)입니다. 이를 연속 벡터로 다루기 위해 4차원 원-핫 인코딩을 사용합니다.

SECURE       → [1.0, 0.0, 0.0, 0.0]
ANXIOUS      → [0.0, 1.0, 0.0, 0.0]
AVOIDANT     → [0.0, 0.0, 1.0, 0.0]
DISORGANIZED → [0.0, 0.0, 0.0, 1.0]

3단계: 매칭 벡터 합성

세 하위 벡터를 하나의 matching_vector로 연결합니다. 이 벡터는 총 19차원(5 + 10 + 4)입니다.

각 하위 벡터에 가중치를 적용한 후 concatenate하는 방식입니다.

V_matching = concat(
  V_ocean    * sqrt(0.50),   // OCEAN 50%
  V_values   * sqrt(0.30),   // 가치관 30%
  V_attach   * sqrt(0.20)    // 애착유형 20%
)

sqrt를 쓰는 이유는 코사인 유사도 계산 시 내적이 가중치의 제곱에 비례하기 때문입니다. sqrt(w)를 적용하면 최종 유사도에 w만큼의 기여가 됩니다.

이 벡터는 matching_profiles.matching_vector 컬럼에 JSONB 형식으로 저장됩니다.

{
  "ocean": [72.0, 58.5, 81.0, 65.0, 34.5],
  "values": [85.0, 60.0, 70.0, 90.0, 65.0, 88.0, 75.0, 55.0, 40.0, 20.0],
  "attachment": [1.0, 0.0, 0.0, 0.0]
}
flowchart LR
    subgraph Input["소울 테스트 응답"]
        Q[50문항 응답]
    end
    subgraph Vectors["하위 벡터 산출"]
        O["V_ocean<br/>5차원 × √0.50"]
        V["V_values<br/>10차원 × √0.30"]
        A["V_attach<br/>4차원 × √0.20"]
    end
    subgraph Output["매칭 벡터"]
        M["V_matching<br/>19차원 가중 연결 벡터<br/>JSONB 저장"]
    end
    Q --> O
    Q --> V
    Q --> A
    O --> M
    V --> M
    A --> M

4단계: 코사인 유사도 계산 원리

두 벡터 A, B 사이의 코사인 유사도는 다음과 같이 정의됩니다.

cos(A, B) = (A · B) / (||A|| × ||B||)

여기서:
  A · B  = Σ(Aᵢ × Bᵢ)          (내적)
  ||A||  = sqrt(Σ Aᵢ²)          (벡터의 크기)

결과는 -1에서 1 사이의 값입니다. 우리 벡터는 모두 0 이상의 값이므로 실제 범위는 0에서 1입니다. 이를 0~100 점수로 환산합니다.

Soul Score = cos(A, B) × 100

왜 유클리드 거리가 아닌 코사인 유사도인가

유클리드 거리는 벡터 크기에 민감합니다. 예를 들어 두 사람이 모두 “외향성이 높다”는 것은 비슷한 성향인데, 한 사람이 80점이고 다른 사람이 90점이라면 유클리드 거리는 10이라는 차이를 단순히 큰 불일치로 처리합니다.

반면 코사인 유사도는 벡터의 방향을 봅니다. 두 사람이 비슷한 성향 패턴을 가지고 있는지, 즉 성격의 구조가 유사한지를 측정합니다. 절대적인 점수 차이보다 상대적인 프로필 형태의 유사성이 실제 궁합에 더 의미 있다고 판단했습니다.

또한 OCEAN 벡터의 각 차원은 단위가 다를 수 있습니다(어떤 사람은 전반적으로 점수가 높고 어떤 사람은 낮을 수 있음). 코사인 유사도는 이 규모 차이를 자동으로 정규화하여 처리합니다.


5단계: PostgreSQL Native Query로 Top N 후보 추출

매일 새벽 배치가 실행될 때 한 명의 사용자에 대해 모든 상대방과 코사인 유사도를 계산하는 것은 O(N)이지만, 전체 사용자 쌍에 대해 실행하면 O(N²)입니다. 사용자가 10만 명이라면 100억 번 연산이 필요합니다.

이를 현실적으로 처리하기 위해 두 단계 전략을 사용합니다.

  1. 후보 필터링: 성별 선호도, 나이 범위, 차단 목록으로 후보를 먼저 좁힘
  2. 유사도 계산: 필터링된 후보 중 Top N을 PostgreSQL에서 뽑음

PostgreSQL은 JSONB에 대한 연산자를 지원하므로, 내적 계산을 Native Query로 표현할 수 있습니다.

-- 의사 SQL (실제 구현은 배열 언팩 + 내적 집계)
WITH candidate_vectors AS (
    SELECT
        mp.member_id,
        mp.matching_vector,
        -- OCEAN 내적 (가중치 적용)
        (
          (my_ocean[1] * their_ocean[1] + ... + my_ocean[5] * their_ocean[5])
          * 0.50
        ) +
        -- 가치관 내적
        (
          (my_values[1] * their_values[1] + ... + my_values[10] * their_values[10])
          * 0.30
        ) +
        -- 애착유형 내적
        (
          (my_attach[1] * their_attach[1] + ... + my_attach[4] * their_attach[4])
          * 0.20
        ) AS dot_product,
        my_magnitude,
        their_magnitude
    FROM matching_profiles mp
    INNER JOIN match_preferences pref ON mp.member_id = pref.target_id
    WHERE
        mp.is_active = true
        AND mp.member_id NOT IN (blocked_ids)
        AND pref.gender_match = true
        AND pref.age_in_range = true
)
SELECT
    member_id,
    (dot_product / (my_magnitude * their_magnitude)) * 100 AS soul_score
FROM candidate_vectors
ORDER BY soul_score DESC
LIMIT :topN;

이 쿼리를 Java의 @Query(nativeQuery = true)로 래핑하고, Javadoc에 SQL 전문을 포함하여 유지보수성을 확보합니다.

/**
 * 특정 회원의 매칭 후보 Top N을 코사인 유사도 기준으로 조회합니다.
 *
 * <p>Native Query를 사용하는 이유:
 * JSONB 벡터 내적 연산은 JPQL로 표현 불가능합니다.
 * PostgreSQL의 jsonb_array_elements_text()와 집계 함수를 조합하여
 * 인덱스 힌트 없이도 수천 건 후보에서 효율적으로 Top N을 추출합니다.
 *
 * @param memberId    매칭 대상 회원 ID
 * @param gender      선호 성별 필터 (null이면 전체)
 * @param minAge      최소 나이
 * @param maxAge      최대 나이
 * @param blockedIds  차단 회원 ID 목록
 * @param limit       반환할 최대 후보 수
 * @return 소울 점수 내림차순 정렬된 후보 목록
 */
@Query(value = "SELECT ...", nativeQuery = true)
List<MatchCandidateProjection> findTopCandidatesByCosineSimilarity(
    @Param("memberId") Long memberId,
    @Param("gender") String gender,
    @Param("minAge") int minAge,
    @Param("maxAge") int maxAge,
    @Param("blockedIds") List<Long> blockedIds,
    @Param("limit") int limit
);

6단계: Spring Scheduler 일일 매칭 배치

매칭은 매일 새벽 2시에 자동 실행됩니다. aro-matching 모듈의 MatchingScheduler가 이를 담당합니다.

flowchart TD
    A["⏰ 매일 새벽 2시<br/>Cron 트리거"] --> B["1. 만료 처리<br/>PENDING → EXPIRED<br/>벌크 UPDATE"]
    B --> C["2. 활성 회원 조회<br/>is_active = true<br/>소울 테스트 완료"]
    C --> D["3. 회원별 매칭 생성"]
    D --> E["3-1. 필터링<br/>성별 · 나이 · 차단"]
    E --> F["3-2. 코사인 유사도<br/>Native Query"]
    F --> G["3-3. Top 3 선정<br/>DailyMatch 저장"]
    G --> H["4. 배치 완료 로깅"]
@Component
@RequiredArgsConstructor
public class MatchingScheduler {

    private final MatchingService matchingService;

    /**
     * 매일 새벽 2시에 일일 매칭을 생성합니다.
     * 실행 순서: 만료 처리 → 활성 프로필 조회 → 후보 추출 → 매칭 저장
     */
    @Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
    public void runDailyMatching() {
        // 1. 전날 PENDING 매칭 EXPIRED 처리 (Native Query 벌크 업데이트)
        matchingService.expirePendingMatches();

        // 2. 활성 매칭 프로필 보유 회원 목록 조회
        List<Long> activeMemberIds = matchingService.findActiveMemberIds();

        // 3. 각 회원에 대해 Top 3 후보 추출 및 DailyMatch 저장
        activeMemberIds.forEach(memberId -> {
            try {
                matchingService.generateDailyMatchesFor(memberId);
            } catch (Exception e) {
                log.error("매칭 생성 실패: memberId={}", memberId, e);
                // 개별 실패가 전체 배치를 중단하지 않도록 catch
            }
        });

        log.info("일일 매칭 배치 완료: 대상 {} 명", activeMemberIds.size());
    }
}

만료 처리는 개별 UPDATE가 아닌 Native Query 벌크 업데이트로 한 번의 쿼리에 처리합니다.

UPDATE daily_matches
SET status = 'EXPIRED', updated_at = NOW()
WHERE status = 'PENDING'
  AND match_date < CURRENT_DATE;

실제로 어떻게 느껴지나

Soul Score 90점대의 매칭은 실제로 OCEAN 프로필이 거의 겹치는 사람들입니다. 예를 들어 개방성이 높고, 신경성이 낮으며, 가족과 로맨스를 중시하고, 둘 다 안정 애착 유형인 경우입니다. 이런 쌍은 대화 주제가 자연스럽게 이어지고, 갈등 해결 방식도 유사한 경향이 있습니다.

반면 70점대는 핵심 특성은 비슷하지만 일부 차이가 있는 경우입니다. 적당한 차이는 오히려 서로에게 새로운 자극이 될 수 있다고 판단하여, 완벽한 동일인을 찾는 것보다 일정 범위의 다양성을 허용하는 설계를 유지하고 있습니다.

Soul Score는 썸블링이 외모 중심 매칭에서 벗어나 내면의 궁합을 수치화하려는 핵심 기술적 시도입니다. 앞으로는 실제 커플의 만남 후기 데이터를 바탕으로 가중치를 학습하여 점수의 정확도를 높이는 방향을 검토하고 있습니다.

댓글

GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.

블로그로 돌아가기