grouping된 N개의 배열을 멀티패러다임 프로그래밍 방식으로 interleaving 하기

June·2024년 6월 6일
0
post-thumbnail

들어가며

최근 프로젝트에서 각 조건에 따라 그룹핑 된 N개의 리스트를 주어진 개수 패턴에 따라 인터리빙해달라는 요구사항이 있었습니다. 이번 포스팅에서는 해당 요구사항을 인동댕님 유튜브 에서 영감을 받아 멀티패러다임 방식으로 구현하는 과정을 이야기하고자 합니다

해당 포스트에서 말하는 인터리빙 은 여러 배열에서 요소를 지정된 개수 패턴에 따라 하나의 배열로 합치는 과정을 말합니다. 예를 들어, 패턴 [2, 2, 2, 1]은 각 그룹핑 된 배열에서 2개, 2개, 2개, 1개의 요소를 순서대로 반복적으로 가져와서 합치는 작업을 수행하라는 의미 입니다.

해당 포스트의 전체 코드 예제는 여기 에서 확인하실 수 있습니다. 해당 예제 프로젝트는 Bun 런타임에서 실행할 수 있도록 작성되었으나 nodejs 환경에서도 문제없이 동작합니다.

예제 요구사항

먼저, 인터리빙 작업의 요구사항을 정리해보겠습니다

  1. 여러 배열을 주어진 패턴에 따라 인터리빙
    • 패턴은 각 배열에서 몇 개의 요소를 가져올지를 지정합니다.

예시)

  • 아래 예시 처럼 미리 그룹핑된 중첩 배열과 지정된 개수 패턴을 전달하면 인터리빙된 하나의 배열을 반환해야한다.
const arrays = [
  [1, 2, 6, 7],
  [3, 8],
  [4, 5, 9, 10],
];

const pattern = [2, 1, 2];

// 이 곳을 구현해야 한다!
const result = interleaving(arrays, pattern);

console.log(result); // [1,2,3,4,5,6,7,8,9,10]
  1. 중복 허용 옵션:
    • 중복을 허용할지 여부를 결정하는 boolean 값입니다.
// 이런 메서드 시그니처가 나와야 할 것 같습니다.
function interleaving<T>(
	arrays: T[][], 
    pattern : number[], 
    options: {allowDuplicates: boolean }
){
      // 구현
}

개선 전 코드

절차지향 방법의 구현

function interleaving<T>(
  arrays: T[][], 
  pattern: number[], 
  options: { allowDuplicates: boolean } = { allowDuplicates: true }
): T[] {
  // 패턴과 배열의 유효성 검사
  if (pattern.length !== arrays.length) {
    throw new Error("패턴과 배열의 길이가 일치하지 않습니다.");
  }
  if (pattern.some(p => p < 1)) {
    throw new Error("패턴은 1 이상의 값을 가져야 합니다.");
  }

  // 각 그룹핑 배열의 현재 위치를 0으로 초기화
  const positions = arrays.map(() => 0);
    
  // 결과 배열
  const result: T[] = [];

  while (true) {
    let allElementsRetrieved = true;

    // 각 배열에서 패턴에 따라 요소를 가져와서 결과 배열에 추가
    for (let idx = 0; idx < pattern.length; idx++) {
      const position = positions[idx];
      const array = arrays[idx];
      const count = pattern[idx];

      // 현재 위치가 배열의 길이를 초과했는지 확인
      if (position < array.length) {
        allElementsRetrieved = false;

        // 패턴에 따라 요소를 가져와 결과 배열에 추가
        const elements: T[] = [];
        for (let i = 0; i < count && (position + i) < array.length; i++) {
          elements.push(array[position + i]);
        }

        // 중복 허용 여부에 따라 요소를 추가
        if (options.allowDuplicates) {
          result.push(...elements);
        } else {
          elements.forEach(element => {
            if (!result.includes(element)) {
              result.push(element);
            }
          });
        }

        // 위치를 업데이트
        positions[idx] = Math.min(position + count, array.length);
      }
    }

    // 모든 요소를 가져왔으면 루프 종료
    if (allElementsRetrieved) {
      break;
    }
  }

  return result;
}


const arrays = [
  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
  [11, 12, 13, 14, 15],
  [16, 17, 18, 19],
  [20, 21, 22],
];

// Pattern 2, 2, 2, 1
const pattern = [2, 2, 2, 1];

