• 지역성의 원칙을 고려한 패키지 구조: 기능별로 나누기

    보다 효율적이고 견고한 패키지 구조에 대한 고민

    들어가며

    패키지 구조의 선택은 프로그래머로서 맞닥뜨리는 단골 고민 주제 중 하나다. 정답이 없는 문제겠지만, 여러 차례 관련된 고민을 하면서 나름의 깨달음을 발견해 글로 남겨본다.

    패키지 구조: 첫 시도

    간단한 쇼핑몰을 만드는 경우를 생각해보자.

    프로젝트를 시작하는 시점엔 코드의 양도 적고, 다루어야 하는 복잡도도 그다지 높지 않다. 그렇다고 모든 파일을 동일한 레벨에 늘어놓을 수는 없기에, 어떤 형태로든 정리를 해나가다 보면 보통 아래와 같은 형태의 디렉터리 구조가 나온다.

    my-project
    ├── components
    │   ├── ProductDetail.tsx
    │   └── ProductItem.tsx
    ├── remotes
    │   └── fetchProducts.ts
    └── utils
        ├── filterProducts.ts
        └── parseProduct.ts

    코드가 속하는 계층(layer)에 따라 분리된 형태다. 유틸리티 함수는 여기, API를 호출하는 코드는 여기, 리액트 컴포넌트는 저기… 작은 프로젝트에서 이런 구조는 잘 동작한다.

    패키지 구조: 첫 시도의 문제

    프로젝트가 어느 정도 복잡해지면, 한 프로젝트 내에서도 서로 상대적으로 독립적으로 동작하는 기능들이 생긴다. 예를 들어, 상품을 보여주는 기능만 있던 우리 프로젝트에 장바구니 기능을 추가하는 경우를 생각해보자. 새로 추가되는 코드를 기존에 존재하던 분류대로 놓아보자면 아래와 같은 형태가 될 것이다.

    my-project
    ├── components
    │   ├── CartItem.tsx
    │   ├── CartTotal.tsx
    │   ├── ProductDetail.tsx
    │   └── ProductItem.tsx
    ├── remotes
    │   ├── addToCart.ts
    │   ├── fetchCart.ts
    │   ├── fetchProducts.ts
    │   └── removeFromCart.ts
    └── utils
        ├── applyDiscount.ts
        ├── filterProducts.ts
        ├── getCartSum.ts
        └── parseProduct.ts

    대부분 프로젝트는 시간이 흐름에 따라 자연스레 덩치가 커지고, 이런 기능의 추가를 여러 차례 겪은 프로젝트는 아래와 같은 형태가 될 것이다.

    my-project
    ├── components
    │   ├── CartItem.tsx
    │   ├── CartTotal.tsx
    │   ├── ProductDetail.tsx
    │   ├── ProductItem.tsx
    │   ├── (...사용자 관련 컴포넌트)
    │   └── (...이벤트 관련 컴포넌트)
    ├── remotes
    │   ├── addToCart.ts
    │   ├── fetchCart.ts
    │   ├── fetchProducts.ts
    │   ├── removeFromCart.ts
    │   ├── (...사용자 관련 리모트 코드)
    │   └── (...이벤트 관련 리모트 코드)
    └── utils
        ├── applyDiscount.ts
        ├── filterProducts.ts
        ├── getCartSum.ts
        ├── parseProduct.ts
        ├── (...사용자 관련 유틸리티 함수)
        └── (...이벤트 관련 유틸리티 함수)

    별 문제는 없어 보인다. 코드는 많아졌지만, 우리가 세운 원칙에 맞게 여러 파일이 잘 분류가 되어있다. 그런데 정말 그럴까? 이렇게 코드베이스가 발전한 시점에서, 제품 목록과 관련된 스펙이 변경되었다고 가정해보자. 프로그래머는 아래와 같은 파일들을 건드리게 될 것이다.

    my-project
    ├── components
    │   ├── CartItem.tsx
    │   ├── CartTotal.tsx
    │   ├── ProductDetail.tsx           // 건드려야 할 파일
    │   ├── ProductItem.tsx             // 건드려야 할 파일
    │   ├── (...사용자 관련 컴포넌트)
    │   └── (...이벤트 관련 컴포넌트)
    ├── remotes
    │   ├── addToCart.ts
    │   ├── fetchCart.ts
    │   ├── fetchProducts.ts            // 건드려야 할 파일
    │   ├── removeFromCart.ts           // 건드려야 할 파일
    │   ├── (...사용자 관련 리모트 코드)
    │   └── (...이벤트 관련 리모트 코드)
    └── utils
        ├── applyDiscount.ts
        ├── filterProducts.ts           // 건드려야 할 파일
        ├── getCartSum.ts
        ├── parseProduct.ts             // 건드려야 할 파일
        ├── (...사용자 관련 유틸리티 함수)
        └── (...이벤트 관련 유틸리티 함수)

    간단한 기능 추가에도 프로젝트의 여러 폴더를 광범위하게 건드리는 작업이 필요하다. 그뿐만이 아니다. 컴포넌트가 유틸리티 함수나 리모트 코드를 참조할 때마다 모든 의존성 경로가 루트 레벨까지 거슬러 올라갔다가 다시 다른 디렉터리를 파고 들어가는 형태로 길고 복잡해진다. (import { applyDiscount } from ‘../../../my-project/utils/applyDiscount’;)

    장바구니, 이벤트 등 특정 기능을 별도의 패키지로 분리하려 할 때는 어떨까? 관련 구현이 프로젝트의 온갖 곳에 흩어져 있어 대수술이 불가피할 것이다. 게다가 이러한 구조에서는 기능별로 기능의 내부 구현 디테일에 해당하는 유틸리티 함수 등을 감싸서 외부에 노출하지 않도록 하기도 여의치 않다.

    정리하면, 계층별로 코드를 분리하는 구조는 아래와 같은 단점을 갖는다.

    • 어떤 기능과 관련된 구현이 코드베이스 전체에 흩어지게 된다. 때문에 특정 작업의 영향 범위가 특정 폴더 수준으로 제한되지 않고 코드베이스 전체로 퍼진다.
    • 구현이 한 군데 모여있지 않아, 특정 기능을 모듈로 분리하는 작업이 어려워진다.
    • 기능의 내부 구현 디테일을 감추고 바깥에 추상화된 형태로 제공하기가 어려워진다.

    지역성의 원칙

    이러한 문제를 해결하기 위한 방법을 제시하기 전에, 잠시 알아보고 갈 개념이 있다. 바로 지역성의 원칙(principal of locality)인데, 영어 위키피디아 항목에는 아래와 같이 설명되어있다.

    컴퓨터 과학에서, 참조의 지역성, 또는 지역성의 원칙이란 프로세서가 짧은 시간 동안 동일한 메모리 공간에 반복적으로 접근하는 경향을 의미한다. 참조 지역성엔 두 종류가 있는데, 시간적 지역성과 공간적 지역성이다. 시간적 지역성이란 특정 데이터 또는 리소스가 짧은 시간 내에 반복적으로 사용되는 것을 가리킨다. 공간적 지역성이란 상대적으로 가까운 저장 공간에 있는 데이터 요소들이 사용되는 것을 가리킨다. 공간적 지역성의 특수한 경우인 순차적 지역성은 배열의 요소를 순회할 때와 같이 데이터 요소들이 선형적으로 배열되어 있고 접근될 때 발생한다.

    In computer science , locality of reference, also known as the principle of locality, is the tendency of a processor to access the same set of memory locations repetitively over a short period of time. There are two basic types of reference locality – temporal and spatial locality. Temporal locality refers to the reuse of specific data, and/or resources, within a relatively small time duration. Spatial locality (also termed /data locality/ refers to the use of data elements within relatively close storage locations. Sequential locality, a special case of spatial locality, occurs when data elements are arranged and accessed linearly, such as, traversing the elements in a one-dimensional array.

    조금 더 풀어서 설명하면, 보통의 프로그램에서는 아래와 같은 두 경향이 존재한다.

    • 특정 메모리가 한 번 참조되었다면, 그 메모리는 조만간 다시 참조될 확률이 높다. (시간적 지역성)
    • 특정 메모리가 한 번 참조되었다면, 그 메모리의 근처에 있는 메모리도 조만간 참조될 확률이 높다. (공간적 지역성)

    예를 들어, 배열을 순회하는 경우 첫 요소에 접근한 이후 곧바로 근처 메모리에 존재하는 다음 요소들에 차례로 접근하거나, 반복문의 본문에서 참조하는 데이터는 첫 참조 직후에도 여러 차례 반복해서 참조 당하는 식이다.

    이는 프로그래머가 그것을 목표로 하고 코드를 짜지 않더라도 일반적인 프로그램에서 나타나는 보편적인 경향이다. 컴퓨터의 핵심 구성 요소 중 하나인 캐시는 이 경향을 이용해 적은 용량에도 불구하고 효율적인 보조 기억 장치로 동작할 수 있다.

    매우 간단하게 설명하면, 어떤 메모리 공간에 대한 접근이 일어났을 때 캐시에는 그 공간을 포함하는 작은 크기의 메모리 블록(block)이 적재된다. 이 블록의 크기는 전체 메모리 공간보다 훨씬 작지만, 지역성에 의해 당분간 해당 블록에 포함되는 메모리가 참조될 확률이 높으므로, 결과적으로 많은 접근을 효과적으로 처리할 수 있다. (캐시의 동작에 더 관심이 있는 분께는 💵 캐시가 동작하는 아주 구체적인 원리 를 추천)

    지역성의 원칙을 고려한 패키지 구조

    그럼 다시 패키지 구조의 문제로 돌아와, 유한한 자원인 프로그래머의 머릿속 공간을 캐시라고 생각해보자.

    모든 프로그램 메모리를 캐시에 올릴 수 없는 것과 유사하게 프로그래머 역시 복잡하고 거대한 프로젝트 전체의 맥락을 한 번에 머리 속에 들고 있을 수 없다. 하지만 일단 코드베이스 내 특정 영역, 내지는 특정 폴더의 동작과 맥락을 파악할 수 있다면, 해당 영역에 대해서는 마치 캐시에 이미 적재된 데이터 블록에 접근하듯 빠르게 처리가 가능하다.

    문제는 캐시에 올릴 데이터 블록을 나누는 기준, 즉 폴더 구조를 어떻게 결정할 것인가이다. 캐시는 프로그램이 메모리에 접근하는 패턴에 존재하는 지역성을 기반으로 메모리상의 연결된 주소 공간을 한 덩어리로 다룬다. 이와 유사하게 프로그래머가 프로그램 내 파일에 접근하는 패턴에서 지역성을 발견할 수 있다면? 그에 맞추어 효율적인 패키지 구조를 찾을 수 있을 것이다.

    현실 세계에서의 프로그래머의 업무는 많은 경우 연결된 맥락을 갖는다. 예를 들어 장바구니 개편이라는 업무를 수행하는 과정에는 장바구니라는 기능에 속하는 다양한 유틸리티, 리모트, 컴포넌트 코드 등이 비슷한 시기에 수정된다. 또한, 그에 수반하여 장바구니라는 기능과 결제, 상품 목록 등에도 적절한 변화가 필요할 수도 있다.

    이때 프로젝트의 기능 각각을 하나의 블록으로 바라보면 일반적으로 아래와 같은 지역성이 성립함을 알 수 있다.

    • 시간적 지역성: 특정 기능에 속하는 코드를 추가, 수정, 삭제한 프로그래머는 조만간 그 기능에 속하는 다른 코드를 건드릴 확률이 높다.
    • 공간적 지역성: 특정 기능에 속하는 코드를 추가, 수정, 삭제한 프로그래머는 그 기능 주변부의 (상대적으로 긴밀히 연관된) 다른 기능에 속하는 코드를 건드릴 확률이 높다.

    이러한 지역성은 코드가 속하는 계층이 아닌, 속하는 기능을 기준으로 발생한다.

    • 장바구니의 유틸리티 함수를 수정한 프로그래머가 장바구니의 리모트, 컴포넌트들도 비슷한 시기에 수정할 가능성
    • 장바구니의 유틸리티 함수를 수정한 프로그래머는 일반적으로 그 이후에 다른 유틸리티 함수 수정할 가능성

    둘을 비교하면 전자가 후자보다 높은 것을 생각하면 쉽다.

    이러한 지역성이 반영되지 않은, 계층을 기준으로 나눈 프로젝트 구조에서는 필연적으로 캐시 미스(cache miss)가 자주 발생한다. 한 폴더 내의 파일을 수정한 뒤, 이어지는 작업을 위해 다른 폴더를 찾아가야 하는 상황이 반복되는 것이다. 높은 캐시 미스 비율이 성능을 해치듯, 이러한 상황이 자주 반복되면 프로그래머의 생산성은 낮아진다.

    이 상황을 타개하기 위해선 프로젝트의 내부 구조가 지역성이 발생하는 단위, 즉 기능별로 묶이도록 재배열할 필요가 있다. 위의 예시 프로젝트를 다시 정렬한다면 아래와 같을 것이다.

    my-project
    ├── shared
    │   └── (여러 기능이 공통으로 사용하는 코드)
    ├── cart
    │   ├── components
    │   │   ├── CartItem.tsx
    │   │   └── CartTotal.tsx
    │   ├── remotes
    │   │   ├── addToCart.ts
    │   │   ├── fetchCart.ts
    │   │   └── removeFromCart.ts
    │   └── utils
    │       ├── applyDiscount.ts
    │       └── getCartSum.ts
    ├── product
    │   ├── components
    │   │   ├── ProductDetail.tsx
    │   │   └── ProductItem.tsx
    │   ├── remotes
    │   │   └── fetchProducts.ts
    │   └── utils
    │       ├── filterProducts.ts
    │       └── parseProduct.ts
    ├── event
    │   └── (이벤트 관련 코드)
    └── user
        └── (사용자 관련 코드)

    프로젝트는 최상위에서 서로 긴밀히 연결된 기능별로 나누어진다. 장바구니에 관련된 작업은 cart 폴더 내에서, 제품 목록에 관련된 작업은 product 폴더 내에서 이루어지고, 여러 기능에서 공용으로 사용하는 코드는 shared 이하에 존재한다. 특정 목적 달성은 높은 응집력(high cohesion)을 갖는 특정 폴더 내에서, 관련 맥락을 머리 속 캐시에 적재한 상태로 이루어진다. 예를 들어, 아까와 동일하게 제품 목록과 관련된 스펙 변경사항을 반영하기 위해 건드려야 할 팔일은 이제 아래와 같이 한 폴더 내에 존재한다.

    my-project
    ├── shared
    │   └── (여러 기능이 공통으로 사용하는 코드)
    ├── cart
    │   ├── components
    │   │   ├── CartItem.tsx
    │   │   └── CartTotal.tsx
    │   ├── remotes
    │   │   ├── addToCart.ts
    │   │   ├── fetchCart.ts
    │   │   └── removeFromCart.ts
    │   └── utils
    │       ├── applyDiscount.ts
    │       └── getCartSum.ts
    ├── product
    │   ├── components
    │   │   ├── ProductDetail.tsx     // 건드려야 할 파일
    │   │   └── ProductItem.tsx       // 건드려야 할 파일
    │   ├── remotes
    │   │   └── fetchProducts.ts      // 건드려야 할 파일
    │   └── utils
    │       ├── filterProducts.ts     // 건드려야 할 파일
    │       └── parseProduct.ts       // 건드려야 할 파일
    ├── event
    │   └── (이벤트 관련 코드)
    └── user
        └── (사용자 관련 코드)

    기능별 패키지 구조의 장점은 캐시 히트 비율만이 아니다. 연관된 코드가 모두 같은 폴더에 존재하므로 추후 기능을 간단하게 삭제, 또는 별도 패키지로 추출할 수 있다. 그뿐만 아니라, 각 기능 폴더의 최상단을 기능 내부를 추상화하는 인터페이스처럼 사용해, 기능 내에서만 쓰일 코드는 감추고 외부서 접근해야 할 코드만 노출하는 식의 추상화를 구현하기도 훨씬 쉽다. 특정 기능 관련 구현이 여러 폴더에 흩뿌려져 있을 땐 달성하기 어려운 목표들이다.

    물론 계층별로 코드를 나누는 일이 무조건 나쁜 것은 아니다. 위의 예시 코드만 보아도 기능별 폴더 안에선 계층별로 코드를 분리하고 있다. 상위 레벨에서 기능별로 나누어져 내부적으로는 마치 작은 하나의 프로젝트처럼 동작하는 특정 기능 폴더 내에서는 계층별 코드 분리가 의존성의 방향 관리를 비롯한 여러 목적에 도움이 될 수 있을 것이다.

    맺으며

    이 글은 며칠 전에 코드 리뷰를 하다 남긴 이 코멘트에서 시작되었다.

    저 자신도 습관적으로 어길 때가 많지만… 개인적으로는 도메인별로 (offboarding, probationComplete, …) 폴더를 나누는 것이 코드의 종류별로 (remote, model, …) 나누는 것에 비해 서로 참조하고 맥락을 공유하는 애들이 근처에 살게 되어서 관리가 편했던 것 같습니다.

    해당 코멘트에 다른 백엔드 엔지니어 분께서 “Package by feature, not layer” 라는 제목을 가진 링크와 함께 자바 쪽에 비슷한 컨벤션이 존재함을 알려주셨고, “Packages by Feature“ 라는 키워드로 검색해보니 여러 글이 나왔다. 이런 장점이 있다 보니 당연히 이미 수많은 사람이 관련 프랙티스를 정리하고 소개한 리소스가 넘쳐나는데, 그걸 뒤늦게 발견한 것이다. (좀 다른 이야기지만 디자인 패턴 부류의 지식에 별로 관심을 가지지 않고 살았는데, 고전에 해당하는 자료만이라도 한 번쯤 읽어봐야겠다는 반성을 했다.)

    경험적으로 얻은 느낌이 이미 널리 알려진 개념과 매치되는 것이 반가워(?) 그냥 간단한 소개와 함께 링크만 공유할까 하다가, 문득 이러한 접근의 차이를 지역성으로 설명할 수도 있겠다는 생각이 들어 글로 정리해보았다. 얼마나 설득력이 있는지는 잘 모르겠지만 😅 개인적으로는 글로 적으며 처음의 막연했던 생각을 어느 정도 정리할 수 있어 좋았다.

    정답이 없는 문제인 만큼, 앞으로 생각이 바뀌거나 더 나은 방법을 찾는 일이 생길 수 있을 것이다 (그리고 그러길 바란다). 지금은 지금 드는 생각을 기록하고, 그런 상황이 생기면 그때 또 포스팅해 보기로!

  • 서로소 유니온 타입을 사용한 안전한 데이터 모델링

    이것이거나 저것이거나 그것인 데이터를 어떻게 다룰까?

    이 글의 모든 예시 코드는 TypeScript 코드입니다.


    들어가며

    코드를 짜다보면 “여러 가지 중 하나의 경우”를 모델링할 일이 많이 생긴다.

    예를 들어, 쇼핑몰 애플리케이션에서 사용자의 결제 수단을 모델링하는 경우를 생각해보자. 결제 수단은 신용카드일수도, 가상 계좌를 통한 계좌 이체일수도, 토스 결제등의 간편결제수단일 수도 있다. 열거형은 이런 “여러 경우의 수 중 하나”인 데이터를 모델링하기 위해서 흔히 사용되는 수단이다. (이 경우의 수를 이하 가지/branch/라 부르자) TypeScript 역시 열거형을 지원한다.

    enum PaymentMethodType {
      CreditCard,
      BankTransfer,
      Toss,
    }
    

    하지만 실제 데이터의 모델링이 이 정도에서 끝나는 일은 흔치 않다. 위 예시를 이어가보자. 신용카드의 경우는 카드 번호, 카드사 등의 정보를, 계좌 이체의 경우 해당 사용자에게 할당된 가상계좌 정보를 추가로 가질 것이다. 이 때, 이 추가적인 정보는 어떤 가지에 해당하는 데이터인지에 따라 필요할 수도, 그렇지 않을 수도 있다.

    이런 데이터를 어떻게 모델링하면 좋을까?

    첫 번째 시도: 선택 속성

    가장 쉽게 생각할 수 있는 방법은 경우에 따라 존재할 수도, 그러지 않을 수도 있는 모든 필드를 선택 속성(optional property)로 정의하는 것이다.

    enum PaymentMethodType {
      CreditCard,
      BankTransfer,
      Toss,
    }
    
    interface PaymentMethod {
      type: PaymentMethodType;
      creditCardInformation?: {
        providerCode: number;
        cardNumber: string;
      };
      bankAccountInformation?: Array<{
        bankCode: number;
        bankAccount: string;
      }>;
      tossUserIdentifier?: string;
    }
    

    새로 정의된 PaymentMethod 타입은 아래와 같이 신용카드, 계좌 이체 등의 결제 정보를 담을 수 있다.

    const creditCardPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
      creditCardInformation: {
        cardNumber: "1234123412341234",
        providerCode: 42,
      },
    };
    
    const bankAccountPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.BankTransfer,
      bankAccountInformation: [
        {
          bankCode: 42,
          bankAccount: "1234123412341234",
        },
      ],
    };
    

    원하는 값을 표현할 수 있게 되었다! 이걸로 충분할까?

    사실 이 타입은 몇 가지 문제를 안고 있다. 예를 들어, 지금의 PaymentMethod 타입은 아래와 같은 값도 허용한다.

    const weirdPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
      creditCardInformation: {
        cardNumber: "1234123412341234",
        providerCode: 42,
      },
      bankAccountInformation: [
        {
          providerCode: 42,
          cardNumber: "1234123412341234",
        },
      ],
    };
    
    const anotherWeirdPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
    };
    

    하지만 신용카드 결제 수단 데이터가 가진 bankAccountInformation 필드의 의미는 무엇일까? 또한 신용카드 정보가 없는 신용카드 결제 수단 데이터는 과연 올바른 값일까? 한 마디로, 현재의 PaymentMethod 타입은 불가능한 상태를 불가능하게 만들지 않는다.

    그 뿐만이 아니다. 어떤 함수가 PaymentMethod 타입의 값을 받되, 해당 값이 신용카드 결제수단 데이터일 때에만 신용카드 정보를 쓰고픈 경우를 생각해보자. 매번 이 값이 신용카드 결제수단 데이터인지(paymentMethod.type === PaymentMethodType.CreditCard), 그리고 신용카드 정보가 실제로 존재하는지 (paymentMethod.creditCardInformation != null ) 두 번씩 검사해야하는 불편함이 발생한다.

    function getCreditCardInformation(
      paymentMethod: PaymentMethod
    ): CreditCardInformation | null {
      if (paymentMethod.type !== PaymentMethod.CreditCard) {
        return null;
      }
    
      // `type`을 체크했지만 여전히 paymentMethod.creditCardInformation 필드가 존재함이 보장되지 않는다.
      if (paymentMethod.creditCardInformation == null) {
        return null;
      }
    
      return paymentMethod.creditCardInformation;
    }
    

    이런 문제가 생기는 근본적인 원인 역시 위에서 언급했듯 불가능한 상태를 불가능하게 만들지 않았기 때문이다. 그럼 해결책은 무엇일까? 불가능한 상태를 불가능하게 만드는 것이다!

    개선안: 불가능한 상태를 불가능하게

    한발짝 물러서서, 이 타입으로 표현하고 싶은 데이터의 형태를 생각해보자.

    우리는 PaymentMethod 타입의 다음 셋 중 한 가지에 해당하는 값을 담을 수 있기를 바란다.

    • 카드 정보를 갖는 신용카드 결제수단
    • 가상 계좌 정보를 갖는 계좌이체 결제수단
    • 토스 서비스의 유저 식별자 정보를 갖는 토스 결제수단

    또한, 우리는 PaymentMethod 타입이 다음과 같은 값을 담을 수 없기를 바란다.

    • 카드 정보를 갖는 계좌이체 결제수단 (???)
    • 가상 계좌 정보가 없는 계좌이체 결제수단 (???)

    이 정보를 그대로 타입으로 옮기는 것이 우리 목표다. 다행히도, TypeScript의 문자열 리터럴 타입(또는 숫자 리터럴 타입)과 유니온 타입의 조합으로 이 목표를 달성할 수 있다!

    먼저 각 가지를 나타내는 타입을 정의해보자. 이 때, 해당 데이터가 어떤 가지에 속하는지 나타내는 type 필드를 해당 PaymentMethodType 를 사용한 리터럴 타입으로 정의하자. 리터럴 타입을 사용해 딱 하나의 값으로 고정되는 타입을 정의할 수 있다. 예를 들어, 다음 코드에서 CardPaymentMethod 타입 값의 type 필드 값은 PaymentMethodType.CreditCard으로 고정된다.

    // 신용카드 결제수단은
    // 신용카드를 나타내는 값을 담은 type 필드와
    // 카드 정보를 담은 creditCardInformation 필드를 갖는다.
    type CardPaymentMethod = {
      type: PaymentMethodType.CreditCard;
      creditCardInformation: {
        providerCode: number;
        cardNumber: string;
      };
    };
    
    // 계좌이체 결제수단은
    // 계좌이체를 나타내는 값을 담은 type 필드와
    // 계좌 정보를 담은 bankAccountInformation 필드를 갖는다.
    type BankPaymentMethod = {
      type: PaymentMethodType.BankTransfer;
      bankAccountInformation: Array<{
        bankCode: number;
        bankAccount: string;
      }>;
    };
    
    // 토스 결제수단은
    // 토스를 나타내는 값을 담은 type 필드와
    // 토스 사용자 아이디를 담은 tossUserIdentifier 필드를 갖는다.
    type TossPaymentMethod = {
      type: PaymentMethodType.Toss;
      tossUserIdentifier: string;
    };
    

    각 가지의 정의가 끝났으니, 유니온 타입을 이용해 PaymentMethod 타입이 이 세 가지 중 하나에 해당함을 나타내보자. 유니온 타입을 사용해 이 타입이거나 저 타입인 타입을 정의할 수 있다.

    // 결제수단은
    // 신용카드 결제수단이거나
    // 계좌이체 결제수단이거나
    // 토스 결제수단이다.
    type PaymentMethod = CardPaymentMethod | BankPaymentMethod | TossPaymentMethod;
    

    이게 전부다! 우리가 의도한 바를 그대로 코드로 옮긴, 새로운 PaymentType 이 완성되었다.

    하지만 정말 이 타입이 아까 전보다 나아진 걸까? 이 정의가 우리의 첫 시도보다 나은지 확인해보자. 먼저, 이 타입은 첫 번째 시도에서처럼 우리의 의도에 알맞는 올바른 값을 허용한다.

    // OK
    const creditCardPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
      creditCardInformation: {
        cardNumber: "1234123412341234",
        providerCode: 42,
      },
    };
    
    // OK
    const bankAccountPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.BankTransfer,
      bankAccountInformation: [
        {
          bankCode: 42,
          bankAccount: "1234123412341234",
        },
      ],
    };
    

    하지만, 첫 번째 시도와는 달리, 이제 PaymentMethod 타입 변수에 올바르지 않은 값을 할당할 수 없다. 만약 이상한 값을 할당하려 하면, TypeScript 컴파일러가 빨간펜을 들고 아래와 같이 경고해 줄 것이다.

    // 해석: `type` 필드를 보니 `CardPaymentMethod` 가지일 수 밖에 없는데,
    // `CardPaymentMethod` 가지에 존재하지 않는 `bankAccountInformation` 필드 값이 넘어왔다.
    //
    // TypeError(TS2322)
    // Type ‘{ type: PaymentMethodType.CreditCard; creditCardInformation: { cardNumber: string; providerCode: number; }; bankAccountInformation: { providerCode: number; cardNumber: any; 1234123412341234: any; }[]; }’ is not assignable to type ‘PaymentMethod’.
    //   Object literal may only specify known properties, and ‘bankAccountInformation’ does not exist in type ‘CardPaymentMethod’.(2322)
    const weirdPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
      creditCardInformation: {
        cardNumber: "1234123412341234",
        providerCode: 42,
      },
      bankAccountInformation: [
        {
          providerCode: 42,
          cardNumber: "1234123412341234",
        },
      ],
    };
    
    // 해석: `type` 필드를 보니 `CardPaymentMethod` 가지일 수 밖에 없는데,
    // `CardPaymentMethod` 가지에 필요한 `creditCardInformation` 필드가 없다.
    //
    // TypeError (TS2322)
    // Type ‘{ type: PaymentMethodType.CreditCard; }’ is not assignable to type ‘PaymentMethod’.
    //  Property ‘creditCardInformation’ is missing in type ‘{ type: PaymentMethodType.CreditCard; }’ but required in type ‘CardPaymentMethod’.
    const anotherWeirdPaymentMethod: PaymentMethod = {
      type: PaymentMethodType.CreditCard,
    };
    

    또한, type 필드만 보면 어떤 가지인지 식별할 수 있고, 가지마다 필요한 데이터가 존재함이 타입 수준에서 보장되므로 동일한 의미의 체크를 두 번 할 필요 또한 없어졌다.

    function getCreditCardInformation(paymentMethod: PaymentMethod): CreditCardInformation | null {
      // 여기서 `paymentMethod.creditCardInformation` 필드에 접근하려
      // 시도하면 타입 에러가 발생한다.
      if (paymentMethod.type !== PaymentMethod.CreditCard) {
        return null;
      }
    
      // `type` 체크를 통과하면 paymentMethod.creditCardInformation 필드가 존재함이 보장된다.
      return paymentMethod.creditCardInformation;
    }
    
    function getFormattedDisplayName(paymentMethod: PaymentMethod) {
      // switch - case 문 또한 의도대로 동작한다.
      switch (paymentMethod.type) {
        case PaymentMethodType.CreditCard {
          return `신용카드 ${paymentMethod.creditCardInformation. cardNumber}`;
        }
        case PaymentMethodType.BankTransfer: {
          return `가상계좌 ${paymentMethod.bankAccountInformation.bankAccount}`;
        }
        case PaymentMethodType.Toss: {
          return `토스 ${paymentMethod.tossUserIdentifier}`;
        }
      }
    }
    

    이 쯤 되면 더 나아졌다고 부르기 큰 부족함이 없을 것 같다. 😁

    서로소 유니온 타입

    안전한 PaymentMethod 타입을 정의하기 위해 거친 과정을 생각해보자.

    1. 원하는 타입(PaymentMethod)을 서로 겹치지 않는 여러 가지로 나누었다.
    2. 각 가지의 타입(CardPaymentMethod, BankPaymentMethod, …)을 정의했다. 이 때, 가지 별로 존재하는 데이터와 함께 각기 다른 리터럴 타입의 type 필드를 두어 if-else, 또는 switch-case 등에서의 구분에 사용했다.
    3. 유니온 타입을 사용해 원하는 타입을 “이 경우 또는 저 경우 또는 요 경우 또는…”으로 (PaymentMethod = CardPaymentMethod | BankPaymentMethod) 정의했다.

    이렇게 겹치지 않는 가지들 중 하나로 정의된 타입을 서로소 유니온 타입(disjoint union type)이라 부른다. “서로소”는 교집합이 없는 집합 사이의 관계를 의미하는 “서로소 집합”에서와 같은 의미를 갖는다.

    이런 식의 타입 정의는 매우 다양한 경우에 응용해볼 수 있다.

    네트워크 요청을 통해 데이터를 받아오는 작업의 상태:

    type FetchStatus<Data, Error> =
      | { type: "idle" }
      | { type: "pending" }
      | { type: "fulfilled"; data: Data }
      | { type: "rejected"; error: Error }
      | { type: "cancelled" };
    

    쇼핑몰의 쿠폰 데이터:

    type CommonCouponData = {
      name: string;
      description?: string;
      expireDate?: Date;
      /* ... */
    }
    
    type FixedAmountDiscountCoupon = CommonCouponData & {
      type: 'fixedAmountDiscount';
      discountAmount: Currency;
    };
    
    type RateDiscountCoupont = CommonCouponData & {
      type: 'rateDiscount';
      discountRate: number;
    };
    
    type: FreeDeliveryCoupon = CommonCouponData & {
      type: 'freeDelivery';
    };
    
    type Coupon =
      | FixedAmountDiscountCoupon
      | RateDiscountCoupont
      | FreeDeliveryCoupon;
    

    등등. 가능성은 무한하다!

    맺으며

    서로소 유니온 타입이 어떤 문제를 해결하는지, 어떻게 정의하고 사용할 수 있는지 다루어 보았다.

    이 글에서는 리터럴 타입과 유니온 타입을 사용했지만, 이는 TypeScript의 언어적 제약일 뿐, 언어에 따라 서로소 유니온 타입을 구현하는 방법은 다양하다. Haskell 이나 Rust 등 보다 강력한 타입 시스템을 갖춘 언어는 대부분 서로소 유니온 타입을 정의하는, 그리고 손쉽게 사용할 수 있게 하는 문법(패턴 매칭)을 언어 수준에서 제공한다.

    핵심은 **“둘 이상의 경우의 수를 갖는 타입을 상호배제와 전체포괄을 만족하는 가지들로 나누고, 각 가지의 타입을 정확히 정의한 뒤, 전체를 가지들의 합으로 나타내기”**라 볼 수 있다. 이 원리를 이해한다면 (이 글에서 그랬듯) 언어 수준의 직접적인 지원이 없는 환경에서도 비슷한 접근을 얼마든 구현할 수 있다.

    서로소 유니온 타입을 이용해 프로그래머의 의도를 명확히 타입으로 표현하고, 타입 시스템으로부터 더 많은 안정성을 보장받고, 사용의 편리함까지 얻을 수 있다. 지금껏 그런 적이 없다면, 앞으로 만나는 문제 또는 지금 고민하는 문제를 한 번쯤 서로소 유니온 타입의 렌즈를 통해 바라보길 추천한다. 분명 도움이 될 것이다.

    type Programmer =
      | { type: "lovesDisjointUnion" }
      | { type: "willLoveDisjointUnion"; from: Date };
    

    뱀발: 글을 적기 시작할 무렵, 문득 ‘서로소 유니온 타입이 상속과 어떻게 다르지?’ 라는 궁금증이 들어 트위터에 올렸다. 친절하게 답변해주신 분들이 계셔서 어느정도 정리가 되었는데, 궁금한 분들은 타래를 보시길.

    뱀발2: 한국어 위키피디아 항목은 “Disjoint Union”을 “분리 합집합” 또는 "서로소 합집합"으로 지칭한다. 하지만 프로그래밍의 맥락에서는 “Union Type”의 번역어로 "합집합 타입" 보다는 "유니온 타입”이 훨씬 흔하게 쓰인다고 판단해, “서로소 합집합 타입" 대신 "서로소 유니온 타입" 이라는 번역어를 사용했다.


    부록 1: 서로소 유니온 타입의 다른 이름

    서로소 유니온 타입은 몇 가지 다른 이름도 갖고 있다. 다른 이름보다 압도적으로 많이 불리는 – 사실상 표준인 – 이름이 존재하진 않는 느낌이라, 다 알아두면 쓸모가 있을 것이라 생각한다. 관련해 이전에 적은 글의 일부를 부록으로 첨부. (출처)

    이러한 타입은 ‘서로소 유니온 타입’ 이외에도 여러가지 다른 이름을 갖고 있다.

    먼저 위의 type 속성처럼, 특정 속성을 통해 값이 속하는 브랜치를 식별할 수 있다는 이유로 식별 가능한 유니온(discriminated union type)또는 태그된 유니온(tagged union)이라는 이름을 갖는다. 브랜치를 식별하기 위해 쓰이는 type 속성은 식별자(discriminator) 또는 태그(tag)라 불린다.

    서로소 유니온 타입의 또 다른 이름으로는 합 타입(sum type)이 있다. 다음 코드를 보자. Bool 타입은 2개의 값, Num 타입은 3개의 값을 갖는다.

    type Bool = true | false;
    type Num = 1 | 2 | 3;
    

    이 때 아래와 같이 정의한 서로소 유니온 타입 SumType은 몇 개의 값을 가질까?

    type SumType = { type: ‘bool’, value: Bool } | { type: ‘num’, value: Num };
    

    두 브랜치에 동시에 속하는 값이 없으므로 SumType은 2 + 3 = 5 개의 값을 갖는다. 합 타입이라는 이름은 이렇듯 각 브랜치가 갖는 값의 수를 합친 만큼의 값을 갖는 타입이라는 데에서 유래했다.

    개인적으로 가장 좋아하는 이름은 “합 타입”이다. 이유는 부르기 쉽고 직관적이어서!

    부록 2: 읽을거리

  • 2019 회고, 2020 다짐

    한 해를 돌아보고 다음 해를 준비합니다.

    들어가며

    2018년에도 분명 많이 겪고 느꼈지만, 거창하게 늘어나기만 하던 회고 분량을 결국 정리해 내놓는 데 실패했다. 기록이 없으니 역시나, 시간이 조금 지나니 얼마 되지도 않은 기억이 벌써 흐려지기 시작했다. 같은 실수를 반복하지 않고자 이번엔 완성에 의의를 두고 간략하게 정리해보았다.

    한 시기의 마무리, 새 시기의 시작

    2019

    2019년은 여러모로 한 시기의 마무리처럼 느껴지는 해였다. 지난 5월에는 세 회사에 걸친 산업기능요원 복무가 끝났다. 그리고 얼마 전, <프로그래밍 언어 이론>수업 기말고사를 끝으로 긴 대학 생활도 사실상 끝이 났다.

    돌아보니 대학 생활, 복무 이전의 내가 어떤 사람이었나 잘 기억나지 않는다. 그만큼 – 물론 짧은 인생 중 차지하는 비중이 큰 만큼 당연하겠으나 – 많은 사람을 만나고, 또 헤어지고, 많이 배우면서 많이 달라졌다.

    어떤 의미에서 이제 “해야 할 일”이 없어졌다. “하기로 마음먹을 수 있는 일” 뿐이다. 자유로우면서 동시에 약간 무섭다.

    2020

    쉽지 않은 고민과 우여곡절을 거쳐 작은 팀에 조인했고, 뚝섬유원지 근처에 자취방을 구했다. 회사의 초기 팀원, 초보 자취·살림꾼, 개인 프로젝트의 책임자로서 필요한 일을 찾아, 모르면 배워가면서, 하나씩 차근차근 잘해나갈 것이다.

    한 마디로, 다음 시기의 첫 발걸음을 잘 떼겠다.

    술과 건강

    2019

    나는 어릴 때부터 운동과 친하지 않았다. 인스턴트 음식을 좋아하고, 건강한 생활습관을 가져본 적이 별로 없다. 목디스크는 직업 삼은 프로그래밍과 궁합이 좋아 잊을만하면 꾸준히 존재감을 드러낸다.

    술맛을 알고부터 조금씩 그러나 꾸준히 늘어간 음주량은 점점 시간과 건강을 잡아먹었다. 언젠가부터 이렇게 살면 안 되겠다는 생각이 들었다. 퇴사하고 학교로 돌아가는 결정이 분명해지며, 자연스레 건강한 생활 습관 만들기를 한 학기의 가장 큰 목표로 삼았다.

    먼저 술. 숙취로 가득 찬 어느 여름 아침, 연말까지 금주를 결심했다. 밝히면 지키기가 어려워질 것 같아 주변엔 숨겼지만, 이제 와 밝히자면 애인과 함께는 종종 술을 먹는 절반의 금주였다. 다행히 애인은 술을 자주 먹는 편도, 많이 먹는 편도 아니라 음주량은 극단적으로 줄었다.

    금주를 다짐하는 트윗

    오래 쌓아 올린 음주 습관을 떼기가 처음엔 쉽지 않았다. 하지만 몸 컨디션이 좋아지는 것이 체감되면서 조금씩은 쉬워졌다. 결과적으로 두 세 번의 예외를 제외하곤 반년 동안 잘 해냈다. 호기롭게 연말까지 금주를 선언하고 뒤늦게 이게 되나? 싶을 때가 있었는데 완벽하진 않아도 이 정도면 성공이다. 기쁘다.

    그리고 운동. 핑계 댈 수 없는 운동인 달리기를 시작했다. 처음엔 20분 정도 걸려 3km 뛰고 나면 눈앞이 핑글 돌았다. 주 2회 정도를 꾸준히 뛰다 보니 학기 끝엔 1km당 5분 이하 페이스로 7km까지 뛸 수 있게 되었다. 반년 동안 150km 정도를 뛰었다. 많이 부족하지만, 첫 5km, 첫 6km, 첫 7km를 하나씩 찍어나가는 기분이 정말 뿌듯했다.

    Nike Run Club 앱 스크린샷

    인생 최초로 주 2회, 학교 앞 헬스장에서 PT도 받았다. 수업이 없는 날에도 생활 리듬을 유지하고 최소한의 근력 운동을 하는 데에 많은 도움이 되었다. 문 앞에 걸어둔 턱걸이 봉을 거의 활용하지 않은 것은 아쉽다. 아마 학기 시작할 때보다 못해졌을 것이다. 훈련소에서 한 만큼 팔굽혀펴기와 턱걸이를 나와서도 꾸준히 했으면 지금 몸이 다른 모양일 텐데.

    2020

    비록 지금은 나아졌지만, 나는 술과 중독의 무서움, 그리고 내 결심과 의지의 나약함을 잘 알고 있다. 내년에도 술에 지배당하지 않는 자신을 유지하고 싶다. 또한 어떤 운동이든 꾸준히 해나가고, 달리기를 위해 굳이 한강 변에 집을 구한만큼 내년엔 꼭 10km 마라톤을 완주할 것이다.

    갈 길은 멀지만 조금 더 건강해진 내 몸과 정신이 마음에 든다. 결심을, 그리고 실행을 물심양면으로 많이 도운 가족과 애인, 친구들에게 고마운 마음을 전한다.

    넓히기

    2019

    올해 나의 세상은 많이 넓어졌다.

    먼저 일터에서. 한 조직의 리드 역할을 맡아 개발자의 그것과는 많이 다른 요구사항을 해결하기 위한 고민과 실행을 경험했다. 또한 숨은포인트찾기–행운상자–행운퀴즈까지 어떤 사업 모델을 아주 작은 아이디어로부터 점진적으로 개선하며 크게 발전시키는 경험도 처음으로 해 보았다. 마지막으로, 개인 프로젝트를 진행하면서 기본적인 수준의 앱 개발, 서버 개발을 경험했다.

    일 바깥으로는 드넓은 과학소설의 세계에 본격적으로 발을 들였다. 한국에 훌륭한 과학소설 작가가 많음을 배웠다. 퇴사와 함께 그만두었지만, 여름까진 춤 학원도 다녀보았다. 턱걸이를 할 수 있는 사람이 되었고 위에 적었듯 달리는 취미가 생겼다. 갈비찜, 미역국, 콩나물불고기, 김치부침개, 스테이크 등 시도해 본 요리 가짓수가 조금 늘었다.

    9달을 꽉 채운 춤 학원 수강증

    해먹은 요리들

    반대로 인간관계를 돌아보니 오히려 좁아지는 한 해였던 것 같은 점은 아주 아쉽다. 새로운 사람을 (실제로든, 웹상으로든) 만나는 것이 점점 귀찮고 부담스럽게 느껴지면서 이미 아는 소수와 또는 혼자서 보내는 시간의 비중이 극도로 늘었다. 간혹 메일이나 메신저 등으로 정중하게 연락을 주신 분들이 계셨는데 어떻게 답장할지 고민만 하다 결국 답장을 안 드리고 피한 적이 많다.

    나와 공유하는 부분이 많지 않은 사람과의 만남을 꺼리고 두려워하는 마음과 그런 만남을 피하고 숨는 행동 사이엔 양성 피드백이 존재하는 것 같다. 꺼리고 두려워할수록 안 만나게 되고, 안 만날수록 더 꺼리고 두려워하게 된다. 이 상황이 심해지는 것은 내게 좋지도, 먼저 마음을 열고 다가와 주는 사람들에게 옳지도 않은 것 같다. 내년엔 이 고리를 깨고 싶다.

    2020

    다음 해에도 일 안팎으로 나의 세계를 넓혀 나가겠다. 잘하던 일을 계속 잘하는 것과 더불어 회사 일과 개인 프로젝트를 통해 지금은 잘 모르는 일에도 도전하고 필요한 기술을 익힐 것이다. 새로운 책, 영화, 게임, 음악 접하는 일을 게을리하지 않겠다. 새로운 사람 만나기를 덜 두려워하고, 인간관계에서 편안한 영역을 더 자주, 더 적극적으로 벗어나겠다.

    어떤 삶을 살고 싶은가

    2019

    직업인으로서의 자신의 능력에 대한 믿음이 단단해지고 생존을 걱정하지 않게 되면서, “해야 할 일”이 없어질 날이 다가오면서, 이 주제를 깊게, 오래 고민(하기 시작)했다. 퇴사, 퇴사 후 반년, 다음 직장까지 나의 모든 크고 작은 결정이 이 고민의 결과로부터 큰 영향을 받았다.

    모든 사건, 과정이 너무 의미 있었지만 공개적인 장소에 기록으로 남기기엔 개인적인 내용이 많고 맥락이 충분치 않은 독자에게 잘 전달할 자신이 없다. 큰 줄기만 정리하면 아래와 같다.

    • 나는 인생은 계단이 아니라 벌판이다– 라고 믿기로 했다. 나는 앞으로도 몇몇 계단을 열심히 오를 것이다. 하지만 누군가(들)에게서 주어진 그 계단을 가장 높이 오르는 데 삶을 바치느라 멀리 언덕 너머 풍경을 놓치지 않을 것이다.
    • 나는 나의 이십대 – 앞으로의 인생 중 나의 부모님이 가장 젊고, 내가 가장 건강하고 활기차며, 책임져야 할 것이 가장 적은 때 – 를 모두 희생해 ‘어떤 수준’에 오르고 나면 그때부턴 행복하게 살 수 있다는 믿음을 따르지 않을 것이다.
    • 나는 지금껏 운이 좋았음을 이해하고, 감사한다. 그에 그치지 않고 운에 따른 삶의 질의 격차를 줄이는 데에, 운이 덜 좋은 사람도 잘살 수 있는 세상을 만드는 데에 기여하고 싶다.

    2020

    내년은 올해 잡은 큰 가지를 좀 더 구체적으로 다듬어나가는 해가 될 것이다. 그 과정에서, 내게 의미 있는 가치를 위해 지금 할 수 있는 일을 성실히 찾고 그에 투자하는 시간·노력·돈을 늘려가겠다. 당장 생각나는 항목은 아래와 같다.

    • 지난 후에 아무것도 남기지 않는, 의미 없는 일에 낭비하는 시간을 줄이고 사랑하는 사람들과 더 많은 시간을 보내겠다. 마음과 시간을 써야 할 때 돈을 써서 대체하지 않겠다.
    • 테크 업계의 성비 문제 해결, 경력이 없거나 적은 주니어의 업계 진입을 직간접적으로 돕겠다.
    • 블로그, 오픈 소스 등 누구나 접근할 수 있는 창구를 통해 지식을 공유하는 일을 계속하겠다.
    • 지금 하는 직장갑질119, 한국여성민우회, Mozilla로의 정기 기부를 계속하고, 기부처를 더 늘리겠다.

    마치며

    2020년엔 올해보다 조금 더 좋은 사람이 되고 싶다. 주변 사람들에게 더 잘하고, 미래의 큰 일을 핑계로 오늘 할 수 있는 작은 실행을 미루지 않고, 나를 돌볼 것이다.

    2019년을 함께해준 사람들 고맙습니다. 블로그 찾아주신 분들 모두 따듯한 연말, 행복한 2020년 되시길 바랍니다.

  • 시작을 떠올리며

    프로그래밍을 시작할 때의 그 너무 너무 막막했던 기분이 지금도 종종 떠오른다.

    트위터 쓰레드로 적은 글을 조금 다듬어 블로그에 옮깁니다.


    프로그래밍을 시작할 때의 그 너무 너무 막막했던 기분이 지금도 종종 떠오른다. 블로그를 만들고 싶어서 검색해서 나온 글 보고 따라는 하는데 뭘 하는 건지도 모르겠고, 처음 보는 단어뿐이고, 언어/라이브러리 버전이든 환경이든 조금 달라져 난 에러 하나 넘을 때마다 몇 시간씩 고생하고... 원래 이렇게 어려운건가, 싶었던.

    지금도 전혀 새로운 분야, 새로운 개념을 좁하면 그 때와 비슷한 느낌이 든다. 다만 맨 처음의 그… 숨이 턱 막히는 것 같은 절망감은 이제 없다. 이 문제에도 일단 넘고 나면 쉬워지는 언덕이 있고, 포기만 안 하고 부딛히다보면 언젠가 그 언덕을 넘으리란 걸 여러 차례 경험으로 확신하게 되었다.

    까지의 글을 트위터에 적은 뒤, 괜시리 추억에 잠겨 블로그를 둘러보았다. 간만에 읽은 옛날 글이 - 프로그래밍을 생각하며 쓰지 않았음에도 - 비슷한 맥락의 이야기를 하고 있어 재미있었다. 삶에서 나도 모르는 사이 반복되는 모티프를 우연찮게 발견하는 순간들이 있는 것 같다.

    추신: 그 막막하던 시절로부터 지금까지 정말 많은 분의 도움을 받았고 받고 있다. 그만큼 지금 필요로 하는 분들께 다시 그 도움을 돌려드려야 하는데… 아직은 맨날 말만 하면서 별로 하는 게 없다. 부채감은 날로 쌓이지만, 언젠가 갚을 날을 그리며 천천히 그리고 꾸준히 이런저런 계획을 세우고 있다.

  • GitHub Action을 사용해 새로 올라온 전월세 방 목록 받아보기

    부동산 앱에 새로 올라오는 방을 매번 직접 체크하는 대신 편하게 받아볼 수 없을까?

    들어가며

    매일 아침 11시, 피터팬의 좋은방 구하기라는 부동산 거래 사이트에 새로 올라온 특정 조건(가격대, 지역 등)을 만족하는 매물을 아래와 같이 정리해서 보내주는 프로그램을 만들었다. (저장소) 요새 블로깅이 너무 뜸하기도 했고, 재밌는 작업이라 과정을 기록으로 남겨보았다.

    메일로 부동산 매물을 받아보는 스크린샷

    2019-11-12 수정: 양성민 님께서 GitHub Action의 타임존 관련 수정이 필요한 부분을 알려주셔서 수정했습니다. 감사합니다!

    문제의식

    이번 여름, 학부 졸업을 위해 다니던 회사를 나와 학교로 돌아왔다. 다음으로 갈 회사가 정해지고 출근 일자가 다가오면서 슬슬 서울에 살 집을 구할 날짜가 다가왔다. 주중에는 대전에서 학교를 다녀 부동산에서 집을 볼 수 있는 시간이 한정적이다보니 직방, 다방 등의 앱과 더불어 피터팬의 좋은방 구하기(이하 "피터팬") 등의 직거래 사이트에서 사전 조사를 열심히 하기로 했다.

    지난 주말, 그렇게 조사한 방을 보러 처음으로 한 동네를 방문했다. 미리 연락해 둔 부동산에서 소개해준 방들, 직방에서 연락해둔 방들, 피터팬에서 찾아본 방들을 모두 본 결과는 실망스러웠다. 예산을 적지 않게 잡았다고 생각했는데도 마음에 드는 방이 없었다. 눈이 너무 높나, 뭔가 포기해야하나 싶은 생각이 스쳤지만 이내 첫술에 배부르길 바라는 것이 욕심이라 마음을 고쳐먹었다. 당장 입사하는 것도 아니니 좀 더 길게 보고 느긋하게 방을 찾기로 했다.

    마음이 편해졌지만, 한편으로는 너무 귀찮았다. 일단 한 플랫폼에만 올라오는 방들이 있으니 (직방에는 직거래 매물이 없고, 피터팬에는 부동산 매물이 덜 올라오는 등) 여러개의 앱을 모두 봐야한다. 아직 어느 동네로 갈지 정확히 정하지 못했으니 각 앱마다 여러 동네를 한 번씩 다 체크해야한다. 게다가 한 번 보고 관심이 안 생긴 집은 또 본다고 마음이 바뀌지 않는데 필터링하는 옵션이 없어 목록을 뒤지며 일일이 새 매물이 올라왔는지 확인해야 한다.

    꾹 참고 수시로 방을 검색하다보니 자연스레

    이거 내가 매번 확인하기 너무 귀찮은데, 그냥 조건만 지정해두고 새 방이 올라온 것만 받아볼 수 없나?

    하는 의문이 들었다. 그런데 생각해보니

    1. 어차피 요새 다들 SPA로 앱을 만들어 이미 데이터를 제공하는 API가 존재하는데다
    2. 매물은 인증 없이도 볼 수 있는 것을 알고 있으니

    생각보다 어렵지 않게 할 수 있을 것 같았다. 개발자 도구를 열어 네트워크 탭을 확인하니 실제로 가능한 구조였다. 마음에 드는 방을 구할 때까지 얼마나 걸릴지 마음 속으로 잠시 추측을 해본 뒤, 자동화가 충분히 수지타산이 맞다는(?) 결론을 내렸다. 직방, 다방, 피터팬 등 다양한 사이트를 한 번에 모두 커버하려다보면 작업이 너무 커질 것 같아 선택을 해야했는데,

    1. 부동산에서 찾아줄 수 없는 직거래 매물이 올라오고 상대적으로 괜찮은 방이 많다고 느꼈다.
    2. 반지하, 옥탑 방을 거르고 싶은데 공식 웹사이트 또는 애플리케이션이 해당 옵션을 지원하지 않아 불편하다.

    두 이유로 인해 피터팬을 첫 목표로 정했다.

    기술 선택

    같은 목적을 달성하더라도 어떤 기술을 택해 어떤 형태로 구현하느냐에 따라 비용은 크게 널뛴다. 때문에 요구사항을 잘 파악하고 적절한 기술을 선택하는 것이 구현 자체만큼, 혹은 그 이상으로 중요한 경우가 많다. 내가 생각한 중요한 요구사항은 세 가지였다.

    1. 이미 본 방을 또 보는 건 의미가 없으므로 마지막으로 본 이후로 새로 올라온 방만 보내줘야 한다.
    2. 내가 매번 확인할 필요 없이 주기적으로 실행되어서 나에게 알려줘야 한다.
    3. 공식 API를 사용하는 것이 아니라 CORS가 막히므로 웹 브라우저 상에서 돌아갈 순 없다.

    그 외에, 필수는 아니지만 기왕이면 만들어서 다른 사람들도 (자신이 원하는 방을 받아볼 수 있도록) 쓸 수 있게 배포하기가 편하면 좋겠다고 생각했다.

    요구사항을 만족시키는 안으로 도커 + cron을 이용하는 방법과 일렉트론을 이용해 데스크탑 앱을 만드는 방법 두 가지 정도가 떠올랐다. (둘 다 익숙한 도구는 아니지만 금방 배울 수 있을거라 생각했다) 바로 작업에 들어가기 전에 더 좋은 방법이 없을지 아는 개발자들이 있는 톡방에 여쭤보았다.

    더 나은 방법이 없을지 물어보는 스크린샷

    이후로 대화를 나누며 요구사항을 좀 더 명확히 정리하던 중 한 분께서 GitHub Action을 사용하면 어떻겠냐는 제안을 주셨다.

    GitHub Action 사용을 제안받는 스크린샷

    처음 들었을 때는 “이미 본 방의 목록”이라는 상태를 로컬에 저장할 계획이었어서 어려울 것이라 생각했다. 하지만 API 응답을 다시 보니 매물이 올라온 시간을 내려주고 있었다. “어제 이후로 올라온 방” 을 매일 받아볼 수단이 있으면 원했던 목적을 달성할 수 있을 뿐더러 내가 생각한 안보다 훨씬 간단하고 나은 해결책 같았다.

    도움을 주신 분들께 감사를 표하고, GitHub Action을 이용해 개발하기로 결정했다.

    API 호출 및 데이터 정제

    일단 배포에 앞서 실제로 매물을 받아오는 로직을 작성해야 했다. 피터팬에서 내부적으로 사용하는 페이로드 형태를 타입스크립트 인터페이스로 정의한 뒤,

    import { ContractType } from "../filter";
    import { differenceInCalendarDays, parse } from "date-fns";
    
    export type RoomType = "일반" | "옥탑" | "반지하" | "복층";
    
    export interface HousePayload {
      hidx: number;
      info: {
        subject: string;
        room_count: number;
        bedroom_count: number;
        thumbnail: string;
        created_at: string;
        live_start_date: string;
        is_octop: boolean;
        is_half_underground: boolean;
        is_multilayer: boolean;
      };
      type: {
        contract_type: ContractType;
        trade_type: "direct" | "agency";
        building_form: string | null;
        building_type: string;
        building_code: string;
        isCafe: boolean;
        fa3Code: 0;
      };
      price: { monthly_fee: number; deposit: number; maintenance_cost: number };
      floor: { target: 2; total: 4; floor_type: 1 };
      location: {
        coordinate: {
          latitude: string;
          longitude: string;
        };
        address: {
          sido: string;
          sigungu: string;
          dong: string;
        };
      };
    }
    

    가져다 쓰기 편한 형태와 좀 더 이해가 쉬운 변수명을 갖는 클래스로 한 번 감쌌다.

    import { ContractType, RoomCount } from "../filter";
    import { differenceInCalendarDays, parse } from "date-fns";
    
    export class House {
      id: number;
    
      price: {
        deposit: number;
        rent: number;
        maintenance: number;
      };
    
      displayLocation: string;
    
      floor: {
        total: number;
        target: number;
      };
    
      info: {
        title: string;
        thumbnail: string;
        createdAt: string;
      };
    
      contractType: ContractType;
      roomType: RoomType;
      roomCount: RoomCount;
    
      get isNew() {
        // GitHub Action은 UTC 시간대에서 실행됨
        const now = addHours(Date.now(), 9);
        const createdAt = parse(
          this.info.createdAt.split(" ")[0],
          "yyyy-MM-dd",
          Date.now()
        );
    
        const diff = differenceInCalendarDays(now, createdAt);
        return diff < 2;
      }
    
      constructor(payload: HousePayload) {
        const { hidx, info, price, type, floor, location } = payload;
    
        this.id = hidx;
    
        this.price = {
          deposit: price.deposit,
          rent: price.monthly_fee,
          maintenance: price.maintenance_cost,
        };
    
        this.displayLocation = [
          location.address.sigungu,
          location.address.dong,
        ].join(" ");
    
        this.floor = floor;
    
        this.info = {
          title: info.subject,
          thumbnail: info.thumbnail,
          createdAt: info.created_at,
        };
    
        this.contractType = type.contract_type;
        this.roomCount =
          info.room_count >= 3
            ? RoomCount.threeAndMoreRooms
            : info.room_count === 2
            ? RoomCount.twoRooms
            : RoomCount.oneRoom;
        this.roomType = info.is_half_underground
          ? "반지하"
          : info.is_multilayer
          ? "복층"
          : info.is_octop
          ? "옥탑"
          : "일반";
      }
    }
    

    그 뒤 관심있는 지역과 조건을 정의하는 예시를 작성했다.

    import { Filter, RoomFloor, RoomCount, ContractType } from "../filter";
    
    /**
     * 월세 한도 (단위 만원)
     */
    const rentBudget = 100;
    
    /**
     * 보증금 한도 (단위 만원)
     */
    const depositBudget = 100;
    
    const commonFilter: Omit<Filter, "id" | "bounds"> = {
      priceRange: {
        rent: { max: rentBudget * 10000 },
        deposit: { max: depositBudget * 10000 },
        shouldIncludeMaintenance: true,
      },
      roomFloors: [RoomFloor.lower, RoomFloor.higher],
      roomCounts: [RoomCount.twoRooms, RoomCount.threeAndMoreRooms],
      contractTypes: [ContractType.rent],
      shouldIncludeHalfUndergrounds: false,
      shouldIncludeLofts: true,
      shouldIncludeRooftops: true,
    };
    
    const candidates: Filter[] = [
      {
        id: "뚝섬 서울숲",
        ...commonFilter,
        bounds: {
          max: { lat: 37.5558485, lng: 127.060802 },
          min: { lat: 37.5317832, lng: 127.0328288 },
        },
      },
      {
        id: "양재",
        ...commonFilter,
        bounds: {
          max: { lat: 37.4854867, lng: 127.0506948 },
          min: { lat: 37.4667919, lng: 127.0319895 },
        },
      },
      {
        id: "회사 근처",
        ...commonFilter,
        bounds: {
          max: { lat: 37.508058, lng: 127.0463052 },
          min: { lat: 37.4893626, lng: 127.0275955 },
        },
      },
    ];
    
    export default candidates;
    

    마지막으로 피터팬에서 사용하는 형태로 조건 쿼리 파라미터를 포매팅하는 함수,

    interface PriceRange {
      min?: number;
      max: number;
    }
    
    export enum RoomFloor {
      lower = "1층 ~ 5층",
      higher = "6층 이상",
    }
    
    export enum RoomCount {
      oneRoom = "원룸",
      twoRooms = "투룸",
      threeAndMoreRooms = "쓰리룸 이상",
    }
    
    export enum ContractType {
      rent = "월세",
      jeonse = "전세",
      sale = "매매",
      shortTerm = "단기임대",
    }
    
    interface Point {
      lat: number;
      lng: number;
    }
    
    export interface Filter {
      id: string;
      priceRange: {
        rent?: PriceRange;
        deposit?: PriceRange;
      };
      bounds: {
        max: Point;
        min: Point;
      };
      roomFloors: RoomFloor[];
      roomCounts: RoomCount[];
      contractTypes: ContractType[];
      shouldIncludeHalfUndergrounds: boolean;
      shouldIncludeLofts: boolean;
      shouldIncludeRooftops: boolean;
    }
    
    export function constructFilterQueryParam(filter: Filter) {
      const { priceRange, bounds, roomFloors, roomCounts, contractTypes } = filter;
    
      const tokens: string[] = [
        `latitude:${bounds.min.lat}~${bounds.max.lat}`,
        `longitude:${bounds.min.lng}~${bounds.max.lng}`,
      ];
    
      if (priceRange.rent) {
        tokens.push(
          `checkMonth:${priceRange.rent.min || 999}~${priceRange.rent.max}`
        );
      }
    
      if (priceRange.deposit) {
        tokens.push(
          `checkDeposit:${priceRange.deposit.min || 999}~${priceRange.deposit.max}`
        );
      }
    
      if (roomFloors.length > 0) {
        const totalRoomFloors = roomFloors.map((f) => `"${f}"`);
    
        tokens.push(`roomCount_etc;[${totalRoomFloors.join(",")}]`);
      }
    
      if (roomCounts.length > 0) {
        tokens.push(`roomType;[${roomCounts.map((t) => `"${t}"`).join(",")}]`);
      }
    
      if (contractTypes.length > 0) {
        tokens.push(
          `contractType;[${contractTypes.map((t) => `"${t}"`).join(",")}]`
        );
      }
    
      return tokens.join("||");
    }
    

    그리고 API를 호출한 뒤 설정에 따라 반지하, 옥탑 등의 매물을 거르는 함수를 작성했다.

    import axios from "axios";
    import { stringify } from "query-string";
    
    import { Filter, constructFilterQueryParam } from "../filter";
    import { HousePayload, House } from "../models/House";
    
    interface FetchHousesResponse {
      houses: {
        direct?: {
          image: HousePayload[];
        };
        agency?: {
          image: HousePayload[];
        };
      };
    }
    
    const apiEndpoint = "https://api.peterpanz.com/houses/area";
    
    export async function fetchHouses(candidate: Filter): Promise<House[]> {
      const query: { [key: string]: any } = {
        filter: constructFilterQueryParam(candidate),
        pageSize: 100,
        pageIndex: 1,
      };
    
      const url = `${apiEndpoint}?${stringify(query)}`;
    
      const { data } = await axios.get<FetchHousesResponse>(url, {
        headers: { "content-type": "application/json" },
      });
    
      const { direct = { image: [] }, agency = { image: [] } } = data.houses;
    
      const houses = [...direct.image, ...agency.image].filter((h) => {
        return (
          (candidate.shouldIncludeHalfUndergrounds ||
            !h.info.is_half_underground) &&
          (candidate.shouldIncludeLofts || !h.info.is_multilayer) &&
          (candidate.shouldIncludeRooftops || !h.info.is_octop)
        );
      });
    
      return houses.map((payload) => new House(payload)).filter((h) => h.isNew);
    }
    

    위 코드엔 버그가 있다. API가 페이지네이션이 되어 있어서 제대로 된 동작을 위해선 응답으로 넘어온 totalCount를 보고 남은 데이터가 있는 경우 페이지를 넘겨가며 끝까지 호출한 뒤 전부 취합해 반환해야 한다. 하지만 내가 찾아보는 지역들은 새 매물이 있어봤자 10개를 넘기는 경우가 드물어서 100개를 넘어가는 경우는 굳이 처리하지 않았다.

    GitHub Action 설정

    GitHub Action은 GitHub이 제공하는 CI/CD 솔루션이다. 선정된 특정 사용자에게만 제공되던 시기를 거쳐 지난 8월 베타 버전을 공개한 소식은 들었지만, 아직까지 한 번도 사용해본 적은 없었다. GitHub Action을 택한 데에는 이 기회에 배워두면 좋겠다는 생각도 있었다. 공식 문서를 참고하면서 개발했는데 별로 어렵지 않게 설정이 가능했다.

    GitHub Action 워크플로우는 저장소 루트의 .github/workflows 폴더에 <action name>.yml 파일을 통해 설정할 수 있다. 메일을 어떻게 보낼지 좀 고민이었는데, Watch 중인 저장소에 이슈가 올라오면 메일이 온다는 점에 착안해 메일을 직접 보내는 대신 매일 필요한 정보를 담은 이슈를 만들도록 설정했다.

    먼저, API로부터 받아온 정보를 적당히 보기 편한 GitHub Flavored Markdown 형태로 포매팅한다.

    const sections: string[] = [];
    
    await Promise.all(
      candidates.map(async (candidate) => {
        const houses = await fetchHouses(candidate);
        const section = [
          `# ${candidate.id}: ${houses.length}`,
          "",
          ...houses.map(
            ({
              displayLocation,
              info,
              price,
              roomType,
              roomCount,
              contractType,
              floor,
              id,
            }) => {
              const title = `## [${displayLocation}] ${info.title}`;
              const thumbnail = `<img src=${info.thumbnail} >`;
              const deposit = formatKRW(price.deposit);
              const monthly = ` ${formatKRW(price.rent)}<br>(+ ${formatKRW(
                price.maintenance
              )})`;
    
              const floorInfo = `${floor.total}층 중 ${floor.target}`;
    
              return [
                title,
                thumbnail,
                "",
                `| 종류 | 보증금 | 월세<br>(+ 관리비) | 방 타입 | 층수 |`,
                `| - | - | - | - | - |`,
                `| ${roomCount} ${contractType} | ${deposit} | ${monthly} | ${roomType} | ${floorInfo} |`,
                "",
                `[바로가기](https://www.peterpanz.com/house/${id})`,
                "<hr>",
                "",
              ].join("\n");
            }
          ),
        ].join("\n");
    
        sections.push(section);
      })
    );
    
    const body = sections.join("\n\n");
    createIssue(`${format(Date.now(), "yyyy-MM-dd")}일 새로 올라온 방`, body);
    

    다음으로 이렇게 포매팅한 스트링을 받아 실제 이슈를 생성한다. 이슈 생성에는 create-an-issue의 코드를 참고했다. 처음엔 그대로 가져다쓸 수 있을 줄 알았는데 내 용례에는 필요없는 중간 파일을 만들어야 하는 문제가 있어, 소스를 가져다 데이터를 바로 넘기도록 수정했다.

    import { Toolkit } from "actions-toolkit";
    
    export default function createIssue(title: string, body: string) {
      Toolkit.run(
        async (tools) => {
          tools.log.info(`Creating new issue ${title}`);
    
          try {
            const issue = await tools.github.issues.create({
              ...tools.context.repo,
              title,
              body,
            });
    
            tools.log.success(
              `Created issue ${issue.data.title}#${issue.data.number}: ${issue.data.html_url}`
            );
          } catch (err) {
            tools.log.error(err);
            if (err.errors) {
              tools.log.error(err.errors);
            }
    
            tools.exit.failure();
          }
        },
        {
          secrets: ["GITHUB_TOKEN"],
        }
      );
    }
    

    이슈 생성을 npm 스크립트로 등록한 뒤 먼저 GitHub에서 제공하는 Node.js 워크플로우를 참고해 작업을 정의했다. GitHub Action의 맥락 내에서는 secrets.GITHUB_TOKEN 이라는 토큰이 자동으로 들어오는데, 이 토큰을 환경변수로 넘겨 GitHub API 인증을 처리할 수 있다.

    jobs:
      fetch:
        name: Fetch
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@master
          - name: Use Node.js 10.x
            uses: actions/setup-node@v1
            with:
              node-version: 10.x
          - name: 이슈 생성
            run: |
              npm ci
              npm run fetch
            env:
              GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    

    작업을 정의했으면 마지막으로 이슈를 만드는 동작을 실행할 트리거를 정의해주어야 한다. GitHub Action은 워크플로우를 트리거하기 위한 다양한 이벤트를 제공하는데, 나는 매일 아침 11시(schedule), 그리고 저장소에 새 코드가 푸시된 경우 (push) 작업이 실행되도록 설정했다.

    name: 새로 올라온 직거래 방 정보 받아오기
    on:
      push:
        branches:
          - master
      schedule:
        # 오전 11시 (GitHub Action은 UTC 타임존)
        - cron: "0 2 * * *"
    jobs:
      fetch:
        name: Fetch
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@master
          - name: Use Node.js 10.x
            uses: actions/setup-node@v1
            with:
              node-version: 10.x
          - name: 이슈 생성
            run: |
              npm ci
              npm run fetch
            env:
              GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    

    설정 파일 시행착오로 인한 몇 차례의 git add -A; git commit —amend; git push -f를 거친 후, 원하는 대로 새로운 매물 정보를 담은 리포트가 메일로 날아오는 것을 확인했다.

    메일로 부동산 매물을 받아보는 스크린샷

    만세!

    원래 바라던 대로 프로그래밍 지식이 없는 사용자도 사용할 수 있는 형태는 아니지만, 이미 내가 원하던 수준을 달성한 상태라 추가적으로 노력을 들일 의욕이 별로 생기지 않았다. 그래도 Git과 GitHub의 기본적인 사용법을 익힌 사람에게는 유용할 수 있을 것 같아 간단하게 커밋을 정리하고 README를 작성해 새로운 공개 저장소를 파서 GitHub에 공개했다.

    마치며

    만들면서 재미있었다. 아주 개인적인 필요에 의해 시작해, 열 시간이 채 안 되는 시간 투자로 매일 앱을 켜서 여러 동네에 새 방이 올라왔는지 확인하는 노동에서 해방되었다. 이런 삶의 소소한 불편/비효율을 개선할 때면 프로그래밍을 배워두길 잘했다는 기분이 든다. 직방, 다방 등도 비슷한 자동화가 가능할 것 같은데 누군가 대신 해주신다면 매일 자기 전 계신 방향으로 큰절을 올리겠습니다.

    GitHub Action을 처음 써본 소감은 상당히 괜찮았다. 설정이 쉽고 직관적이고, 어차피 GitHub을 사용하는 입장에서 GitHub 내에서 모든 것을 처리 가능한 점이 매력적으로 느껴졌다. 복잡한 용례를 얼마나 잘 커버하는지는 모르겠지만, 적어도 간단한 작업에는 앞으로도 종종 사용할 생각이다. 로컬 테스트가 불가능한 점은 좀 아쉬웠다.

    무엇보다도 만든 보람이 있도록 좋은 집이 구해졌으면 좋겠다! 응원해주세요!