PHP / MariaDB / 데이터베이스에 데이터 입력하기

PHP에서 MariaDB에 폼 데이터를 입력할 때는 서버에서 값을 검증한 뒤 INSERT의 값 자리에 매개변수를 바인딩해 실행합니다. 사용자 입력을 SQL 문자열에 직접 이어 붙이지 않습니다. 아래 예제는 직원 이름·성·입사일을 저장하고 새 ID를 표시하며, 저장 후에는 GET 화면으로 이동해 새로고침에 따른 재전송을 줄입니다.

실습 테이블과 연결 설정 준비

PHP의 mysqli·mbstring 확장, MariaDB 서버와 learning 데이터베이스가 필요합니다. 계정과 연결 준비는 mysqli_connect() 사용법을 참고하세요. 예제는 로컬 학습용이며, 운영의 직원 관리 화면에는 로그인·작업 권한 확인과 HTTPS 등 서비스에 맞는 구성이 추가되어야 합니다. CSRF 토큰만으로 작업 권한을 부여하는 것은 아닙니다.

SQL 클라이언트에서 다음을 한 번 실행합니다. 기존 데이터가 없는 별도의 실습 환경을 사용하고 같은 이름의 테이블이 이미 있으면 삭제해서 덮어쓰지 마세요. 계정에는 준비 단계의 CREATE와 실제 저장에 필요한 INSERT 권한 등이 있어야 합니다.

USE learning;
CREATE TABLE employees_insert_demo (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    hire_date DATE NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

id는 AUTO_INCREMENT이므로 INSERT에서 생략하면 서버가 번호를 만듭니다. 이름·성은 각 50문자, 입사일은 DATE로 저장합니다. 원래의 employees 샘플 테이블과 독립된 실습 테이블이므로 그 대규모 데이터를 설치할 필요는 없습니다.

실습 폴더에 database-config.php를 만들고 그 아래 public 폴더를 만드세요. 설정 파일은 public 밖에 둡니다. 다음 자리표시자를 실제 실습 계정의 비밀번호로 바꾸고 호스트·포트·데이터베이스도 맞춥니다. 파일은 공개 배포·버전 관리에서 제외하고 읽기 권한을 제한하세요.

<?php
return [
    'host' => '127.0.0.1',
    'username' => 'learning_user',
    'password' => 'REPLACE_WITH_YOUR_PRIVATE_PASSWORD',
    'database' => 'learning',
    'port' => 3306,
];

폼과 저장 처리를 포함한 전체 PHP 코드

다음 전체 코드를 public/employee-add.php로 저장합니다. 실습 폴더에서 php -S 127.0.0.1:8000 -t public을 실행하고 http://127.0.0.1:8000/employee-add.php를 엽니다. 브라우저가 세션 쿠키를 허용해야 합니다. 파일을 UTF-8로 저장하고 시작 태그 앞에 공백이나 출력이 없도록 하세요.

<?php
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
header('Content-Type: text/html; charset=UTF-8');
if (!extension_loaded('mysqli') || !extension_loaded('mbstring')) {
    http_response_code(500);
    exit('mysqli와 mbstring 확장이 필요합니다.');
}
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
$errors = [];
$firstName = $lastName = $hireDate = '';
function escapeText($value): string {
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['csrf'] ?? null;
    if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
        http_response_code(403);
        exit('폼을 다시 열고 제출하세요.');
    }
    $firstName = is_string($_POST['first_name'] ?? null) ? trim($_POST['first_name']) : '';
    $lastName = is_string($_POST['last_name'] ?? null) ? trim($_POST['last_name']) : '';
    $hireDate = is_string($_POST['hire_date'] ?? null) ? $_POST['hire_date'] : '';
    foreach ([$firstName, $lastName] as $name) {
        if (!mb_check_encoding($name, 'UTF-8') || $name === '' || mb_strlen($name, 'UTF-8') > 50) {
            $errors[] = '이름과 성은 각각 UTF-8 문자열 1~50문자여야 합니다.';
            break;
        }
    }
    $date = preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/D', $hireDate)
        ? DateTimeImmutable::createFromFormat('!Y-m-d', $hireDate) : false;
    if (!$date || $date->format('Y-m-d') !== $hireDate || $hireDate < '1000-01-01') {
        $errors[] = '입사일은 유효한 YYYY-MM-DD 날짜여야 합니다.';
    }
    if ($errors !== []) {
        http_response_code(422);
    } else {
        $link = $stmt = null;
        $saved = false;
        try {
            $c = require dirname(__DIR__) . '/database-config.php';
            mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
            $link = mysqli_connect($c['host'], $c['username'], $c['password'], $c['database'], $c['port']);
            mysqli_set_charset($link, 'utf8mb4');
            $stmt = mysqli_prepare($link,
                'INSERT INTO employees_insert_demo (first_name, last_name, hire_date) VALUES (?, ?, ?)');
            mysqli_stmt_bind_param($stmt, 'sss', $firstName, $lastName, $hireDate);
            mysqli_stmt_execute($stmt);
            $id = mysqli_insert_id($link);
            $saved = mysqli_stmt_affected_rows($stmt) === 1;
            if (!$saved) throw new RuntimeException('Unexpected affected row count');
        } catch (Throwable $e) {
            http_response_code(500);
            error_log('Employee insert failed. Code: ' . $e->getCode());
            $errors[] = '저장하지 못했습니다. 서버 로그를 확인하세요.';
        } finally {
            if ($stmt instanceof mysqli_stmt) mysqli_stmt_close($stmt);
            if ($link instanceof mysqli) mysqli_close($link);
        }
        if ($saved) {
            $_SESSION['flash'] = '저장했습니다. 새 ID: ' . $id;
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
            header('Location: employee-add.php', true, 303);
            exit;
        }
    }
}
?>
<!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><?= escapeText($message) ?></p><?php endif; ?>
  <?php foreach ($errors as $error): ?><p><?= escapeText($error) ?></p><?php endforeach; ?>
  <form action="employee-add.php" method="post">
    <input type="hidden" name="csrf" value="<?= escapeText($_SESSION['csrf']) ?>">
    <p><label>이름 <input name="first_name" maxlength="50" required value="<?= escapeText($firstName) ?>"></label></p>
    <p><label>성 <input name="last_name" maxlength="50" required value="<?= escapeText($lastName) ?>"></label></p>
    <p><label>입사일 <input type="date" name="hire_date" required value="<?= escapeText($hireDate) ?>"></label></p>
    <button type="submit">저장</button>
  </form>
