Skip to content

Latest commit

 

History

History
600 lines (486 loc) · 18.5 KB

File metadata and controls

600 lines (486 loc) · 18.5 KB

매칭 알고리즘 v2 설계 문서

상태: ✅ 구현 완료 작성일: 2026-01-31 버전: v2 (온보딩 v2 스키마 대응) 기술 스택: PostgreSQL Functions, Supabase RPC


1. 개요

Fitting 앱의 사용자 매칭 알고리즘 v2입니다. 온보딩 v2 스키마 변경에 맞춰 재설계되었습니다.

v2 변경 사항

삭제된 요소:

요소 기존 가중치 비고
user_tags (성격 태그) 25% 테이블 삭제
user_dating_philosophies (연애관) 15% 테이블 삭제

새로 추가된 요소:

요소 새 가중치 데이터 소스
주말 장소 10% user_profile_answers.weekend_spot
색상 팔레트 10% profiles.color_palette

점수 계산 공식

총점 = (키점수×20 + 나이점수×20 + 패션점수×30 + 직업점수×10 + 주말장소점수×10 + 색상팔레트점수×10) / 100

패션점수 = 스타일점수 × 0.6 + 브랜드점수 × 0.4

가중치 비교

요소 v1 v2 변경
키 점수 20% 20% 유지
나이 점수 20% 20% 유지
태그 점수 25% - 삭제
연애관 점수 15% - 삭제
패션 점수 20% 30% +10%
직업 분야 점수 5% 10% +5%
주말 장소 점수 - 10% 신규
색상 팔레트 점수 - 10% 신규
총합 100% 100% -

2. 데이터베이스 구조

2.1 ER 다이어그램 (v2)

┌─────────────────────┐
│      profiles       │
│  (사용자 프로필)      │
├─────────────────────┤
│ id (PK)             │
│ height_cm           │
│ gender              │
│ birthdate           │
│ occupation          │
│ occupation_category_id (FK) ──┐
│ color_palette[]     │         │
│ is_active           │         │
│ is_hidden_from_     │         │
│   recommendations   │         │
└──────────┬──────────┘         │
           │                    │
     ┌─────┼─────┬──────────┐   │
     │     │     │          │   │
     ▼     ▼     ▼          ▼   ▼
┌────────┐┌────────┐┌───────────┐┌───────────────────┐
│user_   ││user_   ││user_      ││occupation_        │
│fashion_││fashion_││profile_   ││categories         │
│styles  ││brands  ││answers    ││                   │
│        ││        ││(weekend_  ││                   │
│        ││        ││ spot)     ││                   │
└────────┘└────────┘└───────────┘└───────────────────┘

┌─────────────────────┐
│daily_recommendations│
│  (일일 추천 결과)     │
├─────────────────────┤
│ user_id             │
│ recommended_user_id │
│ recommendation_date │
│ score               │
│ display_order       │
│ is_viewed           │
│ is_acted_upon       │
└─────────────────────┘

2.2 테이블 상세

profiles (사용자 프로필)

CREATE TABLE public.profiles (
    id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
    nickname TEXT NOT NULL,
    occupation TEXT,
    occupation_category_id UUID REFERENCES occupation_categories(id),
    birthdate DATE NOT NULL,
    height_cm INTEGER,                          -- 키 (100-250cm)
    gender TEXT,                                -- 'male' | 'female'
    color_palette TEXT[],                       -- v2: 선택한 색상 배열 (최대 3개)
    is_active BOOLEAN DEFAULT true,
    is_hidden_from_recommendations BOOLEAN DEFAULT false,

    CONSTRAINT valid_height_cm CHECK (height_cm IS NULL OR (height_cm >= 100 AND height_cm <= 250))
);

user_matching_preferences (매칭 선호도 설정) - v2

