여러 동적 타입 언어가 각자의 방식대로 강타입을 지원해가는 과정은 정말 흥미롭습니다. php에서도 타입에 대한 더욱 다양한 지원을 추가하려는 노력이 계속되고 있는 데다 PHPStan, Psalm과 같은 정적 분석 도구의 도움으로 더 단단한 코드를 쉽게 작성할 수 있게 되었습니다. 특히 제네릭 부재에 대해 글을 쓴 적도 있었을 만큼 제네릭이 도입되기를 기대하고 있었는데요. 수 년이 지난 지금은 PHP에서도 제네릭을 충분히 사용할 수 있는 환경이 되었습니다. phpdoc의 @template 키워드를 사용해서 작성하면 PHPStan과 같은 정적 분석 도구로 검사를 수행하는 방식으로 제네릭을 활용할 수 있습니다.

php에서는 별도의 빌드/컴파일 과정이 없기 때문에 정적 분석을 수행하는 과정이 낯설 수 있지만 실제로 코드를 실행하지 않고도 파일을 분석해서 문제를 검출할 수 있다는 점 자체는 정말 큰 장점입니다. PHPStorm은 이미 내장된 플러그인이 있어서 패키지를 설치하고 설정 파일만 작성하면 편리하게 개발 환경에 적용할 수 있습니다. VS Code에서도 또한 플러그인이 있어서 제네릭에 맞춰 반환 타입을 보여주는 등 편리하게 활용 가능합니다.

제네릭(generic)은 클래스, 인터페이스, 메소드, 함수 등에서 타입을 미리 선언하되 나중에 사용할 때 어떤 타입인지 지정하는 식으로 활용할 수 있는 프로그래밍 스타일입니다. 대다수 프로그래밍 언어에서는 리스트와 배열 같은 자료 구조에서 가장 먼저 배우게 됩니다.

목록이라는 이름으로 자료 구조를 만든다고 생각해봅시다. 목록의 동작 방식을 코드로 작성하되 무슨 목록인지는 정하지 않습니다. 다시 말하면 구체적인 타입 대신 제네릭을 활용해서 작성하는 겁니다. 나중에 책이든 자동차든 어떤 타입이든 이 제네릭 목록에 적용해서 책 목록, 자동차 목록, 어떤 목록으로든 활용하는 것이 가능합니다.

먼저 기존 방식을 생각해봅시다. PHP에서 제공하고 있는 타입 힌트를 사용해서 다음처럼 BookList 클래스를 작성합니다.

class Book
{
    public function __construct(protected string $title)
    {
    }
}

class BookList
{
    /**
     * @var Book[] $items
     */
    protected array $items = [];

    /**
     * @param Book[] $items
     */
    public function __construct(array $items = [])
    {
        $this->items = $items;
    }

    /**
     * 목록에서 인덱스에 해당하는 항목을 반환합니다
     */
    public function get(int $index): Book
    {
        return $this->items[$index];
    }

    /**
     * 목록에 제공된 항목을 추가합니다
     */
    public function add(Book $item): void
    {
        $this->items[] = $item;
    }
}

$list = new BookList([
    new Book('그리고 아무도 없었다'),
    new Book('바스커빌 가의 개'),
]);
$list->add(new Book('주홍색 연구'));
$list->get(2); // Book('주홍색 연구')

// PHPStan: [OK] No errors

위 코드에서는 Book 개체만 사용할 수 있는 BookList 클래스를 작성했습니다. 만약 Car 클래스를 추가한다면 위 코드와 별 차이는 없지만 별도의 CarList를 작성해야 합니다. 코드를 분리한다고 해도 각 메소드에서 타입을 지정하고 있기 때문에 별반 다르지 않은 상황입니다. 이런 상황에서 제네릭이 빛을 발합니다.

이제 @template 키워드를 사용해서 제네릭 목록 클래스를 작성합니다.

/**
 * @template T
 */
class GenericList
{
    /**
     * @var T[] $items
     */
    protected array $items = [];

    /**
     * @param T[] $items
     */
    public function __construct(array $items = [])
    {
        $this->items = $items;
    }

    /**
     * 목록에서 인덱스에 해당하는 항목을 반환합니다
     *
     * @param int $index
     * @return T
     */
    public function get(int $index)
    {
        return $this->items[$index];
    }

    /**
     * 목록에 제공된 항목을 추가합니다
     *
     * @param T $item
     * @return void
     */
    public function add($item): void
    {
        $this->items[] = $item;
    }
}

달라진 점을 살펴봅니다. 클래스에서 어떤 제네릭 타입을 사용할지 @template을 사용했고 각 메소드의 인자에 직접 지정한 타입 힌트 대신 phpdoc으로 제네릭 타입을 지정했습니다. 다른 클래스인 Computer를 추가해서 이 제네릭 목록이 제대로 동작하는지 확인합니다.

class Computer
{
    public function __construct(protected string $name)
    {
    }
}

$list = new GenericList([
    new Book('그리고 아무도 없었다'),
    new Book('바스커빌 가의 개'),
]);
$list->add(new Book('주홍색 연구'));
$list->add(new Computer('내 컴퓨터'));

// PHPStan: [ERROR] Found 1 error
// Parameter #1 $item of method GenericList<Book>::add()
//   expects Book, Computer given.

GenericList<Book>Book이 아닌 Computer를 추가했다는 에러가 발생합니다. 정적 분석으로 이 코드에 문제가 발생할 수 있음을 확인했습니다. 다만 이 코드는 현재로는 문제없이 실행 가능한 코드입니다. 실제 구동에 문제가 없더라도 코드 작성자의 의도에 맞게 동작하고 있는지는 또 고민해야 할 부분입니다. 책 목록에 자동차가 들어가는 상황은 괜찮을까요? 위 예제 코드에서는 큰 문제가 없지만 책 목록에서 책 제목을 찾는다고 가정하면 자동차 순서가 왔을 때 문제가 발생하게 됩니다. 이렇게 코드가 구동되는 런타임에서만 발견되는 문제는 문제 해결은 물론 원인을 추적하는 일조차 복잡한 일이 될 수도 있습니다.

다른 언어에서는 정적 분석에 오류가 있는 경우, 수정할 때까지 실행을 해보는 것 자체가 되질 않지만 PHP는 그런 과정 없이 실행할 수 있기 때문에 미리 잡을 수 있는 문제도 놓칠 수 있습니다. 그래서 이 정적 분석이 더 중요한 역할을 수행하게 됩니다.

사실 위 코드에서는 다른 문제가 발생할 수 있습니다.

$list = new GenericList([
    new Book('그리고 아무도 없었다'),
    new Computer('도서관 공공 컴퓨터'),
]);
$list->add(new Book('주홍색 연구'));
$list->add(new Computer('내 컴퓨터'));
// PHPStan: [OK] No errors

위 코드를 보면 목록을 초기화하며 BookComputer가 동시에 존재합니다. PHPStan은 제네릭을 적용할 때 타입을 추론하기 때문에 위 코드에서는 GenericList<Book|Computer> 타입이 됩니다. 즉, BookComputer를 둘 다 사용할 수 있는 목록을 $list에 할당합니다.

가장 심각한 상황은 초기화 배열이 없는 경우입니다. 아무런 배열을 전달하지 않으면 GenericList<mixed>로 추론되어 아무 값이나 다 넣어도 오류가 발생하지 않습니다. 이런 추론은 php 맥락에서는 아주 자연스럽지만 코드를 작성한 의도와는 차이가 많이 날 수 있습니다. 단순하게 정적 분석에 의존하기를 넘어서 제대로 작성 의도가 반영되고 있는 지도 유심하게 봐야 합니다.

책만 다뤄야 하는 목록이라면 @extends 키워드를 활용해서 GenericList<Book>을 확장하는 BookList 클래스를 정의할 수 있습니다.

/**
 * @extends GenericList<Book>
 */
class BookList extends GenericList {}

$list = new BookList([
    new Book('그리고 아무도 없었다'),
    new Book('바스커빌 가의 개'),
    new Computer('내 컴퓨터'),
]);

// PHPStan: [ERROR] Found 1 error
// Parameter #1 $items of class BookList constructor
//    expects array<Book>, array<int, Book|Computer> given.

또는 @param 키워드를 활용해서 GenericList<Book>만 인자로 허용하도록 작성할 수 있습니다.

/**
 * @param GenericList<Book> $list
 * @return void
 */
public function updateBookTitles(GenericList $list): void
{
    // do something
}

$list = new GenericList([
    new Book('그리고 아무도 없었다'),
    new Book('바스커빌 가의 개'),
    new Computer('내 컴퓨터'),
]);

updateBookTitles($list);
// PHPStan: [ERROR] Found 1 error
// Parameter #1 $list of function updateBookTitles() expects
//   GenericList<Book>, GenericList<Book|Computer> given.

다음과 같은 파생 클래스를 생각해봅시다.

class Book
{
    public function __construct(protected string $title)
    {
    }
}

class ClassicBook extends Book
{
}

class RomanceBook extends Book
{
}

이 각각의 파생 클래스로 이뤄진 목록에서 첫번째 책을 가져오려고 합니다. 다음처럼 함수를 작성해볼 수 있겠지만 아쉽게도 오류가 발생합니다.

/**
 * @param GenericList<Book> $list
 * @return Book
 */
public function getFirstBook(GenericList $list): Book
{
    return $list->get(0);
}

$list = new GenericList([
    new ClassicBook('오디세이'),
    new RomanceBook('오만과 편견'),
]);

getFirstBook($list);

// PHPStan: [ERROR] Found 1 error
// Parameter #1 $list of function getFirstBook() expects
//    GenericList<Book>, GenericList<ClassicBook|RomanceBook> given. 

제네릭은 기본적으로 무공변성(invariant)으로 정의됩니다. 즉, GenericList<Book>Book만 허용합니다. 그렇다고 매번 새로운 책 타입이 추가될 때마다 GenericList<ClassicBook|ScienceFictionBook|RomanceBook|HistoryBook> 식으로 수정하긴 어렵습니다. 이런 경우에는 @template-covariant 키워드를 활용해서 타입이 공변성(covariant)을 지니고 있음을 정의해야 합니다.

공변성을 정말 간단하게 정리하면 다음과 같습니다. 공변성과 반공변성은 무엇인가? 글에서 좀 더 예시를 볼 수 있습니다.

  • 공변성(covariance): 지정된 타입과 같거나 그보다 범위가 작아진 타입 허용
  • 무공변성(invariance): 지정된 타입만 허용
  • 반공변성(contravariance): 지정된 타입과 같거나 그보다 범위가 넓어진 타입 허용

먼저 다음과 같이 인터페이스를 추출할 수 있습니다.

/**
 * @template-covariant T
 */
interface ReadableListInterface {
    /**
     * 목록에서 인덱스에 해당하는 항목을 반환합니다
     *
     * @param int $index
     * @return T
     */
    public function get(int $index);
}

/**
 * @template T
 */
interface WritableListInterface {
    /**
     * 목록에 제공된 항목을 추가합니다
     *
     * @param T $item
     * @return void
     */
    public function add($item): void;
}

제네릭 목록은 이 두 인터페이스를 모두 구현하고 있습니다. 다음처럼 정리할 수 있습니다.

/**
 * @template T
 * @extends ReadableListInterface<T>
 * @extends WritableListInterface<T>
 */
interface GenericListInterface
    extends ReadableListInterface, WritableListInterface {}

/**
 * @template T
 * @implements GenericListInterface<T>
 */
class GenericList implements GenericListInterface
{
    /**
     * @var T[] $items
     */
    protected array $items = [];

    /**
     * @param T[] $items
     */
    public function __construct(array $items = [])
    {
        $this->items = $items;
    }

    public function get(int $index)
    {
        return $this->items[$index];
    }

    public function add($item): void
    {
        $this->items[] = $item;
    }
}

앞서의 함수도 다음처럼 ReadableListInterface<Book> 인터페이스를 사용하도록 수정합니다.

/**
 * @param ReadableListInterface<Book> $list
 * @return Book
 */
public function getFirstBook(ReadableListInterface $list): Book
{
    return $list->get(0);
}

$list = new GenericList([
    new ClassicBook('오디세이'),
    new RomanceBook('오만과 편견'),
]);
getFirstBook($list);

// PHPStan: [OK] No errors

ReadableListInterface<T>에서의 T는 공변성을 지니고 있다고 @template-covariant로 정의한 덕분에 위처럼 파생 클래스를 대상으로도 동작하게 됩니다.

참고로 ReadableListInterface<T>WritableListInterface<T>로 분리하는 이유는 함수에서 인자 타입은 반공변성을 갖고 반환 타입은 공변성을 갖기 때문입니다. 이 부분은 PHP 자체에서도 강제하고 있습니다. 그래서 WritableListInterface<T>에서 T는 인자 타입에 사용되고 있는 상황에서 이 타입에 공변성이 있다고 정의하면 서로 충돌합니다. 다행히 이런 부분도 오류로 모두 확인할 수 있습니다. 인자 타입의 반공변성과 반환 타입의 공변성은 공변성과 반공변성은 무엇인가?에서 더 자세히 확인할 수 있습니다.


여기까지 간단하게 제네릭 목록을 구현하면서 어떤 방식으로 제네릭을 활용할 수 있는지 확인했습니다. 아쉽게도 아직 반공변성을 위한 키워드는 제공되고 있지 않지만 PHPStan은 아직도 활발하게 개발이 진행되고 있어서 앞으로 기대해도 좋을 것 같습니다. 제네릭은 이 글에서 살펴본 방식 이외에도 다양하게 활용할 수 있습니다. PHPStan 블로그에 게시된 아래 글에서 더 많은 예시를 확인하시기 바랍니다.

JetBrains TV의 PhpStorm Tips 시리즈를 보면서 정리했다.

스타일 설정하기

이 영상에서는 화면에 보이는 부분을 설정하는 방법을 알려준다. 최대한 깔끔한 방식을 선호해서 그런지 대부분 설정을 끄는데 취향에 맞게 따라하면 되겠다.

단축키

  • 프로젝트 탭 열기/닫기: cmd + 1 (Alt + 1)
  • 전체 화면으로 변경: ctrl + cmd + F
  • 전체 검색(Searching Everywhere): Shift, Shift
    • 파일 뿐만 아니라 설정 등 찾기에도 사용 가능
  • 콘텍스트 메뉴 열기: alt + enter
    • (전구 아이콘 누르면 나타나는 메뉴)

설정 내역

  • Material UI theme 설치, 폰트 JetBrains Mono로 변경
    • Color Scheme Font와 Console Font 설정이 따로 있음
  • 필요에 따라 아래 설정 변경, 전체 검색에서 해당 설정을 찾으면 바로 변경 가능함
    • Show Status Bar: 화면 하단에 있는 상태 막대 숨기기
    • Hide Tool Window Bars: 좌우에 있는 도구 창 숨기기
    • Tab placement: 파일 탭 위치 변경 또는 숨기기
    • Show browser popup in the editor: 편집창 우측 상단에 표시되는 브라우저 숨기기
    • Breadcrumbs
    • Menus and toolbars
      • 우측 상단에 표시되는 빌드 설정 등 버튼 변경할 수 있음
      • Toolbar Run Actions 찾아서 사용하지 않는 버튼 지우기
      • 다 지우더라도 상단에 초기화 버튼으로 초기화 가능

효과적으로 네비게이션 사용하기

단축키

