JetBrains PHPverse 2025을 보면서 짧게 적었다. PHP 30주년을 맞아 즐겁고 축하 분위기 속에 진행되었다. 다년간 서로 영향을 주고 받으면서 성장한 라라벨과 심포니, 새로운 방향성을 제시하고 있는 FrankenPHP와 발족 이후 풍성한 활동을 하고 있는 PHP 재단까지 PHP의 현재를 폭넓게 살펴볼 수 있었던 시간이었다.

Table of Contents

FrankenPHP: Reinventing PHP for the Modern Web - Kévin Dunglas

FrankenPHP 에 대한 간략한 소개와 함께 현재 개발 상황을 공유했다. FrankenPHP는 go로 작성된 현대적 PHP 앱서버로 요즘 뜨거운 프로젝트 중 하나로 최근엔 PHP 재단의 지원도 받기 시작했다.

  • NGINX+FPM 또는 Apache+mod_php를 대체
  • 상당한 성능 향상 (~25%)
  • 쉬운 배포: 도커도 가능하고 바이너리애 임베딩도 가능
  • 가장 빠른 PHP 엔진
  • 103 early hints 지원
  • 실시간 데이터 전송 지원 (Mercure 사용, Server-sent events 참조)
  • 대부분 프레임워크 문제 없이 지원
  • HTTPS 자동화
  • HTTP/2, HTTP/3 네이티브 지원
  • Brotli, zstandard, gzip 압축 지원
  • 구조화된 로그 지원
  • prometheus/openmetrics, opentelementry 등 메트릭과 트레이싱 지원
  • 클라우드 네이티브

Go로 작성된 만큼 go의 가볍고 강력한 동시성, 성능의 혜택을 그대로 누릴 수 있게 됐다. 또한 go의 Caddy 웹서버를 기반으로 하고 있어서 Caddy의 모든 기능과 모듈을 사용할 수 있게 되었다. 거인의 어깨에 서서 고성능 달성!

$ docker run -v $PWD:/app \
    -p 80:80 -p 443:443 -p 443:443/udp \
    duglas/frankenphp

정적 바이너리로도 사용 가능해서 php 코드와 함께 번들해서 배포도 가능하게 되었다. homebrew나 리눅스 패키지로도 이미 다 배포되어 있어 손쉽게 사용할 수 있다.

$ curl https://frankenphp.dev/install.sh | sh
$ mv frankenphp /usr/local/bin/
$ frankenphp php-server -r public/
$ composer install --no-dev -a
$ git clone https://github.com/dunglas/frankenphp && cd frankenphp
$ EMBED=/path/to/my/app ./build-static.sh
$ ./dist/frankenphp-<os>-<arch> php-server
$ ./dist/frankenphp-<os>-<arch> php-cli my-command

다른 특징은 워커 모드의 지원인데 goroutines과 채널을 활용해서 더 빠르게 요청을 처리할 수 있다고 한다. 근래 대부분의 프레임워크와 라이브러리, 서비스(Symfony, Laravel Octane, Platform.sh, Laravel Cloud, Clever Cloud 등)에서 이미 잘 지원하고 있다고 한다.

frankenphp {
  worker {
    file ./public/worker.php
  }
}

103 Early Hints도 지원한다. 웹서버의 응답이 완전히 처리되기 전에는 브라우저에서 html을 받을 때까지 기다리게 되는데 정보성 응답으로 에셋을 미리 받을 수 있도록 돕는다.

// 바닐라
header('Link: </styles.css>; rel=preload; as=style');
header_send(103);
echo << 'HTML'
<!doctype html>
<title>Hello FrankenPHP</title>
<link rel="stylesheet" href="style.css">
HTML;

// symfony, 또는 laravel
$r = new Response();
$r->headers->set('Link', '</style.css>; rel=preload; as=style');
$r->sendHeaders(103);
// ...

go로 확장을 작성하고 php에서 쉽게 접근이 가능한 것도 재미있는 부분이었다. 물론 확장을 사용하기 위해서는 정적 컴파일이 필요하다. Zend 엔진 타입을 Go 타입으로 변환하는 등의 헬퍼를 제공해서 사용성을 높였다. 지원하는 타입은 계속 추가하는 과정에 있다고 한다.

