This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
# 로컬 `userfeedback` DB ERD
|
||||
|
||||
- 추출 기준: 2026-09-17
|
||||
- 대상: `abc-user-feedback-mysql-1 / userfeedback`
|
||||
- 테이블: 33개
|
||||
- UUID 저장 형식: `BINARY(16)` (애플리케이션 경계에서는 UUID v7 문자열)
|
||||
- 관계 기준: MySQL `information_schema.KEY_COLUMN_USAGE`에 등록된 물리 FK
|
||||
|
||||
## 1. 테넌트·사용자·프로젝트·채널
|
||||
|
||||
```mermaid
|
||||
%%{init: {"themeVariables": {"fontSize": "20px"}, "er": {"fontSize": 20, "minEntityWidth": 220, "minEntityHeight": 120, "entityPadding": 18, "diagramPadding": 24}}}%%
|
||||
erDiagram
|
||||
TENANT o|--o{ PROJECTS : "tenant_id"
|
||||
PROJECTS o|--o{ CHANNELS : "project_id"
|
||||
PROJECTS o|--o{ ROLES : "project_id"
|
||||
USERS o|--o{ MEMBERS : "user_id"
|
||||
ROLES o|--o{ MEMBERS : "role_id"
|
||||
PROJECTS ||--o{ CATEGORIES : "project_id"
|
||||
CHANNELS o|--o{ FIELDS : "channel_id"
|
||||
FIELDS o|--o{ OPTIONS : "field_id"
|
||||
PROJECTS o|--o{ API_KEYS : "project_id"
|
||||
PROJECTS o|--o| ISSUE_TRACKERS : "project_id"
|
||||
PROJECTS o|--o{ WEBHOOKS : "project_id"
|
||||
WEBHOOKS o|--o{ EVENTS : "webhook_id"
|
||||
EVENTS ||--o{ EVENTS_CHANNELS_CHANNELS : "events_id"
|
||||
CHANNELS ||--o{ EVENTS_CHANNELS_CHANNELS : "channels_id"
|
||||
|
||||
TENANT {
|
||||
uuid id PK
|
||||
string site_name
|
||||
string description
|
||||
bool use_email
|
||||
text allow_domains
|
||||
bool use_o_auth
|
||||
json oauth_config
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
USERS {
|
||||
uuid id PK
|
||||
string email "UK with sign_up_method"
|
||||
string name
|
||||
string department
|
||||
enum state
|
||||
string hash_password
|
||||
enum type
|
||||
enum sign_up_method
|
||||
string oauth_subject "UK with tenant and method"
|
||||
string oauth_tenant_id
|
||||
json secondary_emails
|
||||
string phone_number
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
PROJECTS {
|
||||
uuid id PK
|
||||
uuid tenant_id FK
|
||||
string name UK
|
||||
string description
|
||||
string timezone
|
||||
json issue_admin_user_ids
|
||||
bool show_feedback_comment_author_name
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
CHANNELS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string name "UK with project_id"
|
||||
string description
|
||||
json image_config
|
||||
int feedback_search_max_days
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
ROLES {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string name
|
||||
text permissions
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
MEMBERS {
|
||||
uuid id PK
|
||||
uuid role_id FK "UK with user_id"
|
||||
uuid user_id FK "UK with role_id"
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
CATEGORIES {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string name "UK with project_id"
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
FIELDS {
|
||||
uuid id PK
|
||||
uuid channel_id FK
|
||||
uuid ai_field_template_id FK
|
||||
string name "UK with channel_id"
|
||||
string key "UK with channel_id"
|
||||
string description
|
||||
enum format
|
||||
enum status
|
||||
enum property
|
||||
int order
|
||||
json ai_field_target_keys
|
||||
bool ai_field_auto_processing
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
OPTIONS {
|
||||
uuid id PK
|
||||
uuid field_id FK
|
||||
string name
|
||||
string key
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
API_KEYS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string value UK
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
ISSUE_TRACKERS {
|
||||
uuid id PK
|
||||
uuid project_id FK,UK
|
||||
json data
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
WEBHOOKS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string name
|
||||
string url
|
||||
string token
|
||||
enum status
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
EVENTS {
|
||||
uuid id PK
|
||||
uuid webhook_id FK
|
||||
enum status
|
||||
enum type
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
EVENTS_CHANNELS_CHANNELS {
|
||||
uuid events_id PK,FK
|
||||
uuid channels_id PK,FK
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 피드백·댓글·이슈
|
||||
|
||||
```mermaid
|
||||
%%{init: {"themeVariables": {"fontSize": "20px"}, "er": {"fontSize": 20, "minEntityWidth": 220, "minEntityHeight": 120, "entityPadding": 18, "diagramPadding": 24}}}%%
|
||||
erDiagram
|
||||
CHANNELS o|--o{ FEEDBACKS : "channel_id"
|
||||
FEEDBACKS ||--o{ FEEDBACK_COMMENTS : "feedback_id"
|
||||
FEEDBACK_COMMENTS ||--o{ FEEDBACK_COMMENT_ATTACHMENTS : "comment_id"
|
||||
PROJECTS o|--o{ ISSUES : "project_id"
|
||||
CATEGORIES o|--o{ ISSUES : "category_id"
|
||||
FEEDBACKS ||--o{ FEEDBACKS_ISSUES_ISSUES : "feedbacks_id"
|
||||
ISSUES ||--o{ FEEDBACKS_ISSUES_ISSUES : "issues_id"
|
||||
ISSUES ||--o{ ISSUE_INTERNAL_MEMOS : "issue_id"
|
||||
CHANNELS o|--o{ FEEDBACK_STATISTICS : "channel_id"
|
||||
PROJECTS o|--o{ ISSUE_STATISTICS : "project_id"
|
||||
ISSUES o|--o{ FEEDBACK_ISSUE_STATISTICS : "issue_id"
|
||||
|
||||
FEEDBACKS {
|
||||
uuid id PK
|
||||
uuid channel_id FK
|
||||
json data
|
||||
datetime admin_first_read_at
|
||||
uuid admin_first_read_by "logical users.id"
|
||||
string source_namespace "UK with source_record_id"
|
||||
string source_record_id
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
FEEDBACK_COMMENTS {
|
||||
uuid id PK
|
||||
uuid feedback_id FK
|
||||
string author_id
|
||||
string author_tenant_id
|
||||
string author_name
|
||||
string author_type
|
||||
text content
|
||||
bool is_internal
|
||||
string comment_type
|
||||
string idempotency_key "UK with feedback_id"
|
||||
string source_namespace "UK with feedback_id and record"
|
||||
string source_record_id
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
FEEDBACK_COMMENT_ATTACHMENTS {
|
||||
uuid id PK
|
||||
uuid comment_id FK
|
||||
string original_file_name
|
||||
string storage_key "UK with storage_bucket"
|
||||
string storage_bucket
|
||||
string mime_type
|
||||
bigint file_size
|
||||
string source_namespace "UK with source_record_id"
|
||||
string source_record_id
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
ISSUES {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
uuid category_id FK
|
||||
uuid issue_admin_user_id "logical users.id"
|
||||
uuid admin_first_read_by "logical users.id"
|
||||
uuid hold_set_by "logical users.id"
|
||||
uuid hold_released_by "logical users.id"
|
||||
string name "UK with project_id"
|
||||
string description
|
||||
enum status
|
||||
string external_issue_id "UK with provider and project"
|
||||
string external_issue_provider
|
||||
string external_issue_url
|
||||
string external_issue_status
|
||||
string external_issue_sync_status
|
||||
text external_issue_sync_error
|
||||
datetime external_issue_synced_at
|
||||
int feedback_count
|
||||
text internal_memo
|
||||
datetime admin_first_read_at
|
||||
text hold_reason
|
||||
datetime hold_set_at
|
||||
datetime hold_released_at
|
||||
string hold_resume_status
|
||||
datetime completed_at
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
FEEDBACKS_ISSUES_ISSUES {
|
||||
uuid feedbacks_id PK,FK
|
||||
uuid issues_id PK,FK
|
||||
}
|
||||
|
||||
ISSUE_INTERNAL_MEMOS {
|
||||
uuid id PK
|
||||
uuid issue_id FK
|
||||
uuid author_id "logical users.id"
|
||||
uuid deleted_by_user_id "logical users.id"
|
||||
string author_name
|
||||
text content
|
||||
string deleted_by_name
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
FEEDBACK_STATISTICS {
|
||||
uuid id PK
|
||||
uuid channel_id FK
|
||||
date date "UK with channel_id"
|
||||
int count
|
||||
}
|
||||
|
||||
ISSUE_STATISTICS {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
date date "UK with project_id"
|
||||
int count
|
||||
}
|
||||
|
||||
FEEDBACK_ISSUE_STATISTICS {
|
||||
uuid id PK
|
||||
uuid issue_id FK
|
||||
date date "UK with issue_id"
|
||||
int feedback_count
|
||||
}
|
||||
|
||||
CHANNELS {
|
||||
uuid id PK
|
||||
}
|
||||
|
||||
PROJECTS {
|
||||
uuid id PK
|
||||
}
|
||||
|
||||
CATEGORIES {
|
||||
uuid id PK
|
||||
}
|
||||
```
|
||||
|
||||
## 3. AI·이력·멱등성·운영
|
||||
|
||||
```mermaid
|
||||
%%{init: {"themeVariables": {"fontSize": "20px"}, "er": {"fontSize": 20, "minEntityWidth": 220, "minEntityHeight": 120, "entityPadding": 18, "diagramPadding": 24}}}%%
|
||||
erDiagram
|
||||
PROJECTS ||--o{ AI_FIELD_TEMPLATES : "project_id"
|
||||
PROJECTS ||--o| AI_INTEGRATIONS : "project_id"
|
||||
PROJECTS ||--o{ AI_USAGES : "project_id"
|
||||
CHANNELS ||--o{ AI_ISSUE_TEMPLATES : "channel_id"
|
||||
AI_FIELD_TEMPLATES o|--o{ FIELDS : "ai_field_template_id"
|
||||
|
||||
AI_FIELD_TEMPLATES {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
string title
|
||||
text prompt
|
||||
string model
|
||||
float temperature
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
AI_INTEGRATIONS {
|
||||
uuid id PK
|
||||
uuid project_id FK,UK
|
||||
enum provider
|
||||
string api_key
|
||||
string endpoint_url
|
||||
text system_prompt
|
||||
int token_threshold
|
||||
float notification_threshold
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
AI_ISSUE_TEMPLATES {
|
||||
uuid id PK
|
||||
uuid channel_id FK
|
||||
json target_field_keys
|
||||
text prompt
|
||||
bool is_enabled
|
||||
string model
|
||||
float temperature
|
||||
int data_reference_amount
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
AI_USAGES {
|
||||
uuid id PK
|
||||
uuid project_id FK
|
||||
int year
|
||||
int month
|
||||
int day
|
||||
enum category
|
||||
enum provider
|
||||
int used_tokens
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
HISTORIES {
|
||||
uuid id PK
|
||||
uuid user_id "logical users.id"
|
||||
enum entity_name
|
||||
string entity_id
|
||||
enum action
|
||||
json entity
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
IDEMPOTENCY_RECORDS {
|
||||
uuid id PK
|
||||
string consumer "UK with idempotency_key"
|
||||
string idempotency_key
|
||||
string request_hash
|
||||
string resource_type
|
||||
uuid resource_id "logical polymorphic id"
|
||||
datetime expires_at
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
NOTIFICATION_DELIVERIES {
|
||||
uuid id PK
|
||||
string idempotency_key UK
|
||||
string event_type
|
||||
string event_id
|
||||
enum status
|
||||
int attempts
|
||||
text last_error
|
||||
datetime sent_at
|
||||
string target_type
|
||||
string target_id
|
||||
uuid project_id "logical projects.id"
|
||||
uuid channel_id "logical channels.id"
|
||||
uuid feedback_id "logical feedbacks.id"
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
CODES {
|
||||
uuid id PK
|
||||
enum type
|
||||
string key
|
||||
string code
|
||||
string data
|
||||
bool is_verified
|
||||
datetime expired_at
|
||||
int try_count
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime deleted_at
|
||||
}
|
||||
|
||||
SCHEDULER_LOCKS {
|
||||
enum lock_type PK
|
||||
string server_id
|
||||
datetime timestamp
|
||||
}
|
||||
|
||||
MIGRATIONS {
|
||||
int id PK
|
||||
bigint timestamp
|
||||
string name
|
||||
}
|
||||
|
||||
PROJECTS {
|
||||
uuid id PK
|
||||
}
|
||||
|
||||
CHANNELS {
|
||||
uuid id PK
|
||||
}
|
||||
|
||||
FIELDS {
|
||||
uuid id PK
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 주요 물리 Unique Key
|
||||
|
||||
| 테이블 | 컬럼 조합 |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `projects` | `name` |
|
||||
| `channels` | `name, project_id` |
|
||||
| `categories` | `project_id, name` |
|
||||
| `fields` | `key, channel_id`; `name, channel_id` |
|
||||
| `members` | `role_id, user_id` |
|
||||
| `users` | `email, sign_up_method`; `oauth_subject, oauth_tenant_id, sign_up_method` |
|
||||
| `feedbacks` | `source_namespace, source_record_id` |
|
||||
| `feedback_comments` | `feedback_id, idempotency_key`; `feedback_id, source_namespace, source_record_id` |
|
||||
| `feedback_comment_attachments` | `storage_bucket, storage_key`; `source_namespace, source_record_id` |
|
||||
| `issues` | `name, project_id`; `external_issue_provider, project_id, external_issue_id` |
|
||||
| `idempotency_records` | `consumer, idempotency_key` |
|
||||
| `ai_integrations` | `project_id` |
|
||||
| `issue_trackers` | `project_id` |
|
||||
|
||||
## 5. 물리 FK가 없는 논리 참조
|
||||
|
||||
다음 컬럼은 UUID 또는 식별자를 저장하지만 현재 MySQL FK 제약은 없다.
|
||||
|
||||
- `feedbacks.admin_first_read_by → users.id`
|
||||
- `issues.issue_admin_user_id → users.id`
|
||||
- `issues.admin_first_read_by → users.id`
|
||||
- `issues.hold_set_by → users.id`
|
||||
- `issues.hold_released_by → users.id`
|
||||
- `issue_internal_memos.author_id → users.id`
|
||||
- `issue_internal_memos.deleted_by_user_id → users.id`
|
||||
- `histories.user_id → users.id`
|
||||
- `idempotency_records.resource_id`는 다형성 리소스 참조
|
||||
- `notification_deliveries.project_id/channel_id/feedback_id`는 이벤트 스냅샷 참조
|
||||
|
||||
## 6. DB 설계 이유
|
||||
|
||||
### 6.1 현재 규모에서는 단일 MySQL이 가장 단순하고 안정적이다
|
||||
|
||||
예상 데이터가 댓글과 피드백을 합쳐 연간 약 5만 건이라면, 단일 MySQL 인스턴스가 처리하기에 충분히 작은 규모다. 이 단계에서 물리적 샤딩이나 여러 DB로 데이터를 나누면 성능 이득보다 분산 트랜잭션, 데이터 정합성, 장애 복구 및 운영 복잡성이 더 커진다.
|
||||
|
||||
따라서 현재 설계는 다음 원칙을 사용한다.
|
||||
|
||||
- 업무 데이터의 최종 권위는 MySQL 하나로 유지한다.
|
||||
- Redis는 캐시·멱등성 선점·일시적 잠금에 사용하되 최종 정합성의 기준으로 사용하지 않는다.
|
||||
- 실제 CPU, 디스크 I/O, Buffer Pool, Lock wait 또는 목록 조회 p95가 허용 범위를 지속해서 넘을 때 읽기 복제본이나 기능별 분리를 검토한다.
|
||||
- UUID를 사용해 향후 여러 서버에서 동시에 데이터를 생성하더라도 PK 발급을 중앙 DB의 `AUTO_INCREMENT`에 의존하지 않는다.
|
||||
|
||||
즉, 분산 환경에서 사용할 수 있는 식별자와 중복 방지 구조는 미리 갖추되, 데이터 저장소 자체는 현재 규모에 맞게 단순하게 유지한 설계다.
|
||||
|
||||
### 6.2 UUID v7을 모든 주요 엔티티의 PK로 사용한다
|
||||
|
||||
`tenant`, `projects`, `channels`, `feedbacks`, `issues`, 댓글과 첨부파일 등 주요 엔티티의 PK는 UUID v7이며 MySQL에는 `BINARY(16)`으로 저장한다.
|
||||
|
||||
이 방식을 선택한 이유는 다음과 같다.
|
||||
|
||||
- 여러 API 인스턴스가 DB 왕복 없이 충돌 가능성이 매우 낮은 ID를 직접 생성할 수 있다.
|
||||
- UUID v7은 시간 순서 특성이 있어 UUID v4보다 B-Tree 인덱스의 무작위 페이지 분할과 단편화를 줄일 수 있다.
|
||||
- 외부 URL과 API에서 동일 UUID를 사용하므로 내부 ID와 외부 ID를 변환하는 조회 로직이 필요 없다.
|
||||
- 프로젝트나 채널을 다른 DB 또는 서비스로 이동하더라도 ID 충돌 없이 그대로 유지할 수 있다.
|
||||
- MySQL에서는 문자열 `CHAR(36)` 대신 `BINARY(16)`을 사용해 PK 및 FK 인덱스 크기를 줄인다.
|
||||
|
||||
UUID는 DB 관리 도구에서 깨진 문자처럼 보일 수 있지만 데이터 손상이 아니라 16바이트 바이너리를 문자로 표시해서 발생하는 현상이다. 조회할 때는 `BIN_TO_UUID(id)`를 사용해야 한다.
|
||||
|
||||
### 6.3 `tenant → project → channel` 계층으로 데이터 소유 범위를 구분한다
|
||||
|
||||
데이터의 기본 소유 계층은 다음과 같다.
|
||||
|
||||
```text
|
||||
Tenant
|
||||
└── Project
|
||||
├── Channel
|
||||
│ ├── Field / Option
|
||||
│ └── Feedback
|
||||
├── Role / Member
|
||||
├── Category / Issue
|
||||
├── API Key
|
||||
└── Webhook / Integration
|
||||
```
|
||||
|
||||
- `tenant`는 고객 또는 조직의 최상위 경계다.
|
||||
- `projects`는 권한, API Key, 이슈 및 외부 연동의 관리 단위다.
|
||||
- `channels`는 프로젝트 안에서 피드백 입력 양식과 피드백 데이터를 분리하는 단위다.
|
||||
- 프로젝트별 역할은 `roles`, 사용자 할당은 `members`로 분리해 한 사용자가 여러 프로젝트에서 서로 다른 권한을 가질 수 있다.
|
||||
|
||||
이 계층은 현재 단일 DB에서도 데이터 격리를 명확하게 하고, 향후 필요하면 `tenant_id` 또는 `project_id`를 기준으로 데이터 배치와 분할 범위를 결정할 수 있게 한다.
|
||||
|
||||
### 6.4 피드백 본문은 JSON, 검색·관계 기준은 정규 컬럼으로 분리한다
|
||||
|
||||
채널마다 입력 필드가 다르므로 `feedbacks.data`는 JSON으로 저장한다. 필드 정의는 `fields`, 선택지는 `options`에서 관리한다.
|
||||
|
||||
모든 입력값을 고정 컬럼으로 만들지 않은 이유는 다음과 같다.
|
||||
|
||||
- 채널별 양식 변경 시 매번 DB migration을 만들 필요가 없다.
|
||||
- 사용자 정의 필드와 AI 생성 필드를 동일한 구조로 처리할 수 있다.
|
||||
- 기존 피드백 데이터를 유지하면서 새 필드를 추가하거나 비활성화할 수 있다.
|
||||
|
||||
반면 검색과 무결성에 자주 사용하는 값은 JSON에 넣지 않고 정규 컬럼으로 둔다.
|
||||
|
||||
- `id`, `channel_id`, `created_at`, `deleted_at`
|
||||
- 외부 중복 방지용 `source_namespace`, `source_record_id`
|
||||
- 관리자 최초 열람 정보
|
||||
|
||||
특정 JSON 필드 검색이 반복적으로 느려질 때만 Generated Column과 인덱스를 추가한다. 연간 5만 건 수준에서 사용 여부가 확인되지 않은 모든 JSON 키를 미리 인덱싱하면 쓰기 비용과 운영 복잡성만 증가하기 때문이다.
|
||||
|
||||
### 6.5 댓글과 첨부파일을 별도 엔티티로 분리한다
|
||||
|
||||
댓글은 `feedback_comments`, 댓글 첨부파일은 `feedback_comment_attachments`로 분리했다.
|
||||
|
||||
- 하나의 피드백에 여러 댓글을 시간순으로 저장할 수 있다.
|
||||
- 공개 댓글과 내부 댓글을 `is_internal`로 구분할 수 있다.
|
||||
- 첨부파일 메타데이터만 DB에 두고 실제 파일은 로컬 볼륨 또는 Object Storage에 저장할 수 있다.
|
||||
- 댓글 삭제와 첨부파일 정리 정책을 피드백 본문과 독립적으로 적용할 수 있다.
|
||||
- 댓글 및 첨부파일도 각각 외부 원본 식별자를 가져 재전송 중복을 막을 수 있다.
|
||||
|
||||
파일 자체를 DB BLOB으로 저장하지 않는 것은 DB 백업 크기와 I/O 부하를 줄이고, 향후 R2/S3 같은 Object Storage로 전환하기 쉽게 하기 위한 선택이다.
|
||||
|
||||
### 6.6 피드백과 이슈는 N:M 관계로 설계한다
|
||||
|
||||
`feedbacks_issues_issues` 연결 테이블을 두어 하나의 피드백을 여러 이슈와 연결하고, 하나의 이슈가 여러 피드백을 묶을 수 있게 했다.
|
||||
|
||||
예를 들어 여러 사용자가 같은 장애를 각각 제보하면 피드백은 개별 기록으로 유지하면서 하나의 이슈로 묶어 처리할 수 있다. 연결 테이블의 복합 PK `(feedbacks_id, issues_id)`는 동일 연결이 중복 저장되는 것을 DB 차원에서 방지한다.
|
||||
|
||||
이슈의 외부 연동 식별자는 다음 조합으로 유일성을 보장한다.
|
||||
|
||||
```text
|
||||
external_issue_provider + project_id + external_issue_id
|
||||
```
|
||||
|
||||
따라서 Gitea, Jira, GitHub가 같은 숫자 이슈 ID를 사용해도 서로 충돌하지 않으며 프로젝트가 다르면 독립적으로 관리된다.
|
||||
|
||||
### 6.7 중복 요청 방지는 업무 Unique Key와 멱등성 레코드를 함께 사용한다
|
||||
|
||||
네트워크 재시도나 외부 시스템의 중복 전송으로 같은 데이터가 여러 번 생성되지 않도록 두 계층으로 방어한다.
|
||||
|
||||
1. 업무 원본 중복 방지
|
||||
- `source_namespace + source_record_id`
|
||||
- 원본 시스템 안에서 같은 레코드를 다시 보내면 기존 데이터를 식별한다.
|
||||
2. HTTP 요청 재시도 방지
|
||||
- `consumer + idempotency_key`
|
||||
- 같은 소비자의 동일 요청 키는 `idempotency_records`에서 한 번만 처리한다.
|
||||
|
||||
Redis가 사용 가능한 경우 짧은 선점 잠금으로 동시 요청을 빠르게 차단하지만, 최종 중복 방지는 MySQL Unique Key가 담당한다. 따라서 Redis가 재시작되거나 일시적으로 중단되어도 중복 데이터가 확정 저장되지 않는다.
|
||||
|
||||
### 6.8 Soft Delete와 이력 테이블을 사용한다
|
||||
|
||||
대부분의 업무 테이블에는 `created_at`, `updated_at`, `deleted_at`이 있다.
|
||||
|
||||
- 일반 삭제는 `deleted_at`을 기록하는 Soft Delete로 처리해 실수로 삭제한 데이터를 복구할 수 있다.
|
||||
- FK의 `ON DELETE CASCADE`는 프로젝트나 피드백을 실제로 물리 삭제하는 명시적 정리 작업에서 하위 데이터를 함께 제거한다.
|
||||
- `histories`는 엔티티 종류, 동작, 당시 데이터를 JSON으로 기록해 관리자 변경 이력을 추적한다.
|
||||
|
||||
Soft Delete만 적용하면 Unique Key의 재사용 정책이 복잡해질 수 있으므로 프로젝트명, 채널명처럼 재사용 여부가 중요한 값은 운영 정책과 함께 관리해야 한다.
|
||||
|
||||
### 6.9 집계 통계는 원본 테이블과 분리한다
|
||||
|
||||
대시보드가 매번 전체 피드백과 이슈를 집계하지 않도록 다음 통계 테이블을 둔다.
|
||||
|
||||
- `feedback_statistics`: 채널·일자별 피드백 수
|
||||
- `issue_statistics`: 프로젝트·일자별 이슈 수
|
||||
- `feedback_issue_statistics`: 이슈·일자별 연결 피드백 수
|
||||
|
||||
원본 데이터는 `feedbacks`와 `issues`이며 통계 테이블은 재생성 가능한 파생 데이터다. `scheduler_locks`는 여러 API 인스턴스가 같은 통계 작업을 동시에 실행하는 것을 막는다.
|
||||
|
||||
연간 5만 건에서는 원본 직접 집계도 가능하지만, 대시보드 요청이 늘어날 때의 반복 스캔을 줄이고 응답시간을 안정적으로 유지하기 위해 집계 구조를 분리했다.
|
||||
|
||||
### 6.10 AI와 외부 연동 설정은 업무 데이터와 분리한다
|
||||
|
||||
AI 기능은 다음과 같이 역할별로 분리했다.
|
||||
|
||||
- `ai_integrations`: 프로젝트별 AI 공급자와 연결 설정
|
||||
- `ai_field_templates`: AI 필드 생성 프롬프트
|
||||
- `ai_issue_templates`: 채널별 이슈 추천 설정
|
||||
- `ai_usages`: 프로젝트별 사용량
|
||||
|
||||
웹훅도 `webhooks`, `events`, `events_channels_channels`로 분리해 하나의 웹훅에 여러 이벤트와 채널을 연결할 수 있다. 설정과 실행 데이터를 분리하면 공급자 변경, 기능 비활성화, 사용량 집계가 피드백 원본 구조에 영향을 주지 않는다.
|
||||
|
||||
### 6.11 일부 사용자·이벤트 참조에 물리 FK를 두지 않은 이유
|
||||
|
||||
관리자 사용자, 이력 작성자, 알림 대상처럼 삭제 이후에도 당시 값을 보존해야 하거나 여러 엔티티를 참조할 수 있는 컬럼에는 물리 FK가 없다.
|
||||
|
||||
- 사용자가 삭제되어도 과거 이력과 처리 담당자 기록은 남아야 한다.
|
||||
- `idempotency_records.resource_id`는 피드백, 댓글 등 여러 리소스를 가리킬 수 있어 단일 FK를 만들 수 없다.
|
||||
- `notification_deliveries`는 발송 당시의 이벤트 스냅샷이므로 원본 삭제가 알림 이력을 삭제하게 만들지 않는다.
|
||||
|
||||
이 선택은 보존성과 결합도 측면에서는 유리하지만 고아 참조가 생길 수 있다. 따라서 애플리케이션 검증과 정기 무결성 점검이 필요하다. 반드시 강한 정합성이 필요한 소유 관계에는 계속 물리 FK를 사용한다.
|
||||
|
||||
### 6.12 인덱스는 조회 패턴과 유일성 중심으로 구성한다
|
||||
|
||||
현재 인덱스는 다음 용도에 집중한다.
|
||||
|
||||
- FK 조인: `project_id`, `channel_id`, `feedback_id`, `issue_id`
|
||||
- 목록 조회: 생성일, 삭제 여부, 상태
|
||||
- 이름 중복 방지: 프로젝트명, 프로젝트 내 채널·카테고리·이슈명
|
||||
- 외부 원본 중복 방지: source identity
|
||||
- 외부 이슈 중복 방지: provider/project/external ID
|
||||
- 요청 재시도 방지: idempotency key
|
||||
|
||||
UUID v7과 `(created_at, id)` 조합은 같은 시각에 생성된 데이터도 안정적으로 정렬하고 cursor pagination의 마지막 위치를 정확하게 표현한다. 데이터가 증가한 뒤에는 추측으로 인덱스를 늘리기보다 Slow Query Log와 실제 실행 계획을 기준으로 추가한다.
|
||||
|
||||
## 7. 재추출 필요 조건
|
||||
|
||||
아래 작업 후에는 이 문서를 다시 생성해야 한다.
|
||||
|
||||
1. TypeORM UUID migration 적용 또는 변경
|
||||
2. Secretary Alembic migration 적용으로 `support_*` 테이블 생성
|
||||
3. FK·Unique Key·인덱스 변경
|
||||
4. 스테이징/운영 스키마와 로컬 스키마 비교
|
||||
Reference in New Issue
Block a user