2019년에 구입한 2015년식 Honda Fit은 차 없이는 도통 힘든 미국 생활에 큰 도움이 되고 있다. 호주에서 지낼 때부터 Honda Jazz가 막연히 갖고 싶은 차였는데 이렇게 타고 다니게 될 줄이야 😬 연비든 크기든 뭐든 모든 게 다 만족스러웠다. 다만 2주만 운행하지 않아도 쉽게 배터리가 방전되는 문제가 있었다. 중고로 구입해서 그런가보다 하고 새 배터리로 교체했고 한동안은 또 괜찮다가 또 금방 방전되기 시작했다. 덕분에 휴대용 점프 배터리를 항상 들고 다녀야 했다.

뒤늦게 인터넷 검색해보니 배터리 규격이 작아서 생기는, 의외로 흔한 문제였다. 추운 동네에서는 더 심하다니 그나마 캘리포니아여서 오히려 나은 사정이었던 건데, 게다가 작은 규격이 흔하지도 않아서 취급하는 곳도 많지 않았고 그 탓에 가격도 더 비쌌다. 더 큰 배터리로 교체하면 된다는데 새 배터리로 교체한지 얼마 되지 않았던 터라 좀만 더 참고 기다리기로 했다. 학교에 종종 몰고 갈 때마다 점프 배터리 챙기는 일이 일상이 되었었다. 번거로워도 시간없다는 핑계로 대충 타고 다녔는데 이제 졸업도 했겠다 탈 일이 더 많을 것 같아 얼른 교체했다.

  • 51R 배터리
  • 51R 배터리 트레이 (파트번호: 31521-T5A-000)
  • 배터리 절연 그리스 (dielectric grease)
  • 10mm 렌치
  • 장갑

원래는 151R 규격 배터리인데 51R 배터리로 교체하면 된단다. 단순하게 큰 배터리로 늘리면 된다니 싶었는데 놀랍게도 미주 차량에만 작은 배터리고 다른 지역엔 다 51R 배터리가 기본으로 탑재되어 있다고. 배터리 트레이는 기존 있는 걸 잘라도 되고 큰 규격에 맞는 트레이를 사도 되는데 기왕 하는 김에 아마존에서 15불 정도에 구입했다. 배터리는 코스트코에서 구입했다. 코스트코에서 구입하려고 하면 차종을 물어보는데 그냥 시빅 얘기하던지 지금51R 설치 되어있다고 하면 된단다. 나는 별 말 없이 구입할 수 있었다.

151R과51R 비교. 아예 체급이 달랐다.

교체 후 모습

교체 영상이 이미 많아서 쉽게 따라했고 20분 정도면 충분했다.

  1. 마이너스 단자(검정) 분리
  2. 플러스 단자(빨강) 분리
  3. 배터리 고정 브라켓 분리
  4. 배터리, 측면 커버, 트레이 제거
  5. 새 트레이와 배터리 설치
  6. 배터리 측면 커버 부착
  7. 브라켓 조립
  8. 배터리 연결부와 단자에 절연 그리스 도포
  9. 플러스 단자(빨강)을 연결
  10. 마이너스 단자(검정)을 연결

차량 악세사리 같은건 교환해본 적이 있지만 배터리를 교체해본 것은 처음이라서 신기했다. 배터리 바꾸고 나서도 쌩쌩 잘 돌아가는 차에 기분이 좋았다. IKEA 가구 직접 조립해서 애정이 더 가는 것이랑 비슷한 기분이다. 별 고장 없이 오래 탈 수 있음 좋겠다.

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
      • 리눅스 패킷 필터링
      • 인터페이스 수준에서의 제한적인 추상 제네릭 타입 (개념 증명)

2025년 3월 봄학기를 마지막으로 졸업했다.

기대했던 부분들