단축키는 모두 keymap 설정에서 변경 가능하다.

  • 다음 탭으로 이동: ctrl + right, 또는 shift + cmd + ], 또는 ctrl + cmd + N
  • 이전 탭으로 이동: ctrl + left, 또는 shift + cmd + [, 또는 ctrl + cmd + P
  • 최근 파일 열기: cmd + E (ctrl + E)
    • 탭을 끄고 사용하는 경우 유용
  • 최근 작업한 위치 열기: shift + cmd + E (shift + ctrl + E)
  • 선언 또는 구현으로 이동하기: cmd + B (ctrl + B)
    • 구현에서 사용하면 선언부로 이동됨
    • 선언에서 사용하면 이 선언을 구현한 파일이 모두 표시되며 선택해서 이동 가능
  • 파일 구조 이동하기: cmd + F12 (ctrl + F12)
    • 클래스 내 구조를 목록으로 표시
    • 프로퍼티나 각 메소드로 이동할 수 있음
    • 이 목록 내에서 검색은 그냥 타이핑하면 가능
  • 파일 검색: shift + cmd + O (shift + ctrl + N)
  • 심볼 검색: alt + cmd + O (shift + ctrl + alt + N)
  • 액션 검색: shift + cmd + A (shift + ctrl + A)

라라벨 플러그인

  • barryvdh/laravel-ide-helper: php 패키지
  • PhpStorm Laravel: PhpStorm 플러그인
  • Laravel Idea: PhpStorm 플러그인, 연 $39
    • 코드 생성 도구 지원
    • 라라벨에 맞는 자동완성 지원
      • 예를 들면 라우터에서 PageController@show 자동완성 지원, 모델 내 cast에서 캐스팅 가능한 타입을 자동완성, 요청 검증에서 검증 규칙을 자동완성 해준다든지 등

라라벨에서는 파사드 패턴으로 다양한 기능을 제공한다. 다만 프레임워크에서 기능을 주입하는 형태로 구현되어 있어서 어떤 코드가 실제로 사용되고 있는지 확인하기 어려운 경우가 많다. 라라벨 패키지에 barryvdh/laravel-ide-helper를 설치하면 PhpStorm이 사용할 수 있는 헬퍼 파일을 생성해주며 어떤 코드가 실제로 실행되고 있는지 쉽게 확인할 수 있다.

패키지 설치 후 다음 명령으로 생성한다.

$ php artisan ide-helper:generate

라라벨의 모델은 엘로퀸트로 작성하게 되는데 액티브레코드 패턴으로 구현되어 있다. 모델 구현체에는 각 프로퍼티가 선언되어 있지 않기 때문에 IDE의 자동 완성이 제대로 동작하지 않는다. 이 플러그인이 이런 문제도 해결한다. 다음처럼 모델 헬퍼를 사용한다.

$ php artisan ide-helper:models

헬퍼 파일을 생성함과 동시에 각 모델에 존재하는 프로퍼티나 메소드를 phpdoc로 작성해준다.

코드 스니핏

라이브 템플릿

설정 내 live templates에서 언어별 전체 목록을 확인할 수 있다. 예를 들면 eco 입력해서 자동완성 선택하면 echo "";로 확장, fore 입력하면 foreach 구문으로 확장해주는 식이다. 템플릿에서도 $변수명$ 형태로 변수를 지정할 수 있다. 설정에서 추가/수정/삭제가 모두 가능하다.

Edit Variables 버튼을 누르면 각 사용한 변수에 표현식을 작성하는 것도 가능하다. 표현식에는 다양한 내장 함수를 지원하는데 commentStart(), commentEnd() 등을 사용해서 언어에 국한되지 않는 라이브 템플릿을 작성할 수 있다.

이렇게 작성한 라이브 템플릿은 xml 포맷으로 저장할 수 있고 또한 설정으로 공유하는 것도 가능하다.

클래스 메소드

  • pubf: public function () {}
  • prof: protected function () {}
  • prif: private function () {}
  • pubsf: public static function () {}
  • prosf: protected static function () {}
  • prisf: private static function () {}

둘러싸기

텍스트를 선택한 뒤에 태그로 감싸거나 할 때 사용하는 기능이다. alt + cmd + T (ctrl + alt + T)로 사용할 수 있다. 어떻게 동작할지 라이브 템플릿에서 지정 가능하며 이 방식으로 동작하는 코드는 $SELECTION$ 템플릿 변수를 사용해야 한다. 예를 들어 링크를 추가한다면 다음처럼 등록한다.

<a href="$URL$">$SELECTION$<a>

postfix 자동완성

예를 들면 $users.if, $users.isset 등으로 입력하면 적절하게 템플릿으로 변경해준다.

// $users.if
if ($users) {}
// $users.isset
if (isset($users)) {}
// $users.nn
if ($users !== null) {}

이 템플릿은 설정 내 postfix completion에서 찾을 수 있으며 추가/변경/삭제가 가능하다.

터미널 활용하기

단축키

  • 터미널 열기: alt + F12
  • 활성화 도구 창 숨기기: Shift + esc
  • 터미널 창에서
    • 새 터미널 탭 열기: cmd + t
    • 현재 터미널 탭 닫기: cmd + w
    • 터미널에서 우 클릭하면 창 분할 선택 가능
    • 분할된 창 이동: alt + tab (영상에서는 창 분할이나 터미널 탭 이동 등 단축키를 등록해서 사용)
    • 터미널에서 코드 창으로 커서 이동: esc

리팩토링 기능 사용하기

클래스명을 변경하거나 메소드명을 변경하는 등의 기능은 다른 파일도 동시에 수정되야 하는데 이런 작업을 효율적으로 수행할 수 있도록 리팩토링 기능을 제공하고 있다. 단축키 ctrl + T (ctrl + alt + shift + T)를 입력하면 수행 가능한 리팩토링 목록이 나온다. 이름 변경 외에도 메소드를 상위 클래스로 이동하거나 코드를 메소드로 분리, 인터페이스로 분리하는 등의 다양한 리팩토링을 수행할 수 있다.

  • 이름 변경하기: shift + F6
  • 변수로 변경하기: alt + cmd + V (ctrl + alt + V)
  • 상수로 변경하기: alt + cmd + C (ctrl + alt + C)
  • 필드(프로퍼티)로 변경하기: alt + cmd + F (ctrl + alt + F)
  • 인자(파라미터)로 변경하기: alt + cmd + P (ctrl + alt + P)
  • 메소드로 추출하기: alt + cmd + M (ctrl + alt + M)
  • 인라인으로 만들기: alt + cmd + N (ctrl + alt + N)
  • Pull members up...: 상위 클래스로 이동
  • pull members down...: 하위 클래스로 이동
  • Make Static: 정적 메소드로 변경
    • 만약 인스턴스에 의존이 있다면 해당 내용에 대해서도 프롬프트를 보여줌
  • 클래스명 위에서 리팩토링 수행
    • 클래스 이동하기: F6
      • 다른 네임스페이스로 클래스를 이동하는데 필요한 부수 작업을 함께 처리함

영상을 보면 실제로 리팩토링을 수행할 때 이 기능을 어떻게 활용하는지 확인할 수 있다.

테스팅 프레임워크

  • phpunit: 사실상 표준이라 볼 수 있는 php 테스팅 프레임워크.
  • behat: 행위주도 개발(Behavior-Driven Development) php 프레임워크. 사용자 스토리를 작성하고 테스트를 작성할 수 있음.
  • phpspec: BDD php 프레임워크. spec 기반.
  • pestphp: 내부적으론 phpunit이지만 더 간편한 문법으로 테스트를 작성할 수 있는 프레임워크.
  • codeception: 유닛 테스트, 기능 테스트, 인수 테스트(PhpBrowser 또는 WebDriver) 모두 가능한 프레임워크.

테스트 유틸리티

  • faker: 모의 데이터 생성을 돕는 라이브러리.
  • mockery: 모의 개체를 생성하는 php 프레임워크. 개체의 어떤 메소드를 호출하면 어떤 반환값을 반환하는 지 등을 지정해서 테스트 대역으로 활용할 수 있음.
  • Infection: 변조 테스트를 수행해서 각 경계값이 제대로 테스트되는지 확인하는 도구. 예를 들면 count($this->products) === 0 과 같은 코드를 count($this->products) > 0 등으로 변조해서 테스트를 통과하는지 실패하는지 확인하는 방식.
  • churn-php: 리팩토링 후보를 찾는데 도움 주는 도구. 얼마나 많은 커밋에서 해당 파일이 변경되었는지와 로직의 순환 복잡도를 기준으로 순위를 보여줌.
  • ParaTest: phpunit 병렬로 구동하는 도구.
  • roave/better-reflection: 내장 reflection API를 좀 더 사용하기 깔끔하게 만든 라이브러리.

리포트, 코드 분석 도구

PHP 8.1에 열거형이 추가되었습니다. 그동안 클래스와 클래스 상수를 사용해서 열거형처럼 사용했었는데 용도에 맞게 사용할 수 있는 타입이 생겼습니다.

Enumerations - php를 중점으로 번역했습니다.

열거형, "Enums"는 제한된 선택지를 정의할 수 있는 타입입니다. [...] 각 언어마다 다양한 구현이 있지만 PHP에서는 특별한 종류의 개체로 처리합니다. Enum 자체는 클래스지만 각각 케이스를 단일 인스턴스 개체로 다루는 것도 가능합니다. 즉, 개체를 사용하는 곳이라면 어디든 열거형 케이스를 적용할 수 있습니다. -- Enumerations overview - PHP

열거형 기초

열거형을 다음처럼 선언할 수 있습니다.

enum Suit
{
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;
}

열거형 타입으로 Suit를 작성했고 4가지 허용된 값으로 Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades를 선언했습니다. 이 값을 직접 사용하거나 변수에 할당해서 사용하는 것도 가능합니다.

function pick_a_suit(Suit $s)
{
  // ...
}

pick_a_suit(Suit::Diamonds);

// 변수에 할당하는 것도 가능
$suit = Suit::Clubs;
pick_a_suit($suit);

pick_a_suit('Hearts');
// TypeError: pick(): Argument #1 ($suit) must
//   be of type Suit, string given...

각 케이스는 별도 정의가 없으면 스칼라 값으로 다뤄지지 않습니다. 내부적으로는 해당 이름의 싱글턴 개체가 존재하기 때문에 다음처럼 작성하는 것도 가능합니다.

$a = Suit::Spades;
$b = Suit::Spades;

$a === $b; // true
$a instanceof Suit; // true

여기서 Suit 타입의 케이스는 별도 데이터를 지정하지 않았기 때문에 "순수 케이스(Pure case)"로 불립니다. 순수 케이스만 포함된 열거형은 순수 열거형(Pure Enum)으로 부릅니다. 모든 순수 케이스는 해당 열거형 타입의 인스턴스로 구현되어 있으며 열거형 타입은 내부적으로는 클래스처럼 동작합니다.

모든 케이스는 읽기 전용 프로퍼티로 name이 존재하며 케이스 이름을 문자열로 반환합니다.

print Suit::Spades->name; // "Spades"

지원 열거형 (Backed enumerations)

위에서 본 열거형은 스칼라 값이 없는, 순수한 형태입니다. 하지만 데이터를 저장한다던지 직렬화 해야 하는 경우에는 열거형에 기본값이 있으면 더 유용하게 사용할 수 있습니다.

스칼라 값을 사용하는 열거형은 다음처럼 작성합니다.

enum Suit: string
{
  case Hearts = 'H';
  case Diamonds = 'D';
  case Clubs = 'C';
  case Spades = 'S';
}

여기서 케이스는 간단한 스칼라 값의 "지원을 받는" 케이스(backed case)입니다. 모든 케이스가 지원 케이스인 열거형을 지원 열거형(Backed Enum)이라고 합니다.

이 지원 열거형은 intstring과 함께 사용할 수 있습니다. 동시에 둘을 지원할 수는 없습니다. 즉, int|string은 안됩니다. 어느 타입이든 지정하면 모든 케이스에서 값이 존재해야 합니다. 즉, int로 지정한다고 하더라도 자동으로 값이 지정되지 않습니다. 또한 각각 케이스의 값은 열거형 내에서 유일해야 합니다.

지정된 값은 리터럴 또는 리터럴 표현식이어야 합니다. 상수와 상수 표현식은 지원되지 않습니다. 즉, 1 + 1은 값으로 지정할 수 있는 표현식이지만 1 + SOME_CONST는 불가능합니다.

지원 케이스도 value라는 읽기 전용 프로퍼티를 제공합니다. 정의할 때 지정한 값을 반환합니다.

print Suit::Clubs->value; // "C"

이 지원 열거형은 내부적으로 BackedEnum 인터페이스를 구현하고 있습니다. 이 인터페이스는 from(int|string): selftryFrom(int|string): ?self 메소드를 포함하고 있습니다. 이 메소드는 다음처럼 활용할 수 있습니다.

enum InvoiceState: string {
  case New = 'new';
  case Paid = 'paid';
  case Confirmed = 'confirmed';
  case Completed = 'completed';
  case Invalid = 'invalid';
}

$invoice = ['id' => 1, 'state' => 'new'];

print $invoice['state']; // 'new'

// 열거형에 정의하지 않은 값으로 테스트
$invoice['state'] = 'half-paid';

$state = InvoiceState::from($invoice['state']);
// Uncaught ValueError: "half-paid" is not a valid
//    backing value for enum "InvoiceState" in...

$state = InvoiceState::tryFrom($invoice['state'])
          ?? InvoiceState::Invalid;

print $state->value; // 'invalid'

이 두 함수를 직접 정의하려고 하면 오류가 발생하니 주의하세요.

열거형 메소드

열거형에도 메소드를 작성할 수 있으며 인터페이스를 구현하는 것도 가능합니다.

interface Colorful
{
  public function color(): string;
}

enum Suit implements Colorful
{
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;

  // 클래스처럼 메소드 작성
  public function shape(): string
  {
    return 'Rectangle';
  }

  // Colorful 인터페이스를 구현
  public function color(): string
  {
    return match($this) {
      Suit::Hearts, Suit::Diamonds => 'Red',
      Suit::Clubs, Suit::Spades => 'Black',
    };
  }
}

function paint(Colorful $c) { /* ... */ }

paint(Suit::Clubs);

print Suit::Diamonds->shape(); // 'Rectangle'

유심히 봐야 할 부분은 메소드 내에서 사용한 $this입니다. 각 열거형 케이스는 내부적으로 인스턴스가 존재하기 때문에 호출된 케이스를 $this로 접근할 수 있게 됩니다. 문법의 모습은 정적 클래스와 유사하기만 할 뿐 맥락이 다르다는 점을 확인할 수 있습니다.

참고로 위 구현은 온전히 예시로 작성되었으며 실제라면 별도의 SuitColor 열거형으로 구현하는 게 바람직합니다.

메소드의 접근자는 public, private, protected 모두 가능하지만 열거형은 상속이 불가능하기 때문에 private과 protected 사이에 실질적인 차이는 없습니다.

열거형 정적 메소드

열거형에 정적 메소드를 정의할 수 있습니다. 아래 코드는 정적 메소드를 별도의 생성자처럼 사용하는 예제입니다.

enum Size
{
  case Small;
  case Medium;
  case Large;

  public static function fromLength(int $cm): static
  {
    return match(true) {
      $cm < 50 => static::Small,
      $cm < 100 => static::Medium,
      default => static::Large,
    };
  }
}

열거형 상수

열거형에 상수도 선언할 수 있습니다. 상수로 열거형 케이스를 지정하는 것도 가능합니다.

enum Size
{
  case Small;
  case Medium;
  case Large;

  // 열거형 케이스를 할당
  public const Huge = self::Large;

  // 이런 것도 그냥 할 수 있음
  private const Someone = 'hello';
}

트레이트 (traits)

클래스처럼 동작하기 때문에 트레이트를 사용할 수 있습니다. 다만 프로퍼티가 존재하는 트레이트는 오류가 발생합니다.

trait Rectangle
{
  public function shape(): string {
    return "Rectangle";
  }
}

enum Suit implements Colorful
{
  use Rectangle;

  // ...
}

열거형과 개체의 차이점

열거형은 클래스와 개체로 구현되어 있지만 모든 개체 관련 기능을 사용할 수는 없습니다. 특히 열거형은 상태를 가질 수 없습니다.

  • 생성자, 소멸자 사용 금지
  • 상속 미지원
  • 정적 또는 개체 프로퍼티 금지
  • 열거형 케이스를 복제(cloning)하는 행위 금지
  • __call, __callStatic, __invoke 이외 매직 메소드 금지

또 다음과 같은 특징이 있습니다.

  • __CLASS__, __FUNCTION__ 상수 사용 가능
  • ::class 매직 상수는 열거형과 열거형 케이스에 동일하게 사용할 수 있지만 둘 다 열거형의 클래스명을 반환
  • 접근자 사용 가능
  • 인터페이스 상속 가능
  • 어트리뷰트 사용 가능

값 목록

열거형은 내부적으로 UnitEnum 인터페이스를 구현하고 있으며 cases() 정적 메소드를 제공합니다. 열거형에 선언된 모든 케이스를 담은 배열을 반환합니다.

var_dump(Size::cases());
// [Size::Small, Size::Medium, Size::Large]

직렬화(Serialization)

열거형 직렬화는 개체 직렬화는 다른 방식으로 구현되어 있습니다. 특히 역직렬화 할 때는 기존 싱글톤 값을 그대로 사용할 수 있어서 다음과 같은 동작이 보장됩니다.

Suit::Hearts === unserialize(serialize(Suit::Hearts));
// true

print serialize(Suit::Hearts);
// 'E:11:"Suit::Hearts";'

순수 열거형은 JSON으로 직렬화 시 오류가 발생합니다. 지원 열거형은 표현하고 있는 스칼라 값만 남게 됩니다. 이런 기본 동작은 JsonSerializable 인터페이스를 구현하는 것으로 대체할 수 있습니다.

예제

제한적인 기본값 지정

enum SortOrder
{
  case ASC;
  case DESC;
}

function query(
  $fields,
  $filter,
  SortOder $order = SortOrder::ASC,
) {
  /* ... */
}

match()와 함께 활용하기

enum UserStatus: string
{
  case Pending = 'P';
  case Active = 'A';
  case Suspended = 'S';
  case CanceledByUser = 'C';

  public function label(): string
  {
    return match($this) {
      static::Pending => 'Pending',
      static::Active => 'Active',
      static::Suspended => 'Suspended',
      static::CanceledByUser => 'Canceled by user',
    };
  }
}

//...

foreach (UserStatus::cases() as $case) {
  printf(
    '<option value="%s">%s</option>\n',
    $case->value,
    $case->label(),
  );
}
/**
 * result:
 * <option value="P">Pending</option>
 * <option value="A">Active</option>
 * <option value="S">Suspended</option>
 * <option value="C">Canceled by user</option>
 */

포트와 어뎁터 아키텍처(ports and adapters architecture)는 육각형 아키텍처(hexagonal architecture)로도 불린다.

(육각형 아키텍처를 통해) UI나 데이터베이스 없이 동작하는 어플리케이션을 만듭니다. 그래서 어플리케이션을 자동화된 테스트를 반복해서 수행할 수 있고, 데이터베이스가 없을 때도 동작 가능하며, 사용자 없이도 애플리케이션을 연결할 수 있습니다.

외부와 어플리케이션, 도메인을 육각형 도식으로 명확하게 분리한다. 각 분리된 영역은 항구(port)를 통해 소통하는 구조를 따른다. 코드의 의존성을 "설정"하는 것으로 필요에 따라서, 재사용 할 수 있다는 점을 강조한다.

만들면서 배우는 클린 아키텍처의 예제 코드를 보면서 php로 작성했다. 어느 스터디 그룹에서 정리한 리포지터리에도 잘 정리되어 있어서 같이 보면 유익하다.

코드

다만 의존성 구조를 체크하는 테스트는 아직 옮기지 못했다. (Alistair의 글에서 보면 이 부분도 매우 중요하다고 언급한다.)

패키지 구조

./src
├── Account
│   ├── Adapter
│   │   ├── In
│   │   │   ├── Console
│   │   │   │   ├── BalanceConsoleCommand.php
│   │   │   │   └── SendConsoleCommand.php
│   │   │   └── Web
│   │   └── Out
│   │       └── Persistence
│   │           ├── AccountMapper.php
│   │           ├── AccountObjectEntity.php
│   │           ├── AccountObjectEntityRepository.php
│   │           ├── AccountPersistenceAdapter.php
│   │           ├── ActivityObjectEntity.php
│   │           └── ActivityObjectEntityRepository.php
│   ├── Application
│   │   ├── Port
│   │   │   ├── In
│   │   │   │   ├── GetAccountBalanceQuery.php (interface)
│   │   │   │   ├── SendMoneyCommand.php
│   │   │   │   └── SendMoneyUseCase.php (interface)
│   │   │   └── Out
│   │   │       ├── AccountLock.php (interface)
│   │   │       ├── LoadAccountPort.php (interface)
│   │   │       └── UpdateAccountStatePort.php (interface)
│   │   └── Service
│   │       ├── GetAccountBalanceService.php
│   │       ├── MoneyTransferProperties.php
│   │       ├── NoOpAccountLock.php
│   │       ├── SendMoneyService.php
│   │       └── ThresholdExceededException.php
│   └── Domain
│       ├── Account.php
│       ├── AccountId.php
│       ├── Activity.php
│       ├── ActivityId.php
│       ├── ActivityWindow.php
│       └── Money.php
├── Common
│   ├── ConsoleAdapter.php (interface)
│   ├── PersistenceAdapter.php (interface)
│   └── UseCase.php (interface)
└── Kernel.php

./tests
├── Account
│   ├── Adapter
│   │   ├── In
│   │   │   └── Console
│   │   │       ├── BalanceCommandTest.php
│   │   │       └── SendCommandTest.php
│   │   └── Out
│   │       └── Persistence
│   │           └── AccountPersistenceAdapterTest.php
│   ├── Application
│   │   └── Service
│   │       └── SendMoneyServiceTest.php
│   └── Domain
│       ├── AccountTest.php
│       ├── ActivityWindowTest.php
│       └── MoneyTest.php
├── DataFixtures
│   └── AppFixtures.php
├── Helpers
│   └── CommandTestTrait.php
├── TestData
│   ├── AccountBuilder.php
│   ├── AccountTestData.php
│   ├── ActivityBuilder.php
│   └── ActivityTestData.php
└── bootstrap.php

franzliedke/studio는 php 패키지를 개발할 때 로컬에 있는 패키지를 참조할 수 있도록 도와주는 composer 확장 도구다.

물론 composer에서도 composer.jsonrepositories 설정을 추가하는 것으로 로컬에 있는 패키지를 참조할 수 있다. 하지만 패키지를 배포할 때마다 이 부분을 다시 정리해야 하는 불편이 있다. 만약 경로가 포함된 상태로 배포가 된다면 해당 리포지터리를 참조할 수 없다고 아예 의존성 설치가 불가능해진다. studio는 이런 문제를 해결한다.

이 도구도 내부적으로는 repositories의 path 타입을 추가하는 방식으로 동작하지만 composer.json 파일은 직접 변경하지는 않으며 studio.json이라는 별도 파일을 생성한다.

설치

다음처럼 전역에 설치할 수 있지만 PATH에 ~/.composer/vendor/bin 경로가 추가되어 있어야 한다.

$ composer global require franzl/studio

또는 지역적으로 설치해서 vendor/bin/studio로 사용하는 것도 가능하다.

$ composer require --dev franzl/studio

사용

사용하려는 다른 패키지를 먼저 studio에 등록한다.

$ studio load path/to/some-package

사용하려는 패키지가 한 폴더 내에 모두 있는 경우에는 와일드카드 사용도 가능하다. packages 폴더에 모두 있다면 다음처럼 불러온다. (대신 따옴표를 잘 사용해야 한다.)

$ studio load 'path/to/packages/*'

이미 패키지가 추가되어 있는 경우에는 업데이트를 하면 된다. 패키지명이 my/some-package라고 한다면,

$ composer update my/some-package

새로 설치하는 경우라면 require를 사용한다. @dev는 가장 마지막 커밋을 참조하도록 dev-master를 사용하는 것과 동일한데 더 자세한 내용은 composer 문서를 참고하자.

$ composer require my/some-package @dev

더 이상 로컬 패키지를 사용하지 않으려면 경로를 지운다.

$ studio unload path/to/some-package

어떤 프로그램이든 배열이나 목록과 같은 자료구조에서 조건에 맞는 요소(element)를 찾아 하위 집합을 만들어야 하는 경우가 있습니다. PHP에서는 배열(array)이 기본이 되는 자료구조 중 하나인데요. 이 배열을 대상으로 내장 함수인 array_filter()를 사용해서 조건에 맞는 요소만 골라내는 작업을 수행할 수 있습니다.

빠르게 callable 표현식/문법만 확인하고 싶다면 callable 정리 부분을 참고하세요.

배열 필터하기

먼저 공식 사이트에서 함수 시그니처를 확인합니다. 함수 시그니처에서는 어떤 타입의 값을 넣어야 하는지, 함수의 결과는 어떤 타입으로 반환되는지 확인할 수 있습니다.

array_filter(
  array $array,
  ?callable $callback = null,
  int $mode = 0
): array

함수 시그니처를 확인하셨나요? $array에는 대상이 되는 배열, $callback에는 배열 요소가 찾으려는 조건에 맞는지 검사하는 함수, 마지막 $mode는 검사하는 함수에 인자를 어떻게 입력하는지 정하는 플래그를 넣을 수 있습니다.

어떤 타입을 넣어야 하는가도 알 수 있습니다. $array는 array 타입, $callback callable 또는 null 타입, $mode는 정수형 타입을 넣을 수 있습니다. 함수의 반환 타입은 array 타입이고요. $callback$mode에는 각각 null과 0이 기본값으로 배정되어 있습니다.

예시로 다음 같은 배열이 있다고 생각해봅니다.

$nums = array(1, 2, 3, 4, 5, 6, 7);

이 배열에서 짝수인 숫자만 모아서 배열을 만들려고 합니다. 그렇다면 숫자 하나를 입력으로 받아서 짝수인지 아닌지 검사하는 함수를 먼저 생각해봅시다. 다음처럼 함수를 작성할 수 있습니다.

function is_even($number) {
  return $number % 2 === 0;
}

이제 대상이 되는 배열과 검사하는 함수를 array_filter()에 인자로 전달합니다. 그 결과로 짝수만 들어있는 배열이 반환됩니다.

$even_nums = array_filter($nums, 'is_even');

// 어떤 값이 있는지 `var_dump()` 함수로 검사합니다.
var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

필요로 한 결과가 나왔지만 자세히 보면 흥미로운 부분이 있습니다. 두 번째 인자로 사용한 'is_even'은 문자열인데 어떻게 array_filter()가 함수로 인식한 것일까요?

문자열로 된 callable 타입

앞서 본 예시처럼 array_filter() 함수에는 사용자 정의 함수를 인자로 전달해야 합니다. 다만 이전 버전의 PHP에서는 함수를 직접 넣어서 전달할 수 있는 방법이 없었습니다. 대신에 그 해결책으로 callable 타입이 존재하게 되었는데 함수명을 문자열로 저장하면 그 함수를 호출할 수 있게 됩니다.

다음 함수가 있다고 가정해봅니다.

function sayHello() {
  echo "Hello!";
}

첫 예제는 함수를 직접 호출했습니다. 당연한 결과가 나옵니다.

// 1.
sayHello(); // "Hello!"

함수명을 문자열로 $a에 저장합니다. 함수처럼 다뤄볼까요?

// 2.
$a = 'sayHello';
$a(); // "Hello!"

함수가 호출됩니다. 저장 안하고 문자열을 그냥 호출하는 것도 가능할까요?

// 3.
'sayHello'(); // "Hello!"

// 4.
$b = 'Hello';
"say$b"(); // "Hello!"

문자열에 함수명이 저장되어 있으면 그 자체로 호출이 가능합니다. 이렇게 문자열에 호출할 수 있는 무언가가 있는 경우를 callable 타입으로 볼 수 있습니다. 물론 그 문자열로 저장된 함수가 실제로 존재해야겠죠? 저장된 문자열이 callable인지 아닌지는 is_callable() 함수로 검사할 수 있습니다.

var_dump(is_callable("sayHello"));  // true
var_dump(is_callable("sayWhaatt")); // false, 없는 함수

문자열로 된 callable 타입 덕분에 다른 함수에 어떤 함수를 호출해야 하는지 전달할 수 있게 되었습니다. 다시 원래 주제로 돌아가서 얘기하면 array_filter() 함수에 문자열로 조건 검사를 수행할 함수 이름만 전달해도 기대한 것처럼 동작하게 됩니다.

정적 클래스 메소드를 callable

앞에서는 단순한 예제라서 단순히 함수를 전달하는 것으로도 충분했습니다. 프로젝트가 좀 더 커져서 여러 필터가 필요한 상황을 생각해봅시다. 여러 필터를 함수로 관리하다보면 다른 배열을 대상으로 하는 비슷한 이름의 함수가 많아질 수 있습니다.

// 홀수를 검사하는 함수
function is_odd($num) {
  return $num % 2 === 1;
}

// 이상한 사람을 검사하는 함수
function is_odd($person) {
  return $person['is_odd'] === true;
}
// Fatal error:  Cannot redeclare is_odd() (previously declared ...)

이런 충돌을 피하기 위해서 긴 함수명을 선택할 수 있지만 깔끔해보이진 않습니다. (다른 영어 단어를 선택할 수도 있지만... 여기서는 같은 이름의 함수여야만 한다고 생각해봅시다. 의외로 그런 경우가 꽤 있거든요.)

function is_odd_number($num) {
  return $num % 2 === 1;
}

function is_odd_person($person) {
  return $person['is_odd'] === true;
}

이럴 때 정적 클래스 메소드를 사용하면 이런 함수를 좀 더 깔끔하게 관리할 수 있습니다. 조금 전통적인 방식 중 하나입니다.

class NumberFilter {
  public static function is_odd($num) {
    return  $num % 2 === 1;
  }
}

class PersonFilter {
  public static function is_odd($person) {
    return $person['is_odd'] === true;
  }
}

NumberFilter::is_odd(3); // true
PersonFilter::is_odd(['name' => 'Edward', 'is_odd' => false ])); // false