CREATE TABLE public.user_matching_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE UNIQUE,

    -- 선호 나이/키 범위
    preferred_age_min INTEGER DEFAULT 18,
    preferred_age_max INTEGER DEFAULT 100,
    preferred_height_min INTEGER DEFAULT 100,
    preferred_height_max INTEGER DEFAULT 250,

    -- v2 가중치 (합계 = 100)
    height_weight INTEGER DEFAULT 20,           -- 키 가중치
    age_weight INTEGER DEFAULT 20,              -- 나이 가중치
    fashion_style_weight INTEGER DEFAULT 30,    -- 패션 가중치 (스타일+브랜드)
    occupation_weight INTEGER DEFAULT 10,       -- 직업 분야 가중치
    weekend_spot_weight INTEGER DEFAULT 10,     -- 주말 장소 가중치
    color_palette_weight INTEGER DEFAULT 10,    -- 색상 팔레트 가중치

    CONSTRAINT valid_weights CHECK (
        height_weight + age_weight + fashion_style_weight +
        occupation_weight + weekend_spot_weight + color_palette_weight = 100
    )
);

user_profile_answers (프로필 Q&A) - v2 신규

CREATE TABLE public.user_profile_answers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
    current_obsession TEXT,           -- Q1: 요즘 푹 빠진게 있나요?
    weekend_spot TEXT,                -- Q2: 주말에 당신을 어디서 찾을 수 있나요?
    best_recent_purchase TEXT,        -- Q3: 최근에 산 것중 제일 만족스러운건?
    UNIQUE(user_id)
);

occupation_categories (직업 카테고리)

CREATE TABLE public.occupation_categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL UNIQUE,
    display_order INTEGER DEFAULT 0,
    is_active BOOLEAN DEFAULT true
);

-- 카테고리 데이터
-- 전문직, IT/테크, 금융, 교육/연구, 의료/보건, 공무원/공공,
-- 미디어/콘텐츠, 예술/문화, 경영/사무, 서비스/유통, 자영업/창업, 기타

occupation_similarity_groups (직업 유사성 그룹) - v2 신규

CREATE TABLE public.occupation_similarity_groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    group_name TEXT NOT NULL,
    category_name TEXT NOT NULL
);

-- 유사 그룹 데이터:
-- high_income_professional: 전문직, IT/테크, 금융
-- social_contribution: 교육/연구, 의료/보건, 공무원/공공
-- creative: 미디어/콘텐츠, 예술/문화
-- business: 경영/사무, 서비스/유통, 자영업/창업

weekend_spot_activity_groups (주말 장소 활동성 그룹) - v2 신규

CREATE TABLE public.weekend_spot_activity_groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    group_name TEXT NOT NULL,  -- 'active' or 'passive'
    spot_label TEXT NOT NULL
);

-- 활동성 그룹 데이터:
-- active: 카페/전시, 쇼핑몰/편집샵, 친구들과 외출
-- passive: 집에서 휴식

3. 점수 계산 함수

3.1 키 점수 계산 (기존 유지)

CREATE OR REPLACE FUNCTION calculate_height_score(height_a INTEGER, height_b INTEGER)
RETURNS FLOAT AS $$
BEGIN
    -- 둘 중 하나라도 NULL이면 중립 점수
    IF height_a IS NULL OR height_b IS NULL THEN
        RETURN 50.0;
    END IF;

    height_diff := ABS(height_a - height_b);

    -- 10cm 이내 = 만점
    IF height_diff <= 10 THEN
        RETURN 100.0;
    END IF;

    -- 10cm 초과 시 cm당 -5점 (최소 0점)
    RETURN GREATEST(0.0, 100.0 - ((height_diff - 10) * 5.0));
END;
$$ LANGUAGE plpgsql IMMUTABLE;

계산 로직:

  • 키 차이 10cm 이내: 100점
  • 키 차이 11cm: 95점
  • 키 차이 20cm: 50점
  • 키 차이 30cm 이상: 0점
  • 선호 범위 내 20% 보너스

3.2 나이 점수 계산 (기존 유지)

CREATE OR REPLACE FUNCTION calculate_age_score(
    target_age INTEGER,
    preferred_min INTEGER,
    preferred_max INTEGER
)
RETURNS FLOAT AS $$
BEGIN
    IF target_age IS NULL THEN
        RETURN 50.0;
    END IF;

    -- 선호 범위 내 = 만점
    IF target_age >= preferred_min AND target_age <= preferred_max THEN
        RETURN 100.0;
    END IF;

    -- 선호 범위 밖 = 중간 점수
    RETURN 50.0;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

