PHP · 심화
설계와 보안으로 깊어지는 PHP
네임스페이스와 자동 로딩 - PSR-4 구조 직접 만들기
spl_autoload_register 로 PSR-4 로더 구현, 디렉터리 구조와 네임스페이스 대응, Composer 의 autoload 설정이 하는 일(설명만), 여러 파일 예제
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 열거형과 값 객체로 강좌와 수강생을 표현하는 클래스를 만들었다. 클래스가 늘어나면 곧 파일이 늘어나고, 파일마다 require 를 적는 방식은 금세 한계에 부딪힌다. 이 장에서는 클래스 이름만 보고 PHP 가 알아서 파일을 찾아 읽게 만드는 자동 로딩(autoloading)을 직접 구현한다. 규칙은 PSR-4 를 따른다. 구현이 끝나면 Composer 의 autoload 설정이 내부에서 무엇을 하는지도 같은 틀로 이해할 수 있다.
- 네임스페이스와 디렉터리 구조를 PSR-4 규칙으로 대응시킬 수 있다.
spl_autoload_register로 로더를 등록하고, PHP 가 로더를 부르는 시점을 설명할 수 있다.- 접두사 맵을 가진 PSR-4 로더 클래스를 외부 도구 없이 작성할 수 있다.
- Composer 의
autoload설정이 하는 일을 설명할 수 있다.
문제 상황
학원 시스템에 강좌, 강좌 상태, 수강생, 명단 클래스가 생겼다고 하자. 지금까지의 방식대로라면 main.php 위쪽은 이렇게 된다.
<?php
declare(strict_types=1);
require __DIR__ . '/src/Course/CourseStatus.php';
require __DIR__ . '/src/Course/Course.php';
require __DIR__ . '/src/Student/Student.php';
require __DIR__ . '/src/Enrollment/Roster.php';
// 클래스가 늘 때마다 한 줄씩 늘어난다
이 방식에는 세 가지 불편이 있다. 첫째, 파일이 늘 때마다 목록을 직접 고쳐야 하고 하나라도 빠지면 실행 중에 "Class not found" 오류가 난다. 둘째, 상속이나 인터페이스 구현이 있으면 부모 파일을 먼저 읽어야 하므로 줄 순서가 의미를 갖는다. 셋째, 이 요청에서 쓰지 않는 클래스까지 전부 읽고 컴파일한다. 파일을 다른 폴더로 옮기면 이 목록을 쓰는 모든 진입점을 같이 고쳐야 한다는 점도 있다.
원하는 상태는 단순하다. new Course(...) 라고 쓰는 순간 PHP 가 Academy\Course\Course 라는 이름에서 파일 위치를 계산해 읽어 오는 것이다. 이름과 위치의 대응 규칙만 정해 두면 목록은 필요 없다.
이름과 위치를 잇는 PSR-4 규칙
네임스페이스는 이름의 소속일 뿐이다
네임스페이스(namespace)는 같은 이름의 클래스가 충돌하지 않게 이름에 소속을 붙이는 장치다. 클래스의 전체 이름(fully qualified name)은 Academy\Course\Course 처럼 네임스페이스와 클래스 이름을 이은 것이다. 네임스페이스 자체는 파일이 어디에 있는지와 무관하다. 한 파일에 여러 네임스페이스를 선언할 수도 있고, 폴더 이름과 전혀 다른 네임스페이스를 쓸 수도 있다. 이름과 위치를 묶는 것은 언어 기능이 아니라 PHP-FIG 가 정한 규약인 PSR-4 이며, 규약 원문은 PSR-4 문서에서 확인할 수 있다.
대응 규칙
규칙은 네 줄로 요약된다.
- 전체 이름의 앞부분을 접두사(namespace prefix)로 잡고, 접두사마다 기준 디렉터리를 하나 정한다.
- 접두사를 뺀 나머지에서 네임스페이스 구분자
\는 디렉터리 구분자/로 바꾼다. - 마지막 클래스 이름 뒤에
.php를 붙인다. - 모든 이름은 대소문자를 구분하며, 파일 하나에 클래스(또는 열거형, 인터페이스) 하나를 둔다.
이 장의 예제는 접두사 Academy\ 를 기준 디렉터리 src/ 에 대응시킨다.
| 클래스 전체 이름 | 접두사를 뺀 부분 | 파일 경로 |
|---|---|---|
| Academy\Course\Course | Course\Course | src/Course/Course.php |
| Academy\Course\CourseStatus | Course\CourseStatus | src/Course/CourseStatus.php |
| Academy\Student\Student | Student\Student | src/Student/Student.php |
| Academy\Enrollment\Roster | Enrollment\Roster | src/Enrollment/Roster.php |
폴더 구조가 곧 네임스페이스 구조이므로, 클래스 이름만 보면 파일을 열 수 있고 파일 경로만 보면 클래스 이름을 안다. 이 일관성이 규약의 실제 가치다.
spl_autoload_register 와 로더
PHP 가 로더를 부르는 시점
spl_autoload_register 는 콜백을 오토로더 스택에 등록한다. PHP 는 아직 정의되지 않은 클래스를 실제로 써야 하는 순간 등록된 콜백을 순서대로 호출하고, 클래스가 정의되면 그 자리에서 멈춘다. 콜백에는 앞에 백슬래시가 붙지 않은 전체 이름이 문자열로 전달된다.
로더가 호출되는 대표적인 경우는 new, 정적 멤버 접근, extends 와 implements 로 선언된 부모를 해석할 때, 그리고 class_exists 호출이다. 반대로 Teacher::class 는 문자열로만 바뀌고, instanceof 의 오른쪽 이름이나 매개변수 타입 선언은 클래스를 읽지 않는다. 타입 선언에 적힌 클래스는 실제 값이 들어올 때 이미 로드되어 있기 때문이다. 이 구분 덕분에 쓰지 않는 클래스는 파일 자체가 읽히지 않는다.
로더를 만드는 순서
로더의 일은 이름 하나를 받아 파일 경로로 바꾸고, 파일이 있으면 읽는 것이다. 설계에서 정할 것이 몇 가지 있다.
- 접두사 맵: 접두사와 기준 디렉터리의 쌍을 여러 개 가질 수 있어야 한다. 접두사 끝에는
\를 붙여Academy\가AcademyTools\...와 섞이지 않게 한다. - 파일 확인: 경로를 계산한 뒤
is_file로 확인하고, 없으면 오류를 내지 않고 조용히 돌아간다. 스택에 다른 로더가 있을 수 있기 때문이다. 끝내 못 찾았을 때 PHP 가 "Class not found" 를 낸다. - 이름 검증: 클래스 이름이 사용자 입력에서 왔다면
..나/가 섞인 문자열이 들어올 수 있다. 영문자, 숫자, 밑줄, 백슬래시만 허용하는 정규식으로 먼저 걸러 둔다. - 첫 로더는 직접 읽는다: 로더 클래스 자신은 아직 로더가 없을 때 필요하므로 한 번은
require로 읽어야 한다. 로더 파일도src/Autoload/아래에 두어 규칙을 지키되, 부트스트랩만 수동으로 한다.
로더가 읽은 클래스를 기록하는 배열도 하나 둔다. 읽는 시점이 언제인지 눈으로 확인하는 용도이며, 출력이 결정적이므로 실행 결과에 그대로 쓸 수 있다.
Composer 의 autoload 설정이 하는 일
이 절은 설명만 하며 이 장의 실습에서는 Composer 를 쓰지 않는다. 실무 프로젝트에서는 composer.json 에 다음과 같이 접두사와 디렉터리 대응을 적는다.
{
"autoload": {
"psr-4": {
"Academy\\": "src/"
}
}
}
JSON 문자열 안의 백슬래시는 두 번 적어야 하므로 "Academy\\" 는 접두사 Academy\ 를 뜻한다. 접두사 끝의 백슬래시는 생략할 수 없다. 이 설정을 적고 composer dump-autoload 를 실행하면 vendor/autoload.php 와 접두사 맵 파일들이 만들어진다. 애플리케이션은 진입점에서 이 파일을 한 번 require 하면 된다. 그 안에서 Composer 의 ClassLoader 가 spl_autoload_register 로 등록되고, 클래스 이름이 오면 접두사 맵을 보고 경로를 계산해 파일을 읽는다. 우리가 만들 로더와 구조가 같다.
Composer 가 더해 주는 것은 두 가지다. 외부 패키지가 선언한 접두사를 한곳에 모아 주는 것, 그리고 클래스 이름과 파일 경로를 미리 표로 만들어 두는 클래스맵(classmap) 최적화로 파일 존재 확인을 줄이는 것이다. autoload 의 files 항목은 클래스가 아닌 함수 파일을 매 요청마다 읽게 하는 용도다. 자세한 설정은 Composer 문서의 autoload 항목에서 확인할 수 있다.
완성 코드
프로젝트 구조는 다음과 같다. 모든 파일은 하나의 폴더 아래에 두고, main.php 가 있는 폴더에서 실행한다.
academy/
main.php
src/
Autoload/Psr4Loader.php
Course/Course.php
Course/CourseStatus.php
Enrollment/Roster.php
Student/Student.php
src/Autoload/Psr4Loader.php
<?php
declare(strict_types=1);
namespace Academy\Autoload;
final class Psr4Loader
{
/** @var array<string, string> 접두사 => 기준 디렉터리 */
private array $prefixes = [];
/** @var list<string> */
private array $loaded = [];
public function addNamespace(string $prefix, string $baseDir): void
{
$this->prefixes[trim($prefix, '\\') . '\\'] = rtrim($baseDir, '/') . '/';
}
public function register(): void
{
spl_autoload_register($this->load(...));
}
public function load(string $class): void
{
$file = $this->resolve($class);
if ($file === null) {
return;
}
require $file;
$this->loaded[] = $class;
}
public function resolve(string $class): ?string
{
if (preg_match('/\A[A-Za-z0-9_\\\\]+\z/', $class) === 0) {
return null;
}
foreach ($this->prefixes as $prefix => $baseDir) {
if (str_starts_with($class, $prefix) === false) {
continue;
}
$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
return $file;
}
}
return null;
}
/** @return list<string> */
public function loadedClasses(): array
{
return $this->loaded;
}
}
src/Course/CourseStatus.php
<?php
declare(strict_types=1);
namespace Academy\Course;
enum CourseStatus: string
{
case Open = 'open';
case Closed = 'closed';
public function label(): string
{
return match ($this) {
self::Open => '모집 중',
self::Closed => '마감',
};
}
}
src/Course/Course.php
<?php
declare(strict_types=1);
namespace Academy\Course;
final class Course
{
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly int $capacity,
public readonly CourseStatus $status,
) {
}
public function describe(): string
{
return sprintf(
'[%d] %s (정원 %d명, %s)',
$this->id,
$this->title,
$this->capacity,
$this->status->label(),
);
}
}
src/Student/Student.php
<?php
declare(strict_types=1);
namespace Academy\Student;
final class Student
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {
}
}
src/Enrollment/Roster.php
<?php
declare(strict_types=1);
namespace Academy\Enrollment;
use Academy\Course\Course;
use Academy\Course\CourseStatus;
use Academy\Student\Student;
final class Roster
{
/** @var list<Student> */
private array $students = [];
public function __construct(private readonly Course $course)
{
}
public function enroll(Student $student): bool
{
if ($this->course->status === CourseStatus::Closed) {
return false;
}
if (count($this->students) >= $this->course->capacity) {
return false;
}
$this->students[] = $student;
return true;
}
public function count(): int
{
return count($this->students);
}
public function names(): string
{
return implode(', ', array_map(fn (Student $s): string => $s->name, $this->students));
}
}
main.php
<?php
declare(strict_types=1);
require __DIR__ . '/src/Autoload/Psr4Loader.php';
use Academy\Autoload\Psr4Loader;
use Academy\Course\{Course, CourseStatus};
use Academy\Enrollment\Roster;
use Academy\Student\Student;
$loader = new Psr4Loader();
$loader->addNamespace('Academy', __DIR__ . '/src');
$loader->register();
echo '등록 직후 로드된 클래스 수: ' . count($loader->loadedClasses()) . "\n";
$course = new Course(1, 'PHP 기초반', 2, CourseStatus::Open);
echo $course->describe() . "\n";
$roster = new Roster($course);
foreach (['김하늘', '이도윤', '박서연'] as $i => $name) {
$accepted = $roster->enroll(new Student($i + 1, $name));
echo $name . ': ' . ($accepted ? '신청 완료' : '정원 초과') . "\n";
}
echo '수강생 ' . $roster->count() . '명: ' . $roster->names() . "\n";
foreach ([Course::class, Roster::class, 'Academy\Course\Teacher'] as $class) {
$file = $loader->resolve($class);
echo $class . ' -> ' . ($file === null ? '(파일 없음)' : str_replace(__DIR__ . '/', '', $file)) . "\n";
}
echo 'Teacher 존재: ' . (class_exists('Academy\Course\Teacher') ? '예' : '아니오') . "\n";
echo '로드 순서: ' . implode(' > ', $loader->loadedClasses()) . "\n";
줄별 해설
Psr4Loader
addNamespace 는 접두사를 trim 으로 정리한 뒤 항상 끝에 \ 를 하나 붙이고, 기준 디렉터리 끝에도 / 를 하나 붙인다. 호출하는 쪽이 'Academy' 로 쓰든 'Academy\' 로 쓰든 같은 결과가 되게 하려는 정규화다. 단일 따옴표 안에서 '\\' 는 백슬래시 한 글자다.
register 의 $this->load(...) 는 메서드를 콜러블로 바꾸는 첫 번째 클래스 콜러블 문법이다. 인스턴스에 묶인 콜백이 되므로 $this->prefixes 를 그대로 쓴다.
load 는 PHP 가 호출하는 진입점이다. 경로 계산은 resolve 에 맡기고, 파일을 찾았을 때만 require 한 뒤 이름을 기록한다. 찾지 못하면 아무것도 하지 않고 돌아가므로 다른 로더나 PHP 의 기본 오류 처리가 이어진다. 로더가 require 로 읽은 파일 안에서 클래스가 정의되어야 호출한 쪽의 new 가 이어서 성공한다.
resolve 의 정규식 /\A[A-Za-z0-9_\\\\]+\z/ 는 PHP 문자열에서 \\\\ 가 \\ 두 글자로 해석되고, 정규식에서 이것이 백슬래시 한 글자를 뜻한다. 이름 전체가 허용 문자로만 이루어져야 통과한다. 그다음 루프는 접두사로 시작하는 항목만 보고, 접두사를 잘라 낸 나머지에서 \ 를 / 로 바꿔 경로를 만든다. 파일이 없으면 continue 없이 다음 접두사로 넘어가므로, 같은 접두사 계열을 여러 폴더에 나누어 둘 수도 있다. 이 방법은 연습 문제에서 다룬다.
도메인 클래스
CourseStatus, Course, Student, Roster 는 모두 파일 하나에 선언 하나이고, 네임스페이스가 폴더 경로와 일치한다. Course 는 같은 네임스페이스의 CourseStatus 를 use 없이 쓴다. Roster 는 다른 네임스페이스의 클래스를 use 로 가져온다. use 는 이름을 줄여 쓰게 할 뿐 파일을 읽게 하지 않는다. 실제로 읽는 시점은 그 이름이 처음 필요해지는 실행 시점이다.
main.php
맨 위에서 로더 파일만 require 하고, 로더를 만들어 접두사 하나를 등록한 뒤 register 를 호출한다. 이 시점에는 읽힌 클래스가 없으므로 첫 출력은 0 이다.
new Course(...) 에서 PHP 는 Course 를 먼저 찾고, 이어서 인수의 CourseStatus::Open 을 평가하며 CourseStatus 를 찾는다. new Roster 와 첫 new Student 도 같은 방식으로 한 번씩 로더를 거친다. 두 번째 new Student 부터는 이미 정의되어 있으므로 로더가 불리지 않는다. 로드 순서 출력에 클래스가 한 번씩만 나오는 이유다. 정원이 2명인 강좌에 세 명이 신청하므로 세 번째는 거절된다.
반복문에서 Course::class 와 Roster::class 는 클래스를 읽지 않고 이름 문자열만 만든다. resolve 는 경로만 계산하므로, 이 구간에서 새로 읽히는 파일은 없다. 존재하지 않는 Teacher 는 파일이 없어 null 이 나오고, class_exists 는 로더를 한 번 거친 뒤 false 를 돌려준다. 로더가 조용히 돌아가기 때문에 오류 없이 "아니오" 가 출력된다.
실행 결과
$ php main.php
등록 직후 로드된 클래스 수: 0
[1] PHP 기초반 (정원 2명, 모집 중)
김하늘: 신청 완료
이도윤: 신청 완료
박서연: 정원 초과
수강생 2명: 김하늘, 이도윤
Academy\Course\Course -> src/Course/Course.php
Academy\Enrollment\Roster -> src/Enrollment/Roster.php
Academy\Course\Teacher -> (파일 없음)
Teacher 존재: 아니오
로드 순서: Academy\Course\Course > Academy\Course\CourseStatus > Academy\Enrollment\Roster > Academy\Student\Student
이 장의 예제는 웹 요청을 다루지 않으므로 php -S 로 브라우저에서 확인하는 절차는 필요 없다.
실무에서 자주 틀리는 것
파일 이름의 대소문자가 클래스 이름과 다르다
macOS 의 기본 파일 시스템은 대소문자를 구분하지 않아서 로컬에서는 문제없이 돌지만, 배포한 Linux 서버에서 클래스를 찾지 못한다.
// 틀린 예: src/Course/course.php
namespace Academy\Course;
final class Course
{
}
로더는 src/Course/Course.php 를 찾으므로 Linux 에서는 is_file 이 실패하고 결국 "Class not found" 가 된다. 파일 이름을 클래스 이름과 글자 하나까지 같게 고친다.
// 고친 예: src/Course/Course.php
namespace Academy\Course;
final class Course
{
}
네임스페이스 선언이 폴더와 어긋난다
// src/Student/Student.php
namespace Academy\Students; // 폴더는 Student 인데 선언은 Students
final class Student
{
}
로더는 Academy\Student\Student 에 대해 이 파일을 읽지만, 파일 안에서 정의되는 클래스 이름은 Academy\Students\Student 다. 읽기는 성공했는데 원하는 클래스가 없으므로 호출한 줄에서 "Class not found" 가 나, 원인이 파일 경로인지 선언인지 헷갈리기 쉽다. 폴더 경로와 namespace 줄을 같이 맞춘다.
// 고친 예
namespace Academy\Student;
로더를 등록하기 전에 클래스를 쓴다
use Academy\Course\Course;
$course = new Course(1, 'PHP 기초반', 2, \Academy\Course\CourseStatus::Open); // 아직 로더가 없다
$loader->register();
use 줄은 파일을 읽지 않으므로, 등록 전에 new 를 실행하면 읽을 방법이 없다. 로더는 진입점의 맨 앞에서 만들고 등록한 뒤에 클래스를 쓴다.
$loader->register();
$course = new Course(1, 'PHP 기초반', 2, CourseStatus::Open);
요청 값을 그대로 클래스 이름으로 쓴다
$request = ['type' => 'Academy\Course\Course'];
$class = $request['type'];
$object = new $class(1, '임의 강좌', 10, CourseStatus::Open); // 입력이 곧 클래스 이름
입력이 곧 클래스 이름이 되면 호출자가 앱 안의 아무 클래스나 생성하게 할 수 있다. 로더의 이름 검증은 경로 조작을 막을 뿐이고 어떤 클래스를 만들지는 정해 주지 않는다. 허용 목록을 두고 목록에 있는 것만 쓴다.
$allowed = ['course' => Course::class];
$class = $allowed[$request['type']] ?? null;
if ($class === null) {
echo "지원하지 않는 종류\n";
} else {
$object = new $class(1, '허용된 강좌', 10, CourseStatus::Open);
}
한눈에 보기
| 요소 | 역할 | 이 장의 코드 | Composer |
|---|---|---|---|
| 네임스페이스 | 이름의 소속을 정한다 | Academy\Course | 같음 |
| PSR-4 규칙 | 이름과 파일 경로를 대응시킨다 | Academy\ → src/ | composer.json 의 psr-4 |
| spl_autoload_register | 없는 클래스를 요청받을 때 부를 콜백을 등록한다 | Psr4Loader::register | ClassLoader 가 등록 |
| 로더 | 이름을 경로로 바꿔 파일을 읽는다 | Psr4Loader::load | vendor/autoload.php 와 접두사 맵 |
| 코드 | 로더 호출 | 이유 |
|---|---|---|
| new Teacher() | 호출한다 | 객체를 만들려면 클래스 정의가 필요하다 |
| Teacher::count() | 호출한다 | 정적 멤버를 쓰려면 클래스가 필요하다 |
| class_exists('Teacher') | 호출한다 | 기본 동작이 로더를 거치는 것이다 |
| Teacher::class | 호출하지 않는다 | 이름 문자열만 만든다 |
| $x instanceof Teacher | 호출하지 않는다 | 없는 클래스면 그냥 거짓이 된다 |
연습 문제
- 출결 표시를 나타내는 열거형
Academy\Attendance\AttendanceMark를 추가하려 한다. 파일 경로를 쓰고, 파일 첫머리의namespace줄을 적어라. $loader->addNamespace('Academy\Course', __DIR__ . '/lib/courses');를 기존Academy등록 뒤에 추가했다.Academy\Course\CourseStatus파일을lib/courses/CourseStatus.php에만 두면 로더가 찾는지, 그 이유와 함께 답하라.- 다음 중 로더가 호출되는 것을 모두 골라라. (가)
$name = Teacher::class;(나)$x instanceof Teacher(다)class_exists(Teacher::class)(라)new Teacher() - 접두사가
Academy\와Academy\Course\둘 다 등록되어 있을 때, 더 긴 접두사를 먼저 검사하도록addNamespace를 고쳐라.
정답과 해설
1. 경로는 src/Attendance/AttendanceMark.php 이고 첫머리는 namespace Academy\Attendance; 다. 접두사 Academy\ 를 뺀 나머지 Attendance\AttendanceMark 에서 구분자를 / 로 바꾸고 .php 를 붙인 결과다. 클래스 이름과 파일 이름의 대소문자까지 같아야 한다.
2. 찾는다. 로더는 등록 순서대로 접두사를 보며, 먼저 Academy\ 로 src/Course/CourseStatus.php 를 계산한다. 이 파일이 없으면 is_file 이 실패해 다음 접두사로 넘어간다. Academy\Course\ 를 잘라 낸 나머지 CourseStatus 로 lib/courses/CourseStatus.php 를 계산해 찾는다. 단, 두 폴더에 같은 클래스가 있으면 먼저 등록된 쪽이 이기므로 등록 순서가 의미를 갖는다.
3. (다)와 (라)다. Teacher::class 는 문자열을 만들 뿐이고, instanceof 는 없는 클래스를 만나도 로더를 부르지 않고 거짓이 된다. class_exists 는 기본적으로 로더를 거치고, new 는 클래스 정의가 필요하다.
4. 접두사를 추가한 뒤 길이가 긴 순서로 정렬하면 된다.
public function addNamespace(string $prefix, string $baseDir): void
{
$this->prefixes[trim($prefix, '\\') . '\\'] = rtrim($baseDir, '/') . '/';
uksort($this->prefixes, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
}
키 길이를 내림차순으로 비교하므로 Academy\Course\ 가 Academy\ 보다 앞에 온다. 더 구체적인 접두사가 우선되는 방식이고, 문제 2 와 달리 등록 순서가 결과에 영향을 주지 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.