오라클 클라우드(OCI) 무료 인스턴스에 Nginx를 올리고, Hugo PaperMod 테마로 기술 블로그를 운영한 지도 몇 달이 흘렀습니다. 평소에 “어차피 정적 사이트 생성기(SSG)니까 대충 CDN 태우면 속도는 90점 이상 그냥 나오겠지"라며 속도 문제는 거의 신경 쓰지 않고 글만 써왔습니다.

그러다 이번에 구글 애드센스 재심사를 준비하면서 사이트 전반의 품질을 점검해 보려고 오랜만에 Google PageSpeed Insights를 돌려보았습니다.

데스크톱 탭을 눌렀을 때는 90점대 후반이 찍히길래 ‘역시 SSG가 빠르긴 하네’라며 고개를 끄덕였는데, 모바일 탭을 누르는 순간 눈을 의심했습니다.

PageSpeed Insights 모바일 최적화 전 성능 진단 (53점)

모바일 성능 점수가 **53점(주황색 경고)**으로 바닥을 기고 있었습니다.

Lighthouse가 시뮬레이션한 모바일 환경(Moto G Power, 느린 4G 네트워크 환경)의 실측 수치는 생각보다 훨씬 심각했습니다:

  • First Contentful Paint (FCP): 9.9초 (빨간색)
  • Largest Contentful Paint (LCP): 12.5초 (빨간색)
  • Total Blocking Time (TBT): 210밀리초

첫 화면 텍스트 하나 뜨는 데 9.9초가 걸리고, 대표 콘텐츠가 렌더링되기까지 12.5초가 걸린다는 뜻입니다. 모바일로 유입된 방문자라면 화면이 뜨기도 전에 뒤로 가기를 눌렀을 게 뻔한 수치였습니다.

“오라클 프리티어 인스턴스(1 OCPU, 1GB RAM) 성능이 딸려서 그런가?“라는 의심도 잠시 해봤지만, 데이터베이스 쿼리를 날리는 동적 사이트도 아니고 빌드된 정적 HTML/CSS를 서빙할 뿐인데 10초씩 걸린다는 건 하드웨어 문제가 아니라 명백한 프론트엔드와 웹서버 세팅의 병목이었습니다.

결국 주말을 반납하고 병목의 원인을 하나씩 뜯어고쳤습니다. 이번 글에서는 53점짜리 모바일 블로그를 **66점으로 끌어올리고(FCP 9.9초 ➔ 3.8초, LCP 12.5초 ➔ 6.5초), 데스크톱 점수를 99점(FCP 0.4초)**까지 달성한 실전 최적화 과정을 가감 없이 정리해보았습니다.


1. 감사 리포트 분석: 범인은 두 놈이었다

PageSpeed Insights의 진단(Diagnostics) 섹션을 내려보니 빨간색 경고 딱지가 붙은 주범이 정확히 두 군데로 압축되었습니다.

PageSpeed Insights 인사이트 진단 세부 내역 (렌더링 차단 및 캐시 부재)

범인 1: “사용하지 않는 자바스크립트 961KiB” (Mermaid.js)

가장 어처구니없었던 병목입니다. 가끔 시스템 아키텍처나 시퀀스 다이어그램을 포스팅할 때 쓰려고 Hugo 테마 footer에 CDN으로 걸어두었던 mermaid.min.js가 문제였습니다.

다이어그램이 들어있는 특정 글에서만 로드되어야 할 이 무거운 라이브러리가, 다이어그램이 단 하나도 없는 메인 홈 화면(/)과 글 목록 페이지(/posts/)에서도 무차별적으로 전역 로드되고 있었습니다.

압축 전 용량만 거의 1MB에 달하는 자바스크립트를 첫 페이지 로딩 때마다 다운로드하고, 파싱하고, 컴파일하느라 저사양 모바일 기기의 브라우저 메인 스레드가 굳어버린 것이었습니다.

범인 2: “렌더링 차단 요청 5,050ms & 캐시 정책 부재 141KiB”

