Vim Remote 기능 활용하기

2023년 1월 18일

간단하게 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 환경에서 단축키로 해당 스크립트를 활용하면 메모를 토글 버튼으로 열 수 있게 된다.

2022년 업데이트

2023년 1월 16일

회고라고 말하기엔 그냥 이렇게 살았더라 정도가 되는 것 같아서, 회고 대신 업데이트로 제목을 붙였다.

2022년은 생각보다 더 바빴다. 아무래도 변화가 많은 해가 될 예정이라서 굵직한 계획만 있었지 세세한 일은 여유를 갖기로 마음 먹었었다. 전반에 보냈던 시간을 지금 생각하면 후반은 얼마나 치열하고 정신 없었는지, 다음 학기가 시작된 지금도 차분한 마음 갖기가 쉽지 않다. 결과만 보면 모든 일을 잘 해냈지만 여전히 내 자신을 돌보는 것에 소홀했던 것 같다.

늘 여행으로 올 때마다 살고 싶은 동네라고 노래하던 샌디에고에 이렇게 와서 살게 되었다. 학업과 업무 사이에서 아직 제대로 적응 못하고 정신없이 치여 지내고 있다. 새로운 학교에서 겪는 좋은 학습 환경과 인프라에 대해서도, 처음으로 완전 리모트로 근무하고 있는 현재 회사 이야기도, 그 외 두루두루 쓰고 싶은 이야기가 참 많았는데 차분하게 앉아서 글을 적을 여유가 이렇게도 없다. 난 글 쓰면서 생각도 정리하고 그래야 하는 사람인데 글을 못쓰니까 더 정신 없이 시간이 지나버린 것만 같아 아쉽다.

일정에 끌려가는 것이 아니라 내가 주도되는 삶을 사는 것이 2023년 목표다. 지금도 충분히 많은 일을 하고 있는 상황인데도 갈 길이 멀다는 생각이 자꾸 앞서서 괴롭힐 때가 자주 있었다. 2023년에는 스스로를 못살게 구는 나와 결별하고, 칭찬과 응원 더하는 나 자신과 함께 했으면 좋겠다.

그리고 이 모든 과정이 수많은 도움 속에서 지속되고 있다는 점을 늘 상기하게 된다. 정신없고 바쁘던 과정에서 잠깐 떨어져서 지내기도 했어야 했던 민경 씨에게 가장 미안하고 고맙다. 슬쩍 회고를 안쓰고 넘어가려는 맘도 있었는데 그래도 써야 한다고, 짧게라도 이렇게 글을 쓰니까 생각이 확실히 정돈되는 느낌이다. 나를 너무나도 잘 아는 사람과 함께하는 일은 이렇게 감사할 일의 연속이다.

2023년에는 현재를 건강하게 잘 유지하는 것이 목표다. 이 도시에서 지내는 동안 별 탈 없이 즐겁고 평안했으면 좋겠다.

월별 있던 일

  • 1~2월: 편입 서류 지원, 결과를 기다리는 삶. 빠듯하게 들은 수업 덕분에 한 학기 여유가 생겼고 재충전의 시간을 가졌다.
  • 3~4월: 뒷마당 조경 공사, 트럼펫 연습
  • 5월: 커뮤니티 컬리지 졸업
  • 6월: 취업, 리모트로 업무 시작.
  • 7~9월: 일, 샌디에고 이사 준비 및 이사.
  • 10월: 편입한 학교에서 첫 학기 시작. 토이 프로젝트로 안드로이드 런처 만들기도 진행.
  • 11~12월: 바쁜 삶으로 2022년 마무리. 쿼터제로 운영되는 학교라서 학기가 훨씬 정신 없이 지나갔고 이제 좀 정신 차리니 1월 중순이 되어버렸다.

새로 만난 기기

  • Gaggia Classic Pro: 정말 오랜 기간 노래를 부르던 에스프레소 머신을 드디어 구입했다. 정말 매일 사용하다시피 하는데 가끔 커피에 밤잠 설치는 날도 생겼다. 기계도, 그라인더도 중요하지만 가장 중요한 것은 신선한 원두였다!
  • Apple AirPods (3세대): 선물로 받았다. 무선이 이렇게 좋구나 🎵
  • Amazon All-new Kindle (2022): 기존에 갖고 있던 1세대 페이퍼화이트를 반납하고 할인 받아 교체했다. 정말 가볍고 작아서 어디 다녀도 꼭 들고 다닌다. 읽을/읽고 싶은 책은 늘 많은데 시간과 마음의 여유가 부족하다.
  • Amazon Fire HD 8 Plus (2020): 블랙프라이데이 할인으로 구입했고 집에서 미디어 소비용으로, 사용하지 않을 때는 시계로 사용되고 있다.

수집한 메모들

글을 많이 쓰지도 못하고 읽는 것도 많지 않았던 해지만 작게라도 읽고 정리하는 일은 꾸준하게 할 수 있었다. 때때로 다른 감정에서 남겨둔 메모라 서로 상충하기도 하지만, 오래 기억하고 싶은 줄글을 여기에 붙여둔다.

