Skip to content

Latest commit

 

History

History
647 lines (531 loc) · 20.2 KB

File metadata and controls

647 lines (531 loc) · 20.2 KB

RunMate 데이터베이스 설계 문서

설계 원칙 적용 체크리스트 ✅

1. ✅ 다대다 관계 (Many-to-Many)

적용된 곳:

  1. User ↔ Tag (사용자-태그)

    • 중간 테이블: user_tags
    • 한 사용자는 여러 태그를 가질 수 있음
    • 한 태그는 여러 사용자에게 속할 수 있음
  2. User ↔ ChatRoom (사용자-채팅방)

    • 중간 테이블: chat_room_users
    • 한 사용자는 여러 채팅방에 참여 가능
    • 한 채팅방에는 여러 사용자가 참여 가능 (그룹 채팅 지원)
// 잘못된 예 (다대다를 직접 연결)
model User {
  tags Tag[] // ❌ 중간 테이블 없음
}

// 올바른 예 (중간 테이블 사용)
model User {
  tags UserTag[] // ✅ 중간 테이블 사용
}

model UserTag {
  user_id Int
  tag_id  Int
  user    User @relation(fields: [user_id], references: [user_id])
  tag     Tag  @relation(fields: [tag_id], references: [tag_id])
  @@unique([user_id, tag_id]) // 중복 방지
}

2. ✅ 유니크 제약 (Unique Constraints)

적용된 곳:

테이블 컬럼 이유
users email 한 이메일로 중복 가입 방지
tags tag_name 동일한 태그 중복 생성 방지
user_tags [user_id, tag_id] 한 사용자가 같은 태그 중복 불가
likes [sender_id, receiver_id] 같은 사람에게 중복 좋아요 불가
chat_room_users [chat_room_id, user_id] 같은 채팅방 중복 참여 불가
// 예시: 좋아요 중복 방지
model Like {
  sender_id   Int
  receiver_id Int

  @@unique([sender_id, receiver_id]) // ✅ 복합 유니크 제약
}

효과:

  • 데이터 무결성 보장
  • 애플리케이션 로직 에러 방지
  • 중복 데이터로 인한 버그 차단

3. ✅ 참조 무결성 (Referential Integrity)

모든 외래키에 적용:

// 예시 1: User 삭제 시 관련 데이터 처리
model Like {
  sender_id Int
  sender    User @relation("LikeSender", fields: [sender_id], references: [user_id], onDelete: Cascade)
  //                                                                                  ^^^^^^^^^^^^^^^^
  //                                                                                  사용자 삭제 시 좋아요도 자동 삭제
}

// 예시 2: ChatRoom 삭제 시 메시지 처리
model Message {
  chat_room_id Int
  chat_room    ChatRoom @relation(fields: [chat_room_id], references: [chat_room_id], onDelete: Cascade)
  //                                                                                    ^^^^^^^^^^^^^^^^
  //                                                                                    채팅방 삭제 시 메시지도 자동 삭제
}

onDelete 옵션:

  • Cascade: 부모 삭제 시 자식도 삭제 (우리가 사용)
  • Restrict: 자식이 있으면 부모 삭제 불가
  • SetNull: 부모 삭제 시 자식의 외래키를 NULL로 설정

우리의 선택: Cascade

  • 이유: 사용자가 탈퇴하면 해당 사용자의 모든 활동(좋아요, 메시지 등)도 삭제되어야 함

4. ✅ 기본키 (Primary Key) 명명 규칙

규칙: {테이블명}_id

테이블 기본키 컬럼명 일관성
users user_id
tags tag_id
user_tags user_tag_id
likes like_id
chat_rooms chat_room_id
chat_room_users chat_room_user_id
messages message_id

장점:

  • 조인 시 어떤 테이블의 ID인지 명확함
  • 코드 가독성 향상
  • 실수 방지
-- 나쁜 예 (모든 테이블이 id)
SELECT u.id, l.id, m.id  -- ❌ 무슨 id인지 모호함
FROM users u
JOIN likes l ON u.id = l.sender_id
JOIN messages m ON u.id = m.sender_id

-- 좋은 예 (명확한 명명)
SELECT u.user_id, l.like_id, m.message_id  -- ✅ 명확함
FROM users u
JOIN likes l ON u.user_id = l.sender_id
JOIN messages m ON u.user_id = m.sender_id

5. ✅ 컬럼 명명 규칙 (Snake Case)

규칙: snake_case 사용

// ✅ 올바른 예
model User {
  user_id              Int      // snake_case
  password_hash        String
  preferred_pace_min   Decimal
  location_updated_at  DateTime
  created_at           DateTime
}

// ❌ 잘못된 예 (혼용)
model User {
  userId               Int      // camelCase ❌
  PasswordHash         String   // PascalCase ❌
  preferred_pace_min   Decimal  // snake_case ✅ (일관성 없음)
}