이런 함수도 callable로 호출 할 수 있을까요? 정적 클래스 메소드도 문자열 형태로 호출이 가능합니다. is_callable()로 확인해보고 array_filter()까지도 사용해봅시다.

var_dump(is_callable('NumberFilter::is_odd')); // true

// [$a, $b, ...] 은 array($a, $b, ...) 처럼 배열을 입력하는 간편 문법입니다.
$nums = [1, 2, 3, 4, 5, 6, 7];

$odd_nums = array_filter($nums, 'NumberFilter::is_odd');

// 어떤 값이 있는지 `var_dump()` 함수로 검사합니다.
var_dump($odd_nums);
// array(4) {
//   [0]=>
//   int(1)
//   [2]=>
//   int(3)
//   [4]=>
//   int(5)
//   [6]=>
//   int(7)
// }

클래스명을 문자열로 넣는다면 글자를 빼먹거나 잘못된 문자가 들어가서 의도와 다르게 동작할 수도 있습니다. 그나마 그런 문제를 해소하기 위해 특별 상수인 클래스 상수를 사용하기도 합니다.

is_callable(NumberFilter::class . '::is_odd');
// true, 'NumberFilter::is_odd'과 동일
array_filter($nums, NumberFilter::class . '::is_odd');

클래스 상수는 클래스명을 문자열로 반환합니다. 반환된 클래스명과 나머지 메소드명을 병합해서 위와 동일한 결과를 만들었습니다. 클래스 상수는 네임스페이스도 알아서 처리해주는 장점이 있습니다. 또한 callable이 실행될 때 해당 클래스가 코드에서 실제로 접근할 수 있는 것인지도 코드를 작성할 때 확인할 수 있습니다.

아직 조금 아쉬운 점도 있습니다. 정적 메소드 접근을 위한 '::'을 문자열로 적어야 한다는 점, 문자열을 합치는 과정도 필요하다는 부분인데요. PHP는 이런 불편함을 조금 덜 수 있도록 배열 형태의 callable을 지원합니다.

$a = 'NumberFilter::is_odd';           // 문자열 callable
$b = NumberFilter::class . '::is_odd'; // 클래스 상수를 활용한 문자열 callable
$c = [NumberFilter::class, 'is_odd'];  // 배열 callable

is_callable($a); // true
is_callable($b); // true
is_callable($c); // true

array_filter($nums, [NumberFilter::class, 'is_odd']);

이제 정적 클래스 메소드도 문제 없이 사용할 수 있게 되었습니다. 조금 더 까다로운 필터가 필요하다면 어떻게 해야 할까요? 검사하는 함수를 재사용 가능하게 만들 수 있을까요? 방금 살펴본 배열 형태의 callable을 활용하면 더 다채로운 형태로 구현할 수 있습니다.

개체(object)를 활용하는 callable

앞에서 callable은 배열 형태로도 사용할 수 있다는 점을 배웠습니다. 정적 클래스 메소드에서는 클래스명과 함수명을 배열에 넣는 방식으로 사용했습니다. 클래스명 대신에 개체를 넣으면 개체의 메소드를 활용할 수 있습니다. 코드를 살펴봅시다.

먼저 개체를 만들 클래스를 작성합니다.

class CompareWithFilter {
  protected $num;

  public function __construct($num) {
    $this->num = $num;
  }

  public function isSmallerThan($input) {
    return $input > $this->num;
  }
}

CompareWithFilter 클래스는 개체를 생성할 때 숫자를 받습니다. 이 숫자를 보관하고 있다가 isSmallerThan() 메소드를 비교할 숫자를 넣어 호출하면 보관된 숫자와 비교해서 결과를 반환합니다. 이 개체의 isSmallerThan() 메소드를 callable로 호출하려고 합니다. 앞서 본 배열의 형태로 전달하면 됩니다.

$nums = [1, 2, 3, 4, 5, 6, 7];

// 앞서 작성한 필터를 생성합니다
$five = new CompareWithFilter(5);

// 5보다 작은 숫자를 걸러냅니다
$filtered = array_filter($nums, [$five, 'isSmallerThan']);
// 5가 1보다 작다? -> false
// 5가 2보다 작다? -> false
// 5가 3보다 작다? -> false
// 5가 4보다 작다? -> false
// 5가 5보다 작다? -> false
// 5가 6보다 작다? -> true
// 5가 7보다 작다? -> true

var_dump($filtered);
// array(2) {
//   [5]=>
//   int(6)
//   [6]=>
//   int(7)
// }

개체를 활용하는 더 간단한 방법도 있을까요? 클래스에 __invoke() 매직 메소드를 선언하면 그 개체 자체를 호출할 수 있습니다.

class SmallerThan {
  protected $num;
  public function __construct($num) {
    $this->num = $num;
  }
  public function __invoke($input) {
    return  $input > $this->num;
  }
}

$two_is_smaller_than = new SmallerThan(2);
is_callable($two_is_smaller_than); // true
var_dump($two_is_smaller_than(3)); // true

위에서 확인할 수 있는 것처럼 이렇게 생성한 인스턴스도 callable 타입에 해당합니다. array_filter() 함수에서도 문제 없이 동작하는 것을 확인할 수 있습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
$five_is_smaller_than = new SmallerThan(5);
$filtered = array_filter($nums, $five_is_smaller_than);
// array(2) {
//   [5]=>
//   int(6)
//   [6]=>
//   int(7)
// }

그런데 PHP에는 익명 클래스도 존재합니다. 간단하게 사용할 클래스라면 익명 클래스를 활용할 수도 있습니다. 여기서 배운 __invoke() 매직 메소드를 사용하면 익명 클래스도 callable로 사용할 수 있습니다.

//@ PHP >= 7.0
// $num의 배수만 골라내는 클래스에 3으로 초기화하고 사용
array_filter($nums, new class(3) {
  protected $num;
  public function __construct($num) {
    $this->num = $num;
  }
  public function __invoke($input) {
    return $input % $this->num === 0;
  }
});
// array(2) {
//   [2]=>
//   int(3)
//   [5]=>
//   int(6)
// }

장황하게 보이지만 신기하게도 가능합니다.

그렇다면 반대로 callable을 아주 간단하게 작성할 방법은 없을까요? 용도가 유일해서 다른 곳에서 쓸 일이 없는 필터라면 명시적인 클래스나 함수로 선언하지 않는 것이 가장 깔끔할 겁니다.

클로저(Closure)를 callable로 활용하기

다른 곳에서 활용할 일이 없는 필터라면 익명 함수를 활용해도 간단하고 편리합니다.

//@ PHP >= 5.3
$is_even = function ($num) {
  return $num % 2 === 0;
};

var_dump($is_even(2)); // true
var_dump(is_callable($is_even)); // true

var_dump($is_even);
// object(Closure)#1 (1) {
//   ["parameter"]=>
//   array(1) {
//     ["$num"]=>
//     string(10) "<required>"
//   }
// }

위 결과처럼 익명 함수를 선언하면 클로저 개체로 반환됩니다. 이 클로저 클래스에는 __invoke() 메소드가 내장되어 있어서 앞서 본 예제와 같이 실행 가능한 개체로 동작합니다. 위에서는 변수에 할당했지만 아래처럼 바로 사용하는 것도 문제 없습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
// 짝수만 골라내는 익명 함수
$even_nums = array_filter($nums, function ($num) {
  return $num % 2 === 0;
});
var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