그림이든, 운동이든, 산책이든, 노래부르기든, 춤추기든. 잘 할 필요는 없다. 우리는 장인이 되려는 게 아니다. 우리는 살려고 좋아하는 것을 하는 것이다. — 너무 참고 절제해도 좋지 않아. 너의 두뇌를 위해서

케이크 한 조각을 서로 아끼며 잘라 먹다 보니 어찌어찌 버텨지고, 버텨지니까 열정이 생기고 노력을 하는 거예요. 케이크 한 조각 놓고 쪼개 먹는 건 그만하고 홀케이크 굽는 연습을 해야 하는데, 많은 업계에 그 연습이 안 돼 있는 것 같아요. — 문명특급 홍민지 PD 인터뷰

미래를 예측하는 가장 좋은 방법은 미래를 발명하는 것입니다. — 미래를 예견하기

좋지 않은 프로그래머는 “코드”에 대해 걱정하고 좋은 프로그래머는 “데이터 구조”와 그것들의 관계를 걱정한다. — 리누스 토발즈가 말하는 좋은 프로그래머

좋은 PR을 만드는 건 결국 좋은 리뷰입니다. — 오픈소스 프로젝트에서 배운 코드리뷰

일을 하다보면 뭔가 적당히 이야기 되지 않고 어딘가에 찝찝함이 남는 경우가 있잖아요. 그 찝찝함을 명쾌하게 가시적으로 만들 수 있는 게 중요하다고 생각해요. 사실 그게 정말 힘들거든요. 이런 부분을 매끄럽게 할 수 있는 능력이 생기면 그때 저는 제가 성장했다고 생각할 것 같아요. — 무엇을 만들어도 제대로 만드는 사람

프로그래밍 언어를 둘러싼 종교들에 빠지지 말고, 언어의 참 목적은 재밌는 일을 하는 도구라는 점을 잊지 마세요. — 오랜 프로그래머로부터의 조언

사람이 자신을 연민하기 시작하면 어른의 성장이 더뎌져요. 그 시절은 끝났고 저는 거기서 나왔어요. 현재에 집중하지 않으면 그때를 복구하는 삶밖에 되지 않고 어린 시절의 손해를 어른이 갚아야 해요. — 정지음 작가의 위로법

대충 1일 1시간은 공부해야 일하는데 필요한 최소한의 지식을 얻을 수 있고, 2시간은 써야 현재 트렌드 내에서 새로운 아이디어를 추구할 수 있고, 3시간은 써야 남들이 안하는 창의적인 기회를 찾을 수 있는 것 같다. 물론 아주 두리뭉실하게 하는 얘기. — 어엉부엉님 트윗

어떤 분야에서 깊이를 가져 본 사람은 자신이 모르는 분야에 던져져 새로운 일을 하더라도 답을 찾아내고, 누구의 도움이 필요한지를 포착하기를 더 빠르게 할 수 있다고 생각합니다. — 여전히 진로고민 : 확신은 어떻게 가질 수 있을까요?

Skepticism should also apply to yourself. You are also fallible, and you should acknowledge this. Be your number one critic. Spotting your mistakes first is extremely beneficial for your personal growth, and it also gives others less chance to criticise you. — Lessons Learned After 20 Years of Software Engineering

고개 쳐박고 오랫동안 공부한다고 성장하지 않는다. 자기객관화, 인정, 수용적인 태도가 깔려있어야 비로소 성장할 준비가 된다. 피드백을 받아들이지 못하면 성장할 수 없고, 성장하지 않으면 그 끝은 도태됨이다. — minieetea님 트윗

자아와 자기인식은 많은 문제를 낳으며 우리 삶을 필요 이상으로 힘들고 불행하게 만듭니다. 자기 생각 자체를 줄여야 해요. 일상생활을 영위하기 위해 필요할 때만 하는 겁니다. — 나는 왜 내가 힘들까

기술의 너머와 선택의 이유에 호기심을 잃지 않으면서도 무엇을 위해 코드를 써내려가고 있는지 잊지 않기. 나를 위해 정리하고 기록하지만 다른 사람에게도 도움이 됩니다. ㅡ 듣되 맹신 않기

재능은 선택할 수 없지만 꾸준함은 선택할 수 있기 때문이다. … 꾸준히 출석하는 애는 어김없이 실력이 늘었다. 계속 쓰는데 나아지지 않는 애는 없었다. — 재능과 반복

조급함을 다스리는 건 중요하다고 생각한다. 꾸준히 노력을 하면 언젠간 찬찬히 빛을 발할 거라는 믿음과 중심을 잃지 말고, 내 커리어랑 인생을 길-게 보자. 오늘 내일하고 그만 둘 거 아니니까. — 네트워킹, 커피 챗 중 가장 자주 들었던 조언들

FileChooser Dialog 크기 설정하기