//export_php:function background_hello(string $name): void
func BackgroundHello(*C.zend_string foo) {
  go func() {
    time.Sleep(10*time.Second)
    slog.Info("Hello", "name", franken.GoString(unsafe.Pointer(foo))
  }
}

위 함수를 다음처럼 간편하게 확장으로 빌드할 수 있다.

$ go mod init example.com/myextension
$ cd myextension
$ frankenphp extension-init myextension.go
 ...
$ xcaddy build \
    --output frankenphp \
    --with github.com/dunglas/frankenphp/caddy \
    --with example.com/myextension
<?php
background_hello("Alexandre Daubois"); // 직접 사용 가능

Symfony: Current State and Future Plans - Nicolas Grekas

Symfony도 라라벨과 함께 PHP 생태계에 큰 역할을 하는 프로젝트로 강력한 PHP 프레임워크이기도 하면서 작고 강력하면서도 다양한 라이브러리를 만들고 있다.

  • 지속적인 발전과 하위 호환성: 반년 주기로 새 기능 배포, 안정성 5년 보장, 대규모 업그레이드는 레거시 코드 제거에 관한 부분 (deprecation을 미리 정리하는 것으로 대비하기)
  • 개발자 경험 향상
    • 다양한 개발용 CLI 지원 (symfony)
    • API 플랫폼 (#[ApiResource])
    • ux.symfony.com 별도 빌드 없이 사용 가능한 UI 컴포넌트 (Stimulus랑 Turbo 사용)
    • autowiring & autoconfiguration: yaml 설정 없이도 알잘딱 붙음, 물론 어트리뷰트도 활용 가능
      <?php
      class ChannelBroadcaster
      {
        public function __construct(
          #[AutowireIterator(ChannelInterface::class)
          private iterable $channels
        ) {
        }
      }
      
    • 다양한 개발도구 지원: dd(), dump(), 에러페이지, 디버그 툴바, bin/console debug:router, debug:autowiring 등등
    • 쉽게 설정 가능한 앱: %env(...)%로 동적 환경변수 설정, 시크릿 관리, 워커모드, 컴파일, SymfonyCloud
  • 단독 컴포넌트: 엄청 많음. 문서 참조
    • Messenger: 메시지 버스 + 큐 시스템. 비동기 잡, CQRS, 이벤트 관리
      • Doctrine, RabbitMQ, Redis 등과 잘 동작
    • HttpClient: 비동기, 스트리밍 가능 재시도, 캐시, 프로파일러, SSE 지원 안정성, 실패 안전성, Interoperable (이식성)
    • JSONStreamer: json_encode/json_decode와 다르게 스트림 가능
    • ObjectMapper: DDO 작성 돕는
    • Twig 확장: 쉽게 twig 헬퍼 연결 가능
    • Console 확장: 어트리뷰트로 쉽게 정의 가능

이외에도 최신 PHP 기능에도 잘 동작하고 PHP에 기여를 많이 한다, 문서화도 많이 신경 쓴다, 큰 규모의 건강한 커뮤니티가 잘 운영되고 있다는 등 이야기로 정리했다.

행사 디스코드에서 누가 Symfony 직장 잡기 힘들지 않냐고 푸념했는데 자기네는 Symfony만 엄청 뽑는다며 온갖 유럽 국가 이름이 쏟아졌다.

Building MCP Servers With PHP - Marcel Pociot

LLM이 어떻게 MCP 서버를 활용해서 서비스에 접근하는지 설명하고 php-mcp/server를 사용해서 MCP 서버를 작성하는 예제를 선보였다.

  • Tinkerwell: REPL 도구
  • Expose: 로컬 터널링, ReactPHP 사용
  • Laravel Herd
  • MCP (Model - Context - Protocol)
    • LLM이 MCP 서버를 통해 컨텍스트를 이해
    • 컨텍스트 타입
      • MCP 클라이언트(예를 들면 JetBrains IDE, Claude Desktop)에서 처리할 수 있는 타입들
      • tools, resources, prompts, sampling
    • JSON-RPC 프로토콜로 MCP 클라이언트-서버 소통
      • 사용 가능한 Transports: 표준 입출력 (stdio), Server-Sent Events (SSE), 웹소켓
    • e.g. claude에 정의해서 사용하기 @automattic/mcp-wordpress-remote
  • MCP 직접 만들기: 많은 패키지 있지만 여기서는 php-mcp/server 사용
    composer require php-mcp/server
    
    <?php
    namespace App\MCP;
    
    use PhpMcp\Server\Attributes\McpTool;
    use App\Models\DownloadStatistic;
    
    class FetchDownloads
    {
      /**
       * Retrieve the number of Herd downloads.
       *
       * @return int The number of downloads.
       */
      #[McpTool(name: 'fetch_downloads')]
      public function count(): int
      {
        return DownloadStatistic::count();
      }
    }
    
    #!/usr/bin/env php
    <?php
    declare(strict_types=1);
    require_once __DIR__ . '/vendor/autoload-php';
    
    use PhpMcp\Server\Server;
    use PhpMcp\Server\Transports\StdioServerTransport;
    
    try {
      $server = Server::make()
        ->withServerInfo('My MCP Server', '1.0.0')
        ->build();
      $server->discover(
        basePath:__DIR__,
        scanDirs: ['app/MCP'],
      );
      $transport = new StdioServerTransport();
      $server->listen($transport);
      exit(0);
    } catch(\Throwable $e) {
      fwrite(STDERR, "[MCP SERVER CRITICAL ERRPR]\n" . $e . "\n");
      exit(1);
    }
    
    // claude_desktop_config.json
    {
      "mcpServers": {
        "php-mcp": {
          "command": "php",
          "args": [ "/path/to/mcp-server.php" ]
        }
      }
    }
    
  • McpResource로 리소스 정의 제공 가능

How AI Is Changing the Tech Industry - Cheuck Ting Ho

  • 바이브코딩은 죄악이 아니지만 무책임한 코딩은 죄악임
  • LLM을 활용하는 워크플로우: 액션 플랜 수립/수행하고 결과를 평가하기
  • 개인정보와 사용 동의 유의하기, AI의 응답에 비판적으로 접근, 맞는 내용인지 확인하기, 저작권 유의하기, AI 사용하는 것에 대해 정직하기, AI 책임감 있게 사용하기

Laravel: Q&A With Its Creator - Taylor Otwell

  • 엔터프라이즈 닷넷, COBOL으로 개발 경력 시작, 당시 환경적으로 쉽게 사용할 수 있던 PHP를 사이드로 사용하다가 자연스럽게 프레임워크로 성장했고 그게 라라벨의 시작점이라고.
  • php 5.3에서 중요한 현대적 기능이 소개된 덕분에 시너지를 얻어 라라벨이 큰 성장을 할 수 있었다고 (5.3의 네임스페이스, 익명함수, 지연 바인딩 등, 5.4의 traits 등)
  • 라라벨에서 쉽게 해결할 수 있는 대부분의 문제는 솔루션이 존재. 새로운 무언가를 만들어내는 일에서 어려움을 느끼지만 그래도 때가 되면 자연스럽게 새로운 것이 도출되는 경험을 함.
  • 서버리스에 대한 생각은 서버를 전혀 고려하지 않고도 개발할 수 있는, 어디 로그인해서 뭘 생성하고 하는 것 조차도 숨길 수 있으면 좋겠다고 생각하는 편. 아직도 Laravel Forge에서는 많은 부분을 알고 있어야 하는 상황이긴 하지만 최대한 쉽고 간단하게 사용할 수 있도록 하기 위해 노력하고 있음 (Laravel Nightwatch는 이 날 발표된 프로젝트)
  • 1인 기업으로 시작했지만 2024에는 10여명 규모였고 지금은 70여명 규모로 성장. (개발 외에도 디자인, 마케팅, 운영 등) 다양한 커뮤니티, 중소규모, 대기업 엔터프레이즈까지 지원하기 위해 노력하고 있음.
  • FrankenPHP에 긍정적. 라라벨에서도 적극 돕고 있음.
  • PHP는 오래 사용되어 왔기 때문에 AI, LLM에서도 라라벨이나 PHP 코드를 잘 작성함. MCP 등 로컬 환경에서 PHP를 잘 작성할 수 있도록 가장 최신 명세를 제공하고 최근 버전에서 사용하고 있는 코딩 규칙을 사용할 수 있게 하는 실험이 진행중.
  • 심포니를 프레임워크로 직접 사용한 적은 없지만 컴포넌트를 많이 사용하고 있음. PHP는 성숙하고 잘 관리되고 있는 두 프레임워크 덕분에 좋은 생태계가 조성됨.
  • 교육에 관해:
    • PHP 쉽게 시작할 수 있도록 php.new 개발에 관여. 어떤 환경에서든 쉽게 설치 가능.
    • 라라벨 부트캠프를 새로운 도구/프레임워크에 맞게 업데이트 진행중.
    • 입문자가 쉽게 읽고 사용할 수 있는 문서화에 집중.
  • API 구축에 촛점을 둔 Lumen이 있었는데 더이상 지원하지 않고 라라벨 11에서 기본 파일 구조를 대폭 줄였음. 프로젝트 생성에서 --api를 사용하면 블레이드 등 API 구축에서 불필요한 부분을 간소화. Lumen에서 얻은 교훈은 가벼운 구조에 집중한 프레임워크지만 규모가 커지면 결국 라라벨에 있는 기능이 필요해질 때가 있는데 그걸 지원하는게 오히려 더 복잡한 구현을 야기한다는 점을 배움.

The Future of PHP Education - Jeffrey Way, Povilas Korop, Kevin Bond

Laracasts의 Jeffery Way, LaravelDaily의 Povilas Korop, SymfonyCasts의 Kevin Bond가 PHP 교육에 관한 패널 토의를 진행했다.

  • 과거의 학습 방식과 비교하면 근래 교육 환경은 많이 변화되었음. 무엇보다도 AI 도구들이 즉각적인 피드백을 제공한다는 점에서 입문자 수준의 질문은 거의 사라지고 고급 주제의 질문이 늘어남.
  • 컨텐츠 소비에 있어 긴 코스보다 짧고 핵심적인 영상을 선호. 교육자의 입장에서는 길게 자세히 가르쳐야 할지, 아니면 짧고 빠르게 접근해야 할지 고민이 됨.
  • 입문 수준의 컨텐츠는 여전히 중요하지만 접근 방식과 매체를 달리해야 함. 짧은 튜토리얼, 실무 워크플로우, 문제 해결 중심.
  • PHP 언어 자체에 대한 부정적인 인식은 분홍 코끼리 효과, 즉 분홍 코끼리를 떠올리지 말라는 말에 분홍 코끼리를 먼저 떠올리는 것과 같은 효과가 있음. PHP 자체를 홍보하는 접근보다 Laravel이나 Symfony와 같은 실용적인 도구를 중심으로 관심을 유도하는 것이 오히려 효과적.

PHP Foundation: Growing PHP for the Future - Roman Pronskiy, Gina Banyard

PHP 재단에 대한 개괄적인 이야기와 현재 무엇이 진행되고 있는지를 설명했다.

  • 재단 출범 이전의 역사적 배경
    • 1998 PHP3 리부트: 확장 아키텍처를 완전히 재작성
    • 2000 PHP4 객체 모델: 불완전한 객체지향 프로그래밍 지원
    • 2004 PHP5: Zend Engine 2로 제대로 된 객체지향 프로그래밍 지원
    • 2007-2010 PHP6: 결국 완료되지 못하고 폐기됨
    • 2014 PHP7 이전 성능 문제: HHVM의 상승세
    • 2021 지속가능성 문제: 펀딩 없음, 유급 개발자는 한 명 남음, 불투명한 미래, 규모 있는 커뮤니티 부재
      • JetBrains 및 여러 회사와 함께 The PHP Foundation를 시작하게 됨
      • 언어가 지속될 수 있도록 펀딩 모델과 예산, 잘하는 메인테이너 확보
  • 개발인력 10명, 보드 멤버 8명, 커뮤니티 맴버
  • 2024년 독일 정부에서 펀딩을 받아 중요 프로젝트 수행
    • 코드 전체에 대한 보안 감리 수행 및 완료
    • FPM 테스팅
    • 문서화 개선 (+ 보안 리뷰)
    • PECL 재작업 (packagist, 보안 향상 및 새 인스톨러 PIE 개발)
    • 새로운 기능들
      • lazy objects, property hooks, asymmetric visibility, BCMath
      • 많은 RFC, 재단에서 업데이트 및 지속 지원
    • 재단 내에서의 프로젝트 외에도 커뮤니티 기여가 상당히 많아짐
    • 유럽 연합 집행위원회의 사이버 복원력법(Cyber Resilience Act) 워킹 그룹에 회원으로 참여
  • 2025년 목표: 파트너십과 마케팅, 커뮤니티 성장 및 언어 향상
    • FrankenPHP도 재단에서 지원
    • 현재 재단에서 개발중인 기능들
      • BC Math 최적화
      • XSSE 라이브러리 (streaming SIMD Extensions) API (ARM 관련인듯)
      • URI API: WhatWG, RFC 3986 명세 지원
      • PIE (PHP Installer for Extensions) 확장 설치를 위한 인스톨러
        • 발표 당일 첫 안정 버전 출시 v1.0
      • 꼬리 호출 관련 구현 (개념 증명)
      • 패턴 매칭
      • SAPI 테스팅 프레임워크
      • 내장 JSON Schema 지원
      • 새로운 DateTime API
      • Windows용 php 빌더
      • PIE 확장 개발을 위한 GitHub Action flow
      • 리눅스 패킷 필터링
      • 인터페이스 수준에서의 제한적인 추상 제네릭 타입 (개념 증명)

The PHP Foundationd에서 게시한 State of Generics and Collections를 번역했습니다.

Table of Contents

제네릭과 컬렉션 현황

제네릭은 오랜 기간 동안 많은 PHP 개발자가 원했던 기능 중 하나입니다. 이 주제는 매번 Q&A 세션에서 언급되기도 합니다. 이 주제에 관해서 현재 상황과 함께 다양한 접근 방식에 대해 논의해보려고 합니다.

완전히 구체화된 제네릭

제네릭이 있다면 클래스를 선언할 때 프로퍼티와 메소드에 플레이스홀더를 활용할 수 있습니다. 이렇게 선언된 제네릭은 클래스가 인스턴스로 생성될 떄 타입이 결정되게 됩니다. 이 방식은 코드 재사용성을 높이고 여러 데이터 타입에서 타입 안정성을 제공하게 됩니다. "구체화된" 제네릭은 제네릭 타입에 대한 정보가 클래스 정의에 사용될 뿐만 아니라 제네릭 요구사항을 런타임에서도 강제하게 됩니다.

PHP의 문법으로 보면 이렇습니다.

class Entry<KeyType, ValueType>
{
  public function __construct(protected KeyType $key, protected ValueType $value)
  {
  }

  public function getKey(): KeyType
  {
    return $this->key;
  }

  public function getValue(): ValueType
  {
    return $this->value;
  }
}

new Entry<int, BlogPost>(123, new BlogPost());

클래스로 인스턴스를 생성하면 다음과 같이 제네릭 타입으로 선언한 KeyTypeint로, ValueTypeBlogPost로 결정되어 해당 개체는 다음 클래스 정의와 같이 동작하게 됩니다.

class IntBlogPostEntry
{
  public function __construct(protected int $key, protected BlogPost $value)
  {
  }

  public function getKey(): int
  {
    return $this->key;
  }

  public function getValue(): BlogPost
  {
    return $this->value;
  }
}

그동안 이 기능을 추가하기 위한 여러 번의 시도가 있었습니다. 2020/2021년에는 Nikita Popov의 가장 포괄적인 실험 구현이 있었고 2016년의 RFC 초안, 그리고 이 주제에서 남아있는 과제를 정리한 레딧 포스트 등에서 그 시도를 확인해볼 수 있습니다.

2024년에 PHP 파운데이션의 지원 아래, Arnaud Le Blanc이 Nikita Popov의 구현을 출발점으로 이 작업을 다시 시작했습니다. 비록 많은 기술적인 이슈가 해결되긴 했지만 여전히 많은 부분이 풀리지 않은 상태입니다.

가장 큰 도전 과제는 타입 추론입니다. 제네릭을 활용하는데 있어서 코드가 장황해지는 경향이 있는데 매번 제네릭 타입이 참조될 때마다 타입 인자를 필요로 하기 때문입니다. 다음 예시를 보면 명확합니다.

funciton f(List<Entry<int,BlogPost>> $entries): Map<int, BlogPost>
{
  return new Map<int, BlogPost>($entries);
}

function g(List<BlogPostId> $ids): List<BlogPost>
{
  return map<int, BlogostId, BlogPost>($ids, $repository->find(...));
}

타입 추론은 이처럼 장황한 부분을 컴파일러에서 적절한 타입을 자동으로 적용하는 방식으로 해결할 수 있습니다. 위 에시에서는 컴파일러가 반환 값인 new Map()map()을 보고서 알맞은 반환 타입을 자동으로 정할 수 있습니다. 다만 이런 접근 방식은 PHP에서 어렵습니다. Nikita에 따르면 PHP의 컴파일러는 주로 한번에 파일을 하나씩만 보는 등 아주 제한적으로 코드베이스를 읽기 때문에 쉽지 않습니다.

다음 예시를 고려해봅니다.

class Box<T>
{
  public function __construct(public T $value) {}
}

new Box(getValue());

이 경우에는 getValue() 표현이 런타임에서 실제로 함수가 호출되기 전까지는 어떤 타입인지 확인할 수 없기 때문에 new Box(...)T를 컴파일 단계에서 추론하기 어렵습니다.

T를 런타임 기준으로 함수의 반환값을 사용할 수는 있겠지만 결과적으로 안정적이지 못한 타입 선언이 됩니다. 앞서 예시에서는 new Box()getValue()의 반환값 구현에 의존적인 상태가 되는데요. 의도와 다르게 불변적인 형태가 되어서 실제 코드에서는 그다지 유용하지 못한 형태가 될 수 있습니다.

interface ValueInterface {]
class A implements ValueInterface {}
class B implements ValueInterface {}

function getValue(): ValueInterface
{
  return new A();
}

function doSomething(Box<ValueInterface> $box)
{
}

$box = new Box(getValue()); // 런타임: Box<A>, 정적: Box<ValueInterface>
doSomething($box); // Box<A>가 아닌 Box<ValueInterface>가 필요

타입은 컴파일 단계에서 구현에 의존하지 않은 정적 정보를 제공할 때 가장 유용합니다.

참고: 이 예제에서 Box는 불변이며 제네릭 클래스의 형태로 자주 구현됩니다. XY 타입이 어떤 관계이든지 간에 Box<X>Box<Y>의 서브타입도, 수퍼타입도 아니라는 의미인데, 위 예시에서 Box<A>Box<ValueInterface>의 서브타입도 아니고 doSomething()Box<A>를 파라미터로 받을 수도 없다는 뜻입니다.

제네릭 클래스는 타입 플레이스홀더가 읽기(반환 타입 등)과 쓰기(파라미터 타입 등)에 함께 사용되면 불변이라고 합니다. 프로퍼티 타입은 읽기와 쓰기 모두에 위치할 수 있습니다.

다음 예시를 보면 좀 더 명확합니다.

function changeValue(Box<ValueInterface> $box)
{
  $box->value = new B();
}

changeValue() 함수는 Box<ValueInterface>를 파라미터로 받기 때문에 ValueInterface의 어떤 서브타입이든 $box->value의 타입으로 배정될 수 있어야 합니다. 하지만 Box<A>를 전달한 후에 (AValueInterface의 서브타입) ValueInterface지만 A가 아닌 타입을 전달하게 되면 이 계약 관계가 준수되질 않습니다.

다른 제네릭 언어에서의 일반적인 해결 방법은 타입 파라미터에 직접 어떤 변성(variant)인지 직접 지정하는 방식으로 해결합니다. 일반적으로 in 또는 out등 단방향으로만 움직이도록, 파라미터나 반환 타입에 지정하는 방식을 활용합니다. 이런 방식으로 반공변성이나 공변성을 명시적으로 지정할 수 있습니다.

타입 추론의 하이브리드 접근 방식

이런 문제를 해결하기 위해서는 하이브리드 접근 방식이 필요한데, 즉 모든 정보가 가능하지 않은 컴파일 타임에 제네릭 파라미터에 대한 정적 타입 추론을 구현할 수 있어야 합니다. 다시 말하면 컴파일 타임에서 알 수 없는 타입을 심볼로만 표현하는 방식입니다 예를 들어 getValue()fcall<getValue> 식으로 표현할 수 있습니다. 심볼릭 타입은 런타임에서 함수와 클래스가 모두 불려온 이후에 해석되며 런타임에서의 전체 분석을 필요로 하기 때문에 일정량의 실행 비용을 소비하게 됩니다. 물론 이 동작은 상속이 캐시되는 것처럼 요청을 처리하는 동안에는 캐시를 통해 처리될 수 있습니다.

개념 증명은 이미 구현되었고 제네릭 타입 파라미터에서 데이터 흐름 기반, 지역적, 또는 단방향의 타입 추론은 PHPStan/Psalm의 동작 방식처럼 동일하게 작동합니다. 이 접근 방식이라면 다른 타입 추론도 실험해볼 수 있게 됩니다.

성능 고려사항

제네릭에 있어 다른 고민거리는 바로 성능에 미치는 영향입니다. 벤치마크를 관찰한 결과,

  • 제네릭 유무가 제네릭이 없는 코드에서 성능 영향을 미치지 않음
  • 단순한 제네릭 코드는 특수 코드와 비교해서 1~2% 정도의 크지 않은 성능 저하가 발생

하지만 이후에 얘기하게 될 union과 같은 복합 타입의 경우는 타입 체크에 초선형(superlinear) 시간 복잡도를 보이기 때문에 잠재적으로 상당한 성능 감소를 야기할 수 있습니다. 예를 들면 A|BB를 받을 수 있는지 확인하는 것은 선형적이지만 Box<A|B>()Box<A|B>()와 확인하게 되면 O(nm)이 됩니다.

초선형 복잡도는 복합 타입을 합치는 중에 심볼릭 타입을 확인하려고 해도 발생할 수 있습니다.

이후 방향

구체화된 제네릭은 다음과 같은 연구 과제가 남아있습니다.

  • 복합 타입, 극단적인 경우 어떤 영향이 있는지 평가 필요
  • 인라인 캐시에서 타입 체크를 구현하고 복합 타입을 처리하는 더 똑똑한 알고리즘이 있는지 연구
  • 즉시 오토로딩(eager-autoloading) 또는 상속 캐시와 같은 방식으로 심볼릭 타입의 양을 줄이는 방법을 탐구

컬렉션

제네릭의 주된 사용 케이스로 자주 언급되는 부분은 타입 배열입니다. PHP에서는 스위스 군용칼 같은 배열 타입이 사용 또는 과용되는 데는 많은 이유가 있습니다. 하지만 현재는 배열에 키 또는 값에 타입을 강제할 수 있는 방법은 존재하지 않습니다.

병렬 프로젝트에서는 전용 컬랙션 문법을 사용하는 방식으로 완전한 제네릭보다는 부족하지만 그래도 도움이 될 수 있습니다.

컬랙션은 집합, 목록, 사전 등의 형식으로 주로 활용됩니다. 집합과 목록의 경우에는 값에 대한 타입이 정의되며 사전 형식은 키와 값 모두에 타입이 지정됩니다. 다음 같은 식의 문법을 활용 할 수 있겠습니다.

class Article
{
  public function __construct(public string $subject) {}
}

collection(Seq) Articles<Article>
{
}

collection(Dict) YearBooks<int => Book>
{
}

다음처럼 목록을 인스턴스로 만들어서 일반 클래스처럼 사용할 수 있게 됩니다.

$a1 = new Articles();
$b1 = new YearBooks();

목록과 사전 형식은 자동으로 많은 메소드가 정의되며 PHP에서 array_* 함수처럼 제공되었던 것들이 기본적인 기능으로 제공됩니다. 컬렉션에 정의된 메소드를 사용해 개체를 추가하거나 수정하려 한다면 컬렉션의 정의된 바에 따라 키와 값의 타입을 맞춰야 합니다.

위 예시에서 YearBooks 사전에 add() 메소드를 사용한다면 키는 int 타입만 사용할 수 있고 값은 Book 타입 인스턴스만 가능합니다. 주요 조작 메소드 (add, get, unset, isset)와 ArrayAccess 스타일의 오버로드 동작도 여전히 사용 가능하며 연산자 오버로드도 적용 가능할 수 있습니다.

이 방식의 단점은 컬랙션을 직접 선언해야 한다는 점입니다. 다음 예시에서 볼 수 있는 것처럼 단일 라인 선언이 별도의 파일에 각각 컬렉션을 위해 존재해야 합니다.

다른 우려 사항은 잠재적으로 메모리 사용량이 높다는 점인데 각 클래스 PHP가 모든 연관 메소드 목록을 포함한 해당 클래스 항목을 계속 들고 있어야 한다는 점입니다.

세번째 우려할 만한 부분은 instanceof/is-a 관계가 호환 가능한 유형의 컬렉션 사이에서 존재하지 않는다는 점입니다.

class A {}
class B extends A {}

seq As<A> {}
seq Bs<B> {}

new B() instanceof A // true
new Bs() instanceof As // false

또는

namespace Foo;
seq As<A> {}

namespace Bar;
seq As<A> {}

namespace;
new Foo\As instanceof Bar\As; // false

컬렉션은 제네릭에 비해서는 부족한 면이 있으며 훨씬 복잡도를 높히는 경향이 있지만 제네릭의 사용 케이스 대부분을 대체할 수 있습니다. 다만 이 구현은 제네릭에 비해 훨씬 간단하며 이 실험 브랜치에서 사용해볼 수 있습니다. 하지만 완전한 제네릭을 구현할 수 있다면 이런 컬렉션 구현 방식보다 제네릭을 활용하는 것이 훨씬 선택할 만한 방향입니다.

Larry Garfield는 다른 언어에서 컬렉션 API가 얼마나 광범위한지 연구를 수행하기도 했습니다. 아직 대략적이긴 하지만 "모든 것을 포함"하는 방향으로 합의되었고 아마도 여러 개의 개별 인터페이스로 나뉘어질 예정입니다. 앞으로의 대략적인 방향은 문서 끝에서 제시하는 방식을 따라갈 것 같습니다.

컬렉션 패치는 https://github.com/php/php-src/pull/15429에서 찾을 수 있습니다.

다른 대안

정적 분석

근래 들어 정적 분석기가 부상하고 있습니다. PHPStanPsalm 모두 제네릭을 지원하며 많은 오픈소스 라이브러리와 개별 프로젝트에서 활용되고 있습니다.

다음은 일반적인 Dict 클래스를 PHPStan과 Psalm에서 지원하는 방식대로 작성한 예시입니다.

/**
 * @template Key
 * @template Value
 */
class Dict
{
  /**
   * @param array<Key,Value> $entries
   */
  public function __construct(private array $entries) {}

  /**
   * @param Key $key
   * @param Value $value
   */
  publci function set($key, $value): self
  {
    $this->entries[$key] = $value;
    return $this;
  }
}

/** @param Dict<string,string> $dict */
function f($dict) {}

$dict = new Dict([1 => 'foo']);
$dict->set('foo', 'bar'); // 정적 분석에서 오류 발생
$dict->set(1, 'bar');     // 통과
f($dict);                 // 정적 분석에서 오류 발생

template 이라는 docblock 어노테이션이 사용된 점에는 역사적인 이유가 있지만 제네릭에 실제적 구현에서는 자바의 제네릭 타입과 유사합니다. 제네릭 타입은 정적 분석 단계에서만 제네릭을 확인하지 실제 런타임에서는 보이지 않습니다.

이 방식은 제네릭의 장점인 타입 안전을 제공하긴 하지만 다음과 같은 아쉬움이 있습니다.

  • docblock은 장황하기 쉬움
  • 타입 체크가 별도의 도구를 통해서만 이루어짐 (PHPStan, 또는 Psalm)
  • 제네릭 타입 정보가 런타임에서는 활용 불가능
  • 제네릭 타입 정보가 런타임에서 강제되지 않음 (즉 코드 실행 전에 정적 분석을 수행하지 않으면 아무런 의미가 없게 됨)

소거된 제네릭 타입 선언

PHP 코어에서 구체화된 제네릭 구현의 어려움이 있기 때문에 문법 수준에서만 지원하고 타입 검사 자체는 정적 분석기를 활용하자는 제안도 있습니다.

이 대안에서는 PHP 문법에서 타입, 클래스, 함수 정의에서 제네릭 문법을 허용하지만 PHP 엔진 자체에서는 타입 체크를 수행하지 않는 것입니다.

이 방식을 "소거된" 타입 선언이라고 부르는 이유는 엔진이 단순히 런타임에서 무시해버리기 때문에 그렇습니다. 이 대안은 다양한 방법을 구현할 수 있습니다.

  • php-src의 일부분으로
  • 확장으로
  • 오토로더 수준에서
  • 그 외

앞서 본 Dict 클래스는 다음처럼 작성 가능합니다.

class Dict<Key,Value>
{
    public function __construct(private array<Key,Value> $entries) {}

    public function set(Key $key, Value $value): self
    {
        $this->entries[$key] = $value;
        return $this;
    }
}

function f(Dict<string,string> $dict) {}

$dict = new Dict([1 => 'foo']);
$dict->set('foo', 'bar'); // 정적 분석에서 오류 발생
$dict->set(1, 'bar');     // 통과
f($dict);                 // 정적 분석에서 오류 발생

이 방식은 정적 분석기에서 docblock이 장황해지던 문제를 해결하긴 하지만 일관성이 부족한 문제가 있습니다. 일반적인 타입 선언은 자동 형 변환(Type coercion)이 가능하지만 소거된 제네릭 타입 선언은 그렇지 않습니다.

다음 예시를 보면 알 수 있습니다.

class StringList
{
  public function add(string $value)
  {
    $this->values[] = $value;
  }
}

class List<T>
{
  public function add(T $value)
  {
    $this->values[] = $value;
  }
}

$list = new StringList();
$list->add(123); // 문자열로 형변환이 됨

$list = new List<string>();
$list->add(123); // 문자열로 형변환 되지 않음

이 시나리오에서 첫 add() 호출은 형변환이 되어 인자가 문자열로 전환되었지만 두번째 경우는 그렇지 않습니다.

자바의 경우에는 소거된 제네릭이 전통적인 타입 시스템 위에 구현되어 있어서 컴파일러가 타입 체크를 수행하기 때문에 위와 같은 문제는 발생하지 않습니다. 하지만 PHP의 경우는 이 문제를 피할 수 없는 상황입니다.

소거된 제네릭 방식의 다른 단점은 런타임 단계에서 제네릭이 보이지 않는다는 점입니다. 이는 패턴 매칭과 같이 제네릭 타입 인자를 봐야 하는 상황 등에서 한계를 보입니다.

완전히 소거된 타입 선언

소거된 제네릭의 비일관성을 해결하는 방법 중 하나는 모든 타입 선언을 제거해버리는 방식입니다. declare()를 사용해서 선택적으로 적용할 수 있습니다.

declare(types=erased);

이 대안에서는 엔진이 런타임에서 타입 체크를 더이상 수행하지 않게 됩니다. 즉 add()를 호출하던 앞서 예시에서 두 경우 모두 자동 형변환을 수행하지 않습니다. 즉 사용자가 직접 분석기를 통해 타입을 확인해야 합니다.

주류 인터프리터 언어에는 이런 접근 방식이 그렇게 새로운 것은 아닙니다. 타입스크립트를 통한 자바스크립트, 파이썬, 루비 등 여러 언어에서 완전히 소거된 타입 선언을 활용하고 있습니다.

사용자가 완전히 소거된 타입과 제네릭을 파일 단위로 선택적 적용을 할 수 있게 하는 방식으로 PHPStan/Psalm의 장황한 제네릭을 덜 복잡하게 활용할 수 있게 됩니다. 이 접근 방식은 다음과 같은 장점도 있습니다.

  • 단기적으로는 선택적으로 런타임 타입 체크를 끄기 때문에 성능 향상이 있을 수 있음
  • 잠재적으로 더 고수준의 타입 시스템으로 확장해서 non-empty-string, list, int, class-string, 조건부 타입 등과 같은 고급 타입을 지원할 수 있음

하지만 다음과 같은 큰 단점도 존재합니다.

  • 리플렉션이나 리플렉션에 의존하고 있는 라이브러리가 이 완전히 소거된 타입에 어떤 영향을 받게 될지 명확하지 않음
  • 타입을 강제하는 것이 개발자가 적극적으로 정적 분석을 사용해야만 달성할 수 있게 되는데 이는 현재 대부분의 PHP 생태계에서는 흔하지 않음
  • 현재 강타입과 약타입 두 가지에서도 개발자가 고려해야 할 부분이 많은 편인데 3번쨰 "타입 모드"를 만드는 것이 맞는 방향인지 의문 (거기에 더해 사용자가 유사 타입이 타입 강제 모드에서는 호환도 되지 않음)
  • 이 접근 방식이 "어떤 타입은 강제되지만 다른 것을 그렇지 않은" 문제를 해결하지 못함. 제네릭을 사용하면서도 완전히 소거된 타입을 원하지 않는 사람이라면 여전히 부분적인 타입 강제 수준에 머물게 됨.
  • PHP는 주요 스크립트 언어 중 타입을 강제하는 유일한 언어. 이를 잃으면 시장에서의 장점도 잃을 수 있음.

제네릭 배열

이 문서에서 제네릭 개체에 대한 얘기를 하고 있으니 제네릭 배열에 대한 얘기도 언급하고자 합니다.

유동적 배열

배열은 작성할 때 복사하게 됩니다. 수정하게 되면 새로운 사본을 만들고 (다른 곳에 사본이 존재한다면), 그리고 사본을 수정하게 됩니다 (복사시점 변경, copy-on-write). 이 접근 방식으로 배열을 다른 곳으로 보내고도 함수가 해당 배열을 수정하는 것에 대한 걱정을 할 필요가 없게 됩니다. (참조로 보내지 않는 한에는 말입니다.)

타입 과점에서 봤을 떄는 배열은 언제나 내부에 있는 내용을 기준으로 타입이 정해지고 배열을 수정했을 떄는 새로운 배열이 생성되기 때문에 타입이 변경되지 않습니다.

제네릭 관점에서 봤을 때는 아주 편리한 특성인데 배열이 가변적이라는 의미이기 떄문입니다. 즉 배열은 상위 타입과 하위 타입을 모두 포함할 수 있습니다. 즉 다음 코드도 타입 안전성을 보장합니다.

class A {}
class B extends A {}

function f(array $a) {}
function g(array<A> $a) {}
function h(array<B> $a) {}

$array = [new B()];

f($array);
g($array);
h($array);

일반적으로 제네릭 컨테이너는 비가변적인데 타입 플레이스홀더가 읽기와 쓰기 모두에 사용되기 때문입니다. 여기서의 경우는 문법적으로 불변이며 복사시점 변경을 수행하기 때문에 문제가 되지 않습니다.

그래서 자연스럽게 제네릭 배열을 구현하는 것이 가능합니다.

$a = [1];         // array<int>
$b = [new A()];   // array<A>
$c = $b;          // array<A>
$c[] = new B();   // array<A|B>
$b;               // array<A>

이 방식은 API 경계 즉 함수에 인자로 전달할 때나 값을 반환할 때, 개체를 업데이트 하는 등의 상황에서 타입을 확인하기 때문에 타입 안전성을 제공합니다.

function f(array<int> $a) {}
$a = [1];
f($a); // ok

$b = [new A()];
f($b); // error

증명 구현은 이미 되었지만 아직 성능에 어떤 영향을 주는지는 잘 평가되지 않았습니다. 다른 문제도 있는데 이 방식에서는 참조나 타입 프로퍼티를 지원하는 것은 어려울 수 있습니다.

정적 배열

유동적 배열의 대안은 인스턴스화에서 타입을 지정하는 방식입니다.

$a = array<int>(1); // array<int>
$a[] = new A();     // error

하지만 이 대안은 현재 PHP에서 배열이 어떻게 사용되고 있는지와 정면으로 충돌합니다. 또한 이 접근 방식은 배열을 반변적으로 만듭니다.

function f(array<int> $a) {}
function g(array $a) {}

$a = [1];
f($a); // ok
g($a); // error

g($a)에 오류가 발생하느냐 하면 제네릭의 반변성을 참고하세요. g()array (array<mixed>)를 인자로 받는데 어떤 타입의 개체든 추가할 수 있는 배열이란 얘기입니다. 하지만 array<int>를 여기에 전달했기 때문에 이 계약이 깨지게 됩니다. 그래서 arrayarray<int>를 받을 수 없습니다.

불변성은 배열에 제네릭을 적용하기 어렵게 합니다. 라이브러리가 제네릭 배열에 타입 힌트를 추가하면 사용자 코드를 깨뜨리게 될 것이고 반대로 사용자는 제네릭 배열을 라이브러리에 전달하려면 타입 선언에 제네릭 배열을 쓰지 않고서는 라이브러리를 사용하지 못하게 됩니다.

이런 문제로 개체 기반 컬렉션을 사용할 수 밖에 없습니다. 대다수 현대적인 언어처럼 컬렉션을 선언하는데 커스텀 문법을 사용하거나 더 확실한 제네릭 문법을 활용해야 할 것입니다. 물론 이 두 방식은 서로 상호적으로 호환이 가능할 겁니다.

결론

이 글에서 PHP에 제네릭을 구현한다는 것이 어떤 의미인지, 그리고 어떤 선택지가 있는지, 제네릭 개체와 컬렉션, 그리고 여러 연관된 기능에 대해 살펴봤습니다. 앞으로도 더 많은 작업이 필요하고 이런 작업은 게속 진행될 예정이며 어떤 기능이 가장 필요하며 가능한 방법인지 계속 논의될 예정입니다.

앞으로의 방향은 이렇습니다.

  • 구체화된 제네릭을 위한 타입 추론에 대해 조사를 지속할 예정이며 이해할 수 있는 수준의 트레이드오프가 있는 방안이 가장 알맞은 방향으로 판단되면 컬렉션은 그 방식으로 구현될 예정.
  • 소거된 제네릭이 여기에 논의된 것 외의 단점으로 실현이 불가능한 방식인지 파악
  • 완전히 소거된 제네릭 타입이 여기에 논의된 것 외의 단점으로 실현이 불가능한 방식인지 파악
  • 컬렉션을 위한 기능을 최적화하고 전용 문법이나 제네릭네서 사용될 수 있는지 확인
  • 컬렉션에서 더 나은 성능과 단순함을 위해 해시맵 (배열) 대신 사용할 수 있는 내부 자료형이 있는지 연구 (이런 이유에서 컬렉션은 사용자 공간에서 구현되지 않을 가능성이 높음)
  • 타입 배열은 배열 동작의 복잡도, 구현 이후의 이득을 고려했을 때 큰 가치가 없는 것으로 판단되어 타입 배열에 대한 연구는 중단

현재는 다음 질의에 대한 피드백을 구하는 것에 집중하고 있습니다.

  • 만약 구체화된 제네릭이 불가능한 방식으로 판명되면 소거된 제네릭 방식이 맞는 접근법이 될지, 아니면 계속 사용자 공간에서의 도구로 남겨둬야 할지
  • 어떤 제네릭 기능이 구현에 포함되고 포함되지 않아야 하는지? (예를 들면 합 타입에 제네릭을 허용하지 않는다, 합 제네릭이 느리게 동작해도 상관하지 않는다, in/out 변성 마커를 지원할 필요 없다 등)
  • 만약 소거된 제네릭이 포함된다면, 타입을 검증하기 위한 공식 린터를 만들 필요가 있을지 아니면 계속 사용자 공간의 도구를 활용할지
  • 만약 구체화된 제네릭이 불가능한 방식으로 판명되면 여기서 보여준 컬렉션 문법이 괜찮은지
  • 소거된 제네릭을 먼저 적용한 후에 구체화된 제네릭을 적용하는 것이 가능하다면 이 전략을 채택하는 것이 맞는지

논의

어쩌다보니 블로그 업데이트를 연례 행사처럼 치루고 있다.

그동안 Gatsby를 잘 사용하고 있었지만 걷어내기로 했다. netlify 인수 이후엔 업데이트 자체도 상당히 정체된 상황이다. 새 버전에 대한 이야기가 있긴 하지만 수많은 플러그인이 동시에 관리되고 업데이트 해왔던 그간의 방향성을 봤을 때 조금 의구심이 들 수 밖에 없는 상황이다. 거기에다 Gatsby Cloud도 접은 것 보면 사용자로서는 좀 김이 빠진다. 정말 좋아하는 프로젝트고 프로덕트였는데 프로덕트의 성숙도와는 달리 순식간에 불투명해져버린 상황이 많이 아쉽다.

react 기반의 정적 사이트 생성기를 계속 사용할까 찾아봤다. Astro가 요즘 유행이기도 하고 Vercel 제품도 사용해보고 싶긴 하지만... 1) 웹브라우저의 기본적인 사이클과 잘 맞지 않는 기분이 종종 들었고 (아무래도 웹사이트니까 SPA같은 느낌이 드는게 묘하게 어색한 그런 기분), 2) 마크다운에 마음대로 인라인 스크립트나 스타일을 넣는 일도 좀 불편한 기분이고, 3) 간단한 html을 넣으려고 이런 저런 컴포넌트를 오가야 하는 일도 조금 번거로웠다. 물론 react 문제 아니고 내가 구성한 방식의 문제겠지만서도.

