16 KiB
다중 프로젝트 피드백 플랫폼의 ID 및 성능 설계
1. 현재 시스템 구조
현재 구조는 여러 프로젝트의 작성 서버가 하나의 중앙 관리 서버로 데이터를 보내는 형태임.
[프로젝트 A 작성 서버] ─┐
[프로젝트 B 작성 서버] ─┼─ API ─▶ [중앙 관리 서버] ─▶ [중앙 MySQL]
[프로젝트 C 작성 서버] ─┘
- 피드백 작성 페이지 서버는 프로젝트마다 별도로 운영함
- 각 작성 서버가 중앙 관리 API를 호출함
- 중앙 관리 서버가 피드백을 한곳에 저장하고 관리함
- 향후 외부 Q&A 서버도 같은 API로 연결할 수 있음
이 구조는 여러 서버가 데이터를 만들고 하나의 서버가 모아 관리하는 다중 발행자·단일 집계 구조임.
2. 이 구조에서 먼저 해결해야 할 문제
2.1 재시도에 따른 중복 등록
다음과 같은 상황이 발생할 수 있음.
1. 프로젝트 A가 중앙 API로 글을 전송함
2. 중앙 서버는 저장했지만 응답이 네트워크 지연으로 늦어짐
3. 프로젝트 A는 실패로 판단하고 같은 글을 다시 전송함
4. 중앙 DB가 매번 새 Auto Increment 번호를 발급하면 같은 글이 2건 저장됨
따라서 글을 작성하는 서버가 중앙 DB에 보내기 전에 고유 ID를 먼저 만들고, 재시도할 때도 같은 ID를 사용해야 함.
같은 글 + 같은 ID = 같은 요청으로 판단
중앙 API와 DB에는 해당 ID를 기본 키 또는 유일 키로 설정해 중복 저장을 막아야 함. 이를 멱등성(Idempotency)이라고 함.
2.2 중앙 번호 발급에 대한 의존
Auto Increment를 사용하면 중앙 DB가 저장하면서 번호를 발급함.
- 작성 서버는 저장이 끝나야 글 번호를 알 수 있음
- 여러 프로젝트에서 동시에 등록하면 중앙 DB에 요청이 집중됨
- 중앙 DB 장애 또는 네트워크 장애가 발생하면 번호 발급도 지연됨
- 비동기 전송이나 재시도 처리가 복잡해짐
2.3 프로젝트 구분
중앙 관리페이지에는 여러 프로젝트의 글이 함께 보이므로 숫자만 표시하면 어느 프로젝트에서 온 글인지 바로 알기 어려움.
예를 들어 다음과 같이 구분할 수 있음.
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비트 숫자를 만듦.
시간 정보 + 서버(Worker) ID + 같은 시간 안의 순번
각 프로젝트 서버마다 서로 다른 Worker ID를 배정해야 함.
프로젝트 A 서버: Worker ID 1
프로젝트 B 서버: Worker ID 2
프로젝트 C 서버: Worker ID 3
서버가 늘어나거나 컨테이너가 추가될 때마다 번호가 겹치지 않도록 관리해야 함. Worker ID가 중복되면 ID 충돌이 발생할 수 있음. 서버 시간이 뒤로 이동하면 일부 구현에서는 ID 발급을 중단할 수도 있으므로 시간 동기화도 필요함.
ULID가 동작하는 방식
ULID는 현재 시간과 무작위 값을 조합함.
시간 정보 + 충돌 가능성이 매우 낮은 무작위 값
각 프로젝트 서버에 번호를 따로 배정할 필요가 없음. 여러 서버가 동시에 생성해도 충돌 가능성이 매우 낮고, PK 유일 제약으로 최종 중복도 차단할 수 있음.
6. 현재 구조에 맞는 3가지 케이스
Case 1. BIGINT PK + 프로젝트 코드 별도 표시
구성
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에 그대로 저장함.
DB ID / API ID / 화면 ID:
PRJA_01K8A9V4N5J9F28D5G3H1A2B3C
가능한 저장 방식은 두 가지임.
| 방식 | 설명 |
|---|---|
| 전체 문자열 저장 | 접두사와 ULID를 VARCHAR에 저장해 화면 ID와 DB ID를 동일하게 유지함 |
| 분리 저장 | 프로젝트 코드와 ULID를 컬럼으로 나누고, 화면에서 다시 조합함 |
이번 요구처럼 글 ID와 표시 ID를 일치시키려면 전체 문자열 저장이 더 명확함. 이 경우 문자열 PK의 인덱스 크기를 감수해야 함.
등록 흐름
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 + 프로젝트 정보 별도 저장
구성
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로 사용하는 방식이 가장 자연스러움.
이유는 다음과 같음.
- 프로젝트별 작성 서버가 중앙 DB에 의존하지 않고 ID를 먼저 만들 수 있음
- 네트워크 타임아웃과 재시도에 따른 중복 등록을 막기 쉬움
- 프로젝트 서버가 늘어나도 Worker ID를 따로 배정할 필요가 없음
- 시간 순 ID라 UUID v4보다 인덱스 페이지 분할에 유리함
- 전체 ID를 DB에 그대로 저장하면 화면 ID와 API ID를 일치시킬 수 있음
다만 숫자 ID가 반드시 필요하다면 Case 3을 선택할 수 있음. 이 경우 ULID보다 저장 공간은 작지만 Worker ID와 서버 시계 관리가 필수임.
8. 일상적인 비유
Snowflake
각 지점에 번호표 기계를 설치하고 지점 번호를 미리 배정하는 방식임.
- 강남점은 1번
- 판교점은 2번
- 여의도점은 3번
지점 번호가 겹치지 않으면 아주 작은 숫자 번호표를 빠르게 만들 수 있음. 하지만 지점이 늘어날 때마다 번호를 관리해야 하고, 지점 시계가 맞지 않으면 번호표 발급에 문제가 생길 수 있음.
ULID
각 지점이 별도 등록 없이 앱을 실행해 바로 고유 번호표를 만드는 방식임.
- 지점 번호를 따로 배정하지 않아도 됨
- 여러 지점에서 동시에 만들어도 충돌 가능성이 매우 낮음
- 네트워크가 끊겨 같은 번호표를 다시 보내도 중앙에서 중복을 확인할 수 있음
- 번호표가 시간 순으로 만들어져 정리하기 쉬움
현재처럼 프로젝트별 작성 서버가 여러 곳에 나뉘어 있다면, 관리해야 할 설정이 적은 ULID가 운영 측면에서 유리함.
9. 피드백 테이블 인덱스 설계
주요 조회 조건
WHERE channel_id = ?
AND deleted_at IS NULL
ORDER BY id DESC
LIMIT 20
기본 후보
CREATE INDEX idx_feedbacks_channel_active_list
ON feedbacks (channel_id, deleted_at, id DESC);
이 인덱스는 채널, 삭제 여부, 최신순 정렬을 함께 고려한 것임.
deleted_at을 포함한 이유는 소프트 삭제된 글을 목록에서 제외하는 조건까지 인덱스 탐색 범위에 포함하기 위해서임. 다만 삭제된 글이 거의 없으면 다음 인덱스가 더 작고 효율적일 수도 있음.
CREATE INDEX idx_feedbacks_channel_list
ON feedbacks (channel_id, id DESC);
둘 중 어떤 방식이 좋은지는 삭제 데이터 비율과 실제 실행 계획으로 확인해야 함.
미확인 피드백 조회
CREATE INDEX idx_feedbacks_unread
ON feedbacks (channel_id, admin_first_read_at, deleted_at);
JSON 내부 값 조회
data 내부의 category를 자주 검색한다면 생성 컬럼과 인덱스를 구성할 수 있음.
ALTER TABLE feedbacks
ADD COLUMN category VARCHAR(50)
GENERATED ALWAYS AS (data->>'$.category') VIRTUAL;
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
[MySQL 데이터와 인덱스]
│
▼
[InnoDB Buffer Pool 메모리 상주]
│
▼
[반복 조회 시 디스크 접근 감소]
Buffer Pool이 데이터와 인덱스보다 충분히 크면 자주 조회되는 페이지가 메모리에 상주할 수 있음. 다만 “항상 100% 상주” 또는 “항상 1ms 이하”라고 단정할 수는 없으며, 서버 메모리와 실행 계획을 확인해야 함.
Redis 애플리케이션 캐시
[클라이언트]
│
▼
[Redis 캐시] ── HIT ──▶ 캐시 결과 반환
│
MISS
▼
[중앙 API] ──▶ [MySQL] ──▶ Redis 갱신
우선 캐싱할 대상:
- 프로젝트·채널별 최신 피드백 목록
- 각 목록의 첫 페이지
- 전체 피드백 수와 상태별 개수
- 답변 대기·미확인 요약 정보
캐시 키 예시:
feedback:project:{project_id}:channel:{channel_id}:page:1
새 글 등록, 수정, 삭제가 발생하면 관련 캐시를 삭제하거나 갱신해야 함.
| 방식 | 설명 |
|---|---|
| 즉시 삭제 | 변경 시 관련 캐시를 바로 삭제함 |
| 짧은 TTL | 30초~1분 후 자동 만료함 |
| 조합 | 첫 페이지는 즉시 삭제하고 나머지는 TTL을 사용함 |
11. 성능 점검 방법
인덱스를 적용하기 전에 실제 실행 계획을 확인해야 함.
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를 함께 비교해야 함.