3.3 패션 점수 계산 (v2: 비율 조정)

-- 패션 점수 = 스타일 점수 × 0.6 + 브랜드 점수 × 0.4
fashion_score := (fashion_style_score * 0.6) + (fashion_brand_score * 0.4);

패션 스타일 점수

CREATE OR REPLACE FUNCTION calculate_fashion_style_score(user_a_id UUID, user_b_id UUID)
RETURNS FLOAT AS $$
BEGIN
    -- 공통 스타일 비율 × 100
    RETURN (common_count::FLOAT / user_a_count::FLOAT) * 100.0;
END;
$$ LANGUAGE plpgsql;

패션 브랜드 점수

CREATE OR REPLACE FUNCTION calculate_fashion_brand_score(user_a_id UUID, user_b_id UUID)
RETURNS FLOAT AS $$
BEGIN
    -- 공통 브랜드 비율 × 100 (마스터 + 사용자 입력 브랜드 모두 포함)
    RETURN (common_count::FLOAT / user_a_count::FLOAT) * 100.0;
END;
$$ LANGUAGE plpgsql;

3.4 직업 분야 점수 (v2: 유사 그룹 포함)

CREATE OR REPLACE FUNCTION calculate_occupation_score_v2(user_a_id UUID, user_b_id UUID)
RETURNS FLOAT AS $$
BEGIN
    -- 같은 직업 분야: 100점
    IF cat_a_id = cat_b_id THEN
        RETURN 100.0;
    END IF;

    -- 유사 직업 그룹: 75점
    -- (전문직/IT/금융, 교육/의료, 미디어/예술 등)
    IF group_a IS NOT NULL AND group_b IS NOT NULL AND group_a = group_b THEN
        RETURN 75.0;
    END IF;

    -- 다른 분야: 50점
    RETURN 50.0;
END;
$$ LANGUAGE plpgsql;

유사 그룹:

그룹명 포함 카테고리
고소득 전문직 전문직, IT/테크, 금융
사회 공헌 교육/연구, 의료/보건, 공무원/공공
창작 미디어/콘텐츠, 예술/문화
비즈니스 경영/사무, 서비스/유통, 자영업/창업

3.5 주말 장소 점수 (v2: 신규)

CREATE OR REPLACE FUNCTION calculate_weekend_spot_score(user_a_id UUID, user_b_id UUID)
RETURNS FLOAT AS $$
BEGIN
    -- 같은 장소: 100점
    IF spot_a = spot_b THEN
        RETURN 100.0;
    END IF;

    -- 같은 활동 그룹 (활동적 vs 활동적): 75점
    IF group_a = group_b THEN
        RETURN 75.0;
    END IF;

    -- 다른 활동 그룹 (외향 vs 내향): 25점
    RETURN 25.0;
END;
$$ LANGUAGE plpgsql;

활동성 그룹:

그룹 포함 장소
활동적 (active) 카페/전시, 쇼핑몰/편집샵, 친구들과 외출
비활동적 (passive) 집에서 휴식

3.6 색상 팔레트 점수 (v2: 신규)

CREATE OR REPLACE FUNCTION calculate_color_palette_score(user_a_id UUID, user_b_id UUID)
RETURNS FLOAT AS $$
BEGIN
    -- 3개 색상 모두 일치: 100점
    IF common_count >= 3 THEN
        RETURN 100.0;
    -- 2개 일치: 66.7점
    ELSIF common_count = 2 THEN
        RETURN 66.7;
    -- 1개 일치: 33.3점
    ELSIF common_count = 1 THEN
        RETURN 33.3;
    -- 0개 일치: 0점
    ELSE
        RETURN 0.0;
    END IF;
END;
$$ LANGUAGE plpgsql;

4. 메인 매칭 점수 계산 함수 v2

