# 다중 프로젝트 피드백 플랫폼의 ID 및 성능 설계 ## 핵심 결론 현재 시스템은 프로젝트마다 작성 서버가 따로 있고, 각 서버가 중앙 관리 API를 통해 하나의 DB에 글을 저장하는 구조임. 이 구조에서는 중앙 DB가 번호를 발급하기를 기다리는 방식보다, **각 작성 서버가 글 ID를 먼저 만들고 중앙 API로 보내는 방식**이 적합함. ### 최종 권장안 1. **1순위: ULID 또는 UUID v7 선발급** - 프로젝트 작성 서버에서 ID를 먼저 생성함 - 화면 ID, API ID, DB ID를 동일하게 사용할 수 있음 - 네트워크 재시도에도 같은 글로 판단해 중복 등록을 막을 수 있음 - 여러 서버에 별도 Worker 번호를 배정하지 않아도 됨 2. **숫자 ID가 반드시 필요할 때: Snowflake** - 여러 서버에서 숫자 ID를 선발급할 수 있음 - ULID보다 저장 공간이 작음 - 대신 Worker ID와 서버 시간 동기화 관리가 필요함 3. **기존 구조를 가장 적게 바꿀 때: BIGINT Auto Increment** - 구현과 인덱스 성능은 가장 단순함 - 중앙 DB 의존성과 재시도 중복을 별도로 해결해야 함 - 화면 ID와 DB ID가 달라질 수 있음 ### 선택 기준 한눈에 보기 | 우선순위 | 선택 | |---|---| | 여러 작성 서버의 독립성, 재시도 중복 방지, ID 일치 | ULID / UUID v7 | | 숫자 ID, 분산 선발급, 높은 쓰기량 | Snowflake | | 변경 최소화, 단일 DB, 중앙 저장 완료 후 ID 발급 | BIGINT Auto Increment | --- ## 1. 현재 시스템 구조 ```text [프로젝트 A 작성 서버] ─┐ [프로젝트 B 작성 서버] ─┼─ API ─▶ [중앙 관리 서버] ─▶ [중앙 MySQL] [프로젝트 C 작성 서버] ─┘ ``` - 피드백 작성 페이지 서버는 프로젝트마다 별도로 운영함 - 각 작성 서버가 중앙 관리 API를 호출함 - 중앙 관리 서버가 피드백을 한곳에 저장하고 관리함 - 향후 외부 Q&A 서버도 같은 API로 연결할 수 있음 여러 서버가 데이터를 만들고 하나의 서버가 모아 관리하는 **다중 발행자·단일 집계 구조**임. ## 2. 현재 구조에서 발생하는 문제 ### 2.1 네트워크 재시도에 따른 중복 등록 ```text 1. 프로젝트 A가 중앙 API로 글을 전송함 2. 중앙 서버는 저장했지만 응답이 네트워크 지연으로 늦어짐 3. 프로젝트 A는 실패로 판단하고 같은 글을 다시 전송함 4. 중앙 DB가 매번 새 번호를 발급하면 같은 글이 2건 저장됨 ``` 작성 서버가 ID를 먼저 만들고 재시도할 때 같은 ID를 사용하면 중복을 막을 수 있음. ```text 같은 글 + 같은 ID = 같은 요청 ``` 중앙 API와 DB에는 해당 ID를 PK 또는 유일 키로 설정해야 함. 이를 멱등성(Idempotency)이라고 함. ### 2.2 중앙 DB 번호 발급 의존 Auto Increment 방식에서는 중앙 DB가 저장하면서 번호를 발급함. - 작성 서버는 저장이 끝나야 글 번호를 알 수 있음 - 여러 프로젝트의 등록 요청이 중앙 DB에 집중됨 - 중앙 DB 또는 네트워크 장애가 번호 발급과 저장에 함께 영향을 줌 - 비동기 전송과 재시도 처리가 복잡해짐 ### 2.3 프로젝트 구분 중앙 목록에서 숫자만 표시하면 출처 프로젝트를 바로 알기 어려움. ```text PRJA_01K8A9V4N5J9F28D5G3H1A2B3C PRJB_01K8A9V4N5J9F28D5G3H1A2B3D ``` 화면 ID와 DB ID를 일치시키려면 위의 전체 식별자를 DB에 그대로 저장해야 함. 프로젝트 코드와 ULID를 분리 저장한 뒤 화면에서 조합하면 화면 ID와 DB ID가 달라질 수 있음. ## 3. 식별자 선택 기준 | 기준 | 확인할 내용 | |---|---| | 중복 방지 | 네트워크 재시도에도 같은 글로 인식되는지 | | 선발급 | 중앙 DB 저장 전에 작성 서버에서 ID를 만들 수 있는지 | | 분산 생성 | 여러 서버가 동시에 만들어도 충돌하지 않는지 | | 정렬 성능 | 시간 순 생성으로 B-Tree 인덱스에 유리한지 | | 저장 크기 | PK와 외래키 인덱스가 과도하게 커지지 않는지 | | 화면 가독성 | 사람이 읽고 프로젝트를 구분하기 쉬운지 | | 일관성 | DB, API, URL, 화면 ID가 같은지 | | 운영 난이도 | 서버별 추가 설정과 관리가 필요한지 | ## 4. 식별자 체계 7가지 비교 | 번호 | 방식 | 저장 스펙 | 인덱스 특성 | 장점 | 단점 | 적합한 환경 | |---:|---|---|---|---|---|---| | 1 | `INT AUTO_INCREMENT` | `INT` 4바이트 | 작고 빠름 | 구현이 가장 쉬움, 메모리 효율이 좋음 | 약 21억 개 한계, 중앙 DB 의존, 프로젝트 구분 어려움 | 소규모 단일 서버 | | 2 | `BIGINT AUTO_INCREMENT` | `BIGINT` 8바이트 | 순차 입력에 유리 | 번호 범위가 매우 큼, 구현이 쉬움 | 중앙 DB에서 번호 발급, 프로젝트 구분 어려움 | 단일 DB 기반 대용량 시스템 | | 3 | `BIGINT + 프로젝트 코드 조합` | DB는 `BIGINT` | 정수 인덱스 유지 | 저장 성능과 화면 식별성을 함께 확보 | 실제 ID와 화면 조합 ID가 달라질 수 있음 | 중앙 DB 구조와 운영 편의성을 함께 중시하는 경우 | | 4 | 비즈니스 문자열 직접 저장 | `VARCHAR` | 정수보다 큼 | DB만 봐도 프로젝트와 글을 구분 가능 | 문자열 인덱스 증가, 동시 채번 문제 | 트래픽이 적고 가독성이 중요한 경우 | | 5 | `ULID / UUID v7` | `BINARY(16)` 또는 문자열 | 시간 순 입력에 유리 | 서버별 선발급, 분산 생성, 재시도 멱등성에 적합 | 정수보다 크고 사람이 읽기 어려움 | 여러 프로젝트 서버가 API로 전송하는 구조 | | 6 | Snowflake | `BIGINT` 8바이트 | 시간 순 입력에 유리 | 분산 서버에서 숫자 ID를 선발급할 수 있음 | Worker ID와 시계 동기화 관리 필요 | 매우 높은 쓰기량의 분산 시스템 | | 7 | UUID v4 | UUID 또는 `VARCHAR(36)` | 랜덤 입력으로 페이지 분할 가능 | 생성이 쉽고 추측이 어려움 | 인덱스가 크고 랜덤 삽입으로 쓰기 효율이 낮아질 수 있음 | 보안상 추측 방지가 최우선인 경우 | ### 대략적인 PK 인덱스 크기 1,000만 건 기준의 대략적인 비교임. 실제 크기는 외래키, 보조 인덱스, 페이지 여유 공간에 따라 달라짐. | 방식 | 대략적인 크기 | |---|---:| | `INT` | 약 200MB | | `BIGINT` | 약 400MB | | `BINARY(16)` | 약 800MB 이상 | | `VARCHAR(36)` UUID | 약 1~2GB 이상 | ## 5. 현재 시스템에 맞는 3가지 케이스 ### Case 1. ULID 또는 UUID v7을 실제 글 ID로 사용 #### 구성 ```text DB ID / API ID / 화면 ID: PRJA_01K8A9V4N5J9F28D5G3H1A2B3C ``` - 각 프로젝트 작성 서버가 ID를 먼저 생성함 - 글 데이터와 ID를 중앙 API로 전송함 - 중앙 DB는 ID를 PK 또는 UNIQUE KEY로 확인함 - 동일 ID의 재요청은 중복 저장하지 않음 - 관리페이지는 전달받은 ID를 그대로 표시함 #### 장점 - 프로젝트 서버가 중앙 DB에 의존하지 않고 ID를 선발급할 수 있음 - 네트워크 타임아웃 후 재시도해도 중복 등록을 막기 쉬움 - 시간 순 ID라 UUID v4보다 B-Tree 인덱스에 유리함 - 서버별 Worker ID를 배정할 필요가 없음 - 화면 ID와 DB ID를 동일하게 유지할 수 있음 #### 단점 - `BIGINT`보다 PK와 외래키 인덱스가 큼 - 사람이 숫자 하나로 기억하거나 구두 전달하기 어려움 - 프로젝트 접두사를 포함하면 ID가 더 길어짐 #### 권장 저장 방식 화면 ID와 DB ID를 완전히 일치시켜야 하면 접두사와 ULID를 합친 전체 문자열을 `VARCHAR`에 저장함. ```text PRJA_01K8A9V4N5J9F28D5G3H1A2B3C ``` DB 용량을 줄이기 위해 ULID를 `BINARY(16)`으로 분리 저장하면 화면에서 조합한 값과 DB의 실제 값이 달라질 수 있음. ### Case 2. Snowflake 숫자 ID 사용 #### 구성 ```text DB ID / API ID / 화면 ID: 178293849182394880 ``` Snowflake는 다음 값을 조합해 64비트 숫자를 생성함. ```text 시간 정보 + Worker ID + 같은 시간 안의 순번 ``` #### 장점 - 여러 서버에서 중앙 DB 의존 없이 숫자 ID를 선발급할 수 있음 - ULID보다 PK와 외래키 인덱스가 작음 - 시간 순 정렬이 가능함 - 화면 ID와 DB ID를 동일하게 유지할 수 있음 #### 단점 - 프로젝트와 서버마다 고유 Worker ID를 배정해야 함 - 서버가 늘어나거나 컨테이너가 추가될 때 번호 관리가 필요함 - Worker ID가 중복되면 ID 충돌이 발생함 - 서버 시간 오차를 관리해야 함 - 현재 등록량에서는 운영 복잡도가 성능 이점보다 클 수 있음 ### Case 3. BIGINT Auto Increment + 프로젝트 코드 별도 표시 #### 구성 ```text DB ID: 10492 프로젝트: ABC 채널: WEB 화면 표시: ABC-WEB-10492 ``` - DB: `id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY` - 프로젝트와 채널은 별도 컬럼으로 저장함 - 화면과 알림에서 프로젝트 코드와 숫자를 조합함 #### 장점 - 정수 PK와 외래키로 인덱스가 작음 - 기존 구조를 가장 적게 변경함 - 중앙 DB에서 구현하기 쉬움 - 순차 입력으로 B-Tree 페이지 분할이 적음 #### 단점 - 중앙 DB에 저장되어야 ID를 알 수 있음 - 재시도 중복을 막으려면 별도의 Idempotency Key가 필요함 - 화면의 `ABC-WEB-10492`와 DB의 `10492`가 다름 - 프로젝트별 번호가 1번부터 시작하지 않음 ## 6. ULID와 Snowflake를 쉽게 비교 ### Snowflake 각 지점에 번호표 기계를 설치하고 지점 번호를 미리 배정하는 방식임. - 프로젝트 A 서버는 Worker ID 1 - 프로젝트 B 서버는 Worker ID 2 - 프로젝트 C 서버는 Worker ID 3 번호표가 작고 빠르지만 서버가 늘어날 때마다 번호를 관리해야 함. 서버 시계가 맞지 않으면 번호 발급에 문제가 생길 수 있음. ### ULID 각 지점이 별도 등록 없이 앱을 실행해 고유 번호표를 만드는 방식임. - Worker ID를 따로 배정하지 않아도 됨 - 여러 서버에서 동시에 생성해도 충돌 가능성이 매우 낮음 - 네트워크가 끊겨 같은 글을 다시 보내도 같은 ID로 중복을 확인할 수 있음 - 시간 순으로 생성되어 DB에 정리하기 쉬움 현재처럼 프로젝트별 작성 서버가 여러 곳에 나뉘어 있으면 관리할 설정이 적은 ULID가 운영 측면에서 유리함. ## 7. 피드백 테이블 인덱스 설계 ### 주요 조회 조건 ```sql WHERE channel_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 20 ``` ### 기본 후보 ```sql CREATE INDEX idx_feedbacks_channel_active_list ON feedbacks (channel_id, deleted_at, id DESC); ``` `deleted_at`을 포함한 이유는 소프트 삭제된 글을 목록에서 제외하는 조건까지 인덱스 탐색 범위에 포함하기 위해서임. 다만 삭제된 글이 거의 없으면 다음 인덱스가 더 작고 효율적일 수도 있음. ```sql CREATE INDEX idx_feedbacks_channel_list ON feedbacks (channel_id, id DESC); ``` 두 방식 중 어느 쪽이 좋은지는 삭제 데이터 비율과 실제 실행 계획으로 확인해야 함. ### 미확인 피드백 조회 ```sql CREATE INDEX idx_feedbacks_unread ON feedbacks (channel_id, admin_first_read_at, deleted_at); ``` ### JSON 내부 값 조회 `data` 내부의 `category`를 자주 검색한다면 생성 컬럼과 인덱스를 구성할 수 있음. ```sql ALTER TABLE feedbacks ADD COLUMN category VARCHAR(50) GENERATED ALWAYS AS (data->>'$.category') VIRTUAL; ``` ```sql CREATE INDEX idx_feedbacks_channel_category ON feedbacks (channel_id, category, id DESC); ``` ## 8. 5만 건 기준 메모리와 캐시 현재 523행의 데이터 용량이 약 `528.0 KiB`라면 다음과 같이 추정할 수 있음. | 항목 | 예상 크기 | |---|---:| | 5만 건 데이터 | 약 50~70MB | | 복합 인덱스 2~3개 | 약 4~6MB | | 데이터와 인덱스 합계 | 약 80MB 미만 | 실제 크기는 JSON 또는 TEXT 데이터 길이, 인덱스 자료형, 보조 인덱스 수에 따라 달라짐. ### InnoDB Buffer Pool ```text [MySQL 데이터와 인덱스] │ ▼ [InnoDB Buffer Pool 메모리 상주] │ ▼ [반복 조회 시 디스크 접근 감소] ``` Buffer Pool이 데이터와 인덱스보다 충분히 크면 자주 조회되는 페이지가 메모리에 상주할 수 있음. 다만 실제 상주율과 응답 시간은 서버 메모리, 설정, 실행 계획, 동시 접속자 수에 따라 달라짐. ### Redis 애플리케이션 캐시 ```text [클라이언트] │ ▼ [Redis 캐시] ── HIT ──▶ 캐시 결과 반환 │ MISS ▼ [중앙 API] ──▶ [MySQL] ──▶ Redis 갱신 ``` 우선 캐싱할 대상: - 프로젝트·채널별 최신 피드백 목록 - 각 목록의 첫 페이지 - 전체 피드백 수와 상태별 개수 - 답변 대기·미확인 요약 정보 캐시 키 예시: ```text feedback:project:{project_id}:channel:{channel_id}:page:1 ``` 새 글 등록, 수정, 삭제가 발생하면 관련 캐시를 삭제하거나 갱신해야 함. | 방식 | 설명 | |---|---| | 즉시 삭제 | 변경 시 관련 캐시를 바로 삭제함 | | 짧은 TTL | 30초~1분 후 자동 만료함 | | 조합 | 첫 페이지는 즉시 삭제하고 나머지는 TTL을 사용함 | ## 9. 성능 점검 방법 인덱스를 적용하기 전에 실제 실행 계획을 확인해야 함. ```sql EXPLAIN ANALYZE SELECT * FROM feedbacks WHERE channel_id = 1 AND deleted_at IS NULL ORDER BY id DESC LIMIT 20; ``` 다음 항목을 확인함. - 예상한 인덱스가 선택되는지 - `key`에 복합 인덱스가 표시되는지 - `rows`가 과도하게 많지 않은지 - 전체 테이블 스캔이 발생하지 않는지 - `Using filesort` 또는 `Using temporary`가 발생하는지 인덱스 적용 전후의 실행 시간, 읽은 행 수, DB CPU와 디스크 I/O를 함께 비교해야 함.