PHP / MariaDB / 데이터베이스 값 가져와서 출력하기

PHP에서 MariaDB 값을 출력하려면 연결을 만든 뒤 SELECT를 실행하고, 결과에서 행을 읽어 HTML이나 텍스트로 표시합니다. 여러 행은 mysqli_fetch_assoc()를 반복 호출하고, COUNT(*)처럼 한 행으로 집계된 결과는 한 번 읽으면 됩니다. 데이터베이스에서 온 문자열도 HTML에 넣을 때는 특수문자를 변환해야 합니다.

재현 가능한 실습 데이터 준비

PHP의 mysqli 확장, 실행 중인 MariaDB 서버, learning 데이터베이스와 접근 계정이 준비되어 있어야 합니다. 연결 준비는 mysqli_connect() 연결 예제를 참고하세요. 아래는 원리를 재현하기 위해 만든 가상의 직원 두 명과 급여 기록 세 건이며 실제 직원·급여 자료가 아닙니다. 기존 employees 샘플 데이터베이스를 설치할 필요는 없습니다.

SQL 클라이언트에서 다음을 실행합니다. 이름이 같은 실습 테이블이 이미 있으면 기존 자료를 지우지 말고 별도 실습 데이터베이스를 선택한 뒤 SQL과 PHP의 설정을 함께 맞추세요. 계정에는 준비 단계의 테이블 생성·입력 권한이 필요하며, 조회만 하는 서비스 계정에는 필요한 조회 권한만 주는 편이 좋습니다.