CREATE OR REPLACE FUNCTION calculate_matching_score_v2(
    source_user_id UUID,
    target_user_id UUID
)
RETURNS FLOAT AS $$
DECLARE
    height_score FLOAT;
    age_score FLOAT;
    fashion_score FLOAT;
    occupation_score FLOAT;
    weekend_spot_score FLOAT;
    color_palette_score FLOAT;
    total_score FLOAT;
BEGIN
    -- 1. 키 점수 (20%)
    height_score := calculate_height_score(...);
    -- 선호 키 범위 내일 경우 20% 보너스
    IF in_preferred_range THEN
        height_score := LEAST(height_score * 1.2, 100.0);
    END IF;

    -- 2. 나이 점수 (20%)
    age_score := calculate_age_score(...);

    -- 3. 패션 점수 (30%) - 스타일 60% + 브랜드 40%
    fashion_score := (style_score * 0.6) + (brand_score * 0.4);

    -- 4. 직업 점수 (10%)
    occupation_score := calculate_occupation_score_v2(...);

    -- 5. 주말 장소 점수 (10%)
    weekend_spot_score := calculate_weekend_spot_score(...);

    -- 6. 색상 팔레트 점수 (10%)
    color_palette_score := calculate_color_palette_score(...);

    -- 가중치 적용 총점 계산
    total_score := (
        height_score * 20 +
        age_score * 20 +
        fashion_score * 30 +
        occupation_score * 10 +
        weekend_spot_score * 10 +
        color_palette_score * 10
    ) / 100.0;

    RETURN ROUND(total_score, 2);
END;
$$ LANGUAGE plpgsql;

5. 점수 계산 예시

예시 시나리오

