This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
# 분산 시스템 도입 시점 및 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안은 분산 요구사항이 기술적 가능성이 아니라 **출시 전 확정된 필수 요구사항**일 때 선택한다.
|
||||
Reference in New Issue
Block a user