실전 기록

홈페이지 문의를 도메인 이메일로 받기: 초보자를 위한 PHP·Gmail 연결 방법

홈페이지 문의를 DB에 먼저 저장하고 Google Workspace 도메인 이메일로 알림을 받은 뒤 Gmail에서 답장하는 안전한 구조를 PHP 예제와 함께 설명합니다.

Read in English →

정보 기준일 내용 정정·오류 제보
홈페이지 문의를 DB에 저장하고 도메인 이메일로 알린 뒤 Gmail에서 답장하는 흐름
핵심 내용

홈페이지 문의를 DB에 먼저 저장하고 Google Workspace 도메인 이메일로 알림을 받은 뒤 Gmail에서 답장하는 안전한 구조를 PHP 예제와 함께 설명합니다.

1편에서 `contact@example.com` 같은 도메인 이메일을 만들었다면, 다음으로 많이 하는 일이 홈페이지 문의 양식과의 연결입니다. 방문자가 문의를 남기면 Gmail로 알림을 받고, 답장 단추를 누르면 방문자에게 바로 회신되는 모습입니다.

처음에는 "문의 내용을 메일로 보내면 끝 아닌가?"라고 생각하기 쉽습니다. 하지만 메일 발송이 잠깐 실패했다고 문의까지 사라지면 안 됩니다. 그래서 이 글의 가장 중요한 원칙은 하나입니다.

문의는 데이터베이스에 먼저 저장하고, 메일은 "새 문의가 왔다"는 알림으로만 씁니다.

글의 PHP와 MariaDB 예시는 그대로 복사해 쓰는 완성품이 아니라 구조를 이해하기 위한 작은 예제입니다. 실제 사이트에 적용할 때는 쓰고 있는 프레임워크, 개인정보처리방침, 호스팅 환경에 맞춰 검토해야 합니다.

문의가 처리되는 순서

방문자가 홈페이지에서 이름, 이메일, 문의 내용을 입력하고 제출하면 다음 일이 순서대로 일어나야 합니다.

1. 서버가 입력값이 정상인지 확인합니다.

2. 문의를 데이터베이스에 저장합니다.

3. 저장에 성공하면 방문자에게 "접수되었습니다" 화면을 보여 줍니다.

4. 서버가 `contact@example.com`으로 새 문의 알림을 보냅니다.

5. 관리자가 Gmail에서 답장을 누르면 방문자의 주소가 받는 사람으로 들어갑니다.

6. 메일 발송에 실패하면 문의는 그대로 두고, 실패 상태를 기록해 다시 시도합니다.

데이터베이스는 원본 보관함이고 이메일은 초인종입니다. 초인종이 고장 나도 택배 상자가 사라지지 않듯, 알림 메일이 실패해도 문의 원문은 관리자 화면에 남아 있어야 합니다.

참고로 이 블로그(Agentickorea)의 문의 양식도 문의를 관리자 데이터베이스에 먼저 저장합니다. 다만 지금은 알림 메일을 보내지 않고, 관리자 화면에서 새 문의를 확인하는 단계까지만 운영하고 있습니다.

Agentickorea 문의 양식: 이름, 이메일, 소속, 문의 유형, 문의 내용, 개인정보 동의
이 블로그의 실제 문의 양식 (2026-10-09 캡처). 문의는 관리자 DB에 저장되고, 아직 알림 메일은 보내지 않습니다.

먼저 네 가지 용어

From은 메일을 보낸 사람입니다. 홈페이지 알림에서는 내 도메인의 검증된 주소인 `contact@example.com`을 씁니다.

To는 알림을 받을 사람입니다. 혼자 운영한다면 같은 `contact@example.com` 메일함이 받으면 되고, 팀이라면 담당자 그룹 주소를 쓸 수 있습니다.

Reply-To는 답장받을 주소입니다. 방문자의 이메일을 여기에 넣으면, 관리자가 Gmail에서 답장을 눌렀을 때 방문자에게 회신됩니다.

SMTP는 이메일을 배달하는 통신 방식입니다. 홈페이지가 만든 알림을 Google이나 메일 발송 서비스에 건네는 창구라고 생각하면 됩니다. SMTP 릴레이는 그 메일을 받아 실제 목적지까지 중계하는 서비스입니다.