사용자 A (소스):

  • 키: 175cm
  • 선호 나이: 25-32세
  • 패션 스타일: [미니멀, 캐주얼]
  • 패션 브랜드: [COS, ZARA]
  • 직업 분야: IT/테크
  • 주말 장소: 카페/전시
  • 색상 팔레트: [#1A1A1A, #FFFFFF, #C4A98F]

사용자 B (타겟):

  • 키: 165cm
  • 나이: 28세
  • 패션 스타일: [미니멀, 모던]
  • 패션 브랜드: [COS, ACNE]
  • 직업 분야: 금융
  • 주말 장소: 쇼핑몰/편집샵
  • 색상 팔레트: [#1A1A1A, #E8E4E0, #C4A98F]

점수 계산

요소 계산 점수 가중치 가중 점수
|175-165| = 10cm, 범위 내 100 20% 20.0
나이 28세 (25-32 범위 내) 100 20% 20.0
패션 스타일 50×0.6 + 브랜드 50×0.4 50 30% 15.0
직업 IT/테크 vs 금융 (고소득 전문직 그룹) 75 10% 7.5
주말 장소 카페 vs 쇼핑몰 (활동적 그룹) 75 10% 7.5
색상 팔레트 2/3 일치 (#1A1A1A, #C4A98F) 66.7 10% 6.67
총점 - - - 76.67점

6. 일일 추천 생성 함수

2026-02-01: 하루 5명 제한으로 변경됨

CREATE OR REPLACE FUNCTION generate_daily_recommendations(target_user_id UUID)
RETURNS INTEGER AS $$
BEGIN
    -- 오늘 기존 추천 삭제
    DELETE FROM daily_recommendations
    WHERE user_id = target_user_id AND recommendation_date = CURRENT_DATE;

    -- 새 추천 생성 (v2 매칭 알고리즘, 5명 제한)
    WITH scored_candidates AS (
        SELECT
            p.id as candidate_id,
            calculate_matching_score_v2(target_user_id, p.id) as score
        FROM profiles p
        JOIN users u ON p.id = u.id
        WHERE p.id != target_user_id
        AND p.is_active = true
        AND u.account_status = 'approved'
        AND COALESCE(p.is_hidden_from_recommendations, false) = false
        AND p.gender != target_profile.gender  -- 이성 매칭
        AND NOT EXISTS (/* 차단 */)
        AND NOT EXISTS (/* 이미 매칭 */)
        AND NOT EXISTS (/* 이미 좋아요 */)
        ORDER BY score DESC
        LIMIT 5  -- 하루 5명 제한 (2026-02-01)
    )
    INSERT INTO daily_recommendations (...);

    RETURN inserted_count;
END;
$$ LANGUAGE plpgsql;

7. 인덱스

-- 성능 최적화 인덱스
CREATE INDEX idx_profiles_height ON profiles(height_cm) WHERE height_cm IS NOT NULL;
CREATE INDEX idx_profiles_gender_active ON profiles(gender, is_active) WHERE is_active = true;
CREATE INDEX idx_profiles_occupation_category ON profiles(occupation_category_id) WHERE occupation_category_id IS NOT NULL;
CREATE INDEX idx_profiles_color_palette ON profiles USING gin(color_palette) WHERE color_palette IS NOT NULL;
CREATE INDEX idx_user_matching_preferences_user_id ON user_matching_preferences(user_id);
CREATE INDEX idx_user_fashion_styles_user_id ON user_fashion_styles(user_id);
CREATE INDEX idx_user_fashion_brands_user_id ON user_fashion_brands(user_id);
CREATE INDEX idx_user_profile_answers_user_id ON user_profile_answers(user_id);
CREATE INDEX idx_user_profile_answers_weekend_spot ON user_profile_answers(weekend_spot) WHERE weekend_spot IS NOT NULL;
CREATE INDEX idx_daily_recommendations_user_date ON daily_recommendations(user_id, recommendation_date);

8. 마이그레이션 파일

파일 설명
20260113000000_matching_algorithm_schema.sql 기본 매칭 알고리즘 구조
20260114000000_fashion_style_matching.sql 패션 스타일 매칭
20260122120000_add_fashion_brands_and_intro.sql 패션 브랜드 추가
20260123120000_add_fashion_brand_score.sql 브랜드 점수 함수
20260128200000_add_occupation_categories.sql 직업 카테고리
20260131080000_onboarding_v2_schema.sql 온보딩 v2 스키마
20260131090000_matching_algorithm_v2.sql v2 매칭 알고리즘
20260201110000_limit_daily_recommendations_to_5.sql 하루 5명 제한 (현재)

9. 삭제된 요소 (v1 → v2)

다음 테이블/함수는 v2에서 삭제되었습니다:

요소 타입 비고
tags 테이블 성격 태그 마스터
user_tags 테이블 사용자-태그 연결
dating_philosophy_tags 테이블 연애관 태그 마스터
user_dating_philosophies 테이블 사용자-연애관 연결
calculate_tag_score() 함수 태그 점수 계산
calculate_philosophy_score() 함수 연애관 점수 계산
calculate_occupation_score() 함수 v1 직업 점수 (v2로 대체)

10. 일일 추천 제한 (✅ 구현 완료 - 2026-02-01)

10.1 하루 5명 제한

DB 변경:

  • daily_recommendations 테이블의 display_order 제약: 0-90-4
  • generate_daily_recommendations 함수: LIMIT 10LIMIT 5

마이그레이션 파일: supabase/migrations/20260201110000_limit_daily_recommendations_to_5.sql

10.2 중복 방지 (이미 본 추천 제외)

프론트엔드에서 is_viewed=true인 추천을 제외하여 오늘 이미 본 유저가 다시 노출되지 않도록 처리:

// api/recommendations.ts
.eq("is_acted_upon", false)
.eq("is_viewed", false)  // 오늘 이미 본 추천 제외

10.3 테스트 모드

테스트 모드(RECOMMEND_ALL_APPROVED_USERS=true)에서도 5명 제한 적용:

// 테스트 모드 마지막에 5명만 반환
return result.slice(0, 5);

11. 참고 파일 경로

파일 설명
supabase/migrations/20260131090000_matching_algorithm_v2.sql v2 마이그레이션
types/matching.ts 매칭 타입 정의
api/recommendations.ts 추천 API
hooks/queries/use-recommendations.ts 추천 Hook
app/(tabs)/index.tsx 홈 화면 (추천 카드)