많은 사람들이 네이버나 티스토리 같은 블로그 플랫폼에 블로그를 구축하여 운영하고 있지만 필자는 IT분야의 개발, 시스템엔지니어, 보안컨설턴트의 경력을 갖고 있기에 경험을 살려 클라우드에 서버를 직접 구축해 운영하고 있다. 다음 이미지에서와 같이 필자의 블로그는 오라클 클라우드의 프리티어(Free Tier) 인스턴스 2개를 사용해 구축되어 있다.

www라는 이름의 인스턴스에는 Apache Web Server 2/PHP 8.3 FPM와 함께 CMS인 WordPress가 설치되어 있고 mariadb라는 인스턴스에는 워드프레스에서 작성한 글이 저장되는 MariaDB 10이 설치되어 있다. 웹서버와 DB 인스턴스가 분리되어 있는 것이 특징이다.
문제는 웹서버와 DB서버가 분리되어 있음에도 상시 무료티어 인스턴스이기 때문에 가상서버의 스펙이 1 Core / 1 GB에 지나지 않아 성능이 넉넉치는 않다는 것이다. 그나마 웹서버와 DB서버가 분리하여 구성했기에 3년이 되도록 성능면에서 문제없이 운영이 가능했다.
응답 속도 개선의 필요성을 느끼다
티스토리 블로그를 워드프레스로 이사하면서 도메인주소, 포스트의 URI 등을 모두 동일하게 옮겼지만 SEO 설정을 1개월 가까이 누락하는 실수를 저질렀기 때문인지 구글 검색엔진에서 검색 노출 순위가 지속적으로 하락했다. 결국 하루 3,000 PV를 넘나들던 일간 PV가 현재 300 PV 정도까지 하락했다. 그나마 더 이상 하락하지 않는 것을 다행으로 생각하고 있다.
검색 노출 순위의 하락에는 여러 이유가 있겠지만 그 중 하나가 바로 페이지의 응답속도 하락이 아닐까 싶다. Tistory에 둥지를 틀고 있을 때는 빠른 응답속도를 보이는 서버와 CDN에서 제공되는 이미지 캐시 서버의 서비스를 받았지만 Tistory를 벗어나 1 Core / 1 GB 가상 서버의 워드프레스에서는 Tistory에서 제공하는 서버의 블로그들 만큼 응답속도가 나오지는 않는다.
그래서 이미지 캐시서버를 직접 구축해 응답 속도를 개선하기로 했다. 이미지의 포맷도 webp로 변경하여 이미지 캐시서버의 효용성도 더 높이기로 했다. 이참에 Nginx를 사용하여 이미지 캐시서버를 구축하는 공부도 하고 말이다.
워드프레스 블로그의 이미지 캐시 서버 구성
웹서버와 DB서버로 구성되어 있는 필자의 워드프레스 블로그 구성에 이미지 캐시서버를 다음과 같이 추가하기로 했다.

