# 다중 프로젝트 피드백 플랫폼의 ID 및 성능 설계 ## 1. 현재 시스템 구조 현재 구조는 여러 프로젝트의 작성 서버가 하나의 중앙 관리 서버로 데이터를 보내는 형태임. ```text [프로젝트 A 작성 서버] ─┐ [프로젝트 B 작성 서버] ─┼─ API ─▶ [중앙 관리 서버] ─▶ [중앙 MySQL] [프로젝트 C 작성 서버] ─┘ ``` - 피드백 작성 페이지 서버는 프로젝트마다 별도로 운영함 - 각 작성 서버가 중앙 관리 API를 호출함 - 중앙 관리 서버가 피드백을 한곳에 저장하고 관리함 - 향후 외부 Q&A 서버도 같은 API로 연결할 수 있음 이 구조는 여러 서버가 데이터를 만들고 하나의 서버가 모아 관리하는 **다중 발행자·단일 집계 구조**임. ## 2. 이 구조에서 먼저 해결해야 할 문제 ### 2.1 재시도에 따른 중복 등록 다음과 같은 상황이 발생할 수 있음. ```text 1. 프로젝트 A가 중앙 API로 글을 전송함 2. 중앙 서버는 저장했지만 응답이 네트워크 지연으로 늦어짐 3. 프로젝트 A는 실패로 판단하고 같은 글을 다시 전송함 4. 중앙 DB가 매번 새 Auto Increment 번호를 발급하면 같은 글이 2건 저장됨 ``` 따라서 글을 작성하는 서버가 중앙 DB에 보내기 전에 고유 ID를 먼저 만들고, 재시도할 때도 같은 ID를 사용해야 함. ```text 같은 글 + 같은 ID = 같은 요청으로 판단 ``` 중앙 API와 DB에는 해당 ID를 기본 키 또는 유일 키로 설정해 중복 저장을 막아야 함. 이를 멱등성(Idempotency)이라고 함. ### 2.2 중앙 번호 발급에 대한 의존 Auto Increment를 사용하면 중앙 DB가 저장하면서 번호를 발급함. - 작성 서버는 저장이 끝나야 글 번호를 알 수 있음 - 여러 프로젝트에서 동시에 등록하면 중앙 DB에 요청이 집중됨 - 중앙 DB 장애 또는 네트워크 장애가 발생하면 번호 발급도 지연됨 - 비동기 전송이나 재시도 처리가 복잡해짐 ### 2.3 프로젝트 구분 중앙 관리페이지에는 여러 프로젝트의 글이 함께 보이므로 숫자만 표시하면 어느 프로젝트에서 온 글인지 바로 알기 어려움. 예를 들어 다음과 같이 구분할 수 있음. ```text PRJA_01K8A9V4N5J9F28D5G3H1A2B3C PRJB_01K8A9V4N5J9F28D5G3H1A2B3D ``` 단, 화면 표시 ID와 DB에 저장된 식별자를 반드시 같게 유지하려면 위의 전체 문자열을 그대로 저장하는 방식과, DB에는 별도 값을 저장하고 화면에서 조합하는 방식을 구분해야 함. ## 3. 식별자 선택 시 확인할 기준 | 기준 | 확인할 내용 | |---|---| | 중복 방지 | 네트워크 재시도에도 같은 글로 인식되는지 | | 선발급 | 중앙 DB 저장 전에 작성 서버에서 ID를 만들 수 있는지 | | 분산 생성 | 여러 프로젝트 서버가 동시에 만들어도 충돌하지 않는지 | | 정렬 성능 | ID가 시간 순으로 생성되어 B-Tree에 유리한지 | | 저장 크기 | PK와 외래키 인덱스가 과도하게 커지지 않는지 | | 화면 가독성 | 사람이 읽고 프로젝트를 구분하기 쉬운지 | | 일관성 | DB ID, API ID, URL ID, 화면 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)` | 랜덤 입력으로 페이지 분할 가능 | 생성이 쉽고 추측이 어려움 | 인덱스가 크고 랜덤 삽입으로 쓰기 효율이 낮아질 수 있음 | 보안상 추측 방지가 최우선인 경우 | ### 대략적인 저장 크기 1,000만 건의 단일 PK 인덱스를 기준으로 한 대략적인 비교임. 실제 크기는 PK 외래키, 보조 인덱스, 페이지 여유 공간에 따라 달라짐. | 방식 | 대략적인 PK 인덱스 크기 | |---|---:| | `INT` | 약 200MB | | `BIGINT` | 약 400MB | | `BINARY(16)` | 약 800MB 이상 | | `VARCHAR(36)` UUID | 약 1~2GB 이상 | ## 5. ULID와 Snowflake 비교 두 방식 모두 중앙 DB에 저장하기 전에 각 작성 서버에서 ID를 만들 수 있음. | 비교 항목 | ULID / UUID v7 | Snowflake | |---|---|---| | 저장 크기 | 16바이트 중심 | 8바이트 | | 시간 순 정렬 | 가능 | 가능 | | 서버 선발급 | 가능 | 가능 | | 서버별 설정 | 거의 없음 | Worker ID 필수 | | 시계 오차 영향 | 상대적으로 낮음 | 민감함 | | 숫자 가독성 | 낮음 | 숫자지만 긴 숫자임 | | 운영 난이도 | 낮음 | 높음 | | 현재 구조 적합성 | 높음 | 조건부 적합 | ### Snowflake가 동작하는 방식 Snowflake는 다음 정보를 조합해 64비트 숫자를 만듦. ```text 시간 정보 + 서버(Worker) ID + 같은 시간 안의 순번 ``` 각 프로젝트 서버마다 서로 다른 Worker ID를 배정해야 함. ```text 프로젝트 A 서버: Worker ID 1 프로젝트 B 서버: Worker ID 2 프로젝트 C 서버: Worker ID 3 ``` 서버가 늘어나거나 컨테이너가 추가될 때마다 번호가 겹치지 않도록 관리해야 함. Worker ID가 중복되면 ID 충돌이 발생할 수 있음. 서버 시간이 뒤로 이동하면 일부 구현에서는 ID 발급을 중단할 수도 있으므로 시간 동기화도 필요함. ### ULID가 동작하는 방식 ULID는 현재 시간과 무작위 값을 조합함. ```text 시간 정보 + 충돌 가능성이 매우 낮은 무작위 값 ``` 각 프로젝트 서버에 번호를 따로 배정할 필요가 없음. 여러 서버가 동시에 생성해도 충돌 가능성이 매우 낮고, PK 유일 제약으로 최종 중복도 차단할 수 있음. ## 6. 현재 구조에 맞는 3가지 케이스 ### Case 1. BIGINT PK + 프로젝트 코드 별도 표시 #### 구성 ```text DB ID: 10492 프로젝트: ABC 채널: WEB 화면 표시: ABC-WEB-10492 ``` - DB: `id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY` - 프로젝트와 채널은 별도 컬럼으로 저장 - 화면과 알림에서 프로젝트 코드와 숫자를 조합 #### 장점 - 정수 PK와 외래키로 인덱스가 작음 - 중앙 DB에서 구현하기 쉬움 - 기존 구조를 가장 적게 변경함 - Buffer Pool에 데이터와 인덱스를 상주시킬 수 있음 #### 주의점 - 작성 서버가 글을 전송하기 전에 중앙 DB의 ID를 알 수 없음 - 네트워크 재시도 중복을 막으려면 별도의 Idempotency Key가 필요함 - 화면의 `ABC-WEB-10492`와 DB의 `10492`가 달라질 수 있음 #### 적합한 경우 - 중앙 DB 저장 완료 후 ID를 받아도 되는 경우 - 기존 시스템 변경을 최소화해야 하는 경우 - 화면 ID와 실제 DB ID가 반드시 같을 필요가 없는 경우 ### Case 2. ULID 또는 UUID v7을 실제 글 ID로 사용 #### 구성 화면 ID와 API ID를 동일하게 유지하려면 전체 식별자를 DB에 그대로 저장함. ```text DB ID / API ID / 화면 ID: PRJA_01K8A9V4N5J9F28D5G3H1A2B3C ``` 가능한 저장 방식은 두 가지임. | 방식 | 설명 | |---|---| | 전체 문자열 저장 | 접두사와 ULID를 `VARCHAR`에 저장해 화면 ID와 DB ID를 동일하게 유지함 | | 분리 저장 | 프로젝트 코드와 ULID를 컬럼으로 나누고, 화면에서 다시 조합함 | 이번 요구처럼 글 ID와 표시 ID를 일치시키려면 **전체 문자열 저장**이 더 명확함. 이 경우 문자열 PK의 인덱스 크기를 감수해야 함. #### 등록 흐름 ```text 1. 프로젝트 작성 서버가 ID를 먼저 생성함 2. 글 데이터와 ID를 중앙 API로 전송함 3. 중앙 DB가 ID를 PK 또는 UNIQUE KEY로 확인함 4. 같은 ID의 재요청이면 중복 저장하지 않음 5. 중앙 관리페이지는 전달받은 ID를 그대로 표시함 ``` #### 장점 - 프로젝트별 작성 서버가 독립적으로 ID를 선발급할 수 있음 - 타임아웃 후 재시도해도 같은 글로 처리할 수 있음 - 중앙 번호 발급을 기다리지 않아 비동기 전송이 쉬움 - 시간 정보가 앞에 있어 B-Tree 인덱스가 UUID v4보다 유리함 - 별도의 Worker ID 관리가 필요 없음 #### 단점 - `BIGINT`보다 PK와 외래키 인덱스가 큼 - 사람이 숫자 하나로 기억하거나 구두 전달하기 어려움 - 프로젝트 접두사를 포함하면 ID가 더 길어짐 #### 적합한 경우 - 프로젝트별 작성 서버가 완전히 분리되어 있음 - 중앙 API로 여러 서버의 데이터를 수집함 - 글 ID와 화면 표시 ID를 일치시켜야 함 - 재시도 중복과 비동기 전송을 안정적으로 처리해야 함 ### Case 3. Snowflake 숫자 ID + 프로젝트 정보 별도 저장 #### 구성 ```text DB ID: 178293849182394880 프로젝트: ABC 화면 표시: 178293849182394880 ``` - `id BIGINT UNSIGNED PRIMARY KEY` - 프로젝트 코드는 별도 컬럼으로 저장 - 각 작성 서버에 고유 Worker ID를 배정함 - 모든 서버의 시계를 동기화함 #### 장점 - 화면과 DB에서 동일한 숫자 ID를 사용할 수 있음 - ULID보다 인덱스와 외래키 크기가 작음 - 여러 서버에서 중앙 DB 의존 없이 ID를 선발급할 수 있음 - 시간 순 정렬이 가능함 #### 단점 - 프로젝트와 서버별 Worker ID 관리가 필요함 - 오토스케일링과 서버 추가 시 번호 배정 정책이 필요함 - Worker ID 중복 여부를 관리해야 함 - 시계가 뒤로 이동하는 상황에 대한 처리 필요 - 현재 등록량에서는 운영 복잡도가 성능 이점보다 클 수 있음 #### 적합한 경우 - 숫자 ID가 반드시 필요함 - 초당 수천~수만 건의 등록이 발생함 - 서버와 Worker ID를 중앙에서 안정적으로 관리할 수 있음 ## 7. 선택 결론 현재 구조에서는 **Case 2: ULID 또는 UUID v7을 실제 글 ID로 사용하는 방식**이 가장 자연스러움. 이유는 다음과 같음. 1. 프로젝트별 작성 서버가 중앙 DB에 의존하지 않고 ID를 먼저 만들 수 있음 2. 네트워크 타임아웃과 재시도에 따른 중복 등록을 막기 쉬움 3. 프로젝트 서버가 늘어나도 Worker ID를 따로 배정할 필요가 없음 4. 시간 순 ID라 UUID v4보다 인덱스 페이지 분할에 유리함 5. 전체 ID를 DB에 그대로 저장하면 화면 ID와 API ID를 일치시킬 수 있음 다만 숫자 ID가 반드시 필요하다면 Case 3을 선택할 수 있음. 이 경우 ULID보다 저장 공간은 작지만 Worker ID와 서버 시계 관리가 필수임. ## 8. 일상적인 비유 ### Snowflake 각 지점에 번호표 기계를 설치하고 지점 번호를 미리 배정하는 방식임. - 강남점은 1번 - 판교점은 2번 - 여의도점은 3번 지점 번호가 겹치지 않으면 아주 작은 숫자 번호표를 빠르게 만들 수 있음. 하지만 지점이 늘어날 때마다 번호를 관리해야 하고, 지점 시계가 맞지 않으면 번호표 발급에 문제가 생길 수 있음. ### ULID 각 지점이 별도 등록 없이 앱을 실행해 바로 고유 번호표를 만드는 방식임. - 지점 번호를 따로 배정하지 않아도 됨 - 여러 지점에서 동시에 만들어도 충돌 가능성이 매우 낮음 - 네트워크가 끊겨 같은 번호표를 다시 보내도 중앙에서 중복을 확인할 수 있음 - 번호표가 시간 순으로 만들어져 정리하기 쉬움 현재처럼 프로젝트별 작성 서버가 여러 곳에 나뉘어 있다면, 관리해야 할 설정이 적은 ULID가 운영 측면에서 유리함. ## 9. 피드백 테이블 인덱스 설계 ### 주요 조회 조건 ```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); ``` ## 10. 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이 데이터와 인덱스보다 충분히 크면 자주 조회되는 페이지가 메모리에 상주할 수 있음. 다만 “항상 100% 상주” 또는 “항상 1ms 이하”라고 단정할 수는 없으며, 서버 메모리와 실행 계획을 확인해야 함. ### Redis 애플리케이션 캐시 ```text [클라이언트] │ ▼ [Redis 캐시] ── HIT ──▶ 캐시 결과 반환 │ MISS ▼ [중앙 API] ──▶ [MySQL] ──▶ Redis 갱신 ``` 우선 캐싱할 대상: - 프로젝트·채널별 최신 피드백 목록 - 각 목록의 첫 페이지 - 전체 피드백 수와 상태별 개수 - 답변 대기·미확인 요약 정보 캐시 키 예시: ```text feedback:project:{project_id}:channel:{channel_id}:page:1 ``` 새 글 등록, 수정, 삭제가 발생하면 관련 캐시를 삭제하거나 갱신해야 함. | 방식 | 설명 | |---|---| | 즉시 삭제 | 변경 시 관련 캐시를 바로 삭제함 | | 짧은 TTL | 30초~1분 후 자동 만료함 | | 조합 | 첫 페이지는 즉시 삭제하고 나머지는 TTL을 사용함 | ## 11. 성능 점검 방법 인덱스를 적용하기 전에 실제 실행 계획을 확인해야 함. ```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를 함께 비교해야 함.