다른 정적 생성기를 사용할까 찾아보다가 그냥 간단하게 만들기로 했다. 페이지네이션 없이 그냥 목록만 제공할 거니까, 복잡하게 생각하지 말고 그냥 생각 가는데로 간단하게 만들기로 했다.

그동안 remark.js를 gatsby 통해서 잘 사용해왔으니까 이젠 직접 사용하기만 하면 될 것 같았다. 이미 라우팅은 각각 마크다운 파일에 정의된 frontmatter를 사용해서 만들어내고 있었기 때문에 frontmatter만 읽어 처리할 수 있으면 되는 상황이었다. remark.js과 여러 플러그인 통해서 별 문제 없이 구현할 수 있었다. 사실 gatsby 대부분 마크다운과 관련된 플러그인은 remark.js의 플러그인을 랩핑한 것이라서, 좀 더 날 것의 형태로 사용할 수 있다는 것은 오히려 장점이었다.

템플릿도 별도 엔진을 사용할까 하다가도 새로운 템플릿 문법을 보고 짜는 것도 번거롭고 이미 리터럴을 잘 쓰고 있으니 간단하게 템플릿 리터럴로 구현했다. 페이지 컨텐츠는 라우팅과 분리했지만 템플릿은 라우팅을 기준으로 불러오게 해서 페이지마다 스타일을 바꾸기 쉽게 만들었다. 그 외에도 sitemap.xml, RSS 피드, 리다이렉션, 이미지 처리 등 필요한 요소도 추가했다.

