글 한 편 여는 데 SQL 쿼리 63개, 글 12편이 뜨는 홈 화면은 38개였다
워드프레스 글 한 편을 열면 데이터베이스에 몇 번이나 물어볼까요. 본문 한 번, 댓글 한 번 정도를 떠올리기 쉽지만 실제로는 그렇지 않았습니다. 이 블로그의 글 한 편을 여는 동안 나간 SQL 쿼리는 63개였습니다. 글 12편의 목록이 뜨는 홈 화면은 38개였습니다. 글 하나가 열두 편짜리 목록보다 데이터베이스를 더 많이 두드립니다. 쿼리마다 붙어 있는 호출 경로를 따라가서 63개가 각각 어디서 나온 것인지 끝까지 분류했습니다.
어떻게 세었는가
워드프레스에는 실행한 쿼리를 전부 기록하는 스위치가 있습니다. SAVEQUERIES 상수를 켜면 $wpdb->queries 배열에 쿼리문, 걸린 시간, 그 쿼리를 부른 함수의 순서가 쌓입니다. 이 스위치를 운영 중인 사이트에 켜 두면 요청마다 메모리를 먹기 때문에, 서버에서 명령줄로 요청 한 건을 재현하는 스크립트를 따로 만들었습니다. HTTP_HOST와 REQUEST_URI를 직접 채우고 wp-blog-header.php를 불러 템플릿까지 렌더링한 다음, 끝나는 시점에 쌓인 배열을 읽는 방식입니다.
define('SAVEQUERIES', true);
define('WP_USE_THEMES', true);
$_SERVER['HTTP_HOST'] = 'kilho.org';
$_SERVER['REQUEST_URI'] = '/145/';
ob_start();
require '/경로/wp-blog-header.php';
ob_end_clean();
echo count($wpdb->queries); // 63
각 페이지를 두 번씩 돌리고 두 번째 값을 썼습니다. 첫 요청에는 만료된 임시 값을 다시 만드는 쿼리가 섞일 수 있어서입니다. 환경은 워드프레스 7.0.4, Kadence 테마 1.5.1, PHP 8.4, MariaDB 10.5이고 활성 플러그인은 0개입니다. 영구 객체 캐시(object-cache.php)는 없습니다. 그러니 아래 숫자는 요청이 올 때마다 실제로 데이터베이스까지 가는 쿼리 수입니다.
페이지 종류별로 몇 개가 나가는가
| 페이지 | 화면에 뜨는 글 | 쿼리 수 | DB 시간 합계 |
|---|---|---|---|
| 홈 화면 | 12편 | 38개 | 14.3ms |
| 글 한 편 (/145/) | 1편 | 63개 | 22.4ms |
| 글 한 편 (/29/) | 1편 | 63개 | 22.2ms |
| 카테고리 목록 | 8편 | 44개 | 19.7ms |
| 홈 2페이지 | 12편 | 38개 | 15.7ms |
| 없는 주소 (404) | 0편 | 29개 | 16.0ms |
글 한 편이 63개로 가장 많습니다. 서로 다른 두 글이 정확히 같은 수였다는 점이 중요합니다. 본문 길이나 이미지 수와는 관계가 없다는 뜻입니다. 같은 테마를 쓰는 다른 사이트(damyo.net)에서도 홈 39개, 글 한 편 63개로 거의 같은 값이 나왔습니다. 쿼리 수를 정하는 것은 글 내용이 아니라 테마가 그 페이지에 무엇을 얹는가였습니다.
존재하지 않는 주소를 열어도 29개가 나갑니다. 이것이 이 사이트의 기본 요금입니다. 어떤 페이지든 내용과 상관없이 29개는 깔고 시작하고, 거기에 페이지 종류에 따라 9개에서 34개가 더해집니다.
63개는 어디서 나왔나
63개를 호출 경로대로 묶으면 다음과 같습니다. 워드프레스가 글을 찾아 놓기까지 11개, 어떤 템플릿 파일을 쓸지 정하는 데 3개, 그리고 테마가 화면을 그리는 동안 49개입니다.
| 출처 | 쿼리 | 무엇을 가져오는가 |
|---|---|---|
| 사이트 판별·네트워크 옵션·autoload 옵션 | 5개 | 도메인으로 블로그 ID 찾기 2, 네트워크 옵션 2, autoload 옵션 한 묶음 1 |
| 없는 옵션 개별 조회 | 4개 | 테이블에 행이 없는 옵션 4개를 하나씩 확인 |
| 메인 쿼리 | 4개 | 글 본체, 분류 2, 글 메타 |
| 블록 템플릿 검색 | 3개 | 편집기에서 만든 사용자 템플릿이 있는지 확인 (결과 0건) |
| 대표이미지·작성자·글로벌 스타일 | 6개 | 첨부 글과 메타 2, 사용자와 메타 2, 글로벌 스타일 글 2 |
| 상단 메뉴 | 8개 | 메뉴 용어, 항목 목록, 항목 메타, 항목이 가리키는 페이지, 분류, 개인정보 페이지 확인 |
| 이전·다음 글 | 4개 | 이전 글 ID와 본체, 다음 글 ID와 본체 |
| 관련 글 6편 목록 | 10개 | 슬러그를 ID로 바꾸기 5, 글 목록·본체·메타·분류 5 |
| 관련 글 썸네일 6장 | 12개 | 썸네일 한 장마다 첨부 글 1, 첨부 메타 1 |
| 댓글·분류 메타 | 2개 | 승인된 댓글 목록 (0건), 분류 메타 한 묶음 |
| 하단 메뉴 | 5개 | 메뉴 용어, 항목 목록, 항목 메타, 가리키는 페이지, 페이지 메타 |

