25 KiB
로컬 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. 테넌트·사용자·프로젝트·채널
%%{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. 피드백·댓글·이슈
%%{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·이력·멱등성·운영
%%{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.idissues.issue_admin_user_id → users.idissues.admin_first_read_by → users.idissues.hold_set_by → users.idissues.hold_released_by → users.idissue_internal_memos.author_id → users.idissue_internal_memos.deleted_by_user_id → users.idhistories.user_id → users.ididempotency_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 계층으로 데이터 소유 범위를 구분한다
데이터의 기본 소유 계층은 다음과 같다.
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 차원에서 방지한다.
이슈의 외부 연동 식별자는 다음 조합으로 유일성을 보장한다.
external_issue_provider + project_id + external_issue_id
따라서 Gitea, Jira, GitHub가 같은 숫자 이슈 ID를 사용해도 서로 충돌하지 않으며 프로젝트가 다르면 독립적으로 관리된다.
6.7 중복 요청 방지는 업무 Unique Key와 멱등성 레코드를 함께 사용한다
네트워크 재시도나 외부 시스템의 중복 전송으로 같은 데이터가 여러 번 생성되지 않도록 두 계층으로 방어한다.
- 업무 원본 중복 방지
source_namespace + source_record_id- 원본 시스템 안에서 같은 레코드를 다시 보내면 기존 데이터를 식별한다.
- 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. 재추출 필요 조건
아래 작업 후에는 이 문서를 다시 생성해야 한다.
- TypeORM UUID migration 적용 또는 변경
- Secretary Alembic migration 적용으로
support_*테이블 생성 - FK·Unique Key·인덱스 변경
- 스테이징/운영 스키마와 로컬 스키마 비교