php callable 살펴보기

😢 이 페이지는 다음 주소로 변경될 예정입니다.

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';
    }
});

모든 방법에는 장단점이 있으므로 필요에 따라 어느 접근 방식을 택해야 할지 결정 해야겠다.

제네릭 없는 PHP 인터페이스

😢 이 페이지는 다음 주소로 변경될 예정입니다.

PHP를 사용하면서 가장 아쉬운 부분은 인터페이스다. PHP는 인터페이스를 지원하고 있고 이 인터페이스를 활용한 타입 힌트, 의존성 주입 등 다양한 방식으로 적용 가능하다. 하지만 제네릭 타입이 존재하지 않아서 타입 컬렉션 같이 재사용하기 좋은 인터페이스를 만들 수 없다.

물론 이 문제를 해결하기 위한 패키지를 찾아보면 존재하긴 한다. 하지만 인터페이스가 아닌 클래스 구현에 의존하고 있어서 타입 검사가 로직 내부에 포함되어 있다. 간략한 구현을 보면 대략 다음과 같다. 1

<?php
class Collection
{
    protected $typeName = null;
    protected $collection = [];
    public function __construct($typeName)
    {
        $this->typeName = $typeName;
    }
    public function add($item) {
        if (! in_array($this->typeName, class_implements($item))) {
            throw new \TypeMismatchException();
        }
        $this->collection[] = $item;
    }
}

로직 내에 위치한 타입 검사는 런타임에서만 구동되어 실제로 코드가 실행되기 전까지는 문제가 있어도 찾기가 힘들다. 이런 방식의 구현에서 내부적으로는 인터페이스를 사용해서 입력을 검증하고 있지만 결국 메서드의 유무를 확인하는 덕타이핑과 큰 차이가 없어진다. 결과적으로 인터페이스가 반 쪽짜리 명세로 남아있게 된다. 주석을 잘 달아서 다소 모호한 함수 시그니처를 이해하도록 설득해야 한다.

조금 다른 부분이긴 하지만 PHP에서는 Type을 위한 타입이 존재하지 않는 대신 string으로 처리하기 때문에 위 방식조차도 깔끔하게 느껴지지 않는다. 즉, 타입::class로 반환되는 값도 타입이 아닌 문자열이며 메서드 시그니처에 적용할 수도 없다.

물론 매번 인터페이스와 클래스를 작성해서 사용하는 방법도 있겠다.

<?php
interface CollectionVehicleInterface implements CollectionInterface
{
  public function add(VehicleInterface $item);
}

class CollectionVehicle implements CollectionVehicleInterface
{
  public function add(VehicleInterface $item) {
    $this->collection[] = $item;
  }
}

의도대로 인터페이스를 통해 함수의 입력을 명확하게 정할 수 있게 되었다. 인터페이스는 명세를 명확하게 나타낸다. 다만 이런 방식으로는 모든 경우의 수에 대해 직접 작성해야 하는 수고스러움이 있다. 내부 로직은 동일한데 결국 함수 시그니처가 달라지므로 비슷한 코드를 반복해서 작성해야 한다. 이런 문제를 해결하기 위해 제네릭을 활용할 수 있다.

<?hh
namespace Haruair;

interface CollectionInterface<T>
{
  public function add(T $item) : void;
}

class Collection<T> implements CollectionInterface<T>
{
  protected array<T> $collection = [];
  public function add(T $item) : void
  {
    $this->collection[] = $item;
  }
}
?>

hack에서의 제네릭은 항상 함수 시그니처를 통해서만 사용 가능하며 명시적 선언으로 바로 사용할 수 없어 다른 언어의 제네릭과는 조금 다르다. 물론 hack은 다양한 컬랙션을 이미 제공하고 있으며 array에서도 타입을 적용할 수 있다.

요즘 제대로 된 타입 시스템이 존재하는 프로그래밍 언어를 사용하고 싶다는 생각을 정말 많이 한다. 최근 유지보수하는 프로젝트는 제대로 된 클래스 하나 없이 여러 단계에 걸친 다중 배열로 데이터를 처리하고 있다. 배열에서 사용하는 키는 전부 문자열로 관리되고 있어서 키가 존재하지 않거나 잘못된 연산을 수행하는지 판단하기 어렵다. 어느 하나 타입을 통해 자료를 확인하는 법이 없어 일일이 값을 열어보고 확인하고 있다. 물론 지금 프로젝트의 문제가 엉성한 타입에서 기인한다고 보기에는 다른 문제도 엄청 많다. 그래도 PHP에 타입이 존재하는 이상 조금 더 단단하게 사용할 수 있도록 만들었으면 이런 상황에 더 좋은 대안을 제시할 수 있지 않았을까 생각이 든다.

