PHP / PHPMailer / SMTP를 이용하여 메일 발송하게 하는 라이브러리

PHPMailer는 PHP에서 SMTP(Simple Mail Transfer Protocol, 메일 전송 규약) 서버에 메시지를 제출하는 라이브러리입니다. SMTP 서버·포트·TLS 방식·인증 정보를 설정한 뒤 발신자와 수신자, 제목과 본문을 지정해 전송합니다. SMTP를 쓴다는 사실만으로 받은편지함 도착이나 스팸 분류 해제를 보장하지는 않습니다.

설치와 실습 파일 준비

아래 예제는 PHP 8.3 이상의 실행 환경과 OpenSSL 확장, 발송 권한을 받은 SMTP 계정이 필요합니다. PHP와 라이브러리는 지원되는 보안 업데이트를 적용하고, 제공자가 비밀번호 기반 SMTP를 허용하는지 먼저 확인합니다. 이 예제는 해당 인증 방식이 허용된 계정용이며 OAuth 계정의 토큰 발급·갱신까지 대신하지 않습니다.

PHPMailer 공식 릴리스에서 안정 버전 소스를 내려받아 lib/PHPMailer에 풀고 src 디렉터리가 아래 위치에 오도록 배치합니다. 실습 코드는 7.1.1의 클래스 구성을 사용합니다. 웹에서 열 폼만 public에 두고 라이브러리와 전송 코드는 상위 폴더에 둡니다.

mail-demo/
  lib/PHPMailer/src/Exception.php
  lib/PHPMailer/src/PHPMailer.php
  lib/PHPMailer/src/SMTP.php
  smtp-mailer.php
  send.php
  public/form.php

Composer가 있으면 프로젝트 루트에서 아래 명령으로 의존성을 관리할 수 있습니다. 이 경우 smtp-mailer.php의 세 require_once 대신 require_once __DIR__ . '/vendor/autoload.php'; 한 줄을 사용합니다. vendor/autoload.php는 Composer가 만드는 파일이므로 ZIP 안에서 찾지 않습니다. Composer를 서버에서 실행할 수 없다면 로컬에서 의존성을 설치해 배포하거나 위 수동 로딩 방식을 사용합니다.

composer require phpmailer/phpmailer

패키지 설치·수동 로딩과 OAuth 추가 의존성은 PHPMailer 공식 저장소의 안내를 따릅니다. 쓰지 않는 공개 예제나 비밀 설정을 웹 루트에 함께 올리지 않습니다.

SMTP 설정과 TLS 방식 선택

항목 의미와 선택 기준
Host·Port 제공자가 안내한 SMTP 호스트와 제출 포트
STARTTLS 접속 후 TLS로 전환, 흔히 587번 포트
SMTPS 접속 시작부터 TLS, 흔히 465번 포트
SMTPAuth SMTP 인증 사용 여부. true만으로 OAuth가 구성되지는 않음
From·수신자 발송 권한이 있는 주소와 서버에서 고정한 목적지
CharSet 한글 제목·본문에 사용할 UTF-8. 속성명의 대소문자 확인

포트와 TLS 모드를 임의로 섞지 말고 서비스의 실제 조합을 사용합니다. 발신 주소는 인증 사용자와 같거나 서비스에서 허용한 별칭·위임 주소여야 합니다. 폼 입력을 그대로 From으로 쓰지 않습니다. 방문자의 답장 주소를 받는 서비스라면 검증한 주소를 Reply-To에 넣는 설계를 검토합니다.

환경 변수 SMTP_HOST·SMTP_PORT·SMTP_SECURITY·SMTP_USER·SMTP_PASS·SMTP_FROM·SMTP_TO를 준비합니다. 비밀번호는 PHP 소스나 Git에 넣지 않고 호스팅의 비밀 설정·환경 변수로 전달합니다. 다음은 Bash의 CLI 실습용 설정 예시이며 example.com 주소는 실제 값으로 바꿉니다. read 입력은 화면에 비밀번호를 표시하지 않습니다. FPM·Apache는 로그인 셸의 환경을 자동으로 물려받는다고 가정하지 말고 실행 프로세스의 환경 전달 방법을 확인하세요.