// Interleave arrays with duplicates allowed
const resultWithDuplicates = interleaving(arrays, pattern, {
  allowDuplicates: true,
});
console.log(resultWithDuplicates); // [1, 2, 11, 12, 16, 17, 20, 3, 4, 13, 14, 18, 19, 21, 5, 6, 15, 22, 7, 8, 9, 10]
// Interleave arrays without duplicates
const resultWithoutDuplicates = interleaving(arrays, pattern, {
  allowDuplicates: false,
});
console.log(resultWithoutDuplicates); // [1, 2, 11, 12, 16, 17, 20, 3, 4, 13, 14, 18, 19, 21, 5, 6, 15, 22, 7, 8, 9, 10]

위 코드는 각 반복마다 각 그룹핑된 배열의 현재 위치 포인터를 옮기면서 매칭되는 위치의 패턴의 개수에 따라 요소들을 생성하고, 최종적으로 만들어진 요소를 결과 배열에 추가하는 방식으로 동작합니다.

문제점

  • 긴 함수와 복잡한 논리로 인해 코드의 가독성이 떨어집니다. 특히, 여러 배열을 다루고 각 배열에서 요소를 추출하여 결과 배열에 추가하는 과정에서 많은 변수가 사용됩니다. 이는 코드의 이해를 어렵게 만듭니다.
  • 상태 관리가 복잡합니다. 패턴에 따라 각 요소들을 추출하기 위해 각 그루핑 배열의 현재 위치를 추적하는 positions 배열의 상태를 관리해야 합니다. 이러한 상태가 함수 내에서 직접 관리되기 때문에, 상태 변경 시 실수가 발생할 가능성이 높습니다.
  • 긴 함수와 복잡한 논리로 인해 테스트와 디버깅이 어려워집니다. 각 단계에서 발생할 수 있는 오류를 추적하기 어렵고, 코드의 특정 부분을 테스트하기 위해 많은 준비 작업이 필요할 수 있습니다.

결론적으로 위 방법은 간단하고 직관적이지만, 가독성이나 유지보수성 등의 측면에서 여러가지 문제점을 가지고 있습니다.

멀티 패러다임으로 전환

해당 포스팅에서는 타입스크립트 함수형 라이브러리 중 하나인 fp-ts 를 사용합니다.

takeFrom

매 반복마다 각 그룹핑된 배열에서 현재 위치에서 해당되는 패턴의 크기만큼의 요소를 추출해야하기 때문에 해당 작업을 수행하는 함수를 먼저 생성합니다

import { dropLeft, takeLeft } from "fp-ts/lib/Array";
import { pipe } from "fp-ts/lib/function";

/**
 * 주어진 배열에서 start부터 count만큼의 요소를 가져옵니다.
 */
export const takeFrom = <T>(start: number, count: number, array: T[]): T[] =>
  pipe(
    array, 
    dropLeft(start), 
    takeLeft(count)
  );

컨테이너 클래스 선언

class Interleaver<T> {
  /**
   * 각 배열의 현재 위치를 저장하는 배열
   */
  private positions: number[] = [];
  
  private constructor(
    private readonly pattern: number[],
    private readonly arrays: T[][]
  ) {
    this.validateInitialization();
    this.initializePositions();
  }
  
  /**
   * 패턴과 배열의 유효성을 검사합니다.
   */
  private validateInitialization = () => {
    if (this.pattern.length !== this.arrays.length) {
      throw new Error("패턴과 배열의 길이가 일치하지 않습니다.");
    }

    if (this.pattern.some((p) => p < 1)) {
      throw new Error("패턴은 1 이상의 값을 가져야 합니다.");
    }
  };

  /**
   * 각 배열의 현재 위치를 0(첫 번째 요소)으로 초기화합니다.
   */
  private initializePositions = () =>
    (this.positions = this.arrays.map(() => 0));
  
  
  /**
   * 결과 배열을 반환하는 퍼블릭 인터페이스 메서드
   */
  public interleave(options?: { allowDuplicates: boolean }): T[] {
    // 구현할 부분
  }
  
  /** 팩토리 메서드 */
  public static of<T>(pattern: number[], arrays: T[][]): Interleaver<T> {
    return new Interleaver(pattern, arrays);
  }
}
  • 위 컨테이너는 패턴 숫자형 배열과 그룹핑된 배열을 인자로 받고, 각 그룹의 최초 위치를 첫번째 요소로 초기화합니다.
  • 해당 컨테이너는 최종 결과 배열을 반환하는 interleave 라는 퍼블릭 인터페이스 메서드를 가집니다.
    • 해당 메서드는 중복 제거 옵션을 인자로 전달 받습니다.

각 그룹핑 배열의 위치 포인터를 업데이트하는 메서드 추가

class Interleaver<T> {
  // (... 생략)
  