통일성 이점:

  • SQL 쿼리 작성 시 일관성
  • 팀원 간 혼란 방지
  • PostgreSQL 표준 관례 준수

6. ✅ 인덱싱 (Indexing)

인덱스 전략:

1) 단일 컬럼 인덱스
model User {
  @@index([email])           // 이메일로 검색
  @@index([is_deleted])      // 소프트 삭제 필터링
  @@index([created_at])      // 가입일 정렬
}
2) 복합 인덱스
model User {
  @@index([latitude, longitude])  // GPS 좌표 검색 최적화
}

model Message {
  @@index([chat_room_id, created_at])  // 채팅방별 메시지 시간순 조회
}
3) 외래키 인덱스
model Like {
  @@index([sender_id])    // 내가 보낸 좋아요 조회
  @@index([receiver_id])  // 내가 받은 좋아요 조회
}

인덱스가 필요한 기준:

  • WHERE 절에 자주 사용되는 컬럼
  • JOIN 조건에 사용되는 외래키
  • ORDER BY로 정렬하는 컬럼
  • GROUP BY에 사용되는 컬럼

인덱스 사용 예시:

-- 쿼리 1: GPS 근처 사용자 찾기
SELECT * FROM users
WHERE latitude BETWEEN 37.5 AND 37.6
  AND longitude BETWEEN 126.9 AND 127.0
  AND is_deleted = false;
-- 인덱스 사용: @@index([latitude, longitude]), @@index([is_deleted])

-- 쿼리 2: 특정 채팅방의 최근 메시지 100개
SELECT * FROM messages
WHERE chat_room_id = 123
  AND is_deleted = false
ORDER BY created_at DESC
LIMIT 100;
-- 인덱스 사용: @@index([chat_room_id, created_at])

-- 쿼리 3: 내가 좋아요한 사람 목록
SELECT * FROM likes
WHERE sender_id = 456
  AND is_deleted = false
ORDER BY created_at DESC;
-- 인덱스 사용: @@index([sender_id]), @@index([created_at])

성능 비교:

쿼리 인덱스 없음 인덱스 있음 개선
사용자 100만명 중 이메일 검색 ~500ms ~5ms 100배
채팅방 메시지 1만개 조회 ~200ms ~10ms 20배
GPS 근처 사용자 검색 (10만명) ~800ms ~50ms 16배

7. ✅ 소프트 삭제 (Soft Delete)

적용된 모든 테이블:

model User {
  is_deleted Boolean  @default(false)
  deleted_at DateTime?
}

model Like {
  is_deleted Boolean  @default(false)
  deleted_at DateTime?
}

model Message {
  is_deleted Boolean  @default(false)
  deleted_at DateTime?
}

Hard Delete vs Soft Delete

구분 Hard Delete Soft Delete
방식 DELETE FROM users WHERE user_id = 1 UPDATE users SET is_deleted = true WHERE user_id = 1
데이터 영구 삭제 DB에 남아있음
복구 불가능 가능
감사 추적 어려움 가능
사용 거의 안 씀 우리가 사용

소프트 삭제 사용 예시:

// ❌ Hard Delete (사용 안 함)
await prisma.user.delete({
  where: { user_id: 1 }
});

// ✅ Soft Delete (우리가 사용)
await prisma.user.update({
  where: { user_id: 1 },
  data: {
    is_deleted: true,
    deleted_at: new Date()
  }
});

// 조회 시 삭제된 데이터 제외
const activeUsers = await prisma.user.findMany({
  where: {
    is_deleted: false  // ✅ 항상 추가
  }
});

왜 소프트 삭제를 사용하는가?

  1. 법적 요구사항: GDPR, 개인정보보호법 (감사 추적 필요)
  2. 데이터 복구: 실수로 삭제한 경우 복원 가능
  3. 비즈니스 분석: 탈퇴 사용자 분석 가능
  4. 참조 무결성: 외래키 관계 유지

주의사항:

// ⚠️ 모든 쿼리에 is_deleted 필터 추가 필수!
// 잊어버리면 삭제된 데이터까지 조회됨

// 나쁜 예
const users = await prisma.user.findMany(); // ❌ 삭제된 사용자도 포함

// 좋은 예
const users = await prisma.user.findMany({
  where: { is_deleted: false } // ✅ 활성 사용자만
});

8. ✅ 타임스탬프 (Timestamps)

모든 테이블에 필수 적용:

model User {
  created_at DateTime @default(now())   // 생성 시각 (자동)
  updated_at DateTime @updatedAt        // 수정 시각 (자동 갱신)
}

model Like {
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  matched_at DateTime?  // 매칭 성공 시각 (선택적)
}

