최근 프로젝트에서 각 조건에 따라 그룹핑 된 N개의 리스트를 주어진 개수 패턴에 따라 인터리빙해달라는 요구사항이 있었습니다. 이번 포스팅에서는 해당 요구사항을 인동댕님 유튜브 에서 영감을 받아 멀티패러다임 방식으로 구현하는 과정을 이야기하고자 합니다
해당 포스트에서 말하는
인터리빙
은 여러 배열에서 요소를지정된 개수 패턴
에 따라 하나의 배열로 합치는 과정을 말합니다. 예를 들어, 패턴 [2, 2, 2, 1]은 각 그룹핑 된 배열에서 2개, 2개, 2개, 1개의 요소를 순서대로 반복적으로 가져와서 합치는 작업을 수행하라는 의미 입니다.
해당 포스트의 전체 코드 예제는 여기 에서 확인하실 수 있습니다. 해당 예제 프로젝트는
Bun
런타임에서 실행할 수 있도록 작성되었으나nodejs
환경에서도 문제없이 동작합니다.
먼저, 인터리빙 작업의 요구사항을 정리해보겠습니다
예시)
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]
// 이런 메서드 시그니처가 나와야 할 것 같습니다.
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
를 사용합니다.
매 반복마다 각 그룹핑된 배열에서 현재 위치에서 해당되는 패턴의 크기만큼의 요소를 추출해야하기 때문에 해당 작업을 수행하는 함수를 먼저 생성합니다
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 크기만큼 가져옵니다. 이 때, 각 그룹 당 포인터의 위치가 그룹의 크기 이상이면 Optional
의 none
을 반환하고, 만들어진 배열에서 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
클래스로 분리하여 관련 로직을 캡슐화 할 수 있습니다.interleaveRecursive
메서드를 Iterable
인터페이스를 구현으로 대체하면 Interleaver
의 인스턴스가 for...of
루프, Array.from()
, 또는 다른 Iterable을 처리할 수 있는 JavaScript 내장 함수와 호환되게 변경할 수 있습니다. iterable
객체로도 사용할 수 있게 되어 더 자유로운 iterable 프로그래밍이 가능하게 됩니다.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를 활용하여 보다 구조적이고 재사용 가능한 코드를 작성하는 방법을 살펴보았습니다.
이번 포스팅에서 다룬 멀티패러다임
접근 방식은 복잡한 로직을 구조적으로 개선하는 데 큰 도움이 됩니다. 함수형 프로그래밍의 장점을 활용하여 내부 로직을 더 명확하게 작성할 수 있음을 보여줍니다. 이러한 접근 방식은 복잡한 상태 관리와 반복적인 작업이 필요한 다른 프로젝트에서도 유용하게 적용할 수 있을 것 같습니다.