앞서 작성한 숫자 비교는 이 익명 함수에서 어떻게 작성할 수 있을까요? 비교하려는 숫자를 함수 내에 명시하지 않고 외부에서 지정하는 것도 가능합니다. 익명 함수를 작성할 때, use 키워드로 바깥 스코프에 있는 변수를 사용할 수 있습니다.

$nums = [1, 2, 3, 4, 5, 6, 7];
$other_num = 5;

// `$other_num`보다 작은 숫자만 골라내기
$filtered = array_filter($nums, function ($num) use ($other_num) {
  return $num < $other_num;
});

var_dump($filtered);
// array(4) {
//   [0]=>
//   int(1)
//   [1]=>
//   int(2)
//   [2]=>
//   int(3)
//   [3]=>
//   int(4)
// }

화살표 함수를 사용하면 더 간단하게 작성할 수 있습니다. 화살표 함수는 익명 함수를 더 간결하게 작성할 수 있는 문법입니다.

//@ PHP >= 7.4
// `$other_num`보다 작은 숫자만 골라내기
$filtered = array_filter($nums, fn($num) => $num < $other_num);

이 화살표 함수는 앞서 작성한 익명 함수와 동일한 역할을 하면서도 더 간결합니다. 부모 스코프에 있는 변수도 별도 지정 없이 바로 사용할 수 있습니다.

CallableExpr(...) 문법으로 callable 날개 달기

마지막으로 살펴 볼 내용은 CallableExpr(...) 문법입니다. 이 문법을 사용하면 callable 문자열로 처리하는 동안 생기는 말썽을 해결할 수 있습니다. 하지만 이 문법을 보기 전에 먼저 Closure::fromCallable() 메소드를 확인합니다.

이 정적 메소드는 callable을 전달하면 Closure 개체로 감싸서 반환하는 래퍼 함수(wrapper function)입니다.

function sayHello() {
        echo "Hello!";
}       

//@ PHP >= 7.4
$actor = Closure::fromCallable('sayHello');
var_dump($actor);
// object(Closure)#1 (0) {
// }

$actor(); // Hello!

문자열로 되어 있는 callableClosure 인스턴스로 바꿔서 활용합니다. 이 메소드를 CallableExpr(...) 문법으로 사용할 수 있습니다.

$a = Closure::fromCallable('sayHello');
$b = sayHello(...); // 위 메소드 호출과 동일한 표현

var_dump($a == $b); // true

다음과 같은 방식으로 사용하게 됩니다.

$nums = [1, 2, 3, 4, 5, 6, 7];

function is_even($number) {
  return $number % 2 === 0;
}

//@ PHP >= 8.1
$even_nums = array_filter($nums, is_even(...));

var_dump($even_nums);
// array(3) {
//   [1]=>
//   int(2)
//   [3]=>
//   int(4)
//   [5]=>
//   int(6)
// }

이전에 문자열일 때는 존재하지 않는 함수명을 적을 수 있는 문제가 있었습니다. CallableExpr(...) 문법은 callable에 없는 메소드 등을 사용하는걸 방지하는데 도움이 됩니다. 또한 문자열이나 배열로 된 callable을 다루는 방식보다 이 문법은 좀 더 일관성이 있습니다.

이 문법은 어떤 callable이든 활용할 수 있습니다. 앞에서 살펴본 callable 예제를 이 문법으로 작성하면 다음과 같습니다.

// 1. 함수
// array_filter($nums, 'is_even');
array_filter($nums, is_even(...));

// 2. 정적 클래스 메소드
// array_filter($nums, 'NumberFilter::is_odd');
// array_filter($nums, NumberFilter::class . '::is_odd');
// array_filter($nums, [NumberFilter::class, 'is_odd']);
array_filter($nums, NumberFilter::is_odd(...));

// 3. 개체 메소드
// array_filter($nums, [$five, 'isSmallerThan']);
array_filter($nums, $five->isSmallerThan(...));

callable 정리

이 글에서 다룬 모든 callable 타입 표현을 정리합니다.

문자열 callable

$a = 'sayHello';
$b = 'Foo\Bar\SomeClass::filter';
$c = Foo\Bar\SomeClass::class . '::filter';

배열 callable

$a = [Foo\Bar\SomeClass::class, 'filter'];
$b = [$obj, 'methodName'];

__invoke() 매직 메소드가 있는 인스턴스

//@ PHP >= 5.3
$obj;

익명함수 (클로저) callable

//@ PHP >= 5.3
$a = function ($num) use ($other) { /* ... */ };
//@ PHP >= 7.4
$b = fn($num) => $num > $other; // 축약식 (화살표 함수)

__invoke() 매직 메소드가 있는 익명 클래스

//@ PHP >= 7.0
$a = new class () {
  public function __invoke() {
    /* ... */
  }
};

Closure::fromCallable()로 만든 클로저 callable

//@ PHP >= 7.4
$a = Closure::fromCallable('sayHello');

CallableExpr(...)로 만든 클로저 callable

//@ PHP >= 8.1
$a = sayHello(...);
$b = Foo\Bar\SomeClass::is_odd(...);
$c = $obj->methodName(...);

PHP 부록에 있는 이주 문서를 읽으면서 정리했다. 완전한 번역은 아니며 중요도가 높다고 생각되는 부분을 주로 정리했다. 세세한 부분이나 함수는 각각 문서를 참고하는 것을 권장한다.

목차

PHP 5.6

호환성 문제 있는 변경

엄격해진 json_decode()

json_decode()에서 소문자가 아닌 true, false, null JSON 리터럴을 사용한 경우에는 오류가 발생하도록 변경되었다. 오류는 json_last_error()로 확인 가능하다.

$json = '{
  "is_available": TRUE
}';
$response = json_decode($json);

json_last_error() === JSON_ERROR_SYNTAX; // true

새 기능

상수(constant) 표현식

숫자나 문자열 리터럴, 배열을 상수로 정의할 수 있다.

const ONE = 1;
const TWO = ONE * 2;
const ARR = [ONE, TWO];

class C {
    const THREE = TWO + 1;
    const ONE_THIRD = ONE / self::THREE;
    const SENTENCE = 'The value of THREE is ' . self::THREE;

    public function f($a = ONE + self::THREE) {
        echo self::SENTENCE;
        return $a;
    }
}

echo (new C)->f(); // 4
echo C::SENTENCE; // 'The value of THREE is 3'
var_dump(ARR); // [1, 2]

개체도 사용할 수 있다.

class Person {
    // ...
}
const ME = new Person('Edward');
var_dump(ME);
// object(Person)#1 (1) {
//   ["name":protected]=>
//   string(6) "Edward"
// }

ME = new Person('Yong');
// Parse error: syntax error, unexpected token "="

... 연산자 (operator)

함수에서 가변 인자 목록 받기

function school($name, $location = null, ...$students) {
    printf('$name: %s, $location: %s, number of students: %d',
        $name, $location, count($students));
}

school('Hogwarts School', 'Scotland', 'Harry', 'Ron', 'Hermione');
// $name: Hogwarts School, $location: Scotland, number of students: 3

인자 풀어넣기

배열이나 Traversable 개체를 대상으로 인자를 풀어놓을 때 ... 연산자를 사용할 수 있다. (다른 언어에서는 splat 연산자로 지칭) 이런 문제는 call_user_func_array() 같은 함수로 해결했었는데 더 간단하고 깔끔하게 작성할 수 있게 되었다.

function add($a, $b, $c) {
    return $a + $b + $c;
}

$nums = [2, 3];
echo add(1, ...$nums); // 6

** 연산자로 거듭제곱하기

$a = 2 ** 3; // 8
$b = 2;
$b **= 2; // 4

다음 연산 순서를 주의하자.

$a = 2 ** 3 ** 2;
$b = (2 ** 3) ** 2;
$c = 2 ** (3 ** 2);
// $a: 512
// $b: 64
// $c: 512

use function, use const

함수나 상수도 use 연산자로 불러 사용할 수 있다.

namespace Hello\App {
    const NAME = 'hello';
    function study() { echo __FUNCTION__; }
}

namespace {
    use const Hello\App\NAME;
    use function Hello\App\study;

    echo NAME; // 'hello'
    study(); // 'Hello\App\study'
}

기본 문자열 인코딩

htmlentities(), html_entity_decode(), htmlspecialchars() 함수에서 기본 문자열 인코딩을 php.ini에 default_charset 값을 사용한다. 해당 설정은 UTF-8이 기본값이다.

hash_equals() 시간 차 공격에 안전한 문자열 비교 함수

$expected = crypt('some-password', 'some-unsafe-salt');
$correct = crypt('some-password', 'some-unsafe-salt');
$incorrect = crypt('some-wrong-password', 'some-unsafe-salt');

hash_equals($expected, $correct); // true
hash_equals($expected, $incorrect); // false

비밀번호 관련 함수를 사용할 수 있다면 다음처럼 작성하는 것을 권장한다.

$hash = password_hash('some-password', PASSWORD_DEFAULT);

password_verify('some-password', $hash); // true
password_verify('some-wrong-password', $hash); // false

참고로 password_verify() 함수는 crypt() 함수의 반환값과도 사용할 수 있다.

__debugInfo() 매직 메소드

클래스에 __debugInfo()를 정의하면 var_dump() 출력을 제어할 수 있다.

class Person {
  private $name;
  private $secret;

  public function __construct($name, $secret) {
    $this->name = $name;
    $this->secret = $secret;
  }

  public function __debugInfo() {
    return [
      'name' => $this->name,
      'secret' => '****',
    ];
  }
}

$ed = new Person('Edward', 'have a national treasure');

var_dump($ed);
// object(Person)#1 (2) {
//   ["name"]=>
//   string(6) "Edward"
//   ["secret"]=>
//   string(4) "****"
// }

함수 변경점

  • crypt() 함수 호출 시 salt 파라미터가 누락되면 E_NOTICE가 발생.
  • substr_compare()length 파라미터로 0을 넣을 수 있음.
  • unserialize() 함수 호출 시 생성자 호출 이전에 직렬화된 데이터를 조작한 시도가 있는 경우 직렬화에 실패하게 됨.

PHP 7.0

호환성 문제 있는 변경

오류/예외 처리 변경

많은 수의 심각한 오류(fatal error)가 예외 처리 형태로 변경되었다. 이 오류 예외는 Error 클래스를 상속하며 Throwable 인터페이스를 구현하고 있다. 직접 구현한 핸들러가 Exception만 받도록 되어 있다면 Error를 처리하지 못해서 심각한 오류가 발생할 수 있다.

set_exception_handler()

Throwable 인터페이스를 활용할 수 있다. 호환성을 고려한다면 타입 선언을 제외한다.

// Will break because of `Error`
function handler(Exception $e) { /* ... */ }
set_exception_handler('handler');

// PHP 5 and 7 compatible.
function handler($e) { /* ... */ }

// PHP 7 only.
function handler(Throwable $e) { /* ... */ }

ParseError

eval() 함수에서 오류가 발생한 경우 ParseErrorcatch로 잡아서 처리할 수 있게 되었다.

변수 사용 변경점

PHP 7부터 abstract syntax tree를 사용하고 있어서 이전에 불가능한 문법을 많이 구현할 수 있게 되었다. 대신 일관성을 유지하기 위해 몇 가지 해석이 달라지는 부분도 생겼다.

// 표현식
$$foo['bar']['baz']
// PHP 5 해석
${$foo['bar']['baz']}
// PHP 7+ 해석
($$foo)['bar']['baz']

// 표현식
$foo->$bar['baz']
// PHP 5 해석
$foo->{$bar['baz']}
// PHP 7+ 해석
($foo->$bar)['baz']

// 표현식
$foo->$bar['baz']()
// PHP 5 해석
$foo->{$bar['baz']}()
// PHP 7+ 해석
($foo->$bar)['baz']()

// 표현식
Foo::$bar['baz']()
// PHP 5 해석
Foo::{$bar['baz']}()
// PHP 7+ 해석
(Foo::$bar)['baz']()

이전과 같은 방식으로 동작하려면 {}를 사용해서 의미를 더 명확하게 작성해야 한다.

list() 변경점

list() 함수는 원래 역순으로 입력했는데 이제 순서대로 입력된다. 다만 list() 함수의 세부 구현이 변경될 가능성이 있기 때문에 이 함수로 생성한 순서의 의존하는 구현은 권하지 않는다.

list($a[], $a[], $a[]) = [1, 2, 3];
var_dump($a); // [1, 2, 3]

더 이상 문자열을 배열로 변환하는데 사용할 수 없다. str_split()을 대신 사용한다.

foreach 변경점

foreach가 배열 커서를 변경하지 않음

$arr = [0, 1, 2];
foreach($arr as &$val) {
  echo current($arr); // always 0
}

by-value, by-reference 동작 차이

by-value 반복은 주어진 배열을 복사해서 반복하기 때문에 길이 변화를 인식하지 못한다. 대신 by-reference 반복 중에는 배열에 추가된 항목도 인식한다.

// by-value
$arr = [0];
foreach ($arr as $val) {
  var_dump($val);
  $arr[1] = 1;
}

var_dump($arr);
// int(0)
// array(2) {
//   [0]=>
//   int(0)
//   [1]=>
//   int(1)
// }
// by-reference
$arr = [0];
foreach ($arr as &$val) {
  var_dump($val);
  $arr[1] = 1;
}
var_dump($arr);
// int(0)
// int(1) <-- 추가된 부분도 인식해서 반복 처리
// array(2) {
//   [0]=>
//   int(0)
//   [1]=>
//   int(1)
// }

Traversable하지 않은 개체의 반복

이런 개체는 by-reference 배열에 반복하는 것과 동일하게 처리된다.

int 변경점

  • 0으로 나누기: 이전엔 false가 반환되며 E_WARNING이 발생했는데 이제는 float으로 +INF, -INF, NAN이 반환되며 DivisionByZeroError가 발생한다.
  • 음수 비트제어 오류: 1 >> -1 등에 ArithmeticError가 발생한다.

문자열 변경점

더 이상 Hexadecimal 문자열이 숫자로 취급되지 않는다.

"0x123" == "291"; // false
is_numeric("0x123"); // false
"0xe" + "0x1"; // 0

Hexadecimal 문자열을 정수로 변환하는 경우 filter_var()를 활용할 수 있다.

$str = "0xffff";
$int = filter_var($str, FILTER_VALIDATE_INT, FILTER_FLAG_ALLOW_HEX);
if (false === $int) {
  throw new Exception("Invalid integer.");
}
var_dump($int); // int(65535)

그 외 변경점 (일부)

  • 동일 이름 함수 파라미터 사용 시 E_COMPILE_ERROR
  • switch에 여러 default 선언 시 E_COMPILE_ERROR
  • $HTTP_RAW_POST_DATA 제거. php://input 스트림 사용할 것.

제거된 함수

  • call_user_method(), call_user_method_array()
  • ereg 함수

전체 목록은 문서 참조.

yield 우측 평가로 변경

echo yield -1;
// Was previously interpreted as
echo (yield) - 1;
// And is now interpreted as
echo yield (-1);

yield $foo or die;
// Was previously interpreted as
yield ($foo or die);
// And is now interpreted as
(yield $foo) or die;

새 기능

스칼라 타입 선언: 강제(coercive)와 엄격(strict)

함수 파라미터에 스칼라 타입을 넣는 경우 타입 선언에 따라서 동작 방식이 달라진다. php의 기본 동작은 "강제"로 되어 있다. 강제 모드에서는 타입 힌트에 맞춰서 값이 캐스팅되지만 엄격 모드에서는 타입 힌트에 맞지 않는 경우에 오류가 발생한다.

강제 동작 방식:

// coercive
// declare(strict_types=0); // 기본값임

function sumOfInts(int ...$ints)
{
  var_dump($ints);
  return array_sum($ints);
}

var_dump(sumOfInts(2, '3', 4.1));
// PHP Deprecated:  Implicit conversion from float 4.1
//        to int loses precision in app.php on line 4
// array(3) {
//   [0]=>
//   int(2)
//   [1]=>
//   int(3)
//   [2]=>
//   int(4)
// }
// int(9)

엄격 동작 방식:

// strict
declare(strict_types=1);

function sumOfInts(int ...$ints)
{
  return array_sum($ints);
}

var_dump(sumOfInts(2, '3', 4.1));

// PHP Fatal error:  Uncaught TypeError: sumOfInts():
//      Argument #2 must be of type int, string given,
//      called in app.php on line 10 and defined in
//      app.php:4
//
// Stack trace:
//  #0 app.php(10): sumOfInts(2, '3', 4.1)
//  #1 {main}
//  thrown in app.php on line 4

이런 동작 방식은 반환 타입 선언에도 적용된다. 엄격 모드는 실행하는 파일 기준으로 적용된다. 즉, 엄격 모드가 선언된 파일에서 엄격 모드로 선언하지 않은 파일을 불러와 함수를 호출하면 엄격 모드로 동작한다. 타입 선언 문서.

// // util.php
function sumOfInts(int ...$ints)
{
  return array_sum($ints);
}

// // app.php
declare(strict_types=1);
require_once __DIR__.'/util.php';
var_dump(sumOfInts(2, '3', 4.1));

// Fatal error: Uncaught TypeError: sumOfInts(): Argument #2
//         must be of type int, string given, called in
//         app.php on line 4 and defined in util.php:2
//
// Stack trace:
// #0 app.php(4): sumOfInts(2, '3', 4.1)
// #1 {main}
//   thrown in util.php on line 2

반환 타입 선언

function arraySum(array ...$arrays): array
{
  return array_map(function(array $array): int {
    return array_sum($array);
  }, $arrays);
}

