WordPress 사이트 속도 최적화 실전 가이드 — 로딩 3초 이내로 줄이기

페이지 로딩 시간이 3초를 넘기면, 방문자의 53%가 이탈한다는 통계가 있다. Google의 2024년 Core Web Vitals 보고서에 따르면 모바일 사용자의 기대 로딩 시간은 2.5초 이하이고, LCP(Largest Contentful Paint) 기준 2.5초를 충족하는 워드프레스 사이트는 전체의 33%에 불과하다. 나도 운영하던 블로그의 PageSpeed Insights 점수가 모바일 기준 28점까지 떨어진 적이 있는데, 아래 과정을 거치고 나서 82점까지 끌어올렸다. 한 번에 다 하려면 막막하지만, 순서대로 따라가면 체감이 확실하다.

현재 속도 진단: PageSpeed Insights와 GTmetrix 활용

최적화를 시작하기 전에 현재 상태를 수치로 확인해야 한다. 감이 아니라 데이터로 판단해야 어디서 시간이 새는지 알 수 있다. 내가 사용하는 도구는 두 가지이다.

Google PageSpeed Insights는 Core Web Vitals 기준으로 점수를 매기고, 구체적인 개선 항목을 알려준다. GTmetrix는 Waterfall 차트를 제공해서 어떤 리소스가 로딩을 지연시키는지 시각적으로 보여준다. 두 도구 모두 무료이고, URL만 넣으면 바로 결과가 나온다.

주요 지표의 의미와 목표치는 다음과 같다.

지표 의미 좋음 개선 필요
LCP 가장 큰 콘텐츠 렌더링 시간 ≤ 2.5초 > 4.0초
FID / INP 사용자 입력 반응 시간 ≤ 200ms > 500ms
CLS 레이아웃 밀림 정도 ≤ 0.1 > 0.25
TTFB 서버 첫 응답 시간 ≤ 800ms > 1.8초

TTFB가 1초 이상이면 서버(호스팅) 자체에 문제가 있을 가능성이 높고, LCP가 4초 이상이면 이미지나 웹폰트가 병목일 확률이 크다. 나는 처음 진단했을 때 TTFB 1.4초, LCP 6.2초가 나왔는데, 나중에 보니 최적화되지 않은 PNG 이미지 한 장이 2.8MB였다.

캐싱 전략: 브라우저 캐시, 서버 캐시, CDN

캐싱은 속도 개선에서 가장 즉각적인 효과가 나는 영역이다. 같은 리소스를 매번 서버에서 새로 받아올 필요가 없으니까. 세 가지 레벨로 나눠서 설정한다.

브라우저 캐시 설정 (.htaccess)

Apache 서버 기준으로 .htaccess 파일에 아래 규칙을 추가하면 정적 리소스를 브라우저에 캐싱할 수 있다. 이미지, CSS, JS 파일을 방문자의 브라우저에 저장해두는 원리이다.

# .htaccess 파일 - WordPress 루트 디렉토리
# BEGIN Browser Caching
<IfModule mod_expires.c>
  ExpiresActive On

  # 이미지 파일: 1년간 캐시
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType image/png "access plus 1 year"
  ExpiresByType image/gif "access plus 1 year"
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType image/svg+xml "access plus 1 year"
  ExpiresByType image/x-icon "access plus 1 year"

  # CSS, JavaScript: 1개월 캐시
  ExpiresByType text/css "access plus 1 month"
  ExpiresByType application/javascript "access plus 1 month"
  ExpiresByType text/javascript "access plus 1 month"

  # 웹폰트: 1년간 캐시
  ExpiresByType font/woff2 "access plus 1 year"
  ExpiresByType font/woff "access plus 1 year"
  ExpiresByType application/font-woff2 "access plus 1 year"

  # HTML: 캐시하지 않음 (항상 최신 콘텐츠 제공)
  ExpiresByType text/html "access plus 0 seconds"
</IfModule>