아직 별도의 캐시를 넣은 것도 아닌데 2~3분 걸리던 빌드가 30초대로 내려왔다. 빌드 로그를 보면 빌드 자체는 금방인데 필드 환경을 불러오는 시간이 꽤 길었다. netlify에서 cloudflare pages로 변경하고 싶은데 cloudflare pages는 빌드 시간 제한이 아니라 횟수 제한이라서 사이트가 좀 더 정리되면 그때 옮길 생각이다.

블로그를 변경하면서 간단한 템플릿 엔진이 필요했다. 템플릿 엔진을 사용하려고 살펴보니 새 문법을 배우는 것도 번거롭고 정말 작은 부분 때문에 의존성을 추가하는 것도 별로 맘에 들지 않았다. 그동안 잘 사용해온 템플릿 리터럴을 그냥 사용할 방법은 없을까 찾아보다가 Function 생성자를 사용해서 다음처럼 작성할 수 있었다.

"use strict";

function template(html, params = {}) {
  const keys = Object.keys(params);
  const ps = keys.map(k => params[k]);
  return new Function(
    ...keys,
    'return (`' + html + '`)')
  (...ps);
}

Function 생성자는 파라미터 목록과 함수 내에 들어갈 내용을 문자열로 받고 그 함수를 반환한다. 위에서 보면 전달한 params에서 키 값을 수집해 함수를 생성하는 일에 사용하고 또 키 이름 순서에 맞게 ps 배열을 만들어 함수에 전달했다.