Nginx 캐시서버가 바로 이미지 캐싱에 사용될 서버인데 앞의 오라클 클라우드에서 사용하고 있는 3대의 서버 중 ap-svr-1 서버가 바로 그 서버다. 이 서버도 무료티어로 사용할 수 있지만 CPU가 ARM 4 Core이고 RAM이 24 GB로 상당한 용량과 성능을 보이는 스펙이다. 다만 상시 무료는 아니고 언제 서비스가 종료될지 모르는 서비스다. 하지만 회수 당하는 그날까지 써먹을 예정이다.
구성도를 기준으로 이미지 캐시서버의 동작흐름을 설명하자면
- 방문자가 블로그의 특정 글에 방문하게 되면 워드프레스는 wordpress DB에 저장되어 있는 글을 브라우저로 전송한다.
- 이 때 글에 포함되어 있는 <img> 태그의 이미지 주소를 웹서버 로컬에서 캐시서버(img.blogger.pe.kr) 로 변경하여 HTML 정적 페이지를 생성해 브라우저로 전송한다. 이미지 주소의 변환은 워드프레스 테마의 functions.php에 후킹 코드를 추가해 수행한다.
- 이 정적 HTML 페이지를 전송받은 브라우저는<img> 태그에 있는 이미지를 “아~ 이미지 파일은 blogger.pe.kr이 아니라 img.blogger.pe.kr에 있구나” 라고 이해하여 img.blogger.pe.kr에서 다운로드 하려고 요청한다.
- 그러면 브라우저로부터 이미지 다운로드 요청을 받은 캐시서버는 캐시된 이미지를 찾게 되는데 최초에는 캐시된 이미지가 없다. 그러면 imgproxy에게 원본주소인 blogger.pe.kr에 이미지를 가져와 webp로 변환하도록 요청한다.
- imgproxy는 원본 이미지가 있는 blogger.pe.kr에서 이미지를 다운로드 받아 webp로 변환하여 캐시에 저장한다.
- nginx는 변환된 webp 파일을 브라우저에게 전송한다.
- 다음에 동일한 이미지의 다운로드 요청이 오면 Nginx는 캐시되어 있는 이미지를 브라우저에게 전송한다. (이 때 매우 빠름)
이 구성의 장점은 원본이 jpg, png, gif 등의 이미지인 경우 imgproxy가 최소 20% 정도 용량이 줄어드는 webp 포맷으로 변환하여 브라우저에 제공하기 때문에 SEO의 속도측면에서 매우 유리하다. 게다가 웹서버의 부하를 이미지 캐시서버가 감당하게 되기 때문에 전체적인 웹서버의 응답속도 향상도 기대할 수 있다.
단점이라면 문제가 생겼을 때 관리상의 어려움이 있을 수 있겠다.
Nginx 설정 파일 준비 (nginx.conf)
이런 구성으로 이미지 캐시서버를 구축하는 과정을 기록해 둔다. 먼저 캐시서버로 사용할 Nginx의 설정파일을 준비한다.
# nginx.conf
events {}
http {
# 캐시 저장 경로 및 설정 (24GB 램과 디스크를 활용)
# levels: 디렉토리 깊이, keys_zone: 메모리 공유 영역 이름과 크기(100MB)
# max_size: 캐시 디스크 최대 크기(10GB), inactive: 7일간 안 쓰면 삭제
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:100m max_size=10g inactive=30d use_temp_path=off;
server {
listen 80;
location / {
# imgproxy로 요청 전달
proxy_pass http://imgproxy:8080;
# 캐시 설정 적용
proxy_cache my_cache;
# 캐시 키 설정 (URL이 같으면 같은 이미지로 취급)
proxy_cache_key "$scheme$request_method$host$request_uri";
# 상태코드 200(성공)인 경우 1년간 캐시 유지
proxy_cache_valid 200 1y;
# 오류가 나거나 타임아웃 시 캐시된 만료 데이터라도 보여줌 (가용성 확보)
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# 브라우저에게 보내는 헤더 (디버깅용: HIT 또는 MISS 표시)
add_header X-Proxy-Cache $upstream_cache_status;
# 클라이언트(Caddy)에게 보내는 헤더
add_header Cache-Control "public, max-age=31536000";
}
}
}
요청은 리버스 프록시인 Caddy를 사용해 처리할 예정이기 때문에 http인 80만 설정해줘도 된다. 만약 Caddy를 Nginx 앞단에 두지 않을 것이라면 80 보다는 443으로 설정하고 인증서 처리도 해줘야 한다. 하지만 필자는 Caddy를 Nginx 앞단에 붙일 것이기 때문에 Nginx에서는 80으로 통신을 해도 문제가 없다. Caddy는 자동으로 SSL 인증서를 알아서 갱신해주기 때문에 편리하다.
이 설정파일에는 브라우저에게서 요청받은 이미지를 원본주소(blogger.pe.kr)에서 다운로드 받아 webp로 변환하도록 imgproxy에게 전달하는 설정도 담겨있다. 그리고 webp로 변환한 다음 해당 이미지에 캐시 키를 설정하는 라인도 포함되어 있다. 이 캐시 키는 아래의 Caddyfile에서 이미지 주소를 원본 URL로 다시 변경해 Nginx에게 전달하는 값과 같다.
즉, Caddy에게서 받은 이미지의 URL에 해당하는 캐시 키가 있다면 해당 캐시 키를 가진 이미지가 캐싱되어 있다고 판단하고 imgproxy에게 webp 변환을 요청하지 않고 곧바로 브라우저에게 이미지를 제공하게 된다.
Portainer의 Stack으로 Nginx와 ImgProxy 컨테이너 구성
이제 이 파일을 적당한 위치에 생성한 다음 Portainer에서 Stack을 생성한다. 그리고 다음과 같이 JPG, GIF 등 이미지를 더 작고 가벼운 이미지 포맷인 webp로 변환하는 imgproxy 프로그램과 변환된 webp 이미지 파일을 캐싱하는 Nginx를 다음의 DockerCompose.yml을 사용해 스택으로 설치해준다.
ersion: '3.8'
services:
# 1. 이미지 변환기 (jpg, png, gif 등을 webp로 변환)
imgproxy:
image: darthsim/imgproxy:latest
container_name: imgproxy
restart: unless-stopped
networks:
- internal_net # 외부 노출 차단, Nginx하고만 통신
environment:
- IMGPROXY_ALLOWED_SOURCES=https://blogger.pe.kr/
- IMGPROXY_ENABLE_WEBP_DETECTION=true
- IMGPROXY_STRIP_METADATA=true
# 2. 캐시 관리자 (Nginx) -> Caddy와 연결됨
nginx_cache:
image: nginx:alpine
container_name: nginx_cache
restart: unless-stopped
volumes:
# 위에서 만든 설정 파일 마운트 (경로 수정 필요)
- /data/blogger-cache/nginx.conf:/etc/nginx/nginx.conf:ro
# 캐시 파일이 저장될 공간 (컨테이너 재시작해도 유지되도록 호스트의 볼륨 연결 설정)
- /data/blogger-cache/images:/var/cache/nginx
networks:
- caddy_net # Caddy와 통신
- internal_net # imgproxy와 통신
volumes:
cache_data: # 도커 볼륨 생성
networks:
caddy_net:
external: true
name: reverse_proxy
internal_net: # 스택 내부 전용 네트워크
driver: bridge
만약 Portainer를 사용하고 있지 않다면 도커 컴포즈(docker-compose.yml)를 사용해 수동으로 구성하면 되며 Caddy를 사용하고 있지 않다면 SSL 인증서 설정을 수동으로 해줘야 한다.
스택이 정상적으로 생성되었다면 Caddyfile에 도메인을 추가해준다.
도메인 설정 및 Caddyfile 편집
브라우저가 제공받은 img.blogger.pe.kr로 변환된 주소를 Caddy는 rewrite 룰에 의해 다시 원본 주소(blogger.pe.kr)가 포함된 주소로 변환해 캐시서버 역할을 하는 Nginx에게 보내도록 한다. 만약 해당 주소의 캐시된 이미지가 없다면 imgproxy에게 원본 주소(blogger.pe.kr)에서 원본 이미지를 다운로드 받아서 webp로 변환해 캐시에 저장하게 된다.

