Files
baron_qa_write/docs/관리페이지 md 파일/distributed-system-and-pk-design-options.md
T
root 3c10478482
Deploy EG-BIM QA Gateway / deploy (push) Successful in 2m4s
관리페이지 데이터 전송 구현
2026-09-21 14:46:36 +09:00

561 lines
23 KiB
Markdown

# 분산 시스템 도입 시점 및 PK 설계안 비교
## 문서 목적
본 문서는 다중 프로젝트·다중 RP에서 Q&A와 피드백을 수집하는 시스템을 기준으로 다음 두 가지 설계를 비교한다.
1. 처음부터 분산 시스템을 고려하여 설계하는 방안
2. 현재 중앙 DB 구조를 유지하고, 실제 병목 발생 시 분산 시스템으로 확장하는 방안
현재 저장소는 `apps/api → 중앙 MySQL` 구조이며, 공통 엔터티의 PK는 `INT AUTO_INCREMENT`이다. 스테이징 덤프의 `feedbacks`도 약 523건 수준이므로 현재는 PK 용량이나 DB 하드웨어가 병목인 단계가 아니다.
---
## 핵심 결론
| 구분 | 1안: 처음부터 분산 고려 | 2안: 단계적 분산 도입 |
|---|---|---|
| 내부 PK | `BINARY(16)` UUID v7 | `BIGINT UNSIGNED AUTO_INCREMENT` |
| 외부 식별자 | PK와 동일한 UUID v7 | 별도 `public_id` UUID v7 |
| 다중 작성 서버 | 즉시 지원 | API에서 중앙 수집, 필요 시 확장 |
| 초기 비용 | 높음 | 낮음 |
| FK·인덱스 크기 | 큼 | 작음 |
| 향후 샤딩 | 쉬움 | `public_id`를 기준으로 단계적 전환 |
| 현재 시스템 적합성 | 미래 요구사항이 확정된 경우 | 현재 시스템에 권장 |
최종 추천은 **2안**이다.
```text
내부 PK BIGINT UNSIGNED AUTO_INCREMENT
외부 ID UUID v7 → BINARY(16)
멱등 키 source_system + source_record_id UNIQUE
검색 MySQL 인덱스 + 필요 시 OpenSearch
분산 도입 실제 Primary 병목 발생 시 단계별 확장
```
---
## 연간 5만 건 기준 하드웨어 검토
연간 피드백·댓글·이슈 등 전체 업무 레코드를 **5만 건**으로 가정한다.
| 항목 | 평균 규모 |
|---|---:|
| 연간 레코드 | 50,000건 |
| 일평균 | 약 137건 |
| 시간당 평균 | 약 5.7건 |
| 초당 평균 쓰기 | 약 0.0016건 |
| 5년 누적 | 약 250,000건 |
| 10년 누적 | 약 500,000건 |
연간 5만 건 자체는 단일 MySQL Primary가 감당하지 못할 규모가 아니다. 데이터가 특정 이벤트에 몰리는 경우를 고려해도, 평균 쓰기량만으로 분산 DB를 선택할 근거는 부족하다.
만약 피드백 5만 건과 댓글 5만 건을 각각 의미한다면 연간 총 10만 건이지만, 이 정도 역시 연간 누적량만으로는 분산 DB 도입을 결정할 수준은 아니다. 동시 접속자와 순간 피크 쓰기량을 별도로 측정해야 한다.
예를 들어 평균 쓰기량의 1,000배가 짧은 시간에 발생해도 약 1.6건/초 수준이다. 다만 신규 글 등록보다 다음 항목이 먼저 병목이 될 수 있다.
- 동시에 접속한 관리자의 목록·통계 조회 수
- JSON 필드의 전문 검색과 `%검색어%` 검색
- 매 요청마다 실행하는 `COUNT(*)`
- OFFSET이 큰 페이지 조회
- 첨부파일 다운로드 트래픽
- 알림·웹훅·검색 색인 작업이 본 요청과 같은 트랜잭션에 묶이는 경우
첨부파일 본문은 DB 용량 계산에서 제외하고 Object Storage에 저장한다. 본문과 댓글 평균 크기를 2~10KB로 가정하면 5만 건의 원본 데이터는 대략 100~500MB이고, 인덱스·레코드 오버헤드를 포함해도 일반적인 단일 DB에서 수년간 관리 가능한 범위다. 실제 크기는 JSON 본문과 인덱스 수를 측정해 확정해야 한다.
### 5만 건 기준 권장 구성
```text
API 서버 2대 이상
MySQL Primary 1대
백업 또는 장애 복구용 Replica 1대
Object Storage: 첨부파일
OpenSearch: 전문 검색이 필요할 때만
```
이 규모에서는 샤딩이나 멀티 Primary보다 다음 순서가 적절하다.
1. `BIGINT UNSIGNED` 내부 PK와 UUID v7 `public_id` 적용
2. 목록 조회용 복합 인덱스와 Keyset 페이지네이션 적용
3. JSON 검색을 Generated Column 또는 OpenSearch로 분리
4. 읽기 부하가 증가하면 Read Replica 추가
5. 쓰기·알림·검색 색인을 Outbox와 큐로 분리
6. 실제 Primary 포화 시에만 샤딩 검토
따라서 **연간 5만 건을 전제로 하면 2안이 적합**하다. 1안은 연간 건수 때문이 아니라 다중 DB 쓰기, 오프라인 생성, 멀티리전, 강한 장애 격리처럼 분산이 출시 필수 요구사항일 때 선택한다.
---
## PK 변경 공수 비교
UUID를 외부 식별자로 추가하는 것과 UUID를 실제 PK로 사용하는 것은 변경 범위가 다르다.
### 비교표
| 항목 | `BIGINT PK` 유지 | `BIGINT PK + UUID public_id` | `UUID v7` 실제 PK |
|---|---|---|---|
| DB PK 변경 | 없음 | 없음 | 모든 PK/FK 변경 |
| FK 컬럼 | `BIGINT` | `BIGINT` | `BINARY(16)` |
| TypeORM Entity | 변경 적음 | 컬럼 추가 | ID 타입·생성 방식 전면 변경 |
| API 계약 | 기존 숫자 ID 유지 | public_id를 단계적 추가 | URL·DTO·응답 ID 변경 |
| 프론트엔드 | 변경 적음 | 필요한 화면부터 변경 | `id: number` 전면 변경 |
| 인덱스 크기 | 가장 작음 | 내부 인덱스는 동일 | PK·FK·보조 인덱스 증가 |
| 기존 데이터 변환 | 없음 | UUID backfill | PK·FK 매핑 및 제약조건 재생성 |
| 외부 연동 영향 | 낮음 | 낮음~중간 | 웹훅·검색·테스트·연동 영향 큼 |
| 분산 선발급 | 어려움 | public_id로 가능 | PK 자체로 가능 |
| 전체 공수 | 낮음 | 중간 | 높음 |
### UUID를 실제 PK로 변경할 때 필요한 작업
현재 시스템은 공통 엔터티에서 `id: number``INT AUTO_INCREMENT`를 사용하고 있으며, 댓글·첨부파일·통계·이슈 연결 테이블이 숫자 ID를 FK로 참조한다. 따라서 UUID PK 전환 시 다음 작업이 필요하다.
1. 모든 핵심 테이블의 PK를 `BINARY(16)`으로 변경
2. 모든 FK와 조인 테이블의 참조 컬럼을 `BINARY(16)`으로 변경
3. TypeORM Entity의 ID 타입과 생성 로직 변경
4. DTO의 숫자 검증을 UUID 검증으로 변경
5. API URL과 응답의 ID 형식 변경
6. 프론트엔드의 `id: number` 타입과 정렬·비교 로직 변경
7. 기존 Foreign Key와 인덱스 삭제·재생성
8. 웹훅, OpenSearch 문서 ID, 테스트 fixture, E2E 데이터 변경
9. UUID 생성·바이너리 변환·JSON 직렬화 규칙 추가
`UUID v4`보다 `UUID v7`을 사용하고, 저장 타입은 `CHAR(36)`이 아니라 `BINARY(16)`으로 통일한다.
### 권장 마이그레이션 방식
현재 구조에서는 기존 PK를 유지하면서 외부 UUID를 추가하는 방식이 안전하다.
```sql
ALTER TABLE feedbacks
ADD COLUMN public_id BINARY(16) NULL,
ADD UNIQUE KEY uk_feedback_public_id (public_id);
```
이후 다음 순서로 전환한다.
1. 모든 기존 행에 UUID v7 `public_id`를 backfill
2. 신규 데이터부터 `public_id`를 동시에 생성
3. API 응답과 URL에 public_id를 선택적으로 추가
4. 외부 클라이언트가 public_id를 사용하도록 전환
5. `source_system + source_record_id` 유일 키 추가
6. 충분한 호환 기간 후 숫자 ID의 외부 노출을 중단
이 방식은 내부 조인과 FK를 그대로 유지하면서도, 향후 API 수평 확장·다중 작성 서버·샤딩에서 UUID를 전역 식별자로 사용할 수 있게 한다.
### 공수 판단
```text
BIGINT PK 유지 낮음
BIGINT PK + UUID public_id 추가 중간
UUID v7을 실제 PK로 전면 전환 높음
```
현재 스테이징 데이터가 약 523건이므로 데이터 backfill 자체는 어렵지 않다. 하지만 UUID 실제 PK 전환의 공수는 데이터 건수보다 Entity, FK, API, 프론트엔드, 테스트와 외부 연동 변경에서 발생한다.
따라서 UUID가 반드시 전역 PK여야 한다는 요구사항이 확정되지 않았다면, `BIGINT 내부 PK + UUID v7 public_id`를 기본 설계로 선택한다.
---
## 외부 식별자 방식 비교
`public_id`는 외부 API, URL, 웹훅, 로그에서 사용하는 식별자다. 내부 DB PK와 달리 다음 기준을 함께 고려해야 한다.
- 여러 서버에서 중앙 조율 없이 생성할 수 있는가
- 시간순 정렬이 가능한가
- URL과 API에서 사용하기 쉬운가
- DB 인덱스에 미치는 영향이 작은가
- 표준과 라이브러리 지원이 충분한가
- 생성 시간이나 순번이 과도하게 노출되지 않는가
| 방식 | 표현 길이 | 시간순 정렬 | 분산 생성 | 운영 복잡도 | 주요 단점 | 적합성 |
|---|---:|:---:|:---:|:---:|---|---|
| UUID v7 | 36자 문자열 / 16바이트 | 가능 | 가능 | 낮음 | 생성 시간이 일부 노출됨 | 가장 균형적 |
| ULID | 26자 문자열 / 16바이트 | 가능 | 가능 | 낮음 | UUID 표준과 별도 사양 | 짧은 URL |
| Snowflake | 약 18~19자리 | 가능 | 가능 | 높음 | Worker ID와 시계 관리 필요 | 초고속 분산 시스템 |
| UUID v4 | 36자 문자열 / 16바이트 | 불가 | 가능 | 낮음 | 랜덤 삽입, 정렬 정보 없음 | 랜덤성과 추측 방지 |
### UUID v7
UUID v7은 IETF RFC 9562에 정의된 표준 UUID 방식이다. 앞부분에 Unix millisecond timestamp를 포함하고 나머지 영역을 랜덤 또는 단조 증가 값으로 사용할 수 있다. [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562.html)
```text
0198f4c0-7a54-7abc-8b1e-9d2a4e5b6c7d
```
장점:
- UUID v4와 동일한 128비트 구조 및 `BINARY(16)` 저장 방식 사용
- UUID v4보다 B-Tree 인덱스의 순차 삽입에 유리
- Worker ID를 별도로 배정하지 않아도 됨
- UUID 표준과 도구를 그대로 활용 가능
- 외부 ID로 사용해도 충돌 가능성이 매우 낮음
주의사항:
- 생성 시간이 밀리초 단위로 일부 노출됨
- 보안 토큰이나 비밀번호 재설정 토큰으로 사용하면 안 됨
- UUID 자체가 접근 권한을 대신하지 않음
### ULID
ULID는 128비트 시간순 식별자이며, 표준 표현이 26자이고 Crockford Base32를 사용한다. UUID 문자열보다 짧고 URL에 넣기 편하다. [ULID 공식 사양](https://github.com/ulid/spec)
```text
01K8A9V4N5J9F28D5G3H1A2B3C
```
UUID v7과 기능적으로 매우 유사하지만, UUID 표준과는 별도의 사양이다. 외부 URL의 길이와 사람이 읽기 쉬운 형태를 가장 중요하게 생각하면 ULID가 더 적합할 수 있다.
### Snowflake
Snowflake는 보통 다음 정보를 조합한 64비트 숫자 ID다.
```text
timestamp + worker ID + sequence
```
인덱스와 FK가 UUID보다 작고 숫자 정렬도 빠르다. 그러나 서버별 Worker ID 할당, 오토스케일링 시 충돌 방지, 시계 역행 처리, JavaScript에서의 큰 정수 직렬화가 필요하다. [X의 Snowflake ID 설명](https://docs.x.com/fundamentals/x-ids)
연간 5만~10만 건 수준의 시스템에서는 저장 공간 절약보다 운영 복잡도가 더 커질 가능성이 높다.
### UUID v4
UUID v4는 랜덤성이 가장 중요한 경우에 적합하다.
```text
c9a646d3-9c61-4cc9-bc19-482386a37365
```
시간 정보가 없으므로 생성 순서로 정렬할 수 없고, 랜덤한 B-Tree 삽입으로 UUID v7보다 인덱스 효율이 떨어질 수 있다. 생성 시점 노출을 피해야 하는 외부 토큰에는 UUID v4가 더 적합할 수 있다.
### 이 시스템의 외부 식별자 선택
현재 시스템에서는 다음 구성이 적절하다.
```text
DB 저장 BINARY(16)
생성 방식 UUID v7
API 응답 표준 UUID 문자열
URL UUID 문자열
화면 표시 축약 UUID + 복사 버튼
내부 조인 BIGINT PK
```
사람이 업무 화면에서 확인할 번호가 필요하다면 UUID를 표시 번호로 사용하지 않고 별도의 업무 번호를 둔다.
```text
외부 식별자: 0198f4c0-7a54-7abc-8b1e-9d2a4e5b6c7d
업무 표시번호: ABC-2026-000123
```
최종 선택 기준:
| 우선순위 | 선택 |
|---|---|
| 표준성·분산성·시간순 정렬의 균형 | UUID v7 |
| 짧은 URL과 가독성 | ULID |
| 초당 수천~수만 건의 분산 숫자 생성 | Snowflake |
| 시간 노출 없는 랜덤성 | UUID v4 |
따라서 이 시스템은 `BIGINT 내부 PK + UUID v7 public_id` 조합을 기본으로 하고, URL 길이가 최우선인 외부 서비스에서만 ULID를 대안으로 검토한다.
---
# 1안. 처음부터 분산 시스템을 고려하는 설계
## 1.1 목표
다음 요구사항이 이미 확정되어 있을 때 선택한다.
- 프로젝트별 작성 서버가 중앙 DB 장애와 독립적으로 ID를 생성해야 함
- 네트워크 단절 후 재시도에도 중복 등록을 방지해야 함
- 향후 여러 DB 샤드 또는 멀티리전 쓰기를 계획하고 있음
- 피드백·댓글·이슈가 서로 다른 저장소에 분산될 수 있음
- 외부 시스템과의 전역 식별자가 필요함
## 1.2 권장 ID 구조
모든 핵심 엔터티의 PK를 UUID v7 기반 `BINARY(16)`으로 통일한다.
```text
feedback.id BINARY(16) PRIMARY KEY
feedback_comment.id BINARY(16) PRIMARY KEY
issue.id BINARY(16) PRIMARY KEY
attachment.id BINARY(16) PRIMARY KEY
```
UUID는 DB에서 생성하지 않고 작성 애플리케이션에서 생성한다.
```text
[RP A 서버] ─┐
[RP B 서버] ─┼─ UUID v7 생성 → 중앙 API → DB 또는 메시지 큐
[RP C 서버] ─┘
```
같은 요청을 재전송할 때 작성 서버가 최초 생성한 UUID를 다시 보내면 중앙 API에서 멱등 처리를 할 수 있다.
## 1.3 권장 테이블 구조
```sql
CREATE TABLE feedbacks (
id BINARY(16) NOT NULL,
channel_id BINARY(16) NOT NULL,
data JSON NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
source_system VARCHAR(32) NULL,
source_record_id VARCHAR(128) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_feedback_source (source_system, source_record_id),
KEY idx_feedback_channel_created (channel_id, created_at DESC, id DESC)
);
```
`source_system``source_record_id`는 외부 RP의 원본 식별자다. UUID가 동일하지 않더라도 같은 원본 요청의 재전송을 차단할 수 있도록 별도 유일 키를 둔다.
## 1.4 서비스 구성
```text
┌─ API 인스턴스 1 ─┐
[RP 서버들] ─────┼─ API 인스턴스 2 ─┼─ [메시지 큐/Outbox] ─ [MySQL Primary]
└─ API 인스턴스 N ─┘ │
├─ [Read Replica]
├─ [OpenSearch]
└─ [Object Storage]
```
구성 원칙:
- API 서버는 상태를 보유하지 않고 수평 확장한다.
- 저장 완료와 알림·검색 색인 작업을 분리한다.
- DB 트랜잭션 안에서 Outbox 이벤트를 저장한 뒤 비동기 발행한다.
- 첨부파일 본문은 DB가 아니라 S3/R2 호환 스토리지에 저장한다.
- 검색과 원본 데이터 저장을 분리한다.
- 샤딩 이후에는 서로 다른 DB 간 Foreign Key를 사용하지 않는다.
## 1.5 장점
- DB에 저장하기 전에도 전역 ID를 발급할 수 있다.
- 네트워크 재시도와 비동기 수집에 유리하다.
- 여러 DB 샤드에 데이터를 나누어도 ID 충돌이 없다.
- 프로젝트 서버가 추가되어도 Worker ID를 배정할 필요가 없다.
- 화면·API·로그에서 같은 식별자를 사용할 수 있다.
## 1.6 단점
- 모든 PK와 FK가 16바이트가 되어 인덱스와 Buffer Pool 사용량이 증가한다.
- TypeORM 엔터티, DTO, 프론트엔드의 `number` 타입을 전반적으로 변경해야 한다.
- UUID 변환 코드와 직렬화 규칙이 필요하다.
- 분산 트랜잭션, 이벤트 중복, 순서 보장 문제를 초기에 해결해야 한다.
- 실제 트래픽이 작으면 운영 복잡도만 증가할 수 있다.
## 1.7 이 안을 선택해야 하는 조건
다음 중 하나라도 출시 전에 확정되어 있다면 1안을 검토한다.
- 초기부터 여러 DB에 쓰기 작업을 분산해야 함
- 모바일·현장·오프라인 환경에서 중앙 DB 없이 글을 생성해야 함
- 리전별 데이터 저장 또는 테넌트별 DB 격리가 필수임
- 초기부터 글로벌 단일 식별자가 외부 계약으로 고정되어야 함
- 향후 PK 변경을 절대로 허용할 수 없음
하드웨어 용량 때문에 1안을 선택하려면 연간 건수가 아니라 예상 피크 부하를 기준으로 판단한다. 부하 테스트에서 단일 Primary의 CPU·I/O·Lock·응답 시간이 목표치를 초과하고, 수직 확장·인덱스·읽기 복제·비동기화로도 해결되지 않는다는 결과가 있어야 한다.
단순히 “언젠가 분산할 수 있다”거나 “연간 5만 건이 쌓인다”는 이유만으로 1안을 선택하는 것은 권장하지 않는다.
---
# 2안. 현재 구조 유지 후 병목 시 분산 시스템 도입
## 2.1 기본 설계
현재 중앙 MySQL 구조를 유지하되, 내부 PK와 외부 식별자를 분리한다.
```text
내부 PK: BIGINT UNSIGNED AUTO_INCREMENT
외부 public_id: UUID v7 BINARY(16)
외부 원본 키: source_system + source_record_id
```
예시:
```sql
CREATE TABLE feedbacks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
public_id BINARY(16) NOT NULL,
channel_id BIGINT UNSIGNED NOT NULL,
data JSON NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
source_system VARCHAR(32) NULL,
source_record_id VARCHAR(128) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_feedback_public_id (public_id),
UNIQUE KEY uk_feedback_source (source_system, source_record_id),
KEY idx_feedback_channel_list (channel_id, created_at DESC, id DESC)
);
```
댓글·첨부파일·이슈 연결 테이블의 내부 FK는 `BIGINT UNSIGNED`로 통일한다.
## 2.2 단계별 도입 순서
### 단계 0. 단일 DB 최적화
- `INT`를 신규 설계부터 `BIGINT UNSIGNED`로 통일한다.
- `feedbacks(channel_id, deleted_at, id)` 복합 인덱스를 추가한다.
- OFFSET 페이지네이션을 Keyset 페이지네이션으로 변경한다.
- JSON에서 자주 검색하는 필드를 일반 컬럼 또는 Generated Column으로 분리한다.
- 목록 조회와 `COUNT(*)`를 분리하거나 count 캐시를 사용한다.
- `%검색어%` 전문 검색은 OpenSearch로 이전한다.
권장 목록 조회:
```sql
SELECT ...
FROM feedbacks
WHERE channel_id = ?
AND deleted_at IS NULL
AND id < ?
ORDER BY id DESC
LIMIT 20;
```
### 단계 1. 읽기 확장
다음 순서로 읽기 부하를 분산한다.
```text
MySQL Primary
├─ Read Replica 1
└─ Read Replica 2
```
- 쓰기·트랜잭션은 Primary로 보낸다.
- 목록·통계·검색용 조회는 Replica 또는 OpenSearch로 보낸다.
- 작성 직후 조회처럼 최신성이 필요한 요청은 Primary를 사용한다.
- Replica 지연을 모니터링한다.
### 단계 2. 애플리케이션 수평 확장
- API 서버를 무상태로 유지한다.
- 세션은 외부 저장소 또는 서명 토큰으로 관리한다.
- Connection Pool을 서버 수에 맞게 제한한다.
- 알림·웹훅·검색 색인은 큐 또는 Outbox로 분리한다.
### 단계 3. 쓰기 부하 분리
Primary 쓰기가 포화되기 시작하면 바로 샤딩하지 않고 다음 작업부터 수행한다.
- 통계 집계를 원본 트랜잭션과 분리한다.
- 알림·웹훅·검색 색인 작업을 비동기화한다.
- 이력·이벤트 테이블을 별도 저장소로 이동한다.
- 오래된 데이터는 보관 DB 또는 파티션으로 이동한다.
- 쓰기 트랜잭션의 Lock 범위와 인덱스를 점검한다.
### 단계 4. 실제 필요 시 샤딩
샤딩 키는 `tenant_id`, `workspace_id`, `project_id` 중 실제 데이터 격리 기준을 선택한다.
```text
Shard Map
tenant/workspace → shard-1 또는 shard-2
```
이때 `public_id`는 샤드와 관계없이 전역 식별자로 유지한다. 샤드 간 JOIN과 Foreign Key는 사용하지 않고, API나 이벤트 계층에서 조회를 조합한다.
## 2.3 장점
- 현재 시스템의 변경 범위가 작다.
- `BIGINT` PK와 FK로 인덱스·조인 성능을 유지한다.
- 실제 병목에 맞춰 읽기·검색·쓰기 분산을 각각 도입할 수 있다.
- UUID v7 `public_id`를 지금 추가하므로 향후 샤딩 시 외부 식별자 변경이 없다.
- 초기 운영과 장애 대응이 단순하다.
## 2.4 단점
- 내부 `BIGINT AUTO_INCREMENT`는 여러 DB에서 독립적으로 발급하기 어렵다.
- 샤딩 시 내부 ID는 전역 식별자로 사용할 수 없다.
- 향후 샤딩 시 DB 간 Foreign Key를 제거해야 한다.
- 처음부터 다중 DB 쓰기가 필수라면 추가 전환 작업이 필요하다.
## 2.5 분산 도입 임계점
다음 조건이 피크 시간에 지속되고, 인덱스·쿼리·캐시·수직 확장으로 해결되지 않을 때 다음 단계로 이동한다.
| 측정 항목 | 검토 기준 |
|---|---:|
| DB CPU | 70~80% 이상 지속 |
| 디스크 I/O 대기 | 10% 이상 |
| 읽기 p95 | 300ms 이상 |
| 쓰기 p95 | 500ms 이상 |
| Connection Pool | 80% 이상 |
| Buffer Pool 적중률 | 99% 미만 |
| Replica 지연 | 5~10초 이상 |
| Lock wait/deadlock | 지속적인 증가 |
| Primary 쓰기 용량 | 부하 테스트 최대치의 70~80% 초과 |
행 수는 참고 지표일 뿐 분산 도입의 직접 기준으로 사용하지 않는다.
---
# PK 자료 유형 비교
| 방식 | 저장 | 중앙 DB 의존 | 분산 선발급 | 인덱스 효율 | 권장 용도 |
|---|---|---:|---:|---:|---|
| `INT AUTO_INCREMENT` | 4바이트 | 높음 | 불가 | 매우 좋음 | 기존 소규모 시스템 |
| `BIGINT UNSIGNED AUTO_INCREMENT` | 8바이트 | 높음 | 불가 | 매우 좋음 | 현재 중앙 DB 내부 PK |
| UUID v4 `BINARY(16)` | 16바이트 | 없음 | 가능 | 보통 | 보안 우선 식별자 |
| UUID v7 `BINARY(16)` | 16바이트 | 없음 | 가능 | 좋음 | 분산·외부 식별자 |
| ULID | 16바이트 또는 문자열 | 없음 | 가능 | 좋음 | UUID v7 대안 |
| Snowflake `BIGINT` | 8바이트 | 없음 | 가능 | 매우 좋음 | 초고속 분산 숫자 ID |
| `CHAR(36)` UUID | 36바이트 | 없음 | 가능 | 낮음 | 비권장 |
`UUID v4`, `UUID v7`, `ULID`를 사용하더라도 문자열로 저장하지 말고 가능하면 `BINARY(16)`으로 저장한다.
---
# 두 안의 최종 선택 기준
## 1안을 선택하는 경우
```text
초기부터 다중 DB 쓰기
또는 오프라인 생성
또는 멀티리전 저장
또는 전역 UUID PK가 외부 계약으로 확정
```
이 경우 `UUID v7 BINARY(16)`을 실제 PK로 사용한다.
## 2안을 선택하는 경우
```text
현재는 중앙 MySQL
쓰기량이 크지 않음
운영 복잡도를 낮춰야 함
향후 분산 가능성만 있음
```
이 경우 `BIGINT UNSIGNED`를 내부 PK로 사용하고 `UUID v7 BINARY(16)`을 외부 식별자로 추가한다.
## 최종 추천
현재 시스템에는 2안을 적용한다.
```text
feedbacks.id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
feedbacks.public_id BINARY(16) UNIQUE NOT NULL
feedbacks.source_system
feedbacks.source_record_id
UNIQUE(source_system, source_record_id)
```
이 설계는 현재의 인덱스·조인 성능을 유지하면서도, 향후 API 수평 확장·Read Replica·OpenSearch·Outbox·샤딩으로 확장할 수 있는 경로를 확보한다.
1안은 분산 요구사항이 기술적 가능성이 아니라 **출시 전 확정된 필수 요구사항**일 때 선택한다.