전공 공부를 하면 오랫동안 느껴온 한계를 조금이라도 넘을 수 있을 거란 기대가 있었다. 매년 반복되는 '비전공 vs 전공' 얘기에서, 굳이 전공이 아니어도 실무는 충분하다는 말도 자주 봤다. 꼭 학교를 가지 않아도 배울 수 있기도 하고 필요할 때마다 그때그때 공부하면 된다는 얘기도 많이 들었다. 실제로도 멋진 결과를 내는 비전공 분들도 많이 봐왔지만, 그럼에도 해보고싶단 생각은 좀처럼 사라지지 않았다. 못해본 경험에 대한 궁금증은 어쩌면 당연한 일이었다. 새로운 기술이 나왔다는 소식을 접할 때마다 이건 언제 배우지, 싶을 정도로 새로운 때가 많았다. 하지만 과거의 무언가를 재해석한 것이거나 유명한 이론의 확장인 경우도 있었다. 그런 경험이 몇 차례 반복되면서, 언제가 되었든 전공을 꼭 해보겠다는 욕심이 생겼다. 때로는 그런 경험이 전공하지 않으면 넘을 수 없는 그런 벽처럼 느껴저서 좌절감을 느꼈던 시절도 있었다. 분야에 대해 폭넓게 알고 있으면 정말 수백 수천 걸음 앞서서 걸을 수 있구나, 그런 넓은 관점과 이해도를 갖고 문제에 접근할 수 있다면 어떻게든 빨리 공부를 시작하는 것이 필요하다 싶었다.

거기에 더해 오래 학업을 이어온 분들의 이야기를 접할 때마다 늘 흥미롭게 들렸다. 자연스럽게 연구자/학자로의 삶에도 궁금증이 생겼다. 연구 주제를 정하고 깊이 공부한다는 것이 어떤 의미인지, 어떻게 공부하고 연구한다는 것인지 배워보고 싶었다. 실제로 겪어본 적이 없다보니 그런 직업이 막연하게 느껴졌고, 그래서 가능하다면 짧게라도 직접 경험해보고 싶었다. 연구한다는 것은 구체적으로 무슨 일을 한다는 것일까? 단순히 글이나 이야기로 듣는 것이 아니라 가까이서 보고 체감해보고 싶었다. 학부 과정에서 그런 경험을 할 수 있는 기회를 얻기는 쉽지 않겠지만 어떻게든 맛보기라도 할 수 있기를 바라는 마음이 컸다.

CSE 건물 곰돌이, 괜히 기분 좋아지는.

CSE 건물 지하던전, 늘 공기가 별로였다

그리고 학교 다니기 전부터 궁금했던 분야를 배울 수 있을 것이란 기대감이 있었다. 시작하기 전에는 단순히 CS에 대한 지식을 배운다는 생각에 기대했는데 실제로는 훨씬 넓고 깊이 있는, 다양한 주제가 유기적으로 연결된 학문이었고, 그 안에서 내가 알고 있던 것은 정말 극히 일부에 불과했다는 것에 주눅이 들 정도였다. 특히 막연하게 배우고 싶었던 분야는 자연어 처리(NLP)와 컴파일러였다. 지금 보면 언어로 하는 것은 무엇이든 깊이 있게 배우고 싶었던 것 같다. NLP는 호주에 가기 전부터 관심이 있었던 부분이고 컴파일러는 프로그래밍 언어에 대한 관심에 자연스럽게 궁금증이 생긴 주제였다. 원하는 타이밍에 그 주제의 수업이 열리지 않아 비슷한 수업만 겨우 들을 수 있었는데 아쉽긴 했지만 비슷했던 수업에서 각 주제가 어떤 식으로 다뤄지는지 작게나마 통찰을 얻을 수 있었다.

