워드프레스 기본 로그인 화면(wp-login.php)을 쓰다 보면, 이게 과연 2026년에 맞는 UX인지 의문이 든다. 디자인은 커스터마이징이 거의 안 되고, AJAX 없이 페이지가 새로고침되며, 소셜 로그인은 별도 플러그인이 필요하다. 결국 처음부터 새로 만들기로 했다. mu-plugin 하나로 로그인, 회원가입, 비밀번호 재설정, 프로필까지 전부 구현한 과정을 코드와 함께 기록한다.
왜 mu-plugin으로 구현했는가
일반 플러그인이 아닌 mu-plugin(Must-Use Plugin)으로 만든 이유가 있다. mu-plugin은 관리자가 실수로 비활성화할 수 없다. 인증 시스템이 꺼지면 사이트 전체가 마비되므로, 반드시 활성화 상태를 보장해야 한다. wp-content/mu-plugins/ 디렉토리에 PHP 파일을 넣으면 자동으로 로드된다.
전체 구조는 하나의 클래스로 감쌌다:
class Nalkkul_Custom_Auth {
private static $instance = null;
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
// Shortcodes
add_shortcode('nalkkul_login', [$this, 'render_login']);
add_shortcode('nalkkul_register', [$this, 'render_register']);
add_shortcode('nalkkul_reset_password', [$this, 'render_reset_password']);
add_shortcode('nalkkul_profile', [$this, 'render_profile']);
// AJAX handlers
add_action('wp_ajax_nopriv_nalkkul_login', [$this, 'ajax_login']);
add_action('wp_ajax_nopriv_nalkkul_register', [$this, 'ajax_register']);
add_action('wp_ajax_nopriv_nalkkul_reset_password', [$this, 'ajax_reset_password']);
add_action('wp_ajax_nalkkul_update_profile', [$this, 'ajax_update_profile']);
add_action('wp_ajax_nalkkul_change_password', [$this, 'ajax_change_password']);
// wp-login.php 리다이렉트
add_action('init', [$this, 'handle_redirects']);
add_filter('login_url', [$this, 'custom_login_url'], 10, 3);
add_filter('register_url', [$this, 'custom_register_url']);
add_filter('logout_redirect', [$this, 'custom_logout_redirect'], 10, 3);
}
}
Nalkkul_Custom_Auth::instance();
싱글톤 패턴을 쓴 이유는 mu-plugin이 여러 번 로드되는 걸 방지하기 위해서다. 각 shortcode가 하나의 페이지에 대응한다: /login/, /register/, /reset-password/, /my-profile/. 워드프레스 페이지를 만들고 해당 shortcode를 넣으면 된다.
커스텀 로그인 폼: AJAX 기반으로
기본 wp-login.php의 가장 큰 불만은 로그인 실패 시 전체 페이지가 새로고침된다는 점이었다. fetch API로 AJAX 로그인을 구현해서 화면 깜빡임 없이 결과를 보여준다.
form.addEventListener("submit", function(e) {
e.preventDefault();
tgHideAlert("tg-login-alert");
var btn = document.getElementById("tg-login-submit");
var origText = btn.textContent;
btn.disabled = true;
btn.innerHTML = '<span class="tg-spinner"></span> 로그인 중...';
var fd = new FormData();
fd.append("action", "nalkkul_login");
fd.append("nonce", nonce);
fd.append("user_login", document.getElementById("tg-login-user").value.trim());
fd.append("user_pass", document.getElementById("tg-login-pass").value);
fd.append("remember", document.getElementById("tg-login-remember").checked ? "1" : "0");
fetch(tgAjaxUrl, { method: "POST", body: fd, credentials: "same-origin" })
.then(function(r){ return r.json(); })
.then(function(data){
if (data.success) {
tgShowAlert("tg-login-alert", "success", data.data.message);
setTimeout(function(){
var redirect = new URLSearchParams(window.location.search).get("redirect_to") || "/";
window.location.href = redirect;
}, 800);
} else {
tgShowAlert("tg-login-alert", "error", data.data.message);
btn.disabled = false;
btn.textContent = origText;
}
});
});
주목할 점은 credentials: "same-origin"이다. 이걸 빼먹으면 fetch 요청에 쿠키가 포함되지 않아서, wp_signon()이 제대로 동작하지 않는다. 로그인 성공 후 redirect_to 파라미터가 있으면 해당 페이지로, 없으면 홈으로 보낸다.
서버 측 로그인 처리는 wp_signon()을 그대로 활용한다:
public function ajax_login() {
if (!check_ajax_referer('nalkkul_login_nonce', 'nonce', false)) {
wp_send_json_error(['message' => '보안 검증에 실패했습니다.']);
}
$creds = [
'user_login' => sanitize_text_field($_POST['user_login']),
'user_password' => $_POST['user_pass'],
'remember' => ($_POST['remember'] ?? '0') === '1',
];
$user = wp_signon($creds, is_ssl());
if (is_wp_error($user)) {
$code = $user->get_error_code();
if ($code === 'invalid_username' || $code === 'invalid_email') {
wp_send_json_error(['message' => '등록되지 않은 아이디 또는 이메일입니다.']);
} elseif ($code === 'incorrect_password') {
wp_send_json_error(['message' => '비밀번호가 올바르지 않습니다.']);
}
}
wp_send_json_success(['message' => '환영합니다, ' . esc_html($user->display_name) . '님!']);
}
에러 코드별로 다른 메시지를 보여주는 게 포인트다. “invalid_username”과 “incorrect_password”를 구분하면 사용자 경험은 좋아지지만, 보안 관점에서는 “아이디 또는 비밀번호가 틀렸습니다” 하나로 통합하는 게 더 안전하다. 프로젝트 성격에 따라 선택하면 된다.
회원가입 폼: 실시간 검증의 집합체
회원가입 폼에는 여러 실시간 검증 요소를 넣었다. 비밀번호 강도 미터, 비밀번호 일치 확인, 사용자명 형식 검증, 이메일 형식 검증이 모두 입력 즉시 반응한다.
// 비밀번호 강도 측정기
function tgCheckPwStrength(val) {
var score = 0;
if (val.length >= 8) score++;
if (val.length >= 12) score++;
if (/[A-Z]/.test(val)) score++;
if (/[a-z]/.test(val)) score++;
if (/[0-9]/.test(val)) score++;
if (/[^A-Za-z0-9]/.test(val)) score++;
var pct, color, text;
if (score <= 2) { pct = 25; color = "#e74c3c"; text = "약함"; }
else if (score <= 3) { pct = 50; color = "#f39c12"; text = "보통"; }
else if (score <= 4) { pct = 75; color = "#f1c40f"; text = "양호"; }
else { pct = 100; color = "#2ecc71"; text = "강함"; }
return { pct: pct, color: color, text: text };
}
// input 이벤트에 연결
pwInput.addEventListener("input", function(){
var s = tgCheckPwStrength(this.value);
pwBar.style.width = s.pct + "%";
pwBar.style.background = s.color;
pwText.textContent = "비밀번호 강도: " + s.text;
});
강도 기준을 6가지로 나눠 점수를 매긴다: 8자 이상, 12자 이상, 대문자 포함, 소문자 포함, 숫자 포함, 특수문자 포함. CSS 프로그레스 바의 width와 색상을 점수에 따라 동적으로 변경한다. transition: width 0.3s를 걸어서 부드럽게 변하는 게 디테일이다.
비밀번호 보기/숨기기 토글도 구현했다:
function tgTogglePassword(btn) {
var input = btn.parentElement.querySelector("input");
if (input.type === "password") {
input.type = "text";
btn.textContent = "uD83DuDE48"; // 원숭이 눈 가리기 이모지
} else {
input.type = "password";
btn.textContent = "uD83DuDC41"; // 눈 이모지
}
}
input type을 password/text로 토글하는 단순한 로직이지만, 사용자 입장에서는 자기가 입력한 비밀번호를 확인할 수 있어서 폼 포기율이 줄어든다.
한글 사용자명 허용: sanitize_user 필터
워드프레스 기본 sanitize_user() 함수는 한글을 제거한다. 한국어 사이트에서 한글 아이디를 허용하려면 필터를 걸어야 한다.
add_filter('sanitize_user', function($username, $raw_username, $strict) {
if ($strict) {
// 영문, 숫자, 한글만 허용
$username = preg_replace('/[^a-zA-Z0-9x{AC00}-x{D7A3}]/u', '', $raw_username);
}
return $username;
}, 10, 3);
x{AC00}-x{D7A3}는 유니코드에서 한글 완성형 범위다. /u 플래그로 유니코드 모드를 활성화해야 한다. 서버 측 검증에서도 동일한 정규식을 사용한다:
if (!preg_match('/^[a-zA-Z0-9x{AC00}-x{D7A3}]{3,20}$/u', $raw_username)) {
wp_send_json_error(['message' => '사용자명은 3~20자의 영문, 한글, 숫자만 사용할 수 있습니다.']);
}
스팸 방지: IP 기반 Rate Limiting
봇이 대량 회원가입을 시도하는 걸 막기 위해 IP당 시간당 5회 제한을 걸었다. WordPress Transient API를 활용한다.
public function ajax_register() {
// Rate limiting: 5 registrations per IP per hour
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$transient_key = 'tg_reg_limit_' . md5($ip);
$attempts = (int) get_transient($transient_key);
if ($attempts >= 5) {
wp_send_json_error(['message' => '너무 많은 가입 시도가 감지되었습니다. 1시간 후에 다시 시도해주세요.']);
}
// ... 가입 처리 로직 ...
// 성공 시 카운트 증가
set_transient($transient_key, $attempts + 1, HOUR_IN_SECONDS);
}
Transient은 워드프레스의 임시 데이터 저장 메커니즘으로, 자동 만료 기능이 있다. HOUR_IN_SECONDS(3600)으로 만료 시간을 설정하면, 1시간 뒤 자동으로 삭제된다. Redis나 Memcached가 설치되어 있으면 오브젝트 캐시에 저장되고, 아니면 DB의 options 테이블을 사용한다.
reCAPTCHA v2 연동: wp-config.php 상수로 ON/OFF
reCAPTCHA를 wp-config.php에 상수로 정의해서, 값이 있으면 활성화되고 없으면 비활성화되게 했다.
// wp-config.php에 추가
define('NALKKUL_RECAPTCHA_SITE_KEY', 'your-site-key');
define('NALKKUL_RECAPTCHA_SECRET_KEY', 'your-secret-key');
// 회원가입 폼에서 조건부 렌더링
$captcha_enabled = defined('NALKKUL_RECAPTCHA_SITE_KEY') && NALKKUL_RECAPTCHA_SITE_KEY;
if ($captcha_enabled) {
echo '<script src="https://www.google.com/recaptcha/api.js" async defer></script>';
}
// ...
if ($captcha_enabled) : ?>
<div class="g-recaptcha" data-sitekey="<?php echo esc_attr(NALKKUL_RECAPTCHA_SITE_KEY); ?>"></div>
<?php endif;
서버 측에서는 Google API로 검증한다:
if (defined('NALKKUL_RECAPTCHA_SITE_KEY') && NALKKUL_RECAPTCHA_SITE_KEY
&& defined('NALKKUL_RECAPTCHA_SECRET_KEY') && NALKKUL_RECAPTCHA_SECRET_KEY) {
$recaptcha_response = $_POST['g-recaptcha-response'] ?? '';
if (empty($recaptcha_response)) {
wp_send_json_error(['message' => '보안 인증(reCAPTCHA)을 완료해주세요.']);
}
$verify = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
'body' => [
'secret' => NALKKUL_RECAPTCHA_SECRET_KEY,
'response' => $recaptcha_response,
'remoteip' => $ip,
],
]);
$verify_body = json_decode(wp_remote_retrieve_body($verify), true);
if (empty($verify_body['success'])) {
wp_send_json_error(['message' => '보안 인증에 실패했습니다.']);
}
}
비밀번호 재설정: 2단계 프로세스
비밀번호 재설정은 2단계로 구현했다. Step 1: 이메일 입력 -> 리셋 링크 발송. Step 2: 이메일 링크 클릭 -> 새 비밀번호 입력.
// Step 1: 리셋 키 생성 및 이메일 발송
$user = get_user_by('email', $email);
if (!$user) {
// 이메일 존재 여부 노출 방지 - 어느 경우든 같은 메시지
wp_send_json_success(['message' => '이메일로 재설정 링크를 보내드렸습니다.']);
return;
}
$key = get_password_reset_key($user);
$reset_url = home_url('/reset-password/?key=' . rawurlencode($key) . '&login=' . rawurlencode($user->user_login));
// 이메일 발송
wp_mail($user->user_email, '[무엇이든알아보자] 비밀번호 재설정', $message);
// Step 2: 키 검증 및 비밀번호 변경
$user = check_password_reset_key($key, $login);
if (is_wp_error($user)) {
wp_send_json_error(['message' => '링크가 만료되었거나 유효하지 않습니다.']);
}
reset_password($user, $password);
보안적으로 중요한 부분이 있다. 이메일이 존재하지 않더라도 “이메일을 보냈습니다”라고 응답한다. 이메일 존재 여부를 노출하면, 공격자가 유효한 이메일 주소를 수집할 수 있기 때문이다. get_password_reset_key()와 check_password_reset_key()는 워드프레스 내장 함수로, 일회용 토큰 생성/검증을 안전하게 처리해준다.
HTML에서는 CSS 클래스로 2단계를 토글한다:
.tg-step { display: none; }
.tg-step.active { display: block; }
URL 파라미터에 key와 login이 있으면 Step 2가 active, 없으면 Step 1이 active.
프로필 페이지: Gravatar 연동과 이용 기록
프로필 페이지는 로그인 사용자 전용이다. 비로그인 시 자동으로 로그인 페이지로 리다이렉트한다.
public function render_profile() {
if (!is_user_logged_in()) {
wp_redirect(home_url('/login/?redirect_to=' . urlencode(home_url('/my-profile/'))));
exit;
}
$user = wp_get_current_user();
$avatar_url = get_avatar_url($user->ID, ['size' => 192]);
$registered = date_i18n('Y년 n월 j일', strtotime($user->user_registered));
// 타로, 사주, 궁합 이용 기록 조회
global $wpdb;
$saju_history = $wpdb->get_results($wpdb->prepare(
"SELECT name, birth_date, created_at FROM {$wpdb->prefix}saju_history WHERE user_id = %d ORDER BY created_at DESC LIMIT 10",
$user->ID
));
// ...
}
get_avatar_url()로 Gravatar 프로필 이미지를 자동으로 불러온다. 사주, 타로, 궁합 각각의 history 테이블에서 최근 10건씩 조회해서 테이블로 표시한다. 서비스별로 다른 색상의 뱃지를 달아 구분한다.
.tg-badge-tarot { background: #e8daef; color: #6c3483; }
.tg-badge-saju { background: #d5f5e3; color: #1e8449; }
.tg-badge-gunghap { background: #fadbd8; color: #c0392b; }
wp-login.php 완전 대체: 리다이렉트 전략
wp-login.php로 접근하는 모든 요청을 커스텀 페이지로 리다이렉트하는 게 핵심이다. 단, 주의할 예외 케이스들이 있다.
public function handle_redirects() {
$uri = $_SERVER['REQUEST_URI'];
// 예외: AJAX, Cron, WP-CLI, 관리자, POST 요청
if (defined('DOING_AJAX') && DOING_AJAX) return;
if (defined('DOING_CRON') && DOING_CRON) return;
if (defined('WP_CLI') && WP_CLI) return;
if (is_user_logged_in() && current_user_can('manage_options')) return;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($uri, 'wp-login.php') !== false) return;
// GET 요청만 리다이렉트
if (strpos($uri, 'wp-login.php') !== false && $_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'register') {
wp_redirect(home_url('/register/'));
exit;
} elseif ($action === 'lostpassword') {
wp_redirect(home_url('/reset-password/'));
exit;
} elseif ($action === 'rp' || $action === 'resetpass') {
// 이메일 리셋 링크 -> 커스텀 페이지로 파라미터 전달
$key = $_GET['key'] ?? '';
$login = $_GET['login'] ?? '';
wp_redirect(home_url('/reset-password/?key=' . rawurlencode($key) . '&login=' . rawurlencode($login)));
exit;
} else {
wp_redirect(home_url('/login/'));
exit;
}
}
}
// 워드프레스 필터로 URL도 변경
public function custom_login_url($login_url, $redirect = '', $force_reauth = false) {
$url = home_url('/login/');
if (!empty($redirect)) {
$url = add_query_arg('redirect_to', urlencode($redirect), $url);
}
return $url;
}
public function custom_register_url($register_url) {
return home_url('/register/');
}
public function custom_logout_redirect($redirect_to, $requested_redirect_to, $user) {
return home_url('/login/');
}
예외 처리가 가장 중요하다. POST 요청을 리다이렉트하면 비밀번호 재설정 프로세스가 깨진다. 관리자는 wp-login.php 접근을 허용해야 하고, WP-CLI 실행 시에도 건드리지 않아야 한다. 비밀번호 리셋 이메일 링크(action=rp)에서 key와 login 파라미터를 커스텀 페이지로 전달하는 부분도 빼먹기 쉽다.
소셜 로그인 버튼: 준비 중 모달
카카오, 네이버, Google 소셜 로그인 버튼은 넣어두되, 실제 연동은 아직 안 했다. 클릭하면 “준비 중” 모달이 뜬다.
function tgSocialNotReady() {
var overlay = document.createElement("div");
overlay.className = "tg-modal-overlay";
overlay.innerHTML = '<div class="tg-modal-box">' +
'<div class="tg-modal-icon">🚧</div>' +
'<p>소셜 로그인은 준비 중입니다.<br>이메일로 가입해 주세요!</p>' +
'<button class="tg-modal-close" onclick="this.closest('.tg-modal-overlay').remove()">확인</button>' +
'</div>';
document.body.appendChild(overlay);
overlay.addEventListener("click", function(e) {
if (e.target === overlay) overlay.remove();
});
}
closest()로 모달 오버레이를 찾아 제거하고, 배경(overlay) 클릭으로도 닫을 수 있게 했다. 소셜 로그인 버튼 디자인은 각 플랫폼 가이드라인을 따랐다 – 카카오는 노란색(#FEE500), 네이버는 초록색(#03C75A), Google은 흰색 배경에 테두리.
CSS 아키텍처: 카드 기반 UI
인증 폼 전체를 카드 형태로 디자인했다. 중앙 정렬, 라운드 코너, 그림자, 페이드인 애니메이션.
.tg-auth-card {
width: 100%;
max-width: 450px;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
padding: 40px 36px;
animation: tgFadeIn 0.4s ease;
}
@keyframes tgFadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* 입력 필드 - 아이콘 포함 */
.tg-input-wrap input {
width: 100%;
padding: 12px 14px 12px 42px; /* 왼쪽 아이콘 공간 */
border: 2px solid #e0e0e0;
border-radius: 10px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.tg-input-wrap input:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52,152,219,0.12);
}
.tg-input-wrap input.tg-error {
border-color: #e74c3c;
box-shadow: 0 0 0 3px rgba(231,76,60,0.1);
}
.tg-input-wrap input.tg-success {
border-color: #2ecc71;
}
입력 필드 왼쪽에 아이콘을 배치하고, 상태에 따라 border 색이 바뀐다. 포커스 시 파란색, 에러 시 빨간색, 성공 시 초록색. 전부 CSS transition으로 부드럽게 전환된다. 스타일은 인라인으로 PHP에서 출력하는 방식을 택했다 — 별도 CSS 파일을 만들면 로드 순서 이슈가 생기고, 인증 페이지에서만 필요한 스타일이므로.
비밀번호 변경: 현재 비밀번호 검증
프로필에서 비밀번호 변경 시, 반드시 현재 비밀번호를 먼저 확인한다.
public function ajax_change_password() {
$user = wp_get_current_user();
$current_password = $_POST['current_password'];
// 현재 비밀번호 검증
if (!wp_check_password($current_password, $user->user_pass, $user->ID)) {
wp_send_json_error(['message' => '현재 비밀번호가 올바르지 않습니다.']);
}
wp_set_password($new_password, $user->ID);
// 비밀번호 변경 후 재로그인 처리
wp_set_current_user($user->ID);
wp_set_auth_cookie($user->ID, true);
}
wp_set_password() 호출 후 기존 세션이 무효화되므로, 바로 wp_set_auth_cookie()로 재로그인 처리를 해줘야 한다. 이걸 빼먹으면 사용자가 비밀번호 변경 직후 로그아웃 당한다.
겪은 문제들
reCAPTCHA v3 vs v2 키 혼동. 처음에 Google reCAPTCHA v3 키를 발급받았는데, 프론트엔드에서 v2 체크박스 위젯을 사용하면서 키 유형 불일치로 검증이 계속 실패했다. v3는 점수 기반(invisible), v2는 체크박스 기반이라 키가 호환되지 않는다. 구글 콘솔에서 명확하게 v2 Checkbox로 키를 재발급해서 해결했다.
reCAPTCHA 리로드 문제. 회원가입 AJAX가 실패한 뒤 다시 시도하면, 이전 reCAPTCHA 토큰은 만료된 상태다. grecaptcha.reset()을 에러 핸들러에서 호출해야 위젯이 초기화되고 새 토큰을 받을 수 있다. 이걸 빼먹으면 두 번째 시도부터 “보안 인증을 완료해주세요” 에러가 계속 뜬다.
wp_signon()의 is_ssl() 파라미터. HTTPS 사이트에서 wp_signon($creds, false)로 호출하면 쿠키가 http-only로 설정되어 보안 문제가 발생한다. 반드시 is_ssl()을 넘겨야 HTTPS 환경에서 올바른 쿠키 설정이 된다.
redirect_to 파라미터 전파. 로그인이 필요한 페이지에서 로그인 페이지로 보낼 때, 원래 가려던 URL을 redirect_to 파라미터로 전달하고, 로그인 성공 후 그 URL로 보내야 한다. 이 체인이 끊기면 사용자가 항상 홈으로만 돌아가서 혼란스러워한다.
정리
custom-auth.php 하나의 mu-plugin 파일에 인증 시스템 전체가 들어간다. 클래스 하나, shortcode 4개, AJAX 핸들러 6개, 필터 4개. wp-login.php를 완전히 대체하면서도, WordPress의 기존 사용자 시스템(wp_signon, wp_create_user, get_password_reset_key 등)을 그대로 활용하기 때문에, 다른 플러그인과의 호환성 문제가 없다. 프론트엔드는 프레임워크 없이 바닐라 JS + fetch API로 구현했고, CSS도 별도 파일 없이 PHP에서 인라인 출력한다. 외부 의존성은 reCAPTCHA 하나뿐이고, 그것마저 상수 미정의 시 꺼진다.