  /**
   * 다음 위치를 패턴에 따라 업데이트합니다.
   */
  private updatePositions(): void {
    this.positions = this.positions.map((position, idx) =>
      position + this.pattern[idx] >= this.arrays[idx].length
        ? this.arrays[idx].length
        : position + this.pattern[idx]
    );
  }
}
  • 매 호출마다 그룹들의 위치 포인터를 패턴의 크기만큼 업데이트 합니다. 이 때, 다음 포인터의 위치가 그룹의 크기 이상이면 마지막 위치로 설정합니다.

위치 포인터에 따라 다음 요소를 가져오는 메서드 추가

class Interleaver<T> {
  // (... 생략)
  
  
  /**
   * 현재 위치에 따라 다음 요소들을 가져옵니다.
   */
  private getNextElements = () =>
    pipe(
      this.arrays,
      mapWithIndex(this.getNextElement),
      filterMap(identity),
      flatten
    );

  /**
   * 현재 위치에 따라 idx번째 배열에서 다음 요소들을 해당 배열의 pattern 크기만큼 가져옵니다.
   */
  private getNextElement = (idx: number, item: T[]): None | Some<T[]> =>
    this.positions[idx] >= item.length
      ? none
      : some(takeFrom(this.positions[idx], this.pattern[idx], item));
}
  • 현재 위치 포인터에 따라 각 그룹 배열에서 다음 요소들을 takeFrom 을 사용해서 해당 배열의 pattern 크기만큼 가져옵니다. 이 때, 각 그룹 당 포인터의 위치가 그룹의 크기 이상이면 Optionalnone 을 반환하고, 만들어진 배열에서 none 이 아닌 요소만 필터링 (fliterMap(identity)) 한 후 평탄화하여 반환합니다.

재귀적으로 각 반복마다 추출한 요소를 방출합니다.

class Interleaver<T> {
  // (...생략)
  
  /**
   * 재귀적으로 인터리빙된 배열을 방출하는 제너레이터입니다.
   * 각 반복마다 현재 위치에 따라 교차된 요소를 방출하고, 위치를 업데이트합니다.
   */
  private *interleaveRecursive(): Generator<T, void, undefined> {
    const nextElements = this.getNextElements();
    // 다음 요소가 없으면 종료
    if (nextElements.length === 0) {
      return;
    }

    // 이번 반복에서 만든 요소를 방출
    yield* nextElements;

    // 다음 위치로 업데이트
    this.updatePositions();

    // 재귀적으로 다음 요소들을 방출
    yield* this.interleaveRecursive();
  }
}
  • 제너레이터를 사용하여 각 반복마다 인터리빙된 배열을 만들고, 위치 포인터를 업데이트 합니다. 이후 만들어진 배열을 방출(yield*)하고, 재귀적으로 다음 배열들을 방출합니다. 이 때, 다음 요소가 없으면 그대로 제너레이터를 종료합니다.

퍼블릭 인터페이스 구현

class Interleaver<T> {
  // (...생략)
  
  public interleave(options?: { allowDuplicates: boolean }): T[] {
    return pipe(
      this.interleaveRecursive(),
      Array.from<T>,
      options?.allowDuplicates ? identity : (arr) => Array.from(new Set(arr))
    );
  }
}
  • 최종적으로 위에서 재귀적으로 각 인터리빙된 배열을 방출하는 함수를 호출하는 퍼블릭 인터페이스 메서드를 구현합니다.
  • interleaveRecursive() 가 방출하는 값을 array 형태로 변경합니다.
  • 이 때, 중복 허용 옵션에 따라 중복 허용 옵션이 true 인 경우 Set 을 이용하여 중복 제거한 배열을 반환 합니다.

전체 구현 컨테이너

import { identity, pipe } from "fp-ts/function";
import { flatten, mapWithIndex, filterMap } from "fp-ts/Array";
import { none, some, type None, type Some } from "fp-ts/Option";
import { takeFrom } from "../functional";

export class Interleaver<T> {
  /**
   * 각 배열의 현재 위치를 저장합니다.
   */
  private positions: number[] = [];

  private constructor(
    private readonly pattern: number[],
    private readonly arrays: T[][]
  ) {
    this.validateInitialization();
    this.initializePositions();
  }

  /**
   * 패턴과 배열의 유효성을 검사합니다.
   */
  private validateInitialization = () => {
    if (this.pattern.length !== this.arrays.length) {
      throw new Error("패턴과 배열의 길이가 일치하지 않습니다.");
    }

    if (this.pattern.some((p) => p < 1)) {
      throw new Error("패턴은 1 이상의 값을 가져야 합니다.");
    }
  };