var_dump(arraySum([1,2,3], [4,5,6], [7,8,9]));
// array(3) {
//   [0]=>
//   int(6)
//   [1]=>
//   int(15)
//   [2]=>
//   int(24)
// }

??: null 병합 연산자

값이 있거나 null이 아닌 경우에는 앞에 있는 연산을, 그 외에는 뒤에 있는 연산을 반환한다.

// 이전 방식
$username = isset($_GET['user']) ? $_GET['user'] : 'nobody';
// null 병합 연산자로 동일한 결과
$username = $_GET['user'] ?? 'nobody';

// 연속해서도 사용 가능
$username = $_GET['user'] ?? $_POST['user'] ?? 'nobody';

<=>: 우주선 연산자

주어진 a, b 표현식을 비교해서 a < b, a == b, a > b 에 각각 -1, 0, 1 을 반환한다.

// Integers
echo 1 <=> 1; // 0
echo 1 <=> 2; // -1
echo 2 <=> 1; // 1

// Floats
echo 1.5 <=> 1.5; // 0
echo 1.5 <=> 2.5; // -1
echo 2.5 <=> 1.5; // 1

// Strings
echo "a" <=> "a"; // 0
echo "a" <=> "b"; // -1
echo "b" <=> "a"; // 1

define()으로 배열 상수 선언 가능

const로만 선언 가능했는데 define()으로도 가능해졌다.

define('ANIMALS', ['dog', 'cat', 'brid']);
echo ANIMALS[1]; // 'cat'

익명 클래스

new class로 익명 클래스를 선언할 수 있다. 아래 예시는 익명 클래스로 Logger 인터페이스를 구현해서 활용하는 예제다. 익명 클래스 문서.

interface Logger {
  public function log(string $msg);
}

class Application {
  private $logger;

  public function getLogger(): Logger {
    return $this->logger;
  }

  public function setLogger(Logger $logger) {
    $this->logger = $logger;
  }
}

$app = new Application;
$app->setLogger(new class implements Logger {
  public function log(string $msg) {
    echo $msg;
  }
});

var_dump($app->getLogger());

유니코드 탈출 문자 문법

\u{...} 형태 문자열을 유니코드로 처리한다.

echo "\u{2615}"; // ☕

Closure::call()

클로저에 맥락을 주입해야 할 때 사용할 수 있는 간편한 방식이다.

class A { private $x = 1; }
$getX = function () { return $this-> x; };

$a = new A;

// 이전 방식
$getXCB = $getX->bindTo($a, A::class); // 중간 단계의 클로저
echo $getXCB();

// 새 방식
echo $getX->call($a);

unserialize()에서 필터링하기

신뢰할 수 있는 데이터만 역직렬화 할 수 있도록 필터가 추가되었다. 코드 삽입 공격을 막는데 도움이 된다.

// 모든 개체를 __PHP_Incomplete_Class 개체로 변환
$data = unserialize($foo, ["allowed_classes" => false]);

// MyClass와 MyClass2를 제외한 모든 개체를 __PHP_Incomplete_Class 개체로 변환
$data = unserialize($foo, ["allowed_classes" => ["MyClass", "MyClass2"]]);

// 기본 동작 방식 (파라미터 없는 것과 동일)
$data = unserialize($foo, ["allowed_classes" => true]);

IntlChar: 유니코드 문자 관련 클래스

IntlChar 문서 참조. Intl 확장이 설치되어 있어야 한다.

printf('%x', IntlChar::CODEPOINT_MAX); // 10ffff
echo IntlChar::charName('☕'); // HOT BEVERAGE
var_dump(IntlChar::ispunct('!')); // bool(true)

Expectations

assert() 함수의 하위 호환성 강화를 위해 도입된 기능이다. assert.exception 설정에 따라서 assert()가 실패했을 경우에 예외를 던진다. 특히 예전 API는 문자열을 처리하는데 그쳤지만 이제는 언어 구조로 편입되어 단순히 문자열 검사나 불린 값 평가가 아닌, 제대로 된 표현식 검사가 이뤄진다.

// 예전에는 이랬다... 문자열을 `eval()`로 처리해서 검사
assert("$hello === false", "hello is false...");
// ini_set('assert.exception', 0); // default
assert(false, 'Some error message');
// PHP Warning: assert(): False return test failed in app.php on line 2
ini_set('assert.exception', 1);
assert(false, 'Some error message');
// PHP Fatal error:  Uncaught AssertionError: Some error message in app.php:2
// Stack trace:
// #0 app.php(2): assert(false, 'Some error mess...')
// #1 {main}
//   thrown in app.php on line 2

별도의 에러도 가능하다.

ini_set('assert.exception', 1);
class CustomError extends AssertionError {}

assert(false, new CustomError('some error message'));
// PHP Fatal error:  Uncaught CustomError: Some error message in app.php:4
// Stack trace:
// #0 app.php(4): assert(false, 'Some error mess...')
// #1 {main}
//   thrown in app.php on line 4

use 선언 모아쓰기

// 이전 방식
use some\namespace\ClassA;
use some\namespace\ClassB;
use some\namespace\ClassC as C;

use function some\namespace\fn_a;
use function some\namespace\fn_b;
use function some\namespace\fn_c;

use const some\namespace\ConstA;
use const some\namespace\ConstB;
use const some\namespace\ConstC;

// 새 방식
use some\namespace\{ClassA, ClassB, ClassC as C};
use function some\namespace\{fn_a, fn_b, fn_c};
use const some\namespace\{ConstA, ConstB, ConstC};

제너레이터 반환 표현

$gen = (function() {
  yield 1;
  yield 2;

  return 3;
})();

foreach ($gen as $val) {
  echo $val, PHP_EOL;
}
// 1
// 2

echo $gen->getReturn(), PHP_EOL;
// 3

제너레이터의 yield가 모두 끝난 후 최종적인 반환 값을 받아올 수 있도록 getReturn() 메소드가 추가되었다.

제너레이터 위임

yield from으로 제너레이터 중간에 커서를 위임할 수 있다.

function gen()
{
  yield 1;
  yield 2;
  yield from gen2();
}

function gen2()
{
  yield 3;
  yield 4;
}

foreach (gen() as $val)
{
  echo $val, PHP_EOL;
}
// 1
// 2
// 3
// 4

intdiv() 정수 나누기 함수

php에서는 정수를 대상으로 / 연산자를 사용하면 소숫점 나누기 결과가 나온다. 정수 나누기에는 새로 소개된 intdiv()를 사용하면 된다.

$quotient = intdiv(10, 3); // 몫
$remainder = 10 % 3; // 나머지
$div = 10 / 3; // 나누기

echo $quotient; // 3
echo $remainder; // 1
echo $div; // 3.3333333333333

세션 옵션

세션 옵션이 ini에 설정되어 있었는데 이제 session_start()에서도 지정할 수 있다. 가능한 설정 목록.

session_start([
  'cache_limiter' => 'private',
  'read_and_close' => true,
]);

preg_replace_callback_array() 추가

정규표현식에 맞는 경우 해당 콜백을 실행한다. 기존 preg_replace_callback()으로 장황하게 작성한 코드를 개선할 수 있다. 문서.

$subject = 'Aaaaaa Bbb';

preg_replace_callback_array(
  [
    '~[a]+~i' => function ($match) {
      echo strlen($match[0]), ' matches for "a" found', PHP_EOL;
    },
    '~[b]+~i' => function ($match) {
      echo strlen($match[0]), ' matches for "b" found', PHP_EOL;
    }
  ],
  $subject
);
// 6 matches for "a" found
// 3 matches for "b" found

CSPRNG 함수 추가

암호학적으로 안전하며 크로스 플랫폼으로 동작하는 함수가 추가되었다.

  • random_bytes()
  • random_int()

list()ArrayAccess 구현 객체 풀기 가능

보장되지 않던 부분인데 수정되었다.

클래스 맴버 접근에 clone 가능하도록 추가

(clone $foo)->bar();

변경/추가된 함수 (일부)

  • dirname(): depth 2번 파라미터로 몇 단계 위 경로를 반환할 지 지정할 수 있다.
  • exec(), system(), passthru(): NULL 바이트 보호가 추가되었다.
  • substr(), iconv_substr(): 해당하는 값이 없을 때 빈 문자열을 반환하도록 변경되었다.
  • error_clear_last(): error_get_last()를 비운다.

변경 전체 목록, 추가 전체 목록.

추가된 클래스/인터페이스 (일부)

전체 목록

그 외 변경사항

예약어 제한 완화

문맥적인 fluent 인터페이스 개발이 가능하도록 예약어 제한이 약해졌다. (class는 ClassName::class 때문에 여전히 사용할 수 없다.)

// 'new', 'private', 'for' 등 전에 사용하지 못했던 메소드명
Project::new('Project Name')
  ->private()
  ->for('purpose here')
  ->with('username here');

date.timezone 경고 제거

설정되지 않은 경우에 나오던 경고가 제거되었다. 기본값은 UTC다.

PHP 7.1

호환성 문제 있는 변경

적은 수의 매개변수로 함수를 호출하는 경우

사용자 정의 함수를 호출할 때, 필요한 매개변수보다 적은 수의 인자로 호출하면 경고 대신 오류 예외가 발생한다.

function hello($param) {}
hello();
// Uncaught ArgumentCountError: Too few arguments to function hello(), 0 passed in...

스코프 연관 함수의 동적 호출 금지

함수가 다른 함수의 기능을 들여다 보거나 스코프를 수정하는 경우라면 동적 호출하는 과정에서 의미가 모호하거나 불안정할 수 있다.

  • assert() - with a string as the first argument
  • compact()
  • extract()
  • func_get_args()
  • func_get_arg()
  • func_num_args()
  • get_defined_vars()
  • mb_parse_str() - with one arg
  • parse_str() - with one arg
(function () {
  $func = 'func_num_args';
  $func();
})();
// Uncaught Error: Cannot call func_num_args() dynamically in....

추가된 금지어

클래스명, 인터페이스나 trait 이름 금지어로 다음 단어가 추가되었다.

  • void
  • iterable

숫자 문자열 변경이 과학적 표기법을 준수함

(int) 캐스팅, intval(), settype(), decbin(), decoct(), dechex()에도 동일하게 적용되었다.

mt_rand() 알고리즘 수정

제대로 된 Mersenne Twister 알고리즘으로 동작한다. 기존 잘못된 구현으로 함수를 실행하려면 MT_RAND_PHP를 두번째 인자로 전달해서 구동할 수 있다.

rand()mt_rand(), srand()mt_srand()를 수행

이 변경은 shuffle(), str_shuffle(), array_rand()의 출력에도 영향을 준다.

ASCII 삭제 제어 문자를 식별자로 사용할 수 없음

0x7E를 더 이상 식별자로 사용할 수 없다.

error_logsyslog로 설정된 경우 syslog의 오류 레벨 설정을 따름

불완전한 개체에서 소멸자를 호출하지 않음

불완전한 개체에 대해 소멸자를 호출하지 않도록 변경되었다. 즉, 생성자에서 예외가 발생했을 때 그 개체의 소멸자가 호출되지 않는다.

call_user_func()에 참조 인자 사용

call_user_func()에 참조 인자를 사용하는 경우 경고가 표시된다. 경고는 표시되지만 호출 자체는 문제 없이 된다.

문자열에서의 빈 인덱스 연산자 지원 제거

예전에는 $str[] = $x 처럼 작성하면 말 없이 배열로 변환되었지만 이제는 심각한 오류가 발생한다.

문자열에서의 인덱스 연산자

예전에는 말 없이 배열로 변환되었던 동작인데 이제는 문자열 인덱스로 접근해서 값을 배정하는 형식으로 동작한다. 대신에 첫 글자만 사용한다.

$a = '';
$a[10] = 'foo';
var_dump($a);
// Warning:  Only the first byte will be assigned to the string offset in...
// string(11) "          f"

동일 요소에 대한 정렬 순서

정렬할 때 동일한 요소로 판단되면 그 순서가 어느 것이 먼저 온다는 보장이 없다. 그러므로 동일한 요소가 있는 목록의 경우는 그 결과 순서에 의존적인 코드를 작성해서는 안된다.

unserialize() 함수의 $optionsallowed_classes

unserialize() 함수를 호출하면서 $optionsallowed_classesarray|bool 타입이 아니면 false를 반환하고 E_WARNING이 발생한다.

DateTime 생성 시 마이크로초 반영

이전까지 마이크로초가 제대로 반영되지 않아서 다음과 같은 호출이 거의 true 였지만 이제는 false를 반환할 가능성이 높아졌다.

new DateTime() ==  new DateTime();

많은 fatal error가 Error 예외로 전환

전체 목록.

클로저에 use에서 제한된 함수명

클로저에 use 생성자를 사용할 때 슈퍼 전역 변수나 $this 등을 사용하면 에러가 발생한다. 매개변수와 동일한 이름도 오류가 발생한다.

$f = function () use ($_SERVER) {};
// Fatal error:  Cannot use auto-global as lexical variable in...

$f = function () use ($this) {};
// Fatal error:  Cannot use $this as lexical variable in...

$f = function ($param) use ($param) {};
// Fatal error:  Cannot use lexical variable $param as a parameter name in...

JSON 변환

serialize_precision 환경 설정으로 double 인코딩 시 정밀도를 지정할 수 있다.

이전에 빈 키를 _empty_ 프로퍼티로 변환하던 것이 고쳐져서 진짜 빈 키로 지정된다.

var_dump(json_decode(json_encode(['' => 1])));
// object(stdClass)#1 (1) {
//   [""]=>
//   int(1)
// }

반환 타입이 지정된 경우에 return; 금지

반환 타입이 지정된 함수에 return;을 사용하면 설령 코드가 해당 반환문에 절대 도착하지 않더라도 E_COMPILE_ERROR가 발생한다.

function sayHello(): string {
	if (true) {
		return "Hello";
	}
	return;
}
// Fatal error:  A function with return type must return a value in...

새 기능

Nullable 타입

타입 선언에서 반환 값이 타입 개체 또는 null인 경우 ?를 사용해서 nullable 타입으로 선언할 수 있다.

function getNothingWrong(): string
{
  return null;
}

var_dump(getNothingWrong());
// Uncaught TypeError: getNothing(): Return value must be of type string, null returned in...

function getNothing(): ?string
{
  return null;
}

var_dump(getNothing()); // null
function getHello(?string $name): string
{
  return "Hello " . ($name ?? 'stranger') . "!";
}

var_dump(getHello(null)); // "Hello stranger!"
var_dump(getHello('Edward')); // "Hello Edward!"
var_dump(getHello());
// Uncaught ArgumentCountError: Too few arguments to function getHello(), 0 passed in...

Void 함수

반환이 없거나 반환 값이 없는 함수의 경우, 타입 선언에 void를 넣을 수 있다. 해당 함수를 변수에 배당하면 null이 나오며 별도의 오류는 발생하지 않는다.

function sayHello(): void
{
  echo 'Hello!' . PHP_EOL; // 반환이 없음
}

function sayBye(): void
{
  echo 'Bye!' . PHP_EOL;
  return; // 빈 반환
}

$a = sayHello();
var_dump($a); // null

null 반환은 void에 해당하지 않는다. 다음과 같이 오류가 발생한다.

function saySomething(): void
{
  echo 'Something!' . PHP_EOL;
  return null;
}
// Fatal error:  A void function must not return a value
//    (did you mean "return;" instead of "return null;"?) in...

배열 분해

[] 문법으로 간단하게 배열을 분해할 수 있다.

$data = [
  [1, 'Tom'],
  [2, 'Fred'],
];

// list() 사용
//
list($id1, $name1) = $data[0];

foreach($data as list($id, $name)) {
  // $id, $name
}

// [] 사용
//
[$id1, $name1] = $data[0];

foreach($data as [$id, $name]) {
  // $id, $name
}

클래스 상수 접근 제한자

클래스 상수에도 접근 제한자를 설정할 수 있게 변경되었다.

class ConstDemo
{
  const PUBLIC_CONST_A = 1;
  public const PUBLIC_CONST_B = 2;
  protected const PROTECTED_CONST = 3;
  private const PRIVATE_CONST = 4;
}

iterable 임시 타입

반복에 사용할 수 있는 iterable 임시 타입이 추가되었다. Traversable 인터페이스를 구현한 개체나 배열을 모두 받는다. (callable도 임시 타입 중 하나로 호출 가능한 다양한 타입/형태를 받는 것과 유사하다.)

funciton iterator(iterable $iter) {
  // ...
}

여러 예외 catch로 한번에 다루기

파이프 문자(|)로 여러 예외를 한번에 처리할 수 있다.

try {
  // some code
} catch (FirstException | SecondException $e) {
  // 두 예외 모두 처리하기
}

list() 키 지원

배열을 분해할 때 키를 지정할 수 있다. list()[] 모두 지원한다.

$data = [
  ['id' => 1, 'name' => 'Hellen'],
  ['id' => 2, 'name' => 'Jane'],
];

// list() 사용
//
list('id' => $id1, 'name' => $name1) = $data[0];
foreach ($data as list('id' => $id, 'name' => $name)) {
  // $id, $name
}

// [] 사용
//
['id' => $id1, 'name' => $name1] = $data[0];
foreach ($data as ['id' => $id, 'name' => $name]) {
  // $id, $name
}

문자열 음수 오프셋 지원

var_dump("abcdef"[-2]); // e
$str = "Hello";
echo "The last character of '$str' is '$str[-1]'.\n";
// "The last character of 'Hello' is 'o'."

Closure::fromCallable(): callableClosure개체로 변환

이 함수로 callableClosure 개체로 변환할 수 있어 좀 더 일관성을 갖출 수 있다.