가장 중요한 구분은 방문자의 주소를 From에 넣지 않는 것입니다. 방문자는 홈페이지 서버에서 직접 메일을 보낸 사람이 아닙니다. 서버가 방문자인 척하면 SPF와 DMARC 인증이 어긋나 스팸 처리되거나 거부될 수 있습니다.

1단계. 문의를 저장할 표 만들기

문의 원문과 알림 상태를 함께 보관할 표를 만듭니다.

CREATE TABLE inquiries (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(254) NOT NULL,
  subject VARCHAR(150) NOT NULL,
  message TEXT NOT NULL,
  mail_status VARCHAR(20) NOT NULL DEFAULT 'pending',
  mail_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
  mail_sent_at DATETIME NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

`mail_status`는 알림이 대기 중인지, 성공했는지, 실패했는지를 표시합니다. `mail_attempts`는 몇 번 시도했는지, `mail_sent_at`은 실제로 보낸 시각을 남깁니다.

문의와 메일 발송 상태를 같은 행에 두면 "문의는 저장됐지만 알림만 실패한 경우"를 쉽게 찾을 수 있습니다. 실제 서비스에서는 처리 상태, 개인정보 동의 시각, 문의 유형 같은 열이 더 필요할 수 있습니다.

2단계. 브라우저가 아니라 서버에서 다시 검사하기

HTML 입력칸에 `required`를 붙였다고 검사가 끝난 것은 아닙니다. 공격자는 브라우저 화면을 거치지 않고 서버로 직접 값을 보낼 수 있습니다. 서버는 다음을 다시 확인해야 합니다.

• 요청 방식이 POST인가

• CSRF 토큰이 현재 세션과 일치하는가

• 이름, 제목, 본문의 길이가 정한 범위 안인가

• 이메일 형식이 맞는가

• 문의 유형이 허용한 목록에 있는가

• 개인정보 수집 동의에 체크했는가

• 숨겨 둔 스팸 방지 입력칸이 비어 있는가

PHP에서는 이메일 형식을 이렇게 확인할 수 있습니다.

$email = trim((string) ($_POST['email'] ?? ''));

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new RuntimeException('이메일 주소를 확인해 주세요.');
}

`FILTER_VALIDATE_EMAIL`은 형식만 확인할 뿐 실제 메일함이 있다는 뜻은 아닙니다. 또 이름에서 따옴표 같은 문자를 무조건 지우면 정상적인 이름도 망가집니다. 칸마다 길이와 형식을 정하고, 저장할 때는 준비된 SQL을 쓰고, 화면에 다시 보여 줄 때는 HTML 이스케이프를 적용하는 것이 핵심입니다.

3단계. CSRF와 자동 스팸을 함께 막기

CSRF 토큰은 "이 양식이 우리 사이트에서 정상적으로 열린 뒤 제출됐는가"를 확인하는 임시 표입니다. 서버가 무작위 값을 세션에 보관하고 양식의 숨은 입력칸에도 넣은 뒤, 제출 때 두 값이 같은지 비교합니다.

// 양식을 보여 줄 때
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));

// 제출받을 때
if (!hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
    throw new RuntimeException('요청을 다시 시도해 주세요.');
}

스팸 방지는 한 가지 장치에만 기대지 않습니다.

• 사람에게 보이지 않는 함정 칸을 두고, 값이 들어오면 거절합니다.

• 같은 방문자의 제출 횟수를 예컨대 한 시간에 몇 번으로 제한합니다.

• 이름 · 이메일 · 본문 길이에 상한을 둡니다.

• 지나치게 빠른 반복 제출 같은 이상 패턴을 기록합니다.

• 공격이 늘 때만 CAPTCHA 같은 추가 장치를 검토합니다.

IP 주소를 원문 그대로 오래 보관하지 않아도 짧은 시간의 요청 제한은 만들 수 있습니다. 개인정보처리방침에는 문의 정보의 이용 목적과 보관 기간을 실제 운영 방식대로 적어야 합니다.

4단계. 준비된 SQL로 문의를 먼저 저장하기

준비된 SQL(prepared statement)은 사용자가 쓴 글과 SQL 명령을 서로 다른 통로로 데이터베이스에 전달합니다. 문의 본문에 따옴표나 SQL처럼 보이는 문장이 있어도 명령으로 실행되지 않게 하는 기본 방어입니다.

$stmt = $pdo->prepare(
    'INSERT INTO inquiries(name,email,subject,message) '
    . 'VALUES(:name,:email,:subject,:message)'
);