Nginx 웹서버 설정도 엉망이었습니다:

  1. Gzip 압축 미적용: nginx.conf에 gzip on;은 켜져 있었지만, 정작 중요한 gzip_types가 비어 있어서 text/html 외의 CSS, JS, SVG 파일들은 원본 크기 그대로 브라우저에 전송되고 있었습니다.
  2. 브라우저 캐시 헤더 누락: 정적 자산(CSS, JS, 폰트, 이미지)에 Cache-Control이나 Expires 헤더가 전혀 지정되어 있지 않았습니다. 방문자가 글을 하나 읽고 다른 글로 넘어갈 때마다 똑같은 스타일시트와 폰트를 매번 서버에서 풀 다운로드하고 있었던 것입니다.

2. 작업 1: Hugo 템플릿의 Mermaid.js 조건부 로딩

첫 번째 목표는 다이어그램이 없는 모든 페이지에서 961KiB짜리 Mermaid.js를 완전히 쫓아내는 것이었습니다.

1단계: 조건 분기 코드 작성 (extend_footer.html)

PaperMod 테마는 layouts/partials/extend_footer.html을 통해 사용자 커스텀 스크립트를 삽입할 수 있게 지원합니다. 기존에는 여기에 <script src="...mermaid.min.js">가 그냥 박혀 있었습니다.

이를 단일 포스팅(.IsPage)이면서, 실제로 다이어그램이 쓰인 경우에만 로드되도록 조건문으로 감쌌습니다.

<!-- layouts/partials/extend_footer.html -->

<!-- 1. Mermaid.js Diagram Engine (조건부 로딩) -->
{{- $hasMermaid := false -}}
{{- if .IsPage -}}
  {{- if or (.Params.mermaid) (.HasShortcode "mermaid") (findRE "language-mermaid" .Content) (findRE `class=["']?mermaid` .Content) -}}
    {{- $hasMermaid = true -}}
  {{- end -}}
{{- end -}}

{{- if $hasMermaid -}}
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
  (function() {
    function initMermaid() {
      var codeBlocks = document.querySelectorAll("pre code.language-mermaid");
      codeBlocks.forEach(function(code) {
        var div = document.createElement("div");
        div.className = "mermaid";
        div.textContent = code.textContent;
        var pre = code.closest("pre");
        if (pre && pre.parentNode) {
          pre.parentNode.replaceChild(div, pre);
        }
      });

      var isDark = document.body.classList.contains("dark") || 
                   (!document.body.classList.contains("light") && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);

      mermaid.initialize({
        startOnLoad: false,
        theme: isDark ? 'dark' : 'neutral',
        securityLevel: 'loose'
      });

      mermaid.run({
        querySelector: '.mermaid'
      });
    }

    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", initMermaid);
    } else {
      initMermaid();
    }
  })();
</script>
{{- end -}}

