Let's Encrypt 인증서 자동화: 모니터링부터 OCI 배포까지

Let's Encrypt 인증서 자동화: 모니터링부터 OCI 배포까지

인증서 만료로 인한 장애를 방지하고 갱신 후 클라우드 배포까지 자동화하는 대시보드 구축 가이드


⏱️ 읽는데 약 10분 소요


🔒 Let's Encrypt와 인증서 관리의 고충

인증서 만료는 DevOps 엔지니어와 시스템 관리자들의 악몽입니다. Let's Encrypt 인증서는 무료라는 장점이 있지만, 90일마다 갱신해야 하는 번거로움이 있죠. 자동화 스크립트를 구성하더라도 배포 과정에서 오류가 발생하거나, 실제 서비스에 적용되었는지 확인하기 어려운 경우가 많습니다.

"장애 보고서를 살펴보면, 가장 흔한 원인 중 하나가 '인증서 만료' 입니다. 예방 가능한 문제가 비즈니스에 영향을 미치게 두지 마세요."

이 글에서는 다음 문제를 해결할 수 있는 대시보드를 구축하는 방법을 소개합니다:

  • 도메인별 인증서 실시간 모니터링 (현재 실제 서비스 중인 인증서 상태)
  • 수동 갱신 트리거 및 결과 추적
  • 인증서 갱신 후 OCI Load Balancer 또는 인스턴스에 자동 배포
  • 모든 과정의 로그 및 결과 모니터링

🏗️ 시스템 아키텍처

우리가 구축할 솔루션은 단순하면서도 효과적입니다:

React + shadcn/ui (프론트엔드) ←→ Express API (백엔드) ←→ certbot + OCI CLI

이 시스템은 인증서 상태를 실시간으로 모니터링하고, 만료가 임박한 인증서를 시각적으로 표시하며, 필요할 때 간단한 클릭으로 인증서를 갱신하고 배포할 수 있게 해줍니다.

주요 기능

  • 실시간 모니터링: 실제 서비스 중인 TLS 인증서 만료일 확인
  • 원클릭 갱신: certbot을 통한 Let's Encrypt 인증서 갱신
  • 자동 배포: OCI Load Balancer 또는 인스턴스에 인증서 자동 배포
  • 상세 로그: 모든 과정의 성공/실패 및 출력 로그 추적

💻 구축 가이드

1. 백엔드 구성 (Node.js + Express)

먼저 필요한 패키지를 설치하고 기본 구조를 생성합니다:

mkdir -p cert-monitor-dashboard/backend
cd cert-monitor-dashboard/backend
npm init -y
npm install express cors ssl-checker

도메인 정보를 관리할 JSON 파일을 준비합니다:

// data/domains.json
[
  {
    "domain": "yourdomain.com",
    "port": 443,
    "note": "메인 서비스",
    "certName": "yourdomain.com",
    "oci": {
      "lbId": "ocid1.loadbalancer.oc1..example",
      "certName": "cert-name-in-lb"
    }
  }
]

백엔드 서버를 구현합니다:

// server.js
const express = require('express');
const cors = require('cors');
const fs = require('fs/promises');
const path = require('path');
const { exec } = require('child_process');
const sslChecker = require('ssl-checker');

const app = express();
const PORT = process.env.PORT || 3040;

// 미들웨어
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../frontend/dist')));

// 데이터 파일 경로
const DOMAINS_FILE = path.join(__dirname, 'data/domains.json');

// 도메인 목록 API
app.get('/api/domains', async (req, res) => {
  try {
    const data = await fs.readFile(DOMAINS_FILE, 'utf8');
    const domains = JSON.parse(data);
    res.json(domains);
  } catch (err) {
    res.status(500).json({ error: `도메인 목록 불러오기 실패: ${err.message}` });
  }
});

// 인증서 상태 확인 API
app.get('/api/status/:domain', async (req, res) => {
  try {
    const domain = req.params.domain;
    const data = await fs.readFile(DOMAINS_FILE, 'utf8');
    const domains = JSON.parse(data);
    const info = domains.find(d => d.domain === domain);
    
    if (!info) {
      return res.status(404).json({ error: '도메인 정보를 찾을 수 없습니다' });
    }
    
    const status = await sslChecker(domain, { method: 'GET', port: info.port || 443 });
    res.json(status);
  } catch (err) {
    res.status(500).json({ error: `인증서 상태 확인 실패: ${err.message}` });
  }
});