# Gzip 압축 활성화
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html
  AddOutputFilterByType DEFLATE text/css
  AddOutputFilterByType DEFLATE application/javascript
  AddOutputFilterByType DEFLATE text/javascript
  AddOutputFilterByType DEFLATE application/json
  AddOutputFilterByType DEFLATE image/svg+xml
  AddOutputFilterByType DEFLATE application/xml
  AddOutputFilterByType DEFLATE text/xml
  AddOutputFilterByType DEFLATE application/font-woff
  AddOutputFilterByType DEFLATE font/woff2
</IfModule>

# ETags 비활성화 (Expires 헤더만 사용)
<IfModule mod_headers.c>
  Header unset ETag
  FileETag None
  # 보안 헤더도 함께 추가
  Header set X-Content-Type-Options "nosniff"
  Header set X-Frame-Options "SAMEORIGIN"
</IfModule>
# END Browser Caching

Nginx를 사용하는 경우에는 nginx.conf 또는 사이트 설정 파일에서 동일한 설정을 한다.

# /etc/nginx/conf.d/wordpress-cache.conf
# 또는 server 블록 안에 직접 추가

# Gzip 압축
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
  text/plain
  text/css
  text/javascript
  application/javascript
  application/json
  application/xml
  image/svg+xml
  font/woff2;

# 정적 리소스 캐시 헤더
location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

location ~* \.(css|js)$ {
    expires 1M;
    add_header Cache-Control "public";
    access_log off;
}

location ~* \.(woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
    access_log off;
}

# PHP-FPM 연결 (FastCGI 캐시)
fastcgi_cache_path /var/run/nginx-cache levels=1:2
    keys_zone=WORDPRESS:100m inactive=60m max_size=512m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