USE learning;
CREATE TABLE employees_demo (
    emp_no INT PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    hire_date DATE NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE salaries_demo (
    emp_no INT NOT NULL,
    salary INT NOT NULL,
    valid_from DATE NOT NULL,
    valid_until DATE NOT NULL,
    PRIMARY KEY (emp_no, valid_from)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO employees_demo VALUES
    (101, '민수', '김', '2023-03-01'),
    (102, '수진', '박', '2024-07-01');
INSERT INTO salaries_demo VALUES
    (101, 50000, '2024-01-01', '2024-12-31'),
    (101, 55000, '2025-01-01', '2025-12-31'),
    (102, 48000, '2025-01-01', '2025-12-31');

employees_demo는 직원 한 명당 한 행이고 salaries_demo는 기간별 급여 기록입니다. 한 직원에게 여러 급여 행이 있을 수 있으므로 JOIN 결과의 행 수가 직원 수와 같다고 가정하면 안 됩니다. 여기에서 급여 수치는 결합 동작을 확인하기 위한 임의의 숫자입니다.

공개 폴더 밖에 설정과 공통 코드 저장

실습 폴더에 database-config.php와 db.php를 만들고, 공개용 public 하위 폴더를 만드세요. 다음은 database-config.php의 전체 내용입니다. 비밀번호 자리표시자와 계정·서버 정보를 실제 실습 환경의 값으로 바꿉니다. 비밀번호가 들어간 파일은 공개 폴더와 버전 관리에 포함하지 않습니다.

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

다음은 db.php입니다. 연결과 문자셋 설정, HTML 본문 텍스트의 변환 함수를 공통으로 제공합니다. 이 두 파일은 public 폴더의 한 단계 밖에 있어야 합니다. 운영 환경에서는 오류를 화면에 표시하지 않도록 PHP 설정을 점검하고 서버 로그의 접근도 제한하세요.

<?php
function openDatabase(): mysqli {
    if (!extension_loaded('mysqli')) {
        throw new RuntimeException('mysqli is required');
    }
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
    $c = require __DIR__ . '/database-config.php';
    $link = mysqli_connect($c['host'], $c['username'], $c['password'], $c['database'], $c['port']);
    try {
        mysqli_set_charset($link, 'utf8mb4');
    } catch (Throwable $e) {
        mysqli_close($link);
        throw $e;
    }
    return $link;
}
function escapeText($value): string {
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

여러 행을 가져와 HTML 표로 출력

다음 전체 코드를 public/employees.php로 저장합니다. 실습 폴더에서 php -S 127.0.0.1:8000 -t public을 실행하고 http://127.0.0.1:8000/employees.php를 열어 확인하세요. PHP 소스가 그대로 보이면 PHP를 실행하는 서버를 통해 접근하고 있는지부터 확인합니다.

<?php
header('Content-Type: text/html; charset=UTF-8');
require dirname(__DIR__) . '/db.php';
$rows = [];
$total = 0;
$failed = false;
$link = null;
try {
    $link = openDatabase();
    $result = mysqli_query($link,
        'SELECT emp_no, first_name, last_name, hire_date
         FROM employees_demo ORDER BY emp_no LIMIT 5');
    while ($row = mysqli_fetch_assoc($result)) {
        $rows[] = $row;
    }
    mysqli_free_result($result);
    $countResult = mysqli_query($link, 'SELECT COUNT(*) AS total FROM employees_demo');
    $countRow = mysqli_fetch_assoc($countResult);
    $total = (int) $countRow['total'];
    mysqli_free_result($countResult);
} catch (Throwable $e) {
    http_response_code(500);
    error_log('Employee query failed. Code: ' . $e->getCode());
    $failed = true;
} finally {
    if ($link instanceof mysqli) mysqli_close($link);
}
?>
<!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 ($failed): ?>
    <p>데이터를 조회하지 못했습니다.</p>
  <?php elseif ($rows === []): ?>
    <p>등록된 직원이 없습니다.</p>
  <?php else: ?>
    <p>전체 <?= $total ?>명 중 최대 5명을 표시합니다.</p>
    <table>
      <thead><tr><th>번호</th><th>이름</th><th>성</th><th>입사일</th></tr></thead>
      <tbody>
      <?php foreach ($rows as $row): ?>
        <tr>
          <td><?= escapeText($row['emp_no']) ?></td>
          <td><?= escapeText($row['first_name']) ?></td>
          <td><?= escapeText($row['last_name']) ?></td>
          <td><?= escapeText($row['hire_date']) ?></td>
        </tr>
      <?php endforeach; ?>
      </tbody>
    </table>
  <?php endif; ?>
</body>
</html>

화면에는 전체 2명이라는 안내와 101·민수·김·2023-03-01, 102·수진·박·2024-07-01 두 행이 나옵니다. 표의 tbody 안에서는 tr로 행을 만들고 td로 각 값을 나눕니다. p 요소로 여러 값을 이어 붙이는 것은 표의 행을 만드는 방법이 아닙니다.

필요한 열을 명시하고 고유 번호로 ORDER BY를 지정했으므로 이 실습에서는 일관된 순서로 최대 5행을 가져옵니다. LIMIT만으로 순서까지 보장되지는 않습니다. 데이터가 커지면 전체를 한꺼번에 가져오기보다 정렬 기준과 페이지 크기를 정한 페이지 나누기가 필요합니다.

결과 읽기와 건수 집계 구분

mysqli_query()의 SELECT 성공 결과는 결과 집합 객체입니다. mysqli_fetch_assoc()는 그중 다음 행을 열 이름이 키인 배열로 읽고, 더 읽을 행이 없으면 null을 반환합니다. 실패 시 false일 수도 있으므로 단순히 false만 종료 조건으로 쓰지 않도록 주의하세요. 예제는 행이 있을 동안 반복하고 mysqli 오류를 별도로 처리합니다.

COUNT(*) 쿼리는 GROUP BY가 없는 집계이므로 직원이 없어도 total이 0인 결과 한 행을 만듭니다. countRow를 한 번 읽는 이유입니다. 직원 목록 쿼리에 LIMIT 5가 있어도 별도의 COUNT 쿼리는 전체 직원 수를 셉니다. MariaDB COUNT 문서에서 COUNT(*)와 NULL을 제외하는 COUNT(열)의 차이를 확인할 수 있습니다.

escapeText()는 htmlspecialchars()로 HTML 본문에 넣을 문자열의 특수문자를 변환합니다. 데이터베이스에 저장된 값이라고 신뢰해서 그대로 HTML로 실행하지 않습니다. SQL의 NULL은 PHP의 null로 오며, 같은 이름의 결과 열은 연관 배열에서 덮어쓸 수 있으므로 필요한 열과 별칭을 명확히 선택해야 합니다.

JOIN으로 직원 이름과 급여 기록 함께 출력

public/salaries.php에 다음 전체 코드를 저장합니다. 같은 설정과 db.php를 사용하고, http://127.0.0.1:8000/salaries.php에서 확인합니다. 이 파일은 text/plain 응답이므로 값이 HTML로 해석되지 않고 줄 단위로 표시됩니다.

<?php
header('Content-Type: text/plain; charset=UTF-8');
require dirname(__DIR__) . '/db.php';
$link = null;
try {
    $link = openDatabase();
    $result = mysqli_query($link,
        'SELECT e.emp_no, e.first_name, e.last_name,
                s.salary, s.valid_from, s.valid_until
         FROM employees_demo AS e
         LEFT JOIN salaries_demo AS s ON s.emp_no = e.emp_no
         ORDER BY e.emp_no, s.valid_from LIMIT 20');
    while ($row = mysqli_fetch_assoc($result)) {
        echo $row['emp_no'], ' ', $row['last_name'], $row['first_name'], ' | ',
            $row['salary'] ?? '급여 기록 없음', ' | ',
            $row['valid_from'] ?? '-', ' ~ ', $row['valid_until'] ?? '-', PHP_EOL;
    }
    mysqli_free_result($result);
} catch (Throwable $e) {
    http_response_code(500);
    error_log('Salary query failed. Code: ' . $e->getCode());
    echo "데이터를 조회하지 못했습니다.", PHP_EOL;
} finally {
    if ($link instanceof mysqli) mysqli_close($link);
}

결과는 김민수의 50000·2024년 기간, 김민수의 55000·2025년 기간, 박수진의 48000·2025년 기간 순서입니다. 사번 101이 두 번 나오는 이유는 서로 다른 기간의 급여 기록이 두 건이기 때문입니다. 현재 급여 한 건만 필요하면 이력 중 어느 기간을 선택할지 조건을 추가해야 하며 LIMIT 1로 전체 결과를 자르는 것으로 대신할 수 없습니다.

LEFT JOIN의 왼쪽을 직원 테이블로 두었으므로 급여 기록이 없는 직원도 나오고 오른쪽 열은 NULL이 됩니다. 예제는 이 경우 급여 기록 없음과 하이픈을 표시합니다. 급여 기록이 있는 직원만 필요하면 INNER JOIN을 검토할 수 있습니다. 결합 규칙은 MariaDB JOIN 안내를 참고하세요.

빈 결과와 오류를 다르게 처리하기

직원이 없는 것은 정상적인 빈 결과이지만 쿼리 실패는 다른 상태입니다. employees.php는 빈 배열이면 등록된 직원이 없다는 문장을, 예외가 발생하면 HTTP 500과 조회 실패 문장을 표시합니다. 오류를 빈 목록으로 숨기면 테이블 이름·권한·연결 문제를 놓칠 수 있습니다. 서버 로그의 오류 코드와 실제 스키마를 함께 확인하세요.

예제의 SQL은 고정된 문자열입니다. 검색어·사번 같은 외부 입력을 조건에 넣을 때는 문자열을 이어 붙이지 말고 준비된 문장과 바인딩을 사용합니다. SQL 매개변수 처리와 HTML 출력 변환은 서로 다른 단계이므로 둘 중 하나가 다른 하나를 대체하지 않습니다. 실습이 끝나면 로컬 서버를 종료하고 임시 공개 파일을 정리하세요.