$stmt->execute([
    'name' => $name,
    'email' => $email,
    'subject' => $subject,
    'message' => $message,
]);

$inquiryId = (int) $pdo->lastInsertId();

문의를 저장하지 못했다면 접수 완료 화면을 보여 주면 안 됩니다. 반대로 저장은 됐는데 메일만 실패했다면 방문자에게 다시 제출하게 할 필요가 없습니다. 같은 문의가 두 번 쌓이기 때문입니다.

5단계. 저장과 메일 발송을 분리하기

처음 만들 때 가장 흔한 실수는 저장과 메일을 한 덩어리로 묶는 것입니다. 더 안전한 흐름은 다음과 같습니다.

$inquiryId = saveInquiry($pdo, $data);

try {
    sendInquiryAlert($inquiryId, $data);
    markMailSent($pdo, $inquiryId);
} catch (Throwable $e) {
    markMailFailed($pdo, $inquiryId);
    error_log('inquiry_mail_failed id=' . $inquiryId);
}

showThankYouPage();

이 코드는 실패 기록에 방문자의 이름, 이메일, 문의 원문, 비밀번호를 남기지 않습니다. 운영 기록에는 문의 번호와 실패 종류처럼 문제 해결에 필요한 최소한만 남깁니다.

방문자가 기다리는 동안 SMTP 연결이 오래 걸리는 사이트라면, 제출 요청에서는 DB 저장까지만 하고 예약 작업(cron)이 `pending` 상태의 문의를 발송하도록 나누는 편이 더 안정적입니다.

6단계. Google Workspace SMTP 릴레이 준비하기

Google은 기기나 앱이 Workspace를 통해 메일을 보낼 때 SMTP 릴레이를 우선 권장합니다. 관리 콘솔에서 앱 → Google Workspace → Gmail → 라우팅으로 이동한 뒤 SMTP 릴레이 서비스 항목을 설정합니다.

홈페이지 알림용이라면 다음 원칙으로 시작할 수 있습니다.

• 허용된 발신자: 내 도메인의 주소만 허용

• 인증: 호스팅 서버의 고정 공인 IP를 등록하거나, 지원되는 경우 SMTP 인증 요구

• 암호화: TLS 요구

• 발송 서버: `smtp-relay.gmail.com`

• 포트: 호스팅이 허용하는 25, 465, 587 중 하나. 보통 TLS 연결에는 587을 검토

설정 변경이 반영되기까지 최대 24시간이 걸릴 수 있으니, 저장 직후 규칙을 계속 바꾸지 말고 시험 메일과 오류 기록부터 확인합니다.

공유 호스팅이라면 먼저 확인할 것

카페24 같은 공유 호스팅(여러 사이트가 한 서버를 나눠 쓰는 저렴한 호스팅)에는 내 사이트만 쓰는 고정 발신 IP가 없을 수 있고, 외부 SMTP 포트가 막혀 있을 수도 있습니다. 이 경우 관리 콘솔의 IP 허용 방식만 따라 해서는 연결되지 않습니다.

호스팅 업체에 다음을 확인하세요.

• 외부 SMTP 연결이 허용되는가

• 587번 포트와 STARTTLS를 쓸 수 있는가

• 외부에 보이는 고정 발신 IP를 제공하는가

• 서버에서 Composer와 예약 작업(cron)을 쓸 수 있는가

고정 IP가 없거나 Google 인증 방식이 호스팅 환경과 맞지 않으면 억지로 우회하지 않습니다. 소량 발송이라면 Gmail SMTP와 앱 비밀번호 방식을 쓸 수 있는지 계정 정책을 확인해 볼 수 있고, 운영용 문의 알림이라면 API 키 방식의 트랜잭션 메일 서비스를 고르는 방법도 있습니다.

트랜잭션 메일은 문의 알림, 비밀번호 재설정, 주문 확인처럼 서비스 동작에 따라 한 통씩 보내는 메일입니다. 뉴스레터나 광고 대량 발송과는 목적이 다릅니다. 어떤 방식을 쓰든 SPF와 DKIM에 실제 발송 서비스를 반영해야 합니다.

7단계. 비밀번호는 코드 밖에 두기

PHP 파일에 비밀번호를 직접 쓰면 안 됩니다.

