tag: php

PHPStan으로 PHP 제네릭 활용하기

2022년 8월 29일

여러 동적 타입 언어가 각자의 방식대로 강타입을 지원해가는 과정은 정말 흥미롭습니다. 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 요약 노트

2022년 8월 21일

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
      • 다른 네임스페이스로 클래스를 이동하는데 필요한 부수 작업을 함께 처리함

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

PHP 테스팅 관련 도구 메모

2022년 8월 5일

테스팅 프레임워크

  • 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 열거형(enumerations) 정리

PHP 8.1부터 추가된 열거형 타입 살펴보기

2022년 7월 17일

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>
 */

포트와 어댑터 아키텍처: PHP 예제

2022년 7월 17일

포트와 어뎁터 아키텍처(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

studio: php 패키지 로컬에서 작업하기

2022년 7월 16일

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