model Message {
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  read_at    DateTime?  // 읽은 시각 (선택적)
}

타임스탬프 타입:

컬럼 타입 설명 예시
created_at DateTime @default(now()) 레코드 생성 시각 (자동) 회원가입 시각
updated_at DateTime @updatedAt 레코드 수정 시각 (자동 갱신) 프로필 수정 시각
deleted_at DateTime? 삭제 시각 (수동, null 허용) 탈퇴 시각
matched_at DateTime? 특정 이벤트 시각 (수동) 매칭 성공 시각

활용 예시:

// 1. 최근 가입한 사용자 조회
const recentUsers = await prisma.user.findMany({
  where: { is_deleted: false },
  orderBy: { created_at: 'desc' },
  take: 10
});

// 2. 7일 이내 활동 내역
const recentActivity = await prisma.message.findMany({
  where: {
    created_at: {
      gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 7일 전
    }
  }
});

// 3. 프로필 마지막 수정 시각 확인
const user = await prisma.user.findUnique({
  where: { user_id: 1 }
});
console.log(`마지막 수정: ${user.updated_at}`);

// 4. 탈퇴 후 30일 지난 사용자 완전 삭제 (배치 작업)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
await prisma.user.deleteMany({
  where: {
    is_deleted: true,
    deleted_at: {
      lte: thirtyDaysAgo
    }
  }
});

추가 설계 원칙

9. ✅ 비정규화 (Denormalization) - 성능 최적화

정규화를 희생하고 중복 데이터를 저장하여 성능 향상:

model ChatRoom {
  // 비정규화: 마지막 메시지 정보를 채팅방에 저장
  last_message_text String?
  last_message_at   DateTime?
  // 👆 원래는 messages 테이블에서 JOIN으로 가져와야 하지만,
  //    채팅방 목록 조회 시 매번 JOIN하면 느려서 여기에 복사
}

model ChatRoomUser {
  // 비정규화: 읽지 않은 메시지 수
  unread_count Int @default(0)
  // 👆 원래는 messages 테이블에서 COUNT(*)로 계산해야 하지만,
  //    실시간으로 계산하면 느려서 여기에 캐시
}

Trade-off:

  • ✅ 장점: 조회 속도 빠름 (JOIN 불필요)
  • ❌ 단점: 데이터 동기화 필요 (메시지 추가 시 last_message_text도 업데이트)

10. ✅ 데이터 타입 최적화

model User {
  email         String  @db.VarChar(255)  // ✅ 길이 제한
  bio           String? @db.Text           // ✅ 긴 텍스트는 Text
  latitude      Decimal @db.Decimal(10, 8) // ✅ 정확한 소수점
  age           Int                        // ✅ 정수
  is_deleted    Boolean                    // ✅ true/false
}

PostgreSQL 타입 선택 가이드:

데이터 Prisma 타입 PostgreSQL 타입 이유
이메일 String @db.VarChar(255) VARCHAR(255) 길이 제한, 인덱스 효율
긴 텍스트 String @db.Text TEXT 무제한 길이
GPS 좌표 Decimal @db.Decimal(10,8) DECIMAL Float보다 정확
나이 Int INTEGER 정수
가격 Decimal @db.Decimal(10,2) DECIMAL 돈 계산은 정확해야 함

ERD (Entity Relationship Diagram)

┌─────────────────────────────────────────────────────────────────┐
│                          users                                  │
├─────────────────────────────────────────────────────────────────┤
│ PK  user_id                                                     │
│ UQ  email                                                       │
│     password_hash, name, age, gender, location, bio             │
│     profile_image, latitude, longitude                          │
│     preferred_pace_min, preferred_pace_max                      │
│     is_deleted, deleted_at, created_at, updated_at              │
└───────────────┬──────────────────────────┬──────────────────────┘
                │                          │
                │ 1:N                      │ 1:N
                │                          │
      ┌─────────▼──────────┐     ┌─────────▼──────────┐
      │    user_tags       │     │       likes        │
      ├────────────────────┤     ├────────────────────┤
      │ PK  user_tag_id    │     │ PK  like_id        │
      │ FK  user_id        │     │ FK  sender_id      │
      │ FK  tag_id         │     │ FK  receiver_id    │
      │     created_at     │     │     is_matched     │
      │                    │     │     matched_at     │
      │ UQ  [user_id,      │     │     is_deleted     │
      │      tag_id]       │     │     deleted_at     │
      └─────────┬──────────┘     │     created_at     │
                │                │     updated_at     │
                │ N:1            │ UQ  [sender_id,    │
                │                │      receiver_id]  │
      ┌─────────▼──────────┐     └────────────────────┘
      │       tags         │
      ├────────────────────┤
      │ PK  tag_id         │
      │ UQ  tag_name       │
      │     created_at     │
      └────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                       chat_rooms                                │