호기심에서 비롯되긴 했던 공부지만 궁극적으로는 모호한 내 진로에 좀 더 분명한 방향이나 단서를 찾을 수 있기를 기대했다. 그동안 오래 웹개발을 해오긴 했어도 시스템 통합과 같이 비지니스에 더 가까운 일을 많이 해왔다. 그래서 내 스스로도 개발자라고는 하지만 코어가 되는 어떤 것을 만드는 일보다는 조금은 주변적이고 때로는 일이 되게 하기 위해서 하는 일들에 내적 혼란을 겪을 때도 있었다. 어떤 분야에서 무슨 일을 할지, 이런 생각은 하게되면 정말 끝이 없는 것 같다. 언젠가는 내가 정말 원하는 어떤 일을 찾게 되고 즐겁게 할 수 있게 되지 않을까, 정말 내가 지금의 일을 계속 할 수 있을까, 이런 고민은 마치 그림자 같이 없어지질 않는다. 이 나이가 되어서도 이런 고민을 계속 하게 될 줄 몰랐지만 학교에서 어떻게든 힌트가 될 만한 실마리라도 찾을 수 있기를 바랐다.

기괴한 Geisel 도서관, 조용한 1층 구석에서 보냈다

조용해서 늘 좋아했던 Sally T. WongAvery 도서관

그렇게 오랜 궁금증과 기대를 안고 학교 생활을 시작했다. 내가 그토록 알고 싶었던 것들, 이해하고 싶었던 주제들을 정말 끝까지 따라가며 머릿속에 차곡차곡 담아 무사히 졸업까지 마칠 수 있었을까.. 🥲

얻고 배운 부분들

먼저 공부하는 방법을 많이 배웠다. 학교 생활 하기 전에도 수업 내용을 보고 배우려고 실라버스를 뒤적인 적이 꽤 있지만 지금 생각해보면 정말 피상적인 수준에 그쳤던 것 같다. 이 수업이 어떤 의미이고 어떤 분량과 진도로 배워야 하는 주제인지, 시간을 얼마나 할애해야 하며 이 수업에서 학습은 어느 정도 깊이를 가지는지 등 실제 수업에 맞춰 따라가다보니 실라버스가 어떤 의미의 문서인지 확실하게 배웠다. 실라버스는 사전적이기도 하고 수업의 목차 역할도 하며 때론 광고지, 때로는 계약서의 역할도 했다. 학교 다니면서 모든 공부가 실라버스에서 시작되고 끝난다는 얘기가 더 와닿게 되었다. 물론 모든 실라버스가 같은 수준으로 작성되진 않지만 잘 작성된 실라버스와 체계적인 수업 틀을 몇 차례 경험할 수 있던 덕분에 그동안 중구난방 공부했던 경험들을 많이 개선할 수 있게 되었다. 단순히 보고 외우는 것이 전부가 아니었다. 내가 특정 주제에 대해 얼마나 시간을 써서 공부해야 하는지에 대한 감각을 만들어줬다. 거기에 더해 여러 수업과 다양한 교수법을 접하면서 "학습하는 나"를 더 잘 관찰할 수 있었고 어떻게 학습하면 내게 더 오래 남고 도움이 되는지, 어떤 방식으로 접근해야 하는지 자연스럽게 체득할 수 있었다. 학습을 체계적으로 계획하고 구체화한 경험은 앞으로도 큰 도움이 될 것 같다.

CS에서 다루는 다양한 주제를 많이 배울 수 있었다. 그동안 매일 업무에서 코드를 작성하고 있지만 컴퓨터라는 넓은 스펙트럼에서 얼마나 한정적인 영역에서 개발하고 있었는지 다시금 느꼈다. 개발과 연구가 진행되는 수많은 크고 작은 주제는 매우 신선했고 때로는 충격적이기도 했다. 세상은 정말 넓었다. 전통적인 주제는 전통인 이유가 있었다. 전혀 다른 문제처럼 보여도 관점을 바꾸면 전통적인 문제와 동일한 접근법으로 유려하게 해결해낼 수 있었다. 새로운 것도 앞서 접근 방법에 대한 깊은 이해도가 필요했다. 학부 과정에서 가장 놀라웠던 부분은 간단하게 배우고 지나간다고 해서 절대 간단한 부분이 아닌 경우가 많다는 점이다. 사소하게 보여도 모든 항목에 레퍼런스가 있고 현재에도 깊이 연구하는 사람들이 어딘가에 존재한다는 것이 너무나도 신기하고 놀라웠다.