만약 Caddy를 사용하지 않는다면 Nginx에서 위의 rewrite 룰을 처리하면 된다.
이제 이 요청이 제대로 처리되도록 하기 위해 blogger.pe.kr 도메인에 새로운 호스트인 img를 추가해줘야 한다. 필자의 블로그 도메인은 Gabia에서 관리된다. Gabia의 도메인 관리도구에서 img 호스트를 추가해줬다.
마지막으로 할 일은 워드프레스에서 방문자가 있을 때 이미지 주소를 img.blogger.pe.kr로 변경하여 브라우저로 보내도록 하는 코드를 추가해줘야 한다.
테마파일(functions.php)에 코드 추가하기
워드프레스 관리자 화면에서 “모양” – “테마파일 편집기”로 가서 function.php 파일을 선택한 다음 적당한 곳에 다음의 코드를 추가해주면 된다.
// ==============================================================================
// [통합] 이미지 CDN 캐시 적용 (wp-content/uploads 및 tistory 경로 포함) 시작
// ==============================================================================
function my_custom_cdn_rewrite( $content ) {
// 1. 워드프레스 미디어 (wp-content/uploads)
// 예: http://..., https://..., 또는 그냥 /wp-content/... 모두 잡아서 교체
$content = preg_replace(
'/(https?:\/\/blogger\.pe\.kr)?\/wp-content\/uploads/i',
'https://blogger.pe.kr:6690https://img.blogger.pe.kr/wp-content/uploads',
$content
);
// 2. 티스토리 레거시 이미지 (/tistory)
// 예: https://img.blogger.pe.kr/tistory/821/... 패턴을 찾아서 CDN 주소로 교체
$content = preg_replace(
'/(https?:\/\/blogger\.pe\.kr)?\/tistory\//i',
'https://blogger.pe.kr:6690https://img.blogger.pe.kr/tistory/',
$content
);
return $content;
}
// 필터 적용
add_filter( 'the_content', 'my_custom_cdn_rewrite' ); // 본문 전체
add_filter( 'wp_get_attachment_url', 'my_custom_cdn_rewrite' ); // 첨부파일 URL
add_filter( 'theme_mod_header_image', 'my_custom_cdn_rewrite' ); // 헤더 이미지
// 반응형 이미지(srcset) 대응
add_filter( 'wp_calculate_image_srcset', function($sources) {
foreach ( $sources as &$source ) {
if ( isset( $source['url'] ) ) {
// wp-content/uploads 처리
$source['url'] = preg_replace(
'/(https?:\/\/blogger\.pe\.kr)?\/wp-content\/uploads/i',
'https://blogger.pe.kr:6690https://img.blogger.pe.kr/wp-content/uploads',
$source['url']
);
// tistory 처리
$source['url'] = preg_replace(
'/(https?:\/\/blogger\.pe\.kr)?\/tistory\//i',
'https://blogger.pe.kr:6690https://img.blogger.pe.kr/tistory/',
$source['url']
);
}
}
return $sources;
}, 10, 1 );
// ==============================================================================
// [통합] 이미지 CDN 캐시 적용 (wp-content/uploads 및 tistory 경로 포함) 끝
// ==============================================================================
이 코드는 브라우저로 전송할 블로그 글에 이미지가 저장된 경로인 wp-content/uploads 와 tistory 가 포함된 경우 서버 주소를 img.blogger.pe.kr를 추가하거나 변경하여 HTML을 생성하고 브라우저로 전송되도록 하는 코드다. 오작동의 가능성을 배제할 수 없지만 적용 이후 아직은 케이스가 발생하지 않았다.
여기까지 적용하고 테스트해보니 캐시서버 잘 동작함을 확인할 수 있었다. 웹 페이지 접근 후 소스보기를 하면 <img> 태그의 이미지 주소가 모두 img.blogger.pe.kr로 변경되어 있음을 확인할 수 있다.
그나저나 블로그의 시스템 구성이 점점 복잡해진다.
#워드프레스 #이미지캐시 #nginx #imgproxy #webp
정말 멋진 시도네요! 클라우드 서버 직접 구축하는 경험은 엄청난 배움이 될 것 같습니다.
IT분야 경험이 충분하시다면 서버 1대만 구하시면 어렵지 않게 구축하실 수 있으실거에요.