PHP / MariaDB / 데이터베이스의 데이터 수정하기
PHP에서 MariaDB의 데이터를 수정하려면 기본 키로 현재 행을 조회해 폼에 채우고, 검증한 POST 값을 준비된 UPDATE 문장으로 저장합니다. WHERE 조건으로 대상을 제한하고 실제 저장 결과를 확인해야 합니다. 아래 예제는 버전 번호도 비교해 폼을 연 뒤 다른 작업이 수정한 값을 덮어쓰는 상황을 감지합니다.
수정 전용 실습 테이블과 설정
PHP의 mysqli·mbstring 확장, MariaDB 서버와 learning 데이터베이스가 준비되어 있어야 합니다. 데이터베이스 연결 준비를 확인하고 운영 데이터가 없는 실습 환경에서 다음 SQL을 실행하세요. 기존의 같은 이름 테이블이 있으면 덮어쓰지 말고 별도 실습 DB를 사용합니다.
USE learning;
CREATE TABLE employees_update_demo (
id INT NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
version INT NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO employees_update_demo (id, name) VALUES
(1, '기존 이름'), (2, '보존 확인용');
두 행 중 ID 1의 이름만 수정합니다. ID 2는 대상 범위가 맞는지 비교하기 위한 행입니다. version은 이 예제에서 수정 여부를 비교하는 정수이며 처음에는 1입니다. 직접 SQL이나 다른 앱으로 같은 행을 바꿀 때도 version을 함께 증가시켜야 이후 충돌 검사가 의미를 가집니다.
실습 폴더에 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,
];
조회 폼과 UPDATE의 전체 코드
다음 전체 코드를 public/employee-edit.php로 저장합니다. 실습 폴더에서 php -S 127.0.0.1:8000 -t public을 실행한 뒤 http://127.0.0.1:8000/employee-edit.php?id=1을 엽니다. 현재 이름이 입력 상자에 표시되어야 합니다. UTF-8로 저장하고 PHP 시작 태그 앞에 출력이 없도록 하세요.
<?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 확장이 필요합니다.');
}
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$source = $isPost ? $_POST : $_GET;
$rawId = $source['id'] ?? null;
$id = is_string($rawId) ? filter_var($rawId, FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 2147483647]]) : false;
if ($id === false) {
http_response_code(400);
exit('유효한 ID가 필요합니다.');
}
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
$name = '';
$version = 0;
$found = false;
$errors = [];
$redirect = false;
if ($isPost) {
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
exit('폼을 다시 열고 제출하세요.');
}
$rawVersion = $_POST['version'] ?? null;
$version = is_string($rawVersion) ? filter_var($rawVersion, FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 2147483646]]) : false;
$name = is_string($_POST['name'] ?? null) ? trim($_POST['name']) : '';
if ($version === false || !mb_check_encoding($name, 'UTF-8') || $name === '' || mb_strlen($name, 'UTF-8') > 50) {
http_response_code(422);
$errors[] = '버전과 이름을 확인하세요. 이름은 UTF-8 문자열 1~50문자입니다.';
}
}
function escapeText($value): string {
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$link = $stmt = null;
if ($errors === []) {
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');
if ($isPost) {
$stmt = mysqli_prepare($link,
'UPDATE employees_update_demo SET name = ?, version = version + 1 WHERE id = ? AND version = ?');
mysqli_stmt_bind_param($stmt, 'sii', $name, $id, $version);
mysqli_stmt_execute($stmt);
if (mysqli_stmt_affected_rows($stmt) === 1) {
$_SESSION['flash'] = '저장했습니다.';
$_SESSION['csrf'] = bin2hex(random_bytes(32));
$redirect = true;
} else {
http_response_code(409);
$errors[] = '다른 작업에서 수정 또는 삭제했습니다. 현재 값을 다시 불러오세요.';
}
} else {
$stmt = mysqli_prepare($link, 'SELECT name, version FROM employees_update_demo WHERE id = ?');
mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);
mysqli_stmt_bind_result($stmt, $name, $version);
$found = mysqli_stmt_fetch($stmt) === true;
if (!$found) {
http_response_code(404);
$errors[] = '해당 ID의 직원이 없습니다.';
}
}
} catch (Throwable $e) {
http_response_code(500);
error_log('Employee update failed. Code: ' . $e->getCode());
$errors[] = '처리하지 못했습니다. 서버 로그를 확인하세요.';
} finally {
if ($stmt instanceof mysqli_stmt) mysqli_stmt_close($stmt);
if ($link instanceof mysqli) mysqli_close($link);
}
}
if ($redirect) {
header('Location: employee-edit.php?id=' . $id, 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>직원 수정: <?= escapeText($id) ?></h1>
<?php if ($message !== ''): ?><p><?= escapeText($message) ?></p><?php endif; ?>
<?php foreach ($errors as $error): ?><p><?= escapeText($error) ?></p><?php endforeach; ?>
<?php if ($found || $isPost): ?>
<form action="employee-edit.php" method="post">
<input type="hidden" name="csrf" value="<?= escapeText($_SESSION['csrf']) ?>">
<input type="hidden" name="id" value="<?= escapeText($id) ?>">
<input type="hidden" name="version" value="<?= escapeText($version) ?>">
<p><label>이름 <input name="name" maxlength="50" required value="<?= escapeText($name) ?>"></label></p>
<button type="submit">저장</button>
</form>
<?php endif; ?>
<p><a href="employee-edit.php?id=<?= escapeText($id) ?>">현재 값 다시 불러오기</a></p>
</body>
</html>
이름을 김민수처럼 다른 가상의 값으로 바꾸고 저장하면 GET 화면으로 돌아오며 새 값과 저장 안내가 표시됩니다. 성공한 요청은 version도 1 증가시킵니다. URL의 id=2로 열면 다른 행을 조회할 수 있지만, 운영에서는 단순히 ID를 바꿀 수 있다는 사실이 그 행을 수정할 권한을 뜻하지 않습니다. 로그인과 행별 권한 범위를 조회·UPDATE 양쪽에서 검사해야 합니다.
조회·검증·저장 단계의 역할
GET 요청은 ID를 검증하고 SELECT만 실행합니다. 쿼리의 물음표에 ID를 바인딩하고 mysqli_stmt_bind_result()로 name·version 결과를 변수에 연결한 뒤 fetch()로 한 행을 읽습니다. 이 예제는 get_result()에 의존하지 않습니다.
POST 요청은 CSRF 토큰, ID·버전 범위, 이름의 자료형·UTF-8 유효성·길이를 서버에서 확인합니다. HTML의 required와 maxlength만으로는 서버 검증을 대신할 수 없습니다. 입력값을 폼의 value 속성에 다시 넣을 때도 HTML 특수문자를 변환하므로 따옴표가 포함된 이름이 속성을 깨뜨리지 않습니다.
UPDATE는 SET에서 바꿀 열을 지정하고 WHERE에서 ID와 이전 버전을 비교합니다. 문자열과 정수 두 개를 s·i·i로 바인딩하므로 사용자 입력이 SQL 구조로 합쳐지지 않습니다. 기본 문법과 WHERE의 범위는 MariaDB UPDATE 문서를 참고하세요. 여러 열을 수정한다면 각 열의 검증과 매개변수도 함께 늘려야 합니다.
영향받은 행 수 0과 수정 충돌 구분
일반적인 UPDATE에서 영향받은 행 수가 0인 것은 같은 값을 저장해 실제 변화가 없거나 일치하는 행이 없는 경우일 수 있습니다. 연결의 일치 행 반환 옵션에 따라서도 해석이 달라질 수 있으므로 0을 무조건 SQL 오류라고 판단하면 안 됩니다.
이 예제는 조건이 맞을 때 version을 항상 1 증가시키므로, 이름이 같아도 성공한 저장은 한 행 변경으로 나타납니다. 0이면 ID가 사라졌거나 이전 버전과 맞지 않는 상태로 판단하고 HTTP 409를 표시합니다. 현재 값을 다시 불러와 차이를 확인한 뒤 저장해야 하며, 충돌을 무시하고 예전 폼 값을 강제로 덮어쓰지 않습니다.
이 방식은 읽을 때 잠금을 오래 유지하는 대신 저장 시 조건을 검사하는 낙관적 동시성 제어의 한 예입니다. 버전 열은 권한 검사나 변경 이력 자체를 대신하지 않습니다. 여러 테이블을 함께 바꾸는 업무나 충돌 후 병합 정책은 별도 설계가 필요하며, 이 예제는 한 행의 이름 수정에 범위를 한정합니다.
저장 결과와 예외 확인
SQL 클라이언트에서 다음을 실행해 ID 1의 이름과 version, ID 2의 보존 상태를 확인합니다. 한 번 정상 저장했다면 ID 1의 version은 2이고 ID 2는 원래 이름과 version 1을 유지해야 합니다.
SELECT id, name, version FROM learning.employees_update_demo ORDER BY id;
존재하지 않는 ID를 GET으로 열면 404, 잘못된 ID는 400, 잘못된 토큰은 403, 이름·버전 검증 실패는 422입니다. DB 오류는 500 응답과 일반적인 안내를 반환하고 제한된 로그에 오류 코드를 남깁니다. 운영에서는 PHP의 상세 오류 표시도 끄고 SQL·접속 정보가 응답에 나타나지 않게 해야 합니다.
이 예제는 저장 후 303 리디렉션으로 POST 재전송을 줄이고 CSRF 토큰을 교체합니다. 여러 탭에서 이전 폼을 열어 두었다면 토큰 또는 버전이 만료될 수 있으므로 새로 조회해야 합니다. 잘못 저장한 값을 되돌릴 때도 최신 상태를 확인하고 현재 버전으로 수정 요청을 해야 하며, 단순 새로고침은 복원 기능이 아닙니다. 실습을 마치면 로컬 서버와 불필요한 공개 파일을 정리하세요.