이제 html로 문자열을 전달하면 템플릿 리터럴로 이용할 수 있다.

const welcomePage = '<h1>${name}님 환영합니다.</h1>'
template(welcomePage, {name: '거북이'})
// "<h1>거북이님 환영합니다.</h1>"

다만 "`" 문자 사용에 주의해야 한다.

const welcomePage = '<h1>\\`${name}\\`님 환영합니다.</h1>'
template(welcomePage, {name: '거북이'})
// "<h1>`거북이`님 환영합니다.</h1>"

다른 도움 함수

부분함수도 손쉽게 만들 수 있다.

function partial(html) {
  return function render(params) {
    return template(html, params);
  }
}
const accountPage = '<button>${name}님의 계정 정보</button>'
const accountPartial = partial(accountPage)

const me = accountPartial({name: '나'})
// "<button>나님의 계정 정보</button>"
const koala = accountPartial({name: '코알라'})
// "<button>코알라님의 계정 정보</button>"

템플릿 함수가 필요하다면 다음처럼 params에 함께 전달할 수 있다.

const balance = () => 3000 // 또는 좀 더 복잡한 코드

const balanceInfo = template('<strong>잔고: ${balance()}</strong>')({balance})

nodejs에서 사용하고 있기 때문에 nodejs의 모듈을 사용해 html을 불러오는 함수를 다음처럼 작성했다. 또한 템플릿 내부에서 별도의 파일을 불러올 수 있도록 load 함수를 경로 맥락과 함께 템플릿 내로 전달했다.