export SMTP_HOST='smtp.example.com'
export SMTP_PORT='587'
export SMTP_SECURITY='starttls'
export SMTP_USER='sender@example.com'
export SMTP_FROM='sender@example.com'
export SMTP_TO='recipient@example.com'
read -r -s -p 'SMTP password: ' SMTP_PASS
echo
export SMTP_PASS

공통 전송 코드와 CLI 실행

smtp-mailer.php를 다음 내용으로 저장합니다. 발신·수신 주소와 접속 대상은 서버 설정에서만 읽고, 제목의 줄바꿈을 거부합니다. 길이 제한은 UTF-8의 글자 수가 아닌 바이트 수이며, 제목 300바이트·내용 10000바이트는 이 실습의 제한입니다. 서비스에 맞는 기준으로 조정할 수 있습니다.

<?php
declare(strict_types=1);

use PHPMailer\PHPMailer\PHPMailer;

require_once __DIR__ . '/lib/PHPMailer/src/Exception.php';
require_once __DIR__ . '/lib/PHPMailer/src/PHPMailer.php';
require_once __DIR__ . '/lib/PHPMailer/src/SMTP.php';

function smtpRequired(string $name): string
{
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException('Required SMTP configuration is missing.');
    }
    return $value;
}

function sendDemoMail(string $subject, string $body): void
{
    if (trim($subject) === '' || strlen($subject) > 300
        || strpbrk($subject, "\r\n") !== false
        || trim($body) === '' || strlen($body) > 10000) {
        throw new InvalidArgumentException('Invalid subject or body.');
    }
    if (!extension_loaded('openssl')) {
        throw new RuntimeException('OpenSSL extension is required.');
    }
    $host = smtpRequired('SMTP_HOST');
    if (!preg_match('/\A[a-zA-Z0-9.-]+\z/', $host)) {
        throw new RuntimeException('Use a single SMTP hostname or IPv4 address.');
    }
    $port = filter_var(smtpRequired('SMTP_PORT'), FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1, 'max_range' => 65535]]);
    $security = smtpRequired('SMTP_SECURITY');
    if ($port === false || !in_array($security, ['starttls', 'smtps'], true)) {
        throw new RuntimeException('Invalid SMTP port or encryption mode.');
    }

    $mail = new PHPMailer(true);
    $mail->isSMTP();
    $mail->Host = $host;
    $mail->Port = $port;
    $mail->SMTPAuth = true;
    $mail->Username = smtpRequired('SMTP_USER');
    $mail->Password = smtpRequired('SMTP_PASS');
    $mail->SMTPSecure = $security === 'starttls'
        ? PHPMailer::ENCRYPTION_STARTTLS : PHPMailer::ENCRYPTION_SMTPS;
    $mail->Timeout = 10;
    $mail->SMTPDebug = 0;
    $mail->CharSet = 'UTF-8';
    $mail->setFrom(smtpRequired('SMTP_FROM'), '사이트 알림');
    $mail->addAddress(smtpRequired('SMTP_TO'));
    $mail->isHTML(false);
    $mail->Subject = $subject;
    $mail->Body = $body;
    $mail->send();
}

CharSet은 대문자 S를 포함한 정확한 속성명입니다. Charset처럼 다른 이름을 쓰지 않습니다. TLS 인증서 검증을 끄는 SMTPOptions는 넣지 않았습니다. 인증서 오류가 나면 호스트명·시스템 시각·CA 인증서 묶음과 PHP의 OpenSSL 구성을 바로잡습니다. 임의의 인증서를 무조건 신뢰하도록 바꾸지 마세요.

다음 send.php는 명령행 전용입니다. 전송 코드가 예외를 발생시키면 종료 코드 1을 반환하고, 성공은 SMTP 서버의 접수로 표현합니다. 테스트는 관리하는 수신 주소를 사용하고 예상하지 않은 반복 실행이나 대량 발송을 피합니다.

<?php
declare(strict_types=1);
require __DIR__ . '/smtp-mailer.php';

if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit;
}
try {
    sendDemoMail('한글 SMTP 테스트', '한글과 줄바꿈을 확인합니다.' . "\n" . '둘째 줄입니다.');
    echo "SMTP 서버가 메시지를 접수했습니다.\n";
} catch (Throwable $e) {
    fwrite(STDERR, "전송에 실패했습니다. 설정과 보호된 서버 로그를 확인하세요.\n");
    exit(1);
}
php send.php