  /**
   * 각 배열의 현재 위치를 0(첫 번째 요소)으로 초기화합니다.
   */
  private initializePositions = () =>
    (this.positions = this.arrays.map(() => 0));

  /**
   * 팩토리 메서드
   */
  public static of<T>(pattern: number[], arrays: T[][]): Interleaver<T> {
    return new Interleaver(pattern, arrays);
  }

  /**
   * 주어진 패턴에 따라 배열을 교차하여 하나의 배열로 만듭니다.
   */
  public interleave(options?: { allowDuplicates: boolean }): T[] {
    return pipe(
      this.interleaveRecursive(),
      Array.from<T>,
      options?.allowDuplicates ? identity : (arr) => Array.from(new Set(arr))
    );
  }

  /**
   * 재귀적으로 인터리빙된 배열을 방출하는 제너레이터입니다.
   * 각 반복마다 현재 위치에 따라 교차된 요소를 방출하고, 위치를 업데이트합니다.
   */
  private *interleaveRecursive(): Generator<T, void, undefined> {
    const nextElements = this.getNextElements();
    // 다음 요소가 없으면 종료
    if (nextElements.length === 0) {
      return;
    }

    // 이번 반복에서 만든 요소를 방출
    yield* nextElements;

    // 다음 위치로 업데이트
    this.updatePositions();

    // 재귀적으로 다음 요소들을 방출
    yield* this.interleaveRecursive();
  }

  /**
   * 현재 위치에 따라 다음 요소들을 가져옵니다.
   */
  private getNextElements = () =>
    pipe(
      this.arrays,
      mapWithIndex(this.getNextElement),
      filterMap(identity),
      flatten
    );

  /**
   * 현재 위치에 따라 idx번째 배열에서 다음 요소들을 해당 배열의 pattern 크기만큼 가져옵니다.
   */
  private getNextElement = (idx: number, item: T[]): None | Some<T[]> =>
    this.positions[idx] >= item.length
      ? none
      : some(takeFrom(this.positions[idx], this.pattern[idx], item));

  /**
   * 다음 위치를 패턴에 따라 업데이트합니다.
   */
  private updatePositions(): void {
    this.positions = this.positions.map((position, idx) =>
      position + this.pattern[idx] >= this.arrays[idx].length
        ? this.arrays[idx].length
        : position + this.pattern[idx]
    );
  }
}

더 나아가기

위치 포인터 책임 분리하기

  • 현재는 Interleaver 클래스가 모든 책임을 다 담당하고 있어 다소 복잡하게 보이고 있습니다.
  • 위치 포인터에 대한 책임을PositionPointer 클래스로 분리하여 관련 로직을 캡슐화 할 수 있습니다.

Interleaver 를 Iterable 로 변경하기

  • interleaveRecursive 메서드를 Iterable 인터페이스를 구현으로 대체하면 Interleaver의 인스턴스가 for...of 루프, Array.from(), 또는 다른 Iterable을 처리할 수 있는 JavaScript 내장 함수와 호환되게 변경할 수 있습니다.
  • Interleaver 인스턴스를 사용하는 클라이언트는 해당 인스턴스를 iterable 객체로도 사용할 수 있게 되어 더 자유로운 iterable 프로그래밍이 가능하게 됩니다.

추가된 PositionPointer

export class PositionPointer {
  #positions: number[] = [];

  private constructor(
    private readonly targetLengths: number[],
    private readonly windowSize: number[]
  ) {
    this.validatePattern();
    this.initializePositions();
  }

  private validatePattern = () => {
    if (this.windowSize.length !== this.targetLengths.length) {
      throw new Error("패턴과 배열의 길이가 일치하지 않습니다.");
    }
    if (this.windowSize.some((p) => p < 1)) {
      throw new Error("패턴은 1 이상의 값을 가져야 합니다.");
    }
  };

  private initializePositions = () =>
    (this.#positions = this.targetLengths.map(() => 0));

  public static of = (lengths: number[], pattern: number[]): PositionPointer =>
    new PositionPointer(lengths, pattern);

  /**
   * {index} 위치의 윈도우 시작 위치를 반환합니다.
   */
  public getLeft = (index: number) => this.#positions[index];

  /**
   * {index} 위치의 윈도우 끝 위치를 반환합니다.
   */
  public getRight = (index: number) =>
    Math.min(
      this.#positions[index] + this.windowSize[index],
      this.targetLengths[index]
    );

  /**
   * {index} 위치의 윈도우 크기를 반환합니다.
   */
  public getWindowSize = (index: number) => this.windowSize[index];

  /**
   * {index} 위치 포인터가 배열의 끝에 도달했는지 여부를 반환합니다.
   */
  public isEnd = (index: number) =>
    this.#positions[index] >= this.targetLengths[index];

  /**
   * pattern에 따라 위치 포인터를 다음 위치로 이동합니다.
   * 배열의 끝에 도달하면 마지막 위치로 설정합니다.
   */
  public next = () =>
    (this.#positions = this.#positions.map((pos, idx) =>
      this.isEnd(idx) ? this.targetLengths[idx] : (pos += this.windowSize[idx])
    ));
}