import fs from 'fs'
import path from 'path'

function load(filename, basePath = '.') {
  const dirname = path.dirname(filename);
  const html = fs.readFileSync(path.join(basePath, filename));
  const partialHtml = partial(html);

  return function loadPartial(params) {
    return partialHtml({
      ...params,
      load: function loadFromSubDir(filename) {
        return load(filename, dirname);
      }
    })
  }
}
<!-- ./templates/index.html -->
<h1>안녕하세요!</h1>

${load('./partials/info.html')({user})}
<!-- ./templates/partials/info.html -->
<p>${user.name}님의 계정 정보</p>
load('./template/index.html')({user: {name: '당근'}})
// "<h1>안녕하세요!</h1>\n\n<p>당근님의 계정 정보</p>"

템플릿 내에서 다른 유틸리티 함수가 더 필요하다면 위 함수와 같이 더 추가하면 되겠다.

보안 고려하기

사용자 입력을 직접 템플릿에 사용하는 것은 당연히 위험하다! 상황에 맞게 적절한 예비 조치가 필요하다.

function sanitize(text) {
  return text.replace(/[^ㄱ-ㅎ|가-힣|a-z|A-Z|0-9| ]+/gi, "")
}

template(
  "<p>${sanitize(name)}님 안녕하세요!</p>", {
    name: "<strong>헤헤</strong>",
    sanitize,
  })