class ShySpeaker
{
  public function exposeWhisper()
  {
    return Closure::fromCallable([$this, 'whisper']);
  }

  private function whisper($saying)
  {
    echo "'$saying', the speaker whispered.";
  }
}

$privateFunc = (new ShySpeaker)->exposeWhisper();
$privateFunc('dang na gui gui');
// "'dang na gui gui', the speaker whispered."

비동기 시그널 처리

문서.

pcntl_async_signals(true); // turn on async signals

pcntl_signal(SIGHUP,  function($sig) {
    echo "SIGHUP\n";
});

posix_kill(posix_getpid(), SIGHUP);

추가된 함수 (일부)

  • session_create_id()
  • session_gc()
  • is_iterable()

추가된 함수 전체 목록.

변경된 함수 (일부)

  • getopt(): 3번째 인자를 참조로 넘기면 어디까지 처리했는지 인덱스를 받을 수 있다.
  • getenv(): 인자 없이 호출하면 전체 값을 배열로 반환한다.
  • get_headers(): 스트림 맥락을 보낼 수 있다.
  • parse_url(): 더 제한적으로 동작하고 RFC3986를 지원한다.
  • unpack(): 세번째 인자로 어디서부터 변환이 시작되었는지 지정할 수 있다.
  • session_start(): 세션 시작에 실패하면 false 반환하도록 변경한다.

전제 목록.

그 외

  • 잘못된 문자열로 산술 연산을 수행하면 E_WARNING, E_NOTICE 둘 다 발생한다.
  • null이 허용되는 경우에 TypeError가 해당 부분도 알려준다. (e.g. must be blarblar or null)

PHP 7.2

새 기능

object 타입

공변(covariant) 반환 타입과 반공변(contravariant) 매개변수 타이핑에 사용할 수 있는 새 타입 object가 소개되었다.

function test(object $obj): object
{
  return new SplQueue();
}

test(new StdClass());

dl(): 이름으로 확장 불러오기

dl() 함수로 .so, .dll 확장을 불러올 수 있다.

추상 메소드 오버라이드

확장한 추상 클래스에서 추상 메소드를 오버라이드 할 수 있다.

abstract class A
{
  abstract function test(string $s);
}

abstract class B extends A
{
  // 오버라이드 됨. 대신 매개변수의 반공변성과 반환값의 공변성을 따라야 함.
  abstract function test(string $s): int;
}

class Imp extends B
{
  function test(string $s) : int {
    return 3;
  }
}

Sodium이 코어 확장에 포함

현대적인 암호화 라이브러리 Sodium이 포함되었다. 문서.

매개변수 타입 확장

인터페이스의 메소드를 오버라이드하면서 매개변수 타입 선언을 지우는 것으로 확장할 수 있다. 매개변수가 반공변성을 유지하기 때문에 이 확장은 리스코프 치환 원칙(LSP)를 준수한다.

interface A
{
  public function Test(array $input);
}

class B implements A
{
  public function Test($input){} // 타입을 제거함
}

네임스페이스에 후행 쉼표(trailing comma) 허용

use Hello\World\{
  Foo,
  Bar,
  Baz
};

새로운 함수

문서.

호환성 문제 있는 변경

number_format()에서 -0 반환 문제 수정

따지고 보면 IEEE 754에는 적합한 표현이지만 사람 눈에 이상해보여서 제거되었다.

var_dump(number_format(-0.01)); // 0

개체에서 배열 캐스팅에 숫자 키 변환 수정

// array -> object
//
$arr = [0 => 1];
$obj = (object) $arr;
var_dump(
  $obj,
  $obj->{'0'},
  $obj->{0},
);
// object(stdClass)#1 (1) {
//   ["0"]=> // 문자열 키로 처리됨
//   int(1)
// }
// int(1)
// int(1)

// object -> array
$obj = new class {
  public function __construct()
  {
    $this->{0} = 1;
  }
};
$arr = (array)$obj;

var_dump(
  $arr,
  $arr[0],
  $arr['0'],
);
// array(1) {
//   [0]=>    // 정수 키로 처리됨
//   int(1)
// }
// int(1)
// int(1)

get_class()null 넘기는 기능이 없어짐

null을 넘기면 현재 맥락에 맞는 클래스명을 반환했는데 이제 단순히 인자 없이 호출하면 된다.

count()로 셀 수 없는 타입을 호출한 경우 경고

var_dump(
  count(null), // NULL은 셀 수 없음
  count(1), // 정수는 셀 수 없음
  count('abc'), // 문자열은 셀 수 없음 (대신 `sizeof()`)
  count(new stdclass), // Countable 구현 안하면 셀 수 없음
  count([1,2]), // 배열을 셀 수 있음
);

참고: 이후 버전에서는 TypeError가 발생하며 값을 반환하지 않는다.

__PHP_Incomplete_Classis_object()

이전엔 false였는데 이제는 true 반환한다.

array_unique()

array_unique()SORT_STRING을 사용했을 때 배열을 복사해서 중복을 지우는 방식이었다. 이제는 새로운 배열에 요소를 추가하는 방식으로 변경되었다. 그 결과로 숫자 인덱스의 결괏값이 달라졌다.

PHP 7.3

새 기능

배열 분해에서 참조 할당 지원

$d = ['a', [1, 2]];

// [] 사용
[&$a, [$b, &$c]] = $d;
$a = 'c';
echo $d[0]; // "c"

// list() 사용
list(&$a, list($b, &$c)) = $d;
$c = 3;
echo $d[1][1]; // 3

instanceof 연산자에서 리터럴 처리

instanceof가 리터럴을 받을 수 있도록 변경되었으며 항상 false를 반환한다.

class Hello {}
var_dump('Hello' instanceof Hello); // false
var_dump(new Hello instanceof Hello); // true

CompileError 예외 추가

몇 가지 심각 오류를 만들던 컴파일 오류가 ParseError에서 파생된 CompileError로 변경되었다.

호출에서 후행 쉼표 사용 가능

함수와 메소드 모두에서 후행 쉼표를 사용할 수 있다.

hello(
  $a,
  $b,
  $c,
  $d, // 예전에 오류가 나던 부분
);

mbstring 개선

본문 참조.

새로운 함수 (일부)

  • array_key_first(), array_key_last(): 배열의 처음 혹은 마지막 키를 반환한다.
  • gc_status(): 가비지 컬렉터의 상태를 반환한다.
  • hrtime(): 시스템의 고해상도 시간을 반환한다.
  • is_countable(): Countable 인터페이스를 구현했거나 셀 수 있는 지 확인한다.
  • net_get_interfaces(): 네트워크 인터페이스 정보를 반환한다.
  • DateTime::createFromImmutable: DateTimeImmutable 개체에서 DatTime 개체를 생성한다.

전체 목록.

호환성 문제 있는 변경

Heredoc/Nowdoc 종결 표식 해석 변경

$str = <<<FOO
abcdefg
   FOO  // 종결 표식 앞에 들여쓰기가 있으면 오류가 나도록 변경됨
FOO;

switch 내에서 continue 경고

switch에서 continuebreak와 동일하게 동작하기 때문에 continue 2를 의도하고 쓴 것인지 확인하는 경고가 추가되었다.

while ($foo) {
    switch ($bar) {
      case "baz":
         continue;
         // Warning: "continue" targeting switch is equivalent to
         //          "break". Did you mean to use "continue 2"?
   }
}

정적 프로퍼티 참조 문제 수정

정적 프로퍼티가 공유되어야 하는데 참조 배정을 했을 경우에 공유가 되지 않던 문제가 수정되었다.

class Test {
    public static $x = 0;
}
class Test2 extends Test { }

Test2::$x = &$x;
$x = 1;

var_dump(Test::$x, Test2::$x);
// 이전: int(0), int(1)
// 현재: int(1), int(1)

배열, 프로퍼티 접근자를 참조로 사용하는 경우 바로 값을 반환하도록 변경

$arr = [1];
var_dump($arr[0] + ($arr[0] = 2));
// 이전: int(4)
// 현재: int(3)
$arr = [1];
$ref =& $arr[0];
var_dump($ref + ($arr[0] = 2));
// int(4)

현재는 이렇게 동작하긴 하지만 표현식 하나에서 값을 읽고 쓰는 부분에 대한 정의가 존재하지 않는다. 그래서 미래에 다른 결과가 나올 수도 있으므로 더 명확한 방식으로 구현하기를 권한다.

Traversable의 정수가 아닌 숫자 키로 인자 분해 안되도록 변경

function foo(...$args) {
    var_dump($args);
}
function gen() {
    yield 1.23 => 123;
}
foo(...gen());
// Uncaught Error: Keys must be of type int|string during argument unpacking in...

더 이상 사용되지 않는 기능 (일부)

  • 대소문자 구분 안하는 define(): 3번째 인자로 대소문자 구분 안하도록 설정 가능했으나 이제 deprecated 되었다.
  • 네임스페이스 내 assert()

전체 목록.

그 외 변경점 (일부)

var_export()는 이제 더 이상 존재하지 않는 stdClass::__setState() 메소드 대신 (object)로 캐스팅 하는 방식으로 변경되었다.

array_push()arry_unshift()는 단일 인자로도 호출 가능하도록 변경되었다. ... 연산자와 함께 사용하는데 편리하도록 개선된 부분이다.

$items = [1, 2, 3];

$pushPayload = [&$items, 4];
array_push(...$pushPayload);

var_dump($items);
// array(4) {
//   [0]=>
//   int(1)
//   [1]=>
//   int(2)
//   [2]=>
//   int(3)
//   [3]=>
//   int(4)
// }

전체 목록.

PHP 7.4

새 기능

클래스 타입 프로퍼티

클래스 프로퍼티에도 타입을 지정할 수 있게 되었다. 타입이 맞지 않는 값을 넣으면 TypeError가 발생한다.

class user {
  public int $id;
  public string $name;
}

$a = new User();
$a->id = 'test';
// Uncaught TypeError: Cannot assign string to property User::$id of type int in...

화살표 함수

화살표 함수는 암묵적 값 스코프(implicit by-value scope)를 지원하는 축약 문법이다.

$factor = 10;
$nums = array_map(fn($n) => $n * $factor, [1, 2, 3, 4]);
// [10, 20, 30, 40]

제한적인 공변 반환 타입과 반공변 인자 타입

class A {}
class B extends A {}

class Producer {
  public function method(): A {}
}
class ChildProducer extends Producer {
  public function method(): B {}
}

오토로딩이 사용되는 경우에는 모든 변성을 지원한다. 단일 파일에서 작성하는 경우에는 순환 참조를 하지 않는 경우에만 사용할 수 있다. 참조 전에 미리 선언되어 있어야 하기 때문이다.

Null 병합 배정 연산자

$array['key'] ??= computeDefault();
// 다음과 유사
if (!isset($array['key'])) {
  $array['key'] = computeDefault();
}

배열 내 배열 풀기

$parts = ['apple', 'pear'];
$fruits = ['banana', 'orange', ...$parts, 'watermelon'];
// ['banana', 'orange', 'apple', 'pear', 'watermelon']

숫자 리터럴 구분자

6.674_083e-11; // float
299_792_458;   // decimal
0xCAFE_F00D;   // hexadecimal
0b0101_1111;   // binary

약한 참조

약한 참조는 프로그래머가 개체가 파괴되더라고 개체에 대한 참조를 유지할 수 있는 기능을 제공한다. WeakReference 클래스를 활용한다.

// 약한 참조
$obj = new stdClass;
$weakref = WeakReference::create($obj);
var_dump($weakref->get());
// object(stdClass)#1 (0) {
// }

unset($obj);
var_dump($weakref->get());
// NULL
// 참조
$obj = new stdClass;
$ref = & $obj;
var_dump($ref);
// object(stdClass)#1 (0) {
// }

unset($obj);
var_dump($ref);
// object(stdClass)#1 (0) {
// }
// 참조로 인해 개체가 파괴되지 않음

__toString()에서 예외 허용

이전에는 예외가 있는 경우에 심각 오류가 발생했는데 이제 예외를 던질 수 있게 변경되었다.

mb_str_split() 추가

str_split()과 동일한 역할을 하는 멀티바이트 함수가 추가되었다.

strip_tags()에 태그명 배열 사용

strip_tags($str, '<a><p>');
strip_tags($str, ['a', 'p']); // 동일한 기능

커스텀 개체 직렬화

다음 두 매직 메소드로 개체의 직렬화와 역직렬화를 제어할 수 있다.

// 반환 배열은 개체에 필요한 모든 상태를 포함
public function __serialize(): array;

// 개체 상태를 제시된 자료 배열을 사용해서 복구함
public function __unserialize(array $data): void;

array_merge(), array_merge_recursive() 단일 인자로도 호출 가능하도록 변경

array_push()와 동일한 이유에서 변경되었다.

array_merge(...$arrays);

proc_open() 배열 지원

proc_open(['php', '-r', 'echo "Hello World\n";'], $descriptors, $pipes);

// `redirect`와 `null` 디스크립터도 지원
proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['null']], $pipes);

새로운 클래스/함수 (일부)

전체 목록.

호환성 문제 있는 변경

배열이 아닌 값에 배열 스타일로 접근하면 알림 발생

null, bool, int, float, 또는 어떤 리소스든 배열이 아닌 것에 배열처럼 접근하면 E_WARNING이 발생한다.

$a = 3;
$a[0];
// Warning: Trying to access array offset on value of type int in...

get_declared_classes()와 익명 클래스

더 이상 초기화 되지 않은 익명 클래스는 get_declared_classes() 함수에서 반환하지 않는다.

fn 키워드 예약어 지정

화살표 함수로 추가된 이 키워드는 더 이상 함수나 클래스명으로 사용할 수 없다. 메소드나 클래스 상수로는 여전히 사용할 수 있다.

파일 끝에 <?php 오류 수정

파일 끝에 <?php가 있으면 문법 오류가 나던 부분이 수정되었다.

비밀번호 알고리즘 상수 변경

이전에는 알고리즘 상수가 정수였는데 ?string으로 변경되었다.

  • PASSWORD_DEFAULT: int 1 -> string '2y' (PHP 7.4.0, 7.4.1, 7.4.2에서는 null)
  • PASSWORD_BCRYPT: int 1 -> string '2y'
  • PASSWORD_ARGON2I: int 2 -> string 'argon2i'
  • PASSWORD_ARGON2ID: int 3 -> string 'argon2id'

htmlentities()

지원이 부족한 인코딩으로 된 데이터를 처리하는 경우에 알림이 발생한다.

일자와 시간

DateInterval 개체를 비교하면 항상 false를 반환한다.

Reflection 개체의 직렬화 불가

원래 지원하지 않던 부분인데 명시적으로 금지되었다.

더 이상 사용되지 않는 기능 (일부)

명시적이지 않는 중첩 삼항 연산자

이후 버전에서는 아예 제거되어서 Fatal error가 발생한다.

1 ? 2 : 3 ? 4 : 5;   // deprecated
(1 ? 2 : 3) ? 4 : 5; // ok
1 ? 2 : (3 ? 4 : 5); // ok

// PHP 8+
// Fatal error:  Unparenthesized `a ? b : c ? d : e` is not supported.
//     Use either `(a ? b : c) ? d : e` or `a ? b : (c ? d : e)` in...

중간에 있는 경우는 모호하지 않아서 중첩에도 여전히 동작한다.

1 ? 2 ? 3 : 4 : 5; // ok

배열, 문자열 오프셋 접근에 {} 사용 중단

// $var{$idx}; 대신 아래 방식 사용
$var[$idx];

(real)is_real() 중단

대신 (float)is_float()을 사용하기를 권장한다.

부모 클래스가 없는데 parent 키워드 사용 중단

중단 메시지가 나왔으나 PHP 8+ 에서는 심각 오류가 발생한다.

class Hello {
	public function __construct() {
		parent::__construct();
	}
}
// Fatal error:  Cannot use "parent" when current class scope has no parent in...

array_key_exists()를 개체에 사용하기 중단

대신 isset() 또는 property_exists()를 사용한다.

money_format() 함수 중단

대신 NumerFormatter 기능을 사용한다.

리플렉션 관련

ReflectionType::__toString() 중단

대신 ReflectionNamedType::getName()을 사용한다.

ReflectionClass::export() 메소드 중단

대신 개체가 문자열로 변환된다.

// ReflectionClass::export(Foo::class, false)는 다음에 대응
echo new ReflectionClass(Foo::class), "\n";

// $str = ReflectionClass::export(Foo::class, true)는 다음에 대응
$str = (string) new ReflectionClass(Foo::class);

그 외

문서 참고.

PHP 8.0

새 기능

새 기능 전체 목록.

명명된 인수(Named arguments) 추가

myFunction(paramName: $value);
array_foobar(array: $value);


function person($name, $age) {
	echo "name: $name, age: $age" . PHP_EOL;
}

// 순서가 바뀌어도 됨
person(age: 72, name: 'Sejong');
// 순서 인자와 명명 인자를 섞어도 됨
person('Sejong', age: 72);

// 인수명을 변수로 넣는 것은 지원하지 않음
function_name($variableStoringParamName: $value);

// 동일한 명명 인자를 쓰면 가장 마지막 인자가 덮어씀
person(name: 'Hana', name: 'Narae'); // $name = 'Narae'
person('Hana', name: 'Narae'); // $name = 'Narae'

어트리뷰트(Attribute) 추가

코드를 선언할 때 기계가 분석할 수 있는 메타 정보를 추가할 수 있도록 어트리뷰트를 지원한다. 이 어트리뷰트의 정보는 리플렉션 API를 통해서 접근할 수 있다.

#[Attribute]
class Setup {}

class CopyFile {
  // ...
  #[SetUp]
  public function fileExists() {
    // ...
  }
}

자세한 내용은 문서를 확인한다.

