한 줄 요약 — API가 느려지면 서버부터 늘리고 싶어집니다. 하지만 제 경험상 먼저 할 일은 요청 하나가 하는 일을 줄이는 것입니다. 같은 걸 두 번 계산하지 않고(캐시), 기다릴 필요 없는 일을 응답 밖으로 빼고(큐), DB에는 필요한 것만 한 번에 묻는 것(N+1, SELECT * 금지)입니다.
AIvance 제품들은 모두 CI4 위에서 돌고, 공통 기반인 ci4-board를 나눠 씁니다. 웹 채팅 상담 솔루션, AITessera 인증 API, AI 화상면접 솔루션을 설계하면서 매번 같은 규칙을 다시 적고 있더군요. 이번 글은 그 규칙들을 한 곳에 모은 것입니다.
PHP-FPM은 상태가 없으니 수평 확장은 언제든 쉽습니다. 다만 요청당 비용이 큰 상태에서 늘리면 비용도 같이 늘고, 결국 DB가 먼저 버티지 못합니다.
1. Redis 캐시: TTL을 표로 정해 둔다
캐시는 "무엇을 얼마나 오래 틀려도 괜찮은가"를 정하는 일입니다. 그래서 TTL을 코드마다 감으로 적지 않고, 데이터 성격별 표를 먼저 만들고 거기서 골라 씁니다.
|
데이터 성격 |
예시 |
TTL |
무효화 |
|---|---|---|---|
|
권한 판정 |
채팅 룸 조인 권한 |
60초 |
재배정·역할 변경 시 키 삭제 |
|
생존 신호 |
상담원 프레즌스 (하트비트 20초) |
컷오프 60초 |
하트비트가 갱신 |
|
토큰 차단 |
로그아웃한 Access의 |
토큰의 남은 수명 (최대 15분) |
만료가 곧 삭제 |
|
토큰 회전 |
Refresh |
7일 |
회전·재사용 탐지 시 |
|
레이트 리밋 창 |
인증 API 분당 10회, 일반 API 분당 120회 |
창 길이 (60초) |
슬라이딩 윈도가 밀어냄 |
|
자주 읽고 가끔 바뀜 |
테넌트 설정, 요금제, 리소스 목록 |
5~10분 |
저장 시 키 삭제 |
|
거의 안 바뀜 |
코드성 데이터, 상품 카테고리 |
1시간~1일 |
배포·저장 시 삭제 |
|
집계·통계 |
대시보드 수치 |
롤업 주기와 맞춤 |
롤업 태스크가 갱신 |
|
캐시 금지 |
잔액, 결제 상태, 메시지 본문 |
없음 |
— |
위 다섯 줄은 웹 채팅 솔루션과 AITessera에서 실제로 정한 값이고, 아래 네 줄은 제품 공통으로 쓰는 기본값입니다. 눈여겨 볼 것은 위쪽 줄들의 TTL이 "성능"이 아니라 "의미"로 정해졌다는 점입니다. 블랙리스트는 토큰이 죽는 시각에 같이 사라져야 무한히 커지지 않고, 프레즈스는 하트비트 세 번을 놓쳤을 때 오프라인이 되어야 합니다.
TTL을 정하는 질문 세 가지
- 틀린 값이 나가면 누가 다치나? 권한이나 돈이면 TTL을 짧게 두거나 변경 시 즉시 지웁니다. 권한 캐시 60초는 "회수된 상담원이 최대 1분간 룸에 남을 수 있다"를 감수한다는 뜻이고, 그래서 재배정 때는 키를 명시적으로 지웁니다.
- 원본이 얼마나 자주 바뀌나? 변경 주기보다 TTL이 길면 사실상 항상 틀린 값을 봅니다.
- 동시에 만료되면 DB가 버티나? 같은 TTL로 한꺼번에 적재한 키들은 한꺼번에 만료됩니다. TTL에 10% 안팎의 무작위 값을 더해 만료를 흩뜨립니다.
키 설계와 DB 인덱스 분리
키는 {용도}:{테넌트}:{식별자} 형태로 두고, 멀티테넌트 제품에서는 테넌트 구분을 빼먹지 않습니다. 키에 테넌트가 없으면 다른 회사의 캐시가 나가는 사고로 이어집니다.
Redis는 역할별로 DB 인덱스를 나눕니다. 채팅 솔루션에서는 0 캐시·세션, 1 큐, 2 프레즌스, 3 Pub/Sub으로 했습니다. 캐시를 비우려고 FLUSHDB를 날렸는데 큐의 작업까지 같이 날아가는 일을 막아 줍니다. 예산이 허락하면 큐는 아예 별도 인스턴스로 두고, 캐시 쪽에만 maxmemory-policy allkeys-lru를 겁니다. 큐에 LRU가 걸리면 메모리가 차는 순간 작업이 조용히 사라집니다.
CI4에서는 remember() 하나로
// app/Services/TenantSettingsService.php
public function get(int $tenantId): array
{
$ttl = 600 + random_int(0, 60); // 10분 + 지터
return cache()->remember("tset_t{$tenantId}", $ttl, fn () =>
$this->settings->select('key, value')
->where('tenant_id', $tenantId)
->findAll()
);
}
public function save(int $tenantId, array $data): void
{
$this->settings->upsertBatch($data);
cache()->delete("tset_t{$tenantId}"); // 저장한 쪽이 지운다
}
CI4 캐시 키에는 {}()/\@: 같은 예약 문자를 쓸 수 없어서, cache() 레이어를 거치는 키는 _로 구분합니다. 프레즈스나 토큰처럼 ZSet·Lua가 필요한 곳은 \Redis를 직접 쓰고 : 구분을 씁니다.
캐시를 막는 것: 모든 방문자의 세션
jblove.net 속도를 진단했을 때, 로그인하지 않은 방문자에게도 PHP 세션이 시작되고 있었습니다. 그 바람에 모든 페이지가 Cache-Control: no-store를 달고 나갔고, Cloudflare는 cf-cache-status: DYNAMIC으로 아무것도 캐시하지 못했습니다. Redis를 아무리 잘 설계해도 세션 하나가 그 앞단의 캐시를 통째로 끄는 셈입니다. 세션은 로그인이나 폼처럼 상태가 필요한 경로에서만 엽니다.
2. 큐 + 202 Accepted: 기다릴 필요 없는 일은 응답 밖으로
PHP-FPM 워커 하나는 요청 하나가 끝날 때까지 묶여 있습니다. 30초 걸리는 작업을 요청 안에서 돌리면 그 30초 동안 워커 하나가 사라지고, 동시에 몇십 건만 들어와도 풀이 바닥납니다. 그때부터는 가벼운 요청까지 줄을 섭니다.
기준은 단순합니다. 사용자가 결과를 보고 다음 행동을 정해야 하면 동기, 아니면 큐입니다.
|
작업 |
처리 |
이유 |
|---|---|---|
|
AI 면접 답변 채점 (FFmpeg 병합 → STT → LLM 채점) |
큐 |
답변 1건에 수십 초~수 분, 응시자는 결과를 기다리지 않음 |
|
채팅 신규 상담 자동 배정 |
큐 ( |
프레즈스 조회·배정·발행까지 여러 단계 |
|
배정 후 90초 무응답 회수 |
지연 큐 |
시간이 지나야 판단 가능 |
|
웹훅 발송 |
큐 + 지수 백오프 재시도 |
상대 서버 사정에 우리 응답이 끌려다니면 안 됨 |
|
엑셀 내보내기, 메일·문자 발송 |
큐 |
실패해도 다시 보내면 됨 |
|
면접 질문의 루브릭 AI 생성 |
동기 |
담당자가 화면 앞에서 결과를 보고 수정함 |
|
로그인, 결제 승인 |
동기 |
결과가 곧 다음 화면 |
AI 화상면접 설계에서는 한 발 더 나갔습니다. 응시자 영상은 API 서버를 거치지 않고 S3로 바로 올리고, API는 업로드 완료 알림을 받아 작업만 큐에 넣습니다. 무거운 바이트도, 무거운 계산도 API 프로세스를 지나가지 않게 한 것입니다.
응답은 202와 "어디서 확인하는지"
큐에 넣었다고 200을 돌려주면 클라이언트는 "끝났다"고 읽습니다. 202 Accepted는 "받았고 처리는 아직"이라는 뜻이고, Location 헤더로 상태를 확인할 곳을 알려 줍니다.
POST /api/v1/answers/8841/evaluate
Idempotency-Key: 3f6c2a1e-...
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/eval_8841
Retry-After: 10
{ "data": { "job_id": "eval_8841", "status": "queued" } }
GET /api/v1/jobs/eval_8841
HTTP/1.1 200 OK
{ "data": { "job_id": "eval_8841", "status": "processing", "step": "stt" } }
상태는 queued → processing → done | failed | needs_review로 둡니다. 면접 채점에서는 LLM 결과의 근거 인용문이 전사문에 없으면 한 번 재시도하고, 그래도 안 되면 needs_review로 사람에게 넘깁니다. 실패도 상태의 하나로 보여 주는 것이 폴링 API의 일입니다.
폴링이 부담이면 끝났을 때 알려 주면 됩니다. 채팅 솔루션처럼 WebSocket이 있으면 Redis PUBLISH로, 없으면 웹훅으로 보냅니다. 그래도 상태 조회 API는 남겨 둡니다. 알림은 유실될 수 있고, 상태 조회는 그때 다시 맞추는 수단입니다. 채팅에서 after_seq 델타 동기화를 항상 병행한 것과 같은 이유입니다.
큐를 쓸 때 지킨 규칙
- 커밋이 끝난 뒤에 적재한다. 트랜잭션 안에서 작업을 넣으면, 워커가 아직 커밋되지 않은 행을 찾지 못하거나 롤백된 데이터로 일합니다. 채팅의 Redis 발행도
$db->transComplete()성공을 확인한 뒤에만 했습니다. - 작업에는 ID만 담는다. 페이로드에 행 데이터를 통째로 넣으면 워커가 오래된 값으로 일합니다. 워커는 ID로 다시 읽습니다.
- 멱등하게 만든다. 큐는 최소 한 번 전달이 기본이라 같은 작업이 두 번 올 수 있습니다.
Idempotency-Key나 상태 검사(status = 'queued'일 때만 시작)로 두 번째는 그냥 끝내게 합니다. - 싸게 끝낼 수 있으면 비싼 단계를 건너뛴다. 유효 발화가 5초 미만인 답변은 LLM을 부르지 않고
no_answer로 처리했습니다. - 워커는 주기적으로 재시작한다.
php spark queue:work는 오래 도는 PHP 프로세스라 메모리가 새거나 배포 전 코드를 들고 있을 수 있습니다. systemd나 Supervisor로 띄우고 일정 건수나 시간마다 끝내고, 배포 후에는 재시작합니다.
이렇게 나누면 확장도 따로 합니다. API는 PHP-FPM 풀로, 채점은 워커 수로 늘립니다. 채점이 밀려도 응시 화면은 느려지지 않습니다.
3. N+1 금지: 목록은 쿼리 두 번으로 끝낸다
N+1은 목록을 한 번 읽고(1), 그 각 행마다 관련 데이터를 또 읽는(N) 패턴입니다. 로컬에서 샘플 10건으로 돌리면 티가 안 나고, 운영에서 한 페이지 30건에 관계 세 개가 붙으면 요청 하나가 쿼리 91번을 보냅니다. 쿼리 하나가 빨라도 왕복 비용이 쌓이고, DB 커넥션을 그만큼 오래 물고 있습니다.
CI4에는 Eloquent의 with() 같은 즉시 로딩이 없습니다. 그래서 N+1은 대개 아래처럼 선의로 생깁니다.
// ❌ 상담 목록 30건 → 쿼리 61번
$conversations = $this->conversations->where('tenant_id', $tid)->findAll(30);
foreach ($conversations as &$c) {
$c['visitor'] = $this->visitors->find($c['visitor_id']); // 30번
$c['assignee'] = $this->users->find($c['assignee_id']); // 30번
}
해결: ID를 모아 whereIn 한 번
// ✅ 쿼리 3번 (목록 1 + 관계별 1)
$conversations = $this->conversations
->select('id, visitor_id, assignee_id, status, last_seq, updated_at')
->where('tenant_id', $tid)
->orderBy('updated_at', 'DESC')
->findAll(30);
$visitors = $this->indexBy($this->visitors
->select('id, uid, verified_user_id')
->whereIn('id', array_unique(array_column($conversations, 'visitor_id')) ?: [0])
->findAll());
$assignees = $this->indexBy($this->users
->select('id, name')
->whereIn('id', array_filter(array_unique(array_column($conversations, 'assignee_id'))) ?: [0])
->findAll());
foreach ($conversations as &$c) {
$c['visitor'] = $visitors[$c['visitor_id']] ?? null;
$c['assignee'] = $assignees[$c['assignee_id']] ?? null;
}
private function indexBy(array $rows, string $key = 'id'): array
{
return array_column($rows, null, $key);
}
빈 배열로 whereIn을 부르면 잘못된 SQL이 나가므로 ?: [0]으로 막아 둡니다. 단순히 화면에 이름 하나 붙이는 정도라면 JOIN도 괜찮습니다. 다만 일대다 관계를 JOIN하면 행이 불어나 LIMIT 30이 깨지므로, 그때는 위처럼 따로 읽습니다.
숫자도 루프로 세지 않는다
"상담원별 오픈 건수"처럼 각 행에 countAllResults()를 부르는 것도 N+1입니다. 자동 배정에서 capacity를 검사할 때도 GROUP BY 한 번으로 묶었습니다.
$open = $this->conversations
->select('assignee_id, COUNT(*) AS cnt')
->where('tenant_id', $tid)
->where('status', 'open')
->whereIn('assignee_id', $onlineIds ?: [0])
->groupBy('assignee_id')
->findAll();
탐지는 테스트에서
눈으로 리뷰해서는 계속 놓칩니다. 특히 클로드코드가 짠 코드는 문법이 깔끔해서 더 그렇습니다. 그래서 목록 API의 통합 테스트에 쿼리 수 상한을 겁니다. CI4는 쿼리마다 DBQuery 이벤트를 쏘기 때문에 세기만 하면 됩니다.
public function testConversationListQueryCountIsConstant(): void
{
$count = 0;
Events::on('DBQuery', static function () use (&$count) { $count++; });
$this->seedConversations(30);
$this->withHeaders($this->agentAuth())->get('/api/v1/agent/conversations');
$this->assertLessThanOrEqual(5, $count); // 30건이든 300건이든 같아야 한다
}
이 테스트의 핵심은 "행 수가 늘어도 쿼리 수는 그대로"입니다. 샘플을 30건과 60건으로 두 번 돌려 같은지 비교하면 더 확실합니다. 개발 중에는 디버그 툴바의 Database 탭에서 같은 모양의 쿼리가 반복되는지만 봐도 대부분 잡힙니다.
4. SELECT * 금지: 필요한 컬럼만 이름으로
CI4 모델의 find(), findAll()은 select()를 안 부르면 SELECT *를 보냅니다. 가장 편한 코드가 가장 비싼 쿼리인 셈입니다. 금지하는 이유는 네 가지입니다.
- 큰 컬럼이 따라온다. 채팅의
settings_json,attrs_json, 면접의 전사문·루브릭 JSON 같은TEXT·JSON컬럼은 행보다 크고, InnoDB는 이런 값을 페이지 밖에 따로 둘 수 있어 읽을 때 추가 I/O가 생깁니다. 목록에서는 쓰지도 않는데 네트워크·메모리·JSON 직렬화 비용을 다 냅니다. - 커버링 인덱스를 못 쓴다. 필요한 컬럼이 전부 인덱스에 있으면 MySQL은 테이블을 읽지 않고 인덱스만으로 답합니다.
*이면 그 기회가 사라집니다. - 민감한 컬럼이 새어 나간다. 나중에 누가 테이블에
password_hash나 내부용 컬럼을 추가하면,*로 읽고 배열을 그대로 내려주는 API는 그 순간 유출 경로가 됩니다. 채팅의 내부 노트처럼 "절대 나가면 안 되는 것"이 있는 제품에서는 성능보다 이 이유가 더 큽니다. - 스키마 변경에 조용히 흔들린다. 컬럼이 늘거나 이름이 바뀌어도 쿼리는 에러가 없고, 응답 모양만 바뀝니다. 컬럼을 이름으로 적어 두면 바뀐 곳에서 바로 터집니다.
모델에 컬럼 묶음을 둔다
쿼리마다 select()를 적으라고 하면 결국 빠뜨립니다. 그래서 용도별 컬럼 묶음을 모델 상수로 두고 거기서만 고릅니다.
// app/Models/ConversationModel.php
final class ConversationModel extends Model
{
public const COLS_LIST = 'id, inbox_id, visitor_id, assignee_id, status, priority, last_seq, updated_at';
public const COLS_DETAIL = self::COLS_LIST . ', first_reply_at, resolved_at, created_at';
public function listForAgent(int $tid, int $inboxId, int $limit = 30): array
{
return $this->select(self::COLS_LIST)
->where('tenant_id', $tid)
->where('inbox_id', $inboxId)
->orderBy('updated_at', 'DESC')
->findAll($limit);
}
}
응답 쪽에서는 Presenter(또는 DTO)가 내보낼 필드를 다시 한 번 고릅니다. 채팅에서 MessagePresenter를 둔 것도 같은 이유입니다. 읽기는 모델이, 내보내기는 Presenter가 책임집니다.
컬럼을 고정하면 인덱스가 보인다
메시지 조회는 거의 항상 "이 대화의 N번 이후"입니다. 그래서 (conversation_id, seq) 복합 인덱스를 핵심으로 잡았습니다.
EXPLAIN
SELECT id, seq, sender_type, sender_id, content, content_type, created_at
FROM messages
WHERE conversation_id = 88213 AND seq > 120 AND visibility = 'public'
ORDER BY seq
LIMIT 50;
EXPLAIN에서 type이 range이고 key가 그 복합 인덱스면 됩니다. Extra에 Using filesort가 보이면 정렬이 인덱스를 타지 못한 것이고, Using index가 보이면 커버링입니다. content처럼 큰 컬럼이 꼭 필요한 조회는 커버링을 포기하되, 목록·카운트처럼 작은 컬럼만 필요한 조회는 인덱스만으로 끝나게 만듭니다.
클로드코드에도 같은 규칙을 줍니다. CLAUDE.md의 "하지 말 것"에 select() 없는 find*() 금지와 루프 안 쿼리 금지를 적어 두면, 새로 짜는 코드부터 이 모양으로 나옵니다.
5. 덧붙임: 싼 검사를 먼저, 비싼 호출은 나중에
세 가지 기본기를 지켜도 필터 순서가 틀리면 부하가 다시 몰립니다. 문의 폼 봇 방어를 붙이다가 격은 일입니다. Turnstile·reCAPTCHA 검증(외부 API 호출)을 레이트 리밋보다 앞에 두었더니, 봇이 몰릴 때 요청마다 외부 응답을 기다리느라 PHP-FPM 워커가 바닥났고 사이트 전체가 502를 냈습니다.
순서는 비용이 싼 것부터입니다.
- 헤더·형식 검사 (메모리, 마이크로초)
- 레이트 리밋 (Redis 한 번, 1ms 안팎)
- 인증·권한 (Redis 캐시 또는 서명 검증)
- DB 조회·쓰기
- 외부 API 호출 — 기다려야 하면 여기, 아니면 큐로
AITessera에서 토큰 발급·갱신·가입을 IP 기준 분당 10회, 일반 API를 분당 120회로 슬라이딩 윈도 리밋을 건 것도 같은 생각입니다. 넘으면 본문을 만들기도 전에 429와 Retry-After를 돌려줍니다. 거절은 가장 싸게 해야 합니다.
하나 더, Cloudflare 뒤에서는 레이트 리밋의 기준 IP를 제대로 잡아야 합니다. 요청이 Cloudflare IP 대역에서 왔는지 확인하지 않고 CF-Connecting-IP를 믿으면, 헤더를 매번 바꾸는 것만으로 리밋을 피해 갑니다.
점검 체크리스트
새 API를 PR로 올리기 전에 확인하는 항목입니다.
- 캐시하는 데이터의 TTL이 TTL 표에 있는 성격 중 하나에 맞는가
- 원본을 바꾸는 코드가 해당 캐시 키를 지우는가
- 캐시 키에 테넌트 구분이 들어가 있는가
- 비로그인 페이지에서 세션이 시작되지 않는가 (
Cache-Control: no-store가 붙지 않는가) - 수 초 이상 걸리는 작업을 요청 안에서 돌리고 있지 않은가
- 비동기 작업 API는
202+Location+ 상태 조회 API를 갖추었는가 - 큐 적재·Redis 발행이 트랜잭션 커밋 뒤에 일어나는가
- 워커가 같은 작업을 두 번 받아도 결과가 한 번만 남는가
- 루프 안에
find(),countAllResults()같은 쿼리가 없는가 - 목록 API 테스트에 쿼리 수 상한 단언이 있는가
- 모든 조회가
select()또는 모델의COLS_*상수를 쓰는가 - 주요 조회의
EXPLAIN에type: ALL이나Using filesort가 없는가 - 레이트 리밋이 DB·외부 API보다 앞에 있는가
돌아보며
- TTL은 성능 수치가 아니라 약속이다. "60초"는 "최대 60초까지 틀려도 된다"는 합의입니다. 표로 적어 두면 그 합의를 팀과 클로드코드가 같이 읽습니다.
- 응답 시간은 사용자가 기다려야 하는 일에만 쓴다. 나머지는 202로 돌려보내고 워커가 합니다. 그래야 API와 워커를 따로 늘릴 수 있습니다.
- 쿼리 수는 행 수와 무관해야 한다. 로컬 샘플 10건은 N+1을 숨깁니다. 리뷰가 아니라 테스트가 잡게 해야 합니다.
SELECT *는 성능 문제이기 전에 보안 문제다. 내일 추가될 컬럼까지 오늘 내보내기로 약속하는 셈입니다.- 서버를 늘리는 것은 마지막이다. 요청당 비용을 줄이고 나면 PHP-FPM은 그냥 늘리면 됩니다. 반대 순서로 하면 DB가 먼저 버티지 못합니다.
클로드코드가 코드를 빠르게 짜줄수록, 이런 기본기는 사람이 규칙으로 적어 두어야 합니다. 코드는 매번 새로 나오지만, 규칙은 한 번 적으면 모든 제품에 같이 걸립니다.
댓글 0개