// "<p>strong헤헤strong님 안녕하세요!</p>"

이렇게 작은 템플릿 함수를 작성해봤다. 템플릿 리터럴을 활용하면 몇 줄 안되는 코드로도 템플릿 함수를 구현할 수 있었다. 간단한 수준에서라면 이 함수로도 충분히 활용 가능하겠지만 이 접근 방식의 한계(Function 내에서 바깥 스코프에 접근할 수 있는 등)로 보안 문제가 발생할 수 있다. 이런게 가능하다 정도로만 이해하고 제대로 된 라이브러리를 활용하는 것이 더 바람직하다.

간단하게 vim 메모 스크립트를 만들고 있었다. 스크립트가 실행될 때 이미 메모가 열려 있는 상태라면 메모를 닫으려고 했다. 기존 스크립트는 프로세스를 확인해서 프로세스가 있으면 닫았는데 닫기 전에 몇 가지 명령을 먼저 실행하고 싶었다. 외부에서 현재 실행되고 있는 vim 버퍼에 명령어를 전달할 수 있을까?

vim remote에 그 답이 있었다.

vim을 실행할 때 먼저 --listen으로 pipe를 구독한다. 이 옵션을 추가하면 버퍼가 실행되는 동안에만 pipe 파일이 해당 경로에 생성된다.

$ nvim --listen ~/.cache/nvim/memo.pipe

그리고 해당 버퍼에 명령을 보내려면 --server로 해당 pip를 지정하고 --remote-send로 실행할 명령을 추가한다.

$ nvim --server ~/.cache/nvim/memo.pipe --remote-send 'ihello world<esc>:wqa<CR>'

원래 해결하려던 문제에 다음처럼 적용할 수 있다. 먼저 pipe 파일 존재 여부로 메모가 열려있는 상태를 확인한다. 열려있다면 저장하고 닫고 열려있지 않다면 실행한다.

if [[ -e ~/.cache/nvim/memo.pipe ]]; then
  nvim --server ~/.cache/nvim/memo.pipe --remote-send '<esc>:wqa<CR>'
else
  nvim --listen ~/.cache/nvim/memo.pipe -c Goyo -c startinsert &
fi

이런 스크립트는 터미널 자체에서 실행하면 큰 의미는 없지만 GUI 환경에서 단축키로 해당 스크립트를 활용하면 메모를 토글 버튼으로 열 수 있게 된다.

단축키/명령

  • 탐색하기: :Explore 또는 명령행 도구에서 vim /path/to 등 사용
  • 새 윈도우에서 파일 열기: :Lexplore 또는 :Vexplore 사용
  • 미리보기: 탐색에서 파일 위로 커서 이동 후 p
  • 미리보기 창 닫기: Ctrl-W z

설정

  • 미리보기 우측에 표시: let g:netrw_preview=1
  • 미리보기 표시 크기: let g:netrw_winsize=<%>
  • 탐색기 상단 표시 숨기기: let g:netrw_banner=0

여러 동적 타입 언어가 각자의 방식대로 강타입을 지원해가는 과정은 정말 흥미롭습니다. 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를 좀 더 사용하기 깔끔하게 만든 라이브러리.

리포트, 코드 분석 도구

Carlos Arguelles, Marko Ivanković, and Adam Bender의 Code Coverage Best Practices를 번역했습니다.


코드 커버리지 모범 사례