이 글의 본문을 보여 주는 데 꼭 필요한 것은 메인 쿼리 4개뿐입니다. 나머지 59개는 사이트를 띄우는 비용이거나, 글 주변에 붙는 요소들의 비용입니다. 호출 경로 어딘가에 테마 함수가 끼어 있는 쿼리로 세면 63개 중 51개, 81%입니다.
관련 글 여섯 편이 22개를 쓴다
가장 큰 덩어리는 글 아래에 붙는 관련 글 영역입니다. 같은 카테고리나 태그의 글을 무작위로 6편 뽑아 보여 주는데, 여기에 22개가 들어갑니다. 홈 화면이 글 12편을 목록으로 그리는 데 쓴 쿼리 전체(38개)의 절반이 넘습니다. 카테고리와 태그의 슬러그를 ID로 바꾸는 데만 5개가 나가고, 그다음 글 6편을 ORDER BY RAND()로 뽑는 쿼리가 이 페이지에서 가장 느린 쿼리(0.92ms)였습니다. 무작위 정렬은 결과를 캐시할 수 없어서 매번 다시 섞습니다.
썸네일 6장에 12개, 홈은 12장에 2개
홈 화면은 글 12편의 썸네일을 쿼리 두 개로 가져옵니다. 워드프레스가 메인 목록을 그리기 시작할 때 대표이미지 ID를 전부 모아서 첨부 글과 메타를 한 번씩에 불러오기 때문입니다. 그런데 이 묶음 조회는 메인 쿼리에만 적용됩니다. 관련 글 영역은 테마가 따로 만든 두 번째 쿼리라서 그 준비 과정을 거치지 않고, 썸네일 한 장마다 첨부 글 1개와 메타 1개를 따로 묻습니다. 6장이면 12개, 12장을 보여 주도록 바꾸면 24개가 됩니다. 목록이 길수록 차이가 벌어지는 전형적인 N+1 형태입니다.
메뉴 13개, 없는 옵션 4개, 템플릿 찾기 3개
상단 메뉴에 8개, 하단 메뉴에 5개가 나갑니다. 메뉴 항목이 nav_menu_item이라는 글 유형으로 저장되어 있어서, 메뉴 하나를 그릴 때 메뉴 용어 조회, 항목 목록, 항목 메타, 항목이 가리키는 페이지, 그 페이지의 메타, 개인정보처리방침 페이지 존재 확인이 줄줄이 붙습니다. 이 사이트의 메뉴 항목은 두 메뉴를 합쳐 11개뿐인데도 그렇습니다.
없는 옵션 4개는 조금 뜻밖이었습니다. kadence_global_palette, kadence_settings, site_logo, https_migration_required 네 옵션은 모두 options 테이블에 행이 없습니다. 테마는 기본값을 쓰고 있고, 로고는 등록한 적이 없고, HTTPS 이전 표시는 애초에 만들어진 적이 없습니다. 워드프레스는 없는 옵션을 notoptions 목록에 적어 두고 다시 묻지 않지만, 그 목록은 요청이 끝나면 사라집니다. 영구 객체 캐시가 없는 사이트는 매 요청마다 네 번씩 “없다”는 답을 새로 받습니다. autoload 옵션 123개는 한 번에 가져오는데, 없는 옵션은 그 한 번에 들어갈 수가 없기 때문입니다.
블록 템플릿 검색 3개는 테마 선언에서 옵니다. Kadence는 클래식 테마지만 block-templates 지원을 선언하고 있어서, 워드프레스가 요청마다 사이트 편집기에서 만든 사용자 템플릿(wp_template)이 DB에 있는지 확인합니다. 이 사이트에서 결과는 항상 0건입니다. 홈 화면에서는 후보 템플릿이 둘이라 4개가 나갑니다.
시간으로 보면 얼마나 되나
| 항목 | 값 |
|---|---|
| 쿼리 63개 합계 | 22.4ms |
| 쿼리 1개 평균 | 0.36ms |
| 가장 느린 쿼리 | 0.92ms (관련 글 무작위 추출) |
| 같은 SQL이 두 번 나간 경우 | 0건 |
| 다른 서버에서 잰 첫 바이트 도착 시간 (3회) | 177 / 351 / 432ms |
63개를 전부 합쳐도 22ms입니다. 다른 지역의 서버에서 curl로 잰 첫 바이트 도착 시간은 177~432ms였으니, 데이터베이스가 차지하는 몫은 넉넉히 잡아도 13% 이하입니다. 나머지는 PHP가 워드프레스와 테마 코드를 읽고 실행하는 시간, 그리고 네트워크입니다. 글 138행, 메타 349행짜리 작은 DB라서 어떤 쿼리도 1ms를 넘지 않습니다. 지금 63개는 느림의 원인이 아닙니다. 다만 글과 메뉴와 메타가 늘어났을 때 어느 항목이 먼저 커질지를 미리 보여 주는 지도입니다.
같은 SQL이 두 번 나간 경우는 0건이었습니다. 요청 하나 안에서는 워드프레스의 객체 캐시가 제 역할을 합니다. 문제는 요청과 요청 사이입니다. 영구 캐시가 없으니 방문자가 올 때마다 63개가 그대로 반복됩니다. 하루 1,000번 열리면 63,000번이고, 그중 “없는 옵션 확인” 4,000번과 “사용자 템플릿 없음 확인” 3,000번은 답이 정해진 질문입니다.
줄일 수 있는 것과 그냥 두어도 되는 것
- 관련 글 22개: 영역을 끄면 그대로 사라집니다. 켜 둔다면 두 번째 쿼리에도 대표이미지 묶음 조회를 적용하는 코드 한 줄로 썸네일 12개가 2개가 됩니다. 63개 중 가장 싸게 줄일 수 있는 열 개입니다.
- 메뉴 13개: 메뉴는 며칠에 한 번도 바뀌지 않습니다. 결과를 저장해 둘 이유가 가장 분명한 항목입니다.
- 없는 옵션 4개, 템플릿 검색 3개: 영구 객체 캐시를 붙이면 없어집니다. 사실 그 경우 63개 대부분이 사라지므로, 쿼리 수가 실제 병목이 되는 규모라면 개별 항목보다 이쪽이 먼저입니다.
- 부트스트랩 5개, 메인 쿼리 4개: 줄일 수 없고 줄일 이유도 없습니다. 워드프레스가 어느 사이트의 어느 글인지 알아내는 최소 비용입니다.
숫자 자체보다 방향이 남습니다. 파일 수를 세었을 때도 글 본문보다 주변이 무거웠고, 쿼리를 세어 보니 같은 결론이었습니다. 글 한 편을 보여 주는 데 필요한 쿼리는 4개이고, 나머지 59개는 그 글을 둘러싼 메뉴·관련 글·썸네일·설정 확인의 몫입니다. 플러그인이 0개인 사이트에서도 그렇습니다. 플러그인을 지우기 전에 테마가 글 주변에 무엇을 얹고 있는지부터 세어 보는 편이 순서에 맞습니다.