2022년 10월 26일

파일을 선택해야 할 때 나오는 다이얼로그 크기가 제각각에 너무 작게 나와서 어떻게 변경하는지 찾아 정리했다. VLC는 QT고 Chrome은 gtk를 사용하고 있는 등 중구난방으로 동작하는데 이건 내가 사용하는 배포판의 문제인지 원래 다 그런지는 잘 모르겠다.

gtk 설정

gsettings로 설정할 수 있다고 하는데 지금 사용하고 있는 gtk 버전에 따라 설정이 다르고 어떤 버전이 사용되고 있는지 확인할 방법을 찾을 수 없었다. 대신 dconf로 모든 설정을 저장해서 직접 확인하고 적용하는 것이 가능하다.

$ dconf dump / > dconf-settings.txt

이제 dconf-settings.txt를 열어서 FileChooser를 찾는다. window-size 및 기타 설정을 적당히 변경한 후에 다시 적용한다.

$ dconf load / < dconf-settings.txt

qt 설정

css의 변형인 qss를 통해서 사이즈를 변경할 수 있다.

$ qt5ct

상단 탭에서 Style Sheets를 누른 후 새로운 qss 파일을 생성한다.

새로운 파일을 체크한 후 Edit을 누르고 다음 내용을 추가한다. 크기는 알맞게 선택한다.

QFileDialog {min-height: 900px; min-width: 1600px;}

저장 후 적용 버튼을 누르면 끝난다.

vim netrw로 탐색하기 메모

2022년 9월 11일

단축키/명령

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

설정

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

루나 디스플레이 mac-to-mac 모드 짧은 사용기

2022년 9월 4일

그동안 아이맥 정말 잘 사용했지만 아쉽게도 타겟 디스플레이 모드가 지원되지 않는 2014년 레티나 5K 모델이다. 게다가 더 이상 macOS 업데이트도 해당이 없어서 최근 macOS에서 사용할 수 있다는 에어플레이 기능도 상관 없는 일이 되어버렸다. 그동안 회사 랩탑으로 일을 해야 하는 상황에서는 아이맥을 전혀 활용하지 못하는 상황이 계속 되었는데 루나 디스플레이 (Luna Display)mac-to-mac 모드를 알게 되었다. 이 모드에서 선더볼트 케이블 연결을 지원한다는 얘기에 그래도 무선보다는 안정적일 것이란 기대를 하고 구입했다. 맥북 프로 (16인치, 2019)와 함께 연결해서 사용해봤다.

사용은 아주 간단하다. 화면을 공유할 쪽 컴퓨터에 Primary 앱을 설치하고 화면을 공유 받을 쪽에서 Secondary 앱을 설치한다. 실행하면 설명에 따라 시스템 설정을 변경하고 동글을 꽂는다. 몇 단계를 거치면 바로 화면을 볼 수 있다.

당연하지만 연결하는 미디엄의 한계(선더볼트2 케이블)가 있기 때문에 설정 수준이 높을 수록 화면 반영 속도가 떨어진다. Wifi로는 처음 설정에만 사용해보고 계속 선더볼트 연결해서 사용하고 있다.

  • 화질을 레티나로 설정하면 선명하고 깔끔한 화면을 볼 수 있지만 프레임이 많이 끊김.
  • 레티나 설정을 끄면 많이 부드러워짐. 여전히 화면 이동이 잦으면 프레임 한계가 조금 보이긴 함.
    • 화면이 동적으로 바뀌는 것이 아니라면 상당히 만족스럽게 사용할 수 있음. 코딩엔 큰 번거로움 없이 쓸 수 있는 정도.
    • 마우스를 자주 사용한다면 미세하게 버벅이는 커서나 화면 스크롤이 약간 느리게 보이는 부분 등이 좀 거슬릴 수도.

어디까지 타협하고 쓸 수 있는가 하는 문제다. 가격이 저렴한 제품은 아니라서 영상도 많이 찾아보고 고민했는데 지금 상황에는 잘 줘서 한 80퍼센트 정도 만족한다. 그래도 아이맥을 아예 디스플레이로 활용하지 못하던 상황보다는 낫다는 쪽이다.

아직도 쌩쌩하게 사용하는 컴퓨터인데 업데이트도 더 이상 없어서 수명이 다한 상황을 걱정하는 것이 아쉽긴 하다. 아이맥이 고장나면 분해해서 보드를 들어내고 디스플레이에 컨트롤 보드를 부착하는 식으로 DIY 프로젝트하는 영상도 많이 보여서 그나마 디스플레이로 계속 사용할 수 있지 않을까 싶다.

PHPStan으로 PHP 제네릭 활용하기

2022년 8월 29일

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

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

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

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

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

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

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

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

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

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

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

// PHPStan: [OK] No errors

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class ClassicBook extends Book
{
}

class RomanceBook extends Book
{
}

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

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

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

getFirstBook($list);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// PHPStan: [OK] No errors

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

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


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