</body>
</html>

가상의 예로 이름 민수, 성 김, 입사일 2025-03-01을 입력해 저장합니다. 성공하면 입력 화면으로 돌아와 저장했습니다와 새 ID가 표시됩니다. 번호는 현재 테이블 상태에 따라 달라지며, 항상 1부터 연속된다고 가정하면 안 됩니다. 데이터베이스 생성·연결·권한 오류는 저장 실패 응답과 서버 로그를 함께 확인하세요.

서버 검증과 준비된 문장 이해하기

required·maxlength·date 입력 요소는 사용자 편의를 위한 브라우저 기능이며 서버 검증을 대신하지 않습니다. 코드는 배열 형태의 값이나 빈 이름, 50문자 초과와 유효하지 않은 UTF-8을 거절합니다. 날짜는 형식뿐 아니라 실제 달력 날짜와 일치하는지도 검사합니다. 예를 들어 2025-02-30은 올바른 입사일로 저장되지 않습니다.

DateTimeImmutable::createFromFormat()은 범위를 벗어난 날짜를 보정할 수 있으므로 파싱 성공만 확인하지 않고 다시 포맷한 값과 원문을 비교했습니다. 입력이 날짜 범위와 비즈니스 규칙을 만족하는지는 서비스별로 추가 판단해야 합니다. 이 예제는 MariaDB DATE 범위에 맞춰 1000년 이상인 날짜를 허용합니다.

INSERT의 세 물음표는 값의 자리입니다. mysqli_stmt_bind_param()의 sss는 세 값을 문자열 매개변수로 보낸다는 뜻입니다. SQL 구조와 데이터가 분리되므로 작은따옴표가 포함된 이름도 문자열 연결로 SQL을 망가뜨리지 않고 전달할 수 있습니다. 테이블 이름 같은 SQL 식별자를 물음표로 바꾸는 방식은 아닙니다. 전체 흐름은 준비된 문장 안내를 참고하세요.

mysqli_insert_id()는 같은 연결에서 방금 생성된 자동 증가 ID를 읽는 용도로 사용합니다. 가장 큰 ID를 별도 SELECT로 조회하면 다른 요청의 행과 혼동할 수 있습니다. 실행 직후 새 ID 조회와 영향받은 행 수를 확인하며, 이 예제에서는 한 행 저장을 기대합니다.

CSRF 확인과 저장 후 리디렉션

교차 사이트 요청 위조(CSRF)는 사용자의 브라우저를 통해 의도하지 않은 변경 요청을 보내는 공격입니다. 예제는 세션에 임의의 토큰을 저장하고 폼의 값과 hash_equals()로 비교합니다. 없거나 다르면 HTTP 403으로 종료하므로 DB 작업을 수행하지 않습니다. 실제 서비스의 인증과 권한 확인은 이 검사와 별도로 수행해야 합니다.

성공하면 자원을 닫은 뒤 HTTP 303으로 같은 폼의 GET 주소로 이동합니다. 이 POST→리디렉션→GET 흐름은 완료 화면의 새로고침이 INSERT를 다시 보내는 것을 줄입니다. header()는 출력 전에 호출해야 하며, 이동 뒤에는 실행을 끝냅니다.

이 방식이 모든 중복 저장을 보장해서 막는 것은 아닙니다. 토큰을 바꾸면 이전 폼은 다시 열어야 하고, 여러 탭에서의 제출 경험도 고려해야 합니다. 중복을 허용할 수 없는 실제 업무에서는 적절한 고유 제약·업무 키나 요청 식별자를 사용해 동시 요청도 처리해야 합니다. 브라우저 버튼을 잠그는 것만으로 데이터 무결성을 보장하지 마세요.

저장 결과와 실패 조건 확인

SQL 클라이언트에서 다음을 실행해 실제 값이 저장되었는지 확인합니다. 성공 문구만 보고 결과를 추정하지 말고 이름·성·입사일과 ID를 비교하세요.

SELECT id, first_name, last_name, hire_date
FROM learning.employees_insert_demo
ORDER BY id DESC LIMIT 5;

빈 이름이나 존재하지 않는 날짜를 보내면 HTTP 422가 되고 새 행이 생기지 않아야 합니다. CSRF 토큰이 없는 POST는 403이며, 폼을 GET으로 여는 것 자체는 저장 작업이 아닙니다. 운영 오류는 상세 SQL·비밀번호를 응답에 넣지 말고 제한된 로그에서 확인합니다.

이 예제는 제출된 값을 폼에 다시 표시할 때 HTML 특수문자를 변환합니다. 준비된 문장은 SQL 처리용이므로 HTML 출력 변환까지 대신하지 않습니다. 실습 후에는 내장 서버를 종료하고 불필요한 공개 파일을 정리하세요. 실제 DB에서 잘못 입력한 행을 지워야 한다면 확인한 ID 등 정확한 조건으로 대상을 먼저 조회하고 처리해야 합니다.