저희는 수 년 간 여러 대형 소프트웨어 회사에서 다양한 소프트웨어 테스팅 이니셔티브를 주도했습니다. 꾸준히 강조하는 영역 중 하나는 위험성을 진단하고 테스트의 부족한 부분을 찾아내기 위한 방법으로 코드 커버리지 데이터를 활용하라는 부분입니다. 하지만 코드 커버리지가 제공하는 가치에도 불구하고 논쟁에 불이 붙어 강한 논쟁으로 이어지기도 하고 양극화 양상도 나타나는 주제입니다. 큰 규모의 그룹에서 코드 커버리지를 언급할 때마다 끝없는 논의가 매번 이어지는 것을 볼 수 있었습니다. 이런 대화는 생산성을 향상하는 쪽으로 진행되기 보다는 각자의 방패 뒤로 숨어버리게 합니다. 이 문서는 다양한 의견을 가진 사람들 사이에서 공통의 목표를 제시할 수 있도록 돕고자 작성되었습니다. 커버리지 정보를 좀 더 실용적으로 접근하고 전진에 힘 쓸 수 있도록, 이 문서가 그 도구로 활용되었으면 합니다. 이 글에서는 코드 건강에 효과적으로 도움이 되는 코드 커버리지를 모범 사례를 통해 안내합니다.

  • 코드 커버리지는 개발자의 워크플로에 상당한 이점을 제공합니다. 코드 커버리지가 테스트 품질에 대한 완벽한 지표라고 볼 수는 없지만 논리적이고 객관적인 산업 표준 지표 중 하나로 무언가 조치를 취할 수 있는 정보를 함께 제공합니다. 코드 커버리지는 많은 인적 자원을 필요로 하는 것도 아니라서 모든 프로덕트에 범용적으로 적용할 수 있으며 대부분의 언어에서 사용 가능한 도구가 이미 존재합니다. 물론 코드 커버리지는 많은 정보를 단 하나의 숫자로 표시하기 때문에 손실적이고 간접적인 지표인 점을 이해해야 하며 어떤 문제를 판단하는 유일한 수치가 되어서는 안됩니다. 대신 다른 기법을 함께 활용해서 테스트에 좀 더 종합적인 판단을 내릴 때는 유익한 도움을 받을 수 있습니다.

  • 코드 커버리지가 문제를 줄이는지 아닌지는 아직 명확한 답이 없는, 열린 연구 과제지만 경험에 비춰보면 코드 커버리지를 높이려는 노력이 우수한 엔지니어링을 추구하는 문화로의 변화를 이끄는 경우를 봐 왔고 장기적으로는 문제를 많이 줄이는데 일조했습니다. 예를 들어 팀에 코드 커버리지에 우선 순위를 두면 테스트 자체를 1급 시민처럼 대우해서 테스트 가능성(testability)이 프로덕트 디자인에 더 깊숙히 반영됩니다. 그 결과로 팀은 더 적은 노력으로도 테스트 목표를 달성하게 됩니다. 이 모든 노력은 처음부터 더 고품질의 코드를 작성하는 노력(모듈로 더 분리하고, API에서 더 깔끔한 계약을 작성하고, 더 관리하기 쉬운 코드 리뷰를 수행하는 등)으로 이어집니다. 결과적으로 전반적인 코드 건강, 엔지니어링, 운영 우수성에 대해 신경쓰기 시작합니다.

  • 높은 코드 커버리지 퍼센트는 테스트 범위의 고품질을 보장한다는 의미가 아닙니다. 100%에 가까운 숫자를 만드는 일에만 집중한다면 비뚤어진 안정감을 쫒는 일과 같습니다. 그런 접근에서는 단순히 숫자를 올리기 위해서 가치 낮은 테스트를 양산하기 마련인데 관리해야 하는 테스트가 늘어나기 때문에 기술적 부채를 크게 만듭니다. 또한 테스트에 소모되는 자원까지 고려하면 심한 낭비로 볼 수 밖에 없습니다. 나쁜 코드가 테스트에서 잡히지 않고 프로덕션으로 넘어가게 되었다면 (a) 특정 경로의 코드가 테스트에서 확인되지 않았다는 의미로 이런 부분은 코드 커버리지 분석에서 쉽게 확인할 수 있는 테스트 격차입니다. (b) 또는 테스트가 특정 경계 상황(edge case)에서 제대로 이뤄지지 않은 경우인데 코드 커버리지에서는 테스트를 수행한 것으로 집계되기 때문에 코드 커버리지 분석 만으로 이 부분을 진단하기에는 아주 어렵거나 불가능에 가깝습니다. 코드 커버리지는 코드의 특정 행이나 브랜치가 의도대로 동작되는지 검사하는 도구가 아니라 단순히 테스트에서 실행이 되고 있는지만 보장합니다. 단순히 테스트를 복사/붙여넣기를 하거나 특정 숫자 값을 몇 넣는 것으로 커버리지를 높이는 일이 없도록 더욱 주의해야 합니다. 더 나은 기법이라면 확인하는 각각의 행에 적절한 테스트를 수행하고 실패하는 상황을 잘 검사하고 있는지 확인하기 위해 뮤테이션 테스트를 적용할 수 있습니다.

  • 하지만 낮은 코드 커버리지 수치는 매 자동화된 배포마다 프로덕트의 큰 부분에서 전혀 테스트가 이뤄지지 않는 상황이라는 점을 장담할 수 있습니다. 낮은 수치는 나쁜 코드를 프로덕션으로 내보낼 확률이 높다는 뜻이며 주의 깊게 확인해야 합니다. 실제로 대다수의 코드 커버리지 정보는 어떤 범위가 테스트 되고 있는가 하는 부분보다 어떤 범위가 테스트되지 않고 있는지를 강조합니다.

  • 모든 프로덕트에는 이런 "이상적인 코드 커버리지 수치가 나와야 한다" 같은 규칙은 없습니다. 어느 수준의 테스트를 요구하거나 필요로 하는가 하는 질문은 (a) 비지니스에 얼마나 영향을 미치고 어느 정도 임계가 보장되어야 하는지 (b) 코드에 얼마나 자주 변경이 이뤄지는지 (c) 코드의 생애가 얼마나 장기적인지, 복잡도가 어느 정도인지, 도메인 영역에서의 변수는 어느 정도인지에 따라 답변되어야 합니다. 모든 팀에서 코드 커버리지 몇 퍼센트 달성을 해야 한다고 강제할 수는 없습니다. 이런 비지니스 결정은 프로덕트의 해당 도메인 분야를 잘 이해하는 프로덕트 오너가 내려야 합니다. 코드 커버리지를 특정 퍼센트 달성하기 위해서는 테스트를 더 쉽게 수행할 수 있도록 인프라스트럭처에 대한 투자가 필요합니다. 예를 들면 개발자의 워크플로에 자연스럽게 녹아들 수 있는 도구를 제공하는 등의 방식이 필요합니다. 다만 엔지니어가 단순히 수치를 목표로 삼고 체크 박스 체크하는 것처럼 목표 이상의 커버리지를 달성하는 것만 집중해버리면 아무리 신중하게 코드를 작성 한들 결과적으로는 그다지 건강하지 않을 수 있습니다.

  • 일반적으로 프로덕트 대다수의 코드 커버리지는 평균 이하입니다. 우리는 전반적으로 코드 커버리지를 대폭 상향하는 것을 목표로 해야 합니다. "이상적인 코드 커버리지 수치"가 있는 것은 아니지만 구글에서는 일반적인 가이드라인으로 60%는 "용인되는 수준", 75%는 "칭찬할 만한 수준", 90%는 "모범적인 수준"으로 보고 있습니다. 하지만 전사적 수준에서 하향적인 강제는 하지 않으며 각각의 팀에서 비지니스 요구에 맞춰 얼마를 달성할지 정하도록 격려하고 있습니다.

  • 코드 커버리지 90%에서 95%가 되는 일에 집착하지 않아야 합니다. 코드 커버리지가 주는 이득은 지수적으로 증가하기 때문에 특정 수준을 넘으면 이득이 크지 않습니다. 하지만 30%에서 70%로 가는 일은 구체적인 계획을 짜서 수행하는 것이 바람직합니다. 또한 새로 작성하는 코드는 모두 그 수준을 맞춰서 작성해야 합니다.

  • 테스트에서 다뤄지지 않는 범위의 코드 또는 동작에서 발생할 위험성을 안고 갈 지 아닐지를 사람이 판단하는 점이 코드 커버리지 수치보다 더 중요합니다. 무엇을 테스트하지 않는지 하는 부분이 무엇을 테스트 하는지보다 더 중요한 부분입니다. 코드 리뷰 과정에서 실용적인 토론을 거쳐 어느 행의 코드가 테스트되지 않을지 합의하는 과정이 단순히 목표 숫자를 맞추는 일보다 더 중요합니다. 코드 커버리지를 코드 리뷰 과정에 내장하면 코드 리뷰가 더 빠르고 쉽게 진행됩니다. 모든 코드가 동일하게 주용한 것은 아닙니다. 예를 들면 디버그하는 로그 행을 테스트하는 일은 그다지 중요하지 않습니다. 그래서 개발자는 단순히 커버리지 숫자를 보는 것보다 코드 리뷰에 포함된 테스트로 강조되는 각각의 행을 확인해서 정말 중요한 코드가 테스트되고 있는지 체크해야 합니다.

  • 단순히 프로덕트가 코드 커버리지가 낮다고 해서 장기적으로 구체적이고 점진적인 향상을 할 수 없다는 의미가 아닙니다. 테스트가 별로 없고 테스트가 어려운 레거시 시스템을 인계 받았다면 개선할 힘도 안나고 어디서 시작해야 하는지 전혀 감이 안올 수도 있습니다. 하지만 최소한 '보이스카우트 원칙'을 적용할 수 있을 겁니다. (캠핑장은 처음 왔을 때보다 깨끗하게 해놓고 떠나라.) 시간이 흐르면 점진적으로 더 건강한 위치에 도착하게 될 겁니다.

  • 주기적으로 변경되는 코드는 꼭 테스트에 포함되어야 합니다. 프로젝트의 목표가 넓어서 90% 이상을 달성하는 일이 큰 의미가 없을 수 있습니다. 각 커밋 당 커버리지 목표를 99%로 잡으면 합리적이고 90%는 좋은 하위 임계점으로 볼 수 있습니다. 적어도 시간이 지날 때마다 더 나빠지는 것 만큼은 막아야 합니다.

  • 단위 테스트 코드 커버리지는 퍼즐 조각 하나에 불과합니다. 통합/시스템 테스트 코드 커버리지도 중요합니다. 또한 단위 테스트와 통합 테스트를 포함한 모든 파이프라인에서의 종합 커버리지 수치는 가장 중요합니다. 이 수치는 코드 전체에서 얼마나 많은 영역이 테스트 자동화에 포함되지 않았는지, 그리고 파이프라인을 통해서 프로덕션 환경으로 얼마나 많은 영역이 테스트 없이 보내지는지 알 수 있습니다. 하나 알아야 할 점은 단위 테스트는 실행된 코드와 평가된 코드 사이에서의 상관 관계가 높지만 통합 테스트나 E2E(end-to-end) 테스트에서의 일부 범위는 부수적일 가능성이 크며 의도되지 않은 테스트 범위일 수도 있습니다. 그렇기에 통합 테스트를 코드 커버리지로 같이 본다고 하더라도 그걸 유닛 테스트에서 다뤄지지 않은 범위도 검사가 되고 있구나 착각하는, 비뚤어진 안정감을 갖지 않도록 조심해야 합니다.

  • 코드 커버리지 표준에 미치지 못하면 배포가 되지 않도록 문을 잠궈야 합니다. 물론 팀에서 이런 배포 프로세스를 만들기 전에 충분한 토론을 거쳐서 모두가 납득할 수 있는 수준에서 결정해야 합니다. 하지만 이렇게 프로세스에 넣으면서 단순히 의례적으로 해야 하는 과정으로 체크박스처럼 만들어버리면 역효과가 날 수 있다는 점에 주의해야 합니다. ('목표 달성'에 대한 압박을 주면 절대 원하는 결과를 얻을 수 없습니다.) 여기에는 많은 기법이 존재합니다. 모든 코드에 대한 커버리지를 확인하는 방법과 새 코드에 대한 커버리지만 확인하는 방법도 있습니다. 코드 커버리지를 정량적인 특정 숫자를 기준으로 평가해서 막는 방법도 있고 이전 버전과 비교해서 그 변화량에 맞춰 막는 방법도 있습니다. 또는 특정 범위의 커버리지는 무시하거나 또는 그 범위에만 가중치를 둬서 평가할 수도 있습니다. 이 코드 커버리지에 대한 팀 내의 약속은 잘 지켜져야 합니다. 코드 커버리지를 낮추는 위반이 발생하면 코드가 체크인되지 않고 프로덕션에 도달할 수 없어야 합니다.

만약 구글의 커버리지 인프라스트럭처에 대해 더 알고 싶다면 "Coverage at Google"을 참고하세요. 여기에서 읽을 수 있습니다.

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.