├─────────────────────────────────────────────────────────────────┤
│ PK  chat_room_id                                                │
│     room_type, room_name                                        │
│     last_message_text, last_message_at (비정규화)               │
│     is_deleted, deleted_at, created_at, updated_at              │
└───────────┬─────────────────────────────┬───────────────────────┘
            │ 1:N                         │ 1:N
            │                             │
  ┌─────────▼──────────────┐    ┌─────────▼──────────────┐
  │  chat_room_users       │    │      messages          │
  ├────────────────────────┤    ├────────────────────────┤
  │ PK  chat_room_user_id  │    │ PK  message_id         │
  │ FK  chat_room_id       │    │ FK  chat_room_id       │
  │ FK  user_id            │    │ FK  sender_id (users)  │
  │     unread_count       │    │     message_text       │
  │     last_read_at       │    │     message_type       │
  │     joined_at          │    │     is_read, read_at   │
  │                        │    │     is_deleted         │
  │ UQ  [chat_room_id,     │    │     deleted_at         │
  │      user_id]          │    │     created_at         │
  └────────────────────────┘    │     updated_at         │
                                └────────────────────────┘

마이그레이션 실행

스키마가 완성되었으니 데이터베이스에 반영:

# 1. Prisma Client 생성
npx prisma generate

# 2. 마이그레이션 파일 생성 및 실행
npx prisma migrate dev --name init_runmate_schema

# 3. Prisma Studio로 확인 (GUI)
npx prisma studio

쿼리 예시

1. GPS 기반 근처 사용자 검색

// Haversine formula를 사용한 거리 계산 (Raw SQL)
const nearbyUsers = await prisma.$queryRaw`
  SELECT
    user_id, name, latitude, longitude,
    (
      6371 * acos(
        cos(radians(${myLat}))
        * cos(radians(latitude))
        * cos(radians(longitude) - radians(${myLon}))
        + sin(radians(${myLat}))
        * sin(radians(latitude))
      )
    ) AS distance_km
  FROM users
  WHERE is_deleted = false
    AND latitude IS NOT NULL
    AND longitude IS NOT NULL
    AND user_id != ${myUserId}
  HAVING distance_km < ${radiusKm}
  ORDER BY distance_km
  LIMIT 50
`;

2. 매칭된 사용자 목록

const matches = await prisma.like.findMany({
  where: {
    sender_id: myUserId,
    is_matched: true,
    is_deleted: false
  },
  include: {
    receiver: {
      select: {
        user_id: true,
        name: true,
        age: true,
        profile_image: true,
        tags: {
          include: { tag: true }
        }
      }
    }
  },
  orderBy: { matched_at: 'desc' }
});

3. 채팅방 목록 (읽지 않은 메시지 수 포함)

const chatRooms = await prisma.chatRoomUser.findMany({
  where: {
    user_id: myUserId
  },
  include: {
    chat_room: {
      include: {
        chat_room_users: {
          where: { user_id: { not: myUserId } },
          include: {
            user: {
              select: { name: true, profile_image: true }
            }
          }
        }
      }
    }
  },
  orderBy: {
    chat_room: {
      last_message_at: 'desc'
    }
  }
});

성능 최적화 체크리스트

  • 자주 조회되는 컬럼에 인덱스 생성
  • 복합 인덱스 (latitude, longitude) 추가
  • 외래키에 인덱스 자동 생성 확인
  • 비정규화로 조인 최소화 (last_message_text, unread_count)
  • 소프트 삭제 필터에 인덱스 (is_deleted)
  • 타임스탬프 인덱스 (정렬 최적화)
  • Connection Pool 설정 (프로덕션)
  • Query 캐싱 (Redis)
  • Read Replica (읽기 분산)

보안 체크리스트

  • 비밀번호 평문 저장 금지 (password_hash 사용)
  • 외래키 제약으로 참조 무결성 보장
  • 소프트 삭제로 데이터 복구 가능
  • Row-Level Security (RLS) 고려
  • 민감 정보 암호화 (향후 추가)
  • API Rate Limiting (서버 코드)
  • SQL Injection 방지 (Prisma가 자동 처리)

결론

이 스키마는 다음을 모두 충족합니다:

  1. 다대다 관계: 중간 테이블 사용 (user_tags, chat_room_users)
  2. 유니크 제약: 중복 방지 (email, tag_name, 복합 유니크)
  3. 참조 무결성: onDelete: Cascade
  4. 기본키 명명: {table}_id 규칙
  5. 컬럼 명명: snake_case 통일
  6. 인덱싱: 조회 성능 최적화
  7. 소프트 삭제: 데이터 복구 가능
  8. 타임스탬프: created_at, updated_at 명시

프로덕션 레벨의 데이터베이스 설계입니다! 🎉