조건문 판별 조건은 총 4가지로 꼼꼼하게 잡았습니다:

  • .Params.mermaid: front matter에 mermaid: true가 명시된 글 (예: Tomcat 세션 클러스터링 글)
  • .HasShortcode "mermaid": 숏코드로 작성된 경우
  • findRE "language-mermaid" .Content: 마크다운 ```mermaid 코드 블록
  • findRE "class=[\"']?mermaid" .Content: Hugo의 렌더 훅(render-codeblock-mermaid.html)이 <div class="mermaid">로 변환해 둔 본문

2단계: 복병 — PaperMod의 partialCached 버그와 마주치다

조건문을 작성하고 기분 좋게 hugo --minify 빌드를 돌렸는데, 브라우저로 확인해 보니 정작 다이어그램이 들어가야 할 글에서도 Mermaid가 로드되지 않는 대참사가 벌어졌습니다.

“조건식 정규표현식이 틀렸나?” 싶어 HTML 소스를 열어보았는데, 다이어그램이 있는 글의 footer에 mermaid.min.js는커녕 주석조차 남아있지 않았습니다.

원인을 추적하기 위해 PaperMod 테마의 기본 레이아웃 파일인 themes/PaperMod/layouts/_default/baseof.html을 열어보았고, 28번째 줄을 보는 순간 머리를 한 대 맞은 것 같았습니다.

<!-- themes/PaperMod/layouts/_default/baseof.html 원본 28행 -->
{{ partialCached "footer.html" . .Layout .Kind (.Param "hideFooter") (.Param "ShowCodeCopyButtons") -}}

Hugo의 partialCached 함수는 지정한 캐시 키를 기준으로 템플릿 실행 결과를 메모리에 캐싱해 재사용합니다. 테마 제작자는 빌드 속도를 높이려고 footer.html에 캐싱을 걸어둔 것입니다.

그런데 넘겨주는 캐시 키를 보면 .Layout, .Kind, hideFooter, ShowCodeCopyButtons만 들어가 있습니다. 개별 페이지를 구별할 수 있는 고유 키(.Page.RelPermalink)가 캐시 키에 전혀 없었던 것입니다.

이로 인해 발생한 문제는 치명적이었습니다:

  1. Hugo가 사이트를 빌드할 때 수백 개의 글 중 **가장 먼저 렌더링된 일반 글(Mermaid 없음)**에서 footer.html을 평가합니다.
  2. Mermaid가 없으니 footer에는 스크립트가 빠진 HTML이 생성됩니다.
  3. Hugo는 이 “Mermaid가 빠진 footer"를 위 캐시 키로 메모리에 영구 저장해 버립니다.
  4. 그 뒤에 빌드되는 다이어그램 포함 포스팅들도 캐시된 빈 footer를 그대로 복사받아 렌더링되면서 다이어그램이 전부 깨져버린 것입니다.

해결책: baseof.html 오버라이드

해결 방법은 간단했습니다. 테마 서브모듈을 직접 건드리는 대신, 블로그 프로젝트 루트의 layouts/_default/baseof.html에 파일을 생성하여 테마의 baseof.html을 오버라이드했습니다.

<!-- layouts/_default/baseof.html 오버라이드 파일 -->
{{- if lt hugo.Version "0.146.0" }}
{{- errorf "=> hugo v0.146.0 or greater is required for hugo-PaperMod to build " }}
{{- end -}}

<!DOCTYPE html>
<html lang="{{ site.Language }}" ...>
<head>
    {{- partial "head.html" . }}
</head>
<body id="top">
    {{ partialCached "header.html" . .Page -}}
    <main class="main">
        {{- block "main" . }}{{ end }}
    </main>
    <!-- partialCached 대신 partial을 사용하여 페이지별 extend_footer가 온전히 평가되도록 수정 -->
    {{ partial "footer.html" . -}}
</body>
</html>

partialCached를 일반 partial로 바꿔주었습니다. Hugo는 빌드 속도가 워낙 사기적이라 300페이지 남짓한 블로그에서 footer 캐싱을 풀더라도 빌드 시간 차이는 0.1초도 나지 않습니다.

다시 빌드한 뒤 파이썬 스크립트로 검증한 결과입니다:

  • 홈 메인 화면 (/index.html): mermaid.min.js 없음 ✅ (961KiB 절감 성공)
  • 글 목록 화면 (/posts/index.html): mermaid.min.js 없음 ✅
  • 일반 포스팅 (/posts/part-01-intro/): mermaid.min.js 없음 ✅
  • 다이어그램 포스팅 (tomcat-session-clustering, internal-ipam 등): mermaid.min.js 정상 로드 ✅

3. 작업 2: Nginx 웹서버 튜닝 (압축, 브라우저 캐싱, HTTP/2)

템플릿 쪽 군살을 뺐으니, 이제 Nginx 웹서버 차례였습니다. 설정 파일(/etc/nginx/sites-available/default)을 열고 본격적인 튜닝을 진행했습니다.

1단계: Gzip 압축 활성화

정적 텍스트 자산(CSS, JS, SVG, JSON)을 브라우저에 보낼 때 실시간 압축 전송하도록 설정했습니다. 압축 레벨은 CPU 부하와 압축률의 밸런스가 가장 좋은 6으로 맞췄습니다.

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

2단계: 정적 자산 1년 브라우저 캐시 설정

Hugo는 빌드 시 정적 자산의 파일명에 해시값(예: stylesheet.de05b5...css)을 붙여주므로, 브라우저 캐시 유효기간을 최대로 잡아도 새 글 배포 시 캐시 오염 걱정이 없습니다. 만료 기간을 1년(expires 1y;)으로 설정했습니다.

# 정적 자산 브라우저 캐시 (1년)
location ~* \.(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, no-transform";
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    access_log off;
}

💡 Nginx 튜닝 시 꼭 알아둬야 할 주의점:
Nginx는 특정 location 블록 안에서 add_header 지시어가 한 번이라도 선언되면, 상위 server 블록에 선언해 둔 add_header 설정들을 전부 무시하고 덮어써 버리는 고약한 상속 규칙이 있습니다.
따라서 정적 자산 location 블록 안에도 보안 헤더(X-Content-Type-Options, X-Frame-Options, HSTS)를 반드시 다시 적어주어야 CSS/JS 파일 요청 시에도 보안 헤더가 누락되지 않습니다.

3단계: HTTP/2 활성화

기존 listen 443 ssl; 지시어 뒤에 http2를 추가하여 단일 TCP 커넥션으로 여러 정적 자산을 다중화(Multiplexing) 전송할 수 있도록 했습니다.

server {
    listen 443 ssl http2;
    server_name chanyeols.com www.chanyeols.com;
    ...

설정 후 문법 검사와 무중단 리로드를 실행했습니다:

sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

sudo systemctl reload nginx

실제 curl -I로 검증해보니 헤더가 예쁘게 떨어집니다:

HTTP/2 200 
content-type: text/css
content-encoding: gzip
expires: Fri, 17 Sep 2027 07:57:35 GMT
cache-control: public, max-age=31536000, no-transform
x-content-type-options: nosniff
strict-transport-security: max-age=31536000; includeSubDomains

4. 작업 3: 놓치면 한 달 뒤에 터지는 시한폭탄 — /ssl/auto_setup_ssl.sh 동기화

Nginx 세팅까지 다 끝내고 터미널을 닫으려던 찰나, 머릿속에 불길한 스파크가 튀었습니다.

“잠깐, 내 서버 Let’s Encrypt SSL 인증서 자동 갱신 어떻게 돌고 있었지?”

황급히 sudo crontab -l을 쳐보았습니다:

0 3 1 * * /ssl/auto_setup_ssl.sh

매월 1일 새벽 3시마다 /ssl/auto_setup_ssl.sh 스크립트가 돌도록 크론이 걸려 있었습니다. 곧바로 해당 스크립트의 내용을 까보았습니다.

# 기존 /ssl/auto_setup_ssl.sh 일부
echo "Step 2: Nginx 설정 업데이트..."
cat <<EOF > $NGINX_CONF
server {
    listen 443 ssl;
    server_name $DOMAIN;
    ...
    # (예전 구형 Nginx 설정이 하드코딩되어 있음)
}
EOF
nginx -t && systemctl restart nginx

등골이 서늘해졌습니다.

이 스크립트는 Certbot으로 인증서를 갱신한 뒤, 자기가 들고 있는 기본 템플릿으로 /etc/nginx/sites-available/default 파일을 통째로 덮어쓰는 구조였습니다.

만약 이 스크립트를 발견하지 못하고 그대로 뒀다면, 다음 달 1일 새벽 3시가 되는 순간 오늘 땀 흘려 적용한 Gzip 압축, HTTP/2, 1년 캐시 설정이 흔적도 없이 초기화되었을 것입니다. 다음 날 아침에 사이트가 다시 53점으로 굴러떨어졌을 텐데 원인을 찾느라 또 머리를 쥐어뜯었을 게 뻔했습니다.

게다가 기존 스크립트에는 또 다른 숨은 버그도 있었습니다: cat <<EOF 블록 안에서 프록시 설정의 $host, $remote_addr, $proxy_add_x_forwarded_for 같은 Nginx 변수 앞에 이스케이프(\)가 빠져 있었습니다. 쉘이 스크립트를 실행하면서 이 변수들을 bash 환경변수로 착각해 전부 빈 문자열("")로 치환해 버리고 있었던 것입니다.

즉시 /ssl/auto_setup_ssl.sh를 백업하고, 스크립트 내부의 Nginx 템플릿을 방금 튜닝한 최신 설정과 100% 동일하게 일치시켰습니다:

  • Nginx 런타임 변수 이스케이프(\$host, \$remote_addr) 완벽 처리
  • gzip, http2, 정적 캐시, 보안 헤더 템플릿 동기화
  • 갱신 후 명령어를 연결이 끊기는 restart 대신 무중단인 reload로 변경

diff 명령어로 현재 동작 중인 Nginx 설정과 스크립트가 생성할 파일 간의 차이가 0임을 완벽히 검증했습니다. 이제 다음 달 1일이 되어도 최적화 세팅이 날아갈 걱정이 없습니다.


5. 최종 결과: 숫자가 증명하는 성능 개선

모든 작업을 마친 뒤, 다시 Google PageSpeed Insights 측정기를 돌렸습니다.

1) 모바일 성능: 53점 ➔ 66점 (+13점 상승)

모바일 성능 최적화 후 점수 (66점, FCP 3.8초, LCP 6.5초)

가장 답답했던 모바일 렌더링 지표가 극적으로 개선되었습니다:

핵심 지표 최적화 전 (Before) 최적화 후 (After) 개선 수치
종합 성능 점수 53점 (경고) 66점 +13점 상승
First Contentful Paint (FCP) 9.9초 3.8초 6.1초 단축 (61.6% 개선)
Largest Contentful Paint (LCP) 12.5초 6.5초 6.0초 단축 (48.0% 개선)
Total Blocking Time (TBT) 210밀리초 120밀리초 녹색 양호 구간 진입
Speed Index 지연 심각 4.4초 정상권 진입

모바일 최적화 후 세부 진단 결과 (렌더링 차단 개선)

세부 진단 항목에서도 5초 이상 걸리던 렌더링 차단 요청이 대폭 해소되었고, 141KiB에 달하던 효율적인 캐시 수명 경고는 24KiB로 83%나 줄어들었습니다. 남은 24KiB는 외부 애널리틱스 관련 스크립트라 오리진 서버에서 더 손댈 수 없는 영역입니다.


2) 데스크톱 성능: 99점 (완벽한 녹색 트리플 100 달성)

모바일 환경을 최적화하고 나니, 상대적으로 네트워크와 CPU 성능이 여유로운 데스크톱 환경은 그야말로 날아다니기 시작했습니다.

PageSpeed Insights 최적화 이후 데스크톱 점수 (99점)

데스크톱 성능은 99점을 기록했습니다:

  • First Contentful Paint (FCP): 0.4초
  • Largest Contentful Paint (LCP): 0.6초
  • Total Blocking Time (TBT): 20밀리초
  • Cumulative Layout Shift (CLS): 0

접근성(100), 권장사항(100), 검색엔진 최적화(100)까지 합쳐 사실상 모든 평가 기준에서 만점에 가까운 성적표를 받았습니다. 클릭하자마자 브라우저가 화면을 즉시 그려냅니다.


6. 작업을 마치며 얻은 교훈

이번 삽질을 통해 몇 가지 중요한 교훈을 얻었습니다:

  1. 정적 사이트(SSG)라고 해서 성능이 공짜로 주어지지 않는다. 편하다고 무심코 얹은 1MB짜리 다이어그램 JS 하나가 모바일 접속자 수천 명의 첫 로딩을 10초씩 지연시키는 주범이 될 수 있습니다. 외부 라이브러리는 반드시 필요한 페이지에만 핀포인트로 조건부 로딩해야 합니다.
  2. 테마의 내부 캐싱 메커니즘(partialCached)을 의심해 보자. 내가 작성한 템플릿 조건문이 아무런 문법 오류 없이 정상인데도 화면에 반영되지 않는다면, 상위 템플릿 어딘가에서 엉뚱한 캐시 키로 전역 캐싱을 때리고 있을 확률이 높습니다.
  3. Nginx Gzip과 브라우저 캐시는 최고의 가성비 튜닝이다. 단 20줄 남짓한 Nginx 설정만으로 FCP와 LCP를 6초 이상 끌어내릴 수 있었습니다. 인프라를 증설하기 전에 웹서버 기본기부터 점검하는 것이 먼저입니다.
  4. 인프라 자동화 스크립트(Cron)는 늘 의식하고 있어야 한다. 웹서버 설정을 아무리 완벽하게 튜닝해 두어도, 백그라운드에서 주기적으로 도는 갱신 스크립트가 이를 덮어쓰고 있다면 모래성 위에 집을 짓는 것과 같습니다. 변경 사항은 반드시 자동화 스크립트에도 함께 반영해야 합니다.