상태: ✅ 구현 완료 작성일: 2026-01-31 버전: v2 (온보딩 v2 스키마 대응) 기술 스택: PostgreSQL Functions, Supabase RPC
Fitting 앱의 사용자 매칭 알고리즘 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% | - |
┌─────────────────────┐
│ 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 │
└─────────────────────┘
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))
);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
)
);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)
);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/테크, 금융, 교육/연구, 의료/보건, 공무원/공공,
-- 미디어/콘텐츠, 예술/문화, 경영/사무, 서비스/유통, 자영업/창업, 기타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: 경영/사무, 서비스/유통, 자영업/창업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: 집에서 휴식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% 보너스
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;-- 패션 점수 = 스타일 점수 × 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;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/테크, 금융 |
| 사회 공헌 | 교육/연구, 의료/보건, 공무원/공공 |
| 창작 | 미디어/콘텐츠, 예술/문화 |
| 비즈니스 | 경영/사무, 서비스/유통, 자영업/창업 |
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) | 집에서 휴식 |
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;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;사용자 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점 |
✅ 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;-- 성능 최적화 인덱스
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);| 파일 | 설명 |
|---|---|
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명 제한 (현재) |
다음 테이블/함수는 v2에서 삭제되었습니다:
| 요소 | 타입 | 비고 |
|---|---|---|
tags |
테이블 | 성격 태그 마스터 |
user_tags |
테이블 | 사용자-태그 연결 |
dating_philosophy_tags |
테이블 | 연애관 태그 마스터 |
user_dating_philosophies |
테이블 | 사용자-연애관 연결 |
calculate_tag_score() |
함수 | 태그 점수 계산 |
calculate_philosophy_score() |
함수 | 연애관 점수 계산 |
calculate_occupation_score() |
함수 | v1 직업 점수 (v2로 대체) |
DB 변경:
daily_recommendations테이블의display_order제약:0-9→0-4generate_daily_recommendations함수:LIMIT 10→LIMIT 5
마이그레이션 파일:
supabase/migrations/20260201110000_limit_daily_recommendations_to_5.sql
프론트엔드에서 is_viewed=true인 추천을 제외하여 오늘 이미 본 유저가 다시 노출되지 않도록 처리:
// api/recommendations.ts
.eq("is_acted_upon", false)
.eq("is_viewed", false) // 오늘 이미 본 추천 제외테스트 모드(RECOMMEND_ALL_APPROVED_USERS=true)에서도 5명 제한 적용:
// 테스트 모드 마지막에 5명만 반환
return result.slice(0, 5);| 파일 | 설명 |
|---|---|
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 |
홈 화면 (추천 카드) |