본문 바로가기

프론트엔드

RxJS를 이용해 함수형으로 데이터 다루기

  • 프로젝트: 채식 지도
  • 키워드: functional programming, reactive programming, RxJS, POI, filter, cluster
  • 상황
    1. json 데이터는 '도/특별시/광역시(lv1) -> 시/군/구/동(lv2) -> 매장(lv3)' 으로 level이 매겨진 POI 정보들이 객체 형식으로 들어있다.

        [
        {
          "name": "서울",
          "lv": 1,
          "coordinates": [37.5462411, 126.9489439],
          "children": [
            {
              "name": "망원합정",
              "lv": 2,
              "coordinates": [37.5543362, 126.9133601],
              "children": [
                {
                  "name": "매장1",
                  "lv": 3,
                  "coordinates": [37.5543362, 126.9033601]
                },
                {
                  "name": "매장2",
                  "lv": 3,
                  "coordinates": [37.5543362, 126.9043601]
                }
              ]
            },
            {
              "name": "이태원",
              "lv": 2,
              "coordinates": [37.5313885, 126.9901009],
              "children": [
                {
                  "name": "매장1",
                  "lv": 3,
                  "coordinates": [37.5353885, 126.9901009]
                },
                {
                  "name": "매장2",
                  "lv": 3,
                  "coordinates": [37.5353885, 126.9921009]
                }
              ]
            }
          ]
        },
        {
            "name": "부산",
            "lv": 1,
            "coordinates": [35.164369,128.9313701],
            "children": [
              {
                "name": "광안리",
                "lv": 2,
                "coordinates": [35.184369,128.9413701],
                "children": [
                  {
                    "name": "매장1",
                    "lv": 3,
                    "coordinates": [35.194369,128.9413701]
                  }
                ]
              }
            ]
          }
      ]
    2. 특정 좌표값이 주어지면(ex> 지도 UI에서 현재 중심(center) 좌표), 해당 좌표와 가까운 POI만 필터링(filtering) + 같은 level끼리 클러스터링(clustering) 해야한다.

    3. json 데이터는 앞으로 다양한 조건, 다양한 순서로 manipulate될 예정이므로 명령형 프로그래밍보다는 함수형 프로그래밍을 통해 확장성있고 재사용 가능한 코드를 작성하고자 한다.
  • 해결 방법
    1. package.json에 rxjs 패키지를 추가한다.
        "rxjs": "^7.4.0"


    2. 중심 좌표에 따라 json POI 데이터를 필터링+클러스터링하고 그 결과를 반환하는 함수를 작성해야한다.
      그 전에 함수에 필요한 type을 먼저 선언한다.
        export type Coordinates = [number, number];
      export type Lv = 1 | 2 | 3;
      
      interface _POI {
        name: string;
        lv: Lv;
        coordinates: Coordinates;
      }
      
      export interface ParentPOI extends _POI {
        children: POI[];
      }
      
      export type POI = _POI | ParentPOI;
      
      export interface POISets {
        lv1POIs: POI[];
        lv2POIs: POI[];
        lv3POIs: POI[];
      }


       그 후 메인함수 작성을 시작한다.
       // 중심 좌표를 인자로 받아 좌표 주변의 POI만 filtering + clustering 한 후, 그 결과인 POISets를 반환하는 함수
      export const generatePOISetsByCenter = (center: Coordinates): POISets => {
        const lv1POIs: POI[] = [];
        const lv2POIs: POI[] = [];
        const lv3POIs: POI[] = [];
        
        // TODO: json 데이터를 필터링 + 클러스터링하고 lv1/2/3POIs 배열을 채워넣는다.
        
        return {
          lv1POIs,
          lv2POIs,
          lv3POIs,
        };
      }


    3. 먼저 json을 import한 후, rxjs from을 이용하여 Observable 로 만든다. 그리고 pipe 함수를 시작한다.
        import POIData from '@D/test.json';
      import { from } from 'rxjs';
      
      from(POIData as POI[])
          .pipe(​


    4. 현재 Observable에 해당하는 lv1 POI(json 배열이 lv1 객체로 시작하므로) 중 가까운 지역만 filtering한 후, lv1POIs에 push하고자 한다.
        import { MonoTypeOperatorFunction } from 'rxjs';
      
      // NOTE: 좌표값 2개를 인자로 받아 두 좌표 사이 거리를 km 단위로 계산하는 함수
      export function calculateDistanceBetweenCoordinates(coordinates1, coordinates2) {
        ...
      }
      
      export const filterNearPOIs = (
        referenceCoordinates: Coordinates,
        allowedDistanceInKm: number,
      ): MonoTypeOperatorFunction<POI> =>
        filter(
          (poi: POI) => calculateDistanceBetweenCoordinates(poi.coordinates, referenceCoordinates) < allowedDistanceInKm,
        );
       
       from(POIData as POI[])
        .pipe(
          filterNearPOIs(center, 100),
          tap((poi) => lv1POIs.push(poi)),​


      filterNearPOIs에서 RxJS filter operator를 사용하여 100km 이내의 POI만 필터링한다. 그 후, tap operator로 side effect를 일으켜 lv1POIs에 해당 POI들을 push한다.

    5. lv2 POI도 탐색하기 위해 lv1 -> lv2로 한 단계 내려간다.
        export const extractChildren = (poi: POI): POI[] => ('children' in poi ? poi.children : []);
      export const goDownOneLevel = mergeMap(extractChildren);
      
      from(POIData as POI[])
        .pipe(
          filterNearPOIs(center, 100),
          tap((poi) => lv1POIs.push(poi)),
          goDownOneLevel,​


      goDownOneLevel에서 mergeMap을 이용하여 lv1의 children으로부터 lv2 Observable을 만든다.

    6. lv2 Observable도 마찬가지로 20km이내의 POI만 필터링하고, lv2POIs에 push한다.
        from(POIData as POI[])
        .pipe(
          filterNearPOIs(center, 100),
          tap((poi) => lv1POIs.push(poi)),
          goDownOneLevel,
          filterNearPOIs(center, 20),
          tap((poi) => lv2POIs.push(poi)),​


    7. lv2의 children인 lv3로 한 단계 내려간 후, 필터링없이 lv3POIs에 push한다.
      from(POIData as POI[])
        .pipe(
          filterNearPOIs(center, 100),
          tap((poi) => lv1POIs.push(poi)),
          goDownOneLevel,
          filterNearPOIs(center, 20),
          tap((poi) => lv2POIs.push(poi)),
          goDownOneLevel,
          tap((poi) => lv3POIs.push(poi)),
        )
        .subscribe();​


       그 후 subscribe를 통해 실제로 pipeline을 실행한다!

      아래는 완성된 cluster.ts 전체 코드이다.
        import POIData from '...';
      import { Coordinates, POI, POISets } from '...';
      import { calculateDistanceBetweenCoordinates } from '...';
      import { from, MonoTypeOperatorFunction } from 'rxjs';
      import { filter, map, mergeMap, tap } from 'rxjs/operators';
      
      export const extractChildren = (poi: POI): POI[] => ('children' in poi ? poi.children : []);
      export const goDownOneLevel = mergeMap(extractChildren);
      export const filterNearPOIs = (
        referenceCoordinates: Coordinates,
        allowedDistanceInKm: number,
      ): MonoTypeOperatorFunction<POI> =>
        filter(
          (poi: POI) => calculateDistanceBetweenCoordinates(poi.coordinates, referenceCoordinates) < allowedDistanceInKm,
        );
      
      export const generatePOISetsByCenter = (center: Coordinates): POISets => {
        const lv1POIs: POI[] = [];
        const lv2POIs: POI[] = [];
        const lv3POIs: POI[] = [];
      
        from(POIData as POI[])
          .pipe(
            filterNearPOIs(center, 100),
            tap((poi) => lv1POIs.push(poi)),
            goDownOneLevel,
            filterNearPOIs(center, 20),
            tap((poi) => lv2POIs.push(poi)),
            goDownOneLevel,
            tap((poi) => lv3POIs.push(poi)),
          )
          .subscribe();
      
        return {
          lv1POIs,
          lv2POIs,
          lv3POIs,
        };
      };​


  • known issues
    1. lv1,2,3에 적용한 POI type을 ParentPOI[](lv1POIs, lv2POIs)와 StorePOI[](lv3POIs)로 변경한다.
        interface _POI {
        name: string;
        lv: Lv;
        coordinates: Coordinates;
      }
      
      export interface ParentPOI extends _POI {
        lv: 1 | 2;
        polygon: Coordinates[];
        children: POI[];
      }
      
      export interface StorePOI extends _POI {
        lv: 3;
        foodKind: typeof FOOD_KINDS[number];
      }
      
      ...
      
      const lv1POISet: ParentPOI[] = [];
      const lv2POISet: ParentPOI[] = [];
      const lv3POISet: StorePOI[] = [];​
  • 의의
    1. 함수형으로 작성해두어 나중에 필터링 조건이 추가되거나 변경될 때 유지보수가 매우 쉽다.
    2. 추후 sorting, take 등의 추가 조건/기능이 생겨도 확장이 매우 쉽다.
    3. 파이프라인이 진행되는 중간중간에 (기존 코드를 방해하지 않고) tap으로 원하는 sideEffect를 추가할 수 있다.
    4. 아직은 데이터를 다루는 것에만 RxJS를 사용하고 있지만, 앞으로 함수형 프로그래밍을 지향하는 React와 RxJS와의 궁합이  기대된다.