PHP / Session / 로그인, 로그아웃, 로그인 상태에서만 보이는 콘텐츠 만들기
PHP에서는 비밀번호를 검증한 뒤 사용자 식별자를 세션에 저장해 로그인 상태를 유지할 수 있습니다. 보호할 페이지는 본문을 출력하기 전에 서버에서 세션을 검사하고, 로그아웃 때는 세션 데이터와 쿠키를 정리해야 합니다. 아래는 로그인·회원 콘텐츠·로그아웃의 흐름을 로컬 컴퓨터에서 재현하는 전체 예제입니다.
실습 범위와 파일 구조
PHP 8.x의 명령행 실행 환경이 필요합니다. 실습 계정 하나를 파일에 저장하므로 DB나 외부 라이브러리는 사용하지 않습니다. 사용자 이름은 demo, 비밀번호는 sample-only passphrase 42로 공개된 실습용 값입니다. 실제 계정의 비밀번호를 넣거나 이 계정을 운영 사이트에 등록하지 마세요. 코드는 요청 주소가 로컬 루프백인 경우로 제한하고 웹 서버도 127.0.0.1에만 연결합니다.
새 login-demo 폴더에 app과 public 하위 폴더를 만들고 아래 구조로 파일을 저장합니다. 웹에 제공하는 문서 루트는 public뿐입니다. 계정 해시와 세션 파일이 있는 app을 문서 루트로 지정하면 안 됩니다. PHP 파일은 UTF-8 BOM 없이 저장하세요.
login-demo/
create-demo-user.php
app/
bootstrap.php
demo-user.php # 생성 도구가 만듭니다
sessions/ # 실행할 때 만들어집니다
public/
login.php
content.php
logout.php
인증은 “누구인지 확인”하는 것이고 권한 검사는 “어떤 자원에 접근할 수 있는지 확인”하는 것입니다. 여기서는 demo라는 한 계정의 로그인 여부만 확인합니다. 여러 회원의 소유권·관리자 역할·가입·비밀번호 재설정까지 구현한 서비스 예제는 아닙니다.
공개 실습 비밀번호의 해시 파일 생성
루트의 create-demo-user.php에 다음 전체 코드를 저장합니다. 비밀번호 평문 대신 password_hash()의 결과를 app/demo-user.php에 기록하며, 이미 있는 파일을 덮어쓰지 않습니다. 이 생성 파일은 직접 편집할 필요가 없습니다. 함수와 저장 형식은 password_hash() 사용법을 참고하세요.
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('명령행 실행 전용입니다.');
}
$dir = __DIR__ . '/app';
if (!is_dir($dir) && !mkdir($dir, 0700, true)) {
throw new RuntimeException('app 폴더를 만들지 못했습니다.');
}
$record = [
'id' => 'demo',
'username' => 'demo',
'password_hash' => password_hash('sample-only passphrase 42', PASSWORD_DEFAULT),
];
$source = "<?php\nreturn " . var_export($record, true) . ";\n";
$file = fopen($dir . '/demo-user.php', 'x');
if ($file === false) {
throw new RuntimeException('설정 파일이 이미 있거나 만들 수 없습니다. 덮어쓰지 않습니다.');
}
$written = fwrite($file, $source);
fclose($file);
if ($written !== strlen($source)) {
throw new RuntimeException('설정 파일 저장을 완료하지 못했습니다.');
}
echo '실습 계정 파일을 생성했습니다.', PHP_EOL;
생성 도구는 명령행 전용입니다. 마지막 절의 실행 명령에서 한 번 호출합니다. 다시 실행하면 기존 파일 때문에 중단하므로, 초기화하려면 자신이 만든 이 실습 폴더와 해당 파일인지 확인한 뒤 별도로 관리하세요. 실제 서비스에서는 계정별 해시를 DB 등 적절한 저장소에서 조회하고 운영 계정과 실습 계정을 분리해야 합니다.
공통 세션 설정과 만료 검사
app/bootstrap.php의 전체 코드입니다. 쿠키 전용·엄격 모드를 적용하고, 사용자 ID·로그인 시각·마지막 접근 시각으로 로그인 상태를 판단합니다. 실습의 비활동 만료는 15분, 로그인 후 전체 만료는 1시간입니다. 이 시간은 동작 확인을 위한 선택이며 모든 서비스의 권장값을 뜻하지 않습니다.
<?php
declare(strict_types=1);
// 이 예제는 컴퓨터 안의 로컬 HTTP 실습으로 제한합니다.
if (!in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('로컬 실습 전용입니다.');
}
$sessionDir = __DIR__ . '/sessions';
if (!is_dir($sessionDir) && !mkdir($sessionDir, 0700, true)) {
http_response_code(500);
exit('세션 저장소를 준비하지 못했습니다.');
}
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_save_path($sessionDir);
session_name('CFLOGINDEMO');
session_set_cookie_params([
'lifetime' => 0, 'path' => '/', 'secure' => false,
'httponly' => true, 'samesite' => 'Lax',
]);
if (!session_start()) {
http_response_code(500);
exit('세션을 시작하지 못했습니다.');
}
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrfToken(): string {
if (!isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function validCsrf(mixed $token): bool {
return is_string($token) && hash_equals(csrfToken(), $token);
}
function redirectTo(string $path): void {
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
header('Location: ' . $path, true, 303);
exit;
}
function isLoggedIn(): bool {
if (($_SESSION['user_id'] ?? null) !== 'demo') {
return false;
}
$created = $_SESSION['logged_in_at'] ?? null;
$lastSeen = $_SESSION['last_seen'] ?? null;
$now = time();
if (!is_int($created) || !is_int($lastSeen)
|| $now - $created >= 3600 || $now - $lastSeen >= 900) {
unset($_SESSION['user_id'], $_SESSION['logged_in_at'], $_SESSION['last_seen']);
$_SESSION['csrf'] = bin2hex(random_bytes(32));
return false;
}
$_SESSION['last_seen'] = $now;
return true;
}
세션 데이터는 app/sessions에 저장합니다. 예제는 로컬 HTTP만 허용해 Secure 쿠키를 끄며 HttpOnly와 SameSite=Lax를 사용합니다. 이를 그대로 외부 서비스에 배포하면 안 됩니다. 운영에서는 HTTPS와 Secure 쿠키, 프록시·공유 저장소 등의 실제 구성을 맞춰야 합니다. PHP 세션 보안 설정과 세션 관리 기본 지침을 참고하세요.
csrfToken()은 요청 간에 유지할 무작위 토큰을 만들고 validCsrf()는 문자열 형식과 일치를 검사합니다. CSRF는 다른 사이트가 사용자의 로그인 세션을 이용해 원치 않는 요청을 보내게 하는 공격입니다. SameSite 설정만으로 모든 CSRF 처리가 끝난다고 보지 말고 로그인과 로그아웃 양식의 토큰도 검증합니다. 관련 원칙은 OWASP CSRF 방어 지침에서 확인할 수 있습니다.
e()는 HTML 표시용 이스케이프 함수이며 비밀번호를 변환하는 함수가 아닙니다. redirectTo()에 전달하는 주소는 아래 코드의 고정 경로뿐입니다. 사용자 입력 URL을 그대로 전달하지 않습니다. HTTP 303과 실행 종료는 PHP 리디렉션 예제와 같은 방식입니다.
login.php: 비밀번호 확인 후 세션 ID 교체
public/login.php에 다음 전체 코드를 저장합니다. GET 요청에서는 양식을 표시하고, POST에서는 CSRF·입력 타입·길이를 검사한 뒤 저장된 해시를 검증합니다. 비밀번호의 공백은 임의로 제거하지 않으며 세션에도 비밀번호를 보관하지 않습니다.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
if (isLoggedIn()) {
redirectTo('/content.php');
}
$error = '';
$username = '';
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method === 'POST') {
if (!validCsrf($_POST['csrf'] ?? null)) {
http_response_code(403);
exit('양식을 다시 열어 주세요.');
}
$rawUser = $_POST['username'] ?? null;
$password = $_POST['password'] ?? null;
if (!is_string($rawUser) || !is_string($password)
|| strlen($rawUser) > 80 || strlen($password) > 72
|| $password === '' || str_contains($password, "\0")) {
http_response_code(422);
$error = '입력 형식과 길이를 확인하세요.';
} else {
$username = trim($rawUser);
$user = require __DIR__ . '/../app/demo-user.php';
$passwordOK = password_verify($password, $user['password_hash']);
if ($username !== $user['username'] || !$passwordOK) {
http_response_code(401);
$error = '사용자 이름 또는 비밀번호가 올바르지 않습니다.';
} else {
// 인증 상태를 넣기 전에 ID를 바꿔 이전 익명 세션과 분리합니다.
if (!session_regenerate_id(false)) {
http_response_code(500);
exit('로그인을 완료하지 못했습니다.');
}
$_SESSION = [
'user_id' => $user['id'], 'logged_in_at' => time(),
'last_seen' => time(), 'csrf' => bin2hex(random_bytes(32)),
];
redirectTo('/content.php');
}
}
} elseif ($method !== 'GET') {
http_response_code(405);
header('Allow: GET, POST');
exit;
}
$csrf = csrfToken();
session_write_close();
?>
<!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 ($error !== ''): ?><p><?= e($error) ?></p><?php endif; ?>
<form method="post" action="/login.php">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<p><label>사용자 이름 <input name="username" value="<?= e($username) ?>" autocomplete="username" required></label></p>
<p><label>비밀번호 <input type="password" name="password" autocomplete="current-password" required></label></p>
<button>로그인</button>
</form>
<p><a href="/content.php">회원 콘텐츠 접근 확인</a></p>
</body>
</html>
사용자 이름이 틀리거나 비밀번호가 틀린 경우 모두 같은 안내를 반환합니다. 이 한 계정 실습에서는 사용자 이름이 다르더라도 password_verify()를 수행합니다. 배열 입력·NUL 문자·빈 비밀번호·72바이트 초과는 입력 오류로 거절합니다. 72바이트는 이 실습의 bcrypt 호환 정책이며 실제 알고리즘과 기존 계정 정책에 맞게 설계해야 합니다. 검증 함수의 범위는 password_verify() 사용 예제를 참고하세요.
인증 성공 시 session_regenerate_id(false)로 새 ID를 발급받은 다음, 새 세션에만 user_id를 저장합니다. 따라서 이전 익명 ID를 알고 있어도 회원 콘텐츠에 접근할 수 없어야 합니다. false는 이전 익명 저장 데이터를 즉시 삭제하지 않는 선택입니다. ID 교체 전에 인증 상태를 넣으면 이전 세션에도 권한이 남을 수 있으므로 순서를 지키세요.
session_regenerate_id() 문서는 동시 요청·불안정한 네트워크에서 즉시 이전 데이터를 지울 때의 경쟁 조건을 설명합니다. 이 예제는 익명→인증 전환만 다룹니다. 실제 서비스의 주기적 ID 교체, 유예·폐기와 동시 요청 처리는 사용하는 세션 저장소와 프레임워크에 맞춰 설계해야 합니다.
content.php: 서버에서 회원 콘텐츠 접근 제한
public/content.php의 전체 코드입니다. isLoggedIn()이 false이면 HTML을 만들기 전에 로그인 페이지로 이동하고 실행을 끝냅니다. 로그인 버튼이나 메뉴를 숨기는 것만으로 접근을 제한하지 않습니다.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
if (!isLoggedIn()) {
redirectTo('/login.php');
}
$userId = $_SESSION['user_id'];
$csrf = csrfToken();
session_write_close();
?>
<!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>
<p><?= e($userId) ?> 계정으로 로그인했습니다.</p>
<p>회원 전용 예시 콘텐츠입니다.</p>
<form method="post" action="/logout.php">
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<button>로그아웃</button>
</form>
</body>
</html>
조건을 통과한 경우에만 회원 전용 예시 문장이 응답 본문에 포함됩니다. 화면에 필요한 사용자 ID와 로그아웃 토큰을 복사한 다음 세션 쓰기를 닫습니다. 비활동·전체 만료 조건에 걸리면 인증 정보를 지우고 다시 로그인하도록 합니다. 브라우저 쿠키의 수명만으로 서버의 로그인 만료를 판단하지 않습니다.
logout.php: POST와 토큰을 확인한 뒤 종료
public/logout.php에 다음 코드를 저장합니다. 직접 주소를 여는 GET은 405, 토큰이 맞지 않는 POST는 403으로 거절합니다. 올바른 로그아웃 버튼 요청에서만 현재 세션 배열·서버 저장 데이터·세션 쿠키를 정리합니다.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/bootstrap.php';
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('로그아웃 버튼을 사용하세요.');
}
if (!validCsrf($_POST['csrf'] ?? null)) {
http_response_code(403);
exit('양식을 다시 열어 주세요.');
}
$name = session_name();
$params = session_get_cookie_params();
$_SESSION = [];
if (!session_destroy()) {
http_response_code(500);
exit('로그아웃을 완료하지 못했습니다.');
}
setcookie($name, '', [
'expires' => time() - 3600, 'path' => $params['path'],
'secure' => $params['secure'], 'httponly' => $params['httponly'],
'samesite' => $params['samesite'],
]);
header('Location: /login.php', true, 303);
exit;
세션 쿠키를 지울 때는 생성할 때 사용한 이름과 path 등 조건을 맞춰야 합니다. session_destroy() 하나만 호출한다고 현재 $_SESSION 값과 브라우저의 쿠키까지 자동으로 사라지는 것은 아닙니다. 자세한 범위는 session_destroy() 문서를 참고하세요. 종료 후에는 303으로 로그인 페이지를 다시 조회합니다.
실행 순서와 확인할 결과
파일을 모두 저장한 뒤 login-demo 폴더에서 아래 두 명령을 차례대로 실행합니다. 첫 명령은 실습 해시 파일을 만들고 끝나며, 두 번째 명령은 public을 문서 루트로 로컬 서버를 실행합니다. 종료는 터미널에서 Ctrl+C입니다. 포트 8000이 사용 중이면 다른 번호로 바꾸고 접속 주소에도 반영하세요.
php create-demo-user.php
php -S 127.0.0.1:8000 -t public
- http://127.0.0.1:8000/content.php에 먼저 접속합니다. 회원 문장은 표시되지 않고 /login.php로 이동해야 합니다.
- 잘못된 비밀번호로 로그인합니다. 실패 안내가 나오고 여전히 content.php에 접근할 수 없어야 합니다. 입력한 비밀번호는 결과 화면이나 HTML 소스에 나타나면 안 됩니다.
- 사용자 이름 demo와 공개 실습 비밀번호 sample-only passphrase 42로 로그인합니다. /content.php로 이동하고 demo 계정 안내와 회원 전용 예시 문장이 표시됩니다.
- 개발자 도구에서 쿠키 CFLOGINDEMO가 로그인 전후 달라지는지 확인합니다. 값을 공개 캡처나 로그에 남기지는 마세요. 새로고침해도 로그인 상태가 유지되어야 합니다.
- 별도 쿠키 저장소를 사용하는 시크릿 창에서는 content.php 접근 시 로그인을 요구해야 합니다.
- 로그아웃 버튼을 누르고 content.php를 직접 다시 엽니다. 회원 문장이 나오지 않고 로그인 페이지로 돌아가야 합니다. GET으로 logout.php만 여는 것은 로그아웃 동작이 아닙니다.
headers already sent가 보이면 각 PHP 파일의 BOM·시작 전 공백과 require한 파일의 출력을 확인합니다. 세션 저장 경고가 있으면 app/sessions의 권한·디스크와 PHP 오류 로그를 점검합니다. localhost와 127.0.0.1을 번갈아 사용하면 쿠키가 이어지지 않을 수 있으므로 예제 주소를 일관되게 사용하세요.
실제 서비스로 확장할 때 필요한 판단
이 예제는 공개된 단일 계정으로 로그인 흐름을 재현하도록 로컬에 한정했습니다. 외부에 제공할 서비스에서는 검증된 인증 기능이나 프레임워크를 우선 검토하고, 계정 저장·로그인 시도 제한·다중 요소 인증·복구 절차·사용자별 권한을 설계해야 합니다. 요구 사항에 따라 재인증과 계정 정지·비밀번호 변경 시 기존 세션을 무효화하는 처리도 필요합니다. OWASP 인증 지침을 기준으로 필요한 범위를 정하세요.
여러 탭·여러 서버의 동시 요청과 로그아웃 후 세션 재사용 방지는 서버 저장소 수준에서도 검토해야 합니다. 화면의 Cache-Control: no-store는 캐시 제어이며 접근 권한 검사나 전체 세션 폐기 정책을 대신하지 않습니다. 세션 ID를 비밀정보처럼 다루고 URL·화면·일반 로그에 노출하지 않는 원칙과 만료·ID 갱신 정책은 OWASP 세션 관리 지침을 참고할 수 있습니다.