어떤 부분이든 흥미가 있다면 더 들여다 볼 부분이 있다니 생각만으로도 즐거운 일이다. 더욱이 학교가 학부에서도 리서치 경험을 쌓을 수 있도록 다양한 프로그램과 연구 수업을 운영하고 있는 덕분에 깊이 있게 들여다보는 과정이 어떤지도 배울 수 있었다. VR의 UI/UX 수업을 들은 계기로 디자인랩에서 확장현실(XR) 관련 리서치에도 참여했고 fine-grained 복잡도에 관한 알고리즘 연구 수업에서 이론 연구 과정도 간접적으로 경험할 수 있었다.

학부 과정에서의 아쉬움도 분명 있었다. 대부분 수업이 "여기까지가 학부의 세계고 더 재미있는 것은 석박사에 와야 배울 수 있다"는 말로 마무리 되었다. 학부에서도 많이 배우기도 했지만 깊이 있게 안다기 보다는 여전히 겉만 더 잘 알게 된 느낌인데. 오랜 기간 학습에 대한 갈증이 있던 탓인지 더 배우고 싶다는 여운이 많이 남았다.

COGS 10 가장 흥미로웠던 인지과학/기술 교양

CSE 167 가장 재밌게 들은 그래픽스 수업

끝나고서 아쉬운 부분들

학자금 대출과 더불어 생각하지 못한 장학금을 꽤 받은 덕분에 비용적으로 엄청 힘든 것은 아니었지만 그래도 학업 대부분의 시간을 회사일과 병행했다. 리모트로 근무하는 회사기도 했고 스케줄을 꽤 유연하게 해준 덕분에 금전적으로는 큰 걱정 없이 학교 마무리까지 할 수 있었다. 다만 회사일과 학업을 같이 하면서 가장 부족한건 시간이었다. 퇴근하면 수업과 과제 따라가는 것만 해도 빠듯해서 학교 내에서 그다지 네트워크를 꾸리지 못했다. 특히나 주중에 배운 내용을 금요일까지 과제해서 내야 하는 수업은 일하면서 듣기엔 정말 비인륜적인 것이었다. 네트워크고 뭐고 밥 먹을 시간도 없었던 그때는 어떻게 버텼는지. 엄청 재미있어 보이는 클럽도 많았는데 당연히 클럽 활동도 전혀 들여다보지 못했고 여름에도 여름학기 수업과 함께 회사일을 병행해서 인턴십 같은 것도 전혀 해보질 못한 것은 아쉽다. 금전적으로 안정을 추구한 대신에 대학 생활에서 가장 얻어볼 만한 부분을 챙기지 못한 것 같다.

그렇게 시간에 쫒기다보니 학습한 내용을 체계적으로 정리하는 작업이 부족했다. 수업 듣고 과제 끝내기에 바빴고 과제가 끝나면 또 과제가 시작되었다. 쿼터제로 운영되는 학교라서 엄청 빠르게 시간이 흐르는 기분인데 얼마나 빠르냐면 첫 주부터 과제가 밀리는 기분이 들 정도다. 다 끝난 이제서야 그나마 저장해뒀던 실라버스와 슬라이드 보면서 키워드라도 목록으로 작성했다. 목록을 적다보니 더 공부해보고 싶은 토픽이 많이 보인다.

가까이 바다가 있어 좋았다. 학교에서도 바다 내음이 날 정도.

밤에도 산책하기 좋았던 HDH 하우징

어쩌면 아쉬운게 다 시간과 관련된 것 같은데 가장 아쉬운건 역시 체력적인 부분이었다. 그룹 프로젝트 같은 게 있으면 애들은 몬스터 같은 드링크 달고서 밤새 공부하고 코딩하던데, 나는 한 줌 체력 가지고 일하며 쫒아가려니 쉽지 않았다. 체력이 없으니 집중도 안되고 더 많은 시간을 써야만 토픽이 이해되는 상황이 되니까, 악순환이었다. 커피 의지해서 겨우 수업 듣고 적으며 따라가고 있으면 눈 반짝이면서 정말 감탄이 나오는 질문을 하는 친구들도 있었는데 부럽고 멋지더라. 여러모로 체력을 좀 더 챙겼더라면 더 즐겁지 않았을까 하는 아쉬움이 있다.