// 사용 예시
#[MyAttribute]
#[\MyExample\MyAttribute]
#[MyAttribute(1234)]
#[MyAttribute(value: 1234)]
#[MyAttribute(MyAttribute::VALUE)]
#[MyAttribute(array("key" => "value"))]
#[MyAttribute(100 + 200)]
class Thing
{
}

#[MyAttribute(1234), MyAttribute(5678)]
class AnotherThing
{
}

클래스 생성자 시그니처로 프로퍼티 선언하기 (constructor property promotion)

생성자 시그니처에 접근 제한자를 포함하면 해당 시그니처를 사용해서 프로퍼티를 자동 할당해준다. 프로모션은 순서에 영향받지 않는다.

class Point {
	public function __construct(protected int $x, protected int $y = 0) {
	}

	public function print(): void {
		echo "x: $this->x, y: $this->y" . PHP_EOL;
	}
}

$p = new Point(1, 2);
$p->print();

유니언 타입 (Union type) 추가

|를 사용해서 타입을 합집합으로 사용할 수 있다. T1|T2|... 방식으로 사용한다.

유니언 타입을 선언할 때 null 유니언 타입도 지원한다. 즉 T1|T2|null 식으로 nullable 합집합을 만들 수 있다. (null은 단독으로 쓸 수 있는 타입은 아니며 여기서만 특수하게 사용 가능하다.)

유니언 타입을 선언할 때 false 임시 타입도 지원한다. true는 존재하지 않는다. false|null, ?false처럼 사용할 수 없다.

match 표현식 추가

switch와 유사하나 switch===으로 검사하는데 반해 match==으로 검사한다. 문서.

$food = 'cake';

$return_value = match ($food) {
  'apple' => 'This food is an apple',
  'bar' => 'This food is a bar',
  'cake' => 'This food is a cake',
};

var_dump($return_value);
// "This food is a cake"

// 조건 표현에는 값, 변수, 값을 반환하는 함수 등 모두 가능하다.
$expressionResult = match ($condition) {
    1, 2 => foo(),
    3, 4 => bar(),
    default => baz(),
};

?-> null 안전 연산자 추가

null에 안전하게 프로퍼티와 메소드에 접근할 수 있도록 ?-> 연산자가 추가되었다.

$result = $repository?->getUser(5)?->name;

// 다음과 동일
if (is_null($repository)) {
  $result = null;
} else {
  $user = $repository->getUser(5);
  if (is_null($user)) {
    $result = null;
  } else {
    $result = $user->name;
  }
}

WeakMap 클래스 추가

레퍼런스 카운트에 영향을 주지 않고 개체를 키로 사용할 수 있는 WeakMap 클래스가 추가되었다. 문서.

$wm = new WeakMap();

$o = new StdClass;

class A {
    public function __destruct() {
        echo "Dead!\n";
    }
}

$wm[$o] = new A;

var_dump(count($wm));
echo "Unsetting...\n";
unset($o);
echo "Done\n";
var_dump(count($wm));

ValueError 클래스 추가

인자 타입에 맞게 값이 전달되었지만 값이 맞는 범위에 들지 않는 경우에 사용할 수 있는 ValueError 클래스가 추가되었다.

메소드 시그니처에 가변 인자 사용 가능

타입이 호환되는 상황이라면 다음처럼 가변 인자를 사용할 수 있다.

class A {
  public function method(int $many, string $parameters, $here) {}
}
class B extends A {
  public function method(...$everything) {}
}

지연된 바인딩을 위한 static 반환 지원

self와 비교해볼 수 있는 키워드로 아래 예제를 참고한다.

class Test {
	public function createSelf(): self {
		return new self();
	}
	public function createStatic(): static {
		return new static();
	}
}

class WorkTest extends Test {
}

$test = new Test;
$self = $test->createSelf();
$static = $test->createStatic();

var_dump($self);
var_dump($static);
// object(Test)#2 (0) {
// }
// object(Test)#3 (0) {
// }

$workTest = new WorkTest;
$workSelf = $workTest->createSelf();
$workStatic = $workTest->createStatic();

var_dump($workSelf);
var_dump($workStatic);
// object(Test)#5 (0) {
// }
// object(WorkTest)#6 (0) {
// }

Stringable 인터페이스 추가

Stringable 인터페이스가 추가되었다. __toString() 메소드가 구현되어 있으면 자동으로 구현된 것으로 처리된다.

class Hello {
	public function __toString() {
		return "Hello";
	}
}

function hey(Stringable $s) {
	echo "YES";
}

hey(new Hello);

throw를 표현식에 사용

throw를 표현식에도 사용할 수 있다.

$fn = fn() => throw new Exception('Exception in arrow function');
$user = $session->user ?? throw new Exception('Must have user');

그 외 추가된 부분

  • 개체에서도 클래스명을 찾을 수 있다. $object::classget_class($object)와 동일한 결과를 반환한다.
  • newinstanceof를 표현식과 함께 사용할 수 있다. 예: new (expression)(...$args), $obj instanceof (expression).
  • Trait으로 추상 private 메소드도 정의할 수 있다.
  • catch (Exception)으로 변수를 저장하지 않고도 try catch 할 수 있다.
  • mixed 타입이 추가되었다. object|resource|array|string|int|float|bool|null와 동일하다.
  • str_contains(), str_starts_with(), str_ends_with()가 추가되었다.
  • array_diff(), array_intersect()도 첫 인자만 갖고도 실행할 수 있다.

호환성 문제 있는 변경

변경점이 많은데 중요하다고 생각되는 것만 아래 정리했다. 전체 목록을 확인하자.

문자열-숫자 비교

숫자와 문자열 비교가 여전히 가능하긴 하지만 조금 달라졌다. 이 변경으로 0 == "숫자가 아닌 것"false로 볼 수 있다.

0 == "0"      // true => true
0 == "0.0"    // true => true
0 == "foo"    // true => false
0 == ""       // true => false
42 == " 42"   // true => true
42 == "42foo" // true => false

매직 메소드 반환 타입 지정

다음과 같은 시그니처가 필요하다.

__call(string $name, array $arguments): mixed
__callStatic(string $name, array $arguments): mixed
__clone(): void
__debugInfo(): ?array
__get(string $name): mixed
__invoke(mixed $arguments): mixed
__isset(string $name): bool
__serialize(): array
__set(string $name, mixed $value): void
__set_state(array $properties): object
__sleep(): array
__unserialize(array $data): void
__unset(string $name): void
__wakeup(): void

중복된 메소드를 갖고 있는 trait

중복된 메소드를 갖고 있는 trait은 심각한 오류가 발생한다. 그 전에는 암묵적으로 처리되었다. 이제는 명시적으로 충돌을 해소해야 한다.

trait Hello {
	public function test() {
		echo "Hello test" . PHP_EOL;
	}
}

trait Bye {
	public function test() {
		echo "Bye test" . PHP_EOL;
	}
}

class Person {
	use Hello, Bye {
		Hello::test insteadof Bye; // 명시적으로 어느 클래스를 사용할지 지정
    // Hello::test as protected; // 또는 접근 제한을 변경
		Hello::test as helloTest; // 또는 alias를 지정
    Bye::test as byeTest;
		// Bye::test as private byeTest; // 또는 접근 제한을 변경하며 alias를 지정
	}
}

$a = new Person;
$a->test(); // "Hello test"
$a->helloTest(); // "Hello test"
$a->byeTest(); // "Bye test"

그 외

  • #[는 더 이상 주석으로 처리되지 않고 어트리뷰트에 사용된다.
  • 메소드 시그니처 불일치로 인한 상속 오류는 심각한 오류로 발생한다. 이전엔 경고만 발생했었다.
  • array_key_exists()가 삭제되었다.
  • 함수에 명시적으로 다른 타입이 적혀 있으나 null 타입을 받는다고 선언되지 않은 경우에는 null을 받지 않는다. 이전엔 암묵적으로 null을 받을 수 있었다.
  • 익명 클래스의 이름은 이제 상속 클래스나 구현 인터페이스의 첫번째 이름을 기준으로 생성된다.
  • 개체 생성자에서 exit()이 호출되어도 소멸자는 호출되지 않는다.
  • {}로 개체 오프셋 접근이 없어졌다. []를 사용한다.
  • assert()는 더 이상 문자열을 평가하지 않는다.

그 외에도 변경된 부분이 많다. 전체 목록을 참조한다.

더 이상 사용되지 않는 기능 (일부)

필수 파라미터 앞에 선언된 기본값은 의미 없어짐

아래 코드에서는 $b가 필수로 필요하기 때문에 $a의 기본값이 의미가 없다. 다만 nullable의 경우는 예외적이다.

function test($a = [], $b) {} // 이전
function test($a, $b) {}      // 이후

function test(A $a = null, $b) {} // 아직 가능함
function test(?A $a, $b) {}       // 추천 방식

usort에서 우주선 연산자 사용 권장

대부분 정렬 함수가 boolean으로 처리했으나 이제는 경고를 표시한다. <=>를 권장한다.

// 다음 코드를
usort($array, fn($a, $b) => $a > $b);
// 다음처럼 사용
usort($array, fn($a, $b) => $a <=> $b);

이외 변경 사항

문서 참조.

PHP 8.1

새 기능

문자열 키와 함께 배열 분해하기

$arr1 = [1, 'a' => 'b'];
$arr2 = [...$arr1, 'c' => 'd']; // [1, 'a' => 'b', 'c' => 'd']

인자 분해 후 명명된 인자 지정하기

foo(...$args, named: $arg);

Enumerations 추가

열거형 상수인 enum이 추가되었다. 자세한 내용은 문서를 참고한다.

enum Suit
{
    case Hearts;
    case Diamonds;
    case Clubs;
    case Spades;
}

Fibers 추가

코드의 실행 흐름을 제어할 수 있는 Fiber 클래스가 추가되었다. 제너레이터는 스택이 없는 반면에 Fiber는 자체 호출 스택을 갖고 있으며 함수 호출이 복잡하고 중첩되더라도 문제 없이 처리한다. 특히 반환 타입이 제한되어 있는 제너레이터와 다르게 어떤 값이든 반환할 수 있다.

$fiber = new Fiber(function (): void {
   $value = Fiber::suspend('fiber');
   echo "다시 시작된 fiber에서 반환된 값: ", $value, PHP_EOL;
});

$value = $fiber->start();

echo "중단된 fiber에서 반환된 값: ", $value, PHP_EOL;

$fiber->resume('test');

// 중단된 fiber에서 반환된 값: fiber
// 다시 시작된 fiber에서 반환된 값: test

myFunc(...) callable 문법 추가

myFunc(...)Closure::fromCallable('myFunc')와 동일하다. ...은 이 문법의 일부이지 줄임표를 쓴 것이 아니다. (처음엔 이상하게 보이더라도 문자열로 된 callable을 사용하던 걸 생각하면 훨씬 나은 접근이다.)

$a = [1, 2, 3, 4];
function filter_odd($number) {
  return $number % 2 === 0;
}

$r = array_filter($a, filter_odd(...));
var_dump($r);
// [2, 4]

교집합(intersection) 타입 추가

&를 사용해 교집합 타입을 만들 수 있다. T1&T2&.... 합집합 타입과 함께 사용할 수 없다.

Never 타입

새로운 반환 타입으로 never가 추가되었다. 이 타입은 exit()이 실행되거나, 예외가 발생하거나, 아니면 종료되지 않아야 한다.

function done(): never {}
done();
// Uncaught TypeError: done(): never-returning function must not implicitly return in...

function done(): never {
  return;
}
done();
// Fatal error: A never-returning function must not return in...

function done(): never {
  exit;
}
done();

new ClassName() 허용 추가

파라미터 기본값, 정적 변수, 전역 상수 초기화, 어트리뷰트 인자 등에 new ClassName()을 사용할 수 있다.

class School {
	public function __construct(protected string $name) {}
}

class HogwartsStudent {
	public function __construct(
		public string $name,
		public School $school = new School('Hogwarts'),
	) {}
}

$a = new HogwartsStudent(name: 'Harry');
var_dump($a);
// object(HogwartsStudent)#1 (2) {
//   ["name"]=>
//   string(5) "Harry"
//   ["school"]=>
//   object(School)#2 (1) {
//     ["name":protected]=>
//     string(8) "Hogwarts"
//   }
// }

이렇게 작성하면 오류가 발생한다.

class HogwartsStudent {
	public School $school = new School('Hogwarts');

	public function __construct(
		public string $name,
	) {}
}
// Fatal error:  New expressions are not supported in this context in...

readonly 프로퍼티 추가

개체 초기화에만 작성할 수 있는 readonly 프로퍼티가 추가되었다. 타입이 지정된 프로퍼티에만 사용할 수 있다. 정적 클래스에서는 지원하지 않는다.

class Test {
   public readonly string $prop;

   public function __construct(string $prop) {
       // 초기화
       $this->prop = $prop;
   }
}

$test = new Test("foobar");
var_dump($test->prop); // string(6) "foobar"

// 어떤 값이든 다시 배정할 수 없다.
$test->prop = "foobar";
// Error: Cannot modify readonly property Test::$prop
class School {
	public function __construct(public readonly string $name) {}
}

$a = new School('MySchool');
var_dump($a);
// object(School)#1 (1) {
//   ["name"]=>
//   string(8) "MySchool"
// }

$a->name = 'MySchool';
// Uncaught Error: Cannot modify readonly property School::$name in...

final 클래스 상수 지원

class Foo
{
    final public const X = "foo";
}

class Bar extends Foo
{
    public const X = "bar";
}
// Fatal error: Bar::X cannot override final constant Foo::X

새로운 함수 (일부)

  • array_is_list(): 배열이 리스트인지 검증하는 함수다. (associative 배열도 존재하기 떄문에)

전체 목록.

호환성 문제 있는 변경

전체 목록.

$GLOBALS 접근 제한

배열 내에 있는 값에 대한 읽기/쓰기도 되고 $GLOBALS를 읽는 것도 가능하지만 이 배열에 직접 요소를 추가하는 것은 더 이상 불가능하다.

$GLOBALS['hello'] = 'hey';
echo $GLOBALS['hello']; // "hey"

$GLOBALS[] = 'hello';
// Fatal error: Cannot append to $GLOBALS in...

상속된 메소드에서의 static 변수 사용

static 변수로 선언하면 상속 트리 내에서 해당 변수가 공유된다.

class StaticCounter {
	public static function count() {
		static $Staticcounter = 0;
		$Staticcounter++;
		return $Staticcounter;
	}
}

class SomeStaticCounter extends StaticCounter {}

var_dump(StaticCounter::count()); // int(1)
var_dump(StaticCounter::count()); // int(2)
var_dump(SomeStaticCounter::count()); // int(3)
var_dump(SomeStaticCounter::count()); // int(4)
var_dump(StaticCounter::count()); // int(5)
class Counter {
	public function count() {
		static $counter = 0;
		$counter++;
		return $counter;
	}
}

class SomeCounter extends Counter {}

$a = new Counter;
$b = new SomeCounter;
var_dump($a->count()); // int(1)
var_dump($a->count()); // int(2)
var_dump($b->count()); // int(3)
var_dump($b->count()); // int(4)
var_dump($a->count()); // int(5)

필수 파라미터를 옵션 파라미터 뒤에 선언

다음처럼 필수 파라미터를 뒤에 선언한 경우 안내가 나온다.

function makeyogurt($container = "bowl", $flavour)
{
  return "Making a $container of $flavour yogurt.\n";
}
// Deprecated: Optional parameter $container declared before
//     required parameter $flavour is implicitly treated as
//     a required parameter in...

이런 함수를 호출하면 ArgumentCountError 예외가 발생한다.

더 이상 사용되지 않는 기능 (일부)

전체 목록 보기.

__serialize(), __unserialize() 없는 Serializable 인터페이스 구현 중단

이전 버전의 PHP만 지원하기 위한 경우를 제외하고는 모두 구현해야 한다.

null을 받을 수 없는 내장 함수에 null 사용 중단

내장 함수의 스카라 타입은 기본적으로 null을 받을 수 있도록 구현되어 있지만 명시적으로 지정되지 않는 이상 중단한다.

var_dump(str_contains("foobar", null));
// Deprecated: Passing null to parameter #2 ($needle) of type string
//             is deprecated

float 배열 키에 대한 묵시적 int 변환 중단

$a = [];
$a[15] = 'a';
echo $a[15.5]; // deprecated, 묵시적 int 변환으로 0.5 잃고 15를 찾긴 함.
// 'a'
echo $a[15.0]; // ok, as 15.0 == 15
// 'a'

void 함수의 참조 반환 중단

function &test(): void {}
// Deprecated: Returning by reference from a void function is deprecated in...

false의 오토비비피케이션(Autovivification) 중단

오토비비피케이션은 배열로 알아서 생성해주는 기능인데 false는 더 이상 되지 않을 예정이다.

$arr = false;
$arr[] = 2;
// Deprecated: Automatic conversion of false to array is deprecated in...

// Undefined나 null에 대해서는 여전히 가능
$arr2[] = 'some value';
$arr2['doesNotExist'][] = 3;

$arr3 = null;
$arr3[] = 2;

지금 있는 회사에서도 정말 오래된 php 페이지가 발굴되어 작업해야 하는 경우가 간혹 있다. 예전에는 그냥 MAMP 같은 패키지를 사용해도 큰 문제가 없었다. 이 회사에서는 기본적으로 포함되어 있지 않은 익스텐션을 사용하는 경우가 많아서 (ldap이라든지) 여기 온 이후로는 docker를 많이 사용하고 있다. 물론 배포 환경은 그대로라서 로컬에서만 주로 사용하고 있다. 배포까지 일괄적으로 사용하지 못하는게 좀 아쉽다.