// 인증서 갱신 및 배포 API
app.post('/api/renew/:domain', async (req, res) => {
  try {
    const domain = req.params.domain;
    const data = await fs.readFile(DOMAINS_FILE, 'utf8');
    const domains = JSON.parse(data);
    const info = domains.find(d => d.domain === domain);
    
    if (!info || !info.certName) {
      return res.status(404).json({ error: '도메인 정보를 찾을 수 없습니다' });
    }
    
    let result = { steps: [] };
    
    // Step 1: Certbot 인증서 갱신
    await new Promise((resolve) => {
      const cmd = `certbot renew --cert-name ${info.certName} --force-renewal --non-interactive`;
      exec(cmd, (err, stdout, stderr) => {
        result.steps.push({
          step: "certbot 인증서 갱신",
          command: cmd,
          ok: !err,
          stdout,
          stderr,
          error: err ? String(err) : undefined
        });
        resolve();
      });
    });
    
    // 인증서 갱신 실패 시 다음 단계 진행하지 않음
    if (!result.steps[0].ok) {
      result.ok = false;
      return res.json(result);
    }
    
    // Step 2: OCI LB 인증서 배포
    if (info.oci && info.oci.lbId && info.oci.certName) {
      const fullchain = `/etc/letsencrypt/live/${info.certName}/fullchain.pem`;
      const privkey = `/etc/letsencrypt/live/${info.certName}/privkey.pem`;
      
      const deployCmd = `oci lb certificate update --load-balancer-id ${info.oci.lbId} ` +
        `--certificate-name ${info.oci.certName} ` +
        `--public-certificate file://${fullchain} ` +
        `--ca-certificate file://${fullchain} ` +
        `--private-key file://${privkey}`;
      
      await new Promise((resolve) => {
        exec(deployCmd, (err, stdout, stderr) => {
          result.steps.push({
            step: "OCI LB 인증서 배포",
            command: deployCmd,
            ok: !err,
            stdout,
            stderr,
            error: err ? String(err) : undefined
          });
          resolve();
        });
      });
    }
    
    // Step 3: 서비스 반영 확인
    try {
      const certStatus = await sslChecker(domain, { method: 'GET', port: info.port || 443 });
      result.steps.push({
        step: "서비스 반영 확인",
        ok: certStatus.valid,
        certStatus
      });
    } catch (err) {
      result.steps.push({
        step: "서비스 반영 확인",
        ok: false,
        error: err.message
      });
    }
    
    result.ok = result.steps.every(s => s.ok !== false);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: `인증서 갱신 처리 실패: ${err.message}` });
  }
});

// SPA 지원
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});

app.listen(PORT, () => {
  console.log(`서버가 http://localhost:${PORT} 에서 실행 중입니다`);
});

2. 프론트엔드 구성 (React + shadcn/ui)

먼저 프로젝트를 설정하고 필요한 패키지를 설치합니다:

# React 앱 생성
cd cert-monitor-dashboard
npm create vite@latest frontend -- --template react
cd frontend

# 필요 패키지 설치
npm install
npm install tailwindcss postcss autoprefixer
npm install lucide-react
npx tailwindcss init -p
npx shadcn-ui@latest init

Vite 설정을 통해 개발 중 API 요청을 백엔드로 프록시합니다:

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3040'
    }
  }
})

이제 메인 대시보드 컴포넌트를 작성합니다:

// src/CertificateDashboard.jsx
import React, { useEffect, useState } from "react";
import { Card, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from "lucide-react";

const api = (path, opts) => fetch(path, opts).then(r => r.json());

export default function CertificateDashboard() {
  const [domains, setDomains] = useState([]);
  const [statuses, setStatuses] = useState({});
  const [renewLoading, setRenewLoading] = useState({});
  const [log, setLog] = useState(null);

  // 도메인 목록 불러오기
  useEffect(() => {
    api("/api/domains").then(setDomains);
  }, []);

  // 인증서 상태 불러오기
  useEffect(() => {
    if (domains.length === 0) return;
    domains.forEach(d => {
      api(`/api/status/${encodeURIComponent(d.domain)}`)
        .then(stat => setStatuses(prev => ({ ...prev, [d.domain]: stat })))
        .catch(e => setStatuses(prev => ({ ...prev, [d.domain]: { error: e.message } })));
    });
  }, [domains]);

  // 갱신 및 배포 핸들러
  const handleRenew = async domain => {
    setRenewLoading(prev => ({ ...prev, [domain]: true }));
    setLog(null);
    
    try {
      const result = await api(`/api/renew/${encodeURIComponent(domain)}`, { method: "POST" });
      setLog(result);
      
      // 갱신 후 상태 재조회
      setTimeout(() => {
        api(`/api/status/${encodeURIComponent(domain)}`)
          .then(stat => setStatuses(prev => ({ ...prev, [domain]: stat })))
          .catch(e => setStatuses(prev => ({ ...prev, [domain]: { error: e.message } })));
      }, 5000);
    } catch (e) {
      setLog({ error: e.message });
    }
    
    setRenewLoading(prev => ({ ...prev, [domain]: false }));
  };

  return (
    <div className="max-w-3xl mx-auto py-10 space-y-8 px-4">
      <h1 className="text-3xl font-bold text-center mb-8">인증서 관리 대시보드</h1>
      
      {domains.length === 0 && (
        <div className="flex justify-center py-8">
          <Loader2 className="animate-spin w-6 h-6 mr-2" /> 불러오는 중...
        </div>
      )}
      
      <div className="space-y-4">
        {domains.map(d => {
          const stat = statuses[d.domain];
          return (
            <Card key={d.domain} className="overflow-hidden">
              <div className="flex flex-col md:flex-row items-start md:items-center justify-between px-6 py-5 gap-6">
                <CardContent className="flex-1 px-0 py-0">
                  <CardTitle className="text-lg">{d.domain}</CardTitle>
                  <CardDescription className="mb-2">{d.note || ""}</CardDescription>
                  
                  {stat ? (
                    stat.error ? (
                      <div className="text-red-500 font-semibold flex items-center gap-1">
                        <AlertTriangle className="w-4 h-4" /> 오류: {stat.error}
                      </div>
                    ) : (
                      <div className="space-y-1">
                        <div>
                          만료일:{" "}
                          <span className={
                            stat.daysRemaining < 7
                              ? "text-red-600 font-bold"
                              : stat.daysRemaining < 28
                              ? "text-yellow-600 font-semibold"
                              : "text-green-700"
                          }>
                            {stat.validTo} ({stat.daysRemaining}일 남음)
                          </span>
                        </div>
                        <div>인증기관: {stat.issuer}</div>
                        {!stat.valid &&
                          <div className="text-red-700 font-bold">만료됨/유효하지 않음</div>
                        }
                      </div>
                    )
                  ) : (
                    <div className="text-gray-400 flex items-center gap-2">
                      <Loader2 className="animate-spin w-4 h-4" /> 조회중...
                    </div>
                  )}
                </CardContent>
                
                <div>
                  <Button
                    disabled={renewLoading[d.domain]}
                    onClick={() => handleRenew(d.domain)}
                    variant="outline"
                    size="sm"
                    className="flex items-center gap-1"
                  >
                    {renewLoading[d.domain] ? (
                      <Loader2 className="animate-spin h-4 w-4" />
                    ) : (
                      <RefreshCw className="h-4 w-4" />
                    )}
                    인증서 갱신/배포
                  </Button>
                </div>
              </div>
            </Card>
          );
        })}
      </div>
      
      {log && (
        <details open className="mt-8 bg-neutral-50 border rounded-lg overflow-hidden">
          <summary className="font-semibold cursor-pointer p-4 bg-neutral-100">
            갱신/배포 상세 로그
            {log.ok === true && (
              <span className="ml-2 inline-flex items-center text-green-700">
                <CheckCircle className="w-4 h-4 mr-1" /> 전체 성공
              </span>
            )}
            {log.ok === false && (
              <span className="ml-2 inline-flex items-center text-red-600">
                <XCircle className="w-4 h-4 mr-1" /> 일부 실패
              </span>
            )}
          </summary>
          
          <div className="p-4">
            {log.error && (
              <div className="bg-red-50 text-red-700 p-3 rounded mb-4">
                오류 발생: {log.error}
              </div>
            )}
            
            {log.steps?.map((s, idx) => (
              <div key={idx} className="mb-6 border-b border-neutral-200 pb-4">
                <div className="flex items-center gap-2 mb-2">
                  {s.ok === false ? (
                    <XCircle className="w-5 h-5 text-red-500" />
                  ) : s.ok === true ? (
                    <CheckCircle className="w-5 h-5 text-green-600" />
                  ) : (
                    <div className="w-5 h-5" />
                  )}
                  <span className={`font-bold ${s.ok === false ? "text-red-700" : "text-green-800"}`}>
                    {s.step}
                  </span>
                </div>
                
                {s.command && (
                  <div className="mb-2 text-xs text-neutral-600">
                    실행 명령어: <code className="bg-neutral-100 px-1 py-0.5 rounded">{s.command}</code>
                  </div>
                )}
                
                {s.stdout && (
                  <details>
                    <summary className="text-sm cursor-pointer text-neutral-600">출력 로그</summary>
                    <pre className="mt-1 bg-neutral-100 rounded text-xs p-2 overflow-auto max-h-40">{s.stdout}</pre>
                  </details>
                )}
                
                {s.stderr && s.stderr.trim() && (
                  <details>
                    <summary className="text-sm cursor-pointer text-neutral-600">오류 로그</summary>
                    <pre className="mt-1 bg-neutral-100 rounded text-xs p-2 overflow-auto max-h-40">{s.stderr}</pre>
                  </details>
                )}
                
                {s.error && (
                  <div className="text-red-600 text-sm mt-1">오류 메시지: {s.error}</div>
                )}
                
                {s.certStatus && (
                  <div className="mt-2 p-2 bg-blue-50 rounded text-sm">
                    <div>적용된 인증서 만료일: <b>{s.certStatus.validTo}</b></div>
                    <div>남은 기간: <b>{s.certStatus.daysRemaining}일</b></div>
                    <div>인증기관: {s.certStatus.issuer}</div>
                    <div>유효성: {s.certStatus.valid ? "✅ 유효함" : "❌ 오류"}</div>
                  </div>
                )}
              </div>
            ))}
          </div>
        </details>
      )}
    </div>
  );
}

최상위 App 컴포넌트:

// src/App.jsx
import React from "react";
import CertificateDashboard from "./CertificateDashboard";

function App() {
  return (
    <div className="min-h-screen bg-gray-50">
      <CertificateDashboard />
    </div>
  );
}

export default App;

3. 서비스 배포 및 관리

애플리케이션을 배포하려면:

백엔드 서버 설정

cd ../backend
node server.js

프론트엔드 빌드

cd frontend
npm run build

프로덕션 환경에서는 systemd 서비스로 설정하는 것이 좋습니다:

# /etc/systemd/system/cert-monitor.service
[Unit]
Description=Certificate Monitor Dashboard
After=network.target

[Service]
WorkingDirectory=/path/to/cert-monitor-dashboard/backend
ExecStart=/usr/bin/node server.js
Restart=always
User=root  # certbot 권한 필요
Environment=NODE_ENV=production
Environment=PORT=3040

[Install]
WantedBy=multi-user.target

서비스를 활성화합니다:

sudo systemctl enable cert-monitor
sudo systemctl start cert-monitor

⚠️ 주의사항

권한

이 애플리케이션은 certbot 명령과 인증서 파일에 접근해야 하므로 적절한 권한이 필요합니다:

# certbot과 letsencrypt 디렉토리 권한 확인
sudo ls -la /etc/letsencrypt/live/

# OCI CLI 구성 확인
oci setup config

보안

  1. 내부망 액세스 제한: 이 대시보드는 내부 관리용이므로 외부에 노출되지 않도록 합니다
  2. HTTPS: 가능하면 대시보드 자체도 HTTPS로 제공합니다
  3. 로그 관리: 민감한 정보가 포함된 로그를 관리합니다

🚀 확장 아이디어

이 기본 구현을 더 발전시킬 수 있는 방법:

  1. 알림 통합: Slack, 이메일 또는 SMS 알림 추가
  2. 자동화 일정: 예약된 시간에 자동 갱신 및 배포 설정
  3. 다중 환경 지원: 스테이징/프로덕션 환경 구분
  4. 감사 로그: 모든 갱신 및 배포 작업의 히스토리 관리
  5. 헬스체크 통합: 인증서 갱신 후 서비스 헬스체크 자동화

💭 결론

인증서 관리는 번거롭고 실수하기 쉬운 작업이지만, 이 대시보드를 통해 팀은 인증서 상태를 한눈에 파악하고, 갱신 및 배포 프로세스를 간소화할 수 있습니다. 특히 OCI 환경에서 여러 도메인과 로드밸런서를 관리할 때 매우 유용합니다.

"자동화는 단순히 시간을 절약하는 것이 아니라, 휴먼 에러로 인한 장애를 방지하는 가장 효과적인 방법입니다."

이 프로젝트를 통해 인증서 관리 프로세스를 개선하고, 팀이 더 중요한 업무에 집중할 수 있기를 바랍니다.


작성자: 당신의 이름
최종 업데이트: 2024년 4월