// 이렇게 저장하지 않습니다.
$smtpPassword = '실제-비밀번호';

소스 저장소, 오류 화면, 백업 파일을 통해 비밀번호가 새어 나갈 수 있기 때문입니다. SMTP 호스트, 사용자 이름, 앱 비밀번호, API 키는 웹에서 내려받을 수 없는 서버 환경 설정이나 비밀 파일에 둡니다.

$smtpHost = getenv('SMTP_HOST');
$smtpUser = getenv('SMTP_USER');
$smtpPass = getenv('SMTP_PASS');

if (!$smtpHost || !$smtpUser || !$smtpPass) {
    throw new RuntimeException('메일 서버 설정이 없습니다.');
}

비밀값을 바꿀 때 코드를 고치지 않아도 되는 구조가 좋습니다. 운영 중 오류 화면에는 인증 정보와 SMTP 대화 내용을 표시하지 않습니다.

8단계. PHPMailer로 From과 Reply-To를 나눠 보내기

PHP 기본 `mail()`로 SMTP 인증, TLS, 오류 처리를 직접 맞추기보다 꾸준히 관리되는 SMTP 라이브러리를 쓰는 편이 이해하고 점검하기 쉽습니다. 아래는 PHPMailer 구조를 단순화한 예입니다.

use PHPMailer\PHPMailer\PHPMailer;

$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = getenv('SMTP_HOST');
$mail->Port = 587;
$mail->SMTPAuth = true;
$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASS');
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;

$mail->setFrom('contact@example.com', 'Example 홈페이지');
$mail->addAddress('contact@example.com', '문의 담당');
$mail->addReplyTo($email, $name);

$mail->Subject = '[홈페이지 문의 #' . $inquiryId . '] ' . $subject;
$mail->Body = "관리자 화면에서 문의 내용을 확인해 주세요.\n문의 번호: " . $inquiryId;
$mail->send();

알림 본문에 문의 전체를 꼭 넣을 필요는 없습니다. "새 문의가 왔다"는 사실과 관리자 화면에서 찾을 번호만 보내면, 메일이 다른 곳으로 전달되거나 노출됐을 때의 개인정보 위험이 줄어듭니다.

IP 인증 방식의 SMTP 릴레이를 쓴다면 `SMTPAuth`와 계정 설정은 달라집니다. 예제를 그대로 믿기보다 선택한 Google 릴레이 규칙과 호스팅 환경에 맞춰 조정해야 합니다.

알림 메일의 From은 내 도메인, Reply-To는 방문자 주소로 나누는 예시
Agentickorea 정리 그림 · 주소와 번호는 예시

9단계. 메일 실패를 다시 시도하기

네트워크나 메일 서비스가 잠시 느려질 수 있으니 한 번 실패했다고 영구 실패로 처리하지 않습니다. 예를 들어 5분 뒤, 30분 뒤, 2시간 뒤처럼 간격을 늘려 최대 세 번까지 다시 시도할 수 있습니다.

재시도 작업은 다음을 확인합니다.

• `mail_status`가 `pending` 또는 `failed`인가

• 이미 `mail_sent_at`이 기록된 문의는 아닌가

• 시도 횟수가 최대값보다 작은가

• 다음 시도 시간이 되었는가

발송에 성공하면 상태와 발송 시각을 한 번만 기록합니다. 같은 문의를 두 작업이 동시에 잡지 않도록 DB 잠금을 쓰면 중복 알림을 막을 수 있습니다.

관리자 화면에는 "문의 접수 성공 / 메일 알림 실패"를 구분해 보여 줍니다. 그래야 Gmail 알림이 오지 않았을 때도 저장된 문의를 놓치지 않습니다.

10단계. Gmail에서 실제로 답장해 보기

도착한 알림을 열고 답장을 누릅니다. 받는 사람에 방문자의 주소가 자동으로 들어가면 Reply-To가 제대로 적용된 것입니다.

보내기 전에 다음을 확인합니다.

• 보내는 사람이 `contact@example.com`인가

• 받는 사람이 방문자의 주소인가

• 알림 메일의 기술 정보나 내부 주소가 답장 본문에 남아 있지 않은가

• 문의 내용과 개인정보가 필요 이상으로 다른 사람에게 전달되지 않는가

문의 원본은 관리자 DB에 있으니 처리 상태를 "새 문의 → 확인 → 처리 완료"처럼 바꿔 두면 팀에서도 빠뜨리거나 두 번 답장하는 일을 줄일 수 있습니다.

