
최근 프로젝트에서 각 조건에 따라 그룹핑 된 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를 활용하여 보다 구조적이고 재사용 가능한 코드를 작성하는 방법을 살펴보았습니다.
이번 포스팅에서 다룬 멀티패러다임 접근 방식은 복잡한 로직을 구조적으로 개선하는 데 큰 도움이 됩니다. 함수형 프로그래밍의 장점을 활용하여 내부 로직을 더 명확하게 작성할 수 있음을 보여줍니다. 이러한 접근 방식은 복잡한 상태 관리와 반복적인 작업이 필요한 다른 프로젝트에서도 유용하게 적용할 수 있을 것 같습니다.