정상 접수되면 “SMTP 서버가 메시지를 접수했습니다.”가 출력됩니다. 이후 최종 배달·반송·스팸 분류는 메일 서버와 수신 측 정책의 별도 단계입니다. 한글 제목·줄바꿈이 맞는지 수신 원문으로 확인하고, 실패하면 CLI 종료 상태와 서비스의 전송 로그를 함께 봅니다. 실제 메시지를 보내기 전에는 격리된 SMTP 시험 서버나 서비스의 샌드박스를 사용하는 편이 좋습니다.

HTML 본문이 필요하면 sendDemoMail() 안의 isHTML(false)와 Body 지정 부분을 아래 세 줄로 바꿉니다. 아래 내용은 고정 예시이며 사용자 입력을 HTML 문자열에 그대로 이어 붙이지 않습니다. 일반 텍스트 대체 본문인 AltBody도 함께 지정합니다.

$mail->isHTML(true);
$mail->Body = '<p>한글 <strong>알림</strong>입니다.</p>';
$mail->AltBody = '한글 알림입니다.';

로컬에서 제목·내용 입력 폼 실행

다음 public/form.php는 루프백 주소에서만 열어 보는 로컬 실습용입니다. 위 전송 코드와 환경 변수가 준비된 상태에서 사용합니다. 수신자를 브라우저에서 바꾸지 못하게 하고, CSRF(Cross-Site Request Forgery, 다른 사이트를 통한 위조 요청) 토큰·입력 타입·길이·제목 줄바꿈을 검사합니다. 전송 후에는 303 리디렉션으로 새로고침에 따른 같은 POST 재전송을 줄입니다.

<?php
declare(strict_types=1);

