-
User ↔ Tag (사용자-태그)
- 중간 테이블:
user_tags - 한 사용자는 여러 태그를 가질 수 있음
- 한 태그는 여러 사용자에게 속할 수 있음
- 중간 테이블:
-
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]) // 중복 방지
}| 테이블 | 컬럼 | 이유 |
|---|---|---|
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]) // ✅ 복합 유니크 제약
}효과:
- 데이터 무결성 보장
- 애플리케이션 로직 에러 방지
- 중복 데이터로 인한 버그 차단
// 예시 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
- 이유: 사용자가 탈퇴하면 해당 사용자의 모든 활동(좋아요, 메시지 등)도 삭제되어야 함
| 테이블 | 기본키 컬럼명 | 일관성 |
|---|---|---|
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// ✅ 올바른 예
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 표준 관례 준수
model User {
@@index([email]) // 이메일로 검색
@@index([is_deleted]) // 소프트 삭제 필터링
@@index([created_at]) // 가입일 정렬
}model User {
@@index([latitude, longitude]) // GPS 좌표 검색 최적화
}
model Message {
@@index([chat_room_id, created_at]) // 채팅방별 메시지 시간순 조회
}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배 |
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 | 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 // ✅ 항상 추가
}
});- 법적 요구사항: GDPR, 개인정보보호법 (감사 추적 필요)
- 데이터 복구: 실수로 삭제한 경우 복원 가능
- 비즈니스 분석: 탈퇴 사용자 분석 가능
- 참조 무결성: 외래키 관계 유지
// ⚠️ 모든 쿼리에 is_deleted 필터 추가 필수!
// 잊어버리면 삭제된 데이터까지 조회됨
// 나쁜 예
const users = await prisma.user.findMany(); // ❌ 삭제된 사용자도 포함
// 좋은 예
const users = await prisma.user.findMany({
where: { is_deleted: false } // ✅ 활성 사용자만
});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
}
}
});정규화를 희생하고 중복 데이터를 저장하여 성능 향상:
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도 업데이트)
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 |
돈 계산은 정확해야 함 |
┌─────────────────────────────────────────────────────────────────┐
│ 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// 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
`;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' }
});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가 자동 처리)
이 스키마는 다음을 모두 충족합니다:
- ✅ 다대다 관계: 중간 테이블 사용 (user_tags, chat_room_users)
- ✅ 유니크 제약: 중복 방지 (email, tag_name, 복합 유니크)
- ✅ 참조 무결성: onDelete: Cascade
- ✅ 기본키 명명: {table}_id 규칙
- ✅ 컬럼 명명: snake_case 통일
- ✅ 인덱싱: 조회 성능 최적화
- ✅ 소프트 삭제: 데이터 복구 가능
- ✅ 타임스탬프: created_at, updated_at 명시
프로덕션 레벨의 데이터베이스 설계입니다! 🎉