PHP RFC를 보면 기대되는 변경이 꽤 많이 있는데 빈번히 통과되지 않는 기능이 많아 참 아쉽다. 이 제네릭의 경우도 그 중 하나다. 기왕 인터페이스도 있는데 이런 구현도 함께 있으면 좋지 않을까. 정적 타입 언어도 아닌데 너무 많은 것을 바라는건가 싶으면서도 인터페이스도 만들었으면서 왜 이건 안만들어주나 생각도 동시에 든다. 이렇게 딱히 대안 없는 불평글은 별로 쓰고싶지 않다 😕


  1. 이 코드는 실무에서 사용하기 어렵다. 가령 class_implements는 문자열로 전달한 경우에는 해당 문자열을 사용해 클래스 또는 인터페이스를 찾으므로 객체임을 확인하는 코드가 필요하다. 

인터페이스는 클래스 구현과 별도의 프로젝트로 분리해야 하나요?

Interfaces separated from the class implementation in separate projects?를 짧게 번역했다. 이 포스트는 cc-by-sa를 따른다.


인터페이스는 클래스 구현과 별도의 프로젝트로 분리해야 하나요?

Tomas Walek의 질문

현재 중간 규모의 프로젝트를 개발자 3명이서 6개월 넘게 진행하고 있다. 구체적인 구현에서 인터페이스를 분리하자는 결론에 이르렀다. 가장 먼저 인터페이스를 별도의 파일로 보관하기로 했다.

추가적으로 데이터를 더 분리하기 위해서 인터페이스 .CS 파일과 헬프 클래스 .CS 파일(이 인터페이스를 사용하는 퍼블릭 클래스나 enum 등)을 담은 프로젝트(CSPROJ)를 만들었다. 그리고 팩토리 패턴이나 구체적인 인터페이스 구현, 다른 “워커” 클래스 등을 별도의 프로젝트(CSPROJ)로 만들었다.

어떤 클래스든 인터페이스를 구현하는 개체를 생성하려면 그 자체만으로 구현하지 않고 인터페이스와 퍼블릭 클래스를 포함하는 첫 번째 프로젝트로 분리한 다음에 해당 프로젝트를 포함하는 방식으로 작성했다.

이 해결책은 큰 단점이 있다. 어셈블리 수가 2배로 늘게 된다는 점인데 모든 “일반” 프로젝트가 하나의 인터페이스 프로젝트와 하나의 구현 프로젝트를 포함하게 되기 때문이다.

당신의 추천은 무엇인가? 각각 프로젝트 자체에 인터페이스를 보관하는 것보다 인터페이스 만을 위한 별도의 프로젝트 하나를 갖는 것이 좋은 생각인가?


Wim Coenen의 답변

  1. 독자적 인터페이스 (Standalone interfaces)는 프로젝트 나머지와 소통할 필요 없이 사용할 수 있도록 제공하는 목적에서 작성한다. 이런 인터페이스는 단일 항목으로 “인터페이스 어셈블리(interface assembly)”에 넣게 되고 프로젝트 내 모든 어셈블리가 참조할 것이다. ILogger, IFileSystem, IServiceLocator가 전형적인 예시다.
  2. 클래스 결합 인터페이스 (Class coupled interfaces)는 오직 프로젝트 내의 클래스와 사용하는 맥락에서만 이해가 되는 경우다. 이 인터페이스는 의존성을 갖는 클래스와 동일한 어셈블리에 포함한다.

예를 들어보자. 도메인 모델이 Banana 클래스를 갖고 있다고 가정한다. 바나나를 IBananaRepository 인터페이스를 통해서 얻을 수 있다면 이 인터페이스는 바나나와 밀접하게 결합된 상황이다. 이 경우에는 바나나에 대해 알지 못하고서는 이 인터페이스를 구현을 한다거나 이 인터페이스를 사용하는 일이 불가능하다. 그러므로 이 인터페이스는 바나나 어셈블리와 함께 위치하는 것이 논리적이다.

앞 예제는 기술적인 결합이지만 논리적으로 결합하는 경우도 있다. 예를 들면, IFecesThrowingTarget 인터페이스는 Monkey 클래스에 기술적인 연결 고리로 선언되어 있지 않더라도 Monkey 클래스와 함께 사용하는 경우에만 유의미할 수 있다.

내 답변은 개념에 의존적이지 않으며 클래스가 약간 결합하는 정도는 괜찮다고 생각한다. 모든 구현을 인터페이스 뒤에 숨기는 일을 실수일 것이다. 의존성을 주입하거나 팩토리를 통해 인스턴스를 생성하지 않고 그냥 클래스를 “new 키워드로 생성”하는 것도 괜찮을 수도 있다.