변경된 Interleaver 클래스

import { identity, pipe } from "fp-ts/function";
import { flatten, mapWithIndex, filterMap } from "fp-ts/Array";
import { none, some, type Option } from "fp-ts/Option";
import { takeFrom } from "../functional";
import { PositionPointer } from "./position";

export class Interleaver<T> {
  /**
   * 각 배열의 현재 위치를 저장합니다.
   */
  private pointer!: PositionPointer;

  private constructor(pattern: number[], private readonly arrays: T[][]) {
    this.pointer = PositionPointer.of(
      arrays.map((a) => a.length),
      pattern
    );
  }

  public static of = <T>(pattern: number[], arrays: T[][]): Interleaver<T> =>
    new Interleaver(pattern, arrays);

  /**
   * 주어진 패턴에 따라 배열을 교차하여 하나의 배열로 만듭니다.
   */
  public interleave = (options?: { allowDuplicates: boolean }): T[] =>
    pipe(
      this,
      Array.from<T>,
      options?.allowDuplicates ? identity : (arr) => Array.from(new Set(arr))
    );

  /**
   * `Iterable` 인터페이스 구현.
   * 인터리빙된 요소들을 반환하는 제너레이터를 제공합니다.
   */
  public *[Symbol.iterator](): Iterator<T> {
    const nextElements = this.getNextElements();
    // 다음 요소가 없으면 종료
    if (nextElements.length === 0) {
      return;
    }

    // 이번 반복에서 만든 요소를 방출
    yield* nextElements;

    // 모든 배열의 방출이 끝나면 다음 위치로 이동
    this.pointer.next();

    // 재귀적으로 다음 요소를 찾습니다.
    yield* this;
  }

  /**
   * 현재 위치에 따라 다음 요소들을 배열로 수집합니다.
   */
  private getNextElements = () =>
    pipe(
      this.arrays,
      mapWithIndex(this.getNextElement),
      filterMap(identity),
      flatten
    );

  /**
   * 현재 위치에 따라 idx번째 배열에서 다음 요소들을 해당 배열의 pattern 크기만큼 가져옵니다.
   */
  private getNextElement = (idx: number, item: T[]): Option<T[]> =>
    this.pointer.isEnd(idx)
      ? none
      : some(
          takeFrom(
            this.pointer.getLeft(idx),
            this.pointer.getWindowSize(idx),
            item
          )
        );
}

결론

이번 포스팅에서는 주어진 패턴에 따라 여러 배열을 인터리빙하는 작업을 절차지향 방식에서 멀티패러다임 방식으로 전환하는 과정을 다루었습니다. 특히, 타입스크립트의 함수형 프로그래밍 라이브러리인 fp-ts를 활용하여 보다 구조적이고 재사용 가능한 코드를 작성하는 방법을 살펴보았습니다.

개선된 점

가독성 향상

  • 절차지향 방식의 긴 함수와 복잡한 논리 구조를 개선하여 코드의 가독성을 높였습니다. 각 기능을 분리하여 코드가 더 명확해졌습니다.

유지보수성 향상

  • 상태 관리와 주요 로직을 캡슐화하여 함수 내부에서 직접 관리하지 않고, 별도의 메서드와 제너레이터를 통해 처리함으로써 유지보수성이 향상되었습니다.

재사용성 향상

  • 컨테이너 클래스를 사용하여 인터리빙 로직을 캡슐화하고, takeFrom과 같은 유틸리티 함수를 별도로 정의하여 다양한 상황에서 재사용할 수 있도록 하였습니다.

이번 포스팅에서 다룬 멀티패러다임 접근 방식은 복잡한 로직을 구조적으로 개선하는 데 큰 도움이 됩니다. 함수형 프로그래밍의 장점을 활용하여 내부 로직을 더 명확하게 작성할 수 있음을 보여줍니다. 이러한 접근 방식은 복잡한 상태 관리와 반복적인 작업이 필요한 다른 프로젝트에서도 유용하게 적용할 수 있을 것 같습니다.

0개의 댓글

관련 채용 정보