학교가 끝나고 나서도 많은 물음표가 있다. 당장 있는 것에만 집중하다보니 학교가 끝나고 나서의 무언가를 크게 계획하지 않았던 탓에 무엇을 어떻게 해야하나 하는 실존적 고민이 쏟아졌다. 30대의 많은 시간을 할애했다는 점에서도 자꾸 텁텁한 기분이 들지만... 그래도 더 늦기 전에 했으니 얼마나 다행인가! 더 이상 학사 언제하나 고민하지 않아도 된다는 것만으로도 만족한다. 뭐든 정말 감사한 일이다. 지금 학업을 더 하고 싶다는 생각도 들지만 현재 일과 공부한 것 사이 괴리 때문에 더 고민이 된다. 학부 이후 학습이 단순히 내 지적인 욕심을 채우는 것에 시간과 비용을 사용하는 것인지, 아니면 내가 다른 진로, 다른 개발 분야에서 일할 것을 가정하고서 공부를 하려는 것인지도 고민이다.

SD 맛있는 카페는 거의 다 가본 것 같다. 카페인 연료 삼아.

바다 보고 힘내고 과제하고 반복 반복

이제 갚아 나가야 할 학자금 대출하며, 취업 문제, 그 외에 현실적인 문제들이 쏟아지기 시작했지만 그래도 긴 과정을 잘 끝냈다는 것에 얼마나 감사한지, 만감이 섞인다. 졸업만 하면 모두 해피엔딩이라고만 생각했는데 끝나고 나서도 이토록 양가적인 감정이 들 일인가. 집 이사도 있고 이런저런 신경 써야 할 일이 많아서 이 글도 얼마나 오래 걸려 썼는지 모른다. 그래도 무엇보다도 후련하다. 학업과 회사일 사이에서 늘 시간과 싸움했고 그 외에도 많은 우여곡절이 있었지만 이 긴 과정을 끝냈다는 것 자체만으로도 얼마나 감사한지 모르겠다. 이 모든 과정에 늘 곁에서 항상 위로 주고 힘이 되어 준 민경 씨에게 너무나도 고맙다. 부지런히 일상을 다시 챙기고 공허한 기분 털어내고 방향을 잘 잡고 걸어갈 일만 남았다. 앞으로 계획하고 도전한 일들에 더 기대가 된다.

그동안 라미 사파리만 써왔던 사람이라 넓고 깊은 만년필의 세계 속에 너무 소소한 사용기를 쓰는 것 같아 괜스럽게 부끄러운 기분이 든다... 여튼.

꽤 오랜 시간 라미 사파리를 썼다. 호주에서 저렴하게 구입해서 손에 익을 정도로 오래 썼다. 특히 사용할 때 무게감이 좋아서 자주 썼었다. 그러다가 수첩을 몰스킨으로 정착하면서부터 라미 사파리와의 관계가 조금씩 틀어지기 시작했다. 문구류는 쉽게 정착하는 편이기도 하고 왠만해서는 불편함을 잘 느끼지 않는 스타일이다. 그런데도 유독 거슬리는 조합이 바로 라미 사파리 만년필과 몰스킨 수첩이다. 뒷면에 비치는 것도 심하고 마르기 전에 덮으면 반대쪽에 뭍는 경우도 꽤 잦다. 그래도 꾹 참고서 꽤 오랜 시간 사용하다가 어느 순간부터 라미 사파리를 잘 들고 다니지 않게 되었다.