11단계. 처음부터 끝까지 시험하기

운영 전에 서로 다른 외부 주소 두 개로 다음 시나리오를 시험합니다.

• 정상 문의가 DB에 한 번만 저장되는가

• 접수 완료 화면에서 새로고침해도 중복 저장되지 않는가

• 틀린 이메일과 너무 긴 본문을 서버가 거절하는가

• 함정 칸과 제출 횟수 제한이 작동하는가

• 알림의 From은 내 도메인이고 Reply-To는 방문자인가

• Gmail에서 답장하면 외부 주소로 도착하는가

• 받은 메일의 원본 보기에서 SPF · DKIM이 PASS이고 DMARC 정책과 맞는가

• SMTP 연결을 일부러 실패시켜도 문의가 남고 실패 상태가 기록되는가

• 재시도 뒤 같은 알림이 여러 번 오지 않는가

• 관리자 화면과 로그에 비밀번호나 전체 개인정보가 나타나지 않는가

흔한 실수 정리

• DB 저장보다 메일 발송을 먼저 합니다: 메일이 실패하면 문의까지 잃을 수 있습니다.

• 방문자 주소를 From으로 씁니다: 발신 인증이 어긋납니다. 방문자 주소는 Reply-To에 둡니다.

• PHP 파일에 Workspace 비밀번호를 적습니다: 환경 설정이나 서버 비밀 파일로 분리합니다.

• 브라우저의 `required`만 믿습니다: 서버에서 길이, 형식, 허용값을 다시 검사합니다.

• 문자열을 이어 붙여 SQL을 만듭니다: PDO 준비문과 매개변수를 씁니다.

• 메일이 실패하면 방문자에게 다시 제출하게 합니다: 저장 성공과 알림 실패를 구분합니다.

• 실패할 때마다 바로 무한 재시도합니다: 횟수와 간격을 제한합니다.

• 오류 로그에 문의 원문과 비밀번호를 남깁니다: 문의 번호와 오류 종류만 기록합니다.

• 공유 호스팅에 고정 IP가 있다고 가정합니다: SMTP 포트, 발신 IP, 예약 작업 지원부터 확인합니다.

• 문의 알림 경로로 뉴스레터까지 보냅니다: 서비스 알림과 대량 마케팅 메일은 목적과 도구를 나눕니다.

2편의 완료 기준

• 문의를 DB에 먼저 보관합니다.

• 검증된 `contact@example.com`에서 알림을 보내고, 방문자 주소는 Reply-To에 둡니다.

• 메일이 실패해도 문의는 남고, 정해진 횟수만큼 다시 시도합니다.

• 비밀번호는 코드 밖에 있고, 관리자 화면에서 알림 상태를 확인할 수 있습니다.

이 구조의 핵심은 Gmail로 메일 한 통을 보내는 코드가 아닙니다. 문의 원문을 잃지 않는 저장 순서, 발신자를 위조하지 않는 헤더, 실패를 확인하고 복구할 수 있는 기록이 함께 있어야 실제 업무에서 믿고 쓸 수 있습니다.

1편과 2편의 단계가 많아 어렵게 느껴진다면, 3편에서 전체 그림을 한 장으로 정리하고 AI에게 그대로 붙여 넣을 수 있는 요청 예시를 모아 두었습니다.

정보 기준일은 2026년 10월 9일입니다. Google Workspace 메뉴, 발송 한도, 인증 정책은 바뀔 수 있으니 실제 적용 때는 관리 콘솔 도움말과 호스팅 업체의 최신 안내를 다시 확인하세요.

참고한 자료

Google Workspace 앱·기기 메일 발송 방법 ↗

Google SMTP 릴레이 라우팅 설정 ↗

Google Workspace Gmail 발송 한도 ↗

Google Workspace SPF 설정 ↗

Google Workspace DKIM 설정 ↗

Google Workspace DMARC 설정 ↗

PHP PDO 준비문 ↗

PHP filter_var 입력 검증 ↗

PHP mail 헤더와 외부 입력 주의 ↗

PHPMailer 공식 저장소와 SMTP 예제 ↗

OWASP 입력값 검증 지침 ↗

OWASP CSRF 방어 지침 ↗

OWASP 안전한 로그 기록 지침 ↗