본 문서는 MVP 범위인 실시간 1:1 맛집 매칭 서비스의 데이터베이스 설계를 정의합니다. 영구 저장소로는 PostgreSQL과 공간 연산 확장 모듈인 PostGIS, AI 기반 식당 추천 및 자유 서술 성향의 보조 랭킹을 위한 pgvector를 사용하며, 실시간 매칭 상태 관리 및 만료 처리 등 휘발성 상태 관리를 위해 Redis를 함께 활용합니다.
전체 구조만 빠르게 확인할 때는 스키마요약.md를 먼저 참고합니다. 정확한 설명과 DDL의 최종 기준은 이 문서입니다.
- 영구 데이터베이스: PostgreSQL (Supabase)
- 사용자 계정과 Refresh Token 세션, 위치·성향 정보, 실시간 1:1 매칭 요청·결과·참여자, 채팅방/메시지, 식당 추천 정보와 사용자 후기 등 장기 보존이 필요한 데이터를 관리합니다.
- 위치 데이터 처리 (PostGIS):
- 사용자의 실시간 현재 위치는 개인정보 보호 및 데이터 정밀성 유지를 위해
USER테이블에 저장하지 않습니다. - 사용자가 선택한 구 단위 기본 활동지역은
USER_LOCATION_PREFERENCE에 행정구역 코드와 표시명으로만 저장합니다. - 프론트엔드는 구 대표 좌표로 지도를 초기화하고, 사용자가 확정한 핀은 실시간 1:1 매칭 요청(
MATCH_REQUEST.location)에만GEOGRAPHY(POINT, 4326)로 저장합니다. - GIST 공간 인덱스를 활용하여 반경 검색(
ST_DWithin) 및 거리 계산을 효율적으로 수행합니다.
- 사용자의 실시간 현재 위치는 개인정보 보호 및 데이터 정밀성 유지를 위해
- AI 추천 (pgvector):
- 외부 식당 정보의 요약 텍스트를 임베딩 벡터로 변환하여
RESTAURANT_EMBEDDING테이블에 저장합니다. - 사용자가 명시적으로 동의한 자유 서술 성향은
USER_PERSONALITY_EMBEDDING에 저장하며, 위치·시간 등 하드 필터를 통과한 후보의 보조 랭킹에만 사용합니다. - Cosine Similarity(
vector_cosine_ops) 기반의 HNSW 인덱스를 설정하여 유사 식당 추천과 자유 서술 성향 후보 랭킹을 최적화합니다.
- 외부 식당 정보의 요약 텍스트를 임베딩 벡터로 변환하여
- 실시간/휘발성 데이터 관리 (Redis):
- 실시간 매칭 대기열, 매칭 매칭 대기 후보군 탐색, 실시간 이벤트 알림(Pub/Sub), 매칭 요청의 TTL(만료 시간)은 Redis에서 전담하여 처리합니다.
- PostgreSQL에는 임시 상태(예: 매칭 대기 중인 사용자의 세부 정보, 요청 만료 시각
expires_at등)를 불필요하게 중복 저장하지 않고, 최종 결과물 및 변경 이력만 동기화하여 저장합니다.
| 번호 | 개체명 | 물리 테이블명 | 설명 |
|---|---|---|---|
| 1 | 사용자 | users |
서비스 이용자 정보 |
| 2 | 실시간 매칭 요청 | match_requests |
실시간 1:1 매칭 요청 정보 및 진행 이력 |
| 3 | 최종 1:1 매칭 | matches |
두 실시간 매칭 요청을 연결한 최종 결과 |
| 4 | 매칭 참여자 | match_participants |
최종 매칭에 참여하는 정확히 두 사용자 |
| 5 | 채팅방 | chat_rooms |
매칭 성공 시 생성되는 1:1 채팅방 |
| 6 | 채팅 메시지 | chat_messages |
채팅방 내부에서 송수신된 메시지 내역 |
| 7 | 식당 | restaurants |
매칭 및 추천의 대상이 되는 식당 기본 정보 |
| 8 | 식당 임베딩 | restaurant_embeddings |
AI 식당 추천에 활용되는 외부 식당 정보 임베딩 |
| 9 | 사용자 후기 | user_reviews |
매칭 상대방에 대해 작성한 재만남 의향·인상 태그 후기 |
| 10 | Refresh Token | refresh_tokens |
로그인 세션별 Refresh Token 해시, 만료·회전·폐기 이력 |
| 11 | 사용자 성향 프로필 | user_personality_profiles |
자가 응답에서 계산한 버전별 정형 성향 점수 |
| 12 | 사용자 성향 응답 | user_personality_answers |
재계산과 사용자 수정에 필요한 설문 응답 |
| 13 | 사용자 성향 태그 | user_personality_tags |
성향 프로필의 @ElementCollection 스타일 태그 |
| 14 | 사용자 음식 선호 | user_food_preferences |
사용자의 @ElementCollection 음식 카테고리 |
| 15 | 사용자 성향 임베딩 | user_personality_embeddings |
동의한 자유 서술 성향의 보조 랭킹 벡터 (1NF 1:N 구조) |
| 16 | 사용자 기본 활동지역 | user_location_preferences |
구 단위 활동지역과 위치 기반 서비스 동의 |
| 17 | 매칭 요청 희망 성향 태그 | match_request_desired_personality_tags |
매칭 요청별 원하는 상대의 PersonalityTag 목록 |
| 18 | 사용자 AI 성향 키워드 | user_personality_ai_keywords |
성향 프로필의 @ElementCollection AI 추출 키워드 목록 |
| 19 | 신고 및 제재 | reports |
불량 이용자 신고 접수 및 관리자 조치 내역 |
| 20 | 행정구역 기준 데이터 | regions |
지원하는 구 단위 행정구역 시·군·구 코드, 표시명 및 대표 좌표 |
- USER:
users(1) : (N)match_requestsusers(1) : (N)match_participantsusers(1) : (N)chat_messagesusers(1) : (N)user_reviews(작성자reviewer/ 대상자reviewee)users(1) : (N)reports(신고자reporter/ 피신고자reported_user)users(1) : (N)refresh_tokensusers(1) : (0..1)user_personality_profilesusers(1) : (N)user_food_preferencesusers(1) : (0..1)user_location_preferences
- PERSONALITY:
user_personality_profiles(1) : (N)user_personality_answersuser_personality_profiles(1) : (N)user_personality_tagsuser_personality_profiles(1) : (N)user_personality_ai_keywordsuser_personality_profiles(1) : (N)user_personality_embeddings(1NF 원자화 1:N 관계)
- MATCH_REQUEST:
match_requests(1) : (N)match_request_desired_personality_tagsmatches.request_1_id,matches.request_2_id가 서로 다른 두match_requests를 정렬된 순서(request_1_id < request_2_id)로 참조합니다.- 하나의 요청은 최대 한 번만 최종 매칭에 사용됩니다.
- MATCH:
matches(1) : (2)match_participants(서비스 계층에서 정확히 두 명 보장)matches(1) : (1)chat_rooms(1:1 대응 관계)matches(1) : (N)user_reviewsmatches(1) : (N)reports
- CHAT:
chat_rooms(1) : (N)chat_messages
- RESTAURANT:
restaurants(1) : (1)restaurant_embeddings(1:1 대응 관계)
- REGION:
regions(1) : (N)user_location_preferences및match_requests(행정구역 코드로 참조 및 대표 좌표 제공)
erDiagram
users ||--o{ match_requests : "requests"
users ||--o{ match_participants : "match_joins"
users ||--o{ chat_messages : "sends"
users ||--o{ user_reviews : "writes_user_review_as_reviewer"
users ||--o{ user_reviews : "receives_user_review_as_reviewee"
users ||--o{ reports : "writes_report_as_reporter"
users ||--o{ reports : "receives_report_as_reported_user"
users ||--o{ refresh_tokens : "owns_sessions"
users ||--o| user_personality_profiles : "has_personality"
users ||--o{ user_food_preferences : "prefers_food"
users ||--o| user_location_preferences : "sets_preferred_region"
user_personality_profiles ||--o{ user_personality_answers : "has_answers"
user_personality_profiles ||--o{ user_personality_tags : "has_tags"
user_personality_profiles ||--o{ user_personality_ai_keywords : "has_ai_keywords"
user_personality_profiles ||--o{ user_personality_embeddings : "has_embeddings"
refresh_tokens |o--o| refresh_tokens : "rotates_to"
match_requests ||--o| matches : "matched_as_requester"
match_requests ||--o| matches : "matched_as_candidate"
match_requests ||--o{ match_request_desired_personality_tags : "desires_tags"
matches ||--|{ match_participants : "contains_participants"
matches ||--|| chat_rooms : "creates_chat"
matches ||--o{ user_reviews : "linked_to_user_reviews"
matches ||--o{ reports : "linked_to_reports"
chat_rooms ||--o{ chat_messages : "contains"
restaurants ||--|| restaurant_embeddings : "has_vector"
users {
uuid id PK
varchar email UK
varchar password_hash
varchar provider
varchar provider_id
varchar nickname UK
text profile_image_url
text description
varchar role
varchar status
varchar personality_onboarding_status
int warning_count
timestamp created_at
timestamp updated_at
}
refresh_tokens {
uuid id PK
uuid user_id FK
varchar token_hash UK
uuid family_id
uuid replaced_by_token_id FK
timestamp expires_at
timestamp last_used_at
timestamp revoked_at
timestamp created_at
}
user_personality_profiles {
uuid user_id PK
varchar questionnaire_version
smallint conversation_level
smallint meal_pace
smallint planning_style
smallint novelty_preference
varchar self_description
boolean ai_analysis_consent
timestamp completed_at
timestamp updated_at
}
user_personality_answers {
bigint id PK
uuid user_id FK
varchar question_code
smallint answer_value
timestamp created_at
timestamp updated_at
}
user_personality_tags {
uuid user_id FK
varchar tag_code
}
user_personality_ai_keywords {
uuid user_id FK
varchar keyword
}
user_food_preferences {
uuid user_id FK
varchar food_category
}
user_personality_embeddings {
bigint id PK
uuid user_id FK
varchar source_text
vector embedding
varchar model_name
varchar source_version
timestamp generated_at
}
user_location_preferences {
uuid user_id PK
varchar region_code
varchar region_name
boolean location_service_consent
timestamp updated_at
}
match_requests {
bigint id PK
uuid user_id FK
varchar food_category
timestamp meal_at
varchar region_code
varchar region_name
varchar location_name
geography location
int search_radius
text desired_personality_text
vector desired_personality_embedding
varchar embedding_model
varchar embedding_version
timestamp embedded_at
varchar matching_formula_version
varchar status
int reject_count
timestamp created_at
timestamp updated_at
}
match_proposals {
bigint id PK
bigint request_1_id FK
bigint request_2_id FK
varchar request_1_decision
varchar request_2_decision
varchar status
jsonb score_snapshot
timestamp expires_at
timestamp request_1_decided_at
timestamp request_2_decided_at
timestamp created_at
timestamp updated_at
}
matches {
bigint id PK
bigint request_1_id FK
bigint request_2_id FK
varchar status
timestamp matched_at
timestamp ended_at
timestamp created_at
timestamp updated_at
}
match_participants {
bigint id PK
bigint match_id FK
uuid user_id FK
varchar role
timestamp joined_at
}
chat_rooms {
bigint id PK
bigint match_id FK
varchar status
timestamp created_at
timestamp closed_at
}
chat_messages {
bigint id PK
bigint chat_room_id FK
uuid sender_id FK
varchar message_type
text content
varchar provider_place_id
varchar place_name
varchar place_category
varchar place_address
geography place_location
varchar place_url
timestamp created_at
}
restaurants {
bigint id PK
varchar name
varchar category
varchar address
geography location
varchar phone
int review_count
timestamp created_at
timestamp updated_at
}
restaurant_embeddings {
bigint restaurant_id PK
text summary
vector embedding
varchar model_name
varchar source_version
timestamp updated_at
}
user_reviews {
bigint id PK
bigint match_id FK
uuid reviewer_id FK
uuid reviewee_id FK
varchar revisit_intention
varchar impression_tag
varchar visibility
timestamp created_at
}
reports {
bigint id PK
uuid reporter_id FK
uuid reported_user_id FK
bigint match_id FK
varchar category
text reason
varchar status
timestamp created_at
}
regions {
varchar region_code PK
varchar city_do
varchar sigungu
varchar full_name
geography center_location
}
- 사용자 위치 미저장:
users테이블에는 사용자의 실시간 현재 위치를 보관하지 않습니다. - 구 단위 기본 활동지역:
user_location_preferences에는 구 단위 행정구역 코드, 표시명, 위치 기반 서비스 동의만 저장합니다. 이는 실시간 위치가 아니라 사용자가 선택한 활동지역입니다. - 지도 초기화: 클라이언트는 저장된 구의 대표 좌표를 행정구역 기준 데이터에서 조회하여 지도 중심으로 사용합니다. Geolocation API에 의존하지 않습니다.
- 매칭 요청 지역 분리: 기본 활동지역은 지도 초기 위치를 정하는 기본값이며 매칭 요청의 선택 구를 제한하지 않습니다. 매칭 요청마다 사용자가 선택한
region_code와 핀을 별도로 저장할 수 있습니다. - 필요 시점의 저장:
- 실시간 매칭 요청에서 사용자가 확정한 핀:
match_requests.location - 음식점 주소 위치:
restaurants.location
- 실시간 매칭 요청에서 사용자가 확정한 핀:
- 행정구역 검증: 요청의
region_code와 핀 좌표를 역지오코딩하거나 행정구역 경계 데이터로 비교하여 핀이 선택 구에 속하는지 검증합니다. 클라이언트가 보낸region_name은 신뢰하지 않고 서버의 기준 데이터로 정규화합니다. - 조회/반경 검색: PostGIS 함수(
ST_DWithin,ST_Distance등)를 사용하여 쿼리 시점에 계산합니다. - 거리 의미: 계산 결과는 실제 사용자 위치 간 거리가 아니라 각 사용자가 확정한 핀 또는 조회 기준 핀 사이의 거리입니다.
- Redis 관리 대상:
- 실시간 매칭 요청의 생명 주기 관리 및 매칭 매치메이킹 큐
- 5분 대기 시간(TTL) 및 사용자별 매칭 진행 대기 상태(
match:user:{user_id}), 제안 확인 중에는 제안 TTL 동안 사용자 잠금을 유지 - 요청별 대기 키(
match:waiting:{request_id}), Geo 인덱스(match:waiting:geo)와 개별 Geo TTL 보조 키 - 후보 제안 ID만 담은 15초 TTL 보조 키(
match:proposal:{proposal_id}) - 실시간 이벤트 알림 처리를 위한 Pub/Sub 메시지 채널
- PostgreSQL 저장 대상:
- Redis의 대기 상태를 제외한, 최종 매칭 성공/실패 이력 로그 (
match_requests테이블에 저장되어 감사/통계용으로 활용) expires_at과 같은 일시적인 세션 성격의 정보는 DB에 중복 저장하지 않습니다.
- Redis의 대기 상태를 제외한, 최종 매칭 성공/실패 이력 로그 (
Redis 키 만료·유실과 DB 상태가 어긋날 수 있으므로 주기적 보정 작업이 WAITING 요청을 잠금 조회합니다. DB 기준 대기 시간이 남아 있으면 요청 위치로 Redis 대기 키와 Geo 멤버를 재등록하고, 시간이 끝났으면 DB를 EXPIRED로 변경한 뒤 Redis 데이터를 정리합니다. Redis 장애 중에는 DB 상태를 임의로 변경하지 않습니다.
- 회원 기본 정보 및 상태를 관리합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
UUID | PK, DEFAULT uuid_generate_v4() |
사용자 식별 고유키 |
email |
VARCHAR(255) | UNIQUE, NOT NULL | 이메일 주소 (인증용 식별자) |
password_hash |
VARCHAR(255) | NULL | Argon2로 해싱한 비밀번호. 카카오·Google OAuth2 전용 계정은 NULL이며 원문 비밀번호는 저장하지 않음 |
provider |
VARCHAR(20) | NOT NULL, DEFAULT LOCAL |
가입 방식 (LOCAL, KAKAO, GOOGLE) |
provider_id |
VARCHAR(255) | NULL | OAuth 제공자가 발급한 고유 사용자 ID. LOCAL 계정은 NULL |
nickname |
VARCHAR(100) | UNIQUE, NOT NULL | 사용자 닉네임 |
profile_image_url |
TEXT | NULL | 프로필 이미지 URL |
description |
TEXT | NULL | 자기소개 및 한 줄 평 |
role |
VARCHAR(20) | NOT NULL | 회원 권한 (USER, ADMIN) |
status |
VARCHAR(20) | NOT NULL | 회원 활동 상태 (ACTIVE, WITHDRAWN) |
personality_onboarding_status |
VARCHAR(20) | NOT NULL, DEFAULT NOT_STARTED |
식사 스타일 온보딩 상태 (NOT_STARTED, SKIPPED, COMPLETED) |
created_at |
TIMESTAMPTZ | NOT NULL | 가입 일시 |
updated_at |
TIMESTAMPTZ | NOT NULL | 정보 수정 일시 |
- Unique Constraint:
(provider, provider_id)(OAuth 제공자별 사용자 고유 ID 중복 방지) - Check Constraint: LOCAL 계정은
password_hash가 필수이고provider_id는 NULL이어야 하며, KAKAO·GOOGLE 계정은password_hash가 NULL이고provider_id가 필수입니다. - 동일 이메일이 다른 Provider로 이미 가입되어 있으면 자동으로 계정을 연결하지 않고 기존 가입 방식을 안내합니다.
- 이메일은 애플리케이션에서 앞뒤 공백 제거 및 소문자 정규화 후 저장하며, OAuth 이메일은 제공자 검증이 완료된 경우에만 저장합니다.
- OAuth 닉네임이 없거나 중복되면 애플리케이션에서
사용자_{무작위 8자리}형식의 임시 닉네임을 생성합니다.
- 실시간 1:1 매칭 요청 기록을 저장합니다. 대기 상태 자체와 TTL 관리는 Redis가 수행합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 실시간 매칭 요청 고유 식별 번호 |
user_id |
UUID | FK -> users.id, NOT NULL |
매칭을 요청한 사용자 |
food_category |
VARCHAR(100) | NOT NULL | 희망 식음료 카테고리 |
meal_at |
TIMESTAMPTZ | NOT NULL | 희망 식사 일시 |
region_code |
VARCHAR(5) | NOT NULL | 서버가 관리하는 5자리 시·군·구 코드 |
region_name |
VARCHAR(100) | NOT NULL | 서버 기준 데이터로 정규화한 구 표시명 |
location_name |
VARCHAR(255) | NULL | 사용자가 확인한 핀의 장소명 또는 설명 |
location |
GEOGRAPHY(POINT, 4326) | NOT NULL | 선택 구 안에서 사용자가 확정한 희망 매칭 장소 핀 |
search_radius |
INT | NULL | 탐색 반경 (단위: 미터) |
desired_personality_text |
TEXT | NULL, 최대 300자 | 해당 요청에서 원하는 상대 성향에 대한 선택적 자유 서술 |
desired_personality_embedding |
vector(1536) | NULL | 희망 상대 자유 서술 임베딩 벡터. Hibernate SqlTypes.VECTOR로 매핑하며 1536차원·유한값을 검증합니다. |
embedding_model |
VARCHAR(100) | NULL | 임베딩 생성에 사용된 모델명 |
embedding_version |
VARCHAR(50) | NULL | 임베딩 파이프라인/문서 버전 |
embedded_at |
TIMESTAMPTZ | NULL | 임베딩 생성 완료 시각 |
matching_formula_version |
VARCHAR(50) | NULL | 매칭 결과 재현과 분석을 위한 호환도 산식 버전 |
status |
VARCHAR(20) | NOT NULL | 요청 상태 (WAITING, CONFIRMING, MATCHED, CANCELLED, EXPIRED) |
reject_count |
INT | NOT NULL, DEFAULT 0 | 거절 횟수 기록 (매칭 성사 실패 분석용) |
created_at |
TIMESTAMPTZ | NOT NULL | 요청 일시 |
updated_at |
TIMESTAMPTZ | NOT NULL | 상태 변경 일시 |
- Check Constraint:
search_radius는 NULL이거나 양수이며reject_count는 0 이상입니다. - 희망 설명 임베딩 벡터, 모델명, 버전, 생성 시각은 모두 존재하거나 모두 NULL이어야 합니다.
MatchRequest.desiredPersonalityTags의 지연 로딩@ElementCollection테이블입니다. 요청 시점의 원하는 상대 성향 태그를 보존하며, 요청 DTO에서 3개 이상 5개 이하를 검증합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
match_request_id |
BIGINT | FK -> match_requests.id, NOT NULL |
태그를 선택한 매칭 요청 |
tag_code |
VARCHAR(50) | NOT NULL, 문자열 Enum | PersonalityTag 코드 |
- Unique Constraint:
(match_request_id, tag_code)
- 조건이 일치하는 두
MatchRequest간의 간략 프로필 제안 및 15초 내 상호 수락 결정을 관리합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 후보 제안 고유 식별 번호 |
request_1_id |
BIGINT | FK -> match_requests.id, NOT NULL |
제안에 포함된 작은 ID의 매칭 요청 |
request_2_id |
BIGINT | FK -> match_requests.id, NOT NULL |
제안에 포함된 큰 ID의 매칭 요청 |
request_1_decision |
VARCHAR(20) | NOT NULL | request1 소유자의 결정 (PENDING, ACCEPTED, REJECTED) |
request_2_decision |
VARCHAR(20) | NOT NULL | request2 소유자의 결정 (PENDING, ACCEPTED, REJECTED) |
status |
VARCHAR(20) | NOT NULL | 제안 상태 (PENDING, MATCHED, REJECTED, EXPIRED, CANCELLED) |
score_snapshot |
JSONB | NULL | 정렬된 요청 기준 request1 → request2, request2 → request1 점수·상위 일치 태그·사유, 최종 쌍 점수와 산식 버전 |
expires_at |
TIMESTAMPTZ | NOT NULL | 15초 수락 응답 제한 시간 |
request_1_decided_at |
TIMESTAMPTZ | NULL | request1 소유자의 응답 일시 |
request_2_decided_at |
TIMESTAMPTZ | NULL | request2 소유자의 응답 일시 |
created_at |
TIMESTAMPTZ | NOT NULL | 제안 생성 일시 |
updated_at |
TIMESTAMPTZ | NOT NULL | 최종 상태 갱신 일시 |
- Check Constraint:
request_1_id < request_2_id - Check Constraint:
MATCHED는 양쪽 결정이 모두ACCEPTED,REJECTED는 한쪽 이상이REJECTED인 경우에만 허용합니다. - Unique Constraint:
(request_1_id, request_2_id)로 동일 요청 쌍의 중복 제안을 차단합니다. - Indexes:
(status),(request_1_id),(request_2_id),(expires_at) - 제안 결정은
PENDING → ACCEPTED|REJECTED단방향으로만 변경하며 동일 결정 재요청만 멱등하게 허용합니다.now >= expires_at이면 새로운 결정을 받지 않습니다.
score_snapshot JSON은 sourceToTargetScore, sourceToTargetMatchedTags, sourceToTargetReasons와 targetToSource... 방향 필드, pairScore, formulaVersion을 보존합니다. 원본 설문 답변·자유 서술·임베딩 벡터·차원별 내부 점수는 저장하지 않습니다.
- 서로 다른 두 실시간 매칭 요청이 최종 성사된 결과를 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 최종 매칭 식별 고유키 |
request_1_id |
BIGINT | UNIQUE, FK -> match_requests.id, NOT NULL |
매칭된 두 요청 중 작은 ID의 요청 |
request_2_id |
BIGINT | UNIQUE, FK -> match_requests.id, NOT NULL |
매칭된 두 요청 중 큰 ID의 요청 |
status |
VARCHAR(20) | NOT NULL | 최종 매칭 상태 (MATCHED, COMPLETED, CANCELLED) |
matched_at |
TIMESTAMPTZ | NOT NULL | 매칭 성사 완료 일시 |
ended_at |
TIMESTAMPTZ | NULL | 매칭 종료 일시. 활성 MATCHED 상태에서는 NULL이며 종료 상태에서만 설정 |
created_at |
TIMESTAMPTZ | NOT NULL | 데이터 생성 시간 |
updated_at |
TIMESTAMPTZ | NOT NULL | 최종 수정 시간 |
- Check Constraint:
request_1_id < request_2_id및MATCHED상태의ended_at IS NULL,COMPLETED·CANCELLED상태의ended_at IS NOT NULL. - Unique Constraint:
(request_1_id, request_2_id)(동일 요청 조합 중복 매칭 차단) - 서비스 계층은 두 요청의
user_id가 서로 다르고 두 요청 모두 다른 활성MATCHED매칭에 사용되지 않았는지 요청 ID 오름차순 비관적 잠금과 같은 트랜잭션에서 검증합니다. 양쪽 수락이 확정된 경우에만 이 행과 참여자·채팅방을 원자적으로 저장하며, 중복 재시도는 기존 매칭 결과를 재사용합니다. - 도메인 상태 전이는
MATCHED -> COMPLETED또는MATCHED -> CANCELLED만 허용하며, 종료 시각은 매칭 성사 시각보다 빠를 수 없습니다. 종료된 매칭은 다시 활성화할 수 없습니다.
- 최종 성사된 매칭의 두 사용자를 관리합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 매칭 참여 고유 식별 번호 |
match_id |
BIGINT | FK -> matches.id, NOT NULL |
대상 매칭 정보 |
user_id |
UUID | FK -> users.id, NOT NULL |
소속된 참여 사용자 |
role |
VARCHAR(20) | NOT NULL | 매칭 참여자 역할 (PARTICIPANT) |
joined_at |
TIMESTAMPTZ | NOT NULL | 매칭 참여 합류 시간 |
- Unique Constraint:
(match_id, user_id)(중복 합류 방지) - 한
match_id에는 애플리케이션 서비스 계층에서 정확히 2개 참여자만 생성합니다. 매칭 확정 트랜잭션이 중간 저장에 실패하면 매칭·참여자·채팅방과 요청 상태가 함께 롤백됩니다. - 기존 스키마에 구 역할 CHECK 제약이 남아 있다면
매칭_참여자_역할_및_ID_정합성.sql을 실행해PARTICIPANT역할과 ID 시퀀스를 정합화합니다.
- 성사된 매칭에 1:1 매핑되어 생성되는 채팅 대화방입니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 채팅방 식별 번호 |
match_id |
BIGINT | FK -> matches.id, UNIQUE, NOT NULL |
매칭 고유 번호 (1:1 매핑) |
status |
VARCHAR(20) | NOT NULL | 채팅방 활성 여부 상태 (ACTIVE, CLOSED) |
created_at |
TIMESTAMPTZ | NOT NULL | 대화방 생성 일시 |
closed_at |
TIMESTAMPTZ | NULL | 대화방 영구 정지/종료 일시 |
- Check Constraint:
ACTIVE상태에서는closed_at IS NULL,CLOSED상태에서는closed_at IS NOT NULL. - 도메인 상태 전이는
ACTIVE -> CLOSED만 허용하며, 종료된 채팅방은 다시 활성화할 수 없습니다.
- 채팅방 내부에서 발송된 텍스트 또는 식당 공유 메시지 로그를 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 메시지 고유 번호 |
chat_room_id |
BIGINT | FK -> chat_rooms.id, NOT NULL |
속한 채팅 대화방 |
sender_id |
UUID | FK -> users.id, NOT NULL |
메시지 송신한 회원 |
message_type |
VARCHAR(20) | NOT NULL, DEFAULT TEXT |
메시지 유형 (TEXT, PLACE) |
content |
TEXT | NOT NULL | 메시지 본문 내용 |
provider_place_id |
VARCHAR(30) | NULL | PLACE 메시지의 Kakao 장소 ID |
place_name |
VARCHAR(200) | NULL | 공유 시점 식당 이름 스냅샷 |
place_category |
VARCHAR(200) | NULL | 공유 시점 카테고리 스냅샷 |
place_address |
VARCHAR(500) | NULL | 공유 시점 주소 스냅샷 |
place_location |
GEOGRAPHY(POINT, 4326) | NULL | 공유 식당 좌표(경도, 위도 순) |
place_url |
VARCHAR(300) | NULL | 서버가 Kakao 장소 ID로 생성한 상세 URL |
created_at |
TIMESTAMPTZ | NOT NULL | 전송 시각 |
TEXT는 모든 장소 컬럼이NULL이어야 하며,PLACE는 모든 장소 컬럼이NOT NULL이어야 합니다.
- 서비스에 등록되어 있는 식당 및 카페 등 요식업체의 정보입니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 식당 등록 고유 번호 |
name |
VARCHAR(255) | NOT NULL | 식당 한글/영어 명칭 |
category |
VARCHAR(100) | NOT NULL | 음식점 세부 분류 카테고리 |
address |
VARCHAR(500) | NOT NULL | 도로명/지번 공식 상세 주소 |
location |
GEOGRAPHY(POINT, 4326) | NOT NULL | 식당 위경도 위치 좌표 |
phone |
VARCHAR(50) | NULL | 식당 연락처 번호 |
review_count |
INT | NOT NULL, DEFAULT 0 | 외부 식당 정보 제공처의 리뷰 누적 개수 |
created_at |
TIMESTAMPTZ | NOT NULL | 식당 DB 최초 등록 시점 |
updated_at |
TIMESTAMPTZ | NOT NULL | 식당 정보 마지막 수정 시점 |
- 외부 식당 정보 제공처에서 수집한 설명·메뉴·평점 요약을 이용한 AI 식당 추천용 임베딩 테이블입니다. pgvector를 이용합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
restaurant_id |
BIGINT | PK, FK -> restaurants.id |
대상 식당 고유 번호 (1:1 관계) |
summary |
TEXT | NULL | 외부 식당 정보의 추천용 요약본 |
embedding |
VECTOR(1536) | NOT NULL | AI 분석용 임베딩 벡터값 |
model_name |
VARCHAR(100) | NOT NULL | 임베딩 생성 모델 식별자 |
source_version |
VARCHAR(100) | NOT NULL | 외부 데이터 및 정규화 규칙 버전 |
updated_at |
TIMESTAMPTZ | NOT NULL | 임베딩 벡터 최종 업데이트/갱신 시각 |
- 식사 후 매칭 상대방에 대한 재만남 의향과 선택 인상 태그만 기록합니다. 기존
rating·content컬럼은 사용하지 않으므로 명시적 마이그레이션으로 제거합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 후기 식별 번호 |
match_id |
BIGINT | FK -> matches.id, NOT NULL |
매칭 정보 |
reviewer_id |
UUID | FK -> users.id, NOT NULL |
후기를 작성한 사용자 |
reviewee_id |
UUID | FK -> users.id, NOT NULL |
평가 대상이 되는 상대 사용자 |
revisit_intention |
VARCHAR(30) | NOT NULL | 재만남 의향 고정 코드 (DEFINITELY_AGAIN, MAYBE_AGAIN, ENOUGH_FOR_NOW) |
impression_tag |
VARCHAR(30) | NULL | 선택 인상 태그 고정 코드 (PUNCTUAL, COMFORTABLE_CONVERSATION, CONSIDERATE, ACTIVE_PARTICIPATION) |
visibility |
VARCHAR(20) | NOT NULL | 타인에게 노출 여부 (PUBLIC: 공개, PRIVATE: 비공개/시스템 분석용) |
created_at |
TIMESTAMPTZ | NOT NULL | 작성 일시 |
- Unique Constraint:
(match_id, reviewer_id, reviewee_id)(동일 매칭 상대에게 중복 후기 작성 방지) - Check Constraint:
revisit_intention은 세 고정 코드 중 하나이고impression_tag는 NULL 또는 네 고정 코드 중 하나이며, 작성자와 평가 대상은 서로 달라야 하고visibility는PUBLIC또는PRIVATE여야 합니다. - 다시한끼 지수의 집계 대상은
visibility = PUBLIC인 유효 후기뿐입니다. 삭제·신고 승인·운영상 무효화 상태를 도입할 때는 동일한 유효성 조건에서 제외하도록 운영 후속 정책을 따릅니다. rating·content제거 전에는 해당 컬럼에 값이 남아 있지 않은지 확인하며, 상세 절차는docs/migrations/사용자_후기_컬럼_전환.sql을 따릅니다.
- Access Token 재발급, 로그아웃, 토큰 회전 및 재사용 탐지를 위한 로그인 세션 정보를 관리합니다.
- Refresh Token 원문은 클라이언트의 Secure/HttpOnly 쿠키에만 전달하고 서버에는 SHA-256 해시만 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
UUID | PK, DEFAULT uuid_generate_v4() |
Refresh Token 레코드 식별자 |
user_id |
UUID | FK -> users.id, NOT NULL |
토큰 소유 사용자 |
token_hash |
VARCHAR(64) | UNIQUE, NOT NULL | Refresh Token 원문을 SHA-256으로 해싱한 16진수 문자열 |
family_id |
UUID | NOT NULL | 한 로그인 세션에서 회전된 토큰을 묶는 식별자 |
replaced_by_token_id |
UUID | FK -> refresh_tokens.id, NULL |
Rotation으로 이 토큰을 대체한 새 토큰 |
expires_at |
TIMESTAMPTZ | NOT NULL | Refresh Token 절대 만료 시각 |
last_used_at |
TIMESTAMPTZ | NULL | 마지막 재발급 사용 시각 |
revoked_at |
TIMESTAMPTZ | NULL | 로그아웃·회원탈퇴·재사용 탐지로 폐기된 시각 |
created_at |
TIMESTAMPTZ | NOT NULL | 토큰 발급 시각 |
- 재발급 시 기존 토큰의
revoked_at과replaced_by_token_id를 기록하고 같은family_id로 새 토큰을 발급합니다. - 이미 폐기된 토큰이 다시 사용되면 같은
family_id의 활성 토큰을 모두 폐기합니다. - 사용자당 최대 5개의 활성 세션(family)을 허용하며, 초과 로그인 시 가장 오래된 활성 세션부터
revoked_at을 기록하여 폐기합니다. - 만료 토큰은 RTR 재사용 탐지 보장을 위해 만료 후 7일간 보관 후, 매일 04:00(KST) 스케줄러를 통해 물리 삭제(Hard Delete)됩니다. (
expires_at < now() - 7 days)
- 자가 응답을 버전된 서버 규칙으로 계산한 정형 점수만 저장합니다. 값의 범위는 0~100이며 심리 진단 결과로 사용하지 않습니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
user_id |
UUID | PK, FK -> users.id |
성향 프로필 소유 사용자 |
questionnaire_version |
VARCHAR(50) | NOT NULL, MEAL_PERSONALITY_V1 |
점수 재계산을 위한 설문·산식 버전 |
conversation_level |
SMALLINT | NOT NULL, 0~100 | 식사 중 선호 대화량 |
meal_pace |
SMALLINT | NOT NULL, 0~100 | 선호 식사 속도 |
planning_style |
SMALLINT | NOT NULL, 0~100 | 즉흥형과 계획형 사이의 자가 응답 점수 |
novelty_preference |
SMALLINT | NOT NULL, 0~100 | 새로운 음식·장소 선호 정도 |
self_description |
VARCHAR(100) | NULL | AI 분석에 동의한 사용자의 선택형 자기 스타일 설명 (최대 100자) |
ai_analysis_consent |
BOOLEAN | NOT NULL, DEFAULT FALSE | 자유 서술 AI 분석에 대한 현재 동의 여부 |
completed_at |
TIMESTAMPTZ | NOT NULL | 현재 버전 설문 완료 시각 |
updated_at |
TIMESTAMPTZ | NOT NULL | 점수 또는 동의 변경 시각 |
- 프로필 재계산과 사용자의 답변 수정에 필요한 정형 응답을 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK | 응답 식별 번호 |
user_id |
UUID | FK -> user_personality_profiles.user_id, NOT NULL |
응답 사용자 |
question_code |
VARCHAR(100) | NOT NULL, 문자열 Enum | 버전 내 네 가지 성향 차원 코드 |
answer_value |
SMALLINT | NOT NULL, 1, 3, 5 |
낮음·중간·높음 카드의 정형 응답 값 |
created_at |
TIMESTAMPTZ | NOT NULL | 최초 응답 시각 |
updated_at |
TIMESTAMPTZ | NOT NULL | 응답 수정 시각 |
- Unique Constraint:
(user_id, question_code)
UserPersonalityProfile.styleTags의 지연 로딩@ElementCollection테이블입니다. 점수만으로 표현하기 어려운 세부 식사 스타일을 최대 5개까지 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
user_id |
UUID | FK -> user_personality_profiles.user_id, NOT NULL |
태그 소유 성향 프로필 |
tag_code |
VARCHAR(50) | NOT NULL, 문자열 Enum | PersonalityTag 코드 |
- Unique Constraint:
(user_id, tag_code) - 최대 5개 제한은 API DTO와 서비스 계층에서 검증합니다.
UserPersonalityProfile.aiKeywords의 지연 로딩@ElementCollection테이블입니다. AI 분석 결과 추출된 식사 스타일 키워드를 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
user_id |
UUID | FK -> user_personality_profiles.user_id, NOT NULL |
키워드 소유 성향 프로필 |
keyword |
VARCHAR(100) | NOT NULL | AI 분석 추출 키워드 문자열 |
User.foodPreferences의 지연 로딩@ElementCollection테이블입니다. 성향 점수·태그와 분리하여 음식 카테고리를 최대 5개까지 저장합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
user_id |
UUID | FK -> users.id, NOT NULL |
음식 선호 소유 사용자 |
food_category |
VARCHAR(50) | NOT NULL, 문자열 Enum | FoodCategory 코드 |
- Unique Constraint:
(user_id, food_category) - 최대 5개 제한은 API DTO와 서비스 계층에서 검증합니다.
- 사용자가 AI 분석에 동의한 자유 서술만 임베딩하고 하드 필터 이후 보조 랭킹에 사용합니다.
- JPA의
float[]필드는 Hibernate Vector 모듈을 통해 PostgreSQLvector(1536)타입으로 바인딩합니다. - 1차 정규화(1NF) 원칙 적용: 다중 속성 묶음 대신 개별 정제 키워드 단어 1개 ↔ 임베딩 1개의 원자화된 1:N 레코드로 독립 보관하며, 동의 철회 시 일괄 삭제합니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK, GENERATED ALWAYS AS IDENTITY | 레코드 고유 식별 번호 |
user_id |
UUID | FK -> user_personality_profiles.user_id, NOT NULL |
임베딩 소유 사용자 |
source_text |
VARCHAR(100) | NOT NULL | 동의한 자기소개 자유 텍스트만 저장한 정제 키워드 단어 |
embedding |
VECTOR(1536) | NOT NULL | 문서화된 동일 모델로 생성한 벡터 |
model_name |
VARCHAR(100) | NOT NULL | 생성 모델 식별자 |
source_version |
VARCHAR(100) | NOT NULL | 공통 자유 텍스트 임베딩 입력·정규화 규칙 버전 (PERSONALITY_FREE_TEXT_V2) |
generated_at |
TIMESTAMPTZ | NOT NULL | 임베딩 생성 시각 |
- 사용자에게서 구 단위로 받은 기본 활동지역과 위치 기반 서비스 동의를 관리합니다. 실제 현재 위치나 상세 핀 좌표는 저장하지 않습니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
user_id |
UUID | PK, FK -> users.id |
기본 활동지역 소유 사용자 |
region_code |
VARCHAR(5) | NOT NULL | 서버가 지원하는 5자리 시·군·구 코드 |
region_name |
VARCHAR(100) | NOT NULL | 서버 기준 데이터로 정규화한 시·도 + 구 표시명 |
location_service_consent |
BOOLEAN | NOT NULL, DEFAULT FALSE | 선택 지역과 매칭 핀 저장을 포함한 위치 기반 서비스 동의 |
updated_at |
TIMESTAMPTZ | NOT NULL | 지역 또는 동의 변경 시각 |
- 행정구역 코드별 대표 좌표는 사용자 데이터가 아닌 기준 데이터로 관리하며 지도 초기 중심 설정에만 사용합니다.
- 동의 철회 시 이후 위치 기반 요청을 거부합니다.
WAITING/CONFIRMING활성 요청은 제안을 먼저 취소하고 상대 요청을 대기 상태로 복구한 뒤 Redis 대기 데이터와 함께 물리 삭제하여 정밀 핀·희망 설명·임베딩을 제거합니다. 이미 종료된 요청은 매칭 이력 보존 정책을 따릅니다.
- 사용자가 불량 이용자를 신고하고 관리자가 검토하여 조치할 수 있도록 관리하는 테이블입니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
id |
BIGINT | PK, GENERATED ALWAYS AS IDENTITY | 신고 레코드 식별 번호 |
reporter_id |
UUID | FK -> users.id, NOT NULL |
신고를 작성한 회원 |
reported_user_id |
UUID | FK -> users.id, NOT NULL |
신고 대상 회원 |
match_id |
BIGINT | FK -> matches.id, NOT NULL |
신고가 발생한 매칭 |
category |
VARCHAR(50) | NOT NULL, 문자열 Enum | 신고 사유 카테고리 (NO_SHOW, ABUSE, SPAM, MISINFORMATION) |
reason |
TEXT | NOT NULL | 구체적인 신고 내용 |
status |
VARCHAR(20) | NOT NULL, DEFAULT PENDING |
신고 처리 상태 (PENDING, DISMISSED, ACTIONED) |
created_at |
TIMESTAMPTZ | NOT NULL | 신고 접수 일시 |
- 신고 행은 관리자 처리 후에도 삭제하지 않고 상태만 변경하여, 동일 사용자가 동일 매칭에서 신고를 다시 제출하지 못하도록 방지합니다.
- 서비스에서 지원하는 구 단위 행정구역 코드, 표시명 및 지도 초기화용 대표 중심 좌표 데이터입니다.
| 컬럼명 | 논리 타입 | 제약 조건 | 설명 |
|---|---|---|---|
region_code |
VARCHAR(5) | PK | 5자리 시·군·구 행정구역 코드 |
city_do |
VARCHAR(50) | NOT NULL | 시/도 명칭 (예: 서울특별시) |
sigungu |
VARCHAR(50) | NOT NULL | 시/군/구 명칭 (예: 종로구) |
full_name |
VARCHAR(100) | NOT NULL | 시·도 + 구 정규화 표시명 |
center_location |
GEOGRAPHY(POINT, 4326) | NOT NULL | 지도 초기 핀 설정을 위한 구 대표 중심 좌표 |
postgis: 공간 데이터 처리를 위해 활성화합니다.vector: pgvector를 활성화하여 식당 임베딩 및 코사인 유사도 검색 연산을 가능케 합니다.uuid-ossp: UUID 무작위 생성을 위해 활성화합니다.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS vector;-- 1. 사용자 테이블 (users)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
provider VARCHAR(20) NOT NULL DEFAULT 'LOCAL'
CHECK (provider IN ('LOCAL', 'KAKAO', 'GOOGLE')),
provider_id VARCHAR(255),
nickname VARCHAR(100) UNIQUE NOT NULL,
profile_image_url TEXT,
description TEXT,
role VARCHAR(20) NOT NULL CHECK (role IN ('USER', 'ADMIN')),
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'WITHDRAWN', 'BANNED')),
personality_onboarding_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED'
CHECK (personality_onboarding_status IN ('NOT_STARTED', 'SKIPPED', 'COMPLETED')),
warning_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_users_provider_provider_id UNIQUE (provider, provider_id),
CONSTRAINT chk_users_auth_provider CHECK (
(provider = 'LOCAL' AND password_hash IS NOT NULL AND provider_id IS NULL) OR
(provider IN ('KAKAO', 'GOOGLE') AND password_hash IS NULL AND provider_id IS NOT NULL)
)
);
-- 2. 식당 테이블 (restaurants)
CREATE TABLE restaurants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
address VARCHAR(500) NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL,
phone VARCHAR(50),
review_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 3. 외부 식당 정보 임베딩 (restaurant_embeddings)
-- 1536차원은 OpenAI의 text-embedding-3-small 등 최신 표준 임베딩 차원에 맞추었습니다.
CREATE TABLE restaurant_embeddings (
restaurant_id BIGINT PRIMARY KEY REFERENCES restaurants(id) ON DELETE CASCADE,
summary TEXT,
embedding VECTOR(1536) NOT NULL,
model_name VARCHAR(100) NOT NULL,
source_version VARCHAR(100) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. 실시간 매칭 요청 테이블 (match_requests)
CREATE TABLE match_requests (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
food_category VARCHAR(100) NOT NULL,
meal_at TIMESTAMPTZ NOT NULL,
region_code VARCHAR(5) NOT NULL,
region_name VARCHAR(100) NOT NULL,
location_name VARCHAR(255),
location GEOGRAPHY(POINT, 4326) NOT NULL,
search_radius INT,
desired_personality_text TEXT,
desired_personality_embedding vector(1536),
embedding_model VARCHAR(100),
embedding_version VARCHAR(50),
embedded_at TIMESTAMPTZ,
matching_formula_version VARCHAR(50),
status VARCHAR(20) NOT NULL CHECK (status IN ('WAITING', 'CONFIRMING', 'MATCHED', 'CANCELLED', 'EXPIRED')),
reject_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_match_requests_search_radius_reject_count CHECK (
(search_radius IS NULL OR search_radius > 0) AND reject_count >= 0
),
CONSTRAINT chk_match_requests_embedding_metadata CHECK (
(desired_personality_embedding IS NULL AND embedding_model IS NULL
AND embedding_version IS NULL AND embedded_at IS NULL)
OR
(desired_personality_embedding IS NOT NULL AND embedding_model IS NOT NULL
AND embedding_version IS NOT NULL AND embedded_at IS NOT NULL)
)
);
-- 4-1. 매칭 요청별 원하는 상대 성향 태그
CREATE TABLE match_request_desired_personality_tags (
match_request_id BIGINT NOT NULL REFERENCES match_requests(id) ON DELETE CASCADE,
tag_code VARCHAR(50) NOT NULL,
CONSTRAINT chk_match_request_desired_personality_tags_values CHECK (tag_code IN (
'INITIATES_CONVERSATION', 'GOOD_LISTENER', 'FOOD_TALK',
'LIGHT_CHAT', 'DEEP_TALK', 'COMFORTABLE_SILENCE',
'CALM_ATMOSPHERE', 'CHEERFUL_ATMOSPHERE', 'ACTIVE_ATMOSPHERE',
'SHARE_DISHES', 'TAKE_FOOD_PHOTOS', 'ENJOY_DESSERT', 'FOCUS_ON_MEAL'
)),
CONSTRAINT uk_match_request_desired_personality_tag UNIQUE (match_request_id, tag_code)
);
-- 4-2. 후보 제안 및 양방향 수락 테이블 (match_proposals)
CREATE TABLE match_proposals (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
request_1_id BIGINT NOT NULL REFERENCES match_requests(id) ON DELETE CASCADE,
request_2_id BIGINT NOT NULL REFERENCES match_requests(id) ON DELETE CASCADE,
request_1_decision VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (request_1_decision IN ('PENDING', 'ACCEPTED', 'REJECTED')),
request_2_decision VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (request_2_decision IN ('PENDING', 'ACCEPTED', 'REJECTED')),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'MATCHED', 'REJECTED', 'EXPIRED', 'CANCELLED')),
score_snapshot JSONB,
expires_at TIMESTAMPTZ NOT NULL,
request_1_decided_at TIMESTAMPTZ,
request_2_decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_match_proposals_state CHECK (
request_1_id < request_2_id
AND (status <> 'MATCHED' OR (request_1_decision = 'ACCEPTED' AND request_2_decision = 'ACCEPTED'))
AND (status <> 'REJECTED' OR (request_1_decision = 'REJECTED' OR request_2_decision = 'REJECTED'))
),
CONSTRAINT uk_match_proposals_request_pair UNIQUE (request_1_id, request_2_id)
);
-- 5. 최종 실시간 1:1 매칭 테이블 (matches)
CREATE TABLE matches (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
request_1_id BIGINT UNIQUE NOT NULL REFERENCES match_requests(id) ON DELETE RESTRICT,
request_2_id BIGINT UNIQUE NOT NULL REFERENCES match_requests(id) ON DELETE RESTRICT,
status VARCHAR(20) NOT NULL CHECK (status IN ('MATCHED', 'COMPLETED', 'CANCELLED')),
matched_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_matches_status_ended_at CHECK (
(status = 'MATCHED' AND ended_at IS NULL)
OR (status IN ('COMPLETED', 'CANCELLED') AND ended_at IS NOT NULL)
),
CONSTRAINT chk_distinct_match_requests CHECK (request_1_id < request_2_id),
CONSTRAINT uk_matches_request_pair UNIQUE (request_1_id, request_2_id)
);
-- 6. 1:1 매칭 참여자 테이블 (match_participants)
CREATE TABLE match_participants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
match_id BIGINT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('PARTICIPANT')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (match_id, user_id)
);
-- 7. 채팅방 테이블 (chat_rooms)
CREATE TABLE chat_rooms (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
match_id BIGINT UNIQUE NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'CLOSED')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
CONSTRAINT chk_chat_rooms_status_closed_at CHECK (
(status = 'ACTIVE' AND closed_at IS NULL)
OR (status = 'CLOSED' AND closed_at IS NOT NULL)
)
);
-- 8. 채팅 메시지 테이블 (chat_messages)
CREATE TABLE chat_messages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
chat_room_id BIGINT NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_type VARCHAR(20) NOT NULL DEFAULT 'TEXT',
content TEXT NOT NULL,
provider_place_id VARCHAR(30),
place_name VARCHAR(200),
place_category VARCHAR(200),
place_address VARCHAR(500),
place_location GEOGRAPHY(POINT, 4326),
place_url VARCHAR(300),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_chat_messages_payload CHECK (
(message_type = 'TEXT' AND provider_place_id IS NULL AND place_name IS NULL
AND place_category IS NULL AND place_address IS NULL AND place_location IS NULL AND place_url IS NULL)
OR
(message_type = 'PLACE' AND provider_place_id IS NOT NULL AND place_name IS NOT NULL
AND place_category IS NOT NULL AND place_address IS NOT NULL AND place_location IS NOT NULL AND place_url IS NOT NULL)
)
);
-- 9. 사용자 후기 테이블 (user_reviews)
CREATE TABLE user_reviews (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
match_id BIGINT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reviewee_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revisit_intention VARCHAR(30) NOT NULL,
impression_tag VARCHAR(30),
visibility VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_user_review_match_reviewer_reviewee UNIQUE (match_id, reviewer_id, reviewee_id),
CONSTRAINT chk_user_reviews_distinct_users CHECK (reviewer_id <> reviewee_id),
CONSTRAINT chk_user_reviews_revisit_intention_values CHECK (revisit_intention IN ('DEFINITELY_AGAIN', 'MAYBE_AGAIN', 'ENOUGH_FOR_NOW')),
CONSTRAINT chk_user_reviews_impression_tag_values CHECK (impression_tag IS NULL OR impression_tag IN ('PUNCTUAL', 'COMFORTABLE_CONVERSATION', 'CONSIDERATE', 'ACTIVE_PARTICIPATION')),
CONSTRAINT chk_user_reviews_visibility_values CHECK (visibility IN ('PUBLIC', 'PRIVATE'))
);
-- 10. Refresh Token 세션 테이블 (refresh_tokens)
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) UNIQUE NOT NULL,
family_id UUID NOT NULL,
replaced_by_token_id UUID REFERENCES refresh_tokens(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 11. 사용자 성향 프로필 테이블 (user_personality_profiles)
CREATE TABLE user_personality_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
questionnaire_version VARCHAR(50) NOT NULL
CHECK (questionnaire_version IN ('MEAL_PERSONALITY_V1')),
conversation_level SMALLINT NOT NULL CHECK (conversation_level BETWEEN 0 AND 100),
meal_pace SMALLINT NOT NULL CHECK (meal_pace BETWEEN 0 AND 100),
planning_style SMALLINT NOT NULL CHECK (planning_style BETWEEN 0 AND 100),
novelty_preference SMALLINT NOT NULL CHECK (novelty_preference BETWEEN 0 AND 100),
self_description VARCHAR(100),
ai_analysis_consent BOOLEAN NOT NULL DEFAULT FALSE,
CHECK (ai_analysis_consent OR self_description IS NULL),
completed_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 12. 사용자 성향 설문 응답 테이블 (user_personality_answers)
CREATE TABLE user_personality_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID NOT NULL REFERENCES user_personality_profiles(user_id) ON DELETE CASCADE,
question_code VARCHAR(100) NOT NULL CHECK (question_code IN (
'CONVERSATION_LEVEL', 'MEAL_PACE', 'PLANNING_STYLE', 'NOVELTY_PREFERENCE'
)),
answer_value SMALLINT NOT NULL CHECK (answer_value IN (1, 3, 5)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, question_code)
);
-- 13. 사용자 세부 식사 스타일 태그 테이블 (user_personality_tags)
CREATE TABLE user_personality_tags (
user_id UUID NOT NULL REFERENCES user_personality_profiles(user_id) ON DELETE CASCADE,
tag_code VARCHAR(50) NOT NULL CHECK (tag_code IN (
'INITIATES_CONVERSATION', 'GOOD_LISTENER', 'FOOD_TALK',
'LIGHT_CHAT', 'DEEP_TALK', 'COMFORTABLE_SILENCE',
'CALM_ATMOSPHERE', 'CHEERFUL_ATMOSPHERE', 'ACTIVE_ATMOSPHERE',
'SHARE_DISHES', 'TAKE_FOOD_PHOTOS', 'ENJOY_DESSERT', 'FOCUS_ON_MEAL'
)),
CONSTRAINT uk_user_personality_tag UNIQUE (user_id, tag_code)
);
-- 13-1. 사용자 AI 성향 키워드 테이블 (user_personality_ai_keywords)
CREATE TABLE user_personality_ai_keywords (
user_id UUID NOT NULL REFERENCES user_personality_profiles(user_id) ON DELETE CASCADE,
keyword VARCHAR(100) NOT NULL
);
CREATE INDEX idx_user_personality_ai_keywords_user ON user_personality_ai_keywords(user_id);
-- 14. 사용자 선호 음식 카테고리 테이블 (user_food_preferences)
CREATE TABLE user_food_preferences (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
food_category VARCHAR(50) NOT NULL CHECK (food_category IN (
'KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN',
'SOUTHEAST_ASIAN', 'SNACK', 'FAST_FOOD', 'CAFE_DESSERT'
)),
CONSTRAINT uk_user_food_preference UNIQUE (user_id, food_category)
);
-- 15. 사용자 성향 임베딩 테이블 (user_personality_embeddings, 1NF 원자화 구조)
CREATE TABLE user_personality_embeddings (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES user_personality_profiles(user_id) ON DELETE CASCADE,
source_text VARCHAR(100) NOT NULL,
embedding VECTOR(1536) NOT NULL,
model_name VARCHAR(100) NOT NULL,
source_version VARCHAR(100) NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_personality_embeddings_user ON user_personality_embeddings(user_id);
-- 16. 사용자 기본 활동지역 테이블 (user_location_preferences)
CREATE TABLE user_location_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
region_code VARCHAR(5) NOT NULL,
region_name VARCHAR(100) NOT NULL,
location_service_consent BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 17. 신고 및 제재 테이블 (reports)
CREATE TABLE reports (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reported_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
match_id BIGINT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
category VARCHAR(50) NOT NULL
CHECK (category IN ('NO_SHOW', 'ABUSE', 'SPAM', 'MISINFORMATION')),
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'DISMISSED', 'ACTIONED')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 18. 행정구역 기준 데이터 테이블 (regions)
CREATE TABLE regions (
region_code VARCHAR(5) PRIMARY KEY,
city_do VARCHAR(50) NOT NULL,
sigungu VARCHAR(50) NOT NULL,
full_name VARCHAR(100) NOT NULL,
center_location GEOGRAPHY(POINT, 4326) NOT NULL
);-- PostGIS 공간 GIST 인덱스 (위치 반경 검색 가속화)
CREATE INDEX IF NOT EXISTS idx_match_requests_location ON match_requests USING GIST (location);
CREATE INDEX idx_restaurants_location ON restaurants USING GIST (location);
-- pgvector HNSW 인덱스 (AI 식당 유사도 매칭 가속화)
-- 맛집 유사도 추천을 위한 코사인 유사도 검색 최적화
CREATE INDEX idx_restaurant_embeddings_hnsw
ON restaurant_embeddings USING hnsw (embedding vector_cosine_ops);
-- 사용자 자유 서술 성향의 보조 랭킹용 코사인 유사도 검색 최적화
CREATE INDEX idx_user_personality_embeddings_hnsw
ON user_personality_embeddings USING hnsw (embedding vector_cosine_ops);
-- 외래 키 참조 컬럼 및 복합 검색 성능을 위한 B-Tree 인덱스
CREATE INDEX idx_match_requests_user ON match_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_match_requests_region_status ON match_requests(region_code, status);
CREATE INDEX idx_match_request_desired_personality_tags_tag ON match_request_desired_personality_tags(tag_code);
CREATE INDEX idx_match_proposals_status ON match_proposals(status);
CREATE INDEX idx_match_proposals_request_1 ON match_proposals(request_1_id);
CREATE INDEX idx_match_proposals_request_2 ON match_proposals(request_2_id);
CREATE INDEX idx_match_proposals_expires_at ON match_proposals(expires_at);
CREATE INDEX idx_match_participants_match ON match_participants(match_id);
CREATE INDEX idx_match_participants_user ON match_participants(user_id);
CREATE INDEX idx_chat_messages_room_created ON chat_messages(chat_room_id, created_at DESC);
CREATE INDEX idx_user_reviews_match ON user_reviews(match_id);
CREATE INDEX idx_user_reviews_reviewer ON user_reviews(reviewer_id);
CREATE INDEX idx_user_reviews_reviewee ON user_reviews(reviewee_id);
CREATE INDEX idx_user_reviews_reviewee_created ON user_reviews(reviewee_id, created_at DESC, id DESC);
CREATE INDEX idx_user_reviews_reviewee_visibility_created
ON user_reviews(reviewee_id, visibility, created_at DESC, id DESC);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
CREATE INDEX idx_user_personality_answers_user ON user_personality_answers(user_id);
CREATE INDEX idx_user_personality_tags_tag ON user_personality_tags(tag_code);
CREATE INDEX idx_user_food_preferences_category ON user_food_preferences(food_category);
CREATE INDEX idx_reports_reported ON reports(reported_user_id);
CREATE INDEX idx_reports_match ON reports(match_id);기존 ddl-auto=update 데이터베이스와 운영 Supabase에는 배포 전에
docs/migrations/README.md의 순서에 따라 성향, 매칭 참여자,
후기 컬럼, 매칭 종료·신고 스키마와 지역 기준 데이터를 적용합니다. 마지막에는
현재_스키마_검증.sql로 Entity 계약과 필수 제약·인덱스를 검증합니다.
데이터를 삭제하는 후기 레거시 컬럼과 프로필 이미지 키 전환은 반드시 백업 및 사전 검증 후 실행합니다.
실시간 1:1 매칭 요청에서 발생하는 빈번한 상태 변경을 모두 PostgreSQL에 반영하면 과도한 Write 부하가 발생합니다. 따라서 Redis를 매칭 큐 및 세션 영역으로 두고 다음과 같이 아키텍처를 설계합니다.
- 실시간 매칭 시작 (Request):
- 클라이언트에서 실시간 매칭을 시작하면, 먼저 PostgreSQL의
match_requests테이블에status = 'WAITING'상태로 요청을 저장합니다. - 동시에, Redis에 사용자 매칭 대기 세션 키(
match:user:{user_id})와 요청 키(match:waiting:{request_id})를 5분 TTL과 함께 생성합니다. - 또한 사용자가 확정한 핀의 PostGIS
GEOGRAPHY데이터를 경도(Longitude)와 위도(Latitude)로 파싱하여 Redis Geo 자료구조인active_matching_users리스트에GEOADD연산으로 추가합니다.
- 클라이언트에서 실시간 매칭을 시작하면, 먼저 PostgreSQL의
- 매칭 탐색 및 매칭 시도 (Matching):
- 주기적으로 스케줄러 혹은 특정 이벤트 기반으로 Redis
GEORADIUS/GEORADIUSBYMEMBER연산을 수행하여 반경 이내의 매칭 희망 대기자들을 색출합니다. - 위치·시간·대기 상태·차단 관계의 하드 필터를 통과한 후보만 정형 성향 호환도 가중합으로 정렬하고, 동의한 자유 서술 임베딩 유사도는 제한된 보조 점수로 합산합니다.
- AI/임베딩 장애 또는 성향 미설정 시 사용 가능한 정형 점수만 사용하고, 이마저 없으면 하드 필터 결과만으로 계속 매칭합니다.
- 1:1 매칭 상대가 탐색되면, 해당 유저들의 상태를 Redis에서
CONFIRMING으로 변경하고 상호 수락 프로세스를 개시합니다. (이때 DB 상의match_requests.status상태도 동기화합니다.)
- 주기적으로 스케줄러 혹은 특정 이벤트 기반으로 Redis
- 수락 시 (Success):
- 두 사용자 모두 매칭을 수락하면 PostgreSQL의
matches,match_participants테이블에 최종 매칭 데이터를 기록하고, 동시에chat_rooms와 대기방을 생성합니다. - Redis 내
active_matching_users큐 및waiting:{user_id}임시 세션 키는 제거됩니다.
- 두 사용자 모두 매칭을 수락하면 PostgreSQL의
- 거절 / 취소 / 만료 시 (Fail & Expiry):
- 어느 한쪽이 거절하는 경우, Redis 내에서
reject_count를 가산하고 DB에 동기화합니다. - 5분이 지나 Redis 키가 TTL 만료되거나 취소하는 경우, 해당 유저를 Redis Geo 리스트에서 즉시 제거하고 PostgreSQL
match_requests테이블의status를EXPIRED또는CANCELLED로 일괄 업데이트합니다. - 임시로 관리하는 매칭 TTL 만료 시간 정보(
expires_at)는 DB 스키마에 중복 설계하지 않고 오직 Redis의 TTL 메커니즘에 의존합니다.
- 어느 한쪽이 거절하는 경우, Redis 내에서
- 식당 카테고리 정의:
restaurants.category와match_requests.food_category는 향후 다중 태그 또는 코드성 데이터를 사용할 수 있도록 표준화된 코드 체계나 공통 문자열 규칙을 적용합니다. - pgvector 차원 수:
restaurant_embeddings.embedding과user_personality_embeddings.embedding은 선택한 임베딩 모델의 차원 수에 맞춰 코드·DDL·문서를 함께 변경해야 합니다. DDL 예시는VECTOR(1536)이며model_name이 다른 벡터를 직접 비교하지 않습니다. 성향 임베딩 전환에서는 1536차원을 유지하고,PERSONALITY_FREE_TEXT_V2계열만 새 산식에 사용합니다. - 성향 데이터 보호: 설문 원문과 자유 서술은 본인 조회·수정·초기화만 허용하고 상대방에게 노출하지 않습니다. AI 분석 동의를 철회하면
user_personality_embeddings의 원문과 파생 벡터를 삭제하며 정형 점수만으로 매칭합니다. - 설명 가능성과 재현성: 희망 태그와 자유 텍스트 임베딩의 가중치 및 호환도 함수는 애플리케이션의 버전된 규칙으로 관리하고
match_requests.matching_formula_version을 남깁니다. 구버전 성향 벡터는 버전 계열 검사에서 제외하며, 동의·자기소개가 유효한 프로필만 비동기 배치로 재생성합니다. LLM 응답을 후보 탈락의 단독 근거로 사용하지 않습니다. - 좌표 순서와 검증: API는
latitude,longitude로 전달하되 JTS/PostGIS Point는 경도(x), 위도(y) 순서로 생성합니다. 저장 전 좌표 범위와region_code소속을 검증합니다.
사용자가 불량 이용자를 신고하고 관리자가 검토하여 조치할 수 있도록 다음과 같이 reports 테이블을 구성합니다.
CREATE TABLE reports (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
reporter_id UUID NOT NULL,
reported_user_id UUID NOT NULL,
match_id BIGINT NOT NULL,
category VARCHAR(50) NOT NULL
CHECK (category IN ('NO_SHOW', 'ABUSE', 'SPAM', 'MISINFORMATION')),
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'DISMISSED', 'ACTIONED')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_reports_reporter FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_reports_reported FOREIGN KEY (reported_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_reports_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE
);
CREATE INDEX idx_reports_reported ON reports(reported_user_id);
CREATE INDEX idx_reports_match ON reports(match_id);