블로그워드프레스 커스텀 로그인/회원가입 시스템 만들기 — wp-login.php 완전 대체

워드프레스 커스텀 로그인/회원가입 시스템 만들기 — wp-login.php 완전 대체

워드프레스 기본 로그인 화면(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 하나뿐이고, 그것마저 상수 미정의 시 꺼진다.

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