// 로컬 실습 전용입니다. 공개 서버나 역방향 프록시 뒤에 배포하지 않습니다.
if (!in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true)) {
    http_response_code(403);
    exit;
}
require dirname(__DIR__) . '/smtp-mailer.php';
session_start(['use_strict_mode' => 1, 'cookie_httponly' => true,
    'cookie_samesite' => 'Strict']);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($method === 'POST') {
    $token = $_POST['csrf'] ?? null;
    $subject = $_POST['subject'] ?? null;
    $body = $_POST['body'] ?? null;
    if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
        http_response_code(403);
        $message = '요청 확인에 실패했습니다. 페이지를 다시 여세요.';
    } elseif (!is_string($subject) || !is_string($body)
        || trim($subject) === '' || strlen($subject) > 300
        || strpbrk($subject, "\r\n") !== false
        || trim($body) === '' || strlen($body) > 10000) {
        http_response_code(422);
        $message = '제목은 300바이트, 내용은 10000바이트 이내로 입력하세요.';
    } elseif (time() - ($_SESSION['last_attempt'] ?? 0) < 60) {
        http_response_code(429);
        $message = '전송 시도 후 60초가 지나면 다시 시도하세요.';
    } else {
        $_SESSION['last_attempt'] = time();
        try {
            sendDemoMail($subject, $body);
            $_SESSION['flash'] = 'SMTP 서버가 메시지를 접수했습니다.';
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
            header('Location: form.php', true, 303);
            exit;
        } catch (Throwable $e) {
            http_response_code(502);
            $message = '전송에 실패했습니다. 설정과 서버 상태를 확인하세요.';
        }
    }
} elseif ($method !== 'GET') {
    header('Allow: GET, POST');
    http_response_code(405);
    exit;
}
function escapeMailForm(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>로컬 메일 전송 실습</title>
</head>
<body>
  <h1>로컬 메일 전송 실습</h1>
  <?php if ($message !== ''): ?>
    <p role="status"><?= escapeMailForm($message) ?></p>
  <?php endif; ?>
  <form method="post" action="form.php">
    <input type="hidden" name="csrf" value="<?= escapeMailForm($_SESSION['csrf']) ?>">
    <p><label>제목 <input name="subject" required></label></p>
    <p><label>내용 <textarea name="body" rows="8" cols="30" required></textarea></label></p>
    <button type="submit">설정된 수신자에게 보내기</button>
  </form>
</body>
</html>

mail-demo 폴더에서 다음 명령으로 시작하고 http://127.0.0.1:8080/form.php를 엽니다. PHP 개발 서버는 로컬 실습에만 사용합니다. 제목과 내용을 입력하면 환경 변수에 고정한 수신자에게 전송을 시도하고 성공 메시지로 이동합니다. 종료는 터미널에서 Ctrl+C를 누릅니다.

php -S 127.0.0.1:8080 -t public

잘못된 토큰은 403, 입력 오류는 422, 같은 세션의 60초 내 재시도는 429, 전송 실패는 502로 응답합니다. 실패한 시도도 간격 제한에 포함됩니다. 쿠키를 새로 만들면 세션별 제한을 우회할 수 있으므로 이것만으로 공개 서비스의 악용을 막을 수는 없습니다. 이 코드를 공개 서버나 역방향 프록시 뒤에 그대로 배포하지 않습니다. 공개 폼에는 인증·권한, 서버 전체 전송량 제한, 중복 작업 방지, 필요에 맞는 대기열과 악용 방지 정책을 추가하고 HTTPS에서 Secure 쿠키를 적용해야 합니다.

Gmail·Microsoft 365 계정의 인증 조건

Gmail은 계정과 조직 정책에 맞는 OAuth 또는 허용된 앱 비밀번호를 검토합니다. Google 앱 비밀번호 안내에 따르면 2단계 인증 등이 필요하고 계정에 따라 해당 기능을 사용할 수 없습니다. 일반 로그인 비밀번호를 그대로 넣으면 된다고 가정하지 않습니다. PHPMailer에서 OAuth를 쓰려면 토큰 공급자와 추가 의존성, 갱신 토큰의 보호까지 구성해야 합니다.

Microsoft 365의 일반 클라이언트 제출은 smtp.office365.com과 STARTTLS 587 조합을 확인하되, 조직·사서함의 SMTP AUTH 허용 여부와 인증 정책을 함께 봅니다. Microsoft의 SMTP OAuth 안내는 앱 등록·권한·토큰 취득과 XOAUTH2 인증을 설명합니다. 위 비밀번호 예제에서 SMTPAuth만 true로 켠다고 OAuth가 되는 것은 아닙니다.

Microsoft의 2026년 1월 갱신 일정은 기존 테넌트의 SMTP AUTH 기본 인증을 2026년 12월 말 기본 비활성화하고 최종 제거 일정은 2027년 하반기에 발표한다고 안내합니다. 이전의 2026년 4월 전면 종료 일정으로 단정하지 않습니다. 실제 사용 가능 여부는 현재 테넌트 정책에 따라 다르며, 새 연동은 OAuth와 지원되는 발송 방식을 기준으로 설계하는 것이 적절합니다.

연결·인증·문자 깨짐 문제 확인

  • 연결 시간 초과: DNS·호스트·포트·호스팅의 SMTP 송신 제한과 방화벽을 확인합니다.
  • TLS 오류: 포트와 STARTTLS/SMTPS 조합, 인증서 호스트명·유효 기간·CA 체인을 확인합니다.
  • 인증 실패: 사용자명·앱 비밀번호/OAuth·조직 정책을 확인합니다. 보안 설정을 일괄 해제해서 해결하지 않습니다.
  • 발신 주소 거절: 허용한 From·별칭·Send As 권한과 발송 도메인 정책을 확인합니다.
  • 한글 깨짐: 소스 파일의 UTF-8 저장, 정확한 CharSet 속성, 수신 메일의 MIME 헤더와 인코딩을 확인합니다.
  • 접수 후 미도착: 반송·수신 스팸함·발송 제한과 도메인의 SPF·DKIM·DMARC 설정을 확인합니다. 라이브러리 변경만으로 수신함 배달이 보장되지는 않습니다.

세부 진단은 PHPMailer 문제 해결 문서를 참고합니다. SMTPDebug를 켜야 한다면 접근이 제한된 시험 환경에서만 사용하고 주소·메시지·인증 정보가 포함될 수 있는 로그를 공개 화면에 출력하지 않습니다. 정상화 후 디버그를 끄고, 오류를 사용자에게 보여줄 때는 비밀 설정과 내부 경로를 제외한 문구를 사용합니다.