요즘은 더 좋은 수첩 브랜드도 많아서 다른 수첩도 사용해보고 싶은 마음이 있는데 아무래도 수첩은 시작하면 마음에 안든다고 한 두 장 쓰고 그만 쓸 수가 없으니까. 펜보다는 수첩을 결정하는 것이 더 큰 결정인 게 확실하다. 몰스킨도 사실 엄청 좋은 질의 수첩은 아닌데 이미 어느 정도 품질인지 알고 있다는 부분에서 계속 사용하게 되는 것 같다. 그나마 펜은 좀 더 가벼운 마음으로 시도해볼 수 있다는 점에서, 몰스킨은 남고 새로운 펜을 찾게 되는데.

  1. 리필 카트리지 대신에 직접 잉크를 충전할 수 있을 것
  2. 얇은 펜촉으로 번지지 않도록
  3. 저렴할수록 좋고 플라스틱도 문제 없음

그렇게 한참 둘러보다가 프레피 플레티넘을 구입하게 되었다.

프레피 플레티넘 만년필

단점부터 얘기하면 좀 내구성이 부실한 소재다. 라미 사파리는 차가 밟아도 부서질 것 같지 않은 그런 느낌1이라면 이건 너무 꽉 닫으면 부서질까 조심히 돌려야 한다. 라미 사파리처럼 대충 가방에 던져 놓거나 몰스킨 사이에 꽂아두고 사용하면 금방 부서져서 가방을 잉크바다로 만들 것 같은 불안함이 있다. 그래서 더 조심조심 사용하게 되었으니 이건 장점으로 봐야 할까.

그 외에는 다 마음에 든다. EF 02 촉인데 일단 몰스킨에는 번짐 없이 사용할 수 있다. 무게도 적당하며 모든 부분을 다 분리해서 청소 가능한 것도 마음에 든다. 카트리지 대신 컨버터를 구입해서 잉크를 충전해서 쓰는 경험도 전혀 불편함이 없다. 구입한지거의 반년 정도 되었고 학교 노트를 다 이 펜으로 했는데 사용할수록 만족스럽다.

학교 다닐 땐 매일 쓰면서도 좋네 마네 쓸 시간도 없었는데 학교가 끝나니 수첩을 펼 시간이 없이 바쁘다. 글도 쓰면서 심정 여유 찾는 시간 챙기기로.

Footnotes

  1. 실제로는 꽤 잘 부서진다는데 복불복인가보다.

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 변성 마커를 지원할 필요 없다 등)
  • 만약 소거된 제네릭이 포함된다면, 타입을 검증하기 위한 공식 린터를 만들 필요가 있을지 아니면 계속 사용자 공간의 도구를 활용할지
  • 만약 구체화된 제네릭이 불가능한 방식으로 판명되면 여기서 보여준 컬렉션 문법이 괜찮은지
  • 소거된 제네릭을 먼저 적용한 후에 구체화된 제네릭을 적용하는 것이 가능하다면 이 전략을 채택하는 것이 맞는지

논의

Structure Over Chaos | How to Self-Learn Like a PhD Student을 보고 나서 메모.

체계적으로 독학하는 방법

목표 정하기

  • 학습의 목표는 무엇인가요?
    • 에세이, 발제, 도구 만들기, 논문 쓰기...
    • 학습의 결과를 적용할 수 있어야 적극적으로 학습 가능
  • 단기적, 장기적인 목표. 중간 목표도 있으면 도움
  • 동기를 적어둘 것 ~ 왜 이 학습을 시작했나요?
    • 커리어, 개인적인 호기심, 회사 차리려고...

맞는 학습 자료 찾기

  • 이미 많은 사람이 학습해서 유명한 학습 자료, 책, 강의가 있나요? vs. 흔하지 않은 분야인가요?
  • 근 5년 이내에 좋은 리뷰가 있거나 10건 이상의 리뷰가 있는 자료
    • 자료에 문헌이나 참조로 목록을 확장
    • 문헌이나 참조 목록에서 재귀적으로 목록을 확장
    • 문헌이나 참조 목록의 저자를 확인, 최근 저작 활동이나 강연, 강의를 찾아본다
  • 체계적인 강의(MOOC)나 교재가 있는지 검색
  • 대학 학과나 전공이 존재하는 분야라면 대학 실라버스를 확인
    • 전체적인 그림을 그리고 계획하는데 도움
  • 학습을 위한 읽기 목록을 작성
    • 딱딱한 글에 한정하지 않고 교양 과학서 등도 추가할 수 있음