server {
    listen 80;
    server_name example.com;
    root /var/www/html;

    # FastCGI 캐시 설정
    set $skip_cache 0;

    # POST 요청은 캐시하지 않음
    if ($request_method = POST) {
        set $skip_cache 1;
    }

    # 로그인한 사용자는 캐시하지 않음
    if ($http_cookie ~* "wordpress_logged_in") {
        set $skip_cache 1;
    }

    # WooCommerce 장바구니/결제 페이지 캐시 제외
    if ($request_uri ~* "/cart|/checkout|/my-account") {
        set $skip_cache 1;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_cache WORDPRESS;
        fastcgi_cache_valid 200 60m;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

서버 사이드 캐시 플러그인

브라우저 캐시는 재방문자에게만 효과가 있다. 첫 방문자를 위해서는 서버 사이드 캐시가 필요하다. 워드프레스에서 방문자가 페이지를 요청할 때마다 PHP가 실행되고 DB 쿼리가 날아가는데, 캐시 플러그인은 이 결과를 HTML 파일로 저장해서 다음 요청에 정적 파일을 바로 내려보낸다.

추천하는 조합은 WP Super Cache(무료, 설정 단순) 또는 W3 Total Cache(세밀한 제어 가능)이다. 유료로는 WP Rocket이 설정 편의성 면에서 압도적이지만, 연 $59부터 시작한다. 솔직히 말하면, 나는 처음에 W3 Total Cache를 설치했다가 설정 항목이 너무 많아서 혼란스러웠고 결국 WP Super Cache로 바꿨다. 설정이 간단한 쪽이 실수할 확률도 줄어든다.

Cloudflare CDN 연동

CDN은 전 세계에 분산된 서버에서 리소스를 제공한다. 한국 호스팅을 사용하더라도 해외 방문자가 있다면 효과가 크고, 국내 방문자에게도 정적 리소스 전송 속도가 개선된다. Cloudflare 무료 플랜만으로도 충분하다. 네임서버만 바꾸면 되고, 워드프레스에서는 Cloudflare 플러그인을 설치해서 캐시 자동 퍼지 연동을 설정할 수 있다.

이미지 최적화: WebP 변환과 Lazy Loading

워드프레스 사이트에서 가장 무거운 리소스는 거의 항상 이미지이다. HTTP Archive 통계에 따르면 평균 웹페이지 용량의 약 50%를 이미지가 차지한다. 이미지 최적화만 제대로 해도 로딩 시간이 40~60% 줄어드는 경우가 많다.

WebP 변환

WebP 포맷은 JPEG 대비 25~35% 작은 용량으로 동등한 화질을 유지한다. 워드프레스 5.8부터 WebP 업로드를 기본 지원하지만, 기존 이미지를 일괄 변환하려면 플러그인이 필요하다. ShortPixel이나 Imagify가 대표적이다. 수동으로 변환하고 싶다면 cwebp CLI 도구를 사용할 수 있다.

#!/bin/bash
# 워드프레스 uploads 폴더의 이미지를 WebP로 일괄 변환하는 스크립트
# 사용법: bash convert-webp.sh /var/www/html/wp-content/uploads

UPLOADS_DIR="${1:-/var/www/html/wp-content/uploads}"
QUALITY=82
CONVERTED=0
SKIPPED=0
TOTAL_SAVED=0

echo "===== WebP 변환 시작 ====="
echo "대상 디렉토리: $UPLOADS_DIR"
echo "품질 설정: $QUALITY"
echo ""

# cwebp 설치 확인
if ! command -v cwebp &> /dev/null; then
    echo "Error: cwebp가 설치되어 있지 않습니다."
    echo "설치 방법:"
    echo "  Ubuntu/Debian: sudo apt install webp"
    echo "  macOS: brew install webp"
    echo "  CentOS: sudo yum install libwebp-tools"
    exit 1
fi

# JPEG 파일 변환
find "$UPLOADS_DIR" -type f \( -name "*.jpg" -o -name "*.jpeg" \) | while read -r file; do
    webp_file="${file%.*}.webp"

    # 이미 WebP 파일이 존재하면 건너뛰기
    if [ -f "$webp_file" ]; then
        SKIPPED=$((SKIPPED + 1))
        continue
    fi

    # 원본 파일 크기
    original_size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)

    # WebP 변환 (lossy, 품질 82)
    cwebp -q "$QUALITY" -m 6 "$file" -o "$webp_file" 2>/dev/null

    if [ $? -eq 0 ]; then
        new_size=$(stat -f%z "$webp_file" 2>/dev/null || stat -c%s "$webp_file" 2>/dev/null)
        saved=$((original_size - new_size))
        TOTAL_SAVED=$((TOTAL_SAVED + saved))
        CONVERTED=$((CONVERTED + 1))
        echo "[OK] $(basename "$file") → $(basename "$webp_file") (${saved} bytes 절약)"
    else
        echo "[FAIL] $(basename "$file") 변환 실패"
    fi
done

# PNG 파일 변환 (lossless)
find "$UPLOADS_DIR" -type f -name "*.png" | while read -r file; do
    webp_file="${file%.*}.webp"

    if [ -f "$webp_file" ]; then
        continue
    fi

    cwebp -lossless "$file" -o "$webp_file" 2>/dev/null

    if [ $? -eq 0 ]; then
        CONVERTED=$((CONVERTED + 1))
        echo "[OK] $(basename "$file") → $(basename "$webp_file")"
    fi
done

echo ""
echo "===== 변환 완료 ====="
echo "변환된 파일: $CONVERTED개"
echo "건너뛴 파일: $SKIPPED개"

변환한 WebP 파일을 워드프레스에서 자동으로 제공하려면 .htaccess에 rewrite 규칙을 추가하거나, <picture> 태그를 활용한다.

Lazy Loading과 반응형 이미지(srcset)

Lazy Loading은 화면에 보이지 않는 이미지를 나중에 로드하는 기법이다. 워드프레스 5.5부터 img 태그에 loading="lazy"가 기본 적용되지만, 테마에 따라 누락되는 경우가 있다. functions.php에서 확실하게 제어할 수 있다.

// functions.php에 추가
// 모든 이미지에 lazy loading 강제 적용 + WebP 소스 자동 추가

/**
 * 이미지 태그에 loading="lazy"와 decoding="async" 속성 추가
 * 첫 화면(above-the-fold)에 표시되는 이미지는 제외
 */
function optimize_image_attributes($attr, $attachment, $size) {
    // 썸네일이나 첫 화면 이미지는 lazy loading 제외
    if ($size === 'thumbnail' || $size === 'hero-image') {
        $attr['loading'] = 'eager';
        $attr['fetchpriority'] = 'high';
    } else {
        $attr['loading'] = 'lazy';
        $attr['decoding'] = 'async';
    }

    return $attr;
}
add_filter('wp_get_attachment_image_attributes', 'optimize_image_attributes', 10, 3);

/**
 * <picture> 태그로 WebP 우선 제공
 * 브라우저가 WebP를 지원하면 WebP를, 아니면 원본을 보여줌
 */
function serve_webp_with_picture_tag($content) {
    if (is_admin()) {
        return $content;
    }

    // img 태그를 찾아서 picture 태그로 감싸기
    $content = preg_replace_callback(
        '/]+)src=["\']([^"\']+)\.(jpe?g|png)["\'](.*?)>/i',
        function ($matches) {
            $img_tag = $matches[0];
            $before_src = $matches[1];
            $file_path = $matches[2];
            $extension = $matches[3];
            $after_src = $matches[4];

            $webp_url = $file_path . '.webp';

            // WebP 파일이 실제로 존재하는지 확인
            $upload_dir = wp_upload_dir();
            $webp_path = str_replace(
                $upload_dir['baseurl'],
                $upload_dir['basedir'],
                $webp_url
            );

            if (!file_exists($webp_path)) {
                return $img_tag; // WebP 없으면 원본 유지
            }

            return sprintf(
                '' .
                '' .
                '' .
                '%s' .
                '',
                $webp_url,
                $file_path,
                $extension,
                ($extension === 'png') ? 'png' : 'jpeg',
                $img_tag
            );
        },
        $content
    );

    return $content;
}
add_filter('the_content', 'serve_webp_with_picture_tag');

/**
 * 이미지 srcset에 적절한 크기 추가
 * 불필요하게 큰 이미지가 로드되는 것을 방지
 */
function custom_image_sizes() {
    // 블로그 콘텐츠 영역 폭에 맞는 크기 추가
    add_image_size('content-sm', 480, 9999);   // 모바일
    add_image_size('content-md', 768, 9999);   // 태블릿
    add_image_size('content-lg', 1024, 9999);  // 데스크톱
}
add_action('after_setup_theme', 'custom_image_sizes');

/**
 * 기본 max srcset 이미지 폭 제한 (2560px → 1600px)
 * 불필요하게 큰 이미지가 srcset에 포함되는 것을 방지
 */
function limit_srcset_max_width($max_width) {
    return 1600;
}
add_filter('max_srcset_image_width', 'limit_srcset_max_width');

이 설정만으로도 이미지 관련 LCP가 상당히 개선된다. 내 경우 히어로 이미지에 fetchpriority="high"를 적용하고 나머지를 lazy로 바꾸니 LCP가 6.2초에서 3.1초로 줄었다.

데이터베이스 정리: 리비전, Transient, 스팸 댓글 제거

워드프레스를 1년 이상 운영하면 데이터베이스에 불필요한 데이터가 상당히 쌓이다. 글을 저장할 때마다 생기는 리비전, 만료된 Transient 캐시, 스팸 댓글, 삭제된 글의 메타 데이터 등이 대표적이다. DB 크기가 커지면 쿼리 속도가 느려지고, 이것이 TTFB 증가로 이어진다.

WP-Optimize 플러그인을 사용하면 UI에서 클릭 몇 번으로 정리가 가능하다. 하지만 정확히 뭘 지우는지 알고 싶다면, 또는 플러그인 설치 없이 직접 처리하고 싶다면 SQL을 사용한다. 아래 쿼리를 실행하기 전에 반드시 DB 백업을 먼저 해두자. 나도 한 번은 백업 없이 쿼리를 돌렸다가 식은땀을 흘린 적이 있다.

-- =============================================
-- WordPress 데이터베이스 최적화 쿼리 모음
-- 실행 전 반드시 mysqldump로 백업할 것!
-- mysqldump -u root -p wordpress_db > backup_$(date +%Y%m%d).sql
-- =============================================

-- 1. 글 리비전 삭제
-- 워드프레스는 글을 저장할 때마다 리비전을 생성합니다.
-- 1년간 매일 1개 글을 쓰면 리비전만 수천 개가 쌓일 수 있음
SELECT COUNT(*) AS revision_count FROM wp_posts WHERE post_type = 'revision';

-- 리비전 관련 메타 데이터부터 삭제
DELETE pm FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_type = 'revision';

-- 리비전 글 삭제
DELETE FROM wp_posts WHERE post_type = 'revision';

-- 2. 자동 저장 초안(auto-draft) 삭제
DELETE FROM wp_posts WHERE post_status = 'auto-draft';

-- 3. 휴지통 비우기
DELETE pm FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_status = 'trash';

DELETE FROM wp_posts WHERE post_status = 'trash';

-- 4. 스팸 댓글 및 휴지통 댓글 삭제
DELETE FROM wp_commentmeta WHERE comment_id IN (
    SELECT comment_ID FROM wp_comments
    WHERE comment_approved IN ('spam', 'trash')
);

DELETE FROM wp_comments WHERE comment_approved IN ('spam', 'trash');

-- 5. 만료된 Transient 삭제
-- Transient는 플러그인이 임시 데이터를 저장하는 용도로 사용
-- 만료된 것은 불필요하므로 제거
DELETE FROM wp_options WHERE option_name LIKE '%_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP();

DELETE FROM wp_options
WHERE option_name LIKE '%_transient_%'
AND option_name NOT LIKE '%_transient_timeout_%'
AND option_name IN (
    SELECT REPLACE(option_name, '_timeout', '')
    FROM (
        SELECT option_name FROM wp_options
        WHERE option_name LIKE '%_transient_timeout_%'
        AND option_value < UNIX_TIMESTAMP()
    ) AS expired
);

-- 6. 고아 메타 데이터 정리 (연결된 글이 없는 메타)
DELETE FROM wp_postmeta
WHERE post_id NOT IN (SELECT ID FROM wp_posts);

DELETE FROM wp_commentmeta
WHERE comment_id NOT IN (SELECT comment_ID FROM wp_comments);

-- 7. 테이블 최적화 (삭제 후 빈 공간 회수)
OPTIMIZE TABLE wp_posts;
OPTIMIZE TABLE wp_postmeta;
OPTIMIZE TABLE wp_comments;
OPTIMIZE TABLE wp_commentmeta;
OPTIMIZE TABLE wp_options;

-- 최적화 결과 확인
SELECT
    table_name AS '테이블',
    ROUND(data_length / 1024 / 1024, 2) AS '데이터(MB)',
    ROUND(index_length / 1024 / 1024, 2) AS '인덱스(MB)',
    ROUND(data_free / 1024 / 1024, 2) AS '빈공간(MB)',
    table_rows AS '행 수'
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'wp_%'
ORDER BY data_length DESC;

향후 리비전이 무한히 쌓이는 것을 방지하려면 wp-config.php에 다음을 추가한다.

// wp-config.php - 리비전 개수 제한 및 자동저장 간격
define('WP_POST_REVISIONS', 5);      // 글당 리비전 최대 5개
define('AUTOSAVE_INTERVAL', 120);     // 자동저장 간격 120초 (기본 60초)

내 사이트에서 이 정리를 처음 했을 때 wp_posts 테이블에 리비전이 4,200개가 있었다. 정리 후 DB 크기가 48MB에서 12MB로 줄었고, 관리자 페이지 로딩 속도가 체감될 정도로 빨라졌다.

코드 최적화: CSS/JS 압축과 불필요한 스크립트 제거

워드프레스는 플러그인을 설치할 때마다 CSS와 JavaScript 파일이 추가된다. 플러그인을 10개 설치하면 HTTP 요청이 20~30개 늘어나는 것은 흔한 일이다. 문제는 이 스크립트들이 실제로 필요하지 않은 페이지에서도 로드된다는 점이다.

특정 페이지에서 불필요한 스크립트 제거

// functions.php에 추가
// 프론트 페이지에서 불필요한 플러그인 스크립트 제거

/**
 * Contact Form 7의 CSS/JS는 문의 페이지에서만 로드
 * 모든 페이지에 로드되는 것을 방지하여 불필요한 요청 제거
 */
function conditionally_load_cf7_assets() {
    // Contact Form 7이 활성화되어 있는지 확인
    if (!function_exists('wpcf7_enqueue_scripts')) {
        return;
    }

    // 문의 페이지가 아니면 CF7 스크립트/스타일 제거
    if (!is_page('contact') && !is_page('문의하기')) {
        wp_dequeue_script('contact-form-7');
        wp_dequeue_style('contact-form-7');
        wp_dequeue_script('wpcf7-recaptcha');
        wp_dequeue_style('wpcf7-recaptcha');
    }
}
add_action('wp_enqueue_scripts', 'conditionally_load_cf7_assets', 20);

/**
 * WooCommerce 스크립트를 쇼핑 관련 페이지에서만 로드
 */
function conditionally_load_woo_assets() {
    if (!class_exists('WooCommerce')) {
        return;
    }

    // 상품 페이지, 장바구니, 결제, 마이페이지가 아니면 제거
    if (!is_woocommerce() && !is_cart() && !is_checkout() && !is_account_page()) {
        wp_dequeue_style('woocommerce-general');
        wp_dequeue_style('woocommerce-layout');
        wp_dequeue_style('woocommerce-smallscreen');
        wp_dequeue_script('wc-cart-fragments');
        wp_dequeue_script('woocommerce');
        wp_dequeue_script('wc-add-to-cart');

        // selectWoo (Select2) 제거
        wp_dequeue_style('select2');
        wp_dequeue_script('selectWoo');
    }
}
add_action('wp_enqueue_scripts', 'conditionally_load_woo_assets', 20);

/**
 * 워드프레스 기본 제공 불필요 스크립트 제거
 */
function remove_unnecessary_wp_defaults() {
    // jQuery Migrate 제거 (최신 jQuery에서는 불필요)
    if (!is_admin()) {
        wp_deregister_script('jquery');
        wp_register_script('jquery', includes_url('/js/jquery/jquery.min.js'), array(), null, true);
    }

    // 이모지 관련 스크립트/스타일 제거 (대부분 불필요)
    remove_action('wp_head', 'print_emoji_detection_script', 7);
    remove_action('wp_print_styles', 'print_emoji_styles');
    remove_action('admin_print_scripts', 'print_emoji_detection_script');
    remove_action('admin_print_styles', 'print_emoji_styles');

    // oEmbed 관련 제거
    remove_action('wp_head', 'wp_oembed_add_discovery_links');
    remove_action('wp_head', 'wp_oembed_add_host_js');

    // REST API 링크 제거 (헤더에서)
    remove_action('wp_head', 'rest_output_link_wp_head');

    // Windows Live Writer 지원 제거
    remove_action('wp_head', 'wlwmanifest_link');

    // RSD 링크 제거
    remove_action('wp_head', 'rsd_link');

    // WP 버전 번호 제거 (보안 + 약간의 용량 절약)
    remove_action('wp_head', 'wp_generator');
}
add_action('wp_enqueue_scripts', 'remove_unnecessary_wp_defaults', 1);
add_action('init', 'remove_unnecessary_wp_defaults');

/**
 * CSS/JS에 defer, async 속성 추가
 * 렌더링 차단 리소스를 줄여서 FCP 개선
 */
function add_defer_async_to_scripts($tag, $handle, $src) {
    // 관리자 페이지에서는 적용하지 않음
    if (is_admin()) {
        return $tag;
    }

    // defer 적용 대상 (렌더링에 필수가 아닌 스크립트)
    $defer_scripts = array(
        'comment-reply',
        'contact-form-7',
        'wp-embed',
        'google-analytics',
        'gtag',
    );

    if (in_array($handle, $defer_scripts)) {
        return str_replace(' src', ' defer src', $tag);
    }

    return $tag;
}
add_filter('script_loader_tag', 'add_defer_async_to_scripts', 10, 3);

/**
 * 핵심 CSS를 인라인으로 삽입하여 렌더링 차단 제거
 * 나머지 CSS는 비동기로 로드
 */
function inline_critical_css() {
    if (is_admin()) {
        return;
    }

    $critical_css_path = get_template_directory() . '/critical.css';

    if (file_exists($critical_css_path)) {
        $critical_css = file_get_contents($critical_css_path);
        echo '' . "\n";
    }
}
add_action('wp_head', 'inline_critical_css', 1);

CSS/JS Minification

Autoptimize 플러그인을 사용하면 CSS와 JavaScript를 결합하고 압축할 수 있다. 설정에서 "CSS 코드 최적화", "JavaScript 코드 최적화", "HTML 코드 최적화"를 모두 활성화하면 된다. 다만 JS 결합 시 일부 플러그인이 깨질 수 있으므로, 활성화 후 사이트 전체를 확인해야 한다. 문제가 생기면 "Aggregate JS-files" 옵션을 끄고 개별 압축만 유지하는 것이 안전하다.

호스팅 환경과 PHP 버전이 속도에 미치는 영향

아무리 최적화를 해도 호스팅 자체가 느리면 한계가 있다. TTFB가 1초 이상이면 코드나 캐시 최적화로는 해결이 안 된다.

호스팅 유형별 일반적인 TTFB 범위이다.

호스팅 유형 TTFB 범위 월 비용 적합 대상
공유 호스팅 600ms~2초 $3~10 트래픽 적은 블로그
VPS 200ms~800ms $10~40 중간 트래픽 사이트
매니지드 WP 호스팅 100ms~400ms $25~100 비즈니스 사이트
클라우드 (AWS, GCP) 50ms~300ms $20~200+ 대규모 트래픽

PHP 버전도 속도에 직접적인 영향을 준다. PHP 8.2는 7.4 대비 약 2~3배의 성능 향상이 있고, PHP 8.3은 JIT 컴파일러 개선으로 추가적인 속도 향상이 있다. 현재 워드프레스 6.x는 PHP 8.2를 권장한다. 호스팅 패널(cPanel 등)에서 PHP 버전을 확인하고, 7.4 이하라면 반드시 올려야 한다. 다만 업그레이드 전에 PHP Compatibility Checker 플러그인으로 사용 중인 플러그인/테마가 호환되는지 먼저 확인하자.

OPcache 설정도 확인할 항목이다. 대부분의 호스팅에서 기본 활성화되어 있지만, 메모리 할당이 작으면 효과가 반감된다. php.ini에서 아래 설정을 권장한다.

; php.ini OPcache 설정 (워드프레스 최적화)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.enable_cli=0

; PHP 메모리 및 업로드 제한
memory_limit=256M
upload_max_filesize=64M
post_max_size=64M
max_execution_time=300

최적화 전후 실측 데이터 비교

위 과정을 모두 적용한 뒤의 실측 결과이다. 테스트 환경은 VPS(2코어, 4GB RAM), PHP 8.2, 플러그인 14개 설치 상태이다.

지표 최적화 전 최적화 후 개선율
PageSpeed (모바일) 28점 82점 +54점
PageSpeed (데스크톱) 51점 95점 +44점
LCP 6.2초 1.8초 -71%
TTFB 1.4초 320ms -77%
CLS 0.32 0.04 -87%
전체 페이지 용량 4.8MB 1.1MB -77%
HTTP 요청 수 87개 23개 -74%

가장 효과가 컸던 항목 순서는 이렇다: (1) 이미지 WebP 변환 + Lazy Loading, (2) 서버 캐시 + Cloudflare CDN, (3) 불필요한 스크립트 제거, (4) DB 정리. 특히 이미지 최적화 하나만으로 페이지 용량이 4.8MB에서 2.1MB로 줄었고, 캐시 설정까지 추가하니 TTFB가 급격히 떨어졌다.

속도 최적화에는 정답이 하나가 아니다. 사이트마다 병목 지점이 다르기 때문에, PageSpeed Insights 진단 결과를 보고 가장 점수가 낮은 항목부터 순서대로 개선하는 것이 효율적이다. 한 가지 확실한 건, 아무것도 안 하면 아무것도 안 변한다는 점이다.