라라벨과 같은 프레임워크를 사용하고 있으면 이미 공개된 docker도 많고 튜토리얼도 찾기 쉽다. 대신 예전 방식으로 작성된 코드를 기준으로 설명하는건 별로 못본 것 같아서 간단하게 정리하려고 한다. 이 글에서는 용어 없이 슥슥 넘어가는 부분이 많다. 또한 실제로 배포 환경까지 사용하지 않고 로컬에서만 사용하더라도 편리한 점이 많다. 그래서 단순히 로컬에서 php 프로젝트를 돌린다는 것 자체에 한정했다. 이 글에서는 docker-compose로 php-apache, mysql 스택을 빠르게 구성하는 방법 을 살펴본다.

도커에 대해 자세히 알고 싶다면 다음 글을 보자.

이 글에서는 다음 이미지를 사용하고 있다.

여기서 사용한 모든 코드는 haruair/docker-simple-php 리포지터리에서 확인할 수 있다.

docker 설치하기

먼저 docker를 받아 설치한다.

환경을 파일로 작성하기

이제 구축할 환경을 파일로 먼저 만든다. 다음 내용에 따라서 docker-compose.ymlDockerfile을 작성한다.

docker-compose.yml 작성하기

docker-compose.yml은 스마트폰을 예로 들면 어떤 앱을 설치하고 앱을 어떻게 설정할지 정리한 파일이다. 1 docker compose라는 도구가 이 파일을 읽어서 앱을 설치하게 된다.

먼저 다음과 같이 작성한다.

version: '3'

services:

services: 아래로는 설치할 이미지를 작성한다.

#...

services:
  db:
    image: mariadb:5.5
    volumes:
      - "./data:/var/lib/mysql:rw"
    environment:
      - "MYSQL_DATABASE=hello"
      - "MYSQL_USER=hello"
      - "MYSQL_PASSWORD=hello"
      - "MYSQL_ROOT_PASSWORD=root"
    ports:
      - "3306:3306"

먼저 db를 추가했다. image를 보면 mariadb의 공식 이미지를 사용했음을 알 수 있다. 이런 이미지는 docker hub를 통해 공유되는데 앱스토어 정도로 생각하면 되겠다. volumes를 사용해서 앱의 경로에 현재 폴더의 ./data를 연결했다. mysql에 생성되는 데이터베이스는 이 폴더에 저장된다.

대부분의 도커 앱은 environment를 통해서 설정을 할 수 있는데 여기서는 데이터베이스명과 사용자, 비밀번호와 루트 비밀번호를 설정한 것을 볼 수 있다. ports는 현재 컴퓨터의 포트와 해당 앱의 포트를 연결하는 설정이다. 즉, 로컬 호스트의 3306 포트로 접속하면 해당 앱의 3306 포트로 접속하는 것과 동일하게 된다.

#...
services:
  #...
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:4.7
    depends_on:
      - db
    ports:
      - "8000:80"
    environment:
      - "PMA_HOST=db"
      - "PMA_USER=root"
      - "PMA_PASSWORD=root"

다음으로 phpmyadmin을 추가했다. phpmyadmin을 사용하지 않는다면 이 부분은 건너뛴다. mysqlworkbench 등으로 접속해도 사용해서 된다. 나는 웹에서 쓸 수 있는 도구를 선호하는 편이라서 phpmyadmin을 개발 환경에 넣어놓는 편이다.

앞서 db와 크게 차이는 없다. depends_on으로 앞에서 추가한 db에 의존하고 있다고 명시했다. 환경변수에 PWA_HOST를 db라고 설정했는데 docker의 앱은 서로 지정된 이름으로 호스트를 참조할 수 있기 때문이다. 사용자명과 비밀번호는 위에서 설정한 대로 설정해서 접근할 수 있도록 했다.

#...
services:
  #...
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ".:/var/www/html"
    depends_on:
      - db
    ports:
      - "80:80"

마지막으로 web을 추가했다. 앞서 추가했던 이미지와 다르게 build가 포함되어 있다. buildDockerfile을 참고해서 이미지를 생성한다. 대부분 도커 앱은 환경변수로도 제어할 수 있지만 앱 안에 확장 기능을 설치하거나 하는 동작도 가능하도록 Dockerfile을 사용할 수 있다. 자세한 내용은 아래서 다룬다. context는 이미지 생성에서 사용할 기본 경로를 지정하는데 사용하며 다음 내용에서 설명한다. Dockerfile의 명칭이 다르거나 경로가 다르면 dockerfile을 통해 지정할 수 있다. volumes에서 현재 프로젝트의 경로를 앱 내 웹서버의 기본 경로에 연결한다. ports에 보면 앱의 80 포트를 로컬의 80포트와 연결했다. http://localhost/를 입력하면 앱에 있는 서버로 연결되게 된다.

작성을 마친 docker-compose.yml 파일은 다음과 같다. php앱을 구동하기 위해서 필요한 환경을 한 위치에 모두 작성했다.

version: '3'

services:
  db:
    image: mariadb:5.5
    volumes:
      - "./data:/var/lib/mysql:rw"
    environment:
      - "MYSQL_DATABASE=hello"
      - "MYSQL_USER=hello"
      - "MYSQL_PASSWORD=hello"
      - "MYSQL_ROOT_PASSWORD=root"
    ports:
      - "3306:3306"
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:4.7
    depends_on:
      - db
    ports:
      - "8000:80"
    environment:
      - "PMA_HOST=db"
      - "PMA_USER=root"
      - "PMA_PASSWORD=root"
  web:
    build:
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ".:/var/www/html"
    depends_on:
      - db
    ports:
      - "80:80"

Dockerfile 작성하기

앞서 설정에서 작성했던 내용대로 web에 사용하기로 한 Dockerfile을 작성해야 한다. 여기서는 php의 공식 이미지를 기반으로 사용한다. 파일에 다음 내용을 추가하자.

FROM php:5.6-apache

php:5.6-apache를 기반 이미지로 사용했다. Docker hub에 php:5.6-apache로 지정되어 있는 Dockerfile의 내용을 그대로 상속하게 된다. 이제 필요한 패키지와 확장을 설치한다. Dockerfile로 이미지를 생성하면 매 명령마다 중간 이미지를 생성하기 때문에 필수적인 패키지와 확장을 우선순위로 두면 나중에 비슷한 이미지를 생성할 때 더 빠르게 동작한다.

# ...
RUN apt-get update
RUN apt-get install -y git zip

RUN apt-get install -y libpng12-dev libjpeg-dev
RUN apt-get install -y mysql-client
RUN docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr \
&& docker-php-ext-install gd

RUN docker-php-ext-install mbstring
RUN docker-php-ext-install mysqli
RUN docker-php-ext-install pdo
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install opcache

RUN apt-get install -y libssl-dev openssl
RUN docker-php-ext-install phar

RUN apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

이제 apache의 모드를 활성화하고 웹서버에 반영하는 부분을 추가한다.

# ...
RUN a2enmod rewrite
RUN a2enmod headers
RUN apache2ctl -k graceful

여기서 작성한 Dockerfile의 전체 내용은 다음과 같다.

FROM php:5.6-apache

RUN apt-get update
RUN apt-get install -y git zip

RUN apt-get install -y libpng12-dev libjpeg-dev
RUN apt-get install -y mysql-client
RUN docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr \
&& docker-php-ext-install gd

RUN docker-php-ext-install mbstring
RUN docker-php-ext-install mysqli
RUN docker-php-ext-install pdo
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install opcache

RUN apt-get install -y libssl-dev openssl
RUN docker-php-ext-install phar

RUN apt-get clean \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN a2enmod rewrite
RUN a2enmod headers
RUN apache2ctl -k graceful

docker 실행하기 및 종료하기

docker-compose up을 실행한다. 처음 실행하면 이미지를 받고 빌드를 시작하는 것을 확인할 수 있다.

$ docker-compose up
Creating network "dockerhello_default" with the default driver
Pulling db (mariadb:5.5)...
5.5: Pulling from library/mariadb
4269eaa217cc: Downloading [=========>      ]  14.12MB/38.11MB
b5d5817a79f8: Download complete
5a270f0327f3: Download complete
911f94a14d77: Download complete
114588764b3b: Downloading [=============>  ]   5.12MB/5.994MB
d1dcaee5ec4a: Download complete

모든 빌드가 완료되면 현재 폴더에 있는 내용을 http://localhost/를 통해 접속할 수 있다.

앞서 phpmyadmin도 포함했고 포트에 연결했었다. 이제 http://localhost:8000/로 접속하면 phpmyadmin도 잘 실행되고 있는 것을 확인할 수 있다.

docker-compose down으로 종료한다.

docker 컨테이너 다루기

docker-compose로 실행한 후에 실행된 컨테이너를 보기 위해서는 docker ps 명령을 사용할 수 있다.

$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS                            NAMES
bacdc4de6660        dockerhello_web             "docker-php-entrypoi…"   6 minutes ago       Up 5 minutes        0.0.0.0:80->80/tcp               dockerhello_web_1
408909957ec4        phpmyadmin/phpmyadmin:4.7   "/run.sh phpmyadmin"     6 minutes ago       Up 5 minutes        9000/tcp, 0.0.0.0:8000->80/tcp   dockerhello_phpmyadmin_1
4949e0a2d10f        mariadb:5.5                 "docker-entrypoint.s…"   6 minutes ago       Up 5 minutes        0.0.0.0:3306->3306/tcp           dockerhello_db_1

그 외에도 다양한 docker 명령어를 직접 사용해서 컨테이너를 제어할 수 있다. 다음 명령으로 컨테이너에 쉘을 실행하고 접속할 수 있다.

$ docker exec -it dockerhello_web_1 bash
root@bacdc4de6660:/var/www/html# 

다만 이렇게 접속해서 설정을 변경한다면 지금 당장은 문제가 없겠지만 다시 이미지를 생성했을 때는 그 변경 부분이 새 이미지에서는 적용되지 않는다. 도커파일을 사용하면 그런 반복되는 과정을 없에고 파일에 명시적으로 남기는 것으로 매번 동일한 환경을 구성할 수 있다. 설정을 추가하거나 변경할 필요가 있다면 그 명령어는 Dockerfile에 정의하자.


docker-compose를 처음 사용하고 난 후에 docker로 환경을 구성한다는 것이 더 와닿아서 일반적인 설명과는 조금 다른 순서로 적어봤다. 시작부터 대뜸 복잡하게 느껴졌다면 (죄송하지만) 위에 추천한 글을 읽어보자.

여기서는 웹서버가 포함된 php를 사용했지만 fpm을 사용하는 php로 분리하고 별도의 웹서버 컨테이너를 사용하는 것도 가능하다. 반대로 한 이미지에 lamp 스택을 모두 담고 있는 linode/lamp와 같은 경우도 존재한다. 개인적으로는 역할별로 컨테이너를 분리하기를 선호하는데 상황에 맞게 전략을 짜야겠다.

  • 이런 역할을 오케스트레이션(orchestration)이라고 한다. 
  • php에서는 callable 이라는 타입 힌트를 제공한다. 이 타입 힌트는 말 그대로 호출이 가능한 클래스, 메소드, 또는 함수인 경우에 사용할 수 있다. php에서는 타입이 별도의 타입으로 존재하지 않는 대신에 문자열로 처리하고 있어서 다소 모호한 부분도 있다. callable을 타입 힌트로 사용했을 때 어떤 값을 넘길 수 있는지 명확히 알고 있어야 한다.

    function callableOnly(callable $callable): void {
        // callable에 해당하면 다음처럼 호출할 수 있음
        call_user_func($callable);
    
        // 일부를 제외하고는 다음과 같이 호출 가능함
        $callable();
    }
    

    특히 callable은 명확한 제한 없이 열어두고 사용하면 보안 문제 등을 만들어낼 수 있기 때문에 유의해야 한다.

    callable

    다음은 callable에 해당하는 경우로 상당히 다양한 형태로 callable을 정의할 수 있다. 여기서는 callable인지 확인하는 is_callable() 함수를 사용했다.

    함수

    function sayHello() {
        echo 'Hello';
    }
    is_callable('sayHello'); // true
    

    꼭 사용자 정의 함수가 아니더라도 이와 같이 사용할 수 있다. 다만 언어에서 제공하는 구조는 callable에 해당하지 않는다. 예를 들면 isset(), empty(), list()는 callable이 아니다.

    is_callable('isset'); // false
    

    익명 함수

    $hello = function () {
        echo 'Hello';
    };
    
    is_callable($hello); // true
    

    정적 메소드

    class HelloWorld()
    {
        static function say()
        {
            echo 'Hello World!';
        }
    }
    is_callable('HelloWorld::say'); // true
    is_callable(['HelloWorld', 'say']); // true
    is_callable([HelloWorld::class, 'say']); // true
    

    ::class 상수는 PHP 5.5.0 이후로 사용할 수 있는데 해당 클래스의 네임스페이스를 포함한 전체 클래스명을 반환한다. 문자열로 사용하는 경우에는 개발도구에서 정적분석을 수행하지 못하기 때문에 오류를 검출하기 어렵다. 대신에 이 상수를 사용하면 현재 맥락에서 해당 클래스가 선언되어 있는지 없는지 검사해주기 때문에 이런 방식으로 많이 작성한다.

    주의할 점은 정적분석 기능이 없는 개발도구에서는 ::class를 사용해도 문자열을 사용하는 것과 차이가 없다. ::class는 실제로 해당 클래스로 인스턴스를 생성하거나 하지 않기 때문에 autoload와는 상관 없이 동작하기 때문이다.

    echo SomethingNotDefined::class; // "SomethingNotDefined"
    

    대신 런타임에서 is_callable을 사용하거나 callable로 넘겨주는 경우에 정적 메소드의 경우는 autoload를 통해 검사하는 식으로 동작한다.

    클래스 인스턴스 메소드

    class Person
    {
        protected $name;
    
        public function __construct(string $name)
        {
            $this->name = $name;
        }
    
        public function getName(): string
        {
            return $this->name;
        }
    }
    
    $edward = new Person('Edward');
    is_callable([$edward, 'getName']); // true
    

    클래스의 스코프 해결 연산자를 이용한 메소드

    스코프 해결 연산자(Scope Resolution Operator)를 callable에서도 사용할 수 있다. Paamayim Nekudotayim라고 부르는 ::를 의미한다.

    class Animal
    {
        public function getType()
        {
            echo 'Animal';
        }
    }
    
    class Dog extends Animal
    {
        public function getType()
        {
            echo 'Dog';
        }
    }
    
    $dog = new Dog;
    is_callable([$dog, 'parent::getType']); // true
    call_user_func([$dog, 'parent::getType']); // Animal
    
    $callable(); // 이 경우에는 이 방식으로 호출할 수 없음
    

    구현 메소드 대신 부모 클래스의 메소드를 직접 호출할 수 있다. 관계를 뒤집는 좋지 않은 구현이므로 이런 방식에 의존적인 코드는 작성하지 않는다.

    매직 메소드 __invoke()

    __invoke() 매직 메소드가 구현된 클래스는 인스턴스를 일반 함수처럼 호출할 수 있다.

    class PersonSay
    {
        protected $name;
    
        public function __construct(string $name)
        {
            $this->name = $name;
        }
    
        public function __invoke()
        {
            echo "Hello, {$this->name} said.";
        }
    }
    
    $say = new PersonSay('Edward');
    is_callable($say); // true
    call_user_func($say); // Hello, Edward said.
    

    익명 클래스의 경우도 호출 가능하다.

    $say = new class {
        public function __invoke(string $name)
        {
            echo "What up, {$name}?";
        }
    };
    is_callable($say); // true
    call_user_func($say, 'Edward'); // What up, Edward?
    

    이 매직 메소드는 손쉽게 상태를 만들어낼 수 있어서 유용할 때가 종종 있다.


    Iterator를 callable로 사용할 수 있을까?

    Iterator를 넘기면 인스턴스를 넘긴 것으로 인식해서 __invoke() 구현을 확인한다. 즉, Iterator를 루프를 돌려서 사용하지는 않는다.

    Closure vs callable vs 인터페이스

    매개변수로 익명함수만을 받고 싶다면 Closure를 지정할 수 있다. 하지만 익명함수에도 use 키워드로 스코프를 집어 넣거나 global로 전역 변수에 접근하는 방식도 여전히 가능하기 때문에 callable이 아니더라도 callable 만큼 열려 있는 것이나 마찬가지라는 점에 유의해야 한다. 열려있는 것 자체는 문제가 아니지만 Closure, callable은 전달받은 함수가 사용하기 적합한지 판단해야 하는 경우가 생긴다. 예를 들면 매개변수의 숫자라든지, 타입이라든지 사용 전에 확인해야 하는 경우가 있다.

    그래서 단순히 함수 기능이 필요하더라도 계약을 명확하게 들어내야 한다면 인터페이스를 활용하는게 바람직하다. 인터페이스를 사용하면 전통적인 방식대로 인터페이스를 구현해서 사용하면 되겠다. 물론 익명 클래스로 다음처럼 사용할 수 있다. 익명 함수에 비해서는 다소 장황하게 느껴질 수 있지만 사전조건으로 검증해야 하는 내용을 줄일 수 있다.

    interface NamedInterface
    {
        public function getName(): string;
    }
    
    function sayHello(NamedInterface $named): void {
        echo "Hello! {$named->getName()} said.";
    }
    
    sayHello(new class implements NamedInterface {
        public function getName(): string {
            return 'Edward';
        }
    });
    

    모든 방법에는 장단점이 있으므로 필요에 따라 어느 접근 방식을 택해야 할지 결정 해야겠다.

    색상을 바꿔요

    눈에 편한 색상을 골라보세요 :)

    Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.