자신에게 맞는 일정 짜기

  • 주간 또는 일간 시간 블럭을 설정해서 학습
  • 비슷한 관심사의 사람들을 찾아서 함께 학습
  • 학습에 맞는 환경 찾기: 도서관, 카페, 집 등

지속하는 팁

  • 학습하는 내용, 참조 등을 잘 정리하기
  • 학습에 참여 유도하기
    • 스터디 그룹이나 북클럽에 가입
    • 도전 과제를 만들어 수행하기 (예시: The Writers' Hour)
  • 학습과 적용 사이 균형을 유지하기
    • 5개 글을 읽은 후에는 500자 글로 정리해보기
  • 무슨일이 있어도 항상성을 유지하기
    • 데드라인을 정한다든지
  • 진행도를 추적하기
    • 주제, 하이라이트, 핵심 정리 등

Jeffrey MorganBuild Bigger With Small AI: Running Small Models Locally을 보고 정리했다. 항상 큰 모델에 대한 얘기만 강조되다 보니 작은 모델로는 무엇이 가능한가 싶었었는데 이 발표가 이해에 많은 도움이 되었다.

  • (발표자는 Docker에서 근무하다가 현재는 Ollama를 만들고 있음)
    • 다른 도메인 같지만 여러 모델을 운용한다는 점에서 컨테이너처럼 문제를 해결
  • 작은 모델
    • 대형 클라우드 모델과 유사한 아이디어와 구조로 구현
    • 0.5B - 70B 파라미터
    • 적은 용량 (몇 GB 정도)
    • 일반 하드웨어서도 충분히 구동 가능 (적은 용량)
    • 무료 & 자유롭게 사용 가능
  • 작은 모델의 장점
    • 로컬에서 구동되기 때문에 낮은 지연 달성 가능
    • 적은 파라미터로 연산이 적어져서 높은 출력량, 즉각적인 응답을 받을 수 있음
    • 데이터 프라이버시, 보안에 유리
    • 비용이 상대적으로 적음 (이미 있는 컴퓨팅 자원 활용, 통합 비용 등)
    • 다양한 선택지 (Llama, Gemma, Phi, ... 다양한 전문성을 가진 모델을 사용 가능)
  • 모델과 데이터
    • 검색 증강 생성 (Retrieval Augmented Generation, RAG)
      • 데이터를 모델이 이해할 수 있는 형태로 변환해서 모델에 전달
      • 데이터는 벡터 스토어에 저장 (키워드: 벡터 스토어, 임베딩, 도큐먼트)
    • 도구 호출 (Tool calling)
      • 모델이 직접 코드를 구동할 수 있게 함 (예시: DuckDB의 쿼리 도구)
      • 별도의 전처리 과정이 필요 없음
      • 최근 모델에서 지원
  • 적용
    • 외부에 노출되는 서비스보다 내부에서 활용하기 유리 (적은 리소스)
    • 지식 베이스, 헬프데스크, 코드 리뷰, 이슈 배정, 데이터 엔지니어링, 리포팅, 보안, 컴플라이언스 등
    • 큰 모델과 작은 모델 함께 사용도 충분히 가능
  • 데모
    • 일반 예시: gemma2:2b, 간단한 대화 프롬프트 시연
    • RAG 예시: gemma2:2b, llama_index로 텍스트 파일을 documents로 변환한 후 DuckDB VectorStore를 활용
    • Tool Calling 예시: qwen2.5-coder, 질문에 대해 SQL를 생성한 후 duckDB에서 답을 찾아 반환
  • 레퍼런스

RAG 예시 코드

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, Settings
from llama_index.core.node_parser import TokenTextSplitter
from llama_index.vector_stores.duckdb import DuckDBVectorStore
from llama_index.core import StorageContext
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding

# models
Settings.embed_model = OllamaEmbedding(model_name="all-minilm")
Settings.llm = Olama (model="gemma2:2b", temperature=0, request_timeout=360.0)

# load documents into a vector store (DuckDB)
documents = SimpleDirectoryReader(input_files=["facts.txt"]).\
              load_data(show_progress=True)
splitter = TokenTextSplitter(separator="\n", chunk_size=64, chunk_overlap=0)
vector_store = DuckDBVectorStore()
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex(splitter.get_nodes_from_documents(documents), \
  storage_context=storage_context, show_progress=True)
query_engine = index.as_query_engine()

try:
    while True:
        user_query = input (">>> ")
        response = query_ engine. query (user_query)
        print (response)
except KeyboardInterrupt:
    exit()

Tool Calling 예시 코드

import duckdb
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, ToolMessage

con = duckdb.connect(database="ducks.duckdb")
schema = con.execute(f"DESCRIBE ducks"). fetchdf()
schema_str = schema.to_string(index=False)

@tool
def query(query: str) -> str:
    """Queries the database for information and returns the result.
    Args:
    query: The query to run against the database.
    """
    return str(con.execute(query).fetchone()[0])

llm = ChatOllama (model="qwen2.5-coder") .bind_tools([query])
try:
    while True:
        user_query = input(">>> ")
        messages = [HumanMessage(f"You are provided You are given a DuckDB   \\
         schema for table 'ducks': \n\n{schema_strschema_str}\n\n.\n\nAnswer \\
          the user query: '{user_query}' in a single sentence.")]
        ai_msg = llm.invoke(messages)
        messages.append(ai_msg)

        for tool_call in ai_msg.tool_calls:
            print('>>> tool_call:', tool_call)
            selected_tool = {"query": query}[tool_call["name"].lower()]
            tool_output = selected_tool.invoke(tool_call["args"])
            print('>>> tool_output:', tool_output)
            messages.append(ToolMessage(tool_output, tool_call_id=tool_call["id"]))

        response = llm.invoke(messages)
        print(response.content)
except KeyboardInterrupt:
    exit()

기분이 좋아서 오랜만에 짧게라도 글을 쓰려고 페이지를 만들었다.

드디어 졸업했다. 배운 것 없이 끝난 기분이라 졸업 당해버렸나 하는 감정이랑 무슨 이유든 일단 끝냈으니 다행이라는 감정이 반반이다. 졸업 이후에 대한 불안감은 오히려 덜하다. 생성형 AI가 범람하는 시대에서는 학업이야말로 인간을 가장 인간답게 유지해 주는 행위인 것 같다. 그래서 오히려 내 큰 자산이 될 경험이었다고 든든한 기분이 든다.

계속 회사와 학교를 함께 다니면서 늘 시간이 없었다. 풀타임 근무하고 매주 수업과 과제를 해가는 일이 전혀 쉽지 않았고 하루하루가 닳아가는 시간이었다. 이걸 어떻게들 해내는 것인지. 마지막 학기는 회사를 정리하고 학교에 집중했다. 학기 끝나고서 생각해 보면 집중을 더 했다기보다는 그동안 지쳐버렸던 것에 가까웠다. 내가 생각보다 너덜너덜한 상태였구나 하면서도 바쁘게 돌아가는 학사일정에 정신없었다.

학교에서 무엇을 배웠는지도 정리해 두고 싶다.

올해는 또 많은 변화를 계획하고 있다. 아무래도 샌디에고를 떠나게 될 것 같아서, 남은 시간 동안 샌디에고에서의 추억을 부지런히 만들기로.

글은 쓰지 않으면서 웹사이트는 조금씩 계속 만져보고 있다. 개인 웹사이트를 만드는 분들을 보며 나는 왜 이렇게 기록하게 되었는지 고민하면서. 멋진 분들 알게 되어 즐겁고 더 찾아서 목록을 만들어두고 싶다. 보일 때마다 즐겨찾기에 메모하고 있는데, 좀 더 잘 정리할 방법이 없을까 고민이다.

글로 쓰지 못했던 많은 생각들을 좀 정리하는 시간도 갖고 앞으로 나갈 방향을 정리하고 싶다.

봄이 왔는지 날이 따뜻하다.

색상을 바꿔요

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

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